Files
ExperionCrawler/plans/트렌드-P2-작업지시서.md
windpacer 52ed77efac 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>
2026-05-25 17:46:42 +09:00

12 KiB

트렌드 워크스페이스 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.csGetLiveAsync/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, 그대로 존재)

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:
    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={} 추가.
  • 로더:
    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 방식 동일):
    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):
    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 클릭 사용:
    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에서:
    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).