feat: 온도 프로파일 과거 이력 time scrubber + 날짜 선택

- z-score 추세 미니차트 제거 → time scrubber(슬라이더) 방식으로 전환
- 5초 폴링은 stTempLive만 갱신, 렌더링은 유저 액션(슬라이더/실시간버튼)으로만
- 실시간(thick blue) / 과거(thick amber + thin dashed live) 모드 분리
- 날짜 선택(date input) 추가 — 과거일 선택 시 00:00~23:59 전일 조회
- 실시간 버튼 클릭 시 오늘로 리셋 + 조회 재실행
- 타임스탬프 KST(UTC+9) 표시
- time_bucket → date_trunc('second', recorded_at) (TimescaleDB 의존성 제거)
- 히스토리 조회 제한 4h→24h, 500→2000행
This commit is contained in:
windpacer
2026-06-07 18:10:58 +09:00
parent 1f989bd861
commit 919e637f9c
4 changed files with 334 additions and 63 deletions

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Infrastructure.Control; using Hc900Crawler.Infrastructure.Control;
using Hc900Crawler.Infrastructure.Database; using Hc900Crawler.Infrastructure.Database;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -16,12 +17,14 @@ public sealed class SteamAdvisorController : ControllerBase
private readonly SteamAdvisor _advisor; private readonly SteamAdvisor _advisor;
private readonly Hc900DbContext _ctx; private readonly Hc900DbContext _ctx;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly IExperionDbService _db;
public SteamAdvisorController(SteamAdvisor advisor, Hc900DbContext ctx, IConfiguration config) public SteamAdvisorController(SteamAdvisor advisor, Hc900DbContext ctx, IConfiguration config, IExperionDbService db)
{ {
_advisor = advisor; _advisor = advisor;
_ctx = ctx; _ctx = ctx;
_config = config; _config = config;
_db = db;
EnsureTagDescs(); EnsureTagDescs();
} }
@@ -179,31 +182,134 @@ public sealed class SteamAdvisorController : ControllerBase
// col 규약 = 컬럼키("C-6111","C-6211","C-8111",...). // col 규약 = 컬럼키("C-6111","C-6211","C-8111",...).
[HttpGet("tempprofile/{col}")] [HttpGet("tempprofile/{col}")]
public async Task<IActionResult> TempProfile(string col) public async Task<IActionResult> TempProfile(string col)
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
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
return Ok(new
{
column = tref.Column,
period = tref.Period,
matchedProduct = prod?.Label,
nProducts = tref.NProducts,
stages,
vacuum,
spanAD,
spanRef = prod?.SpanAD,
products = tref.Products,
timestamp = DateTime.UtcNow,
});
}
// ── 레이어③: 온도 프로파일 과거 이력 (z-score 추세) ──────────────
// history_table에서 time_bucket 집계 → 각 스냅샷 z-score 계산.
// 기본 1시간, max 4시간. UI는 z-score 추세 미니차트로 표시.
[HttpGet("tempprofile/{col}/history")]
public async Task<IActionResult> TempProfileHistory(
string col,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null)
{
try
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
to ??= DateTime.UtcNow;
from ??= to.Value.AddHours(-1);
// 24시간 초과 방지 (전일 조회 대응)
if ((to.Value - from.Value).TotalHours > 24)
from = to.Value.AddHours(-24);
var tagMap = TagsFor(ToSuffix(col));
var tagNames = tagMap.Values.ToList();
var result = await _db.QueryHistoryWithIntervalAsync(
new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000));
var snapshots = new List<object>();
var products = tref.Products;
foreach (var row in result.Rows)
{
var cur = tagMap.ToDictionary(kv => kv.Key, kv =>
{
if (row.Values.TryGetValue(kv.Value, out var s) && double.TryParse(s, out var v))
return (double?)v;
return null;
});
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
snapshots.Add(new
{
ts = row.TimeBucket,
matchedProduct = prod?.Label,
stages,
vacuum,
spanAD,
});
}
return Ok(new
{
column = tref.Column,
period = tref.Period,
from,
to,
interval = "1 minute",
n = snapshots.Count,
snapshots,
timestamp = DateTime.UtcNow,
});
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message, stack = ex.ToString() });
}
}
// ── 헬퍼 ──────────────────────────────────────────────────────────
private async Task<TempRef?> LoadTempRef(string col)
{ {
var dir = _config.GetValue<string>("SteamAdvisor:ModelDir") var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; ?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
var path = Path.Combine(dir, $"{col}_tempref.json"); var path = Path.Combine(dir, $"{col}_tempref.json");
if (!System.IO.File.Exists(path)) if (!System.IO.File.Exists(path)) return null;
return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); try
{
var tref = JsonSerializer.Deserialize<TempRef>( return JsonSerializer.Deserialize<TempRef>(
await System.IO.File.ReadAllTextAsync(path), await System.IO.File.ReadAllTextAsync(path),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (tref is null) return StatusCode(500, new { error = "tempref 파싱 실패" }); }
catch { return null; }
}
private async Task<Dictionary<string, double?>> FetchRealtimeValues(string col, TempRef tref)
{
var tagMap = TagsFor(ToSuffix(col)); var tagMap = TagsFor(ToSuffix(col));
var tagNames = tagMap.Values.ToArray(); var tagNames = tagMap.Values.ToArray();
var live = await _ctx.RealtimePoints var live = await _ctx.RealtimePoints
.Where(r => tagNames.Contains(r.TagName)) .Where(r => tagNames.Contains(r.TagName))
.GroupBy(r => r.TagName) .GroupBy(r => r.TagName)
.ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(r => r.Timestamp).FirstOrDefault()?.LiveValue); .ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(r => r.Timestamp).FirstOrDefault()?.LiveValue);
double? Val(string tag) return tagMap.ToDictionary(kv => kv.Key, kv =>
=> live.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? v : null; {
var cur = tagMap.ToDictionary(kv => kv.Key, kv => Val(kv.Value)); if (live.TryGetValue(kv.Value, out var s) && double.TryParse(s, out var v))
return (double?)v;
return null;
});
}
// 제품 매칭: 현재 reb_temp에 가장 가까운 기준 제품(온도프로파일=제품 식별자) private static (TempProduct? prod, List<object> stages, object vacuum, double? spanAD) ComputeStages(
Dictionary<string, double?> cur, TempRef tref)
{
TempProduct? prod = null; TempProduct? prod = null;
if (cur["reb_temp"] is double reb && tref.Products.Count > 0) if (cur.GetValueOrDefault("reb_temp") is double reb && tref.Products.Count > 0)
prod = tref.Products prod = tref.Products
.OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb)) .OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb))
.First(); .First();
@@ -218,32 +324,27 @@ public sealed class SteamAdvisorController : ControllerBase
var z = Z(c, rs); var z = Z(c, rs);
return (object)new return (object)new
{ {
stage = s, current = c, refMedian = rs?.Median, refStd = rs?.Std, stage = s,
z, deviated = z is double zz && Math.Abs(zz) > 2 current = c,
refMedian = rs?.Median,
refStd = rs?.Std,
z,
deviated = z is double zz && Math.Abs(zz) > 2
}; };
}).ToList(); }).ToList();
var vac = cur.GetValueOrDefault("vacuum"); var vac = cur.GetValueOrDefault("vacuum");
var vz = Z(vac, prod?.Vacuum); var vz = Z(vac, prod?.Vacuum);
double? spanAD = (cur["reb_temp"] is double r2 && cur["T_D"] is double td) ? r2 - td : null; double? spanAD = (cur.GetValueOrDefault("reb_temp") is double r2 && cur.GetValueOrDefault("T_D") is double td) ? r2 - td : null;
return Ok(new return (prod, stages, new
{ {
column = tref.Column, current = vac,
period = tref.Period, refMedian = prod?.Vacuum.Median,
matchedProduct = prod?.Label, refStd = prod?.Vacuum.Std,
nProducts = tref.NProducts, z = vz,
stages, deviated = vz is double v && Math.Abs(v) > 2
vacuum = new }, spanAD);
{
current = vac, refMedian = prod?.Vacuum.Median, refStd = prod?.Vacuum.Std,
z = vz, deviated = vz is double v && Math.Abs(v) > 2
},
spanAD,
spanRef = prod?.SpanAD,
products = tref.Products,
timestamp = DateTime.UtcNow,
});
} }
// roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응. // roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응.

