docs: 트렌드 P2 작업지시서 (LLM 구현용)

- 알람선(markLine HI/LO/SP)·운전음영(markArea RUN/TRIP)·듀얼커서Δ·자동집계/LTTB
- 슬롯 추가 절차, 기존 함수/엔드포인트/스키마, camelCase DTO 등 cold-start 자가완결

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-25 17:46:42 +09:00
parent c7e2250bd3
commit 52ed77efac

View File

@@ -0,0 +1,196 @@
# 트렌드 워크스페이스 P2 — 작업지시서 (LLM 구현용)
> 이 문서 하나로 cold-start 착수 가능. P1은 커밋 `c7e2250`로 완료됨. 같은 단일 ECharts 차트에 **레이어만 추가**하는 구조라 P1 코드 거의 안 건드림.
---
## 0. 전제 — 반드시 먼저 읽기
- **차트 1개에 레이어를 쌓는 구조**다. 새 시각요소 = `① 레이어 함수 + ② TR_LAYERS 등록 1줄 + ③ 토글 버튼 + ④ (필요시) 데이터 로더/엔드포인트`. **차트 본체(`trRender`/`trInitChart`)는 수정 금지.**
- 이미 구현된(참고/복사 대상) 레이어: `layerMinMax`(perSeries), `layerEvents`(global, 트립/이벤트 세로 플래그). **운전음영·알람선은 이걸 그대로 베껴서** 만든다.
- **JSON 직렬화 주의**: 이 프로젝트 MVC는 기본 PascalCase 패스스루다. 새 DTO 반환 시 **반드시 속성마다 `[JsonPropertyName("camelCase")]`** 를 단다(`TrendDtos.cs`가 선례). 안 그러면 프론트(camelCase)와 안 맞음.
- **DB 조회**: EF 미사용, **raw `NpgsqlCommand`** 패턴. `TrendService.cs``GetLiveAsync`/`GetAnalogPointsAsync`를 복사해서 쓴다(`NewConn()` 헬퍼 있음).
- **날짜 입력 id**: 트렌드는 `hf-trfrom`/`hf-trto`(hist의 `hf-from/to`와 충돌 회피). 보이는 윈도우는 `trVisibleWindow()`로 얻는다.
- 빌드: `dotnet build src/Web/ExperionCrawler.csproj -clp:ErrorsOnly` (경고/에러 0 유지). JS: `node -c src/Web/wwwroot/js/trend.js`.
- 정적 파일(html/css/js)은 서버 재기동 없이 새로고침으로 반영. C# 변경은 재빌드+재기동 필요.
---
## 1. 파일 맵
| 영역 | 파일 |
|---|---|
| 프론트 로직 | `src/Web/wwwroot/js/trend.js` |
| 프론트 마크업 | `src/Web/wwwroot/panes/trend.html` (타임바의 `.tr-layers` 안에 토글 추가) |
| 프론트 스타일 | `src/Web/wwwroot/css/trend.css` |
| 백엔드 서비스 | `src/Infrastructure/Trend/TrendService.cs` + `ITrendService.cs`(Core/Application/Interfaces) |
| 백엔드 API | `src/Web/Controllers/TrendController.cs` (`[Route("api/trend")]`) |
| DTO | `src/Core/Application/DTOs/TrendDtos.cs` |
| DB 뷰/테이블 DDL | `src/Infrastructure/Database/ExperionDbContext.cs` (`InitializeAsync` 부팅 DDL) |
## 2. 현재 `trState` / `TR_LAYERS` (trend.js, 그대로 존재)
```js
const trState = {
group, members:[{tag,color,axis,desc,unit,euLo,euHi,hidden}],
seriesData:{tag:[[ms,val],...]}, window:{t0,t1}, selected, live, euAxis, yZoom, normalize,
liveNow:{tag:val},
layers:{ minmax:false, events:false }, // ← P2 토글 키 추가
cache:{ events:[] } // ← P2 캐시 키 추가
};
const TR_LAYERS = [
{ id:'minmax', perSeries: layerMinMax, when: s=>s.layers.minmax },
{ id:'events', global: layerEvents, when: s=>s.layers.events },
// ← 여기에 P2 레이어 push
];
```
- `perSeries(member, state)` → 멤버 시리즈에 머지될 조각 반환(`{markPoint|markLine|markArea|lineStyle...}`).
- `global(state)` → 전역 helper series 1개 반환(markArea/markLine 운반용, `data:[]`). 이름은 `__`로 시작(툴팁 formatter가 `__` 시리즈를 자동 제외함).
- `trRender()`가 켜진 레이어를 모아 1회 `setOption`. 토글은 `trToggleLayer(id)`(이미 있음, events 처리 패턴 참고).
---
## 3. P2 작업 항목 (4개)
### P2-1. 알람 한계선 (HI/LO/SP 수평선) — markLine perSeries
**목적**: 멤버별 EU 상/하한(euhi/eulo)과 현재 SP를 수평 점선으로.
**백엔드**
- DTO (`TrendDtos.cs`), camelCase:
```csharp
public class TrendLimitDto {
[JsonPropertyName("tag")] public string Tag {get;set;}="";
[JsonPropertyName("hi")] public double? Hi {get;set;}
[JsonPropertyName("lo")] public double? Lo {get;set;}
[JsonPropertyName("sp")] public double? Sp {get;set;}
[JsonPropertyName("unit")]public string? Unit {get;set;}
}
```
- `ITrendService` + `TrendService.GetLimitsAsync(IReadOnlyList<string> tags, ct)`:
- 입력 tags는 멤버 태그(예 `ficq-6113.pv`). base_tag = `split_part(tag,'.',1)`.
- SQL(파라미터 배열 `@bases` = base_tag 목록): `v_instrument_range`에서 `unit, eu_lo, eu_hi` + (선택) `realtime_table`에서 `base||'.sp'` 숫자 현재값.
- 컬럼: `v_instrument_range(base_tag, unit, eu_lo, eu_hi)`. SP는 `realtime_table` 에서 `tagname = base||'.sp' AND livevalue ~ '^-?[0-9.]+$'`.
- 반환은 입력 **tag 기준**으로 매핑(hi=eu_hi, lo=eu_lo).
- `TrendController`: `[HttpGet("limits")] Limits([FromQuery] string? tags, ct)` → `GetLiveAsync` 컨트롤러 메서드 복사해서 `tags` split 후 호출.
**프론트 (trend.js)**
- `trState.layers.limits=false`, `trState.cache.limits={}` 추가.
- 로더:
```js
async function trLoadLimits(){
const tags=trState.members.map(m=>m.tag); if(!tags.length)return;
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;
}
```
- 레이어(perSeries) — **100%환산 시 좌표 환산 필요**(`trMemberSpan` 사용; `layerMinMax` 방식 동일):
```js
function layerLimits(m,s){
const lim=s.cache.limits[m.tag]; if(!lim) return {};
let toY=v=>v; if(s.normalize){const sp=trMemberSpan(m); if(sp) toY=v=>(v-sp[0])/sp[1]*100;}
const mk=(v,txt,style)=> v==null?null:{yAxis:toY(v),lineStyle:style,label:{formatter:txt,position:'insideEndTop',fontSize:9}};
const data=[mk(lim.hi,'HI',{color:'#ef4444',type:'dashed'}),
mk(lim.lo,'LO',{color:'#ef4444',type:'dashed'}),
mk(lim.sp,'SP',{color:m.color,type:'dotted'})].filter(Boolean);
return { markLine:{ silent:true, symbol:'none', data } };
}
```
- `TR_LAYERS`에 `{ id:'limits', perSeries:layerLimits, when:s=>s.layers.limits }` 추가.
- `trToggleLayer`의 events 분기처럼: `if(id==='limits'&&trState.layers.limits) await trLoadLimits();` 후 `trRender()`.
- `trQuery()` 끝부분(events 로딩 옆)에 `if(trState.layers.limits) await trLoadLimits();` 추가.
- 토글 버튼(trend.html `.tr-layers`): `<button class="tr-tog" data-layer="limits" onclick="trToggleLayer('limits')">⚠ 알람선</button>`
> 주의: markLine은 `yAxisIndex` 다른 멤버(우축)면 그 축 기준으로 그려진다. perSeries라 멤버 시리즈에 붙으니 자동으로 해당 멤버 축을 따른다.
### P2-2. 운전상태 음영 (RUN 구간 markArea) — global
**목적**: 시간축 배경에 펌프 RUN 구간 초록, TRIP 빨강 음영.
**데이터 출처**: `event_history_table`(컬럼: `tagname, node_id, prev_value, curr_value, event_type{ALARM|TRIP|NORMAL|RUN|CHANGE}, event_time, area, sub_area, duration_seconds, metadata`). RUN 구간 = `event_type='RUN'` 시작 ~ 다음 상태변경(또는 `duration_seconds`).
**백엔드**
- DTO: `TrendBandDto { [tag], [t0], [t1], [state] }` (camelCase, t0/t1은 ISO string 또는 DateTime).
- `GetRunBandsAsync(from,to,area?,ct)`:
- 한 펌프 태그의 시간순 이벤트에서 `RUN`→다음 이벤트 시각까지를 한 밴드로. SQL은 `LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time)`로 다음 시각 산출 후 `event_type IN ('RUN','TRIP')` 행만 밴드화. 윈도우 [from,to]로 클리핑.
- `area` 필터(선택): `area ILIKE '%'||@area||'%'`.
- 반환 `[{tag,t0,t1,state}]` (state='RUN'|'TRIP').
- `TrendController [HttpGet("runbands")]`.
**프론트**
- `trState.layers.runstate=false`, `trState.cache.runbands=[]`.
- 로더 `trLoadRunbands()`: `/api/trend/runbands?from&to` (from/to = `hf-trfrom/hf-trto`), 결과를 `{t0:ms,t1:ms,state}`로 변환 저장.
- 레이어(global):
```js
function layerRunState(s){
if(!s.cache.runbands.length) return null;
const COL={RUN:'rgba(16,185,129,.10)',TRIP:'rgba(239,68,68,.12)'};
const data=s.cache.runbands.map(b=>[{xAxis:b.t0,itemStyle:{color:COL[b.state]||'rgba(148,163,184,.1)'}},{xAxis:b.t1}]);
return { name:'__runstate', type:'line', data:[], silent:true, showSymbol:false, markArea:{ silent:true, data } };
}
```
- `TR_LAYERS` 등록 + `trToggleLayer` 분기(`runstate` 켜질 때 `trLoadRunbands()`) + `trQuery` 끝에 조건 로딩 + 토글 버튼 `▒ 운전음영`.
### P2-3. 듀얼 커서 Δ (두 점 클릭 → Δt·Δy·기울기)
**프론트 only**(백엔드 없음).
- 상태: `trState.cursor={a:null,b:null}` (각 `{ms,val,tag}` 또는 좌표).
- ECharts zr 클릭 사용:
```js
trChart.getZr().on('click', e=>{
if(!trState.cursorMode) return;
const pt=trChart.convertFromPixel({xAxisIndex:0,yAxisIndex:0},[e.offsetX,e.offsetY]);
// pt=[ms,val]; a→b 순으로 채우고 두 점 채워지면 graphic 으로 Δ 표시
});
```
- 토글 버튼 `📐 Δ커서` → `trState.cursorMode` on/off. 두 점 찍히면 `graphic`(또는 별도 markLine 2개 + 텍스트)으로 `Δt=…s, Δy=…, 기울기=Δy/Δt /min` 표시. 세 번째 클릭은 리셋.
- 보이는 윈도우/통계와 독립. 구현 자유.
### P2-4. 자동 집계 + LTTB 다운샘플
**현재 상태**: `trQuery()`가 raw `/api/history/query`(limit 5000)만 쓴다(P1에서 단순화됨). 긴 구간은 5000행에서 잘림.
- **자동 집계 복구**: 타임바 `#tr-interval` 셀렉트(`auto/1 minute/5 minutes/...`)는 trend.html에 이미 있다. `trQuery`에서:
```js
let interval=document.getElementById('tr-interval').value;
if(interval==='auto') interval = trAutoInterval(from,to); // 구현 추가
if(interval==='1 minute'){ /* 기존 raw 경로 */ }
else { /* POST /api/text-to-sql/query-history-interval {tagNames,from,to,interval,limit:5000} → rows[].timeBucket */ }
```
- `trAutoInterval(from,to)`: 창 길이 h시간 → `h<=6?'1 minute':h<=48?'5 minutes':h<=24*14?'1 hour':'1 day'`.
- 집계 응답은 `rows[].timeBucket`(원시는 `recordedAt`), 값은 `rows[].values[tag]`. (기존 hist.js와 동일 계약)
- **LTTB**: ECharts line series에 `sampling:'lttb'` 추가. `layerBaseSeries` 반환에 `sampling:'lttb'` 한 줄. (대용량 렌더 경량화, 형태 보존)
> 백엔드 `/api/text-to-sql/query-history-interval`는 이미 존재(재사용). 신규 백엔드 불필요.
---
## 4. 공통 통합 체크 (각 레이어 추가 시)
1. `trState.layers.<id>` 기본 false, 필요시 `trState.cache.<id>` 초기화.
2. `TR_LAYERS`에 1줄 등록(`perSeries` 또는 `global` + `when`).
3. `trToggleLayer(<id>)`에서 켜질 때 데이터 로더 await 후 `trRender()`.
4. `trQuery()` 말미에 `if(trState.layers.<id>) await trLoad…()` (새 조회 시 갱신).
5. trend.html `.tr-layers`에 토글 버튼(`data-layer="<id>"`), css `.tr-tog.on` 스타일 이미 있음.
6. 100%환산(`trState.normalize`) 호환: y값 좌표 쓰는 레이어는 `trMemberSpan(m)`로 환산(P2-1 참고).
## 5. 검증
- `dotnet build … -clp:ErrorsOnly` 0/0, `node -c trend.js` OK.
- 새 엔드포인트 스모크(서버 :5000): `curl -s 'http://localhost:5000/api/trend/limits?tags=ficq-6113.pv'` → camelCase JSON.
- 브라우저(Ctrl+F5): 각 토글 ON/OFF, 100%환산·Y줌·라이브와 동시 동작, 토글 OFF 시 잔상 없음.
- 회귀: 기존 P1(그룹/dataZoom/범례/minmax/events/라이브/normalize) 정상.
## 6. 커밋
- 메시지 스타일(한국어): `feat: 트렌드 P2 — 알람선·운전음영·듀얼커서·자동집계`.
- 끝에 `Co-Authored-By: Claude <noreply@anthropic.com>` 트레일러.
- 트렌드 관련 파일만 스테이징(무관 산출물 `opencode.json`·`*.xlsx`·`*.bak`·`*.md` 잡파일 제외).
## 7. 참고 (DB 스키마 요약)
- `v_analog_points(tagname, base_tag, value, timestamp, unit, eu_lo, eu_hi, description, area)` — 숫자 livevalue=아날로그.
- `v_instrument_range(base_tag, unit, eu_lo, eu_hi)`.
- `v_tag_summary(base_tag, pv, sp, op, …, description, area, sub_area)`.
- `event_history_table(tagname, prev_value, curr_value, event_type, event_time, area, sub_area, duration_seconds, metadata)` — event_type ∈ ALARM/TRIP/NORMAL/RUN/CHANGE.
- `realtime_table(tagname, node_id, livevalue, timestamp)` — 현재값(enum은 `{n | LABEL | }`, 숫자는 그대로).
- 기존 이력 API: `GET /api/history/query?tagNames=&from=&to=&limit=`(rows[].recordedAt), `POST /api/text-to-sql/query-history-interval`(rows[].timeBucket).