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:
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Control;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,12 +17,14 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
private readonly SteamAdvisor _advisor;
|
||||
private readonly Hc900DbContext _ctx;
|
||||
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;
|
||||
_ctx = ctx;
|
||||
_config = config;
|
||||
_db = db;
|
||||
EnsureTagDescs();
|
||||
}
|
||||
|
||||
@@ -179,31 +182,134 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
// col 규약 = 컬럼키("C-6111","C-6211","C-8111",...).
|
||||
[HttpGet("tempprofile/{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")
|
||||
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
|
||||
var path = Path.Combine(dir, $"{col}_tempref.json");
|
||||
if (!System.IO.File.Exists(path))
|
||||
return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
|
||||
|
||||
var tref = JsonSerializer.Deserialize<TempRef>(
|
||||
await System.IO.File.ReadAllTextAsync(path),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (tref is null) return StatusCode(500, new { error = "tempref 파싱 실패" });
|
||||
if (!System.IO.File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<TempRef>(
|
||||
await System.IO.File.ReadAllTextAsync(path),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, double?>> FetchRealtimeValues(string col, TempRef tref)
|
||||
{
|
||||
var tagMap = TagsFor(ToSuffix(col));
|
||||
var tagNames = tagMap.Values.ToArray();
|
||||
var live = await _ctx.RealtimePoints
|
||||
.Where(r => tagNames.Contains(r.TagName))
|
||||
.GroupBy(r => r.TagName)
|
||||
.ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(r => r.Timestamp).FirstOrDefault()?.LiveValue);
|
||||
double? Val(string tag)
|
||||
=> 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));
|
||||
return tagMap.ToDictionary(kv => kv.Key, kv =>
|
||||
{
|
||||
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;
|
||||
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
|
||||
.OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb))
|
||||
.First();
|
||||
@@ -218,32 +324,27 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var z = Z(c, rs);
|
||||
return (object)new
|
||||
{
|
||||
stage = s, current = c, refMedian = rs?.Median, refStd = rs?.Std,
|
||||
z, deviated = z is double zz && Math.Abs(zz) > 2
|
||||
stage = s,
|
||||
current = c,
|
||||
refMedian = rs?.Median,
|
||||
refStd = rs?.Std,
|
||||
z,
|
||||
deviated = z is double zz && Math.Abs(zz) > 2
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var vac = cur.GetValueOrDefault("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,
|
||||
period = tref.Period,
|
||||
matchedProduct = prod?.Label,
|
||||
nProducts = tref.NProducts,
|
||||
stages,
|
||||
vacuum = new
|
||||
{
|
||||
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,
|
||||
});
|
||||
current = vac,
|
||||
refMedian = prod?.Vacuum.Median,
|
||||
refStd = prod?.Vacuum.Std,
|
||||
z = vz,
|
||||
deviated = vz is double v && Math.Abs(v) > 2
|
||||
}, spanAD);
|
||||
}
|
||||
|
||||
// roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응.
|
||||
|
||||
@@ -76,20 +76,48 @@ async function stLoadColumns() {
|
||||
|
||||
/* ── 온도 프로파일 이격 모니터 ── */
|
||||
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_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 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() {
|
||||
const sel = document.getElementById('st-temp-col');
|
||||
if (sel && !sel.options.length)
|
||||
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.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() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -97,57 +125,166 @@ async function stTempTick() {
|
||||
const col = document.getElementById('st-temp-col').value;
|
||||
const st = document.getElementById('st-temp-status');
|
||||
try {
|
||||
const d = await api('GET', `/api/steam/tempprofile/${col}`);
|
||||
stRenderTemp(d);
|
||||
stTempLive = await api('GET', `/api/steam/tempprofile/${col}`);
|
||||
if (stTempIsLive) {
|
||||
stRenderTemp();
|
||||
} else {
|
||||
stTempUpdateBadges();
|
||||
}
|
||||
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
st.textContent = '오류: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function stRenderTemp(d) {
|
||||
// 제품/진공 배지
|
||||
const pb = document.getElementById('st-temp-product');
|
||||
pb.textContent = '제품 ' + (d.matchedProduct || '—');
|
||||
async function stTempHistLoad() {
|
||||
const col = document.getElementById('st-temp-col').value;
|
||||
const dateStr = document.getElementById('st-temp-date').value; // "2026-06-07"
|
||||
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');
|
||||
vb.textContent = `진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})` + (d.vacuum.deviated ? ' ⚠이격' : '');
|
||||
vb.style.background = d.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
||||
vb.style.color = d.vacuum.deviated ? '#f66' : '#66f';
|
||||
vb.textContent = `진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})` + (stTempLive.vacuum.deviated ? ' ⚠이격' : '');
|
||||
vb.style.background = stTempLive.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
||||
vb.style.color = stTempLive.vacuum.deviated ? '#f66' : '#66f';
|
||||
}
|
||||
|
||||
const cats = d.stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage);
|
||||
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' } }));
|
||||
let stRenderTempCol = '';
|
||||
|
||||
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 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({
|
||||
backgroundColor: 'transparent',
|
||||
title: { text: `${d.column} 단면 온도 프로파일`, subtext: `매칭제품 ${d.matchedProduct || '—'} · 진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})${d.vacuum.deviated ? ' ⚠' : ''}`,
|
||||
textStyle: { color: '#ccc', fontSize: 13 }, subtextStyle: { color: d.vacuum.deviated ? '#f66' : '#888', fontSize: 11 } },
|
||||
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 ? ' ⚠' : ''}`,
|
||||
textStyle: { color: '#ccc', fontSize: 13 },
|
||||
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
|
||||
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 },
|
||||
xAxis: { type: 'category', data: cats, axisLabel: { color: '#aaa' } },
|
||||
yAxis: { type: 'value', name: '℃', scale: true, axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#1a2a3a' } } },
|
||||
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 },
|
||||
],
|
||||
series,
|
||||
});
|
||||
chart.resize();
|
||||
|
||||
// 상세 메타
|
||||
// 상세 메타 (선택 시점 기준)
|
||||
const meta = document.getElementById('st-temp-meta');
|
||||
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 ? ' ⚠이격' : ''}`);
|
||||
lines.push(`ΔT(A-D) ${stFmt(d.spanAD)}℃ 기준 ${stFmt(d.spanRef)}`);
|
||||
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 ? ' ⚠이격' : ''}`);
|
||||
meta.textContent = lines.join('\n') + `\n\n기간 ${d.period} · 제품 ${d.nProducts}종`;
|
||||
if (snapSrc.spanAD != null) lines.push(`ΔT(A-D) ${stFmt(snapSrc.spanAD)}℃`);
|
||||
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');
|
||||
}
|
||||
|
||||
/* ── Live panel ── */
|
||||
|
||||
@@ -65,10 +65,31 @@
|
||||
<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-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>
|
||||
</div>
|
||||
<div class="st-temp-note">단별 온도(reb-A>T_B>T_C>T_D)를 기준밴드(±2σ)와 대조 · 진공 종속이라 진공 동시표시 · 밴드 이탈 단계=빨강(조성/제품전환 의심)</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>
|
||||
|
||||
@@ -158,5 +179,16 @@
|
||||
.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-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>
|
||||
|
||||
|
||||
@@ -453,7 +453,7 @@ public class Hc900DbService : IExperionDbService
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
||||
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(
|
||||
"ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'");
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
@@ -1721,7 +1721,8 @@ public class Hc900DbService : IExperionDbService
|
||||
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
|
||||
{
|
||||
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)
|
||||
foreach (var tag in tags)
|
||||
|
||||
Reference in New Issue
Block a user