Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user