From 18963455f21b72e857a68175694dbd58a3a01808 Mon Sep 17 00:00:00 2001 From: windpacer Date: Wed, 10 Jun 2026 16:56:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Steam=20=EC=98=A8=EB=8F=84=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=ED=92=88=EB=AA=85=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=A0=95=EC=9D=98=20+=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EB=A7=A4=EC=B9=AD=20=ED=8F=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제품 라벨(P0/P1)에 MSDS 화학물질명(PMA/PGMEA/EL) 매핑 레이어 추가 (product_labels.json, 플랜트=컬럼별). 통계 기준밴드는 데이터 산출 유지 - 운전원용 ⚙제품명 설정 모달: GET/POST /api/steam/productlabels/{col} - 온도기반 자동매칭 폐지 → 운전원이 고른 제품만 기준밴드로 비교, 미선택 시작 - 드롭다운: 탭 진입/컬럼변경 시 즉시 채움, 선택은 플랜트별 localStorage 유지 - 중복 '제품(수동)' 배지 제거(헤더 줄바꿈 해소) - 설계·검수 문서 추가 Co-Authored-By: Claude Opus 4.8 --- ...m-온도프로파일-제품-사용자정의-수정방안.md | 845 ++++++++++++++++++ scripts/analysis/gen_temp_profiles.py | 3 +- scripts/analysis/product_labels.json | 12 + .../Controllers/SteamAdvisorController.cs | 169 +++- src/Hc900Crawler/wwwroot/js/steam.js | 157 +++- src/Hc900Crawler/wwwroot/panes/steam.html | 52 +- 6 files changed, 1211 insertions(+), 27 deletions(-) create mode 100644 docs/steam-온도프로파일-제품-사용자정의-수정방안.md create mode 100644 scripts/analysis/product_labels.json diff --git a/docs/steam-온도프로파일-제품-사용자정의-수정방안.md b/docs/steam-온도프로파일-제품-사용자정의-수정방안.md new file mode 100644 index 0000000..d8c531c --- /dev/null +++ b/docs/steam-온도프로파일-제품-사용자정의-수정방안.md @@ -0,0 +1,845 @@ +# Steam Advisory 온도프로파일 — 제품 사용자정의 수정방안 + +> 작성일: 2026-06-10 +> 목적: 온도프로파일의 **제품(P0/P1/P2) 자동 결정**을 ① 화학물질명(MSDS) 기반으로 표기하고 +> ② 사용자 정의 가능하게 하며 ③ 런타임 자동선택을 **사용자 수동선택으로 오버라이드** 가능하게 한다. +> 참조 제품명 출처: [`docs/MSDS_PMA_PGMEA_EL.md`](MSDS_PMA_PGMEA_EL.md) + +--- + +## 1. 현황 (As-Is) + +### 1.1 데이터 흐름 + +``` +gen_temp_profiles.py (KMeans 클러스터) + └─▶ scripts/analysis/{col}_tempref.json (products[].label = "P0","P1","P2") + │ + ▼ +SteamAdvisorController.ComputeStages() ← reb_temp 최근접 클러스터 자동매칭 + └─▶ GET /api/steam/tempprofile/{col} → { matchedProduct: "P0", products:[...] } + │ + ▼ +steam.js / steam.html → 배지 "제품 P0" (읽기전용, 자동) +``` + +### 1.2 핵심 코드 위치 + +| 단계 | 파일 | 위치 | 동작 | +|---|---|---|---| +| 제품 라벨 생성 | `scripts/analysis/gen_temp_profiles.py` | L55-64 | `reb_temp` 1D KMeans(k=3→2→1) 클러스터링, median 오름차순으로 `label=f"P{rank}"` 부여. 라인 64 주석에 *"추후 PM/PGMEA/EL 등 화학 라벨 매핑"* 명시 | +| 기준밴드 저장 | 〃 | L61-77 | 제품별 단별(reb_temp·T_B·T_C·T_D) median/std + vacuum → `{col}_tempref.json` | +| 런타임 자동매칭 | `src/Hc900Crawler/Controllers/SteamAdvisorController.cs` | L400-407 | `ComputeStages`에서 현재 `reb_temp`와 각 제품 `reb_temp.median` 차이 최소 제품을 자동선택 | +| API 응답 | 〃 | L184-226 (`TempProfile`), L231-… (`TempProfileHistory`) | `matchedProduct = prod?.Label`, `products = tref.Products` 반환 | +| UI 표시 | `src/Hc900Crawler/wwwroot/js/steam.js` | L204-211 (`stTempUpdateBadges`) | `제품 {matchedProduct}` 배지. **선택 UI 없음 — 자동값 그대로 표시** | +| UI 마크업 | `src/Hc900Crawler/wwwroot/panes/steam.html` | L66 | `제품 —` (정적 배지) | + +### 1.3 현재 클러스터 실측 (참고) + +| 컬럼 | n | P0 (저온) | P1 (고온) | 비고 | +|---|---|---|---|---| +| C-6111 | 2 | reb 84.8 / Tc 84.1 / vac 113 | reb 87.5 / Tc 85.5 / vac 113 | 감압운전이라 절대온도가 MSDS 상압비점(146/154℃)과 다름 | +| C-6211 | 2 | reb 85.4 / vac 113 | reb 90.4 / vac 113 | | +| C-8111 | 2 | reb 93.4 / vac 50 | reb 94.9 / vac 50 | | + +> ⚠ 감압 운전이므로 reb_temp 절대값만으로는 화학물질을 단정할 수 없다. +> **클러스터→화학물질 매핑은 오퍼레이터 지식 기반의 사용자 정의가 필수.** + +### 1.4 문제점 + +1. 라벨 `P0/P1/P2`는 의미 불명 — 오퍼레이터는 화학물질명(PMA/PGMEA/EL)으로 인식. +2. 제품 개수·경계가 KMeans로 **자동 결정**되어 운전 실제와 어긋날 수 있고, 사용자가 교정할 수 없음. +3. 런타임 매칭이 `reb_temp` 최근접 하나로 **자동 고정** — 전환구간·조성변동 시 오판 가능하나 오퍼레이터가 수동으로 기준 제품을 바꿀 수단이 없음. + +--- + +## 2. 변경 요구 (To-Be) + +| # | 요구 | 해석 | +|---|---|---| +| A | 제품명을 MSDS 기준으로 | `P0/P1/P2` → `PMA` / `PGMEA` / `EL` 등 화학물질명 표기 | +| B | 제품 정의를 사용자 정의로 | 자동 클러스터 결과를 사용자가 라벨·개수·매핑을 정의/교정 가능 | +| C | 자동선택 → 사용자 선택 우선 | 기본은 온도기반 자동매칭, 사용자가 제품을 고르면 그 제품 기준밴드로 비교 | + +MSDS 제품 후보 (`docs/MSDS_PMA_PGMEA_EL.md`): + +| 명칭 | CAS | 상압 비점 | 비고 | +|---|---|---|---| +| PMA | 108-65-6 | 146 ℃ | PGMEA와 동일 물질 | +| PGMEA | 108-65-6 | 146 ℃ | 반도체 등급 명칭(=PMA) | +| EL (Ethyl Lactate) | 97-64-3 | 154 ℃ | 그린 용제 | + +--- + +## 3. 수정방안 + +설계 원칙: **통계 기준밴드(median/σ)는 데이터에서 산출(유지)** 하고, **표시명·사용자 의도는 별도 매핑 레이어**로 분리한다. tempref.json의 클러스터 통계는 그대로 두고 그 위에 사용자 정의를 얹는다. + +### 3.A 제품명 MSDS 매핑 (라벨 레이어 분리) + +신규 매핑 파일 도입 — 클러스터(`P0/P1/P2`)에 화학물질명을 사용자가 부여: + +`scripts/analysis/product_labels.json` (전체 컬럼 한 파일, 사람이 편집) +```json +{ + "C-6111": [ + { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" }, + { "cluster": "P1", "name": "EL", "casNo": "97-64-3" } + ], + "C-6211": [ + { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" }, + { "cluster": "P1", "name": "PGMEA", "casNo": "108-65-6" } + ] +} +``` + +변경: +1. **`gen_temp_profiles.py` L63-70** — `label`(=`P{rank}`)은 **클러스터 키로 유지**(통계 식별자). 출력 product에 `name` 필드를 추가하되 기본값은 라벨과 동일(매핑 없을 때 폴백). → 재생성해도 사용자 매핑 보존. +2. **컨트롤러 `LoadTempRef` 직후 머지** — `product_labels.json`을 읽어 각 product의 `name`을 cluster 매칭으로 채움. (`SteamAdvisorController.cs` L187 부근, `TempRef`/`TempProduct`에 `Name` 필드 추가 — L500-507) +3. **API 응답** — `matchedProduct`를 `prod?.Name ?? prod?.Label`로 변경 (L201, L281). `products[]`에도 `name` 포함. +4. **UI** — 배지·툴팁·드롭다운이 `name` 표시 (steam.js L207, L267-268). + +> 매핑 파일이 없으면 기존처럼 `P0/P1/P2`로 그대로 동작(하위호환). + +### 3.B 사용자 정의 제품 (클러스터 교정) + +3.A의 `product_labels.json`이 1차 수단(라벨·개수 매핑). 더 나아가 **경계/개수까지 사용자 지정**이 필요하면: + +- `gen_temp_profiles.py`에 수동 모드 추가: `--manual-bands "C-6111:84-86,86-89"` 또는 컬럼별 정의를 `product_labels.json`에 `rebRange:[lo,hi]`로 기입 → KMeans 대신 사용자 밴드로 그룹핑. +- 통계(median/σ)는 사용자 밴드 내 데이터로 재산출. + +> 1단계에서는 **3.A(라벨 매핑)만으로 요구 충족** 가능. 경계 재정의(3.B 확장)는 운전팀이 KMeans 결과에 불만족할 때 후속으로. + +권장: 라벨 편집을 위한 경량 관리 UI는 `setup` 페인에 “제품명 매핑” 카드로 추가(선택). 우선은 JSON 직접편집으로 시작. + +### 3.C 사용자 수동선택 오버라이드 (런타임) + +런타임 자동매칭을 기본값으로 두되 **수동 선택 시 우선**: + +1. **API** — `TempProfile`/`TempProfileHistory`에 `?product=` 쿼리 추가 (L184, L231). + - 값 있으면 `ComputeStages`가 자동 최근접 대신 **지정 제품**의 기준밴드로 z-score·이격 계산. + - `ComputeStages` 시그니처에 `TempProduct? forced` 추가 (L400) — `forced ?? 자동매칭`. +2. **응답에 매칭 출처 표기** — `matchSource: "auto" | "manual"` 추가 → UI가 “자동/수동” 표시. +3. **UI (steam.html L62-76 / steam.js)**: + - `st-temp-product` 정적 배지를 ` + + +``` + +#### 4.C-2 `steam.js` — 전역 상태 변수 추가 (L82 부근) + +```javascript +// 기존 L82 부근에 신규: +const ST_TEMP_COLS = [['C-6111','6-1차'],['C-6211','6-2차'],['C-8111','8차'],['C-9111','9-1차'],['C-9211','9-2차'],['C-10111','10-1차'],['C-10211','10-2차']]; +const ST_STAGE_LABEL = { reb_temp:'reb-A(보텀)', T_B:'T_B', T_C:'T_C(민감단)', T_D:'T_D(탑)' }; +const stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1); +const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + +// ── 신규: 제품 선택 상태 ── +let stTempSelectedProduct = '__auto__'; // '__auto__' 또는 제품명("PMA","EL","PGMEA") +let stTempMatchSource = 'auto'; // API 응답에서 받은 matchSource +``` + +#### 4.C-3 `steam.js` `stTempInit` — 드롭다운 옵션 채우기 + 이벤트 바인딩 (L92-111) + +```javascript +function stTempInit() { + const sel = document.getElementById('st-temp-col'); + if (sel && !sel.options.length) + sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => ``).join(''); + + // ── 신규: 제품 선택 드롭다운 ── + const prodSel = document.getElementById('st-temp-prod-sel'); + if (prodSel) { + prodSel.onchange = () => { + stTempSelectedProduct = prodSel.value; + stTempLoad(); // 선택 변경 시 재조회 + }; + } + // ──────────────────────────────── + + document.getElementById('st-temp-date').value = new Date().toISOString().split('T')[0]; + document.getElementById('st-temp-date').onchange = stTempLoad; + document.getElementById('st-temp-load').onclick = stTempLoad; + ... +} +``` + +#### 4.C-4 `steam.js` `stTempLoad` — 선택 제품을 URL에 전달 (L113-123) + +```javascript +// 기존 L113-123 (변경 전): +async function stTempLoad() { + if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; } + stTempSnapshots = []; + stTempLive = null; + stTempIsLive = true; + try { + stTempLive = await api('GET', `/api/steam/tempprofile/${document.getElementById('st-temp-col').value}`); + } catch (_) {} + await stTempHistLoad(); + stTempTimer = setInterval(stTempTick, 5000); +} + +// 변경 후: +async function stTempLoad() { + if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; } + stTempSnapshots = []; + stTempLive = null; + stTempIsLive = true; + try { + const col = document.getElementById('st-temp-col').value; + let url = `/api/steam/tempprofile/${col}`; + if (stTempSelectedProduct !== '__auto__') + url += `?product=${encodeURIComponent(stTempSelectedProduct)}`; + stTempLive = await api('GET', url); + } catch (_) {} + await stTempHistLoad(); + stTempTimer = setInterval(stTempTick, 5000); +} +``` + +#### 4.C-5 `steam.js` `stTempTick` — 제품 선택 유지 + matchSource 반영 (L125-137) + +```javascript +// 기존 L125-137 (변경 전): +async function stTempTick() { + const col = document.getElementById('st-temp-col').value; + const st = document.getElementById('st-temp-status'); + try { + stTempLive = await api('GET', `/api/steam/tempprofile/${col}`); + if (stTempIsLive) { + stRenderTemp(); + } + st.textContent = '갱신: ' + new Date().toLocaleTimeString(); + } catch (e) { + st.textContent = '오류: ' + e.message; + } +} + +// 변경 후: +async function stTempTick() { + const col = document.getElementById('st-temp-col').value; + const st = document.getElementById('st-temp-status'); + try { + let url = `/api/steam/tempprofile/${col}`; + if (stTempSelectedProduct !== '__auto__') + url += `?product=${encodeURIComponent(stTempSelectedProduct)}`; + stTempLive = await api('GET', url); + if (stTempLive) { + stTempMatchSource = stTempLive.matchSource || 'auto'; + } + if (stTempIsLive) { + stRenderTemp(); + } + st.textContent = '갱신: ' + new Date().toLocaleTimeString() + + (stTempMatchSource === 'manual' ? ' (수동)' : ''); + } catch (e) { + st.textContent = '오류: ' + e.message; + } +} +``` + +#### 4.C-6 `steam.js` `stTempHistLoad` — 히스토리에도 제품 전달 (L139-184) + +```javascript +// 기존 L160 (변경 전): +const url = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`; + +// 변경 후: +let histUrl = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`; +if (stTempSelectedProduct !== '__auto__') + histUrl += `&product=${encodeURIComponent(stTempSelectedProduct)}`; +const url = histUrl; +``` + +#### 4.C-7 `steam.js` `stTempUpdateBadges` — 제품명 + matchSource 표시 (L203-212) + +```javascript +// 기존 L203-212 (변경 전): +function stTempUpdateBadges(src) { + const s = src || stTempLive; + if (!s) return; + document.getElementById('st-temp-product').textContent = '제품 ' + (s.matchedProduct || '—'); + const vb = document.getElementById('st-temp-vac'); + vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : ''); + vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a'; + vb.style.color = s.vacuum.deviated ? '#f66' : '#66f'; +} + +// 변경 후: +function stTempUpdateBadges(src) { + const s = src || stTempLive; + if (!s) return; + + // 제품명 배지 → 드롭다운 값 동기화 + const prodSel = document.getElementById('st-temp-prod-sel'); + if (prodSel) { + const matchedName = s.matchedProduct || '—'; + const matchLabel = stTempMatchSource === 'manual' ? `${matchedName} (수동)` : matchedName; + // 드롭다운에서 현재 선택값 유지 (자동매칭 결과가 선택과 다르면 드롭다운 값 변경 안함) + if (stTempSelectedProduct === '__auto__') { + // 자동 모드: 매칭 결과를 표시 + const badge = prodSel.parentElement.querySelector('.st-badge') || + document.getElementById('st-temp-product'); + if (badge) badge.textContent = '제품 ' + matchedName; + } + } + + const vb = document.getElementById('st-temp-vac'); + vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : ''); + vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a'; + vb.style.color = s.vacuum.deviated ? '#f66' : '#66f'; +} +``` + +#### 4.C-8 `steam.js` `stTempLive` 응답 수신 시 드롭다운 옵션 동적 생성 + +`stTempLoad`와 `stTempTick`에서 API 응답 수신 후 제품 옵션을 드롭다운에 추가: + +```javascript +// stTempLoad / stTempTick 내부, API 응답 수신 직후 추가: + +// ── 신규: 드롭다운 옵션 갱신 ── +function stUpdateProdDropdown(resp) { + const prodSel = document.getElementById('st-temp-prod-sel'); + if (!prodSel || !resp || !resp.products) return; + + const currentVal = prodSel.value; + const options = resp.products.map(p => + `` + ).join(''); + + prodSel.innerHTML = `` + options; + + // 이전 선택값이 옵션에 있으면 유지, 없으면 [자동]으로 + if (prodSel.querySelector(`option[value="${currentVal}"]`)) { + prodSel.value = currentVal; + } else { + prodSel.value = '__auto__'; + stTempSelectedProduct = '__auto__'; + } +} +// ───────────────────────────────── +``` + +`stTempLoad`와 `stTempTick`의 API 응답 처리 끝에 호출: + +```javascript +// stTempLoad 내부: +stTempLive = await api('GET', url); +if (stTempLive) stUpdateProdDropdown(stTempLive); // ← 추가 + +// stTempTick 내부: +stTempLive = await api('GET', url); +if (stTempLive) { + stTempMatchSource = stTempLive.matchSource || 'auto'; + stUpdateProdDropdown(stTempLive); // ← 추가 +} +``` + +#### 4.C-9 `steam.js` `stRenderTemp` — 차트 제목에 matchSource 표시 (L264-268) + +```javascript +// 기존 L266-268 (변경 전): +title: { + text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`, + subtext: snap + ? `매칭제품 ${snap.matchedProduct || '—'} · 진공 ${stFmt(snap.vacuum?.current)} ...` + : `매칭제품 ${stTempLive.matchedProduct || '—'} · 진공 ${stFmt(stTempLive.vacuum.current)} ...`, + +// 변경 후: +title: { + text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`, + subtext: snap + ? `매칭제품 ${snap.matchedProduct || '—'} (${snap.matchSource || 'auto'}) · 진공 ${stFmt(snap.vacuum?.current)} ...` + : `매칭제품 ${stTempLive.matchedProduct || '—'} (${stTempMatchSource}) · 진공 ${stFmt(stTempLive.vacuum.current)} ...`, +``` + +#### 4.C-10 `steam.js` `stTempScrub` — 스크럽 시 제품 선택 유지 (L191-201) + +```javascript +// 기존 L191-201 (변경 전): +function stTempScrub() { + const idx = parseInt(document.getElementById('st-scrub-slider').value); + const snap = stTempSnapshots[idx]; + if (!snap) return; + stTempIsLive = false; + document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.ts); + stRenderTemp(snap); +} + +// 변경 후 (변경 없음 — stTempSnapshots가 이미 선택 제품 기준으로 로드되었으므로 그대로 동작) +// 다만 stRenderTemp에서 snap.matchSource를 사용하도록 4.C-9에서 수정했으므로 자동 반영됨 +``` + +--- + +### 4.D 변경 파일 요약 + +| # | 파일 | 변경 내용 | 라인 | +|---|------|----------|------| +| D1 | `scripts/analysis/product_labels.json` | **신규** — 컬럼별 cluster→화학물질명 매핑 | 신규 | +| D2 | `scripts/analysis/gen_temp_profiles.py` | product에 `name` 필드 추가 (기본값=label) | L63-70 | +| D3 | `SteamAdvisorController.cs` | `TempProduct.Name` 필드 추가 | L500-507 | +| D4 | `SteamAdvisorController.cs` | `MergeProductLabels()` 헬퍼 추가 | TagsFor 앞에 | +| D5 | `SteamAdvisorController.cs` | `TempProfile`에 `?product=` 파라미터 + forced 전달 | L184-226 | +| D6 | `SteamAdvisorController.cs` | `TempProfileHistory`에 `?product=` 파라미터 + forced 전달 | L231-314 | +| D7 | `SteamAdvisorController.cs` | `ComputeStages`에 `forced` 파라미터 추가 | L400-407 | +| D8 | `SteamAdvisorController.cs` | `matchSource` 필드를 TempProfile/History 응답에 추가 | L197, L278 | +| D9 | `steam.html` | `st-temp-product` 배지 → `st-temp-prod-sel` 드롭다운 | L66 | +| D10 | `steam.js` | `stTempSelectedProduct`, `stTempMatchSource` 전역 변수 | L82 부근 | +| D11 | `steam.js` | `stUpdateProdDropdown()` — 드롭다운 옵션 동적 생성 | 신규 | +| D12 | `steam.js` | `stTempInit` — 드롭다운 이벤트 바인딩 | L92-111 | +| D13 | `steam.js` | `stTempLoad` — URL에 `?product=` 전달 | L113-123 | +| D14 | `steam.js` | `stTempTick` — URL에 `?product=` 전달 + matchSource 갱신 | L125-137 | +| D15 | `steam.js` | `stTempHistLoad` — URL에 `?product=` 전달 | L160 | +| D16 | `steam.js` | `stTempUpdateBadges` — matchSource 표시 | L203-212 | +| D17 | `steam.js` | `stRenderTemp` — 차트 제목에 `(auto/manual)` 표시 | L266-268 | + +--- + +## 4. 작업 순서 · 영향 파일 + +| 단계 | 작업 | 파일 | +|---|---|---| +| 1 | `product_labels.json` 스키마 확정 + C-6111 등 초기 매핑 작성 | `scripts/analysis/product_labels.json` (신규) | +| 2 | tempref product에 `name` 필드(폴백=label) | `scripts/analysis/gen_temp_profiles.py` L63-70 | +| 3 | `TempRef`/`TempProduct`에 `Name` 추가 + LoadTempRef 머지 | `SteamAdvisorController.cs` L187, L500-507 | +| 4 | `matchedProduct`=name, `products[].name` 반환 | 〃 L201, L207, L281 | +| 5 | `?product=` 오버라이드 + `ComputeStages(forced)` + `matchSource` | 〃 L184, L231, L400-407 | +| 6 | 배지→드롭다운, 선택상태 유지, 요청 URL에 product 전달 | `steam.html` L66, `steam.js` L113-201, L204-211 | +| 7 | (선택) setup 페인 제품명 매핑 관리 UI | `panes/setup.html`, `SetupController.cs` | + +빌드/테스트: `cd src/Hc900Crawler && dotnet build` 후 온도프로파일 탭에서 드롭다운·배지·밴드 재렌더 확인. + +--- + +## 5. 확인 필요 (Open Questions) + +1. **컬럼별 제품 매핑 실제값** — 각 컬럼(C-6111…C-10211)의 클러스터(P0/P1/…)가 실제로 어떤 화학물질(PMA/PGMEA/EL)인지 운전팀 확인 필요. (감압 reb_temp만으로 자동 단정 불가 — §1.3) +2. **PMA vs PGMEA 표기** — 동일물질(CAS 108-65-6)인데 둘 다 쓸지, 하나로 통일할지. +3. **사용자 선택 지속성** — 수동선택을 세션 한정으로 둘지, 서버에 저장(컬럼별 마지막 선택)할지. +4. **경계 재정의(3.B 확장)** 필요 여부 — 1단계 라벨매핑만으로 충분한지 운전팀 피드백 후 결정. +5. **advisory(라이브 OP 권장) 영향 범위** — 본 변경은 온도프로파일 탭 한정. 라이브 advisory(`Predict`)의 product 입력(연속 유량값)과는 별개임을 확인. + +--- + +## 6. 검수 및 진단 (Code Review) + +> 검수일: 2026-06-10 · 대상: §4 상세 구현 코드 · 방법: 실제 소스(`SteamAdvisorController.cs`, `steam.js` L84-86, `gen_temp_profiles.py`)와 1:1 대조. +> 사전 확인 사실 — 컨트롤러에 `_config`(IConfiguration)·`_db`(IExperionDbService) 주입 존재(L18-28), `LoadTempRef`도 동일 `SteamAdvisor:ModelDir` 사용(L326), `escHtml`/`stFmt`/`stFmtDT`는 steam.js L84-86에 **이미 정의됨**. + +### 6.1 판정 요약 + +| # | 심각도 | 위치 | 한줄 진단 | +|---|---|---|---| +| F1 | 🔴 BLOCKER | §4.A-4 | `foreach` 반복변수 `p` 재할당 → **컴파일 에러**(CS1656) + 설령 통과해도 List 미반영(레코드 불변) | +| F2 | 🔴 BLOCKER | §4.A-5 | `Name` 기본값 `""` + `prod?.Name ?? prod?.Label` → 빈문자열은 null이 아니라 폴백 안됨 → **기존 7개 tempref.json에서 제품명 공란** | +| F3 | 🔴 BLOCKER | §4.C-1+4.C-7 | 제거된 `st-temp-product` 참조 + 폴백 `querySelector('.st-badge')`가 **진공배지(st-temp-vac)를 덮어씀** | +| F4 | 🟠 HIGH | §4.B-2/4.B-3 | 미매칭(오타)인데 `matchSource="manual"` 오표기 (forced=null인데 manual) | +| F5 | 🟡 MED | §4.C-8 | 5초 틱마다 `select.innerHTML` 재생성 → 드롭다운 깜빡임/사용자 조작 방해 | +| F6 | 🟡 MED | §4.A-6 ↔ §4.B-2 | `matchSource` 하드코딩(4.A-6) vs 변수(4.B-2) 중복 — 4.A-6만 적용 시 항상 "auto" | +| F7 | 🟢 LOW | §4.C-2 | `escHtml`/`stFmt`는 이미 존재 → 코드블록 그대로 붙이면 `const` 중복선언 에러 | +| F8 | 🟢 LOW | 문서 | `## 5` 헤딩 2개 중복(L726, L742) — 앞 표는 "작업 순서"인데 "확인 필요"로 오기 | + +### 6.2 블로커 상세 + 수정 코드 + +**F1 — `MergeProductLabels` foreach 재할당 (§4.A-4)** + +```csharp +// ❌ 문서 코드: 컴파일 안 됨 (foreach 변수는 읽기전용) + 레코드라 List 미반영 +foreach (var p in tref.Products) { + var match = mapping.FirstOrDefault(m => m.Cluster == p.Label); + if (match.Name != null) p = p with { Name = match.Name }; // CS1656 +} +return tref; + +// ✅ 수정: 새 리스트 재구성 후 record-with로 교체 +var newProducts = tref.Products.Select(p => { + var match = mapping.FirstOrDefault(m => m.Cluster == p.Label); + return string.IsNullOrEmpty(match.Name) ? p : p with { Name = match.Name }; +}).ToList(); +return tref with { Products = newProducts }; +``` + +**F2 — `Name` 폴백이 빈문자열에서 깨짐 (§4.A-5)** + +`TempProduct.Name`의 기본값이 `""`(§4.A-3)인데, 기존 7개 `*_tempref.json`에는 `name` 필드가 없어 역직렬화 후 `Name=""`. `prod?.Name ?? prod?.Label`은 `??`가 **null에만** 폴백하므로 `""`(빈문자열)이 그대로 반환 → **UI 제품명 공란**. (gen_temp_profiles.py로 재생성하기 전까지 전 컬럼 영향) + +```csharp +// ❌ 문서 코드 (L276, L279, L293, L304, L380, L427) +matchedProduct = prod?.Name ?? prod?.Label, + +// ✅ 수정: 빈문자열까지 폴백 (헬퍼 1개로 통일 권장) +matchedProduct = string.IsNullOrEmpty(prod?.Name) ? prod?.Label : prod!.Name, +``` +> 또는 §4.A-3에서 `Name` 기본값을 `null`로(`public string? Name { get; init; }`) 두면 `??` 그대로 동작. 둘 중 하나 택일. + +**F3 — UI 배지/드롭다운 충돌 (§4.C-1 + §4.C-7)** + +`st-temp-product` span을 select로 교체(4.C-1)했는데 4.C-7이 그 id를 다시 찾고, 폴백 `prodSel.parentElement.querySelector('.st-badge')`는 같은 바의 **진공 배지(`st-temp-vac`, class `st-badge st-badge-conf`)**를 선택해 "제품 …"으로 덮어씀 → 진공 표시 파괴. 또한 자동모드에서 매칭 제품명을 보여줄 자리가 사라짐(설계 공백). + +```html + + +제품 — +``` +```javascript +// ✅ 4.C-7: 전용 배지만 갱신 (querySelector 폴백 삭제) +const badge = document.getElementById('st-temp-product'); +if (badge) badge.textContent = '제품 ' + matchedName + (stTempMatchSource === 'manual' ? ' (수동)' : ''); +``` + +### 6.3 그 외 수정 + +- **F4**: `string matchSrc = forced != null ? "manual" : "auto";` (product 지정했어도 실제 매칭됐을 때만 manual). §4.B-2·§4.B-3 양쪽. +- **F5**: 드롭다운 옵션 구성은 **컬럼 변경/최초 로드 시 1회**만. `stTempTick`(5초)에서는 `stUpdateProdDropdown` 호출 제거(또는 옵션 동일 시 no-op). 매 틱 innerHTML 재작성 금지. +- **F6**: 문서에 *"§4.A-6은 §4.B-2/4.B-3로 대체됨(최종본은 변수 `matchSrc`)"* 명시. 구현 시 4.A-6 하드코딩 블록 생략. +- **F7**: §4.C-2에서 **신규 2줄(`let stTempSelectedProduct`, `let stTempMatchSource`)만 추가**. `const stFmt`/`escHtml`은 이미 steam.js L84-85에 있으니 재선언 금지(컨텍스트 표기일 뿐). +- **F8**: `## 5` 중복 헤딩 정리 — 앞(L726)은 `## 4. 작업 순서·영향 파일`로, 뒤(L742)만 `## 5. 확인 필요`로. + +### 6.4 정상 확인 (문제 없음) + +- `_config.GetValue("SteamAdvisor:ModelDir")` 사용 — 컨트롤러 필드 존재·LoadTempRef와 동일 경로 로직 ✓ +- `ComputeStages`에 `TempProduct? forced = null` 추가(static 메서드) — 시그니처·호출부 정합 ✓ +- `forced` 매칭키 `p.Label == product || p.Name == product` ↔ 드롭다운 옵션값 `p.name || p.label` — 키 정합 ✓ +- `products = tref.Products` 직렬화 시 `Name`→`name`(ASP.NET camelCase 기본) — 프론트 `p.name` 수신 가능 ✓ +- `MergeProductLabels` try/catch 비치명 처리·파일 부재 시 원본 반환 — 하위호환 ✓ + +### 6.5 진단: 적용 순서 권장 + +1. **F1·F2 먼저** (백엔드 컴파일/표시 정확성) → `dotnet build` 통과 확인. +2. 기존 7개 tempref.json은 `name` 없음 → **F2 폴백 수정**이 없으면 매핑 미작성 컬럼이 전부 공란. 우선 F2 적용, 이후 `product_labels.json` 작성. +3. **F3·F5·F7** UI 수정 → 온도프로파일 탭에서 ①자동 배지 표시 ②드롭다운 수동선택 시 밴드 재계산 ③5초 폴링 중 드롭다운 안정성 육안 확인. +4. 회귀: `product_labels.json` **부재** 상태에서 기존과 동일하게 `P0/P1` 표시되는지(하위호환) 먼저 검증 후 매핑 파일 투입. + +--- + +*이 문서는 수정방안(설계) + §4 구현 코드 + §6 검수를 포함한다. F1~F3(블로커) 해소 전에는 빌드/동작 불가.* diff --git a/scripts/analysis/gen_temp_profiles.py b/scripts/analysis/gen_temp_profiles.py index e77e456..a0e3c8a 100644 --- a/scripts/analysis/gen_temp_profiles.py +++ b/scripts/analysis/gen_temp_profiles.py @@ -61,7 +61,8 @@ def build(prefix, stable_from=None, stable_to=None): stages = {s: {"median": round(float(g[s].median()), 2), "std": round(float(g[s].std()), 2)} for s in STAGES} products.append({ - "label": f"P{rank}", # 추후 PM/PGMEA/EL 등 화학 라벨 매핑 + "label": f"P{rank}", + "name": f"P{rank}", # 기본값=label. product_labels.json에서 후처리 채움 "n_rows": len(g), "span_AD": round(float((g["reb_temp"] - g["T_D"]).median()), 2), "vacuum": {"median": round(float(g["vacuum"].median()), 2), diff --git a/scripts/analysis/product_labels.json b/scripts/analysis/product_labels.json new file mode 100644 index 0000000..2cd5536 --- /dev/null +++ b/scripts/analysis/product_labels.json @@ -0,0 +1,12 @@ +{ + "C-6111": [ + { + "cluster": "P0", + "name": "PGMEA" + }, + { + "cluster": "P1", + "name": "PMA" + } + ] +} \ No newline at end of file diff --git a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs index dac53c7..4ab9242 100644 --- a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs +++ b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs @@ -182,14 +182,22 @@ public sealed class SteamAdvisorController : ControllerBase // realtime 단별 온도/진공 vs 기준밴드({col}_tempref.json) → 제품매칭 + z-score 이격. // col 규약 = 컬럼키("C-6111","C-6211","C-8111",...). [HttpGet("tempprofile/{col}")] - public async Task TempProfile(string col) + public async Task TempProfile( + string col, + [FromQuery] string? product = null) { var tref = await LoadTempRef(col); if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); + tref = MergeProductLabels(col, tref); + + // 운전원이 고른 제품만 기준밴드로 사용 (자동매칭 폐지). 미선택이면 selected=null → 밴드 없음. + TempProduct? selected = (product != null && tref.Products.Count > 0) + ? tref.Products.FirstOrDefault(p => p.Label == product || p.Name == product) + : null; + var cur = await FetchRealtimeValues(col, tref); - var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref); - if (prod is null) tref = tref with { }; // no-op, keep tref for metadata + var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected); var flow = await FetchFlowValues(col); var bases = FlowBases(ToSuffix(col)); @@ -198,13 +206,20 @@ public sealed class SteamAdvisorController : ControllerBase { column = tref.Column, period = tref.Period, - matchedProduct = prod?.Label, + matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null nProducts = tref.NProducts, stages, vacuum, spanAD, spanRef = prod?.SpanAD, - products = tref.Products, + // 소문자 키로 projection (TempProduct 레코드는 PascalCase 직렬화 → 프론트가 못 읽음) + products = tref.Products.Select(p => new { + label = p.Label, + name = p.Name, + rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median, + vacMedian = p.Vacuum.Median, + nRows = p.NRows, + }).ToList(), timestamp = DateTime.UtcNow, flow = new { feed = flow["feed"], @@ -232,13 +247,21 @@ public sealed class SteamAdvisorController : ControllerBase public async Task TempProfileHistory( string col, [FromQuery] DateTime? from = null, - [FromQuery] DateTime? to = null) + [FromQuery] DateTime? to = null, + [FromQuery] string? product = null) { try { var tref = await LoadTempRef(col); if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); + tref = MergeProductLabels(col, tref); + + // 운전원이 고른 제품만 기준밴드로 사용 (자동매칭 폐지). 미선택이면 null. + TempProduct? selected = (product != null && tref.Products.Count > 0) + ? tref.Products.FirstOrDefault(p => p.Label == product || p.Name == product) + : null; + to ??= DateTime.UtcNow; from ??= to.Value.AddHours(-1); // 24시간 초과 방지 (전일 조회 대응) @@ -257,7 +280,6 @@ public sealed class SteamAdvisorController : ControllerBase new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000)); var snapshots = new List(); - var products = tref.Products; static double? V(IReadOnlyDictionary d, string tag) => d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null; @@ -272,13 +294,13 @@ public sealed class SteamAdvisorController : ControllerBase return (double?)null; }); - var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref); + var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected); object FlowRole(string baseTag, string role) => new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") }; snapshots.Add(new { ts = row.TimeBucket, - matchedProduct = prod?.Label, + matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null stages, vacuum, spanAD, @@ -319,8 +341,127 @@ public sealed class SteamAdvisorController : ControllerBase } } + // ── 제품명 매핑 편집 (운전원 설정 모달용) ───────────────────────── + // MSDS 후보 (docs/MSDS_PMA_PGMEA_EL.md). 자유 입력도 허용하되 기본 제안 목록 제공. + private static readonly object[] _productSuggestions = + { + new { name = "PMA", casNo = "108-65-6" }, + new { name = "PGMEA", casNo = "108-65-6" }, + new { name = "EL", casNo = "97-64-3" }, + }; + + private string ProductLabelsPath() + { + var dir = _config.GetValue("SteamAdvisor:ModelDir") + ?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; + return Path.Combine(dir, "product_labels.json"); + } + + // 모달이 클러스터별(온도통계 + 현재 지정명)을 표시하도록 제공. + [HttpGet("productlabels/{col}")] + public async Task GetProductLabels(string col) + { + var tref = await LoadTempRef(col); + if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); + tref = MergeProductLabels(col, tref); + + var clusters = tref.Products.Select(p => new + { + cluster = p.Label, + rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median, + vacMedian = p.Vacuum.Median, + nRows = p.NRows, + // 현재 지정명: 매핑이 있으면 Name, 없으면 빈값(=미지정) + name = string.IsNullOrEmpty(p.Name) || p.Name == p.Label ? "" : p.Name, + }).ToList(); + + return Ok(new { column = col, clusters, suggestions = _productSuggestions }); + } + + public sealed record ProductLabelEntry + { + public string Cluster { get; init; } = ""; + public string Name { get; init; } = ""; + public string? CasNo { get; init; } + } + + // 모달 저장 → product_labels.json의 해당 컬럼 항목만 교체(다른 컬럼 보존). 파일 없으면 생성. + [HttpPost("productlabels/{col}")] + public IActionResult SaveProductLabels(string col, [FromBody] List entries) + { + if (entries is null) return BadRequest(new { error = "본문(entries) 누락" }); + var path = ProductLabelsPath(); + + try + { + // 기존 파일 읽어 dictionary로 (없으면 빈 맵) + var root = new Dictionary>(StringComparer.Ordinal); + if (System.IO.File.Exists(path)) + { + var existing = JsonSerializer.Deserialize>>( + System.IO.File.ReadAllText(path), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (existing != null) root = new(existing, StringComparer.Ordinal); + } + + // 이름이 비지 않은 항목만 저장(빈값=미지정 → P0/P1 폴백) + var clean = entries + .Where(e => !string.IsNullOrWhiteSpace(e.Cluster) && !string.IsNullOrWhiteSpace(e.Name)) + .Select(e => new ProductLabelEntry { Cluster = e.Cluster.Trim(), Name = e.Name.Trim(), CasNo = e.CasNo }) + .ToList(); + + if (clean.Count == 0) root.Remove(col); // 전부 미지정이면 컬럼 키 제거 + else root[col] = clean; + + var json = JsonSerializer.Serialize(root, new JsonSerializerOptions + { + WriteIndented = true, + // MergeProductLabels가 "cluster"/"name"을 대소문자 구분해 읽으므로 camelCase 강제 + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + System.IO.File.WriteAllText(path, json); + return Ok(new { success = true, saved = clean.Count, path }); + } + catch (Exception ex) + { + return StatusCode(500, new { success = false, error = ex.Message }); + } + } + // ── 헬퍼 ────────────────────────────────────────────────────────── + private TempRef MergeProductLabels(string col, TempRef tref) + { + var dir = _config.GetValue("SteamAdvisor:ModelDir") + ?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; + var path = Path.Combine(dir, "product_labels.json"); + if (!System.IO.File.Exists(path)) return tref; + + try + { + var json = System.IO.File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty(col, out var colEntry)) return tref; + + var mapping = colEntry.EnumerateArray() + .Select(e => (Cluster: e.GetProperty("cluster").GetString(), + Name: e.GetProperty("name").GetString())) + .ToList(); + if (!mapping.Any()) return tref; + + var newProducts = tref.Products.Select(p => { + var match = mapping.FirstOrDefault(m => m.Cluster == p.Label); + return string.IsNullOrEmpty(match.Name) ? p : p with { Name = match.Name }; + }).ToList(); + return tref with { Products = newProducts }; + } + catch { /* non-fatal: 매핑 파일 파싱 실패 시 기존 동작 유지 */ } + + return tref; + } + private async Task LoadTempRef(string col) { var dir = _config.GetValue("SteamAdvisor:ModelDir") @@ -398,13 +539,10 @@ public sealed class SteamAdvisorController : ControllerBase } private static (TempProduct? prod, List stages, object vacuum, double? spanAD) ComputeStages( - Dictionary cur, TempRef tref) + Dictionary cur, TempRef tref, TempProduct? selected = null) { - TempProduct? prod = null; - if (cur.GetValueOrDefault("reb_temp") is double reb && tref.Products.Count > 0) - prod = tref.Products - .OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb)) - .First(); + // 자동매칭 폐지 — 운전원이 고른 제품만 기준밴드로 사용. 미선택(null)이면 밴드 없음. + TempProduct? prod = selected; static double? Z(double? c, TempStat? r) => (c is double cv && r is not null && r.Std > 1e-6) ? (cv - r.Median) / r.Std : null; @@ -500,6 +638,7 @@ public sealed record TempRef public sealed record TempProduct { public string Label { get; init; } = ""; + public string? Name { get; init; } // MSDS 화학물질명 (null이면 Label 폴백) [JsonPropertyName("n_rows")] public int NRows { get; init; } [JsonPropertyName("span_AD")] public double SpanAD { get; init; } public TempStat Vacuum { get; init; } = new(); diff --git a/src/Hc900Crawler/wwwroot/js/steam.js b/src/Hc900Crawler/wwwroot/js/steam.js index a9f750f..15143f7 100644 --- a/src/Hc900Crawler/wwwroot/js/steam.js +++ b/src/Hc900Crawler/wwwroot/js/steam.js @@ -83,6 +83,33 @@ const ST_TEMP_COLS = [['C-6111','6-1차'],['C-6211','6-2차'],['C-8111','8차'], const ST_STAGE_LABEL = { reb_temp:'reb-A(보텀)', T_B:'T_B', T_C:'T_C(민감단)', T_D:'T_D(탑)' }; const stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1); const escHtml = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + +// ── 제품 선택 상태 ── ('' = 미선택, 자동매칭 없음) +let stTempSelectedProduct = ''; // 운전원이 고른 제품명("PMA","EL"…). 빈값=미선택 + +// 선택을 플랜트(컬럼)별로 localStorage에 저장 → 새로고침·틱에도 유지 +function stProdKey(col) { return 'st-prod-sel:' + col; } +function stGetSavedProduct(col) { try { return localStorage.getItem(stProdKey(col)) || ''; } catch (_) { return ''; } } +function stSetSavedProduct(col, v) { + try { v ? localStorage.setItem(stProdKey(col), v) : localStorage.removeItem(stProdKey(col)); } catch (_) {} +} + +// 제품 드롭다운을 저장된 매핑(productlabels)으로 채움. 조회와 무관하게 탭 진입/컬럼변경 시 즉시 호출. +async function stLoadProdOptions(col) { + const sel = document.getElementById('st-temp-prod-sel'); + if (!sel) return; + let data; + try { data = await api('GET', `/api/steam/productlabels/${col}`); } catch (_) { return; } + const opts = (data.clusters || []).map(c => { + const v = c.name || c.cluster; // 매핑된 이름 우선, 없으면 클러스터 라벨(P0…) + return ``; + }).join(''); + const saved = stGetSavedProduct(col); + sel.innerHTML = `` + opts; + sel.value = [...sel.options].some(o => o.value === saved) ? saved : ''; + stTempSelectedProduct = sel.value; +} +let stTempLastDropdownOptions = ''; // 드롭다운 옵션 캐시 (F5: 깜빡임 방지) const stFmtDT = ts => { const d = new Date(ts); const kst = new Date(d.getTime() + 9 * 3600000); @@ -93,7 +120,22 @@ function stTempInit() { const sel = document.getElementById('st-temp-col'); if (sel && !sel.options.length) sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => ``).join(''); - // default date = today + // 컬럼 변경 시 해당 플랜트의 제품 옵션·저장선택 복원 후 재조회 + if (sel) sel.onchange = async () => { await stLoadProdOptions(sel.value); stTempLoad(); }; + + // 제품 선택 드롭다운 — 선택을 플랜트별로 저장(유지) + const prodSel = document.getElementById('st-temp-prod-sel'); + if (prodSel) { + prodSel.onchange = () => { + stTempSelectedProduct = prodSel.value; + stSetSavedProduct(document.getElementById('st-temp-col').value, prodSel.value); + stTempLoad(); // 선택 변경 시 재조회 + }; + } + + // 탭 진입 즉시 드롭다운 채움(조회/저장 없이도 저장된 제품명 표시 + 선택 복원) + if (sel) stLoadProdOptions(sel.value); + document.getElementById('st-temp-date').value = new Date().toISOString().split('T')[0]; document.getElementById('st-temp-date').onchange = stTempLoad; document.getElementById('st-temp-load').onclick = stTempLoad; @@ -108,6 +150,75 @@ function stTempInit() { slider.addEventListener('input', stTempScrub); slider.addEventListener('change', stTempScrub); document.getElementById('st-scrub-live').onclick = stTempGoLive; + + // 제품명 설정 모달 + const cfgBtn = document.getElementById('st-temp-prod-cfg'); + if (cfgBtn) cfgBtn.onclick = stOpenProdModal; + const x = document.getElementById('st-prod-modal-x'); + const cancel = document.getElementById('st-prod-modal-cancel'); + if (x) x.onclick = stCloseProdModal; + if (cancel) cancel.onclick = stCloseProdModal; + const save = document.getElementById('st-prod-modal-save'); + if (save) save.onclick = stSaveProdLabels; + const modal = document.getElementById('st-prod-modal'); + if (modal) modal.onclick = (e) => { if (e.target === modal) stCloseProdModal(); }; +} + +// ── 제품명 설정 모달 ────────────────────────────────────────────── +async function stOpenProdModal() { + const col = document.getElementById('st-temp-col').value; + const modal = document.getElementById('st-prod-modal'); + const rows = document.getElementById('st-prod-rows'); + const msg = document.getElementById('st-prod-modal-msg'); + document.getElementById('st-prod-modal-col').textContent = col; + msg.textContent = ''; msg.className = 'st-modal-msg'; + rows.innerHTML = '불러오는 중…'; + modal.classList.remove('hidden'); + + let data; + try { + data = await api('GET', `/api/steam/productlabels/${col}`); + } catch (e) { + rows.innerHTML = `로드 실패: ${escHtml(e.message)}`; + return; + } + + // 제안 목록(datalist) + const dl = document.getElementById('st-prod-suggest'); + dl.innerHTML = (data.suggestions || []).map(s => ``; + }).join(''); + sel.innerHTML = `` + opts; + // 이전 선택값이 여전히 옵션에 있으면 유지, 없으면 미선택 + sel.value = [...sel.options].some(o => o.value === prev) ? prev : ''; + stTempSelectedProduct = sel.value; +} + async function stTempTick() { const col = document.getElementById('st-temp-col').value; const st = document.getElementById('st-temp-status'); try { - stTempLive = await api('GET', `/api/steam/tempprofile/${col}`); - if (stTempIsLive) { - stRenderTemp(); - } + let url = `/api/steam/tempprofile/${col}`; + if (stTempSelectedProduct) + url += `?product=${encodeURIComponent(stTempSelectedProduct)}`; + stTempLive = await api('GET', url); + // F5: 드롭다운 옵션 갱신은 최초 1회만 (매 틱 innerHTML 재작성 금지) + if (stTempLive && stTempIsLive) stRenderTemp(); st.textContent = '갱신: ' + new Date().toLocaleTimeString(); } catch (e) { st.textContent = '오류: ' + e.message; @@ -157,7 +290,10 @@ async function stTempHistLoad() { from = new Date(dateStr + 'T00:00:00+09:00'); to.setTime(from.getTime() + 86400000 - 1000); // 23:59:59 } - const url = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`; + let histUrl = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`; + if (stTempSelectedProduct) + histUrl += `&product=${encodeURIComponent(stTempSelectedProduct)}`; + const url = histUrl; console.log('[steam] hist URL:', url); try { const h = await api('GET', url); @@ -204,7 +340,8 @@ function stTempScrub() { function stTempUpdateBadges(src) { const s = src || stTempLive; if (!s) return; - document.getElementById('st-temp-product').textContent = '제품 ' + (s.matchedProduct || '—'); + + // 제품명은 드롭다운이 표시 — 별도 배지 없음 (중복 제거) const vb = document.getElementById('st-temp-vac'); vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : ''); vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a'; @@ -264,8 +401,8 @@ function stRenderTemp(snap) { title: { text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`, subtext: snap - ? `매칭제품 ${snap.matchedProduct || '—'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}` - : `매칭제품 ${stTempLive.matchedProduct || '—'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`, + ? `기준제품 ${snap.matchedProduct || '미선택'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}` + : `기준제품 ${stTempLive.matchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`, textStyle: { color: '#ccc', fontSize: 13 }, subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } }, tooltip: { trigger: 'axis' }, diff --git a/src/Hc900Crawler/wwwroot/panes/steam.html b/src/Hc900Crawler/wwwroot/panes/steam.html index ee9c821..3ab7de2 100644 --- a/src/Hc900Crawler/wwwroot/panes/steam.html +++ b/src/Hc900Crawler/wwwroot/panes/steam.html @@ -63,7 +63,10 @@
컬럼: - 제품 — + + 진공 — 일자: @@ -91,6 +94,33 @@
+ + + @@ -198,5 +228,25 @@ .st-meta-flow th small { display:block; font-size:9px; color:#555; margin-top:2px; text-align:left; } .st-meta-flow td { padding:2px 6px; border-bottom:1px solid #1a2a3a; color:#ccc; text-align:right; white-space:nowrap; } .st-meta-flow td:first-child { color:#666; font-weight:bold; text-align:left; } + +/* ── 제품명 설정 모달 ── */ +.st-modal { position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; } +.st-modal.hidden { display:none; } +.st-modal-box { background:#0d1622; border:1px solid #2a3a4a; border-radius:6px; width:520px; max-width:92vw; max-height:86vh; display:flex; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,.5); } +.st-modal-hd { display:flex; align-items:center; gap:10px; padding:12px 16px; border-bottom:1px solid #1e2a3a; } +.st-modal-hd strong { font-size:14px; color:#cde; } +.st-modal-x { margin-left:auto; background:none; border:none; color:#789; font-size:16px; cursor:pointer; } +.st-modal-x:hover { color:#f66; } +.st-modal-bd { padding:14px 16px; overflow:auto; } +.st-modal-note { font-size:11px; color:#789; margin-bottom:10px; line-height:1.5; } +.st-prod-tbl { width:100%; border-collapse:collapse; font-size:12px; } +.st-prod-tbl th { color:#789; font-weight:normal; text-align:left; padding:4px 8px; border-bottom:1px solid #2a3a4a; font-size:11px; } +.st-prod-tbl td { padding:5px 8px; border-bottom:1px solid #16202c; color:#cde; } +.st-prod-tbl td.num { color:#8cf; font-variant-numeric:tabular-nums; } +.st-prod-tbl input.inp { width:140px; font-size:12px; padding:3px 6px; } +.st-modal-msg { margin-top:10px; font-size:11px; min-height:15px; } +.st-modal-msg.ok { color:#6d6; } +.st-modal-msg.err { color:#f66; } +.st-modal-ft { display:flex; gap:8px; justify-content:flex-end; padding:12px 16px; border-top:1px solid #1e2a3a; }