diff --git a/src/Core/Application/DTOs/TrendDtos.cs b/src/Core/Application/DTOs/TrendDtos.cs index 577f8af..96b653a 100644 --- a/src/Core/Application/DTOs/TrendDtos.cs +++ b/src/Core/Application/DTOs/TrendDtos.cs @@ -54,3 +54,22 @@ public class TrendLivePointDto [JsonPropertyName("value")] public double? Value { get; set; } [JsonPropertyName("ts")] public DateTime Ts { get; set; } } + +/// 알람 한계선 (HI/LO/SP) +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; } +} + +/// 운전상태 밴드 (RUN/TRIP 구간) +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; } = ""; +} diff --git a/src/Core/Application/Interfaces/ITrendService.cs b/src/Core/Application/Interfaces/ITrendService.cs index 64a9530..fcdb47a 100644 --- a/src/Core/Application/Interfaces/ITrendService.cs +++ b/src/Core/Application/Interfaces/ITrendService.cs @@ -12,4 +12,6 @@ public interface ITrendService Task UpdateGroupAsync(int id, TrendGroupUpsertDto dto, CancellationToken ct); Task DeleteGroupAsync(int id, CancellationToken ct); Task> GetLiveAsync(IReadOnlyList tags, CancellationToken ct); + Task> GetLimitsAsync(IReadOnlyList tags, CancellationToken ct); + Task> GetRunBandsAsync(DateTime from, DateTime to, string? area, CancellationToken ct); } diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 3716f81..b868faa 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -1581,14 +1581,14 @@ public class ExperionDbService : IExperionDbService { var fromParam = cmd.CreateParameter(); fromParam.ParameterName = "fromTime"; - fromParam.Value = request.From.Value; + fromParam.Value = request.From.Value.ToUniversalTime(); // 타임존 포맷 무관 UTC 정규화('+00:00'/Local 입력 9h 시프트 방지) cmd.Parameters.Add(fromParam); } if (request.To.HasValue) { var toParam = cmd.CreateParameter(); toParam.ParameterName = "toTime"; - toParam.Value = request.To.Value; + toParam.Value = request.To.Value.ToUniversalTime(); // 타임존 포맷 무관 UTC 정규화 cmd.Parameters.Add(toParam); } diff --git a/src/Infrastructure/Trend/TrendService.cs b/src/Infrastructure/Trend/TrendService.cs index 72c2229..06758ef 100644 --- a/src/Infrastructure/Trend/TrendService.cs +++ b/src/Infrastructure/Trend/TrendService.cs @@ -160,6 +160,82 @@ public class TrendService : ITrendService return list; } + public async Task> GetLimitsAsync(IReadOnlyList 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(); + + 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> 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(); + 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) { var membersJson = r.IsDBNull(3) ? "[]" : r.GetFieldValue(3); diff --git a/src/Web/Controllers/TrendController.cs b/src/Web/Controllers/TrendController.cs index 517ad5f..e195e4f 100644 --- a/src/Web/Controllers/TrendController.cs +++ b/src/Web/Controllers/TrendController.cs @@ -70,4 +70,21 @@ public class TrendController : ControllerBase 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 }); } } + + /// 알람 한계선 (HI/LO/SP 수평선용) + [HttpGet("limits")] + public async Task 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 }); } + } + + /// 운전상태 밴드 (RUN/TRIP 구간) + [HttpGet("runbands")] + public async Task 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 }); } + } } diff --git a/src/Web/wwwroot/js/trend.js b/src/Web/wwwroot/js/trend.js index 423fccb..b16e5a0 100644 --- a/src/Web/wwwroot/js/trend.js +++ b/src/Web/wwwroot/js/trend.js @@ -27,14 +27,18 @@ const trState = { yZoom: null, // { min, max } from Y zoom, null = auto normalize: false, // true=전체 태그를 EU 레인지 대비 %(0~100)로 환산 liveNow: {}, // { tag: 현재값 } — 라이브 중 실시간 갱신 - layers: { minmax:false, events:false }, - cache: { events:[] } + layers: { minmax:false, events:false, limits:false, runstate:false }, + cache: { events:[], limits:{}, runbands:[] }, + cursor: { a:null, b:null }, + cursorMode: false }; // ── 레이어 레지스트리 — P2/P3는 여기 1줄 + 함수만 추가 ──────────── const TR_LAYERS = [ { id:'minmax', perSeries: layerMinMax, when: s => s.layers.minmax }, { 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; trRender(); }); + trChart.getZr().on('click', trOnCursorClick); 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 }, itemStyle: { color: m.color }, emphasis: { focus: 'series', lineStyle: { width: 4 } }, + sampling: 'lttb', data: m.hidden ? [] : trPlotData(m), markPoint: { silent: true, data: [] } }; @@ -157,6 +163,59 @@ function layerEvents(s) { // 트립/알람 세로 플 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) { return { ...a, ...b, 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() { trStopLive(); if (!trChart) trInitChart(); @@ -254,19 +321,46 @@ async function trQuery() { const from = document.getElementById('hf-trfrom').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'; - try { + // 원시 조회 (집계 실패/0행 시 폴백으로도 사용) + const rawQuery = async () => { const p = new URLSearchParams(); tags.forEach(t => p.append('tagNames', t)); if (from) p.set('from', new Date(from).toISOString()); if (to) p.set('to', new Date(to).toISOString()); p.set('limit', '5000'); 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) { - console.error('trQuery:', e); setGlobal('err', '트렌드 조회 실패'); - alert('조회 실패: ' + e.message); return; + console.error('trQuery:', e); + try { const r = await rawQuery(); rows = r.rows; tk = r.tk; } // 집계 예외도 원시 폴백 + catch (e2) { setGlobal('err', '트렌드 조회 실패'); alert('조회 실패: ' + e2.message); return; } } trState.seriesData = {}; @@ -283,6 +377,8 @@ async function trQuery() { trState.seriesData[m.tag] = trBreakGaps(trState.seriesData[m.tag]); if (trState.layers.events) await trLoadEvents(); + if (trState.layers.limits) await trLoadLimits(); + if (trState.layers.runstate) await trLoadRunbands(); trState.yZoom = null; trChart.setOption({ dataZoom: [ { start: 0, end: 100, startValue: null, endValue: null }, @@ -437,12 +533,131 @@ function trResetYZoom() { 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) { trState.layers[id] = !trState.layers[id]; const btn = document.querySelector(`.tr-tog[data-layer="${id}"]`); if (btn) btn.classList.toggle('on', trState.layers[id]); 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(); } diff --git a/src/Web/wwwroot/panes/trend.html b/src/Web/wwwroot/panes/trend.html index 1c1fb0a..6bba804 100644 --- a/src/Web/wwwroot/panes/trend.html +++ b/src/Web/wwwroot/panes/trend.html @@ -36,6 +36,9 @@ + + + Y축 zoom ↩