Initial commit

This commit is contained in:
2026-08-24 15:35:32 +05:30
commit fcade251a6
51 changed files with 11565 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LINE-1 Digital Twin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "digitaltwin-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^3.2.1",
"three": "^0.180.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.2",
"vite": "^7.1.5"
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Layout.
*
* The 3D hero sits beside the copilot, so the two things that make this different
* from a SCADA screen are on the same screen line. KPIs, the station strip and the
* detail charts run beneath, with alarms and the what-if controls to the right of
* them - roughly the order you talk through in the demo.
*/
import { useTelemetry } from './hooks/useTelemetry.js';
import { useStore } from './store.js';
import { Scene } from './three/Scene.jsx';
import { TimeControls } from './panels/TimeControls.jsx';
import { KpiRow } from './panels/KpiRow.jsx';
import { StationStrip } from './panels/StationStrip.jsx';
import { StationDetail } from './panels/StationDetail.jsx';
import { AlarmFeed } from './panels/AlarmFeed.jsx';
import { WhatIfPanel } from './panels/WhatIfPanel.jsx';
import { Copilot } from './panels/Copilot.jsx';
import { SettingsPage } from './panels/SettingsPage.jsx';
import { askCopilot, explainAlarmPrompt } from './lib/copilotClient.js';
export default function App() {
useTelemetry();
const connected = useStore((s) => s.connected);
const connError = useStore((s) => s.connError);
const frame = useStore((s) => s.frame);
const select = useStore((s) => s.select);
const settingsOpen = useStore((s) => s.settingsOpen);
const explain = (alarm) => {
select(alarm.station);
askCopilot(explainAlarmPrompt(alarm));
};
return (
<div className="app">
<TimeControls />
{settingsOpen && <SettingsPage />}
{!connected && connError && <div className="banner">{connError}</div>}
{!frame ? (
<div className="card"><div className="empty">
Connecting to the telemetry stream. If this persists, check that the server is
running on port 8787.
</div></div>
) : (
<>
<div className="row-hero">
<Scene />
<Copilot />
</div>
<KpiRow />
<StationStrip />
<div className="row-lower">
<div className="col"><StationDetail /></div>
<div className="col">
<AlarmFeed onExplain={explain} />
<WhatIfPanel />
</div>
</div>
</>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* WebSocket telemetry client.
*
* Reconnects automatically with a bounded backoff. On stage the server is on the
* same laptop, so the realistic failure is "I restarted the server mid-demo" -
* the dashboard has to come back on its own rather than needing a page reload.
*/
import { useEffect, useRef } from 'react';
import { useStore } from '../store.js';
const RECONNECT_MIN_MS = 500;
const RECONNECT_MAX_MS = 5000;
export function useTelemetry() {
const timerRef = useRef(null);
const socketRef = useRef(null);
const delayRef = useRef(RECONNECT_MIN_MS);
useEffect(() => {
let disposed = false;
// Static metadata (station specs, thresholds, fault catalogue) is fetched
// once instead of riding along on every frame.
const loadMeta = async () => {
try {
const res = await fetch('/api/meta');
if (res.ok) useStore.getState().setMeta(await res.json());
} catch {
/* the socket carries the important part; meta will retry on reconnect */
}
};
loadMeta();
const connect = () => {
if (disposed) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/ws`);
socketRef.current = ws;
ws.onopen = () => {
delayRef.current = RECONNECT_MIN_MS;
useStore.getState().setConnected(true);
if (!useStore.getState().meta) loadMeta();
};
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'history') {
useStore.getState().seedHistory(msg.frames);
return;
}
if (msg.type !== 'frame') return;
const state = useStore.getState();
const prev = state.frame;
// A reset rewinds the simulated clock. Client history from the previous
// run would otherwise render as a jump backwards on every chart.
if (prev && msg.frame.t < prev.t - 1) state.clearHistory();
state.ingest(msg.frame);
};
ws.onclose = () => {
useStore.getState().setConnected(false, 'Telemetry stream disconnected. Reconnecting…');
if (disposed) return;
timerRef.current = setTimeout(connect, delayRef.current);
delayRef.current = Math.min(RECONNECT_MAX_MS, delayRef.current * 2);
};
ws.onerror = () => ws.close();
};
connect();
return () => {
disposed = true;
if (timerRef.current) clearTimeout(timerRef.current);
if (socketRef.current) {
socketRef.current.onclose = null;
socketRef.current.close();
}
};
}, []);
}
/** POST a control action. Returns the parsed body, or throws with the server message. */
export async function control(path, body) {
const res = await fetch(`/api/control/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) throw new Error(json.error || `Request failed (${res.status})`);
return json;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Copilot SSE client.
*
* Kept out of the component so the alarm feed's "Explain this" button and the
* copilot's own input box drive exactly the same code path.
*/
import { useStore } from '../store.js';
let controller = null;
let ticker = null;
export function abortAsk() {
if (controller) controller.abort();
controller = null;
if (ticker) clearInterval(ticker);
ticker = null;
useStore.getState().setCopilot({ streaming: false });
}
/**
* Ask a question and stream the answer into the message log.
*
* providerOverride: 'auto' | 'openrouter' | 'ollama' | 'fallback'
*/
export async function askCopilot(question, providerOverride) {
const store = useStore.getState();
const q = String(question || '').trim();
if (!q || store.copilot.streaming) return;
const provider = providerOverride || store.copilot.provider;
// Only user/assistant turns go back as history; notes and metadata do not.
const history = store.copilot.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role, content: m.content }));
store.pushMessage({ role: 'user', content: q });
store.pushMessage({ role: 'assistant', content: '', note: null, label: null });
store.setCopilot({ streaming: true, elapsedMs: 0 });
const started = Date.now();
ticker = setInterval(
() => useStore.getState().setCopilot({ elapsedMs: Date.now() - started }),
100,
);
controller = new AbortController();
try {
const res = await fetch('/api/copilot/chat', {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q, history, provider }),
});
if (!res.ok || !res.body) throw new Error(`Request failed (${res.status})`);
const decoder = new TextDecoder();
let buf = '';
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 2);
if (!line.startsWith('data:')) continue;
let obj;
try { obj = JSON.parse(line.slice(5).trim()); } catch { continue; }
const s = useStore.getState();
if (obj.type === 'meta') {
s.patchLast({ label: obj.label });
s.setCopilot({ activeLabel: obj.label });
} else if (obj.type === 'token') {
s.appendToLast(obj.text);
} else if (obj.type === 'note') {
s.patchLast({ note: obj.text });
}
}
}
} catch (err) {
if (err.name !== 'AbortError') {
useStore.getState().patchLast({
note: `Could not reach the copilot: ${err.message}`,
});
}
} finally {
if (ticker) clearInterval(ticker);
ticker = null;
controller = null;
const s = useStore.getState();
s.setCopilot({ streaming: false, elapsedMs: Date.now() - started });
// An empty bubble looks broken; say what happened instead.
const msgs = s.copilot.messages;
const last = msgs[msgs.length - 1];
if (last && last.role === 'assistant' && !last.content.trim()) {
s.patchLast({ content: '(No answer was returned. Try the offline rule engine from the provider selector.)' });
}
}
}
/** Turn an alarm into a question the copilot can answer well. */
export function explainAlarmPrompt(alarm) {
const where = alarm.signal ? `${alarm.station}.${alarm.signal}` : alarm.station;
return `Explain this alarm on ${where}: "${alarm.message}" — what is the likely root cause, what is the production impact, and what should the operator do?`;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Formatting and status helpers shared by every panel and the 3D scene, so a
* station reads the same colour and the same words everywhere it appears.
*/
export function num(v, precision = 1) {
if (!Number.isFinite(v)) return '—';
return v.toFixed(precision);
}
export function pct(v, precision = 1) {
if (!Number.isFinite(v)) return '—';
return `${(v * 100).toFixed(precision)}%`;
}
/** Simulated seconds as a human duration. Mirrors the server-side formatter. */
export function duration(sec) {
if (!Number.isFinite(sec)) return '—';
if (sec < 90) return `${Math.round(sec)}s`;
if (sec < 5400) return `${(sec / 60).toFixed(1)} min`;
if (sec < 172800) return `${(sec / 3600).toFixed(1)} h`;
return `${(sec / 86400).toFixed(1)} d`;
}
/** Wall-style clock for the simulated run time. */
export function clock(sec) {
const s = Math.max(0, Math.floor(sec));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}
/** Where a reading sits against its declared thresholds. */
export function signalStatus(spec, v) {
if (!spec || !Number.isFinite(v)) return 'ok';
if (spec.alarmHigh !== undefined && v >= spec.alarmHigh) return 'alarm';
if (spec.alarmLow !== undefined && v <= spec.alarmLow) return 'alarm';
if (spec.warnHigh !== undefined && v >= spec.warnHigh) return 'warn';
if (spec.warnLow !== undefined && v <= spec.warnLow) return 'warn';
return 'ok';
}
export const STATUS_COLOR = {
ok: 'var(--text-primary)',
warn: 'var(--warning)',
alarm: 'var(--critical)',
};
/**
* Station state presentation.
*
* Every state carries a word as well as a colour - a status colour never conveys
* meaning on its own.
*/
export const STATE_META = {
running: { label: 'Running', color: '#0ca30c' },
microstop: { label: 'Micro-stop', color: '#fab219' },
blocked: { label: 'Blocked', color: '#ec835a' },
starved: { label: 'Starved', color: '#898781' },
down: { label: 'Down', color: '#d03b3b' },
fault: { label: 'Fault', color: '#d03b3b' },
idle: { label: 'Idle', color: '#898781' },
};
export function stateMeta(state) {
return STATE_META[state] || { label: state || 'Unknown', color: '#898781' };
}
/** The signal a station card leads with. */
export function primarySignal(spec) {
if (!spec) return null;
return spec.signals.find((g) => g.primary) || spec.signals[0];
}
/**
* Render a very small subset of markdown inline: **bold** only.
*
* Local models reliably emit bold for emphasis and little else. Rendering just
* that keeps the copilot output readable without pulling in a markdown parser.
*/
export function renderBold(text) {
const parts = [];
const re = /\*\*(.+?)\*\*/g;
let last = 0;
let m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push(text.slice(last, m.index));
parts.push({ bold: m[1] });
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(<App />);
+70
View File
@@ -0,0 +1,70 @@
/**
* Active alarms, most severe first.
*
* Every alarm carries its severity as a WORD as well as a colour, and "Explain"
* hands the alarm to the copilot - which is the moment the demo stops looking
* like a dashboard and starts looking like a twin that understands itself.
*/
import { useStore } from '../store.js';
import { duration } from '../lib/format.js';
const SEVERITY_ORDER = ['critical', 'major', 'warning', 'predictive', 'info'];
export function AlarmFeed({ onExplain }) {
const frame = useStore((s) => s.frame);
const select = useStore((s) => s.select);
if (!frame) return null;
const alarms = frame.analytics.alarms;
const counts = SEVERITY_ORDER.map((sev) => ({
sev,
n: alarms.filter((a) => a.severity === sev).length,
})).filter((x) => x.n > 0);
return (
<div className="card">
<h2 className="card-title">
Alarms
<span className="hint">
{counts.length
? counts.map((c) => `${c.n} ${c.sev}`).join(' · ')
: 'all clear'}
</span>
</h2>
{alarms.length === 0 ? (
<div className="empty">
No active alarms. All signals are within limits and no threshold crossings are projected.
</div>
) : (
<div className="alarm-list">
{alarms.map((a) => (
<div className={`alarm ${a.severity}`} key={a.key}>
<div className="alarm-top">
<span className={`sev ${a.severity}`}>
{a.severity === 'predictive' ? 'Trend' : a.severity}
</span>
<button
className="who"
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => select(a.station)}
title={`Select ${a.station}`}
>
{a.station}{a.signal ? `.${a.signal}` : ''}
</button>
<span className="muted small" style={{ marginLeft: 'auto' }}>
{duration(frame.t - a.raisedAt)} ago
</span>
</div>
<div className="msg">{a.message}</div>
<div className="actions">
<button onClick={() => onExplain(a)}>Explain this</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* The plant copilot panel.
*
* Two deliberate choices here.
*
* The provider selector is visible rather than buried in config. A strong
* reasoning model gives markedly better analysis but can take 15-20 s to first
* token, which is a long silence in front of a customer; the local model answers
* in a couple of seconds; the rule engine answers instantly and offline. Being
* able to switch mid-demo, and to SHOW that the reasoning is reproducible without
* any model at all, is worth the panel space.
*
* The suggestion chips exist so nobody has to type on stage.
*/
import { useEffect, useRef, useState } from 'react';
import { useStore } from '../store.js';
import { askCopilot, abortAsk } from '../lib/copilotClient.js';
import { renderBold } from '../lib/format.js';
const CHIPS = [
'Why is OEE down?',
"What's wrong with CNC-02?",
'What happens if I raise line speed 10%?',
'Draft a maintenance work order.',
'Which station is the constraint right now?',
];
const PROVIDERS = [
{ id: 'auto', label: 'Auto' },
{ id: 'openrouter', label: 'Cloud' },
{ id: 'ollama', label: 'Local' },
{ id: 'fallback', label: 'Offline rules' },
];
function Message({ msg }) {
if (msg.role === 'user') return <div className="msg user">{msg.content}</div>;
return (
<div className="msg assistant">
<div className="who">{msg.label || 'Copilot'}</div>
{msg.note && <div className="note">{msg.note}</div>}
{renderBold(msg.content).map((part, i) =>
typeof part === 'string'
? <span key={i}>{part}</span>
: <strong key={i}>{part.bold}</strong>,
)}
</div>
);
}
export function Copilot() {
const messages = useStore((s) => s.copilot.messages);
const streaming = useStore((s) => s.copilot.streaming);
const elapsedMs = useStore((s) => s.copilot.elapsedMs);
const provider = useStore((s) => s.copilot.provider);
const setCopilot = useStore((s) => s.setCopilot);
const configured = useStore((s) => (s.meta ? s.meta.copilot : null));
const [text, setText] = useState('');
const logRef = useRef(null);
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [messages]);
const submit = (e) => {
e.preventDefault();
const q = text.trim();
if (!q) return;
setText('');
askCopilot(q);
};
return (
<div className="card copilot" style={{ minHeight: 0 }}>
<h2 className="card-title">
Plant copilot
<span className="hint">{configured ? configured.label : 'detecting…'}</span>
</h2>
<div className="btn-group" style={{ marginBottom: 10 }}>
{PROVIDERS.map((p) => (
<button
key={p.id}
className={provider === p.id ? 'active' : ''}
disabled={streaming}
onClick={() => setCopilot({ provider: p.id })}
title={
p.id === 'auto' ? 'Use whichever provider the server detected'
: p.id === 'openrouter' ? 'Cloud reasoning model — best analysis, slowest to first token'
: p.id === 'ollama' ? 'Local model — fast, needs Ollama running'
: 'Deterministic rule engine — instant, works with no network at all'
}
>
{p.label}
</button>
))}
</div>
<div className="copilot-log" ref={logRef}>
{messages.length === 0 && (
<div className="empty" style={{ textAlign: 'left', lineHeight: 1.6 }}>
Ask about the line and I will answer from live telemetry — station states,
readings against their limits, OEE attribution and measured trends.
<br /><br />
I am not told which fault was injected; the diagnosis comes from the data.
</div>
)}
{messages.map((m, i) => <Message key={i} msg={m} />)}
{streaming && (
<div className="thinking">
<span className="spinner" />
Thinking… {(elapsedMs / 1000).toFixed(1)}s
<button style={{ padding: '2px 7px', fontSize: 10.5 }} onClick={abortAsk}>Stop</button>
</div>
)}
</div>
<div className="chips">
{CHIPS.map((c) => (
<button key={c} disabled={streaming} onClick={() => askCopilot(c)}>{c}</button>
))}
</div>
<form className="ask" onSubmit={submit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Ask about the line…"
disabled={streaming}
/>
<button type="submit" disabled={streaming || !text.trim()}>Ask</button>
</form>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Line KPI tiles.
*
* OEE gets a hero figure; its three factors sit beside it as their own tiles with
* magnitude meters. Deliberately tiles rather than a chart: these are five
* single headline numbers, and a bar chart of five unrelated measures would be
* harder to read, not easier.
*/
import { useStore } from '../store.js';
function Meter({ value, color }) {
return (
<div className="meter">
<span style={{ width: `${Math.max(0, Math.min(100, value))}%`, background: color }} />
</div>
);
}
/** Colour the OEE factors by how bad the loss is, always beside a number. */
function factorColor(v) {
if (v >= 0.95) return 'var(--good)';
if (v >= 0.85) return 'var(--series-1)';
if (v >= 0.70) return 'var(--warning)';
return 'var(--critical)';
}
export function KpiRow() {
const kpi = useStore((s) => (s.frame ? s.frame.kpi : null));
const windowSec = useStore((s) => (s.meta ? s.meta.kpiWindowSec : 1200));
if (!kpi) {
return <div className="card"><div className="empty">Waiting for telemetry…</div></div>;
}
const factors = [
{ label: 'Availability', v: kpi.availability, foot: 'Stoppage losses' },
{ label: 'Performance', v: kpi.performance, foot: 'Micro-stops, slow cycles' },
{ label: 'Quality', v: kpi.quality, foot: 'Rejects at INS-04' },
];
return (
<div className="kpi-row">
<div className="tile hero">
<span className="label">OEE · rolling {Math.round(windowSec / 60)} min</span>
<span className="value" style={{ color: factorColor(kpi.oee) }}>
{(kpi.oee * 100).toFixed(1)}<span className="unit">%</span>
</span>
<Meter value={kpi.oee * 100} color={factorColor(kpi.oee)} />
<span className="foot">Availability × Performance × Quality</span>
</div>
{factors.map((f) => (
<div className="tile" key={f.label}>
<span className="label">{f.label}</span>
<span className="value">{(f.v * 100).toFixed(1)}<span className="unit">%</span></span>
<Meter value={f.v * 100} color={factorColor(f.v)} />
<span className="foot">{f.foot}</span>
</div>
))}
<div className="tile">
<span className="label">Output</span>
<span className="value">{kpi.throughputPerHour.toFixed(0)}<span className="unit">/h</span></span>
<span className="foot">
{kpi.good.toLocaleString()} good · {kpi.rejected.toLocaleString()} rejected
</span>
</div>
<div className="tile">
<span className="label">Energy</span>
<span className="value">{kpi.energyKw.toFixed(0)}<span className="unit">kW</span></span>
<span className="foot">
{kpi.energyPerUnit > 0 ? `${kpi.energyPerUnit.toFixed(3)} kWh/unit` : '—'}
</span>
</div>
</div>
);
}
+432
View File
@@ -0,0 +1,432 @@
/**
* Copilot settings.
*
* Rendered as a full-screen overlay rather than a route: the dashboard keeps
* streaming behind it, so changing a model never interrupts the demo, and there is
* no router dependency for a single page.
*
* The Test button is the point of this page as much as the model list is. A model
* that answers brilliantly in 30 seconds is unusable in front of a customer, and
* that is not something you want to discover mid-demo. Measure first.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useStore } from '../store.js';
const PROVIDERS = [
{ id: 'auto', title: 'Auto', desc: 'Cloud if a key is set, else local, else offline rules.' },
{ id: 'openrouter', title: 'Cloud', desc: 'OpenRouter. Best analysis, slowest to first token.' },
{ id: 'ollama', title: 'Local', desc: 'Ollama on this machine. Fast, needs it running.' },
{ id: 'fallback', title: 'Offline rules', desc: 'Deterministic rule engine. Instant, no network.' },
];
const EFFORTS = ['none', 'low', 'medium', 'high'];
/** Latency bands for the test result, in ms to first token. */
function latencyClass(ms) {
if (ms === null || ms === undefined) return 'bad';
if (ms < 4000) return 'good';
if (ms < 12000) return 'slow';
return 'bad';
}
function latencyVerdict(ms) {
if (ms < 4000) return 'Fast enough to present live.';
if (ms < 12000) return 'Usable, but there will be a noticeable pause on stage.';
return 'Too slow for a live beat — use this for follow-up questions only.';
}
function bytes(n) {
if (!n) return null;
return `${(n / 1e9).toFixed(1)} GB`;
}
function ModelPanel({
title, provider, models, error, loading, selected, active,
search, onSearch, onPick, onRefresh, onTest, testing, result, children,
}) {
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return models;
return models.filter((m) => m.id.toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q));
}, [models, search]);
const shown = filtered.slice(0, 80);
return (
<div className={`model-panel ${active ? 'active' : ''}`}>
<div className="model-panel-head">
<span className="t">{title}</span>
{active && <span className="badge live"><span className="dot" />Active</span>}
<span className="spacer" />
<button onClick={onRefresh} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
<button onClick={onTest} disabled={testing}>{testing ? 'Testing…' : 'Test'}</button>
</div>
{children}
{error ? (
<div className="test-result bad">{error}</div>
) : (
<>
<input
type="search"
placeholder={`Filter ${models.length} models…`}
value={search}
onChange={(e) => onSearch(e.target.value)}
/>
<div className="model-list">
{shown.length === 0 && <div className="empty">No models match that filter.</div>}
{shown.map((m) => (
<button
key={m.id}
className={`model-row ${selected === m.id ? 'sel' : ''}`}
onClick={() => onPick(m.id)}
title={m.name}
>
<span className="mid">{m.id}</span>
<span className="meta">
{provider === 'openrouter'
? [
m.contextLength ? `${Math.round(m.contextLength / 1000)}k ctx` : null,
m.promptPerM !== null ? `$${m.promptPerM.toFixed(2)}/M` : null,
].filter(Boolean).join(' · ')
: [m.parameterSize, m.quantization, bytes(m.sizeBytes)].filter(Boolean).join(' · ')}
</span>
</button>
))}
</div>
{filtered.length > shown.length && (
<span className="small muted">
Showing {shown.length} of {filtered.length} matches — narrow the filter to see more.
</span>
)}
</>
)}
{result && (
<div className={`test-result ${result.ok ? latencyClass(result.firstTokenMs) : 'bad'}`}>
{result.ok ? (
<>
<span className="lat">{result.firstTokenMs} ms</span> to first token
{result.totalMs !== result.firstTokenMs && <> · {result.totalMs} ms total</>}
<br />{latencyVerdict(result.firstTokenMs)}
{provider === 'ollama' && (
<><br /><span className="muted">
A first test after switching models includes loading it into memory. Run it twice.
</span></>
)}
</>
) : (
<>Test failed: {result.error}</>
)}
</div>
)}
</div>
);
}
export function SettingsPage() {
const close = useStore((s) => s.closeSettings);
const setMeta = useStore((s) => s.setMeta);
const setCopilot = useStore((s) => s.setCopilot);
const [settings, setSettings] = useState(null);
const [status, setStatus] = useState(null);
const [errors, setErrors] = useState([]);
const [flash, setFlash] = useState(null);
const [orModels, setOrModels] = useState({ models: [], error: null, loading: true });
const [olModels, setOlModels] = useState({ models: [], error: null, loading: true });
const [orSearch, setOrSearch] = useState('');
const [olSearch, setOlSearch] = useState('');
const [testing, setTesting] = useState(null);
const [results, setResults] = useState({});
// --- load ---
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((j) => { setSettings(j.settings); setStatus(j.status); })
.catch((e) => setErrors([`Could not load settings: ${e.message}`]));
}, []);
const loadModels = useCallback((provider, force = false) => {
const set = provider === 'openrouter' ? setOrModels : setOlModels;
set((s) => ({ ...s, loading: true }));
fetch(`/api/copilot/models?provider=${provider}${force ? '&force=1' : ''}`)
.then((r) => r.json())
.then((j) => set({ models: j.models || [], error: j.error || null, loading: false }))
.catch((e) => set({ models: [], error: e.message, loading: false }));
}, []);
useEffect(() => {
loadModels('openrouter');
loadModels('ollama');
}, [loadModels]);
// Escape closes, as an overlay should.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') close(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [close]);
const save = async (patch) => {
setErrors([]);
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const j = await res.json();
if (!j.ok) { setErrors(j.errors || ['Rejected.']); return; }
setSettings(j.settings);
setStatus(j.status);
setFlash('Saved');
setTimeout(() => setFlash(null), 1600);
// Refresh the badge shown in the header and the copilot panel.
const meta = await fetch('/api/meta').then((r) => r.json());
setMeta(meta);
// A provider chosen here becomes the default for new questions.
if (patch.provider) setCopilot({ provider: patch.provider });
// The host may have moved, so the local model list could be stale.
if (patch.ollamaHost) loadModels('ollama', true);
} catch (e) {
setErrors([e.message]);
}
};
const resetAll = async () => {
const j = await fetch('/api/settings/reset', { method: 'POST' }).then((r) => r.json());
setSettings(j.settings);
setStatus(j.status);
setFlash('Reset to environment defaults');
setTimeout(() => setFlash(null), 2200);
const meta = await fetch('/api/meta').then((r) => r.json());
setMeta(meta);
};
const runTest = async (provider, model) => {
setTesting(provider);
try {
const j = await fetch('/api/copilot/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, model }),
}).then((r) => r.json());
setResults((r) => ({ ...r, [provider]: j }));
} catch (e) {
setResults((r) => ({ ...r, [provider]: { ok: false, error: e.message } }));
} finally {
setTesting(null);
}
};
if (!settings) {
return (
<div className="settings-scrim" onClick={close}>
<div className="settings" onClick={(e) => e.stopPropagation()}>
<div className="settings-body"><div className="empty">Loading settings…</div></div>
</div>
</div>
);
}
const effective = status ? status.provider : 'fallback';
return (
<div className="settings-scrim" onClick={close}>
<div className="settings" onClick={(e) => e.stopPropagation()}>
<div className="settings-head">
<h2>Copilot settings</h2>
{status && (
<span className="badge live"><span className="dot" />{status.label}</span>
)}
<span className="spacer" />
{flash && <span className="saved-flash">{flash}</span>}
<button onClick={close}>Close</button>
</div>
<div className="settings-body">
{errors.length > 0 && (
<div className="banner">{errors.join(' · ')}</div>
)}
<div className="settings-section">
<h3>Provider</h3>
<p className="desc">
Which engine answers by default. You can still override per question from the
copilot panel. {status && <>Currently resolving to <strong>{effective}</strong> — {status.detail}.</>}
</p>
<div className="provider-grid">
{PROVIDERS.map((p) => (
<button
key={p.id}
className={`provider-opt ${settings.provider === p.id ? 'sel' : ''}`}
onClick={() => save({ provider: p.id })}
>
<span className="t">{p.title}</span>
<span className="d">{p.desc}</span>
</button>
))}
</div>
{!settings.openRouterKeyPresent && (
<span className="small muted">
No OPENROUTER_API_KEY is set, so Cloud is unavailable and the model list cannot be
fetched. Add the key to .env and restart the server.
</span>
)}
</div>
<div className="settings-section">
<h3>Model</h3>
<p className="desc">
Pick a model per provider, or type an id directly. Test measures time to first
token — the number that decides whether a model is presentable live.
</p>
<div className="model-cols">
<ModelPanel
title="Cloud · OpenRouter"
provider="openrouter"
models={orModels.models}
error={orModels.error}
loading={orModels.loading}
selected={settings.openRouterModel}
active={effective === 'openrouter'}
search={orSearch}
onSearch={setOrSearch}
onPick={(id) => save({ openRouterModel: id })}
onRefresh={() => loadModels('openrouter', true)}
onTest={() => runTest('openrouter', settings.openRouterModel)}
testing={testing === 'openrouter'}
result={results.openrouter}
>
<input
type="text"
className="mono"
value={settings.openRouterModel}
onChange={(e) => setSettings((s) => ({ ...s, openRouterModel: e.target.value }))}
onBlur={(e) => e.target.value && save({ openRouterModel: e.target.value.trim() })}
spellCheck={false}
/>
</ModelPanel>
<ModelPanel
title="Local · Ollama"
provider="ollama"
models={olModels.models}
error={olModels.error}
loading={olModels.loading}
selected={settings.ollamaModel}
active={effective === 'ollama'}
search={olSearch}
onSearch={setOlSearch}
onPick={(id) => save({ ollamaModel: id })}
onRefresh={() => loadModels('ollama', true)}
onTest={() => runTest('ollama', settings.ollamaModel)}
testing={testing === 'ollama'}
result={results.ollama}
>
<input
type="text"
className="mono"
value={settings.ollamaModel}
onChange={(e) => setSettings((s) => ({ ...s, ollamaModel: e.target.value }))}
onBlur={(e) => e.target.value && save({ ollamaModel: e.target.value.trim() })}
spellCheck={false}
/>
</ModelPanel>
</div>
</div>
<div className="settings-section">
<h3>Generation</h3>
<p className="desc">
Reasoning tokens count against the output budget on most providers. A reasoning
model on a tight budget spends it all thinking and returns nothing, so keep effort
low and the budget generous unless you have a reason not to.
</p>
<div className="gen-grid">
<div className="field" style={{ marginBottom: 0 }}>
<div className="field-head">
<span>Temperature</span>
<span className="v">{settings.temperature.toFixed(2)}</span>
</div>
<input
type="range" min={0} max={1} step={0.05}
value={settings.temperature}
onChange={(e) => setSettings((s) => ({ ...s, temperature: Number(e.target.value) }))}
onMouseUp={(e) => save({ temperature: Number(e.target.value) })}
onKeyUp={(e) => save({ temperature: Number(e.target.value) })}
/>
<span className="small muted">Low keeps diagnoses consistent between runs.</span>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<div className="field-head"><span>Max output tokens</span></div>
<input
type="number" min={128} max={8000} step={128}
value={settings.maxTokens}
onChange={(e) => setSettings((s) => ({ ...s, maxTokens: Number(e.target.value) }))}
onBlur={(e) => save({ maxTokens: Number(e.target.value) })}
/>
<span className="small muted">Includes reasoning tokens on most providers.</span>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<div className="field-head"><span>Reasoning effort</span></div>
<div className="btn-group">
{EFFORTS.map((e) => (
<button
key={e}
className={settings.reasoningEffort === e ? 'active' : ''}
onClick={() => save({ reasoningEffort: e })}
>
{e}
</button>
))}
</div>
<span className="small muted">Local models map none/low to reasoning off.</span>
</div>
</div>
</div>
<div className="settings-section">
<h3>Ollama host</h3>
<p className="desc">
Accepts a bare host, host:port, or full URL. Ollama often sets OLLAMA_HOST to
0.0.0.0 machine-wide, which is a bind address and not dialable — that is normalized
automatically.
</p>
<input
type="text"
className="mono"
value={settings.ollamaHost}
onChange={(e) => setSettings((s) => ({ ...s, ollamaHost: e.target.value }))}
onBlur={(e) => e.target.value && save({ ollamaHost: e.target.value.trim() })}
spellCheck={false}
/>
<span className="small muted">Dialing: <span className="mono">{settings.resolvedOllamaHost}</span></span>
</div>
</div>
<div className="settings-foot">
<button onClick={() => runTest('fallback')} disabled={testing === 'fallback'}>
Test offline rules
</button>
{results.fallback && (
<span className="small" style={{ color: 'var(--good)' }}>
{results.fallback.firstTokenMs} ms · always available
</span>
)}
<span className="spacer" />
<button className="danger" onClick={resetAll}>Reset to defaults</button>
<button onClick={close}>Done</button>
</div>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Trend charts for the selected station.
*
* Most signals get their own single-series chart. The oven is the exception: its
* three zone temperatures belong on one axis because comparing them to each other
* (and to setpoint) is the whole diagnosis, and they share a unit. Series colours
* there are the first three validated categorical slots.
*/
import { useStore } from '../store.js';
import { TrendChart } from './TrendChart.jsx';
import { duration } from '../lib/format.js';
/**
* Per-station chart grouping. Anything not named here becomes its own chart.
*/
const GROUPS = {
'OVN-03': [
{ title: 'Zone temperatures', keys: ['zone1Temp', 'zone2Temp', 'zone3Temp'] },
{ keys: ['tempDeviation'] },
{ keys: ['burnerDuty'] },
],
};
/** Turn per-signal history buffers into rows Recharts can consume. */
function buildRows(history, stationId, keys) {
const buffers = keys.map((k) => history[`${stationId}.${k}`] || []);
const base = buffers.reduce((a, b) => (b.length > a.length ? b : a), []);
if (!base.length) return [];
return base.map((point, i) => {
const row = { t: point.t };
keys.forEach((k, ki) => {
const buf = buffers[ki];
// Buffers are appended in lockstep, so align from the end to stay correct
// if one signal started later than another.
const idx = buf.length - (base.length - i);
row[k] = idx >= 0 && idx < buf.length ? buf[idx].v : null;
});
return row;
});
}
export function StationDetail() {
const selected = useStore((s) => s.selected);
const meta = useStore((s) => s.meta);
const history = useStore((s) => s.history);
const station = useStore((s) => (s.frame ? s.frame.stations.find((x) => x.id === selected) : null));
const predictions = useStore((s) => (s.frame ? s.frame.analytics.predictions : []));
if (!meta || !station) return null;
const spec = meta.stations.find((x) => x.id === selected);
if (!spec) return null;
const charted = spec.signals.filter((g) => g.chart);
const groups = GROUPS[selected] || charted.map((g) => ({ keys: [g.key] }));
const myPredictions = predictions.filter((p) => p.station === selected);
return (
<div className="card">
<h2 className="card-title">
{station.id} · {station.name}
<span className="hint">
{station.online ? `${charted.length} live signals` : 'Station is not reporting — traces paused'}
</span>
</h2>
{!station.online && (
<div className="banner" style={{ marginBottom: 12 }}>
No data from {station.id}. The readings shown elsewhere are the last known values, and
the charts stop rather than drawing a flat line that would look like a healthy reading.
</div>
)}
{myPredictions.length > 0 && (
<div className="stack" style={{ marginBottom: 12 }}>
{myPredictions.map((p) => (
<div className="alarm predictive" key={p.signal}>
<div className="alarm-top">
<span className="sev predictive">Trend</span>
<span className="who">{p.station}.{p.signal}</span>
</div>
<div className="msg">
{p.label} is rising {p.slopePerMin.toFixed(4)} {p.unit}/min. At this rate it reaches
the {p.threshold} {p.unit} limit in about <strong>{p.eta}</strong> of run time.
<span className="muted"> Least-squares fit over {duration(p.windowSec)}, r² {p.r2.toFixed(2)}.</span>
</div>
</div>
))}
</div>
)}
<div className="chart-grid">
{groups.map((group) => {
const series = group.keys
.map((k) => spec.signals.find((g) => g.key === k))
.filter(Boolean);
if (!series.length) return null;
const rows = buildRows(history, selected, group.keys);
return (
<TrendChart
key={group.keys.join('+')}
title={group.title || series[0].label}
series={series}
data={rows}
/>
);
})}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The five stations in process order, with WIP buffer levels between them.
*
* Clicking a card selects that station everywhere - here, in the detail charts,
* and in the 3D scene. The buffer pills between cards are what make blocking and
* starvation legible: when the packer stops, you watch these fill from the right.
*/
import { useStore } from '../store.js';
import { primarySignal, num, stateMeta, signalStatus, STATUS_COLOR } from '../lib/format.js';
function BufferPill({ level, capacity }) {
const frac = capacity > 0 ? level / capacity : 0;
const full = level >= capacity;
return (
<div
title={`Work in progress: ${level} of ${capacity}${full ? ' — full, upstream station is blocked' : ''}`}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
minWidth: 34, paddingTop: 26,
}}
>
<div style={{
width: 8, height: 42, borderRadius: 999, background: 'var(--grid)',
display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', overflow: 'hidden',
}}>
<div style={{
height: `${frac * 100}%`,
background: full ? 'var(--serious)' : 'var(--series-1)',
borderRadius: 999,
}} />
</div>
<span className="mono" style={{ fontSize: 9.5, color: full ? 'var(--serious)' : 'var(--text-muted)' }}>
{level}
</span>
</div>
);
}
export function StationStrip() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const selected = useStore((s) => s.selected);
const select = useStore((s) => s.select);
if (!frame || !meta) return null;
return (
<div className="card">
<h2 className="card-title">
Line 1 · stations
<span className="hint">WIP between stations shown as vertical bars</span>
</h2>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 4 }}>
{frame.stations.map((st, i) => {
const spec = meta.stations.find((x) => x.id === st.id);
const g = primarySignal(spec);
const value = g ? st.signals[g.key] : null;
const sm = stateMeta(st.state);
const status = signalStatus(g, value);
return (
<div key={st.id} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<button
className={`station-card ${selected === st.id ? 'sel' : ''}`}
style={{ borderLeftColor: sm.color, flex: 1, minWidth: 0 }}
onClick={() => select(st.id)}
aria-pressed={selected === st.id}
>
<div>
<div className="id">{st.id}</div>
<div className="nm">{st.name}</div>
</div>
{/* Colour plus the state word: never colour alone. */}
<span className={`state s-${st.state}`}>
<span className="dot" style={{ background: sm.color }} />
{st.online ? sm.label : 'No data'}
</span>
{g && (
<div>
<div className="primary" style={{ color: STATUS_COLOR[status] }}>
{num(value, g.precision)}<span className="u">{g.unit}</span>
</div>
<div className="nm">{g.label}</div>
</div>
)}
</button>
{i < frame.buffers.length && (
<BufferPill level={frame.buffers[i]} capacity={frame.bufferCapacity} />
)}
</div>
);
})}
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Header: identity, the simulated clock, and the clock multiplier.
*
* The speed control is not a convenience. A bearing trend that reads convincingly
* over twenty simulated minutes cannot be watched in real time in a meeting, and
* 60x turns that into twenty seconds. The multiplier is always shown next to the
* clock so nobody is misled about how fast time is passing.
*/
import { useState } from 'react';
import { useStore } from '../store.js';
import { control } from '../hooks/useTelemetry.js';
import { clock } from '../lib/format.js';
export function TimeControls() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const connected = useStore((s) => s.connected);
const clearHistory = useStore((s) => s.clearHistory);
const openSettings = useStore((s) => s.openSettings);
const copilotLabel = useStore((s) => (s.meta ? s.meta.copilot.label : null));
const [busy, setBusy] = useState(false);
const speeds = meta ? meta.speeds : [0, 1, 5, 20, 60];
const current = frame ? frame.sim.speed : 1;
const canTime = meta ? meta.source.capabilities.timeControl : false;
const send = async (path, body, after) => {
setBusy(true);
try {
await control(path, body);
if (after) after();
} catch {
/* the control panel surfaces errors; the header stays quiet */
} finally {
setBusy(false);
}
};
return (
<header className="header">
<div className="brand">
<h1>LINE-1 Digital Twin</h1>
<span className="sub">Assembly &amp; Cure · 5 stations</span>
</div>
<span className={`badge ${connected ? 'live' : 'stale'}`}>
<span className="dot" />
{connected ? 'Telemetry live' : 'Disconnected'}
</span>
{meta && (
<span className="badge">
<span className="dot" style={{ background: 'var(--series-1)' }} />
Source: {meta.source.kind}
</span>
)}
<div className="header-spacer" />
<span className="clock">
{frame ? clock(frame.t) : '00:00:00'}
<span className="muted"> sim</span>
</span>
<div className="btn-group">
{speeds.map((s) => (
<button
key={s}
className={current === s ? 'active' : ''}
disabled={!canTime || busy}
onClick={() => send('speed', { speed: s })}
title={s === 0 ? 'Pause the simulation' : `Run at ${s}x real time`}
>
{s === 0 ? 'Pause' : `${s}×`}
</button>
))}
</div>
<button
className="danger"
disabled={busy || !canTime}
onClick={() => send('reset', {}, clearHistory)}
title="Reset the line, clear faults and learned baselines"
>
Reset
</button>
<button
onClick={openSettings}
title="Choose the copilot provider and model, and measure its latency"
>
{copilotLabel ? `AI: ${copilotLabel.split(' · ')[0]}` : 'AI'} ⚙
</button>
</header>
);
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Trend chart for one or more signals on a station.
*
* Mark specs follow the data-viz method: 2px lines, no per-point dots, a
* recessive horizontal-only grid, muted axes, and a crosshair tooltip. Threshold
* lines are drawn in reserved status colours and always carry a text label, so a
* limit is never indicated by colour alone.
*
* The y-domain auto-fits the data rather than using the signal's full physical
* range: a bearing drifting from 1.6 to 2.2 mm/s is invisible on a fixed 0-6
* axis, and that drift is the entire point. A threshold is folded into the domain
* once the data gets within reach of it, so the line you are climbing toward
* appears exactly when it becomes relevant.
*/
import { memo } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer,
} from 'recharts';
import { duration, num } from '../lib/format.js';
const SERIES_COLORS = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)'];
function CustomTooltip({ active, payload, label, series }) {
if (!active || !payload || !payload.length) return null;
return (
<div className="tooltip">
<div className="t-time">t = {duration(label)}</div>
{payload.map((p) => {
const s = series.find((x) => x.key === p.dataKey);
return (
<div className="t-row" key={p.dataKey}>
<span className="legend-swatch" style={{ background: p.stroke, height: 2, width: 10 }} />
<span>{s ? s.label : p.dataKey}</span>
<span className="t-val">{num(p.value, s ? s.precision : 2)}</span>
<span className="muted">{s ? s.unit : ''}</span>
</div>
);
})}
</div>
);
}
/**
* series: [{ key, label, unit, precision, warnHigh, alarmHigh, warnLow, alarmLow }]
* data: [{ t, <key>: value, ... }]
*/
function TrendChartBase({ title, series, data, height = 150 }) {
if (!data || data.length < 2) {
return (
<div className="chart-box">
{title && <div className="chart-head"><span className="chart-name">{title}</span></div>}
<div className="empty" style={{ height }}>Collecting data…</div>
</div>
);
}
// --- domain ---
let lo = Infinity;
let hi = -Infinity;
for (const row of data) {
for (const s of series) {
const v = row[s.key];
if (Number.isFinite(v)) { if (v < lo) lo = v; if (v > hi) hi = v; }
}
}
if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
// Pull a threshold into view only once the data approaches it.
const marks = [];
for (const s of series) {
const span = Math.max(1e-6, hi - lo);
const consider = (value, kind) => {
if (value === undefined) return;
const near = value > hi ? value - hi < span * 2.5 : lo - value < span * 2.5;
if (!near) return;
marks.push({ value, kind, label: s.unit ? `${kind === 'alarm' ? 'Alarm' : 'Warn'} ${value}` : String(value) });
if (value > hi) hi = value;
if (value < lo) lo = value;
};
consider(s.alarmHigh, 'alarm');
consider(s.warnHigh, 'warn');
consider(s.alarmLow, 'alarm');
consider(s.warnLow, 'warn');
}
// Clamp to the signal's declared physical range. Padding and threshold
// inclusion can otherwise push the axis outside what the quantity can be - a
// spindle load axis running to -8% and 106%, or negative rpm. An impossible
// number on an axis is the kind of detail a plant engineer spots immediately.
const specMin = Math.min(...series.map((s) => (s.min ?? -Infinity)));
const specMax = Math.max(...series.map((s) => (s.max ?? Infinity)));
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.002, 1e-4);
const domain = [
Math.max(specMin, lo - pad),
Math.min(specMax, hi + pad),
];
const primary = series[0];
return (
<div className="chart-box">
{title && (
<div className="chart-head">
<span className="chart-name">{title}</span>
{series.length === 1 && (
<span className="chart-now">
{num(data[data.length - 1][primary.key], primary.precision)}
<span className="u">{primary.unit}</span>
</span>
)}
</div>
)}
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 6, right: 8, bottom: 2, left: -18 }}>
<CartesianGrid stroke="var(--grid)" strokeWidth={1} vertical={false} />
<XAxis
dataKey="t"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={duration}
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
stroke="var(--axis)"
minTickGap={44}
/>
<YAxis
domain={domain}
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
stroke="var(--axis)"
width={46}
tickFormatter={(v) => num(v, primary.precision > 1 ? 1 : primary.precision)}
/>
<Tooltip
content={<CustomTooltip series={series} />}
cursor={{ stroke: 'var(--text-muted)', strokeWidth: 1, strokeDasharray: '3 3' }}
isAnimationActive={false}
/>
{marks.map((m, i) => (
<ReferenceLine
key={`${m.kind}-${m.value}-${i}`}
y={m.value}
stroke={m.kind === 'alarm' ? 'var(--critical)' : 'var(--warning)'}
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: m.label,
position: 'insideTopRight',
fill: m.kind === 'alarm' ? 'var(--critical)' : 'var(--warning)',
fontSize: 9.5,
}}
/>
))}
{series.map((s, i) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
stroke={SERIES_COLORS[i % SERIES_COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
isAnimationActive={false}
connectNulls={false}
/>
))}
</LineChart>
</ResponsiveContainer>
{/* A legend is always present for two or more series, and each item carries
the name and its current value, so identity never rests on hue alone. */}
{series.length > 1 && (
<div className="legend">
{series.map((s, i) => (
<span className="legend-item" key={s.key}>
<span className="legend-swatch" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{s.label}
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{num(data[data.length - 1][s.key], s.precision)}
</span>
<span className="muted">{s.unit}</span>
</span>
))}
</div>
)}
</div>
);
}
export const TrendChart = memo(TrendChartBase);
+132
View File
@@ -0,0 +1,132 @@
/**
* The what-if panel: the controls that make this a twin rather than a dashboard.
*
* Setpoints are applied on release rather than on every slider tick, so dragging
* does not spam the model with intermediate values and pollute the event log.
*
* Controls the telemetry source cannot honour are disabled with a reason. A read
* -only historian or MQTT feed lets you observe the plant, not drive it, and the
* UI must say so rather than offering buttons that silently do nothing.
*/
import { useEffect, useState } from 'react';
import { useStore } from '../store.js';
import { control } from '../hooks/useTelemetry.js';
export function WhatIfPanel() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const [setpoint, setSetpoint] = useState(305);
const [lineSpeed, setLineSpeed] = useState(100);
const [error, setError] = useState(null);
const serverSetpoint = frame ? frame.controls.setpoint : 305;
const serverLineSpeed = frame ? frame.controls.lineSpeedPct : 100;
// Follow the server unless the user is mid-drag, so a reset or an external
// change is reflected here instead of the slider drifting out of sync.
const [dragging, setDragging] = useState(null);
useEffect(() => { if (dragging !== 'setpoint') setSetpoint(serverSetpoint); }, [serverSetpoint, dragging]);
useEffect(() => { if (dragging !== 'lineSpeed') setLineSpeed(serverLineSpeed); }, [serverLineSpeed, dragging]);
if (!frame || !meta) return null;
const caps = meta.source.capabilities;
const activeFaults = new Set(frame.faults.map((f) => f.id));
const send = async (path, body) => {
try {
setError(null);
await control(path, body);
} catch (err) {
setError(err.message);
}
};
return (
<div className="card">
<h2 className="card-title">
What-if controls
<span className="hint">{caps.setpointControl ? 'live on the model' : 'read-only source'}</span>
</h2>
{!caps.setpointControl && (
<div className="banner" style={{ marginBottom: 12 }}>
This telemetry source is read-only, so setpoints and faults cannot be driven from here.
</div>
)}
{error && <div className="banner" style={{ marginBottom: 12 }}>{error}</div>}
<div className="field">
<div className="field-head">
<span>Oven setpoint</span>
<span className="v">{setpoint} °C</span>
</div>
<input
type="range" min={250} max={340} step={1} value={setpoint}
disabled={!caps.setpointControl}
onChange={(e) => { setDragging('setpoint'); setSetpoint(Number(e.target.value)); }}
onMouseUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
onTouchEnd={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
onKeyUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
/>
<span className="small muted">
Zone 2 responds with a ~50 s lag. Reject rate climbs once deviation passes about 6 °C.
</span>
</div>
<div className="field">
<div className="field-head">
<span>Line speed</span>
<span className="v">{lineSpeed}%</span>
</div>
<input
type="range" min={50} max={130} step={1} value={lineSpeed}
disabled={!caps.setpointControl}
onChange={(e) => { setDragging('lineSpeed'); setLineSpeed(Number(e.target.value)); }}
onMouseUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
onTouchEnd={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
onKeyUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
/>
<span className="small muted">
CNC-02 is the bottleneck at 4.4 s. Faster cycles raise spindle load.
</span>
</div>
<div className="divider" />
<h2 className="card-title">Inject a fault</h2>
<div className="fault-list">
{meta.faults.map((f) => {
const on = activeFaults.has(f.id);
return (
<div className={`fault-row ${on ? 'on' : ''}`} key={f.id}>
<span className="nm">
{f.label}
<small>{f.station} · {f.severity}</small>
</span>
<button
className={on ? 'danger active' : ''}
disabled={!caps.faultInjection}
title={f.description}
onClick={() => send('fault', { id: f.id, action: on ? 'clear' : 'inject' })}
>
{on ? 'Clear' : 'Inject'}
</button>
</div>
);
})}
</div>
<div className="divider" />
<div className="inline">
<button disabled={!caps.setpointControl} onClick={() => send('tool-change', {})}>
Tool change
</button>
<span className="small muted">Resets CNC-02 tool wear, as a maintenance intervention would.</span>
</div>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Client state.
*
* Frames arrive at 2 Hz. A naive context provider would re-render the whole tree
* on every frame, which stutters the 3D canvas; selector subscriptions keep the
* canvas, the charts and the copilot independent of each other.
*
* Signal history is kept here rather than derived, because the server sends
* point-in-time snapshots, not series. The buffers are the client's memory of the
* run and are what the trend charts draw.
*/
import { create } from 'zustand';
/** Frames of history per signal. At 2 Hz this is about two real minutes. */
const HISTORY = 240;
export const useStore = create((set, get) => ({
connected: false,
connError: null,
meta: null,
frame: null,
/** "STATION.signal" -> [{ t, v }] */
history: {},
/** [{ t, oee, availability, performance, quality }] */
kpiHistory: [],
selected: 'CNC-02',
select: (id) => set({ selected: id }),
settingsOpen: false,
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
setConnected: (connected, connError = null) => set({ connected, connError }),
setMeta: (meta) => set({ meta }),
/** Fold one telemetry frame into state. */
ingest: (frame) => {
const { history, kpiHistory, meta } = get();
const nextHistory = { ...history };
for (const st of frame.stations) {
// A station that is not reporting must not extend its traces - a flat line
// drawn from stale data is indistinguishable from a real steady reading.
if (!st.online) continue;
for (const [key, value] of Object.entries(st.signals)) {
if (!Number.isFinite(value)) continue;
const k = `${st.id}.${key}`;
const prev = nextHistory[k];
const next = prev ? (prev.length >= HISTORY ? prev.slice(1) : prev.slice()) : [];
next.push({ t: frame.t, v: value });
nextHistory[k] = next;
}
}
const kpi = frame.kpi;
const nextKpi = kpiHistory.length >= HISTORY ? kpiHistory.slice(1) : kpiHistory.slice();
nextKpi.push({
t: frame.t,
oee: kpi.oee * 100,
availability: kpi.availability * 100,
performance: kpi.performance * 100,
quality: kpi.quality * 100,
});
set({ frame, history: nextHistory, kpiHistory: nextKpi });
// Guard against a stale selection if the asset model ever changes.
if (meta && !frame.stations.some((s) => s.id === get().selected)) {
set({ selected: frame.stations[0].id });
}
},
/**
* Seed history from the server's replay ring, so charts are populated the
* instant the page loads instead of filling over the next few minutes.
*/
seedHistory: (frames) => {
const history = {};
const kpiHistory = [];
for (const f of frames) {
for (const st of f.stations) {
if (!st.online) continue;
for (const [key, value] of Object.entries(st.signals)) {
if (!Number.isFinite(value)) continue;
const k = `${st.id}.${key}`;
if (!history[k]) history[k] = [];
history[k].push({ t: f.t, v: value });
}
}
kpiHistory.push({
t: f.t,
oee: f.kpi.oee * 100,
availability: f.kpi.availability * 100,
performance: f.kpi.performance * 100,
quality: f.kpi.quality * 100,
});
}
for (const k of Object.keys(history)) {
if (history[k].length > HISTORY) history[k] = history[k].slice(-HISTORY);
}
set({ history, kpiHistory: kpiHistory.slice(-HISTORY) });
},
/** A reset on the server invalidates all client-side history. */
clearHistory: () => set({ history: {}, kpiHistory: [] }),
// --- copilot ------------------------------------------------------------
copilot: {
messages: [],
streaming: false,
provider: 'auto',
activeLabel: null,
elapsedMs: 0,
},
setCopilot: (patch) => set((s) => ({ copilot: { ...s.copilot, ...patch } })),
pushMessage: (msg) =>
set((s) => ({ copilot: { ...s.copilot, messages: [...s.copilot.messages, msg] } })),
/** Append streamed text to the last assistant message. */
appendToLast: (text) =>
set((s) => {
const msgs = s.copilot.messages.slice();
const last = msgs[msgs.length - 1];
if (!last || last.role !== 'assistant') return {};
msgs[msgs.length - 1] = { ...last, content: last.content + text };
return { copilot: { ...s.copilot, messages: msgs } };
}),
patchLast: (patch) =>
set((s) => {
const msgs = s.copilot.messages.slice();
const last = msgs[msgs.length - 1];
if (!last) return {};
msgs[msgs.length - 1] = { ...last, ...patch };
return { copilot: { ...s.copilot, messages: msgs } };
}),
}));
/** Read a signal's history without subscribing to unrelated signals. */
export const selectSeries = (stationId, key) => (s) => s.history[`${stationId}.${key}`];
export const selectStation = (id) => (s) =>
s.frame ? s.frame.stations.find((x) => x.id === id) : undefined;
export const selectSpec = (id) => (s) =>
s.meta ? s.meta.stations.find((x) => x.id === id) : undefined;
+596
View File
@@ -0,0 +1,596 @@
/**
* Design tokens and layout.
*
* This commits deliberately to a single dark theme. It is a plant operations
* dashboard shown on a projector in a meeting room, where dark is idiomatic and
* light washes out; a theme toggle would be a feature nobody uses in that
* setting. Colours are painted explicitly rather than inherited.
*
* Palette roles come from the validated reference palette. Status colours are
* reserved - they are never reused as chart series - and every status colour is
* always paired with a text label, never carrying meaning by hue alone.
*/
:root {
color-scheme: dark;
/* Surfaces and ink */
--plane: #0d0d0d;
--surface-1: #1a1a19;
--surface-2: #201f1e;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--grid: #2c2c2a;
--axis: #383835;
--border: rgba(255, 255, 255, 0.1);
--border-strong: rgba(255, 255, 255, 0.18);
/* Status - reserved, never used as a series colour */
--good: #0ca30c;
--warning: #fab219;
--serious: #ec835a;
--critical: #d03b3b;
--predictive: #9085e9;
/* Categorical series slots 1-3 (validated all-pairs on this surface) */
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--radius: 10px;
--radius-sm: 6px;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: ui-monospace, "Cascadia Mono", "Consolas", monospace;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--plane);
color: var(--text-primary);
font-family: var(--font);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
/* ---------------------------------------------------------------- layout --- */
.app {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
min-height: 100%;
}
.row-hero {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(340px, 1fr);
gap: 12px;
min-height: 400px;
}
.row-lower {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(340px, 1fr);
gap: 12px;
align-items: start;
}
@media (max-width: 1280px) {
.row-hero, .row-lower { grid-template-columns: 1fr; }
}
.col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
/* ----------------------------------------------------------------- cards --- */
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
min-width: 0;
}
.card.flush { padding: 0; overflow: hidden; }
.card-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-title .hint { text-transform: none; letter-spacing: 0; font-weight: 400; }
/* ---------------------------------------------------------------- header --- */
.header {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 16px;
}
.brand { display: flex; align-items: baseline; gap: 10px; }
.brand h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
.brand .sub { font-size: 12px; color: var(--text-muted); }
.header-spacer { flex: 1; }
.clock {
font-family: var(--mono);
font-size: 13px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
white-space: nowrap;
}
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); flex: none; }
.badge.live .dot { background: var(--good); }
.badge.stale .dot { background: var(--critical); }
/* --------------------------------------------------------------- buttons --- */
button {
font-family: inherit;
font-size: 12px;
color: var(--text-secondary);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 11px;
cursor: pointer;
transition: background 120ms, border-color 120ms, color 120ms;
}
button:hover:not(:disabled) { background: #2a2928; color: var(--text-primary); border-color: var(--border-strong); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.active { background: #33322f; color: var(--text-primary); border-color: var(--border-strong); }
button.danger { color: #f0a3a3; }
button.danger:hover:not(:disabled) { background: #3a2020; border-color: #6b3535; color: #ffc9c9; }
.btn-group { display: inline-flex; gap: 4px; flex-wrap: wrap; }
/* ------------------------------------------------------------- KPI tiles --- */
.kpi-row {
display: grid;
grid-template-columns: 1.4fr repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1100px) { .kpi-row { grid-template-columns: repeat(3, 1fr); } }
.tile {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tile .label {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.tile .value {
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.tile.hero .value { font-size: 40px; }
.tile .unit { font-size: 14px; color: var(--text-secondary); font-weight: 400; margin-left: 2px; }
.tile .foot { font-size: 11px; color: var(--text-muted); }
/* meter: a thin magnitude bar, 4px rounded end, anchored at zero */
.meter { height: 5px; background: var(--grid); border-radius: 999px; overflow: hidden; margin-top: 2px; }
.meter > span { display: block; height: 100%; border-radius: 999px; background: var(--series-1); }
/* --------------------------------------------------------- station strip --- */
.strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
@media (max-width: 1100px) { .strip { grid-template-columns: repeat(2, 1fr); } }
.station-card {
text-align: left;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 7px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-left: 3px solid var(--text-muted);
}
.station-card.sel { border-color: var(--border-strong); background: #2b2a28; }
.station-card .id { font-size: 12.5px; font-weight: 650; color: var(--text-primary); font-family: var(--mono); }
.station-card .nm { font-size: 10.5px; color: var(--text-muted); line-height: 1.25; }
.station-card .primary { font-size: 17px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.station-card .primary .u { font-size: 10.5px; color: var(--text-muted); font-weight: 400; margin-left: 3px; }
/* status is always colour + text, never colour alone */
.state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.state .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.s-running { color: var(--good); }
.s-microstop { color: var(--warning); }
.s-blocked { color: var(--serious); }
.s-starved { color: var(--text-muted); }
.s-down, .s-fault { color: var(--critical); }
.s-idle { color: var(--text-muted); }
/* ---------------------------------------------------------------- charts --- */
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@media (max-width: 860px) { .chart-grid { grid-template-columns: 1fr; } }
.chart-box { min-width: 0; }
.chart-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 2px; }
.chart-name { font-size: 12px; color: var(--text-secondary); font-weight: 550; }
.chart-now { font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
.chart-now .u { font-size: 10.5px; color: var(--text-muted); font-weight: 400; margin-left: 3px; }
.legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
.legend-item { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-secondary); }
.legend-swatch { width: 10px; height: 2px; border-radius: 1px; flex: none; }
.tooltip {
background: #262523;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 7px 9px;
font-size: 11.5px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
}
.tooltip .t-time { color: var(--text-muted); margin-bottom: 3px; font-variant-numeric: tabular-nums; }
.tooltip .t-row { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); }
.tooltip .t-val { color: var(--text-primary); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------- alarms --- */
.alarm-list { display: flex; flex-direction: column; gap: 7px; max-height: 340px; overflow-y: auto; }
.alarm {
display: flex;
flex-direction: column;
gap: 4px;
padding: 9px 11px;
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--text-muted);
border-radius: var(--radius-sm);
}
.alarm.critical { border-left-color: var(--critical); }
.alarm.major { border-left-color: var(--serious); }
.alarm.warning { border-left-color: var(--warning); }
.alarm.predictive { border-left-color: var(--predictive); }
.alarm-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sev {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
background: var(--grid);
color: var(--text-secondary);
}
.sev.critical { color: #ffb3b3; background: #3a1f1f; }
.sev.major { color: #f7c3a8; background: #3a2a1f; }
.sev.warning { color: #f8dd9a; background: #3a341f; }
.sev.predictive { color: #cfc8f6; background: #272341; }
.alarm .who { font-family: var(--mono); font-size: 11px; color: var(--text-muted); }
.alarm .msg { font-size: 12px; color: var(--text-secondary); line-height: 1.45; }
.alarm .actions { display: flex; gap: 6px; }
.alarm button { padding: 3px 8px; font-size: 11px; }
.empty { font-size: 12px; color: var(--text-muted); padding: 14px 0; text-align: center; }
/* ---------------------------------------------------------------- whatif --- */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 12px; color: var(--text-secondary); }
.field-head .v { font-family: var(--mono); font-size: 12.5px; color: var(--text-primary); font-variant-numeric: tabular-nums; }
input[type="range"] { width: 100%; accent-color: var(--series-1); background: transparent; }
.fault-list { display: flex; flex-direction: column; gap: 6px; }
.fault-row { display: flex; align-items: center; gap: 8px; }
.fault-row .nm { flex: 1; font-size: 12px; color: var(--text-secondary); min-width: 0; }
.fault-row .nm small { display: block; color: var(--text-muted); font-size: 10.5px; }
.fault-row.on .nm { color: var(--text-primary); }
.divider { height: 1px; background: var(--border); margin: 14px 0; }
/* --------------------------------------------------------------- copilot --- */
.copilot { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.copilot-log { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding-right: 4px; min-height: 120px; }
.msg { font-size: 12.5px; line-height: 1.55; }
.msg.user { color: var(--text-primary); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; }
.msg.assistant { color: var(--text-secondary); white-space: pre-wrap; }
.msg.assistant strong { color: var(--text-primary); font-weight: 650; }
.msg .who { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px; }
.msg .note { font-size: 11px; color: var(--warning); margin-bottom: 5px; }
.chips { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0 8px; }
.chips button { font-size: 11px; padding: 5px 9px; }
.ask { display: flex; gap: 6px; }
.ask input {
flex: 1;
min-width: 0;
font-family: inherit;
font-size: 12.5px;
color: var(--text-primary);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.ask input:focus { outline: none; border-color: var(--border-strong); }
.thinking { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
.spinner {
width: 11px; height: 11px; flex: none;
border: 2px solid var(--grid);
border-top-color: var(--series-1);
border-radius: 50%;
animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* -------------------------------------------------------------------- 3D --- */
.scene { position: relative; width: 100%; height: 100%; min-height: 380px; border-radius: var(--radius); overflow: hidden; background: #121211; }
.scene canvas { display: block; }
.scene-overlay {
position: absolute;
left: 14px; top: 12px;
display: flex; flex-direction: column; gap: 4px;
pointer-events: none;
}
.scene-overlay .ttl { font-size: 12px; font-weight: 650; letter-spacing: 0.02em; }
.scene-overlay .sub { font-size: 11px; color: var(--text-muted); }
.scene-hint {
position: absolute;
right: 14px; bottom: 12px;
font-size: 10.5px;
color: var(--text-muted);
pointer-events: none;
}
.scene-legend {
position: absolute;
left: 14px; bottom: 12px;
display: flex; gap: 10px; flex-wrap: wrap;
pointer-events: none;
}
/* --------------------------------------------------------------- utility --- */
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
.muted { color: var(--text-muted); }
.small { font-size: 11px; }
.stack { display: flex; flex-direction: column; gap: 8px; }
.inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.banner {
background: #3a2020;
border: 1px solid #6b3535;
color: #ffc9c9;
border-radius: var(--radius-sm);
padding: 9px 12px;
font-size: 12px;
}
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #35342f; border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: #45443e; }
/* ------------------------------------------------------------- settings --- */
.settings-scrim {
position: fixed;
inset: 0;
background: rgba(6, 6, 6, 0.78);
z-index: 100;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 24px;
overflow-y: auto;
}
.settings {
width: 100%;
max-width: 1080px;
background: var(--surface-1);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0;
overflow: hidden;
}
.settings-head {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--surface-1);
z-index: 2;
}
.settings-head h2 { margin: 0; font-size: 16px; font-weight: 650; }
.settings-head .spacer { flex: 1; }
.settings-body { padding: 18px 20px 22px; display: flex; flex-direction: column; gap: 22px; }
.settings-section { display: flex; flex-direction: column; gap: 10px; }
.settings-section > h3 {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.settings-section .desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; margin: -2px 0 4px; }
.provider-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
@media (max-width: 820px) { .provider-grid { grid-template-columns: repeat(2, 1fr); } }
.provider-opt {
text-align: left;
padding: 11px 13px;
display: flex;
flex-direction: column;
gap: 4px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface-2);
}
.provider-opt.sel { border-color: var(--series-1); background: #21293a; }
.provider-opt .t { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.provider-opt .d { font-size: 11px; color: var(--text-muted); line-height: 1.4; }
.model-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
@media (max-width: 900px) { .model-cols { grid-template-columns: 1fr; } }
.model-panel {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
padding: 12px;
display: flex;
flex-direction: column;
gap: 9px;
min-width: 0;
}
.model-panel.active { border-color: var(--series-1); }
.model-panel-head { display: flex; align-items: center; gap: 8px; }
.model-panel-head .t { font-size: 13px; font-weight: 600; }
.model-panel-head .spacer { flex: 1; }
.model-list {
max-height: 230px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-1);
}
.model-row {
width: 100%;
text-align: left;
border: none;
border-bottom: 1px solid var(--border);
border-radius: 0;
background: transparent;
padding: 8px 10px;
display: flex;
align-items: baseline;
gap: 8px;
}
.model-row:last-child { border-bottom: none; }
.model-row.sel { background: #21293a; }
.model-row .mid { font-family: var(--mono); font-size: 11.5px; color: var(--text-primary); word-break: break-all; }
.model-row .meta { margin-left: auto; font-size: 10.5px; color: var(--text-muted); white-space: nowrap; flex: none; }
.settings input[type="text"], .settings input[type="number"], .settings input[type="search"] {
width: 100%;
min-width: 0;
font-family: inherit;
font-size: 12.5px;
color: var(--text-primary);
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 9px;
}
.settings input:focus { outline: none; border-color: var(--border-strong); }
.settings input[type="text"].mono { font-family: var(--mono); }
.gen-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
@media (max-width: 820px) { .gen-grid { grid-template-columns: 1fr; } }
.test-result {
font-size: 11.5px;
border-radius: var(--radius-sm);
padding: 7px 9px;
border: 1px solid var(--border);
background: var(--surface-1);
line-height: 1.5;
}
.test-result.good { border-color: #1d5c1d; color: #b6e6b6; }
.test-result.slow { border-color: #6b5a1f; color: #f3dd9f; }
.test-result.bad { border-color: #6b3535; color: #ffc9c9; }
.test-result .lat { font-family: var(--mono); font-weight: 600; }
.settings-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.settings-foot .spacer { flex: 1; }
.saved-flash { font-size: 11.5px; color: var(--good); }
.err-flash { font-size: 11.5px; color: #ffc9c9; }
+99
View File
@@ -0,0 +1,99 @@
/**
* Conveyor segments and the product on them.
*
* The queued boxes are not decoration: there is exactly one box per unit of real
* WIP in that buffer, packed toward the downstream end. So when the packer jams,
* you watch the boxes physically pile up and the buffers fill from the right -
* the same propagation the 2D buffer pills show, in a form a plant manager reads
* instantly.
*
* The gliding boxes on top are the only decorative element, indicating flow while
* the upstream station is producing.
*/
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
const BOX = 0.34;
export function Belt({ x, width }) {
return (
<group position={[x, 0, 0]}>
{/* belt deck */}
<mesh position={[0, 0.42, 0]} receiveShadow castShadow>
<boxGeometry args={[width, 0.08, 1.1]} />
<meshStandardMaterial color="#26251f" roughness={0.85} metalness={0.15} />
</mesh>
{/* side rails */}
{[-0.58, 0.58].map((z) => (
<mesh key={z} position={[0, 0.5, z]}>
<boxGeometry args={[width, 0.1, 0.05]} />
<meshStandardMaterial color="#3a3931" roughness={0.7} metalness={0.3} />
</mesh>
))}
{/* legs */}
{[-width / 2 + 0.25, width / 2 - 0.25].map((lx) => (
<mesh key={lx} position={[lx, 0.19, 0]}>
<boxGeometry args={[0.1, 0.38, 0.7]} />
<meshStandardMaterial color="#2a2924" roughness={0.8} />
</mesh>
))}
</group>
);
}
/** One box per unit of WIP, queued from the downstream end. */
export function QueueBoxes({ x, width, count, full }) {
const spacing = BOX + 0.06;
const start = x + width / 2 - 0.28;
const boxes = [];
for (let i = 0; i < count; i++) {
const px = start - i * spacing;
// Never let the queue render past its own segment.
if (px < x - width / 2) break;
boxes.push(px);
}
return (
<group>
{boxes.map((px, i) => (
<mesh key={i} position={[px, 0.63, 0]} castShadow>
<boxGeometry args={[BOX, BOX, BOX]} />
<meshStandardMaterial
color={full ? '#8a5a3a' : '#a9a293'}
roughness={0.75}
metalness={0.05}
/>
</mesh>
))}
</group>
);
}
/** Decorative in-transit product, moving only while the feeding station runs. */
export function FlowBoxes({ x, width, active, rate }) {
const group = useRef(null);
const phase = useRef(0);
useFrame((_, dt) => {
if (!group.current) return;
if (active) phase.current = (phase.current + dt * rate * 0.55) % 1;
const n = group.current.children.length;
group.current.children.forEach((child, i) => {
const p = (phase.current + i / n) % 1;
child.position.x = x - width / 2 + p * width;
child.visible = active;
});
});
return (
<group ref={group}>
{[0, 1].map((i) => (
<mesh key={i} position={[x, 0.63, 0]} castShadow>
<boxGeometry args={[BOX, BOX, BOX]} />
<meshStandardMaterial color="#c2bbaa" roughness={0.7} />
</mesh>
))}
</group>
);
}
+171
View File
@@ -0,0 +1,171 @@
/**
* The 3D hero view of the line.
*
* No external assets are loaded anywhere in this scene - no HDRI environment, no
* CDN font, no texture. Everything is primitives and analytic lights, because the
* demo has to survive a room with no working wifi.
*/
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, ContactShadows } from '@react-three/drei';
import { useStore } from '../store.js';
import { Station } from './Station.jsx';
import { Belt, QueueBoxes, FlowBoxes } from './Conveyor.jsx';
import { stateMeta } from '../lib/format.js';
/** Station centres, evenly spaced along the line. */
const SPACING = 4.3;
const stationX = (i, n) => (i - (n - 1) / 2) * SPACING;
function Floor() {
return (
<>
<mesh position={[0, -0.02, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[60, 26]} />
<meshStandardMaterial color="#161513" roughness={0.95} metalness={0} />
</mesh>
{/* aisle markings, purely to give the eye a ground plane to read depth from */}
{[-3.2, 3.2].map((z) => (
<mesh key={z} position={[0, 0, z]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[26, 0.06]} />
<meshBasicMaterial color="#3a3830" />
</mesh>
))}
</>
);
}
function LineContent() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const selected = useStore((s) => s.selected);
const select = useStore((s) => s.select);
if (!frame || !meta) return null;
const n = frame.stations.length;
return (
<group>
{frame.stations.map((st, i) => (
<Station
key={st.id}
station={st}
spec={meta.stations.find((x) => x.id === st.id)}
x={stationX(i, n)}
selected={selected === st.id}
onSelect={select}
/>
))}
{frame.buffers.map((level, i) => {
const x = (stationX(i, n) + stationX(i + 1, n)) / 2;
const width = SPACING - 2.15;
const upstream = frame.stations[i];
return (
<group key={i}>
<Belt x={x} width={width} />
<QueueBoxes
x={x}
width={width}
count={level}
full={level >= frame.bufferCapacity}
/>
<FlowBoxes
x={x}
width={width}
active={upstream.state === 'running'}
rate={frame.controls.lineSpeedPct / 100}
/>
</group>
);
})}
</group>
);
}
export function Scene() {
const frame = useStore((s) => s.frame);
const connected = useStore((s) => s.connected);
const worstState = frame
? (frame.stations.find((s) => s.state === 'fault' || s.state === 'down')
|| frame.stations.find((s) => s.state === 'blocked'))
: null;
return (
<div className="scene">
<Canvas
shadows
dpr={[1, 1.75]}
camera={{ position: [11, 8.5, 13], fov: 40 }}
gl={{ antialias: true }}
>
<color attach="background" args={['#121211']} />
<fog attach="fog" args={['#121211', 26, 52]} />
{/* Analytic lights only - an HDRI would be a network fetch. */}
<hemisphereLight args={['#8fa4b8', '#2a2620', 0.55]} />
<directionalLight
position={[9, 14, 8]}
intensity={1.7}
castShadow
shadow-mapSize={[1024, 1024]}
shadow-camera-left={-16}
shadow-camera-right={16}
shadow-camera-top={12}
shadow-camera-bottom={-12}
/>
<directionalLight position={[-10, 6, -8]} intensity={0.45} color="#7fa8d0" />
<Suspense fallback={null}>
<Floor />
<LineContent />
<ContactShadows
position={[0, 0.01, 0]}
opacity={0.5}
scale={40}
blur={2.4}
far={5}
resolution={512}
color="#000000"
/>
</Suspense>
<OrbitControls
target={[0, 1.1, 0]}
enablePan
minDistance={7}
maxDistance={34}
minPolarAngle={0.15}
maxPolarAngle={Math.PI / 2.15}
/>
</Canvas>
<div className="scene-overlay">
<span className="ttl">LINE-1 · Assembly &amp; Cure</span>
<span className="sub">
{connected ? 'Live twin' : 'Disconnected'}
{worstState ? ` · ${worstState.id} ${stateMeta(worstState.state).label.toLowerCase()}` : ' · all stations nominal'}
</span>
</div>
{/* Legend pairs every status colour with its word. */}
<div className="scene-legend">
{['running', 'microstop', 'blocked', 'fault'].map((s) => {
const sm = stateMeta(s);
return (
<span className="legend-item" key={s}>
<span className="dot" style={{
width: 7, height: 7, borderRadius: '50%', background: sm.color, display: 'inline-block',
}} />
{sm.label}
</span>
);
})}
</div>
<div className="scene-hint">Drag to orbit · scroll to zoom · click a machine to inspect</div>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
/**
* One machine, built from primitives.
*
* Geometry is boxes and cylinders on purpose. Sourcing or modelling CAD-accurate
* machines is a day of work that adds nothing to the argument being made, and it
* is the classic way a two-day demo becomes a four-day demo.
*
* What the geometry DOES carry is state, three ways: a status-coloured status bar
* on the machine, a pulse when it is stopped on a fault, and - the detail that
* lands with maintenance engineers - a physical shake on CNC-02 that scales with
* measured bearing vibration.
*/
import { useMemo, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Html } from '@react-three/drei';
import { stateMeta } from '../lib/format.js';
function Body({ w, h, d, y, color, roughness = 0.6, metalness = 0.35 }) {
return (
<mesh position={[0, y, 0]} castShadow receiveShadow>
<boxGeometry args={[w, h, d]} />
<meshStandardMaterial color={color} roughness={roughness} metalness={metalness} />
</mesh>
);
}
/** Per-kind machine shape. */
function Machine({ kind, station, spinRef }) {
const shell = '#4a4941';
const dark = '#2f2e28';
switch (kind) {
case 'conveyor':
return (
<group>
<Body w={1.9} h={0.5} d={1.3} y={0.25} color={shell} />
{[-0.5, 0, 0.5].map((x) => (
<mesh key={x} position={[x, 0.56, 0]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.09, 0.09, 1.15, 14]} />
<meshStandardMaterial color={dark} roughness={0.5} metalness={0.6} />
</mesh>
))}
</group>
);
case 'cnc':
return (
<group>
<Body w={2.0} h={1.5} d={1.5} y={0.75} color={shell} />
{/* enclosure window */}
<mesh position={[0, 0.95, 0.77]}>
<boxGeometry args={[1.3, 0.6, 0.03]} />
<meshStandardMaterial color="#1b2a33" roughness={0.2} metalness={0.5} />
</mesh>
{/* spindle, rotates while cutting */}
<group ref={spinRef} position={[0, 1.62, 0]}>
<mesh castShadow>
<cylinderGeometry args={[0.17, 0.17, 0.42, 18]} />
<meshStandardMaterial color="#6d6a5e" roughness={0.35} metalness={0.8} />
</mesh>
<mesh position={[0, -0.3, 0]}>
<cylinderGeometry args={[0.05, 0.05, 0.24, 12]} />
<meshStandardMaterial color="#9a978c" roughness={0.25} metalness={0.9} />
</mesh>
</group>
</group>
);
case 'oven': {
// Zone glow follows burner duty, so a saturated burner visibly runs hot.
const duty = (station.signals.burnerDuty || 0) / 100;
return (
<group>
<Body w={2.6} h={1.25} d={1.5} y={0.62} color="#514f45" />
{/* tunnel mouth */}
<mesh position={[0, 0.62, 0.76]}>
<boxGeometry args={[2.1, 0.42, 0.04]} />
<meshStandardMaterial color="#120e0b" roughness={0.9} />
</mesh>
{/* three zone strips on the roof */}
{[-0.75, 0, 0.75].map((x, i) => (
<mesh key={x} position={[x, 1.27, 0]}>
<boxGeometry args={[0.62, 0.06, 1.2]} />
<meshStandardMaterial
color="#8a3a10"
emissive="#ff6a1a"
emissiveIntensity={0.15 + duty * 1.5}
roughness={0.6}
/>
</mesh>
))}
</group>
);
}
case 'inspection':
return (
<group>
<Body w={1.7} h={0.45} d={1.3} y={0.22} color={shell} />
{/* gantry */}
{[-0.6, 0.6].map((x) => (
<mesh key={x} position={[x, 1.05, 0]}>
<boxGeometry args={[0.11, 1.25, 0.11]} />
<meshStandardMaterial color={dark} roughness={0.5} metalness={0.6} />
</mesh>
))}
<mesh position={[0, 1.7, 0]}>
<boxGeometry args={[1.35, 0.12, 0.3]} />
<meshStandardMaterial color={dark} roughness={0.5} metalness={0.6} />
</mesh>
{/* camera + light cone */}
<mesh position={[0, 1.5, 0]}>
<cylinderGeometry args={[0.11, 0.11, 0.24, 14]} />
<meshStandardMaterial color="#23221d" roughness={0.4} metalness={0.7} />
</mesh>
<mesh position={[0, 1.05, 0]}>
<coneGeometry args={[0.34, 0.72, 20, 1, true]} />
<meshStandardMaterial
color="#7ec8ff"
emissive="#4aa8ff"
emissiveIntensity={station.online ? 0.5 : 0}
transparent
opacity={station.online ? 0.16 : 0.03}
side={2}
/>
</mesh>
</group>
);
case 'packer':
return (
<group>
<Body w={1.9} h={1.15} d={1.4} y={0.57} color={shell} />
{/* film roll */}
<mesh position={[0.55, 1.35, 0]} rotation={[Math.PI / 2, 0, 0]} castShadow>
<cylinderGeometry args={[0.28, 0.28, 0.85, 20]} />
<meshStandardMaterial color="#b9b2a2" roughness={0.85} />
</mesh>
<mesh position={[-0.5, 1.22, 0]}>
<boxGeometry args={[0.75, 0.14, 1.0]} />
<meshStandardMaterial color={dark} roughness={0.55} metalness={0.5} />
</mesh>
</group>
);
default:
return <Body w={1.8} h={1} d={1.3} y={0.5} color={shell} />;
}
}
export function Station({ station, spec, x, selected, onSelect }) {
const group = useRef(null);
const spinRef = useRef(null);
const barRef = useRef(null);
const sm = stateMeta(station.state);
const stopped = station.state === 'fault' || station.state === 'down';
// Shake amplitude from measured vibration, above the healthy baseline.
const shake = useMemo(() => {
if (station.kind !== 'cnc') return 0;
const v = station.signals.vibration || 0;
return Math.max(0, v - 2.0) * 0.011;
}, [station.kind, station.signals.vibration]);
const running = station.state === 'running';
useFrame((state, dt) => {
const t = state.clock.elapsedTime;
if (group.current) {
group.current.position.x = x + (shake ? Math.sin(t * 47) * shake : 0);
group.current.position.z = shake ? Math.cos(t * 61) * shake : 0;
}
if (spinRef.current && running) {
spinRef.current.rotation.y += dt * 14;
}
// A stopped station pulses, so it draws the eye from across the room.
if (barRef.current) {
const mat = barRef.current.material;
mat.emissiveIntensity = stopped ? 0.7 + Math.sin(t * 5) * 0.6 : 0.65;
}
});
return (
<group ref={group} position={[x, 0, 0]}>
<group onClick={(e) => { e.stopPropagation(); onSelect(station.id); }}>
<Machine kind={station.kind} station={station} spinRef={spinRef} />
{/* status bar: the machine's own state light */}
<mesh ref={barRef} position={[0, 0.06, 0.8]}>
<boxGeometry args={[1.5, 0.09, 0.09]} />
<meshStandardMaterial color={sm.color} emissive={sm.color} emissiveIntensity={0.65} />
</mesh>
{/* selection ring */}
{selected && (
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[1.35, 1.5, 48]} />
<meshBasicMaterial color="#ffffff" transparent opacity={0.5} side={2} />
</mesh>
)}
</group>
{/* Labels are DOM, not 3D text: drei's <Text> fetches a font from a CDN,
which would fail in an offline demo room. */}
<Html position={[0, 2.32, 0]} center distanceFactor={12} zIndexRange={[10, 0]} pointerEvents="none">
<div style={{
textAlign: 'center', whiteSpace: 'nowrap', userSelect: 'none',
fontFamily: 'system-ui, sans-serif',
}}>
<div style={{
fontSize: 13, fontWeight: 700, letterSpacing: '0.03em',
color: selected ? '#ffffff' : '#d8d6cc',
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
}}>{station.id}</div>
<div style={{
fontSize: 11, fontWeight: 600, color: sm.color,
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
}}>{station.online ? sm.label : 'No data'}</div>
</div>
</Html>
</group>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// The API and the telemetry socket live on the server process. Proxying them
// keeps the browser on one origin, so there is no CORS setup to go wrong on
// stage and the copilot API key never leaves the server.
proxy: {
'/api': { target: 'http://localhost:8787', changeOrigin: true },
'/ws': { target: 'ws://localhost:8787', ws: true },
},
},
});