259 lines
8.5 KiB
TypeScript
259 lines
8.5 KiB
TypeScript
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<string, any> = {
|
|
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<WidgetCmd>(() => getWidgetCmdStore(widget.id).get());
|
|
useEffect(() => getWidgetCmdStore(widget.id).subscribe(setCmd), [widget.id]);
|
|
if (cmd.hidden) return null;
|
|
if (!cmd.disabled) return children;
|
|
return (
|
|
<Fragment>
|
|
{children}
|
|
<div
|
|
class="widget-disable-overlay"
|
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
|
onContextMenu={onContextMenu}
|
|
/>
|
|
</Fragment>
|
|
);
|
|
}
|
|
|
|
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<CtxState>({
|
|
visible: false, x: 0, y: 0, signal: null,
|
|
});
|
|
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
|
|
// View-mode collapse state for collapsible container panes (ephemeral, by id).
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
// View-mode active-tab state for tabbed container panes (ephemeral, by id).
|
|
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => 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 (
|
|
<div class="canvas-container">
|
|
<div class="placeholder">
|
|
<p>Select an interface from the left panel, or import one.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div class="canvas-container">
|
|
<div class="plot-panel-view">
|
|
<SplitLayout
|
|
layout={iface.layout}
|
|
renderLeaf={(wid) => {
|
|
const widget = byId.get(wid);
|
|
if (!widget) return <div class="plot-pane-empty">missing plot</div>;
|
|
return (
|
|
<PlotWidget
|
|
widget={widget}
|
|
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
|
timeRange={timeRange}
|
|
/>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<ContextMenu
|
|
{...ctxMenu}
|
|
onClose={closeCtxMenu}
|
|
onInfo={handleInfo}
|
|
/>
|
|
|
|
{infoSignal && (
|
|
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
|
)}
|
|
|
|
<LogicDialogs />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div class="canvas-container">
|
|
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
|
{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
|
|
? <Comp
|
|
widget={widget}
|
|
onContextMenu={(e: MouseEvent) => 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)}
|
|
/>
|
|
: (
|
|
<div
|
|
class="unknown-widget"
|
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
|
title={`Unknown widget type: ${widget.type}`}
|
|
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
|
>
|
|
<span class="unknown-label">{widget.type}</span>
|
|
</div>
|
|
);
|
|
return (
|
|
<WidgetView
|
|
key={widget.id}
|
|
widget={widget}
|
|
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
|
>
|
|
{inner}
|
|
</WidgetView>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<ContextMenu
|
|
{...ctxMenu}
|
|
onClose={closeCtxMenu}
|
|
onInfo={handleInfo}
|
|
/>
|
|
|
|
{infoSignal && (
|
|
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
|
)}
|
|
|
|
<LogicDialogs />
|
|
</div>
|
|
);
|
|
}
|