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:
595
plans/히스토리컬-트렌드차트-ECharts-구현플랜.md
Normal file
595
plans/히스토리컬-트렌드차트-ECharts-구현플랜.md
Normal 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 필수 |
|
||||
```
|
||||
Reference in New Issue
Block a user