View File

@@ -76,20 +76,48 @@ async function stLoadColumns() {
/* ── 온도 프로파일 이격 모니터 ── */ /* ── 온도 프로파일 이격 모니터 ── */
let stTempTimer = null; let stTempTimer = null;
let stTempSnapshots = [];
let stTempLive = null;
let stTempIsLive = true;
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_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 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 stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1);
const stFmtDT = ts => {
const d = new Date(ts);
const kst = new Date(d.getTime() + 9 * 3600000);
return kst.toISOString().slice(0,19).replace('T',' ');
}; // KST (UTC+9)
function stTempInit() { function stTempInit() {
const sel = document.getElementById('st-temp-col'); const sel = document.getElementById('st-temp-col');
if (sel && !sel.options.length) if (sel && !sel.options.length)
sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => `<option value="${v}">${l}</option>`).join(''); sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
// default date = today
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; document.getElementById('st-temp-load').onclick = stTempLoad;
document.querySelectorAll('.st-temp-range').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.st-temp-range').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
stTempLoad();
};
});
const slider = document.getElementById('st-scrub-slider');
slider.addEventListener('input', stTempScrub);
slider.addEventListener('change', stTempScrub);
document.getElementById('st-scrub-live').onclick = stTempGoLive;
} }
async function stTempLoad() { async function stTempLoad() {
if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; } if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; }
await stTempTick(); 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); stTempTimer = setInterval(stTempTick, 5000);
} }
@@ -97,57 +125,166 @@ async function stTempTick() {
const col = document.getElementById('st-temp-col').value; const col = document.getElementById('st-temp-col').value;
const st = document.getElementById('st-temp-status'); const st = document.getElementById('st-temp-status');
try { try {
const d = await api('GET', `/api/steam/tempprofile/${col}`); stTempLive = await api('GET', `/api/steam/tempprofile/${col}`);
stRenderTemp(d); if (stTempIsLive) {
stRenderTemp();
} else {
stTempUpdateBadges();
}
st.textContent = '갱신: ' + new Date().toLocaleTimeString(); st.textContent = '갱신: ' + new Date().toLocaleTimeString();
} catch (e) { } catch (e) {
st.textContent = '오류: ' + e.message; st.textContent = '오류: ' + e.message;
} }
} }
function stRenderTemp(d) { async function stTempHistLoad() {
// 제품/진공 배지 const col = document.getElementById('st-temp-col').value;
const pb = document.getElementById('st-temp-product'); const dateStr = document.getElementById('st-temp-date').value; // "2026-06-07"
pb.textContent = '제품 ' + (d.matchedProduct || ''); const todayStr = new Date().toISOString().split('T')[0];
const isToday = (dateStr === todayStr);
const activeBtn = document.querySelector('.st-temp-range.active');
const range = activeBtn ? activeBtn.dataset.range : '1h';
const to = new Date();
let from;
if (isToday) {
if (range === '30m') from = new Date(to - 30 * 60000);
else if (range === '1h') from = new Date(to - 3600000);
else if (range === '4h') from = new Date(to - 4 * 3600000);
else if (range === 'today') { from = new Date(to); from.setHours(0, 0, 0, 0); }
else from = new Date(to - 3600000);
} else {
// 과거 날짜: 00:00~23:59 UTC → KST 기준
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()}`;
console.log('[steam] hist URL:', url);
try {
const h = await api('GET', url);
console.log('[steam] hist response:', JSON.stringify(h).slice(0, 300));
stTempSnapshots = h && h.snapshots || [];
} catch (e) {
console.warn('[steam] hist load fail:', e);
stTempSnapshots = [];
}
const slider = document.getElementById('st-scrub-slider');
const n = stTempSnapshots.length;
if (n >= 2) {
slider.max = (n - 1).toString();
slider.value = (n - 1).toString();
document.getElementById('st-scrub-from').textContent = stFmtDT(stTempSnapshots[0].ts);
document.getElementById('st-scrub-to').textContent = stFmtDT(stTempSnapshots[n - 1].ts);
} else {
slider.max = '0'; slider.value = '0';
document.getElementById('st-scrub-ts').textContent = n === 0 ? '히스토리 없음' : stFmtDT(stTempSnapshots[0].ts);
document.getElementById('st-scrub-from').textContent = '—';
document.getElementById('st-scrub-to').textContent = '—';
}
stRenderTemp();
}
function stTempGoLive() {
document.getElementById('st-temp-date').value = new Date().toISOString().split('T')[0];
stTempLoad();
}
function stTempScrub() {
const idx = parseInt(document.getElementById('st-scrub-slider').value);
const snap = stTempSnapshots[idx];
if (!snap) {
console.log('[steam] scrub: no snapshot at idx', idx, 'total', stTempSnapshots.length);
return;
}
stTempIsLive = false;
document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.ts);
stRenderTemp(snap);
}
// 배지만 갱신 (과거 모드에서 현재값 badge update)
function stTempUpdateBadges() {
if (!stTempLive) return;
document.getElementById('st-temp-product').textContent = '제품 ' + (stTempLive.matchedProduct || '—');
const vb = document.getElementById('st-temp-vac'); const vb = document.getElementById('st-temp-vac');
vb.textContent = `진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})` + (d.vacuum.deviated ? ' ⚠이격' : ''); vb.textContent = `진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})` + (stTempLive.vacuum.deviated ? ' ⚠이격' : '');
vb.style.background = d.vacuum.deviated ? '#3a1a1a' : '#1a1a3a'; vb.style.background = stTempLive.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
vb.style.color = d.vacuum.deviated ? '#f66' : '#66f'; vb.style.color = stTempLive.vacuum.deviated ? '#f66' : '#66f';
}
const cats = d.stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage); let stRenderTempCol = '';
const lo = d.stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null);
const band = d.stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null);
const med = d.stages.map(s => s.refMedian);
const cur = d.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
function stRenderTemp(snap) {
stTempUpdateBadges();
if (!stTempLive) {
const el = document.getElementById('st-chart-temp');
if (el) el.innerHTML = '<div style="padding:40px;text-align:center;color:#555">데이터 없음</div>';
return;
}
stRenderTempCol = document.getElementById('st-temp-col').value;
const stages = stTempLive.stages;
if (!stages || !stages.length) return;
const cats = stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage);
const lo = stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null);
const band = stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null);
const med = stages.map(s => s.refMedian);
const col = stRenderTempCol;
const el = document.getElementById('st-chart-temp'); const el = document.getElementById('st-chart-temp');
const chart = echarts.getInstanceByDom(el) || echarts.init(el); const chart = echarts.getInstanceByDom(el) || echarts.init(el);
const snapSrc = snap || stTempLive;
// 실시간 모드: thick blue one line
// 과거 모드: thin dashed live + thick amber 선택시점
const leg = ['기준밴드', '기준'];
const series = [
{ name: '_lo', type: 'line', data: lo, lineStyle: { opacity: 0 }, stack: 'band', symbol: 'none', silent: true },
{ name: '기준밴드', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true },
{ name: '기준', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' },
];
if (snap) {
// 과거 모드: thin dashed live + thick amber 선택시점
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
const snapD = snapSrc.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#fa3' } }));
series.push({ name: '현재', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbolSize: 9 });
leg.push('현재', '선택시점');
} else {
// 실시간 모드: thick blue
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
series.push({ name: '현재', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbolSize: 9 });
leg.push('현재');
}
chart.setOption({ chart.setOption({
backgroundColor: 'transparent', backgroundColor: 'transparent',
title: { text: `${d.column} 단면 온도 프로파일`, subtext: `매칭제품 ${d.matchedProduct || '—'} · 진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})${d.vacuum.deviated ? ' ⚠' : ''}`, title: {
textStyle: { color: '#ccc', fontSize: 13 }, subtextStyle: { color: d.vacuum.deviated ? '#f66' : '#888', fontSize: 11 } }, 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 ? ' ⚠' : ''}`,
textStyle: { color: '#ccc', fontSize: 13 },
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { data: ['기준밴드', '기준', '현재'], textStyle: { color: '#888' }, top: 6, right: 10 }, legend: { data: leg, textStyle: { color: '#888' }, top: 6, right: 10 },
grid: { top: 64, left: 48, right: 20, bottom: 28 }, grid: { top: 64, left: 48, right: 20, bottom: 28 },
xAxis: { type: 'category', data: cats, axisLabel: { color: '#aaa' } }, xAxis: { type: 'category', data: cats, axisLabel: { color: '#aaa' } },
yAxis: { type: 'value', name: '℃', scale: true, axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#1a2a3a' } } }, yAxis: { type: 'value', name: '℃', scale: true, axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#1a2a3a' } } },
series: [ series,
{ name: '_lo', type: 'line', data: lo, lineStyle: { opacity: 0 }, stack: 'band', symbol: 'none', silent: true },
{ name: '기준밴드', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true },
{ name: '기준', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' },
{ name: '현재', type: 'line', data: cur, lineStyle: { color: '#6cf', width: 2 }, symbolSize: 9 },
],
}); });
chart.resize(); chart.resize();
// 상세 메타 // 상세 메타 (선택 시점 기준)
const meta = document.getElementById('st-temp-meta'); const meta = document.getElementById('st-temp-meta');
meta.style.display = 'block'; meta.style.display = 'block';
const lines = d.stages.map(s => const lines = snapSrc.stages.map(s =>
`${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ${stFmt(s.refMedian)}±${stFmt(s.refStd)} z=${s.z != null ? s.z.toFixed(1) : '—'}${s.deviated ? ' ⚠이격' : ''}`); `${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ${stFmt(s.refMedian)}±${stFmt(s.refStd)} z=${s.z != null ? s.z.toFixed(1) : '—'}${s.deviated ? ' ⚠이격' : ''}`);
lines.push(`ΔT(A-D) ${stFmt(d.spanAD)} 기준 ${stFmt(d.spanRef)}`); if (snapSrc.spanAD != null) lines.push(`ΔT(A-D) ${stFmt(snapSrc.spanAD)}`);
lines.push(`진공 ${stFmt(d.vacuum.current)} 기준 ${stFmt(d.vacuum.refMedian)}±${stFmt(d.vacuum.refStd)} z=${d.vacuum.z != null ? d.vacuum.z.toFixed(1) : '—'}${d.vacuum.deviated ? ' ⚠이격' : ''}`); if (snapSrc.vacuum) lines.push(`진공 ${stFmt(snapSrc.vacuum.current)} 기준 ${stFmt(snapSrc.vacuum.refMedian)}±${stFmt(snapSrc.vacuum.refStd)} z=${snapSrc.vacuum.z != null ? snapSrc.vacuum.z.toFixed(1) : '—'}${snapSrc.vacuum.deviated ? ' ⚠이격' : ''}`);
meta.textContent = lines.join('\n') + `\n\n기간 ${d.period} · 제품 ${d.nProducts}`; meta.textContent = lines.join('\n');
} }
/* ── Live panel ── */ /* ── Live panel ── */

