import { h, Fragment } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { initLocalState } from './lib/localstate'; import { logicEngine } from './lib/logic'; import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands'; import type { Interface, Widget, SignalRef } from './lib/types'; import TextView from './widgets/TextView'; import TextLabel from './widgets/TextLabel'; import Gauge from './widgets/Gauge'; import BarH from './widgets/BarH'; import BarV from './widgets/BarV'; import Led from './widgets/Led'; import MultiLed from './widgets/MultiLed'; import SetValue from './widgets/SetValue'; import Toggle from './widgets/Toggle'; import Button from './widgets/Button'; import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; import LinkWidget from './widgets/LinkWidget'; import ConfigSelect from './widgets/ConfigSelect'; import Container from './widgets/Container'; import TableWidget from './widgets/TableWidget'; import ContextMenu from './ContextMenu'; import InfoPanel from './InfoPanel'; import SplitLayout from './SplitLayout'; import LogicDialogs from './LogicDialogs'; import { centreInside, widgetTab, tabPanes } from './lib/containers'; const COMPONENTS: Record = { textview: TextView, textlabel: TextLabel, gauge: Gauge, barh: BarH, barv: BarV, led: Led, multiled: MultiLed, setvalue: SetValue, toggle: Toggle, button: Button, plot: PlotWidget, image: ImageWidget, link: LinkWidget, configselect: ConfigSelect, container: Container, table: TableWidget, }; // Container panes are decorative frames placed behind other widgets; render them // first so they paint underneath. function renderOrder(widgets: Widget[]): Widget[] { const containers = widgets.filter(w => w.type === 'container'); const rest = widgets.filter(w => w.type !== 'container'); return [...containers, ...rest]; } interface CtxState { visible: boolean; x: number; y: number; signal: SignalRef | null; } // Wraps a single view-mode widget so panel logic (action.widget) can drive it: // `hidden` removes it from the view, `disabled` dims it and blocks interaction // via a transparent overlay (which still surfaces the right-click menu). Plot // pause/clear are handled inside PlotWidget itself. function WidgetView({ widget, children, onContextMenu }: { widget: Widget; children: any; onContextMenu: (e: MouseEvent) => void; key?: string; }) { const [cmd, setCmd] = useState(() => getWidgetCmdStore(widget.id).get()); useEffect(() => getWidgetCmdStore(widget.id).subscribe(setCmd), [widget.id]); if (cmd.hidden) return null; if (!cmd.disabled) return children; return ( {children}
); } interface Props { iface: Interface | null; onNavigate?: (interfaceId: string) => void; timeRange?: { start: string; end: string } | null; } export default function Canvas({ iface, onNavigate, timeRange }: Props) { const [ctxMenu, setCtxMenu] = useState({ visible: false, x: 0, y: 0, signal: null, }); const [infoSignal, setInfoSignal] = useState(null); // View-mode collapse state for collapsible container panes (ephemeral, by id). const [collapsed, setCollapsed] = useState>(() => new Set()); // View-mode active-tab state for tabbed container panes (ephemeral, by id). const [activeTabs, setActiveTabs] = useState>(() => new Map()); function toggleCollapse(id: string) { setCollapsed(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); } function selectTab(id: string, i: number) { setActiveTabs(prev => { const next = new Map(prev); next.set(id, i); return next; }); } // Instantiate this panel's local state variables from their initial values. useEffect(() => { initLocalState(iface?.statevars); }, [iface?.id, iface?.statevars]); // Activate panel logic for the live view; tear it down on unmount/panel switch. useEffect(() => { logicEngine.load(iface?.logic); return () => logicEngine.clear(); }, [iface?.id, iface?.logic]); function onCtxMenu(e: MouseEvent, widget: Widget) { e.preventDefault(); e.stopPropagation(); setCtxMenu({ visible: true, x: e.clientX, y: e.clientY, signal: widget.signals[0] ?? null }); } function closeCtxMenu() { setCtxMenu(c => ({ ...c, visible: false })); } function handleInfo() { if (ctxMenu.signal) setInfoSignal(ctxMenu.signal); } if (!iface) { return (

Select an interface from the left panel, or import one.

); } // Plot panel: plots fill the viewport arranged in a recursive split layout. if (iface.kind === 'plot' && iface.layout) { const byId = new Map(iface.widgets.map(w => [w.id, w])); return (
{ const widget = byId.get(wid); if (!widget) return
missing plot
; return ( onCtxMenu(e, widget)} timeRange={timeRange} /> ); }} />
{infoSignal && ( setInfoSignal(null)} /> )}
); } // Collapsible panes currently in the collapsed state — their contents are hidden. const collapsedPanes = iface.widgets.filter( w => w.type === 'container' && w.options['collapsible'] === 'true' && collapsed.has(w.id), ); // Tabbed panes — widgets inside that aren't on the active tab are hidden. const tPanes = tabPanes(iface.widgets); return (
{renderOrder(iface.widgets).map(widget => { // Hide widgets that fall inside a currently-collapsed container pane. const hidden = collapsedPanes.some(c => c.id !== widget.id && centreInside(widget, c)); // Hide widgets inside a tabbed pane that aren't on its active tab. const tabHidden = tPanes.some(c => c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0), ); if (hidden || tabHidden) return null; const Comp = COMPONENTS[widget.type]; const inner = Comp ? onCtxMenu(e, widget)} onNavigate={onNavigate} timeRange={timeRange} collapsed={collapsed.has(widget.id)} onToggleCollapse={() => toggleCollapse(widget.id)} activeTab={activeTabs.get(widget.id) ?? 0} onSelectTab={(i: number) => selectTab(widget.id, i)} /> : (
onCtxMenu(e, widget)} > {widget.type}
); return ( onCtxMenu(e, widget)} > {inner} ); })}
{infoSignal && ( setInfoSignal(null)} /> )}
); }