Files
digitaltwin/web/src/panels/WhatIfPanel.jsx
T
2026-08-24 15:35:32 +05:30

133 lines
5.1 KiB
React

/**
* 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>
);
}