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 ↩