View File

@@ -65,10 +65,31 @@
<button class="btn-a" id="st-temp-load">조회</button> <button class="btn-a" id="st-temp-load">조회</button>
<span id="st-temp-product" class="st-badge st-badge-mode">제품 —</span> <span id="st-temp-product" class="st-badge st-badge-mode">제품 —</span>
<span id="st-temp-vac" class="st-badge st-badge-conf">진공 —</span> <span id="st-temp-vac" class="st-badge st-badge-conf">진공 —</span>
<span style="margin-left:12px;font-size:11px;color:#666">일자:</span>
<input type="date" id="st-temp-date" class="inp" style="width:140px;font-size:11px">
<span style="margin-left:8px;font-size:11px;color:#666">범위:</span>
<button class="st-temp-range btn-a-s" data-range="30m">30분</button>
<button class="st-temp-range btn-a-s active" data-range="1h">1시간</button>
<button class="st-temp-range btn-a-s" data-range="4h">4시간</button>
<button class="st-temp-range btn-a-s" data-range="today">전일</button>
<span id="st-temp-status" style="margin-left:8px;font-size:11px;color:#888"></span> <span id="st-temp-status" style="margin-left:8px;font-size:11px;color:#888"></span>
</div> </div>
<div class="st-temp-note">단별 온도(reb-A&gt;T_B&gt;T_C&gt;T_D)를 기준밴드(±2σ)와 대조 · 진공 종속이라 진공 동시표시 · 밴드 이탈 단계=빨강(조성/제품전환 의심)</div> <div class="st-temp-note">단별 온도(reb-A&gt;T_B&gt;T_C&gt;T_D)를 기준밴드(±2σ)와 대조 · 진공 종속이라 진공 동시표시 · 밴드 이탈 단계=빨강(조성/제품전환 의심)</div>
<div id="st-chart-temp" style="height:360px"></div> <div id="st-chart-temp" style="height:360px"></div>
<!-- time scrubber -->
<div class="st-scrubber">
<div class="st-scrub-top">
<span id="st-scrub-ts" class="st-scrub-ts"></span>
<button id="st-scrub-live" class="btn-a-s st-scrub-live">실시간</button>
</div>
<input type="range" id="st-scrub-slider" class="st-scrub-slider" min="0" max="0" value="0" step="1">
<div class="st-scrub-labels">
<span id="st-scrub-from"></span>
<span id="st-scrub-to"></span>
</div>
</div>
<div class="st-bt-meta" id="st-temp-meta" style="display:none"></div> <div class="st-bt-meta" id="st-temp-meta" style="display:none"></div>
</div> </div>
@@ -158,5 +179,16 @@
.st-chart-panel { min-height:200px; } .st-chart-panel { min-height:200px; }
.st-bt-meta { margin-top:6px; font-size:11px; color:#666; background:#0d1520; border:1px solid #1a2a3a; border-radius:4px; padding:8px; white-space:pre-wrap; } .st-bt-meta { margin-top:6px; font-size:11px; color:#666; background:#0d1520; border:1px solid #1a2a3a; border-radius:4px; padding:8px; white-space:pre-wrap; }
.st-temp-note { font-size:11px; color:#789; margin-bottom:6px; } .st-temp-note { font-size:11px; color:#789; margin-bottom:6px; }
.st-temp-range { font-size:10px; padding:2px 8px; }
.st-temp-range.active { background:#2a4a6a; color:#4af; border-color:#4af; }
.st-scrubber { margin-top:8px; padding:6px 10px; background:#0d1520; border:1px solid #1a2a3a; border-radius:4px; }
.st-scrub-top { display:flex; align-items:center; justify-content:space-between; margin-bottom:2px; }
.st-scrub-ts { font-size:12px; color:#8cf; }
.st-scrub-live { font-size:10px; padding:1px 10px; border:1px solid #2a4a6a; background:#0d1520; color:#4af; border-radius:3px; cursor:pointer; }
.st-scrub-live:hover { background:#1a2a4a; }
.st-scrub-slider { width:100%; height:4px; -webkit-appearance:none; appearance:none; background:#1a2a3a; border-radius:2px; outline:none; cursor:pointer; }
.st-scrub-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:14px; height:14px; border-radius:50%; background:#4af; border:2px solid #0a121a; cursor:pointer; }
.st-scrub-slider::-moz-range-thumb { width:14px; height:14px; border-radius:50%; background:#4af; border:2px solid #0a121a; cursor:pointer; }
.st-scrub-labels { display:flex; justify-content:space-between; font-size:9px; color:#555; margin-top:2px; }
</style> </style>

View File

@@ -453,7 +453,7 @@ public class Hc900DbService : IExperionDbService
await _ctx.Database.ExecuteSqlRawAsync( await _ctx.Database.ExecuteSqlRawAsync(
"ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'"); "ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
await _ctx.Database.ExecuteSqlRawAsync( await _ctx.Database.ExecuteSqlRawAsync(
"ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'"); "ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT DEFAULT 'HC1'");
await _ctx.Database.ExecuteSqlRawAsync( await _ctx.Database.ExecuteSqlRawAsync(
"ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'"); "ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
await _ctx.Database.ExecuteSqlRawAsync( await _ctx.Database.ExecuteSqlRawAsync(
@@ -1721,7 +1721,8 @@ public class Hc900DbService : IExperionDbService
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit) List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
{ {
var selectParts = new List<string>(); var selectParts = new List<string>();
selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket"); // time_bucket은 TimescaleDB 전용 함수 — PostgreSQL 내장 date_trunc로 대체
selectParts.Add($"date_trunc('second', recorded_at) AS time_bucket");
// 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE) // 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE)
foreach (var tag in tags) foreach (var tag in tags)