Initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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?`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user