docs: 트렌드 워크스페이스(ECharts) 구현 플랜 — P1~P3 단일차트 슬롯구조

- trState + TR_LAYERS + trRender 합성구조로 P2/P3를 같은 차트에 무중단 증분
- P1(그룹/dataZoom/범례강조/minmax/라이브/트립오버레이) ~ P3(LLM분석/스마트그룹/상관) 로드맵
- 설치(ECharts 로컬번들)·백엔드(trend_group/v_analog_points)·프론트 코드 스니펫 포함

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-25 17:34:04 +09:00
parent 81e2ea145a
commit 930fac2b4f

View File

@@ -0,0 +1,595 @@
# 통합 트렌드 워크스페이스 (Apache ECharts) — 구현 플랜 (P1→P2→P3, 단일 차트)
> 작성일 2026-05-25 · 대상: ExperionCrawler Web UI · 엔진: **Apache ECharts 5.x (Apache-2.0, 로컬 번들)**
> 설계 원칙: **하나의 차트에 기능을 층층이 쌓는다.** P1/P2/P3는 별도 화면이 아니라 *같은 Trend Workspace*에 더해지는 토글형 레이어다.
---
## 0. 핵심 원칙 — "하나의 차트, 슬롯 구조"
```
┌──────────── 단일 Trend Workspace (하나의 ECharts 인스턴스) ──────────┐
P3 지능 │ 🤖LLM Insights · 스마트그룹(P&ID trace) · 상관(X-Y)모드 · 공유링크 │ ← 같은 차트의 window를 읽음
├────────────────────────────────────────────────────────────────────┤
P2 분석 │ 알람한계선(markLine) · 운전음영(markArea) · 이벤트플래그 · │ ← 같은 인스턴스에 오버레이
│ 보이는구간 통계패널 · 듀얼커서Δ · 자동집계/다운샘플 │
├────────────────────────────────────────────────────────────────────┤
P1 기반 │ 멀티시리즈 · dataZoom(좌우날짜) · 범례표(색/강조) · minmax · 라이브토글 │ ← 토대 + 빈 슬롯
└────────────────────────────────────────────────────────────────────┘
같은 데이터 · 같은 시간축 · 같은 그룹 · 같은 window 공유
```
**왜 하나로 합쳐지나** — P1에서 *중앙 상태(`trState`) + 레이어 레지스트리(`TR_LAYERS`) + 합성 렌더(`trRender`)*를 세워두기 때문. 모든 시각요소는 "레이어 함수 → ECharts 옵션 조각"으로 표현되고, `trRender`**켜진 레이어만 모아 한 번에 `setOption`**. P2·P3 기능 추가 = `layerXxx()` 함수 + 레지스트리 1줄 + 토글 1개. **기존 코드 리팩터 없음.**
> 유일한 "모드 전환"은 상관(X-Y)뿐 — 선↔산점도를 **같은 컴포넌트**에서 토글. 새 차트가 아니다.
---
## 1. 결정 요약
| 항목 | 결정 |
|------|------|
| 엔진 | ECharts 5.x (Apache-2.0). `dataZoom`·`emphasis`·`markPoint`·`markLine`·`markArea` 내장 |
| 번들 | 로컬 `wwwroot/lib/echarts.min.js` (uPlot/marked/mermaid와 동일 방식) |
| 배치 | **신규 전용 탭 "트렌드"** (단일 워크스페이스 페이지) |
| 그룹 저장 | 서버 DB `trend_group` (공유·영속). localStorage 대체 가능(§5.1 주석) |
| 아날로그 한정 | `v_analog_points` (숫자 livevalue만 = 아날로그; enum `{n\|RUN\|}` 자동 제외) |
| 합성 구조 | `trState` + `TR_LAYERS` + `trRender` — P2/P3 확장의 핵심(§6.3) |
| 데이터원 | history(원시/집계)·realtime(live)·event_history(이벤트/운전상태)·tag_metadata(한계)·trace(연관)·vLLM(분석) |
---
## 2. 기능 → 단계 매핑 (전부 같은 차트)
| 기능 | 단계 | 메커니즘 | 활용 자산 |
|------|------|----------|----------|
| 멀티시리즈 + 이중/멀티 Y축 | P1 | `series[].yAxisIndex` | history API |
| 좌·우 날짜(`dataZoom`)↔from/to 동기 | P1 | `dataZoom` slider ↔ dt피커 | core.js dt |
| 범례표(엑셀식)·색상편집·클릭 강조 | P1 | `lineStyle.color`/`width`+`emphasis.focus` | — |
| 보이는 범위 최대/최소 마커 | P1 | `markPoint` 윈도우 재계산 | §7 |
| 실시간 폴링 시작/정지 | P1 | `setInterval`+`setOption`/`appendData` | `/api/trend/live` |
| **알람 한계선 HI/LO/SP** | P2 | `markLine{yAxis}` | `v_instrument_range`(euhi/eulo)+`.sp` |
| **운전상태 음영(RUN/STOP/TRIP)** | P2 | `markArea{xAxis구간}` | `event_history_table`(상태변경+duration) |
| **알람/이벤트 세로 플래그** | P2 | `markLine{xAxis}` | `event_history_table`/`active_alarms` |
| **보이는 구간 통계(min/max/avg/σ)** | P2 | 클라 계산(window) | minmax 로직 재사용 |
| **듀얼 커서 Δt·Δy·기울기** | P2 | `graphic`+클릭 | — |
| **자동 집계/LTTB 다운샘플** | P2 | interval API + `sampling:'lttb'` | TimescaleDB 연속집계 |
| **🤖 보이는 구간 LLM 분석** | P3 | window 통계+이벤트→프롬프트 | `ask_iiot_llm`/MCP |
| **스마트그룹(연관 태그 추천)** | P3 | 상·하류 trace | `trace_connections`/`v_pump_signal_map` |
| **상관(X-Y) 모드** | P3 | line↔scatter 토글+회귀 | 클라 |
| **공유 링크/PNG·XLSX 내보내기** | P3 | URL state + toolbox | — |
---
## 3. 아키텍처
### 3.1 데이터 흐름 (단계별로 같은 차트에 합류)
```
realtime_table ─┬─(숫자)→ v_analog_points → /api/trend/analog-points [P1 그룹빌더]
└─(현재값)→ /api/trend/live [P1 실시간 tail]
history_table ──→ /api/history/query · /query-history-interval [P1 시리즈]
tag_metadata ──→ v_instrument_range → /api/trend/limits [P2 알람선]
event_history ──→ /api/trend/events · /api/trend/runbands [P2 이벤트/운전음영]
pid graph ──→ /api/trend/related (trace_connections) [P3 스마트그룹]
vLLM/MCP ──→ /api/trend/analyze (ask_iiot_llm) [P3 분석]
trend_group(DB)──→ /api/trend/groups CRUD [그룹 영속]
trState (중앙 상태) ── TR_LAYERS(레이어 레지스트리) ── trRender() → 단일 ECharts
```
### 3.2 ⭐ 프론트 합성 구조 — "슬롯"의 정체 (§6.3 코드)
- **`trState`**: 멤버·시리즈데이터·보이는 window·라이브·모드·**레이어 토글**·레이어 캐시. 모든 기능이 읽는 단일 진실원.
- **`TR_LAYERS`**: 각 레이어 = `{ id, when(state), perSeries(member,state)?, global(state)? }`. `when`이 false면 합성에서 빠짐(= 토글 OFF).
- **`trRender()`**: 멤버별로 활성 레이어의 `perSeries` 조각을 병합 + 전역 레이어의 `global` 조각을 모아 **한 번** `setOption`. → 새 기능은 레이어 추가만.
### 3.3 백엔드 엔드포인트 맵
| Phase | 엔드포인트 | 비고 |
|------|-----------|------|
| P1 | `GET /api/trend/analog-points` · `GET/POST/PUT/DELETE /api/trend/groups` · `GET /api/trend/live` | 신규 |
| P1 | `GET /api/history/query` · `POST /api/text-to-sql/query-history-interval` | **기존 재사용** |
| P2 | `GET /api/trend/limits?tags=` · `GET /api/trend/events?from&to&tags=` · `GET /api/trend/runbands?from&to&area=` | 신규(뷰/테이블 조회) |
| P3 | `POST /api/trend/analyze` · `GET /api/trend/related?tag=` | LLM/trace 위임 |
---
## 4. 설치 — ECharts 로컬 번들
```bash
cd src/Web/wwwroot/lib
curl -L -o echarts.min.js https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js
ls -la echarts.min.js # ~1MB UMD
```
- 버전 5.5.1(안정) 권장. 폐쇄망은 파일 복사. 용량 이슈 시 echarts 빌더로 line+dataZoom+markLine/Area/Point+tooltip+toolbox만 ~400KB 커스텀 빌드.
- index.html에 `<script src="/lib/echarts.min.js"></script>` 추가(§6.1).
---
## 5. 백엔드
### 5.1 P1 DDL — `trend_group` + `v_analog_points`
`src/Infrastructure/Database/ExperionDbContext.cs` 부팅 DDL(현 `fast_session`·`v_instrument_range` 블록)에 멱등 추가:
```csharp
// 트렌드 그룹 (members JSONB: [{tag,color,axis}])
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS trend_group (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
members JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""");
// 아날로그 포인트(숫자 livevalue) + 단위/레인지 + 설명 조인
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_analog_points");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE VIEW v_analog_points AS
SELECT rt.tagname,
split_part(rt.tagname,'.',1) AS base_tag,
rt.livevalue::double precision AS value,
rt.timestamp,
ir.unit, ir.eu_lo, ir.eu_hi,
ts.description, ts.area, ts.sub_area
FROM realtime_table rt
LEFT JOIN v_instrument_range ir ON ir.base_tag = split_part(rt.tagname,'.',1)
LEFT JOIN v_tag_summary ts ON ts.base_tag = split_part(rt.tagname,'.',1)
WHERE rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
ORDER BY rt.tagname
""");
```
> **아날로그 판별**: enum/디지털은 `{n | LABEL | }`라 숫자 정규식 불일치 → 자동 제외. 메타 적재 여부와 무관.
> **localStorage 대체 시**: `trend_group` DDL + §5.2 그룹 CRUD 생략, `trState.group`을 `localStorage['trendGroups']`로. analog-points/live는 유지.
### 5.2 P1 DTO / Service / Controller / DI
DTO (`TrendDtos.cs`): `TrendMemberDto{Tag,Color,Axis}`, `TrendGroupDto{Id,Name,Description,Members[],CreatedAt,UpdatedAt}`, `TrendGroupUpsertDto`, `AnalogPointDto{TagName,BaseTag,Value,Timestamp,Unit,EuLo,EuHi,Description,Area}`, `TrendLivePointDto{Tag,Value,Ts}`.
Service (`ITrendService`/`TrendService`, `IDbContextFactory<ExperionDbContext>` 주입 — 124행에 이미 등록):
```csharp
public interface ITrendService {
Task<List<AnalogPointDto>> GetAnalogPointsAsync(string? q, CancellationToken ct);
Task<List<TrendGroupDto>> GetGroupsAsync(CancellationToken ct);
Task<TrendGroupDto?> GetGroupAsync(int id, CancellationToken ct);
Task<TrendGroupDto> CreateGroupAsync(TrendGroupUpsertDto d, CancellationToken ct);
Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto d, CancellationToken ct);
Task<bool> DeleteGroupAsync(int id, CancellationToken ct);
Task<List<TrendLivePointDto>> GetLiveAsync(IEnumerable<string> tags, CancellationToken ct);
}
```
- `members``System.Text.Json`으로 (de)serialize, INSERT `@m::jsonb`.
- 뷰 조회는 ADO(`ctx.Database.GetDbConnection()`) 또는 keyless `ToView`. 프로젝트 raw 조회 패턴 답습.
- `GetLiveAsync`: `SELECT tagname, livevalue::double precision, timestamp FROM realtime_table WHERE tagname = ANY(@tags) AND livevalue ~ '^-?[0-9.]+$'`.
Controller (`/api/trend`): `analog-points`(q) · `groups` CRUD · `live?tags=` (§ 이전 설계와 동일).
DI: `builder.Services.AddScoped<ITrendService, TrendService>();`
### 5.3 P2 엔드포인트
```csharp
// 알람 한계선 — euhi/eulo + 현재 SP
[HttpGet("limits")] // ?tags=a,b
// SELECT base_tag, eu_hi, eu_lo, unit FROM v_instrument_range WHERE base_tag = ANY(@bases)
// + (선택) realtime_table 의 base||'.sp' 현재값을 SP 선으로
// → [{tag, hi, lo, sp, unit}]
// 이벤트 플래그 — 디지털 상태변경/알람
[HttpGet("events")] // ?from&to&tags=
// SELECT tagname, event_time, event_type, prev_value, curr_value
// FROM event_history_table
// WHERE event_time BETWEEN @from AND @to AND (@tags='' OR tagname = ANY(@tags))
// → [{tag, ts, type, label}]
// 운전상태 음영 — RUN 구간(상태+지속시간)
[HttpGet("runbands")] // ?from&to&area=
// event_history_table 의 펌프 상태변경에서 RUN→다음변경 구간을 밴드로 환산
// (duration_seconds 활용) → [{tag/area, t0, t1, state}]
```
> 통계패널·듀얼커서·자동집계는 **백엔드 무변경**(클라 계산 + 기존 interval API).
### 5.4 P3 엔드포인트
```csharp
// 🤖 보이는 구간 분석 — window 통계+이벤트를 받아 LLM 프롬프트 구성→ask_iiot_llm 위임
[HttpPost("analyze")]
// body: { window:{from,to}, members:[{tag,unit,min,max,avg,last,slope}], events:[...] }
// → OllamaController/McpService(ask_iiot_llm) 재사용. 한국어 진단 텍스트 반환.
// 스마트그룹 — 선택 태그의 상·하류 연관 아날로그 추천
[HttpGet("related")] // ?tag=
// TagMappingService/PidGraphService 의 trace + v_pump_signal_map → 연관 base_tag
// → v_analog_points 로 필터해 아날로그만 [{tagName, desc, relation}]
```
> 상관(X-Y)·공유링크는 **백엔드 무변경**(클라).
---
## 6. 프론트엔드 — 단일 워크스페이스
### 6.1 배선 — index.html
```html
<li class="nav-item" data-tab="trend"><span class="nav-ico">📈</span><span class="nav-txt">17 트렌드</span></li>
<section class="pane" id="pane-trend" data-src="/panes/trend.html"></section>
<script src="/lib/echarts.min.js"></script>
<script src="/js/trend.js"></script>
```
탭 라우터가 lazy-load + `paneInit.trend?.()` 자동 호출. CSS는 pane 안 `<link href="/css/trend.css">`.
### 6.2 레이아웃 — panes/trend.html (슬롯 포함)
```html
<link rel="stylesheet" href="/css/trend.css">
<header class="pane-hdr"><div><h1>트렌드 워크스페이스</h1><p>히스토리·실시간·분석 통합</p></div><div class="pane-tag">TREND</div></header>
<!-- 타임바: 퀵레인지 + from/to + 라이브 + 집계 + 토글들(슬롯) -->
<div class="card trend-timebar">
<div class="tr-quick"><button onclick="trQuickRange('1h')">1h</button><button onclick="trQuickRange('8h')">8h</button>
<button onclick="trQuickRange('24h')">24h</button><button onclick="trQuickRange('7d')">7d</button><button onclick="trQuickRange('shift')">교대</button></div>
<input type="hidden" id="hf-from"><div class="dt-display inp" id="dtp-from-display" onclick="dtOpen('from')"></div>
<input type="hidden" id="hf-to"><div class="dt-display inp" id="dtp-to-display" onclick="dtOpen('to')"></div>
<select id="tr-interval" class="inp"><option value="auto">자동</option><option value="1 minute">원시</option><option value="5 minutes">5분</option><option value="1 hour">1시간</option></select>
<button class="btn-a" onclick="trQuery()">🔍 조회</button>
<button class="btn-b" id="tr-live-btn" onclick="trToggleLive()">▶ 라이브</button>
<!-- 레이어 토글(슬롯): P1 minmax부터, P2/P3는 같은 자리에 버튼만 추가 -->
<span class="tr-layers">
<button class="tr-tog" data-layer="minmax" onclick="trToggleLayer('minmax')">↕ 최대/최소</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" data-layer="events" onclick="trToggleLayer('events')">┊ 이벤트</button>
<button class="tr-tog" data-layer="insights" onclick="trToggleInsights()">🤖 분석</button>
<button class="tr-tog" onclick="trToggleXY()">⊹ 상관</button>
</span>
<span class="tr-groupbar"><select id="tr-group-select" class="inp"></select>
<button onclick="trLoadGroup()">불러오기</button><button onclick="trOpenBuilder()">+그룹</button>
<button onclick="trEditGroup()">편집</button><button onclick="trDeleteGroup()">삭제</button></span>
</div>
<div class="trend-main">
<div class="card trend-chartwrap"><div id="tr-chart" class="trend-chart"></div></div>
<!-- Insights 슬롯(P3): 기본 숨김, 토글 시 슬라이드인 -->
<aside id="tr-insights" class="card trend-insights hidden">
<div class="tr-ins-hd">🤖 분석 <button onclick="trAnalyze()">이 구간 설명</button></div>
<div id="tr-ins-body" class="tr-ins-body">보이는 구간을 선택하고 "이 구간 설명"을 누르세요.</div>
</aside>
</div>
<!-- 범례/통계 표 (엑셀식, 보이는 구간 통계 칸 포함) -->
<div class="card"><table class="tr-legend"><thead><tr>
<th></th><th>태그</th><th>설명</th><th>현재</th><th>최소</th><th>최대</th><th>평균</th><th>σ</th><th>단위</th><th></th><th>👁</th><th></th>
</tr></thead><tbody id="tr-legend-body"></tbody></table></div>
<!-- 그룹 빌더 모달 (아날로그 클릭 선택) -->
<div id="tr-builder" class="tr-modal hidden"><div class="tr-modal-box">
<div class="tr-modal-hd"><input id="tr-builder-name" class="inp" placeholder="그룹 이름">
<input id="tr-builder-search" class="inp" placeholder="태그/설명 검색" oninput="trRenderAnalog()">
<button onclick="trBuilderRelated()">🔗 연관추천</button></div> <!-- P3 슬롯 -->
<div class="tr-analog-wrap"><table class="tr-analog"><thead><tr>
<th></th><th>태그</th><th>설명</th><th>구역</th><th>현재값</th><th>단위</th><th>EU Lo</th><th>EU Hi</th></tr></thead>
<tbody id="tr-analog-body"></tbody></table></div>
<div class="tr-modal-ft"><span id="tr-builder-count">0개</span>
<button class="btn-a" onclick="trSaveGroup()">저장</button><button class="btn-b" onclick="trCloseBuilder()">취소</button></div>
</div></div>
```
### 6.3 ⭐ 상태 모델 + 합성 렌더 (P2/P3 확장의 토대) — js/trend.js
```js
let trChart = null, trLiveTimer = null, trEditingId = null;
const TR_PALETTE = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4','#42d4f4','#f032e6','#bfef45','#469990','#fabed4'];
// 중앙 상태 — 모든 레이어가 읽는 단일 진실원
const trState = {
group: null,
members: [], // [{tag,color,axis,unit,desc,euLo,euHi}]
seriesData: {}, // { tag: [[ms,val],...] }
window: { t0:null, t1:null },
selected: null, // 강조 중 태그
mode: 'line', // 'line' | 'xy'
live: false,
layers: { minmax:false, limits:false, runstate:false, events:false, insights:false },
cache: { limits:{}, events:[], runbands:[] }
};
// 레이어 레지스트리 — 새 기능은 여기 1줄 + 함수만 추가하면 같은 차트에 합류
const TR_LAYERS = [
{ id:'base', perSeries: layerBaseSeries },
{ id:'minmax', perSeries: layerMinMax, when: s => s.layers.minmax },
{ id:'limits', perSeries: layerLimits, when: s => s.layers.limits },
{ id:'runstate', global: layerRunState, when: s => s.layers.runstate },
{ id:'events', global: layerEvents, when: s => s.layers.events },
// P3 등 추가 레이어도 동일 형태로 push
];
// 합성 렌더 — 켜진 레이어를 모아 한 번에 setOption
function trRender() {
if (trState.mode === 'xy') return trRenderXY(); // 상관모드(같은 컴포넌트)
const series = trState.members.map(m => {
let s = { name:m.tag, type:'line', showSymbol:false };
for (const L of TR_LAYERS) {
if (L.when && !L.when(trState)) continue;
if (L.perSeries) s = trMerge(s, L.perSeries(m, trState));
}
return s;
});
for (const L of TR_LAYERS) { // 전역 레이어(markArea/markLine 운반용 helper series)
if (L.when && !L.when(trState)) continue;
if (L.global) { const g = L.global(trState); if (g) series.push(g); }
}
trChart.setOption({ series }, { replaceMerge:['series'] });
trRenderLegend(); // 통계칸 포함
}
function trMerge(a,b){ return { ...a, ...b, lineStyle:{...a.lineStyle,...b.lineStyle},
markPoint:b.markPoint||a.markPoint, markLine:b.markLine||a.markLine, markArea:b.markArea||a.markArea }; }
```
```js
// ── 레이어 함수들 ───────────────────────────────────────────
function layerBaseSeries(m, s){
return { yAxisIndex: m.axis==='right'?1:0,
lineStyle:{ color:m.color, width: m.tag===s.selected?4:1.5 },
itemStyle:{ color:m.color }, emphasis:{ focus:'series', lineStyle:{width:4} },
data: s.seriesData[m.tag] || [] };
}
function layerMinMax(m, s){ // P1 — 보이는 구간 max/min 핀
const [t0,t1]=trVisibleWindow(); const d=s.seriesData[m.tag]||[];
let mn=Infinity,mx=-Infinity,mnp=null,mxp=null;
for(const [ms,v] of d){ if(v==null||ms<t0||ms>t1)continue; if(v>mx){mx=v;mxp=[ms,v];} if(v<mn){mn=v;mnp=[ms,v];} }
const data=[]; if(mxp)data.push({coord:mxp,symbol:'pin',symbolSize:40,itemStyle:{color:m.color},label:{formatter:`${mx.toFixed(1)}`,fontSize:10}});
if(mnp)data.push({coord:mnp,symbol:'pin',symbolSize:40,symbolRotate:180,itemStyle:{color:m.color},label:{formatter:`${mn.toFixed(1)}`,fontSize:10,offset:[0,18]}});
return { markPoint:{ silent:true, data } };
}
function layerLimits(m, s){ // P2 — HI/LO/SP 수평선
const lim=s.cache.limits[m.tag]; if(!lim) return {};
const data=[];
if(lim.hi!=null)data.push({yAxis:lim.hi,lineStyle:{color:'#e55',type:'dashed'},label:{formatter:'HI'}});
if(lim.lo!=null)data.push({yAxis:lim.lo,lineStyle:{color:'#e55',type:'dashed'},label:{formatter:'LO'}});
if(lim.sp!=null)data.push({yAxis:lim.sp,lineStyle:{color:m.color,type:'dotted'},label:{formatter:'SP'}});
return { markLine:{ silent:true, symbol:'none', data } };
}
function layerRunState(s){ // P2 — RUN 구간 음영(전역 helper series)
const bands=s.cache.runbands.map(b=>[{xAxis:b.t0,itemStyle:{color:b.state==='TRIP'?'rgba(229,85,85,.10)':'rgba(60,180,75,.10)'}},{xAxis:b.t1}]);
return { name:'__runstate', type:'line', data:[], showSymbol:false, silent:true, markArea:{ data:bands } };
}
function layerEvents(s){ // P2 — 이벤트 세로 플래그(전역 helper series)
const data=s.cache.events.map(e=>({xAxis:e.ts,label:{formatter:e.label,rotate:90,fontSize:9},lineStyle:{color:'#f58231',type:'solid',width:1}}));
return { name:'__events', type:'line', data:[], showSymbol:false, silent:true, markLine:{ symbol:'none', data } };
}
```
> 보다시피 **P2 추가 = 함수 1개 + 레지스트리 1줄 + 캐시 채우는 fetch + 토글 버튼.** 차트 본체(`trRender`)는 손대지 않는다.
### 6.4 P1 기능
```js
function trInitChart(){
const el=document.getElementById('tr-chart'); if(trChart)trChart.dispose();
trChart=echarts.init(el,null,{renderer:'canvas'});
trChart.setOption({
animation:false, grid:{left:56,right:56,top:24,bottom:96},
tooltip:{trigger:'axis',axisPointer:{type:'cross'},valueFormatter:v=>v==null?'—':(+v).toFixed(2)},
legend:{show:false}, xAxis:{type:'time'},
yAxis:[{type:'value'},{type:'value',position:'right'}],
dataZoom:[{type:'inside',filterMode:'none'},{type:'slider',filterMode:'none',height:26,bottom:44}],
toolbox:{right:12,feature:{saveAsImage:{title:'PNG'},dataView:{readOnly:true,title:'표'},restore:{title:'복원'}}},
series:[]
});
trChart.on('dataZoom', trOnZoom);
window.addEventListener('resize',()=>trChart&&trChart.resize());
}
async function trQuery(){
const tags=trState.members.map(m=>m.tag); if(!tags.length)return;
const from=document.getElementById('hf-from').value, to=document.getElementById('hf-to').value;
let interval=document.getElementById('tr-interval').value;
if(interval==='auto') interval=trAutoInterval(from,to); // P2 자동집계
let rows,tk;
if(interval==='1 minute'){ 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';
} else { 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.rows; tk='timeBucket'; }
trState.seriesData={}; for(const m of trState.members)trState.seriesData[m.tag]=[];
for(const r of rows){ const ms=new Date(r[tk]).getTime();
for(const m of trState.members){ const v=r.values?.[m.tag]; trState.seriesData[m.tag].push([ms,v==null?null:+v]); } }
if(trState.layers.limits) await trLoadLimits(); // 켜져 있으면 갱신
if(trState.layers.runstate||trState.layers.events) await trLoadEvents(from,to);
trRender();
}
// 범례표(엑셀식) + 보이는 구간 통계 칸
function trRenderLegend(){
const [t0,t1]=trVisibleWindow();
const tb=document.getElementById('tr-legend-body');
tb.innerHTML=trState.members.map((m,i)=>{
const st=trStats(trState.seriesData[m.tag],t0,t1);
return `<tr data-tag="${esc(m.tag)}" class="${m.tag===trState.selected?'tr-sel':''}" onclick="trHighlight('${esc(m.tag)}')">
<td><input type="color" value="${m.color}" onclick="event.stopPropagation()" onchange="trSetColor('${esc(m.tag)}',this.value)"></td>
<td class="tr-tag">${esc(m.tag)}</td><td>${esc(m.desc||'')}</td>
<td class="val">${st.last??'—'}</td><td class="val">${st.min??'—'}</td><td class="val">${st.max??'—'}</td>
<td class="val">${st.avg??'—'}</td><td class="val">${st.sd??'—'}</td><td>${esc(m.unit||'')}</td>
<td><select onclick="event.stopPropagation()" onchange="trSetAxis('${esc(m.tag)}',this.value)">
<option value="left" ${m.axis!=='right'?'selected':''}>좌</option><option value="right" ${m.axis==='right'?'selected':''}>우</option></select></td>
<td><input type="checkbox" checked onclick="event.stopPropagation();trToggleVis('${esc(m.tag)}',this.checked)"></td>
<td><button onclick="event.stopPropagation();trRemoveMember('${esc(m.tag)}')">×</button></td></tr>`;
}).join('');
}
function trStats(d,t0,t1){ if(!d)return{}; let mn=Infinity,mx=-Infinity,sum=0,n=0,last=null,sq=0;
for(const [ms,v] of d){ if(v==null||ms<t0||ms>t1)continue; if(v<mn)mn=v; if(v>mx)mx=v; sum+=v; n++; last=v; }
if(!n)return{}; const avg=sum/n; for(const [ms,v] of d){ if(v==null||ms<t0||ms>t1)continue; sq+=(v-avg)**2; }
return {min:mn.toFixed(2),max:mx.toFixed(2),avg:avg.toFixed(2),sd:Math.sqrt(sq/n).toFixed(2),last:last.toFixed(2)}; }
// 클릭 강조(요구②): 선 굵게 + 나머지 흐림
function trHighlight(tag){ trState.selected=(trState.selected===tag)?null:tag; trRender();
trChart.dispatchAction({type:'downplay'}); if(trState.selected)trChart.dispatchAction({type:'highlight',seriesName:trState.selected}); }
function trSetColor(tag,c){ const m=trState.members.find(x=>x.tag===tag); if(!m)return; m.color=c; trRender(); trPersist(); }
function trSetAxis(tag,a){ const m=trState.members.find(x=>x.tag===tag); if(!m)return; m.axis=a; trRender(); trPersist(); }
// 날짜 ↔ dataZoom 동기(요구③)
function trOnZoom(){ const [t0,t1]=trVisibleWindow(); trState.window={t0,t1};
if(t0&&t1){ trSetDt('from',new Date(t0)); trSetDt('to',new Date(t1)); }
trRenderLegend(); if(trState.layers.minmax)trRender(); }
function trApplyDateRange(){ const f=document.getElementById('hf-from').value,t=document.getElementById('hf-to').value;
if(f&&t)trChart.dispatchAction({type:'dataZoom',startValue:new Date(f).getTime(),endValue:new Date(t).getTime()}); }
function trVisibleWindow(){ const dz=(trChart.getOption().dataZoom||[])[0]||{}; let t0=dz.startValue,t1=dz.endValue;
if(t0==null||t1==null){ let lo=Infinity,hi=-Infinity; for(const k in trState.seriesData)for(const [ms] of trState.seriesData[k]){if(ms<lo)lo=ms;if(ms>hi)hi=ms;}
if(!isFinite(lo))return[null,null]; t0=lo+(hi-lo)*((dz.start??0)/100); t1=lo+(hi-lo)*((dz.end??100)/100);} return[t0,t1]; }
// 라이브(요구④)
function trToggleLive(){ if(trLiveTimer){trStopLive();return;} const b=document.getElementById('tr-live-btn');
b.textContent='⏸ 정지'; b.classList.add('live-on'); trState.live=true; trLiveTimer=setInterval(trLiveTick,5000); trLiveTick(); }
function trStopLive(){ clearInterval(trLiveTimer); trLiveTimer=null; trState.live=false;
const b=document.getElementById('tr-live-btn'); b.textContent='▶ 라이브'; b.classList.remove('live-on'); }
async function trLiveTick(){ const tags=trState.members.map(m=>m.tag); if(!tags.length)return;
const d=await api('GET',`/api/trend/live?tags=${encodeURIComponent(tags.join(','))}`); let ch=false;
for(const p of (d.items||[])){ const a=trState.seriesData[p.tag]; if(!a||p.value==null)continue; const ms=new Date(p.ts).getTime();
if(!a.length||a[a.length-1][0]!==ms){a.push([ms,+p.value]);ch=true;} }
if(ch){ trChart.setOption({series:trState.members.map(m=>({name:m.tag,data:trState.seriesData[m.tag]}))});
if(trState.layers.minmax)trRender(); trRenderLegend(); } }
// 레이어 토글 — 공통
function trToggleLayer(id){ trState.layers[id]=!trState.layers[id];
document.querySelector(`.tr-tog[data-layer="${id}"]`)?.classList.toggle('on',trState.layers[id]);
if(id==='limits'&&trState.layers.limits)trLoadLimits().then(trRender); else
if((id==='runstate'||id==='events')&&trState.layers[id]){const f=document.getElementById('hf-from').value,t=document.getElementById('hf-to').value;trLoadEvents(f,t).then(trRender);} else trRender(); }
```
### 6.5 P2 레이어 (데이터 로더 — 차트 본체 무수정)
```js
async function trLoadLimits(){ const tags=trState.members.map(m=>m.tag);
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; }
async function trLoadEvents(from,to){ const tags=trState.members.map(m=>m.tag);
const qs=`from=${from?new Date(from).toISOString():''}&to=${to?new Date(to).toISOString():''}&tags=${encodeURIComponent(tags.join(','))}`;
if(trState.layers.events){ const e=await api('GET',`/api/trend/events?${qs}`); trState.cache.events=(e.items||[]).map(x=>({ts:new Date(x.ts).getTime(),label:x.label||x.type})); }
if(trState.layers.runstate){ const r=await api('GET',`/api/trend/runbands?${qs}`); trState.cache.runbands=(r.items||[]).map(x=>({t0:new Date(x.t0).getTime(),t1:new Date(x.t1).getTime(),state:x.state})); } }
function trAutoInterval(from,to){ if(!from||!to)return '1 minute'; const h=(new Date(to)-new Date(from))/3.6e6;
return h<=6?'1 minute':h<=48?'5 minutes':h<=24*14?'1 hour':'1 day'; }
// 듀얼커서Δ: trChart.getZr().on('click', ...) 로 두 점 선택 → graphic 텍스트로 Δt/Δy/기울기 표시 (P2 추가 함수)
```
### 6.6 P3 레이어 (슬롯에 합류)
```js
// 🤖 LLM 분석 — 보이는 구간 통계+이벤트를 백엔드로 → ask_iiot_llm
async function trAnalyze(){ const [t0,t1]=trVisibleWindow();
const members=trState.members.map(m=>({tag:m.tag,unit:m.unit,...trStats(trState.seriesData[m.tag],t0,t1)}));
const body={window:{from:new Date(t0).toISOString(),to:new Date(t1).toISOString()},members,events:trState.cache.events};
document.getElementById('tr-ins-body').textContent='분석 중…';
const d=await api('POST','/api/trend/analyze',body);
document.getElementById('tr-ins-body').innerHTML=marked.parse(d.text||''); } // marked 이미 번들됨
function trToggleInsights(){ document.getElementById('tr-insights').classList.toggle('hidden'); }
// 스마트그룹 — 연관 아날로그 추천(빌더에서)
async function trBuilderRelated(){ const seed=[...trBuilderSel][0]; if(!seed)return;
const d=await api('GET',`/api/trend/related?tag=${encodeURIComponent(seed)}`);
for(const r of (d.items||[]))trBuilderSel.add(r.tagName); trRenderAnalog(); }
// 상관(X-Y) 모드 — 같은 컴포넌트에서 line↔scatter 토글
function trToggleXY(){ trState.mode=trState.mode==='xy'?'line':'xy'; trState.mode==='xy'?trRenderXY():trRender(); }
function trRenderXY(){ /* 멤버[0]=X, 멤버[1]=Y 시간조인 → scatter + 회귀선 */ }
// 공유링크 — 그룹/윈도우/레이어 상태를 URL 해시로 인코딩/복원
function trShareLink(){ const st={g:trState.group?.id,t0:trState.window.t0,t1:trState.window.t1,L:trState.layers};
location.hash='trend='+btoa(JSON.stringify(st)); navigator.clipboard.writeText(location.href); }
```
### 6.7 그룹 빌더 + paneInit
```js
let trAnalogAll=[], trBuilderSel=new Set();
async function trOpenBuilder(){ document.getElementById('tr-builder').classList.remove('hidden');
trBuilderSel=new Set(trState.members.map(m=>m.tag)); const d=await api('GET','/api/trend/analog-points'); trAnalogAll=d.items||[]; trRenderAnalog(); }
function trRenderAnalog(){ const q=(document.getElementById('tr-builder-search').value||'').toLowerCase();
document.getElementById('tr-analog-body').innerHTML=trAnalogAll.filter(p=>!q||p.tagName.toLowerCase().includes(q)||(p.description||'').toLowerCase().includes(q))
.map(p=>`<tr class="${trBuilderSel.has(p.tagName)?'tr-pick':''}" onclick="trTogglePick('${esc(p.tagName)}')">
<td>${trBuilderSel.has(p.tagName)?'✔':''}</td><td class="tr-tag">${esc(p.tagName)}</td><td>${esc(p.description||'')}</td><td>${esc(p.area||'')}</td>
<td class="val">${p.value??'—'}</td><td>${esc(p.unit||'')}</td><td class="val">${p.euLo??'—'}</td><td class="val">${p.euHi??'—'}</td></tr>`).join('');
document.getElementById('tr-builder-count').textContent=`${trBuilderSel.size}`; }
function trTogglePick(t){ trBuilderSel.has(t)?trBuilderSel.delete(t):trBuilderSel.add(t); trRenderAnalog(); }
async function trSaveGroup(){ const name=document.getElementById('tr-builder-name').value.trim(); if(!name){alert('그룹 이름');return;}
const members=[...trBuilderSel].map((t,i)=>{const ex=trState.members.find(m=>m.tag===t);return{tag:t,color:ex?.color||TR_PALETTE[i%TR_PALETTE.length],axis:ex?.axis||'left'};});
const saved=trEditingId?await api('PUT',`/api/trend/groups/${trEditingId}`,{name,members}):await api('POST','/api/trend/groups',{name,members});
trCloseBuilder(); await trLoadGroupList(); document.getElementById('tr-group-select').value=saved.id; trLoadGroup(); }
paneInit.trend=function(){ if(!trChart)trInitChart(); trLoadGroupList(); trRestoreFromHash&&trRestoreFromHash(); };
```
> `core.js dtConfirm()` 끝에 한 줄: 트렌드 탭 활성 시 `trApplyDateRange()` 호출(또는 `tr-from/tr-to`로 분리).
### 6.8 CSS — css/trend.css (핵심)
```css
.trend-timebar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.tr-quick button,.tr-tog{background:var(--s3);border:1px solid var(--bd);color:var(--t1);border-radius:var(--r);padding:4px 8px;cursor:pointer}
.tr-tog.on{background:var(--blu,#4363d8);color:#fff} #tr-live-btn.live-on{background:var(--red,#e55);color:#fff}
.trend-main{display:flex;gap:12px} .trend-chartwrap{flex:1} .trend-chart{width:100%;height:480px}
.trend-insights{width:320px} .trend-insights.hidden{display:none}
.tr-legend,.tr-analog{width:100%;border-collapse:collapse;font-size:12px}
.tr-legend td,.tr-legend th,.tr-analog td,.tr-analog th{padding:6px 8px;border-bottom:1px solid var(--bd)}
.tr-legend tr{cursor:pointer} .tr-legend tr.tr-sel{background:rgba(67,99,216,.15);font-weight:600}
.tr-legend tr:hover,.tr-analog tr:hover{background:var(--s3)} .tr-analog tr.tr-pick{background:rgba(60,180,75,.15)}
.tr-tag{font-family:var(--fm)} .val{text-align:right;font-variant-numeric:tabular-nums}
.tr-modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:50}
.tr-modal.hidden{display:none} .tr-modal-box{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);width:min(960px,93vw);max-height:86vh;display:flex;flex-direction:column}
.tr-analog-wrap{overflow:auto;flex:1} input[type=color]{width:26px;height:20px;border:none;background:none;padding:0;cursor:pointer}
```
---
## 7. ⭐ 최대/최소 (보이는 범위) — 기술 검증
**가능(확정).** 채택안 = §6.3 `layerMinMax`: `dataZoom` 이벤트(`trOnZoom`)로 보이는 `[t0,t1]`를 얻어 멤버별 min/max를 스캔 → `markPoint`에 명시 좌표로 배치 + 범례표 통계칸 동시 갱신. 줌/팬마다 재계산, 멤버별 토글 가능.
대안(간단): `markPoint{type:'max'/'min'}` + `dataZoom.filterMode:'filter'` → 보이는 점만으로 자동 재계산(라벨/토글 제약). 요구 충족엔 채택안.
---
## 8. 검증 체크리스트
빌드/스모크: `dotnet build` 0/0 · `node -c js/trend.js` · 부팅 후 `trend_group`/`v_analog_points` 생성 · `analog-points`는 숫자태그만 · 기존 history API 무변경.
P1: 빌더 클릭선택→그룹 저장/복원 · 범례 색변경 영속·행클릭 굵게+흐림 · from/to↔슬라이더 동기 · 라이브 tail/정지 · minmax 줌 재계산 · 이중축 · resize.
P2: 알람선 토글(HI/LO/SP) · 운전음영 RUN/TRIP 색 · 이벤트 세로플래그 · 통계칸(min/max/avg/σ) 윈도우 반영 · 듀얼커서Δ · 자동집계 윈도우폭별 전환.
P3: 🤖"이 구간 설명"→한국어 진단 · 연관추천 태그 추가 · 상관모드 line↔scatter · 공유링크 복원 · PNG/표 내보내기.
---
## 9. 단계별 작업 순서
**P1 (토대 — 슬롯 포함)**
1. 설치: echarts.min.js + index.html 배선
2. DDL: trend_group + v_analog_points
3. 백엔드: DTO→TrendService→TrendController→DI
4. 프론트 스캐폴드: trend.html/css/js, `trState`+`TR_LAYERS`+`trRender` 골격, paneInit
5. 그룹 빌더(아날로그 클릭)·그룹 CRUD
6. 차트 init·history 조회·`layerBaseSeries`
7. 범례표(색/강조/축/통계칸)·날짜↔dataZoom·`layerMinMax`·라이브
**P2 (분석 레이어 — 함수+토글만 추가)**
8. limits/events/runbands 엔드포인트
9. `layerLimits`/`layerRunState`/`layerEvents` + 로더 + 토글 버튼
10. 자동집계(trAutoInterval+LTTB)·듀얼커서Δ
**P3 (지능/연동 레이어)**
11. `/api/trend/analyze`(LLM)·Insights 패널
12. `/api/trend/related`(trace)·빌더 연관추천
13. 상관(X-Y)모드·공유링크·내보내기
각 번호는 독립 커밋. P1 끝나면 화면이 완성 동작하고, P2·P3는 같은 차트에 무중단 증분.
---
## 10. 잔여 / 리스크
| 항목 | 메모 |
|------|------|
| 번들 ~1MB | 필요 시 echarts 커스텀 빌드 ~400KB |
| dt 피커 id 공유 | hist와 `hf-from/to` 공유. 분리 필요 시 `tr-from/to`+`dtOpen` 일반화 |
| 운전음영 데이터 | 현재값 뷰가 아닌 `event_history_table` 상태변경/지속시간에서 RUN 구간 산출 |
| 실시간 해상도 | history 60s·tail은 수집주기 의존. stall 시 `[[memory:project_realtime_collector_stall]]` 신선도 확인 |
| LLM 호출 | `ask_iiot_llm` 위임. MCP 재시작 필요 가능. 응답 지연 대비 로딩/취소 |
| 뷰 매핑 | v_analog_points keyless → ADO/ToView. 프로젝트 raw 조회 패턴 답습 |
| 인증 | 그룹 CRUD 공개 vs KB admin 토큰 재사용 — 결정 필요(문서 탐색기 패턴 참고) |
| 대용량 | 멤버多×장기 5천행↑ → 자동집계/LTTB 필수 |
```