From 930fac2b4feadb49ad41a40e80c849b26d5f0784 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 25 May 2026 17:34:04 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=ED=8A=B8=EB=A0=8C=EB=93=9C=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4(ECharts)=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=ED=94=8C=EB=9E=9C=20=E2=80=94=20P1~P3=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=EC=B0=A8=ED=8A=B8=20=EC=8A=AC=EB=A1=AF?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trState + TR_LAYERS + trRender 합성구조로 P2/P3를 같은 차트에 무중단 증분 - P1(그룹/dataZoom/범례강조/minmax/라이브/트립오버레이) ~ P3(LLM분석/스마트그룹/상관) 로드맵 - 설치(ECharts 로컬번들)·백엔드(trend_group/v_analog_points)·프론트 코드 스니펫 포함 Co-Authored-By: Claude Opus 4.7 --- .../히스토리컬-트렌드차트-ECharts-구현플랜.md | 595 ++++++++++++++++++ 1 file changed, 595 insertions(+) create mode 100644 plans/히스토리컬-트렌드차트-ECharts-구현플랜.md diff --git a/plans/히스토리컬-트렌드차트-ECharts-구현플랜.md b/plans/히스토리컬-트렌드차트-ECharts-구현플랜.md new file mode 100644 index 0000000..3257459 --- /dev/null +++ b/plans/히스토리컬-트렌드차트-ECharts-구현플랜.md @@ -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에 `` 추가(§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` 주입 — 124행에 이미 등록): +```csharp +public interface ITrendService { + Task> GetAnalogPointsAsync(string? q, CancellationToken ct); + Task> GetGroupsAsync(CancellationToken ct); + Task GetGroupAsync(int id, CancellationToken ct); + Task CreateGroupAsync(TrendGroupUpsertDto d, CancellationToken ct); + Task UpdateGroupAsync(int id, TrendGroupUpsertDto d, CancellationToken ct); + Task DeleteGroupAsync(int id, CancellationToken ct); + Task> GetLiveAsync(IEnumerable 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();` + +### 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 + +
+ + +``` +탭 라우터가 lazy-load + `paneInit.trend?.()` 자동 호출. CSS는 pane 안 ``. + +### 6.2 레이아웃 — panes/trend.html (슬롯 포함) +```html + +

트렌드 워크스페이스

히스토리·실시간·분석 통합

TREND
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + +
+ +
+
+ + +
+ + +
+ +
태그설명현재최소최대평균σ단위👁
+ + + +``` + +### 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||mst1)continue; if(v>mx){mx=v;mxp=[ms,v];} if(v[{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 ` + + ${esc(m.tag)}${esc(m.desc||'')} + ${st.last??'—'}${st.min??'—'}${st.max??'—'} + ${st.avg??'—'}${st.sd??'—'}${esc(m.unit||'')} + + + `; + }).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||mst1)continue; if(vmx)mx=v; sum+=v; n++; last=v; } + if(!n)return{}; const avg=sum/n; for(const [ms,v] of d){ if(v==null||mst1)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(mshi)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=>` + ${trBuilderSel.has(p.tagName)?'✔':''}${esc(p.tagName)}${esc(p.description||'')}${esc(p.area||'')} + ${p.value??'—'}${esc(p.unit||'')}${p.euLo??'—'}${p.euHi??'—'}`).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 필수 | +```