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:
windpacer
2026-05-25 18:56:40 +09:00
parent 52ed77efac
commit 647c7c090f
7 changed files with 341 additions and 9 deletions

View File

@@ -54,3 +54,22 @@ public class TrendLivePointDto
[JsonPropertyName("value")] public double? Value { 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; } = "";
}

View File

@@ -12,4 +12,6 @@ public interface ITrendService
Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto dto, CancellationToken ct);
Task<bool> DeleteGroupAsync(int id, 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);
}

View File

@@ -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);
}

View File

@@ -160,6 +160,82 @@ public class TrendService : ITrendService
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)
{
var membersJson = r.IsDBNull(3) ? "[]" : r.GetFieldValue<string>(3);

View File

@@ -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 }); }
}
/// <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 }); }
}
}

View File

@@ -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();
}

View File

@@ -36,6 +36,9 @@
<span class="tr-layers">
<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="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>
<span class="tr-yzoom-indicator" id="tr-yzoom-indicator" onclick="trResetYZoom()" title="Y축 원위치">Y축 zoom ↩</span>
</span>