diff --git a/src/Web/wwwroot/js/trend.js b/src/Web/wwwroot/js/trend.js index b16e5a0..b7dc3d3 100644 --- a/src/Web/wwwroot/js/trend.js +++ b/src/Web/wwwroot/js/trend.js @@ -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) {