diff --git a/plans/트렌드-P2-작업지시서.md b/plans/트렌드-P2-작업지시서.md new file mode 100644 index 0000000..aa6b0ce --- /dev/null +++ b/plans/트렌드-P2-작업지시서.md @@ -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 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`): `` + +> 주의: 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.` 기본 false, 필요시 `trState.cache.` 초기화. +2. `TR_LAYERS`에 1줄 등록(`perSeries` 또는 `global` + `when`). +3. `trToggleLayer()`에서 켜질 때 데이터 로더 await 후 `trRender()`. +4. `trQuery()` 말미에 `if(trState.layers.) await trLoad…()` (새 조회 시 갱신). +5. trend.html `.tr-layers`에 토글 버튼(`data-layer=""`), 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 ` 트레일러. +- 트렌드 관련 파일만 스테이징(무관 산출물 `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).