- 알람선(markLine HI/LO/SP)·운전음영(markArea RUN/TRIP)·듀얼커서Δ·자동집계/LTTB - 슬롯 추가 절차, 기존 함수/엔드포인트/스키마, camelCase DTO 등 cold-start 자가완결 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 KiB
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.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, 그대로 존재)
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).
- 입력 tags는 멤버 태그(예
TrendController:[HttpGet("limits")] Limits([FromQuery] string? tags, ct)→GetLiveAsync컨트롤러 메서드 복사해서tagssplit 후 호출.
프론트 (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.cursorModeon/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. 공통 통합 체크 (각 레이어 추가 시)
trState.layers.<id>기본 false, 필요시trState.cache.<id>초기화.TR_LAYERS에 1줄 등록(perSeries또는global+when).trToggleLayer(<id>)에서 켜질 때 데이터 로더 await 후trRender().trQuery()말미에if(trState.layers.<id>) await trLoad…()(새 조회 시 갱신).- trend.html
.tr-layers에 토글 버튼(data-layer="<id>"), css.tr-tog.on스타일 이미 있음. - 100%환산(
trState.normalize) 호환: y값 좌표 쓰는 레이어는trMemberSpan(m)로 환산(P2-1 참고).
5. 검증
dotnet build … -clp:ErrorsOnly0/0,node -c trend.jsOK.- 새 엔드포인트 스모크(서버 :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).