refactor: 트렌드 듀얼커서를 graphic 수동배치 → 레이어(markLine/markPoint)로 재구현

- TR_LAYERS에 cursor 레이어(global) 추가, cursorMode일 때만 활성
- 기존 graphic group 수동 픽셀배치(setOption replaceMerge) 제거 →
  layerCursor가 A/B 마커(markPoint)·연결선·Δ라벨(markLine)을 데이터좌표로 반환,
  픽셀변환은 ECharts에 위임
- trRenderCursor()는 trRender() 위임으로 단순화
- 기울기 단위 보정: /s → /min (분당 변화율)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-26 09:57:21 +09:00
parent 3e9f3076ef
commit 01ed4527d3

View File

@@ -39,6 +39,7 @@ const TR_LAYERS = [
{ id:'events', global: layerEvents, when: s => s.layers.events },
{ id:'limits', perSeries: layerLimits, when: s => s.layers.limits },
{ id:'runstate', global: layerRunState, when: s => s.layers.runstate },
{ id:'cursor', global: layerCursor, when: s => s.cursorMode },
];
/* ── 차트 초기화 ─────────────────────────────────────────────── */
@@ -541,88 +542,33 @@ function trToggleCursorMode() {
if (!trState.cursorMode) { trState.cursor = { a: null, b: null }; trRenderCursor(); }
}
function trRenderCursor() {
if (!trChart) return;
const { a, b } = trState.cursor;
let data = [];
function trRenderCursor() { trRender(); } // 커서는 레이어(layerCursor)로 그린다
// 듀얼 커서 레이어 (global) — A/B 점 + 연결선 + Δ 라벨. 데이터좌표를 ECharts가 자동 픽셀변환.
function layerCursor(s) {
const { a, b } = s.cursor;
if (!a && !b) return null;
const mp = [];
if (a) mp.push({ coord: [a.ms, a.val], symbol: 'circle', symbolSize: 10, itemStyle: { color: '#f59e0b' },
label: { show: true, formatter: 'A', position: 'top', color: '#f59e0b', fontSize: 11, fontWeight: 'bold' } });
if (b) mp.push({ coord: [b.ms, b.val], symbol: 'circle', symbolSize: 10, itemStyle: { color: '#3b82f6' },
label: { show: true, formatter: 'B', position: 'top', color: '#3b82f6', fontSize: 11, fontWeight: 'bold' } });
let ml;
if (a && b) {
const dtMs = b.ms - a.ms;
const dtS = dtMs / 1000;
const dy = b.val != null && a.val != null ? b.val - a.val : null;
const slope = dy != null && dtS !== 0 ? dy / dtS : null;
const fmtVal = v => v == null ? '—' : (+v).toFixed(3);
const fmtS = s => s == null ? '—' : s.toFixed(4);
let lines = [`Δt = ${fmtS(dtS)}s`];
if (dy != null) lines.push(`Δy = ${fmtVal(dy)}`);
if (slope != null) lines.push(`기울기 = ${fmtS(slope)}/min`);
data = [
{
xAxis: a.ms, yAxis: a.val,
symbol: 'circle', symbolSize: 8,
itemStyle: { color: '#f59e0b' },
label: { show: true, position: 'right', formatter: 'A', color: '#f59e0b', fontSize: 10 }
},
{
xAxis: b.ms, yAxis: b.val,
symbol: 'circle', symbolSize: 8,
itemStyle: { color: '#3b82f6' },
label: { show: true, position: 'right', formatter: 'B', color: '#3b82f6', fontSize: 10 }
},
{
xAxis: [a.ms, b.ms],
lineStyle: { color: '#8b5cf6', type: 'dashed', width: 1 },
symbol: 'none'
},
{
xAxis: b.ms, yAxis: a.val,
lineStyle: { color: '#8b5cf6', type: 'dotted', width: 1 },
symbol: 'none'
},
{
coord: [b.ms, a.val],
symbol: 'none',
label: {
show: true, position: 'inside',
formatter: lines.join('\n'),
fontSize: 10, color: '#c084fc',
backgroundColor: 'rgba(15,15,25,.75)',
padding: [4, 6], borderRadius: 4
}
}
];
} else if (a) {
data = [{
xAxis: a.ms, yAxis: a.val,
symbol: 'circle', symbolSize: 8,
itemStyle: { color: '#f59e0b' },
label: { show: true, position: 'right', formatter: 'A', color: '#f59e0b', fontSize: 10 }
}];
const dtS = (b.ms - a.ms) / 1000;
const dy = (a.val != null && b.val != null) ? b.val - a.val : null;
const slope = (dy != null && dtS !== 0) ? dy / (dtS / 60) : null; // 분당 변화율
const txt = [`Δt=${Math.abs(dtS).toFixed(0)}s`,
dy != null ? `Δy=${dy.toFixed(2)}` : null,
slope != null ? `기울기=${slope.toFixed(2)}/min` : null].filter(Boolean).join(' ');
ml = { symbol: 'none', silent: true, lineStyle: { color: '#8b5cf6', type: 'dashed', width: 1.5 },
data: [[{ coord: [a.ms, a.val] },
{ coord: [b.ms, b.val],
label: { show: true, formatter: txt, fontSize: 11, color: '#fff',
backgroundColor: 'rgba(124,58,237,.9)', padding: [4, 7], borderRadius: 4 } }]] };
}
trChart.setOption({
graphic: [{
type: 'group',
bounding: 'raw',
children: data.length ? data.map(d => {
if (d.coord) {
return {
type: 'text',
left: d.coord[0], top: d.coord[1],
style: { text: d.label.formatter, fontSize: d.label.fontSize, fill: d.label.color,
backgroundColor: d.label.backgroundColor, padding: d.label.padding,
borderRadius: d.label.borderRadius, boxShadow: '0 2px 8px rgba(0,0,0,.3)' }
};
}
return {
type: 'circle',
shape: { cx: d.xAxis, cy: d.yAxis, r: d.symbolSize / 2 },
style: { fill: d.itemStyle.color, stroke: '#fff', lineWidth: 1 },
...(d.label ? { label: { show: d.label.show, position: d.label.position,
formatter: d.label.formatter, color: d.label.color, fontSize: d.label.fontSize } } : {}),
silent: true
};
}) : []
}]
}, { replaceMerge: ['graphic'] });
return { name: '__cursor', type: 'line', data: [], silent: true, showSymbol: false, z: 20,
markPoint: { silent: true, data: mp }, markLine: ml };
}
function trOnCursorClick(e) {