Files
ExperionCrawler/plans/히스토리컬-트렌드차트-ECharts-구현플랜.md
windpacer 930fac2b4f 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>
2026-05-25 17:34:04 +09:00

38 KiB
Raw Blame History

통합 트렌드 워크스페이스 (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 로컬 번들

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 블록)에 멱등 추가:

// 트렌드 그룹 (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.grouplocalStorage['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행에 이미 등록):

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);
}
  • membersSystem.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 엔드포인트

// 알람 한계선 — 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 엔드포인트

// 🤖 보이는 구간 분석 — 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

<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 (슬롯 포함)

<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

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 }; }
// ── 레이어 함수들 ───────────────────────────────────────────
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 기능

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 레이어 (데이터 로더 — 차트 본체 무수정)

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 레이어 (슬롯에 합류)

// 🤖 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

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 (핵심)

.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 필수