Faceplate
⏵ DATA REPLAY — 1×

—

Màn hình là JSON khai báo của plugin (/screen/<id>); client render generic theo symbol + áp binding {property,tag,transform,condition}. Dữ liệu từ mô hình vật lý qua vòng CCS khép kín (không Math.random). Giá trị trung tính, chỉ đổi màu khi bất thường (ISA-101).
P1 0 P2 0 P3 0 P4 0
Không có alarm đang hoạt động
🔕 Shelve alarm — —
🔕 Alarm đang shelve (ISA-18.2)
'); doc.close(); ifr.contentWindow.focus(); setTimeout(() => { try { ifr.contentWindow.print(); } finally { setTimeout(() => ifr.remove(), 800); } }, 220); } $('openJournal').onclick = () => send({ cmd: 'journal' }); $('journalX').onclick = () => { $('journalModal').hidden = true; }; $('openDiag').onclick = () => send({ cmd: 'diag' }); $('openRat').onclick = () => send({ cmd: 'alarm-rationalization' }); $('ratX').onclick = () => { $('ratModal').hidden = true; }; // ── AI Cognitive / Predictive Maintenance panel (P5, chỉ đọc) ── const AI_BAND = { healthy: ['#2FA84F', 'Lành mạnh'], watch: ['#8FBF3F', 'Theo dõi'], warning: ['#F5A524', 'Cảnh báo'], critical: ['#E5484D', 'Nguy cấp'] }; const AI_PRIO = { routine: 'Thường quy', planned: 'Theo kế hoạch', urgent: 'Khẩn cấp' }; let aiOpen = false, aiSel = null, aiNames = {}; function openAi(){ aiOpen = true; $('aiModal').hidden = false; send({ cmd: 'ai-fleet' }); if (aiSel) aiAsk(aiSel); } function aiAsk(assetId){ if (ws && ws.readyState === 1){ send({ cmd: 'ai-assess', assetId }); send({ cmd: 'ai-history', assetId }); } } function closeAi(){ aiOpen = false; $('aiModal').hidden = true; } function onAiFleet(m){ const info = m.info || {}, ov = m.overview || {}; $('aiNotice').textContent = (info.notice ? info.notice.vi : '') + ' · TB ' + (ov.averageScore != null ? ov.averageScore.toFixed(0) : '—') + '/100 · ' + (ov.anomalyCount || 0) + ' bất thường'; const box = $('aiFleet'); box.innerHTML = ''; for (const a of (ov.assets || [])) { aiNames[a.assetId] = a.name ? a.name.vi : a.assetId; const bc = (AI_BAND[a.band] || AI_BAND.healthy)[0]; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:4px;cursor:pointer;' + (aiSel === a.assetId ? 'background:rgba(255,255,255,.06)' : ''); row.innerHTML = '' + (a.name ? a.name.vi : a.assetId) + '' + '' + '' + a.score.toFixed(0) + ''; row.onclick = () => { aiSel = a.assetId; const c = $('aiSpark'); if (c) c.style.display = 'none'; aiAsk(a.assetId); onAiFleet(m); }; box.appendChild(row); } } function onAiHistory(m){ if (!aiOpen || m.assetId !== aiSel) return; const c = $('aiSpark'); if (!c) return; const s = (m.series || []).map(p => p.value).filter(v => typeof v === 'number'); if (s.length < 2){ c.style.display = 'none'; return; } c.style.display = 'block'; const ctx = c.getContext('2d'); const W = c.width, H = c.height; ctx.clearRect(0, 0, W, H); // Nền dải sức khoẻ (đỏ <40, vàng <65) để đọc nhanh mức độ. ctx.fillStyle = 'rgba(229,72,77,.10)'; ctx.fillRect(0, H - (40 / 100) * H, W, (40 / 100) * H); ctx.fillStyle = 'rgba(245,165,36,.08)'; ctx.fillRect(0, H - (65 / 100) * H, W, (25 / 100) * H); const last = s[s.length - 1]; const col = last >= 65 ? '#2FA84F' : last >= 40 ? '#F5A524' : '#E5484D'; ctx.strokeStyle = col; ctx.lineWidth = 1.6; ctx.beginPath(); for (let i = 0; i < s.length; i++){ const x = (i / (s.length - 1)) * (W - 2) + 1; const y = H - 2 - (Math.max(0, Math.min(100, s[i])) / 100) * (H - 4); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); } ctx.stroke(); ctx.fillStyle = col; ctx.font = '10px ui-monospace,monospace'; ctx.fillText('sức khoẻ ' + last.toFixed(0) + ' (trend ' + s.length + ' mẫu)', 6, 12); } function onAiAssess(m){ if (!aiOpen) return; const a = m.assessment; const el = $('aiDetail'); if (!a){ el.style.opacity = '.6'; el.textContent = 'Không có dữ liệu cho tài sản này.'; return; } el.style.opacity = '1'; const bc = (AI_BAND[a.health.band] || AI_BAND.healthy); let h = '
' + (aiNames[m.assetId] || m.assetId) + '
'; h += '
' + a.health.score.toFixed(0) + '/100 · ' + bc[1] + '
'; // Factors (đóng góp minh bạch) h += '
Tín hiệu → đóng góp
'; for (const f of a.health.factors){ const g = Math.round(f.goodness * 100); const fc = g >= 65 ? '#2FA84F' : g >= 40 ? '#F5A524' : '#E5484D'; h += '
' + f.label.vi + ' (' + f.value.toFixed(2) + (f.unit ? ' ' + f.unit : '') + ')' + '' + '' + g + '%
'; } // Diagnosis WHAT/WHY/HOW + RCA if (a.diagnosis){ const d = a.diagnosis; h += '
'; h += '
Điều gì: ' + esc(d.what) + '
'; h += '
Vì sao: ' + esc(d.why) + '
'; h += '
Nên làm: ' + esc(d.how) + '
'; if (d.rootCauses && d.rootCauses.length){ h += '
Nguyên nhân gốc (xác suất)
'; for (const r of d.rootCauses){ const p = Math.round(r.probability * 100); h += '
' + esc(r.label) + '' + '' + '' + p + '%
'; } } h += '
'; } else { h += '
✓ Không có bất thường — tài sản lành mạnh.
'; } // RUL (nhãn MÔ PHỎNG bắt buộc) if (a.rul){ const rh = a.rul.hours == null ? '—' : a.rul.hours >= 1000 ? (a.rul.hours / 1000).toFixed(1) + 'k h' : a.rul.hours.toFixed(0) + ' h'; h += '
Tuổi thọ còn lại (RUL): ' + rh + ' MÔ PHỎNG' + '
' + esc(a.rul.basis) + ' · độ tin cậy ' + a.rul.confidence + '
'; } // Recommendations if (a.recommendations && a.recommendations.length){ h += '
Khuyến nghị
'; for (const r of a.recommendations){ h += '
• ' + esc(r.action) + ' [' + (AI_PRIO[r.priority] || r.priority) + ']
'; } } el.innerHTML = h; } function esc(s){ return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } $('openAi').onclick = openAi; $('aiX').onclick = closeAi; $('aiModal').onclick = (e) => { if (e.target === $('aiModal')) closeAi(); }; // Demo 90s: tiêm sự cố quạt FD → sức khoẻ tụt → AI chẩn đoán → tự khôi phục (chỉ đọc với AI; malf là hành động vận hành). let aiDemoTimer = null, aiDemoPoll = null; function startAiDemo(){ if (aiDemoTimer){ showToast('Demo AI đang chạy…', false); return; } aiSel = 'FAN-FD'; openAi(); // Tiêm sự cố = hành động vận hành cần quyền override (Engineer+); demo tạm nâng quyền rồi trả về Operator. send({ cmd: 'login', user: 'engineer' }); send({ cmd: 'malf', malf: 'fd-fan-surge', value: 1, confirm: true }); showToast('▶ Demo AI: tiêm sự cố quạt FD — theo dõi AI chẩn đoán (chỉ đọc)…', true); aiDemoPoll = setInterval(() => { if (ws && ws.readyState === 1){ send({ cmd: 'ai-fleet' }); aiAsk('FAN-FD'); } }, 2000); aiDemoTimer = setTimeout(() => { send({ cmd: 'malf', malf: 'fd-fan-surge', value: 0, confirm: true }); send({ cmd: 'login', user: 'operator' }); // trả quyền về Operator clearInterval(aiDemoPoll); aiDemoPoll = null; aiDemoTimer = null; showToast('✓ Demo AI kết thúc — khôi phục quạt FD, trả quyền Operator.', true); setTimeout(() => { if (aiOpen){ send({ cmd: 'ai-fleet' }); aiAsk('FAN-FD'); } }, 1500); }, 90000); } $('aiDemo').onclick = startAiDemo; // ── Trend viewer đa tag (Historian) ── const TREND_COLORS = ['#E8952A', '#4A90D9', '#57A773', '#E5484D', '#C9A227', '#38BDF8', '#B14CFF', '#9AA5B1']; let trPens = ['GEN_MW_01', 'BLR_STEAM_FLOW_01', 'BLR_MSTM_SH_PRESS_01', 'BLR_DRUM_LEVEL_01']; let trHours = 1; function openTrend(){ $('trTagList').innerHTML = builderTags.map((t) => '').join(''); $('trendModal').hidden = false; trSend(); } function trSend(){ send({ cmd: 'trend', tags: trPens, hours: trHours }); } function addPen(){ const t = $('trTagPick').value.trim(); if (t && !trPens.includes(t) && trPens.length < 8){ trPens.push(t); $('trTagPick').value = ''; trSend(); } } function drawTrend(m){ const cv = $('trendCanvas'), ctx = cv.getContext('2d'), W = cv.width, H = cv.height, padL = 8, padR = 8, padT = 10, padB = 22; ctx.clearRect(0, 0, W, H); ctx.strokeStyle = 'rgba(127,127,127,.25)'; ctx.lineWidth = 1; ctx.strokeRect(padL, padT, W - padL - padR, H - padT - padB); const series = m.series || {}, leg = $('trLegend'); leg.innerHTML = ''; const x0 = padL, x1 = W - padR, y0 = H - padB, y1 = padT; trPens.forEach((tag, i) => { const pts = series[tag] || [], color = TREND_COLORS[i % TREND_COLORS.length]; let mn = Infinity, mx = -Infinity, last = null; for (const p of pts){ if (p.value < mn) mn = p.value; if (p.value > mx) mx = p.value; last = p.value; } const chip = document.createElement('span'); chip.style.cssText = 'cursor:pointer;display:inline-flex;align-items:center;gap:5px'; chip.title = 'Bấm để bỏ pen'; chip.innerHTML = '' + '' + tag + '' + (last != null ? ' ' + last.toFixed(2) + '' : '') + (pts.length ? ' [' + mn.toFixed(1) + '–' + mx.toFixed(1) + ']' : '— chưa có dữ liệu'); chip.onclick = () => { trPens = trPens.filter((x) => x !== tag); trSend(); }; leg.appendChild(chip); if (pts.length < 2) return; // Deadband tương đối khi VẼ (legend vẫn hiện min–max thật): nhiễu nhỏ quanh giá trị ổn định không bị kéo full-height. const ref = Math.max(Math.abs(mn), Math.abs(mx), 1e-6); const span = Math.max(mx - mn, ref * 0.01) || 1; const mid = (mn + mx) / 2, lo = mid - span / 2; ctx.beginPath(); pts.forEach((p, j) => { const x = x0 + (x1 - x0) * (j / (pts.length - 1)); const y = y0 - (y0 - y1) * ((p.value - lo) / span); j ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }); ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.stroke(); }); ctx.fillStyle = 'rgba(150,160,175,.85)'; ctx.font = '10px monospace'; const fmt = (s) => (s ? s.slice(11, 16) : ''); ctx.fillText(fmt(m.from), padL + 2, H - 7); const tt = fmt(m.to); ctx.fillText(tt, W - padR - ctx.measureText(tt).width - 2, H - 7); } $('openTrend').onclick = openTrend; $('trendX').onclick = () => { $('trendModal').hidden = true; }; $('trAdd').onclick = addPen; $('trTagPick').addEventListener('keydown', (e) => { if (e.key === 'Enter'){ e.preventDefault(); addPen(); } }); document.querySelectorAll('.trh').forEach((b) => b.onclick = () => { trHours = Number(b.dataset.h); trSend(); }); $('diagX').onclick = () => { $('diagModal').hidden = true; }; $('openBuilder').onclick = openBuilder; $('builderX').onclick = () => { $('builderModal').hidden = true; }; // UX: đóng modal bằng Esc hoặc bấm ra nền tối (nhất quán, tránh overlay bẫy thao tác). const overlayModals = ['reportModal', 'journalModal', 'diagModal', 'ratModal', 'builderModal', 'trendModal']; overlayModals.forEach((id) => { const el = $(id); if (el) el.addEventListener('click', (e) => { if (e.target === el) el.hidden = true; }); }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; let closed = false; overlayModals.forEach((id) => { const el = $(id); if (el && !el.hidden) { el.hidden = true; closed = true; } }); if (closed) e.preventDefault(); }); $('bAddTile').onclick = () => { const t = $('bTagPick').value.trim(); if (t){ bTiles.push({ tag: t, label: '', unit: '', kind: 'value' }); $('bTagPick').value = ''; renderBTiles(); } }; $('bBuild').onclick = () => { const id = $('bScreenId').value.trim(); if (!id || !bTiles.length){ showToast('Cần mã màn hình + ít nhất 1 ô', false); return; } const title = $('bTitle').value || id; const spec = { screenId: id, level: 'D3', title: { vi: title, en: title }, cols: Number($('bCols').value) || 4, tiles: bTiles.map(t => ({ tag: t.tag, label: t.label || undefined, unit: t.unit || undefined, kind: t.kind })) }; send({ cmd: 'build-screen', spec }); $('builderModal').hidden = true; }; let replayMode = false, seeking = false, blockedTimer = null; function setMode(mode){ replayMode = (mode === 'REPLAY'); $('replaybar').classList.toggle('on', replayMode); document.querySelectorAll('.controls button[data-load],.controls button[data-leak],.controls button[data-vacuum],.controls button[data-cmd],.controls button[data-malf],.controls button[data-rstart]') .forEach(b => b.disabled = replayMode); if (!replayMode){ $('rMsg').textContent = ''; if (current) subscribe(current); } // về LIVE → làm mới snapshot } function updateReplay(r){ $('rClock').textContent = (r.clockTs || '').replace('T', ' ').slice(0, 19); $('rSpeed').textContent = (r.speed || 1) + '×'; if (!seeking && r.from && r.to){ const f = Date.parse(r.from), t = Date.parse(r.to), c = Date.parse(r.clockTs); if (t > f) $('rSeek').value = Math.round((c - f) / (t - f) * 1000); } } function flashBlocked(reason){ $('rMsg').textContent = '⛔ ' + reason; if (blockedTimer) clearTimeout(blockedTimer); blockedTimer = setTimeout(() => { $('rMsg').textContent = ''; }, 2500); } document.querySelector('button[data-rstart]').onclick = () => send({ cmd: 'replay-start' }); document.querySelector('button[data-rstop]').onclick = () => send({ cmd: 'replay-stop' }); document.querySelectorAll('button[data-rspeed]').forEach(b => b.onclick = () => send({ cmd: 'replay-speed', value: Number(b.dataset.rspeed) })); $('rSeek').oninput = () => { seeking = true; }; $('rSeek').onchange = () => { send({ cmd: 'replay-seek', value: Number($('rSeek').value) / 1000 }); seeking = false; }; const PRIO = { P1:'alarm-1', P2:'alarm-2', P3:'alarm-3', P4:'alarm-4' }; const isUnack = (s) => s === 'UnackAlarm' || s === 'RtnUnack'; let lastShelved = []; const shelfExpiry = new Map(); // alarmId → mốc hết hạn (wall-clock ms) để đếm ngược mượt phía client function syncShelfExpiry(shelved){ // Đồng bộ mốc hết hạn từ remainingMin server (nguồn chuẩn) mỗi lần nhận 'alarms' (sim ~ thời gian thực). const ids = new Set(); for (const s of shelved){ ids.add(s.alarmId); shelfExpiry.set(s.alarmId, Date.now() + Math.max(0, s.remainingMin) * 60000); } for (const id of [...shelfExpiry.keys()]) if (!ids.has(id)) shelfExpiry.delete(id); // bung/hết hạn → bỏ } function fmtCountdown(id){ const exp = shelfExpiry.get(id); if (exp === undefined) return '—'; const ms = exp - Date.now(); if (ms <= 0) return 'hết hạn'; const m = Math.floor(ms / 60000), sX = Math.floor((ms % 60000) / 1000); return m + 'm ' + String(sX).padStart(2, '0') + 's'; } function renderAlarms(active, shelved){ if (shelved !== undefined){ lastShelved = shelved; syncShelfExpiry(shelved); } const c = { P1:0, P2:0, P3:0, P4:0 }; for (const a of active) c[a.priority] = (c[a.priority] || 0) + 1; $('cP1').textContent = c.P1; $('cP2').textContent = c.P2; $('cP3').textContent = c.P3; $('cP4').textContent = c.P4; const unacked = active.filter(a => isUnack(a.state)); const show = (unacked.length ? unacked : active).slice(0, 3); const list = $('alist'); if (!show.length) list.innerHTML = '
Không có alarm đang hoạt động
'; else list.innerHTML = show.map(a => '
' + '' + '' + a.alarmId + '' + a.priority + '' + '' + a.state + (typeof a.value === 'number' ? ' · ' + a.value.toFixed(1) : '') + '' + '
').join(''); list.querySelectorAll('.arow[data-alarm]').forEach(r => { r.onclick = (e) => { if (e.target.closest('.shelfrow')) return; const s = navAlarmIdx[r.dataset.alarm]; if (s) openScreen(s); }; }); list.querySelectorAll('button[data-shelf]').forEach(b => { b.onclick = (e) => { e.stopPropagation(); openShelveModal(b.dataset.shelf); }; }); const latest = unacked[unacked.length - 1]; const btn = $('ackBtn'); btn.disabled = !latest; btn.dataset.id = latest ? latest.alarmId : ''; const adv = $('adviseBtn'); const first = active[0]; adv.disabled = !first; adv.dataset.id = first ? first.alarmId : ''; $('shelfN').textContent = lastShelved.length; $('shelfBtn').classList.toggle('warn', lastShelved.length > 0); if ($('shelvedMask').style.display === 'flex') renderShelvedBody(); } $('ackBtn').onclick = () => { const id = $('ackBtn').dataset.id; if (id && ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd: 'ack', alarmId: id })); }; $('adviseBtn').onclick = () => { const id = $('adviseBtn').dataset.id; if (id && ws && ws.readyState === 1) ws.send(JSON.stringify({ cmd: 'advise', alarmId: id })); }; // Shelve (ISA-18.2): mở modal chọn lý do + thời hạn cho một alarm. function openShelveModal(alarmId){ $('shelfTarget').textContent = alarmId; $('shelfMask').dataset.id = alarmId; $('shelfReason').value = ''; $('shelfDur').value = '60'; $('shelfMask').style.display = 'flex'; $('shelfReason').focus(); } $('shelfCancel').onclick = () => { $('shelfMask').style.display = 'none'; }; $('shelfConfirm').onclick = () => { const id = $('shelfMask').dataset.id; const reason = $('shelfReason').value.trim(); const durationMin = Number($('shelfDur').value); if (!reason){ $('shelfReason').style.borderColor = 'var(--alarm-1)'; return; } send({ cmd: 'shelve', alarmId: id, durationMin, reason }); $('shelfMask').style.display = 'none'; }; // Danh sách alarm đang shelve + bung thủ công (unshelve). function renderShelvedBody(){ const b = $('shelvedBody'); if (!lastShelved.length){ b.innerHTML = '
Không có alarm đang shelve.
'; return; } b.innerHTML = lastShelved.map(s => '
' + '' + s.alarmId + ' (' + s.priority + ')
lý do: ' + s.reason + ' · bởi ' + s.user + ' · còn ' + fmtCountdown(s.alarmId) + '
' + '
').join(''); b.querySelectorAll('button[data-unshelf]').forEach(x => { x.onclick = () => send({ cmd: 'unshelve', alarmId: x.dataset.unshelf }); }); } // Tick 1s: cập nhật ĐỒNG HỒ ĐẾM NGƯỢC tại chỗ (không re-render) khi modal shelved đang mở. setInterval(() => { if ($('shelvedMask').style.display !== 'flex') return; $('shelvedBody').querySelectorAll('.cd[data-cd]').forEach(el => { el.textContent = fmtCountdown(el.dataset.cd); }); }, 1000); $('shelfBtn').onclick = () => { renderShelvedBody(); $('shelvedMask').style.display = 'flex'; }; $('shelvedClose').onclick = () => { $('shelvedMask').style.display = 'none'; }; document.querySelectorAll('.controls button[data-load]').forEach(b => b.onclick = () => ws && ws.readyState === 1 && ws.send(JSON.stringify({ cmd: 'load', value: Number(b.dataset.load) }))); document.querySelectorAll('.controls button[data-cmd]').forEach(b => b.onclick = () => ws && ws.readyState === 1 && ws.send(JSON.stringify({ cmd: b.dataset.cmd }))); document.querySelectorAll('.controls button[data-leak]').forEach(b => b.onclick = () => ws && ws.readyState === 1 && ws.send(JSON.stringify({ cmd: 'leak', value: Number(b.dataset.leak) }))); // Malfunction toggle (OTS): bấm bật/tắt sự cố; viền đỏ = đang tiêm. const malfOn = new Set(); document.querySelectorAll('.controls button[data-malf]').forEach(b => b.onclick = () => { if (!(ws && ws.readyState === 1)) return; const id = b.dataset.malf, on = !malfOn.has(id); if (on) malfOn.add(id); else malfOn.delete(id); b.style.outline = on ? '2px solid var(--alarm-1)' : ''; ws.send(JSON.stringify({ cmd: 'malf', malf: id, value: on ? 1 : 0 })); }); // Cây điều hướng đến từ WS ('nav' — Navigation Engine); onNav dựng nav + mở D1. connect();