feat: 트렌드 P2 — 알람선·운전음영·듀얼커서·자동집계(LTTB) + 집계 버그수정
- 알람선(markLine HI/LO/SP; v_instrument_range euhi/eulo + .sp) — GET /api/trend/limits - 운전음영(markArea RUN/TRIP; event_history LEAD 구간화) — GET /api/trend/runbands - 듀얼커서 Δ(zr 클릭→Δt·Δy·기울기), 자동집계 경로 + line sampling:'lttb' - fix: trQuery 집계 호출이 query-string으로 가 body의 tagNames 누락→빈 차트. JSON body로 전송 + 집계 0행/예외 시 raw(/api/history/query) 폴백 - fix: QueryHistoryWithIntervalAsync from/to UTC 정규화(ToUniversalTime) — '+00:00'/Local-kind 입력이 9h 시프트되던 잠재버그 방지(프론트 'Z' 경로 영향 0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -54,3 +54,22 @@ public class TrendLivePointDto
|
|||||||
[JsonPropertyName("value")] public double? Value { get; set; }
|
[JsonPropertyName("value")] public double? Value { get; set; }
|
||||||
[JsonPropertyName("ts")] public DateTime Ts { get; set; }
|
[JsonPropertyName("ts")] public DateTime Ts { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>알람 한계선 (HI/LO/SP)</summary>
|
||||||
|
public class TrendLimitDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||||
|
[JsonPropertyName("hi")] public double? Hi { get; set; }
|
||||||
|
[JsonPropertyName("lo")] public double? Lo { get; set; }
|
||||||
|
[JsonPropertyName("sp")] public double? Sp { get; set; }
|
||||||
|
[JsonPropertyName("unit")] public string? Unit { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>운전상태 밴드 (RUN/TRIP 구간)</summary>
|
||||||
|
public class TrendBandDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||||
|
[JsonPropertyName("t0")] public DateTime T0 { get; set; }
|
||||||
|
[JsonPropertyName("t1")] public DateTime T1 { get; set; }
|
||||||
|
[JsonPropertyName("state")] public string State { get; set; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ public interface ITrendService
|
|||||||
Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto dto, CancellationToken ct);
|
Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto dto, CancellationToken ct);
|
||||||
Task<bool> DeleteGroupAsync(int id, CancellationToken ct);
|
Task<bool> DeleteGroupAsync(int id, CancellationToken ct);
|
||||||
Task<List<TrendLivePointDto>> GetLiveAsync(IReadOnlyList<string> tags, CancellationToken ct);
|
Task<List<TrendLivePointDto>> GetLiveAsync(IReadOnlyList<string> tags, CancellationToken ct);
|
||||||
|
Task<List<TrendLimitDto>> GetLimitsAsync(IReadOnlyList<string> tags, CancellationToken ct);
|
||||||
|
Task<List<TrendBandDto>> GetRunBandsAsync(DateTime from, DateTime to, string? area, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1581,14 +1581,14 @@ public class ExperionDbService : IExperionDbService
|
|||||||
{
|
{
|
||||||
var fromParam = cmd.CreateParameter();
|
var fromParam = cmd.CreateParameter();
|
||||||
fromParam.ParameterName = "fromTime";
|
fromParam.ParameterName = "fromTime";
|
||||||
fromParam.Value = request.From.Value;
|
fromParam.Value = request.From.Value.ToUniversalTime(); // 타임존 포맷 무관 UTC 정규화('+00:00'/Local 입력 9h 시프트 방지)
|
||||||
cmd.Parameters.Add(fromParam);
|
cmd.Parameters.Add(fromParam);
|
||||||
}
|
}
|
||||||
if (request.To.HasValue)
|
if (request.To.HasValue)
|
||||||
{
|
{
|
||||||
var toParam = cmd.CreateParameter();
|
var toParam = cmd.CreateParameter();
|
||||||
toParam.ParameterName = "toTime";
|
toParam.ParameterName = "toTime";
|
||||||
toParam.Value = request.To.Value;
|
toParam.Value = request.To.Value.ToUniversalTime(); // 타임존 포맷 무관 UTC 정규화
|
||||||
cmd.Parameters.Add(toParam);
|
cmd.Parameters.Add(toParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,82 @@ public class TrendService : ITrendService
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<TrendLimitDto>> GetLimitsAsync(IReadOnlyList<string> tags, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (tags.Count == 0) return new();
|
||||||
|
var bases = tags.Select(t => t.Split('.')[0]).Distinct().ToList();
|
||||||
|
var baseArr = bases.ToArray();
|
||||||
|
var result = new Dictionary<string, TrendLimitDto>();
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
SELECT ir.base_tag, ir.unit, ir.eu_lo, ir.eu_hi,
|
||||||
|
rt.tagname AS sp_tag, rt.livevalue AS sp_val
|
||||||
|
FROM v_instrument_range ir
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT tagname, livevalue FROM realtime_table
|
||||||
|
WHERE tagname = (ir.base_tag || '.sp')
|
||||||
|
AND livevalue ~ '^-?[0-9.]+$'
|
||||||
|
LIMIT 1
|
||||||
|
) rt ON true
|
||||||
|
WHERE ir.base_tag = ANY(@bases)
|
||||||
|
""";
|
||||||
|
await using var conn = NewConn();
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||||
|
cmd.Parameters.AddWithValue("bases", baseArr);
|
||||||
|
|
||||||
|
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await r.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
var bt = r.GetString(0);
|
||||||
|
var unit = r.IsDBNull(1) ? null : r.GetString(1);
|
||||||
|
var euLo = r.IsDBNull(2) ? (double?)null : r.GetDouble(2);
|
||||||
|
var euHi = r.IsDBNull(3) ? (double?)null : r.GetDouble(3);
|
||||||
|
var spVal = r.IsDBNull(5) ? (double?)null : double.TryParse(r.GetString(5), out var sp) ? sp : null;
|
||||||
|
|
||||||
|
foreach (var tag in tags.Where(t => t.Split('.')[0] == bt))
|
||||||
|
{
|
||||||
|
if (!result.ContainsKey(tag))
|
||||||
|
{
|
||||||
|
result[tag] = new TrendLimitDto { Tag = tag, Unit = unit, Lo = euLo, Hi = euHi, Sp = spVal };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Values.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TrendBandDto>> GetRunBandsAsync(DateTime from, DateTime to, string? area, CancellationToken ct)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT tagname, event_time, event_type,
|
||||||
|
LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time) AS next_time
|
||||||
|
FROM event_history_table
|
||||||
|
WHERE event_time >= @from AND event_time <= @to
|
||||||
|
AND event_type IN ('RUN', 'TRIP')
|
||||||
|
AND (@area IS NULL OR area ILIKE '%'||@area||'%' OR sub_area ILIKE '%'||@area||'%')
|
||||||
|
ORDER BY tagname, event_time
|
||||||
|
""";
|
||||||
|
await using var conn = NewConn();
|
||||||
|
await conn.OpenAsync(ct);
|
||||||
|
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||||
|
cmd.Parameters.AddWithValue("from", from);
|
||||||
|
cmd.Parameters.AddWithValue("to", to);
|
||||||
|
cmd.Parameters.AddWithValue("area", (object?)area ?? DBNull.Value);
|
||||||
|
|
||||||
|
var bands = new List<TrendBandDto>();
|
||||||
|
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await r.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
var tag = r.GetString(0);
|
||||||
|
var t0 = r.GetDateTime(1);
|
||||||
|
var type = r.GetString(2);
|
||||||
|
var t1 = r.IsDBNull(3) ? to : r.GetDateTime(3);
|
||||||
|
if (t1 > to) t1 = to;
|
||||||
|
bands.Add(new TrendBandDto { Tag = tag, T0 = t0, T1 = t1, State = type });
|
||||||
|
}
|
||||||
|
return bands;
|
||||||
|
}
|
||||||
|
|
||||||
private static TrendGroupDto ReadGroup(NpgsqlDataReader r)
|
private static TrendGroupDto ReadGroup(NpgsqlDataReader r)
|
||||||
{
|
{
|
||||||
var membersJson = r.IsDBNull(3) ? "[]" : r.GetFieldValue<string>(3);
|
var membersJson = r.IsDBNull(3) ? "[]" : r.GetFieldValue<string>(3);
|
||||||
|
|||||||
@@ -70,4 +70,21 @@ public class TrendController : ControllerBase
|
|||||||
try { return Ok(new { items = await _svc.GetLiveAsync(list, ct) }); }
|
try { return Ok(new { items = await _svc.GetLiveAsync(list, ct) }); }
|
||||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { error = ex.Message }); }
|
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>알람 한계선 (HI/LO/SP 수평선용)</summary>
|
||||||
|
[HttpGet("limits")]
|
||||||
|
public async Task<IActionResult> Limits([FromQuery] string? tags, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = (tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
try { return Ok(new { items = await _svc.GetLimitsAsync(list, ct) }); }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "[Trend] limits 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>운전상태 밴드 (RUN/TRIP 구간)</summary>
|
||||||
|
[HttpGet("runbands")]
|
||||||
|
public async Task<IActionResult> RunBands([FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] string? area, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try { return Ok(new { items = await _svc.GetRunBandsAsync(from, to, area, ct) }); }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "[Trend] runbands 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,18 @@ const trState = {
|
|||||||
yZoom: null, // { min, max } from Y zoom, null = auto
|
yZoom: null, // { min, max } from Y zoom, null = auto
|
||||||
normalize: false, // true=전체 태그를 EU 레인지 대비 %(0~100)로 환산
|
normalize: false, // true=전체 태그를 EU 레인지 대비 %(0~100)로 환산
|
||||||
liveNow: {}, // { tag: 현재값 } — 라이브 중 실시간 갱신
|
liveNow: {}, // { tag: 현재값 } — 라이브 중 실시간 갱신
|
||||||
layers: { minmax:false, events:false },
|
layers: { minmax:false, events:false, limits:false, runstate:false },
|
||||||
cache: { events:[] }
|
cache: { events:[], limits:{}, runbands:[] },
|
||||||
|
cursor: { a:null, b:null },
|
||||||
|
cursorMode: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 레이어 레지스트리 — P2/P3는 여기 1줄 + 함수만 추가 ────────────
|
// ── 레이어 레지스트리 — P2/P3는 여기 1줄 + 함수만 추가 ────────────
|
||||||
const TR_LAYERS = [
|
const TR_LAYERS = [
|
||||||
{ id:'minmax', perSeries: layerMinMax, when: s => s.layers.minmax },
|
{ id:'minmax', perSeries: layerMinMax, when: s => s.layers.minmax },
|
||||||
{ id:'events', global: layerEvents, when: s => s.layers.events },
|
{ 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── 차트 초기화 ─────────────────────────────────────────────── */
|
/* ── 차트 초기화 ─────────────────────────────────────────────── */
|
||||||
@@ -89,6 +93,7 @@ function trInitChart() {
|
|||||||
trState.yZoom = null;
|
trState.yZoom = null;
|
||||||
trRender();
|
trRender();
|
||||||
});
|
});
|
||||||
|
trChart.getZr().on('click', trOnCursorClick);
|
||||||
window.addEventListener('resize', () => { if (trChart) trChart.resize(); });
|
window.addEventListener('resize', () => { if (trChart) trChart.resize(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +105,7 @@ function layerBaseSeries(m, s) {
|
|||||||
lineStyle: { color: m.color, width: m.tag === s.selected ? 4 : 1.6, opacity: m.hidden ? 0 : 1 },
|
lineStyle: { color: m.color, width: m.tag === s.selected ? 4 : 1.6, opacity: m.hidden ? 0 : 1 },
|
||||||
itemStyle: { color: m.color },
|
itemStyle: { color: m.color },
|
||||||
emphasis: { focus: 'series', lineStyle: { width: 4 } },
|
emphasis: { focus: 'series', lineStyle: { width: 4 } },
|
||||||
|
sampling: 'lttb',
|
||||||
data: m.hidden ? [] : trPlotData(m),
|
data: m.hidden ? [] : trPlotData(m),
|
||||||
markPoint: { silent: true, data: [] }
|
markPoint: { silent: true, data: [] }
|
||||||
};
|
};
|
||||||
@@ -157,6 +163,59 @@ function layerEvents(s) { // 트립/알람 세로 플
|
|||||||
markLine: { symbol: 'none', silent: true, data } };
|
markLine: { symbol: 'none', silent: true, data } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function trLoadLimits() {
|
||||||
|
const tags = trState.members.map(m => m.tag);
|
||||||
|
if (!tags.length) return;
|
||||||
|
try {
|
||||||
|
const d = await api('GET', `/api/trend/limits?tags=${encodeURIComponent(tags.join(','))}`);
|
||||||
|
trState.cache.limits = {};
|
||||||
|
for (const l of (d.items || [])) trState.cache.limits[l.tag] = l;
|
||||||
|
} catch (e) { console.error('trLoadLimits:', e); trState.cache.limits = {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trLoadRunbands() {
|
||||||
|
const from = document.getElementById('hf-trfrom').value;
|
||||||
|
const to = document.getElementById('hf-trto').value;
|
||||||
|
if (!from || !to) return;
|
||||||
|
try {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('from', new Date(from).toISOString());
|
||||||
|
p.set('to', new Date(to).toISOString());
|
||||||
|
const d = await api('GET', `/api/trend/runbands?${p}`);
|
||||||
|
trState.cache.runbands = (d.items || []).map(b => ({
|
||||||
|
tag: b.tag, t0: new Date(b.t0).getTime(), t1: new Date(b.t1).getTime(), state: b.state
|
||||||
|
}));
|
||||||
|
} catch (e) { console.error('trLoadRunbands:', e); trState.cache.runbands = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerLimits(m, s) {
|
||||||
|
const lim = s.cache.limits[m.tag];
|
||||||
|
if (!lim) return {};
|
||||||
|
let toY = v => v;
|
||||||
|
if (s.normalize) { const sp = trMemberSpan(m); if (sp) toY = v => (v - sp[0]) / sp[1] * 100; }
|
||||||
|
const mk = (v, txt, style) => v == null ? null : {
|
||||||
|
yAxis: toY(v), lineStyle: style,
|
||||||
|
label: { formatter: txt, position: 'insideEndTop', fontSize: 9 }
|
||||||
|
};
|
||||||
|
const data = [
|
||||||
|
mk(lim.hi, 'HI', { color: '#ef4444', type: 'dashed' }),
|
||||||
|
mk(lim.lo, 'LO', { color: '#ef4444', type: 'dashed' }),
|
||||||
|
mk(lim.sp, 'SP', { color: m.color, type: 'dotted' })
|
||||||
|
].filter(Boolean);
|
||||||
|
return { markLine: { silent: true, symbol: 'none', data } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerRunState(s) {
|
||||||
|
if (!s.cache.runbands.length) return null;
|
||||||
|
const COL = { RUN: 'rgba(16,185,129,.10)', TRIP: 'rgba(239,68,68,.12)' };
|
||||||
|
const data = s.cache.runbands.map(b => [
|
||||||
|
{ xAxis: b.t0, itemStyle: { color: COL[b.state] || 'rgba(148,163,184,.1)' } },
|
||||||
|
{ xAxis: b.t1 }
|
||||||
|
]);
|
||||||
|
return { name: '__runstate', type: 'line', data: [], silent: true, showSymbol: false,
|
||||||
|
markArea: { silent: true, data } };
|
||||||
|
}
|
||||||
|
|
||||||
function trMerge(a, b) {
|
function trMerge(a, b) {
|
||||||
return { ...a, ...b,
|
return { ...a, ...b,
|
||||||
lineStyle: { ...(a.lineStyle || {}), ...(b.lineStyle || {}) },
|
lineStyle: { ...(a.lineStyle || {}), ...(b.lineStyle || {}) },
|
||||||
@@ -246,6 +305,14 @@ function trBreakGaps(arr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── 데이터 조회 ─────────────────────────────────────────────── */
|
/* ── 데이터 조회 ─────────────────────────────────────────────── */
|
||||||
|
function trAutoInterval(from, to) {
|
||||||
|
const h = (to - from) / 3600000;
|
||||||
|
if (h <= 6) return '1 minute';
|
||||||
|
if (h <= 48) return '5 minutes';
|
||||||
|
if (h <= 14 * 24) return '1 hour';
|
||||||
|
return '1 day';
|
||||||
|
}
|
||||||
|
|
||||||
async function trQuery() {
|
async function trQuery() {
|
||||||
trStopLive();
|
trStopLive();
|
||||||
if (!trChart) trInitChart();
|
if (!trChart) trInitChart();
|
||||||
@@ -254,19 +321,46 @@ async function trQuery() {
|
|||||||
|
|
||||||
const from = document.getElementById('hf-trfrom').value;
|
const from = document.getElementById('hf-trfrom').value;
|
||||||
const to = document.getElementById('hf-trto').value;
|
const to = document.getElementById('hf-trto').value;
|
||||||
|
const intervalSel = document.getElementById('tr-interval');
|
||||||
|
let interval = intervalSel ? intervalSel.value : '1 minute';
|
||||||
|
if (interval === 'auto') {
|
||||||
|
const fromD = from ? new Date(from) : new Date();
|
||||||
|
const toD = to ? new Date(to) : new Date();
|
||||||
|
interval = trAutoInterval(fromD.getTime(), toD.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
let rows = [], tk = 'recordedAt';
|
// 원시 조회 (집계 실패/0행 시 폴백으로도 사용)
|
||||||
try {
|
const rawQuery = async () => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
tags.forEach(t => p.append('tagNames', t));
|
tags.forEach(t => p.append('tagNames', t));
|
||||||
if (from) p.set('from', new Date(from).toISOString());
|
if (from) p.set('from', new Date(from).toISOString());
|
||||||
if (to) p.set('to', new Date(to).toISOString());
|
if (to) p.set('to', new Date(to).toISOString());
|
||||||
p.set('limit', '5000');
|
p.set('limit', '5000');
|
||||||
const d = await api('GET', `/api/history/query?${p}`);
|
const d = await api('GET', `/api/history/query?${p}`);
|
||||||
rows = d.rows || []; tk = 'recordedAt';
|
return { rows: d.rows || [], tk: 'recordedAt' };
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = [], tk = 'recordedAt';
|
||||||
|
try {
|
||||||
|
if (interval !== '1 minute') {
|
||||||
|
// 집계 경로 — 엔드포인트가 [FromBody]라 반드시 JSON body로 전송
|
||||||
|
const d = await api('POST', '/api/text-to-sql/query-history-interval', {
|
||||||
|
tagNames: tags,
|
||||||
|
from: from ? new Date(from).toISOString() : null,
|
||||||
|
to: to ? new Date(to).toISOString() : null,
|
||||||
|
interval, limit: 5000
|
||||||
|
});
|
||||||
|
rows = (d && d.success !== false) ? (d.rows || []) : [];
|
||||||
|
tk = rows[0]?.timeBucket != null ? 'timeBucket' : 'recordedAt';
|
||||||
|
// 집계가 0행이면(엔드포인트 이슈 등) 원시로 폴백 → 차트 공백 방지
|
||||||
|
if (!rows.length) { const r = await rawQuery(); rows = r.rows; tk = r.tk; }
|
||||||
|
} else {
|
||||||
|
const r = await rawQuery(); rows = r.rows; tk = r.tk;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('trQuery:', e); setGlobal('err', '트렌드 조회 실패');
|
console.error('trQuery:', e);
|
||||||
alert('조회 실패: ' + e.message); return;
|
try { const r = await rawQuery(); rows = r.rows; tk = r.tk; } // 집계 예외도 원시 폴백
|
||||||
|
catch (e2) { setGlobal('err', '트렌드 조회 실패'); alert('조회 실패: ' + e2.message); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
trState.seriesData = {};
|
trState.seriesData = {};
|
||||||
@@ -283,6 +377,8 @@ async function trQuery() {
|
|||||||
trState.seriesData[m.tag] = trBreakGaps(trState.seriesData[m.tag]);
|
trState.seriesData[m.tag] = trBreakGaps(trState.seriesData[m.tag]);
|
||||||
|
|
||||||
if (trState.layers.events) await trLoadEvents();
|
if (trState.layers.events) await trLoadEvents();
|
||||||
|
if (trState.layers.limits) await trLoadLimits();
|
||||||
|
if (trState.layers.runstate) await trLoadRunbands();
|
||||||
trState.yZoom = null;
|
trState.yZoom = null;
|
||||||
trChart.setOption({ dataZoom: [
|
trChart.setOption({ dataZoom: [
|
||||||
{ start: 0, end: 100, startValue: null, endValue: null },
|
{ start: 0, end: 100, startValue: null, endValue: null },
|
||||||
@@ -437,12 +533,131 @@ function trResetYZoom() {
|
|||||||
trChart.setOption({ dataZoom: dz }, { replaceMerge: ['dataZoom'] });
|
trChart.setOption({ dataZoom: dz }, { replaceMerge: ['dataZoom'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 듀얼 커서 Δ ───────────────────────────────────────────── */
|
||||||
|
function trToggleCursorMode() {
|
||||||
|
trState.cursorMode = !trState.cursorMode;
|
||||||
|
const btn = document.getElementById('tr-cursor-btn');
|
||||||
|
if (btn) btn.classList.toggle('on', trState.cursorMode);
|
||||||
|
if (!trState.cursorMode) { trState.cursor = { a: null, b: null }; trRenderCursor(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function trRenderCursor() {
|
||||||
|
if (!trChart) return;
|
||||||
|
const { a, b } = trState.cursor;
|
||||||
|
let data = [];
|
||||||
|
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 }
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
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'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function trOnCursorClick(e) {
|
||||||
|
if (!trState.cursorMode || !trChart) return;
|
||||||
|
const pt = trChart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [e.offsetX, e.offsetY]);
|
||||||
|
if (!pt || !isFinite(pt[0])) return;
|
||||||
|
const ms = pt[0];
|
||||||
|
// 보이는 데이터에서 가장 가까운 값 찾기
|
||||||
|
let minDist = Infinity, bestVal = null;
|
||||||
|
for (const tag in trState.seriesData) {
|
||||||
|
for (const [t, v] of trState.seriesData[tag]) {
|
||||||
|
if (v == null) continue;
|
||||||
|
const dist = Math.abs(t - ms);
|
||||||
|
if (dist < minDist) { minDist = dist; bestVal = v; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestVal == null) return;
|
||||||
|
if (!trState.cursor.a) {
|
||||||
|
trState.cursor.a = { ms, val: bestVal };
|
||||||
|
} else if (!trState.cursor.b) {
|
||||||
|
trState.cursor.b = { ms, val: bestVal };
|
||||||
|
} else {
|
||||||
|
trState.cursor = { a: null, b: null };
|
||||||
|
}
|
||||||
|
trRenderCursor();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 레이어 토글 ─────────────────────────────────────────────── */
|
/* ── 레이어 토글 ─────────────────────────────────────────────── */
|
||||||
async function trToggleLayer(id) {
|
async function trToggleLayer(id) {
|
||||||
trState.layers[id] = !trState.layers[id];
|
trState.layers[id] = !trState.layers[id];
|
||||||
const btn = document.querySelector(`.tr-tog[data-layer="${id}"]`);
|
const btn = document.querySelector(`.tr-tog[data-layer="${id}"]`);
|
||||||
if (btn) btn.classList.toggle('on', trState.layers[id]);
|
if (btn) btn.classList.toggle('on', trState.layers[id]);
|
||||||
if (id === 'events' && trState.layers.events) await trLoadEvents();
|
if (id === 'events' && trState.layers.events) await trLoadEvents();
|
||||||
|
if (id === 'limits' && trState.layers.limits) await trLoadLimits();
|
||||||
|
if (id === 'runstate' && trState.layers.runstate) await trLoadRunbands();
|
||||||
trRender();
|
trRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<span class="tr-layers">
|
<span class="tr-layers">
|
||||||
<button class="tr-tog" data-layer="minmax" onclick="trToggleLayer('minmax')">↕ 최대/최소</button>
|
<button class="tr-tog" data-layer="minmax" onclick="trToggleLayer('minmax')">↕ 최대/최소</button>
|
||||||
<button class="tr-tog" data-layer="events" onclick="trToggleLayer('events')">⚠ 트립/이벤트</button>
|
<button class="tr-tog" data-layer="events" onclick="trToggleLayer('events')">⚠ 트립/이벤트</button>
|
||||||
|
<button class="tr-tog" data-layer="limits" onclick="trToggleLayer('limits')">⚡ 알람선</button>
|
||||||
|
<button class="tr-tog" data-layer="runstate" onclick="trToggleLayer('runstate')">▒ 운전음영</button>
|
||||||
|
<button class="tr-tog" id="tr-cursor-btn" onclick="trToggleCursorMode()">📐 Δ커서</button>
|
||||||
<button class="tr-tog" id="tr-norm-btn" onclick="trToggleNormalize()" title="전체 태그를 EU 레인지 대비 %(0~100)로 환산 — 단위/범위 다른 태그 비교">% 100%환산</button>
|
<button class="tr-tog" id="tr-norm-btn" onclick="trToggleNormalize()" title="전체 태그를 EU 레인지 대비 %(0~100)로 환산 — 단위/범위 다른 태그 비교">% 100%환산</button>
|
||||||
<span class="tr-yzoom-indicator" id="tr-yzoom-indicator" onclick="trResetYZoom()" title="Y축 원위치">Y축 zoom ↩</span>
|
<span class="tr-yzoom-indicator" id="tr-yzoom-indicator" onclick="trResetYZoom()" title="Y축 원위치">Y축 zoom ↩</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user