Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
+35
-16
@@ -5,7 +5,8 @@ import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import FullscreenMode from './FullscreenMode';
|
||||
import { applyZoom, getStoredZoom } from './ZoomControl';
|
||||
import type { Interface } from './lib/types';
|
||||
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
||||
import type { Interface, Me } from './lib/types';
|
||||
|
||||
type AppMode = 'view' | 'edit';
|
||||
|
||||
@@ -18,6 +19,8 @@ export default function App() {
|
||||
const [editTarget, setEditTarget] = useState<Interface | null>(null);
|
||||
// Interface to show when returning to view mode after editing
|
||||
const [viewTarget, setViewTarget] = useState<Interface | null>(null);
|
||||
// Resolved identity + global access level for the current user.
|
||||
const [me, setMe] = useState<Me>(DEFAULT_ME);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = wsClient.status.subscribe(setWsStatus);
|
||||
@@ -29,6 +32,7 @@ export default function App() {
|
||||
document.documentElement.style.setProperty('--dpr', String(dpr));
|
||||
applyZoom(getStoredZoom());
|
||||
if (!fsParam) wsClient.connect('/ws');
|
||||
fetchMe().then(setMe);
|
||||
}, []);
|
||||
|
||||
if (fsParam) {
|
||||
@@ -40,25 +44,40 @@ export default function App() {
|
||||
setMode('edit');
|
||||
}
|
||||
|
||||
function exitEdit(iface: Interface) {
|
||||
setViewTarget(iface);
|
||||
// `iface === null` means the editor discarded an unsaved/new panel; keep
|
||||
// showing whatever was previously open (held in viewTarget).
|
||||
function exitEdit(iface: Interface | null) {
|
||||
if (iface) setViewTarget(iface);
|
||||
setMode('view');
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed.
|
||||
EditMode is conditionally rendered; it resets from props each time anyway. */}
|
||||
<div style={`display:${mode === 'view' ? 'contents' : 'none'}`}>
|
||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} />
|
||||
if (!canRead(me.level)) {
|
||||
return (
|
||||
<div class="app-root">
|
||||
<div class="access-denied">
|
||||
<h1>Access denied</h1>
|
||||
<p>
|
||||
Your account{me.user ? ` (${me.user})` : ''} does not have permission
|
||||
to access this application. Contact an administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'edit' && (
|
||||
<EditMode initial={editTarget} onDone={exitEdit} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={me}>
|
||||
<div class="app-root">
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
{mode === 'view' ? (
|
||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} onView={setViewTarget} />
|
||||
) : (
|
||||
<EditMode key={editTarget?.id || 'new'} initial={editTarget} onDone={exitEdit} />
|
||||
)}
|
||||
</div>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+51
-8
@@ -1,5 +1,7 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import { logicEngine } from './lib/logic';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
@@ -15,6 +17,7 @@ import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import InfoPanel from './InfoPanel';
|
||||
import SplitLayout from './SplitLayout';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView,
|
||||
@@ -42,15 +45,25 @@ interface Props {
|
||||
iface: Interface | null;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
timeRange?: { start: string; end: string } | null;
|
||||
onPlot?: (signal: SignalRef) => void;
|
||||
}
|
||||
|
||||
export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) {
|
||||
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);
|
||||
|
||||
// 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();
|
||||
@@ -65,10 +78,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
|
||||
if (ctxMenu.signal) setInfoSignal(ctxMenu.signal);
|
||||
}
|
||||
|
||||
function handlePlot() {
|
||||
if (ctxMenu.signal && onPlot) onPlot(ctxMenu.signal);
|
||||
}
|
||||
|
||||
if (!iface) {
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
@@ -79,6 +88,41 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
|
||||
);
|
||||
}
|
||||
|
||||
// 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)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
@@ -110,7 +154,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
|
||||
{...ctxMenu}
|
||||
onClose={closeCtxMenu}
|
||||
onInfo={handleInfo}
|
||||
onPlot={onPlot ? handlePlot : undefined}
|
||||
/>
|
||||
|
||||
{infoSignal && (
|
||||
|
||||
@@ -11,10 +11,9 @@ interface Props {
|
||||
signal: SignalRef | null;
|
||||
onClose: () => void;
|
||||
onInfo?: () => void;
|
||||
onPlot?: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, onPlot }: Props) {
|
||||
export default function ContextMenu({ visible, x, y, signal, onClose, onInfo }: Props) {
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -47,11 +46,6 @@ export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, on
|
||||
onInfo?.();
|
||||
}
|
||||
|
||||
function openPlot() {
|
||||
onClose();
|
||||
onPlot?.();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// Phase 5+: export CSV data
|
||||
onClose();
|
||||
@@ -73,7 +67,6 @@ export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, on
|
||||
</div>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={openInfo}>Signal info</button>
|
||||
<button class="ctx-item" onClick={openPlot}>Plot</button>
|
||||
<div class="ctx-divider" />
|
||||
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
|
||||
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
|
||||
|
||||
@@ -364,7 +364,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
>
|
||||
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||
|
||||
{iface.widgets.map(widget => {
|
||||
{(iface.widgets || []).map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
? <Comp key={widget.id} widget={widget} />
|
||||
@@ -376,8 +376,9 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
||||
);
|
||||
})}
|
||||
|
||||
{iface.widgets.map(widget => {
|
||||
{(iface.widgets || []).map(widget => {
|
||||
const isSelected = selectedIds.includes(widget.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`ov-${widget.id}`}
|
||||
|
||||
+483
-140
@@ -1,17 +1,29 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useCallback, useRef } from 'preact/hooks';
|
||||
import type { Interface, Widget } from './lib/types';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
|
||||
import { serializeInterface, parseInterface } from './lib/xml';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import SignalTree from './SignalTree';
|
||||
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
|
||||
import PlotPanelCanvas from './PlotPanelCanvas';
|
||||
import LogicEditor from './LogicEditor';
|
||||
import PropertiesPane from './PropertiesPane';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: (iface: Interface) => void;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
@@ -69,12 +81,14 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): W
|
||||
|
||||
const STATIC_WIDGET_TYPES = [
|
||||
{ type: 'textlabel', label: 'Text Label' },
|
||||
{ type: 'button', label: 'Action Button' },
|
||||
{ type: 'image', label: 'Image' },
|
||||
{ type: 'link', label: 'Link' },
|
||||
];
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||
const writable = canWrite(useAuth().level);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -86,8 +100,22 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const [helpSection, setHelpSection] = useState('edit');
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [rightW, setRightW] = useState(260);
|
||||
// Center column tab: the drag-and-drop layout editor, or the panel-logic editor.
|
||||
const [centerTab, setCenterTab] = useState<'layout' | 'logic'>('layout');
|
||||
|
||||
function startResize(side: 'left' | 'right') {
|
||||
// History panel
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tag, setTag] = useState('');
|
||||
// Bumped whenever the editor content is replaced wholesale (version load /
|
||||
// promote) to force a full canvas remount, so no stale widget state lingers.
|
||||
const [canvasNonce, setCanvasNonce] = useState(0);
|
||||
|
||||
// Clipboard
|
||||
const clipboard = useRef<Widget[]>([]);
|
||||
|
||||
const startResize = useCallback((side: 'left' | 'right') => {
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
@@ -104,74 +132,189 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
};
|
||||
}
|
||||
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
|
||||
}, [leftW, rightW]);
|
||||
|
||||
// Undo / redo
|
||||
const undoStack = useRef<Widget[][]>([]);
|
||||
const redoStack = useRef<Widget[][]>([]);
|
||||
const openHelp = useCallback((section = 'edit') => {
|
||||
setHelpSection(section);
|
||||
setShowHelp(true);
|
||||
}, []);
|
||||
|
||||
// Undo / redo. Snapshots capture both widgets and (for plot panels) the split
|
||||
// layout, so split/close/resize operations are undone atomically.
|
||||
type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
|
||||
const undoStack = useRef<Snapshot[]>([]);
|
||||
const redoStack = useRef<Snapshot[]>([]);
|
||||
const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
|
||||
const widgetsRef = useRef(iface.widgets);
|
||||
widgetsRef.current = iface.widgets;
|
||||
const widgetsRef = useRef(iface.widgets || []);
|
||||
widgetsRef.current = iface.widgets || [];
|
||||
const layoutRef = useRef(iface.layout);
|
||||
layoutRef.current = iface.layout;
|
||||
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]];
|
||||
const snapshot = useCallback((): Snapshot => ({
|
||||
widgets: [...widgetsRef.current],
|
||||
layout: layoutRef.current,
|
||||
}), []);
|
||||
|
||||
const pushUndo = useCallback(() => {
|
||||
undoStack.current = [...undoStack.current.slice(-49), snapshot()];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(undoStack.current.length);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function undo() {
|
||||
const undo = useCallback(() => {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [widgetsRef.current, ...redoStack.current];
|
||||
redoStack.current = [snapshot(), ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: prev }));
|
||||
setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
function redo() {
|
||||
const redo = useCallback(() => {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, widgetsRef.current];
|
||||
undoStack.current = [...undoStack.current, snapshot()];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setHistoryLen(undoStack.current.length);
|
||||
setIface(f => ({ ...f, widgets: next }));
|
||||
setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
|
||||
setDirty(true);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
const handleWidgetsChange = useCallback((widgets: Widget[]) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
}, [pushUndo]);
|
||||
|
||||
// Combined widgets + layout change for plot-panel split/close/resize.
|
||||
const handlePlotChange = useCallback((widgets: Widget[], layout: PlotLayout) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets, layout }));
|
||||
setDirty(true);
|
||||
}, [pushUndo]);
|
||||
|
||||
const handleWidgetChange = useCallback((updated: Widget) => {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) }));
|
||||
setIface(f => ({ ...f, widgets: (f.widgets || []).map(w => w.id === updated.id ? updated : w) }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
}, [pushUndo]);
|
||||
|
||||
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||
setIface(updated);
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleStateVarsChange = useCallback((statevars: StateVar[]) => {
|
||||
setIface(f => ({ ...f, statevars }));
|
||||
setDirty(true);
|
||||
initLocalState(statevars);
|
||||
}, []);
|
||||
|
||||
const handleLogicChange = useCallback((logic: LogicGraph) => {
|
||||
setIface(f => ({ ...f, logic }));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
// Instantiate local state variables when the edited panel loads/changes so
|
||||
// they can be previewed live in the canvas just like real signals.
|
||||
useEffect(() => {
|
||||
initLocalState(iface.statevars);
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const target = e.target as Element;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
if (e.key === '?') { openHelp('edit'); return; }
|
||||
|
||||
// The logic editor manages its own undo/redo/clipboard/delete shortcuts.
|
||||
if (centerTab === 'logic') return;
|
||||
|
||||
// Plot panels use split/close controls instead of free-form widget editing;
|
||||
// only undo/redo apply here.
|
||||
if (iface.kind === 'plot') {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
const curWidgets = widgetsRef.current;
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: curWidgets.filter(w => !selectedIds.includes(w.id)) }));
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
clipboard.current = curWidgets
|
||||
.filter(w => selectedIds.includes(w.id))
|
||||
.map(w => ({ ...w }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
|
||||
e.preventDefault();
|
||||
pushUndo();
|
||||
const offset = 20;
|
||||
const pasted = clipboard.current.map(w => ({
|
||||
...w,
|
||||
id: genWidgetId(),
|
||||
x: w.x + offset,
|
||||
y: w.y + offset,
|
||||
}));
|
||||
setIface(f => ({ ...f, widgets: [...curWidgets, ...pasted] }));
|
||||
setSelectedIds(pasted.map(w => w.id));
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedIds(curWidgets.map(w => w.id));
|
||||
return;
|
||||
}
|
||||
// Arrow nudge
|
||||
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
handleWidgetsChange(curWidgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
// ── Align / distribute ─────────────────────────────────────────────────────
|
||||
|
||||
function doAlign(mode: string) {
|
||||
const aligned = alignWidgets(iface.widgets, selectedIds, mode);
|
||||
const doAlign = useCallback((mode: string) => {
|
||||
const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
|
||||
handleWidgetsChange(aligned);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
function doDistribute(axis: 'h' | 'v') {
|
||||
const distributed = distributeWidgets(iface.widgets, selectedIds, axis);
|
||||
const doDistribute = useCallback((axis: 'h' | 'v') => {
|
||||
const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
|
||||
handleWidgetsChange(distributed);
|
||||
}
|
||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
||||
|
||||
// ── Insert static widget ───────────────────────────────────────────────────
|
||||
|
||||
function insertWidget(type: string) {
|
||||
const insertWidget = useCallback((type: string) => {
|
||||
setShowInsertMenu(false);
|
||||
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
|
||||
const newWidget: Widget = {
|
||||
@@ -182,22 +325,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
w: defW,
|
||||
h: defH,
|
||||
signals: [],
|
||||
options: type === 'textlabel' ? { label: 'Label' } : {},
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...iface.widgets, newWidget]);
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
setSelectedIds([newWidget.id]);
|
||||
}
|
||||
}, [iface.widgets, handleWidgetsChange]);
|
||||
|
||||
// ── Save / export / import ─────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const handleSave = useCallback(async (saveTag = '') => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const xml = serializeInterface(iface);
|
||||
const url = iface.id
|
||||
const base = iface.id
|
||||
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
|
||||
: '/api/v1/interfaces';
|
||||
const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
|
||||
const method = iface.id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
@@ -210,15 +357,141 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
setIface(f => ({ ...f, id: json.id }));
|
||||
}
|
||||
setDirty(false);
|
||||
setTag('');
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
if (showHistory) loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
// loadVersions is stable (declared below); intentionally omitted from deps.
|
||||
}, [iface, showHistory]);
|
||||
|
||||
function handleExport() {
|
||||
// ── Version history ────────────────────────────────────────────────────────
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!iface.id) { setVersions([]); return; }
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
|
||||
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
|
||||
setVersions(await res.json());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setShowHistory(s => {
|
||||
if (!s) loadVersions();
|
||||
return !s;
|
||||
});
|
||||
}, [loadVersions]);
|
||||
|
||||
// Load a past revision into the editor non-destructively: the current id is
|
||||
// preserved, so saving the restored content creates a new revision on top.
|
||||
const loadVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes. Load this version anyway?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}`);
|
||||
if (!res.ok) throw new Error(`Load version failed (${res.status})`);
|
||||
const restored = parseInterface(await res.text());
|
||||
restored.id = iface.id; // keep editing the same interface
|
||||
setIface(restored);
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty]);
|
||||
|
||||
// Edit (or clear) the label of an existing revision in place.
|
||||
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
|
||||
if (!iface.id) return;
|
||||
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, loadVersions]);
|
||||
|
||||
// Make a past revision the current one (saved as a new revision on top).
|
||||
const promoteVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
|
||||
// Reload the now-current interface content into the editor.
|
||||
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
const reloaded = parseInterface(await cur.text());
|
||||
setIface(reloaded);
|
||||
setSelectedIds([]);
|
||||
setDirty(false);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty, loadVersions]);
|
||||
|
||||
// Fork a revision into a brand-new interface.
|
||||
const forkVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
// ── Auto-snapshot: every 5 minutes, if there are unsaved changes on an
|
||||
// already-persisted interface, save a new revision automatically. ──────────
|
||||
const saveRef = useRef(handleSave);
|
||||
saveRef.current = handleSave;
|
||||
const autoSaveState = useRef({ dirty, id: iface.id, saving });
|
||||
autoSaveState.current = { dirty, id: iface.id, saving };
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const s = autoSaveState.current;
|
||||
if (s.dirty && s.id && !s.saving) {
|
||||
saveRef.current('auto');
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const xml = serializeInterface(iface);
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -227,9 +500,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [iface]);
|
||||
|
||||
function handleImport() {
|
||||
const handleImport = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.xml,application/xml,text/xml';
|
||||
@@ -248,78 +521,30 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleLeave() {
|
||||
const handleLeave = useCallback(async () => {
|
||||
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return;
|
||||
// Brand-new panel that was never saved: nothing to show — return to the
|
||||
// previously open panel (signalled by passing null).
|
||||
if (!iface.id) { onDone(null); return; }
|
||||
// Existing panel closed with unsaved edits: reopen the last saved (active)
|
||||
// version rather than carrying the discarded in-memory changes into view.
|
||||
if (dirty) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
onDone(parseInterface(await res.text()));
|
||||
} catch {
|
||||
onDone(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onDone(iface);
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
const clipboard = useRef<Widget[]>([]);
|
||||
|
||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const target = e.target as Element;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
if (e.key === '?') { openHelp('edit'); return; }
|
||||
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) {
|
||||
pushUndo();
|
||||
setIface(f => ({ ...f, widgets: f.widgets.filter(w => !selectedIds.includes(w.id)) }));
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
clipboard.current = iface.widgets
|
||||
.filter(w => selectedIds.includes(w.id))
|
||||
.map(w => ({ ...w }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) {
|
||||
e.preventDefault();
|
||||
pushUndo();
|
||||
const offset = 20;
|
||||
const pasted = clipboard.current.map(w => ({
|
||||
...w,
|
||||
id: genWidgetId(),
|
||||
x: w.x + offset,
|
||||
y: w.y + offset,
|
||||
}));
|
||||
setIface(f => ({ ...f, widgets: [...f.widgets, ...pasted] }));
|
||||
setSelectedIds(pasted.map(w => w.id));
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
setSelectedIds(iface.widgets.map(w => w.id));
|
||||
return;
|
||||
}
|
||||
// Arrow nudge
|
||||
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
|
||||
handleWidgetsChange(iface.widgets.map(w =>
|
||||
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
|
||||
));
|
||||
}
|
||||
}
|
||||
}, [dirty, onDone, iface]);
|
||||
|
||||
const selectedWidget = selectedIds.length === 1
|
||||
? iface.widgets.find(w => w.id === selectedIds[0]) ?? null
|
||||
? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
|
||||
: null;
|
||||
|
||||
const canUndo = historyLen > 0;
|
||||
@@ -327,7 +552,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
const multiSelected = selectedIds.length > 1;
|
||||
|
||||
return (
|
||||
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||
<div class="edit-mode-layout" tabIndex={-1}>
|
||||
{/* ── Toolbar ── */}
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
@@ -362,17 +587,19 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
|
||||
{/* Insert static widget */}
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert ▾</button>
|
||||
{showInsertMenu && (
|
||||
<div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
|
||||
{STATIC_WIDGET_TYPES.map(({ type, label }) => (
|
||||
<button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Insert static widget — not applicable to split plot panels */}
|
||||
{iface.kind !== 'plot' && (
|
||||
<div class="toolbar-dropdown">
|
||||
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert ▾</button>
|
||||
{showInsertMenu && (
|
||||
<div class="toolbar-dropdown-menu" onClick={() => setShowInsertMenu(false)}>
|
||||
{STATIC_WIDGET_TYPES.map(({ type, label }) => (
|
||||
<button key={type} class="ctx-item" onClick={() => insertWidget(type)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Align/distribute — only when ≥2 selected */}
|
||||
{selectedIds.length >= 2 && (
|
||||
@@ -402,7 +629,20 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
|
||||
<button class="toolbar-btn" onClick={handleImport}>Import</button>
|
||||
<button class="toolbar-btn" onClick={handleExport}>Export</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}>
|
||||
<button
|
||||
class={`toolbar-btn${showHistory ? ' toolbar-btn-active' : ''}`}
|
||||
onClick={toggleHistory}
|
||||
disabled={!iface.id}
|
||||
title={iface.id ? 'Version history' : 'Save the interface first to enable history'}
|
||||
>
|
||||
🕘 History
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn toolbar-btn-primary"
|
||||
onClick={() => handleSave(tag)}
|
||||
disabled={saving || !writable}
|
||||
title={writable ? '' : 'You have read-only access'}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button class="toolbar-btn" onClick={handleLeave}>✕ Close</button>
|
||||
@@ -411,24 +651,127 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
{/* ── Three-panel body ── */}
|
||||
<div class="edit-body">
|
||||
<SignalTree width={leftW} />
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
|
||||
<EditCanvas
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handleWidgetsChange}
|
||||
snapGrid={snapGrid}
|
||||
/>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
multiCount={multiSelected ? selectedIds.length : 0}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
width={rightW}
|
||||
/>
|
||||
{/* The logic tab is a self-contained flow editor with its own palette and
|
||||
inspector, so the layout-only side panes are hidden while it is open. */}
|
||||
{centerTab !== 'logic' && (
|
||||
<Fragment>
|
||||
<SignalTree
|
||||
width={leftW}
|
||||
panelId={iface.id}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('left')} />
|
||||
</Fragment>
|
||||
)}
|
||||
<div class="edit-center">
|
||||
<div class="center-tabs">
|
||||
<button
|
||||
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('layout')}
|
||||
>Layout</button>
|
||||
<button
|
||||
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('logic')}
|
||||
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
||||
</div>
|
||||
{centerTab === 'logic' ? (
|
||||
<LogicEditor
|
||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||
onChange={handleLogicChange}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
) : iface.kind === 'plot' ? (
|
||||
<PlotPanelCanvas
|
||||
key={canvasNonce}
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handlePlotChange}
|
||||
/>
|
||||
) : (
|
||||
<EditCanvas
|
||||
key={canvasNonce}
|
||||
iface={iface}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={setSelectedIds}
|
||||
onChange={handleWidgetsChange}
|
||||
snapGrid={snapGrid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{centerTab !== 'logic' && (
|
||||
<Fragment>
|
||||
<div class="panel-resize-handle" onMouseDown={startResize('right')} />
|
||||
<PropertiesPane
|
||||
selected={selectedWidget}
|
||||
multiCount={multiSelected ? selectedIds.length : 0}
|
||||
iface={iface}
|
||||
onChange={handleWidgetChange}
|
||||
onIfaceChange={handleIfaceChange}
|
||||
width={rightW}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
{showHistory && (
|
||||
<div class="history-pane">
|
||||
<div class="history-pane-header">
|
||||
<span>Version History</span>
|
||||
<button class="icon-btn" onClick={() => setShowHistory(false)} title="Close">✕</button>
|
||||
</div>
|
||||
<div class="history-save-row">
|
||||
<input
|
||||
class="history-tag-input"
|
||||
placeholder="Optional label…"
|
||||
value={tag}
|
||||
onInput={(e) => setTag((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="toolbar-btn" onClick={() => handleSave(tag)} disabled={saving || !writable}>
|
||||
Save snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
{historyLoading && <div class="history-empty">Loading…</div>}
|
||||
{!historyLoading && versions.length === 0 && (
|
||||
<div class="history-empty">No saved versions yet.</div>
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
|
||||
+136
-40
@@ -1,32 +1,49 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import type { Interface } from './lib/types';
|
||||
|
||||
interface ServerInterface {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
}
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface, InterfaceListItem, Folder } from './lib/types';
|
||||
import ShareDialog from './ShareDialog';
|
||||
|
||||
interface Props {
|
||||
onLoad: (xml: string) => void;
|
||||
onSelect?: (id: string) => void;
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
onNewPlot?: () => void;
|
||||
onEditId?: (id: string) => void;
|
||||
width?: number;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, width }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]);
|
||||
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
|
||||
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
|
||||
function refresh() {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/interfaces')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then(data => setInterfaces(data))
|
||||
Promise.all([
|
||||
fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []),
|
||||
fetch('/api/v1/folders').then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
.then(([ifaceData, folderData]) => {
|
||||
const list = Array.isArray(ifaceData) ? ifaceData : [];
|
||||
const normalized: InterfaceListItem[] = list.map((item: any) => ({
|
||||
id: String(item.id || item.ID || ''),
|
||||
name: String(item.name || item.Name || ''),
|
||||
version: Number(item.version || item.Version || 0),
|
||||
owner: item.owner ? String(item.owner) : '',
|
||||
folder: item.folder ? String(item.folder) : '',
|
||||
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
||||
})).filter((item: InterfaceListItem) => item.id);
|
||||
setInterfaces(normalized);
|
||||
setFolders(Array.isArray(folderData) ? folderData : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
@@ -71,13 +88,89 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
|
||||
window.open(`?fs=${encodeURIComponent(id)}`, '_blank');
|
||||
}
|
||||
|
||||
async function handleNewFolder(parent: string) {
|
||||
const name = prompt('New folder name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const res = await fetch('/api/v1/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), parent }),
|
||||
});
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(id: string, name: string) {
|
||||
if (!confirm(`Delete folder "${name}"? Its panels and subfolders move up one level.`)) return;
|
||||
const res = await fetch(`/api/v1/folders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (res.ok) refresh();
|
||||
}
|
||||
|
||||
// The caller may manage sharing when they can write and either own the panel
|
||||
// or it is unmanaged (no owner recorded yet).
|
||||
function canShare(item: InterfaceListItem): boolean {
|
||||
return writable && (!item.owner || item.owner === me.user);
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
|
||||
function renderPanel(item: InterfaceListItem) {
|
||||
return (
|
||||
<li key={item.id} class="iface-item">
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
|
||||
{item.name || item.id}
|
||||
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}>✎</button>}
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}>⛶</button>
|
||||
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}>⎘</button>}
|
||||
{canShare(item) && <button class="icon-btn" title="Share" onClick={() => setShare({ id: item.id, name: item.name })}>⚹</button>}
|
||||
{item.perm === 'write' && <button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(item.id, item.name)}>✕</button>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// Render a folder and everything beneath it (subfolders first, then panels).
|
||||
function renderFolder(folder: Folder) {
|
||||
const children = folders.filter(f => f.parent === folder.id);
|
||||
const panels = interfaces.filter(i => i.folder === folder.id);
|
||||
const open = expanded[folder.id] !== false; // default expanded
|
||||
return (
|
||||
<li key={folder.id} class="iface-folder">
|
||||
<div class="iface-folder-header">
|
||||
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
|
||||
{open ? '▾' : '▸'} {folder.name}
|
||||
</span>
|
||||
{folder.perm === 'write' && (
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="New subfolder" onClick={() => handleNewFolder(folder.id)}>+</button>
|
||||
<button class="icon-btn iface-delete" title="Delete folder" onClick={() => handleDeleteFolder(folder.id, folder.name)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<ul class="iface-list iface-sublist">
|
||||
{children.map(renderFolder)}
|
||||
{panels.map(renderPanel)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const rootFolders = folders.filter(f => !f.parent);
|
||||
const rootPanels = interfaces.filter(i => !i.folder);
|
||||
|
||||
return (
|
||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||
<div class="panel-header">
|
||||
{!collapsed && <span class="panel-title">Interfaces</span>}
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? 'Expand panel' : 'Collapse panel'}
|
||||
>
|
||||
{collapsed ? '▶' : '◀'}
|
||||
@@ -85,44 +178,47 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{writable && (
|
||||
<div class="panel-actions">
|
||||
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
|
||||
<button class="panel-btn" onClick={() => onNewPlot?.()}>+ Plot</button>
|
||||
<button class="panel-btn" onClick={() => handleNewFolder('')}>+ Folder</button>
|
||||
<button class="panel-btn" onClick={triggerImport}>Import</button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml,application/xml,text/xml"
|
||||
style="display:none"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="panel-list">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : interfaces.length === 0 ? (
|
||||
) : interfaces.length === 0 && folders.length === 0 ? (
|
||||
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
||||
) : (
|
||||
<ul class="iface-list">
|
||||
{interfaces.map(iface => (
|
||||
<li key={iface.id} class="iface-item">
|
||||
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
|
||||
{iface.name || iface.id}
|
||||
</span>
|
||||
<div class="iface-item-actions">
|
||||
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}>✎</button>
|
||||
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}>⛶</button>
|
||||
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}>⎘</button>
|
||||
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{rootFolders.map(renderFolder)}
|
||||
{rootPanels.map(renderPanel)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{share && (
|
||||
<ShareDialog
|
||||
ifaceId={share.id}
|
||||
ifaceName={share.name}
|
||||
folders={folders}
|
||||
onClose={() => setShare(null)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import { checkExpr } from './lib/expr';
|
||||
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar } from './lib/types';
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; }
|
||||
|
||||
// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
|
||||
function splitRef(ref: string): { ds: string; name: string } {
|
||||
const i = ref.indexOf(':');
|
||||
if (i < 0) return { ds: '', name: '' };
|
||||
return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
graph: LogicGraph;
|
||||
onChange: (graph: LogicGraph) => void;
|
||||
// Panel-local state variables, offered as quick write targets / signals.
|
||||
statevars?: StateVar[];
|
||||
// Edit local state variables directly from the logic editor (the layout-side
|
||||
// SignalTree is hidden while the logic tab is open).
|
||||
onStateVarsChange?: (vars: StateVar[]) => void;
|
||||
}
|
||||
|
||||
// Visual geometry of a node block. Expressed in rem (relative to the root font
|
||||
// size) so the whole flow editor scales coherently on high-DPI screens / when
|
||||
// the base font is enlarged for accessibility, instead of staying pixel-tiny.
|
||||
const REM = (() => {
|
||||
if (typeof document === 'undefined') return 16;
|
||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
})();
|
||||
const NODE_W = 11.5 * REM; // node width
|
||||
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
|
||||
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
|
||||
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
||||
|
||||
interface PaletteEntry {
|
||||
kind: LogicNodeKind;
|
||||
label: string;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'trigger.button', label: 'Button', params: { name: 'action' } },
|
||||
{ kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } },
|
||||
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
|
||||
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
|
||||
{ kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } },
|
||||
{ kind: 'gate.and', label: 'AND gate', params: {} },
|
||||
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
|
||||
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
|
||||
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
|
||||
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
|
||||
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
|
||||
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } },
|
||||
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
|
||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'trigger.button': 'Button',
|
||||
'trigger.threshold': 'Threshold',
|
||||
'trigger.change': 'On change',
|
||||
'trigger.timer': 'Timer',
|
||||
'trigger.loop': 'Panel loop',
|
||||
'gate.and': 'AND gate',
|
||||
'flow.if': 'If / else',
|
||||
'flow.loop': 'Loop',
|
||||
'action.write': 'Write',
|
||||
'action.delay': 'Delay',
|
||||
'action.accumulate': 'Accumulate',
|
||||
'action.export': 'Export CSV',
|
||||
'action.clear': 'Clear array',
|
||||
'action.log': 'Log',
|
||||
};
|
||||
|
||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||
|
||||
function isTrigger(kind: LogicNodeKind): boolean { return kind.startsWith('trigger.'); }
|
||||
function category(kind: LogicNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
|
||||
if (kind.startsWith('trigger.')) return 'trigger';
|
||||
if (kind.startsWith('gate.')) return 'gate';
|
||||
if (kind.startsWith('flow.')) return 'flow';
|
||||
return 'action';
|
||||
}
|
||||
function hasInput(kind: LogicNodeKind): boolean { return !isTrigger(kind); }
|
||||
|
||||
interface Port { id: string; label: string; }
|
||||
function outputs(kind: LogicNodeKind): Port[] {
|
||||
if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
|
||||
if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
|
||||
return [{ id: 'out', label: '' }];
|
||||
}
|
||||
function nodeHeight(kind: LogicNodeKind): number {
|
||||
return PORT_TOP + outputs(kind).length * PORT_GAP;
|
||||
}
|
||||
|
||||
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
|
||||
|
||||
// Port anchors in canvas coordinates.
|
||||
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; }
|
||||
function outAnchor(n: LogicNode, port: string) {
|
||||
const ports = outputs(n.kind);
|
||||
const i = Math.max(0, ports.findIndex(p => p.id === port));
|
||||
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
|
||||
}
|
||||
|
||||
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) {
|
||||
const nodes = graph.nodes;
|
||||
const wires = graph.wires;
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
||||
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
|
||||
const pendingRef = useRef(pendingWire);
|
||||
pendingRef.current = pendingWire;
|
||||
|
||||
// Undo/redo history + clipboard, scoped to the logic graph (separate from
|
||||
// EditMode's widget/layout history). `graphRef` always holds the latest graph
|
||||
// so the ref-based stacks read a stable value across renders.
|
||||
const graphRef = useRef(graph);
|
||||
graphRef.current = graph;
|
||||
const undoStack = useRef<LogicGraph[]>([]);
|
||||
const redoStack = useRef<LogicGraph[]>([]);
|
||||
const clipboard = useRef<{ nodes: LogicNode[]; wires: LogicWire[] } | null>(null);
|
||||
const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates
|
||||
const bump = () => setHistTick(t => t + 1);
|
||||
const canUndo = undoStack.current.length > 0;
|
||||
const canRedo = redoStack.current.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (!ds || ds === 'local' || ds === 'sys' || dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 'sys' exposes the engine's built-in time signals ({sys:time}, {sys:dt}).
|
||||
const dsOptions = ['local', 'sys', ...dataSources];
|
||||
function signalOptions(ds: string): string[] {
|
||||
if (ds === 'local') return (statevars ?? []).map(v => v.name);
|
||||
if (ds === 'sys') return ['time', 'dt'];
|
||||
return dsSignals[ds] ?? [];
|
||||
}
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
|
||||
loadSignals(splitRef(selected.params.signal ?? '').ds);
|
||||
}
|
||||
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
|
||||
}, [selectedNode, selected?.kind]);
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
// Push the current graph onto the undo stack (clearing redo). Call right
|
||||
// before a discrete edit; for continuous gestures (node drag) call once.
|
||||
function pushUndo() {
|
||||
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
|
||||
redoStack.current = [];
|
||||
bump();
|
||||
}
|
||||
function undo() {
|
||||
if (undoStack.current.length === 0) return;
|
||||
const prev = undoStack.current[undoStack.current.length - 1];
|
||||
redoStack.current = [graphRef.current, ...redoStack.current];
|
||||
undoStack.current = undoStack.current.slice(0, -1);
|
||||
setSelectedNode(null);
|
||||
setSelectedWire(null);
|
||||
onChange(prev);
|
||||
bump();
|
||||
}
|
||||
function redo() {
|
||||
if (redoStack.current.length === 0) return;
|
||||
const next = redoStack.current[0];
|
||||
undoStack.current = [...undoStack.current, graphRef.current];
|
||||
redoStack.current = redoStack.current.slice(1);
|
||||
setSelectedNode(null);
|
||||
setSelectedWire(null);
|
||||
onChange(next);
|
||||
bump();
|
||||
}
|
||||
|
||||
// ── Graph mutators ─────────────────────────────────────────────────────────
|
||||
// Apply a new graph, recording an undo entry unless `record` is false (used
|
||||
// for the intermediate frames of a node drag, which record once on first move).
|
||||
function setGraph(next: LogicGraph, record = true) {
|
||||
if (record) pushUndo();
|
||||
onChange(next);
|
||||
}
|
||||
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); }
|
||||
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); }
|
||||
|
||||
function addNode(entry: PaletteEntry, x?: number, y?: number) {
|
||||
const node: LogicNode = {
|
||||
id: genId(),
|
||||
kind: entry.kind,
|
||||
x: x ?? 40 + (nodes.length % 5) * 30,
|
||||
y: y ?? 40 + (nodes.length % 5) * 30,
|
||||
params: { ...entry.params },
|
||||
};
|
||||
setGraph({ nodes: [...nodes, node], wires });
|
||||
setSelectedNode(node.id);
|
||||
setSelectedWire(null);
|
||||
}
|
||||
|
||||
function patchParams(id: string, patch: Record<string, string>) {
|
||||
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
||||
}
|
||||
function moveNode(id: string, x: number, y: number, record = false) {
|
||||
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record);
|
||||
}
|
||||
function deleteNode(id: string) {
|
||||
setGraph({
|
||||
nodes: nodes.filter(n => n.id !== id),
|
||||
wires: wires.filter(w => w.from !== id && w.to !== id),
|
||||
});
|
||||
if (selectedNode === id) setSelectedNode(null);
|
||||
}
|
||||
function addWire(from: string, port: string, to: string) {
|
||||
if (from === to) return;
|
||||
if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
|
||||
setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
|
||||
}
|
||||
function deleteWire(idx: number) {
|
||||
setWires(wires.filter((_, i) => i !== idx));
|
||||
if (selectedWire === idx) setSelectedWire(null);
|
||||
}
|
||||
|
||||
// ── Clipboard ────────────────────────────────────────────────────────────
|
||||
// Copy the selected node (params deep-cloned). Single-selection only, but the
|
||||
// paste remap is written generically so it extends to multi-select.
|
||||
function copySelection() {
|
||||
const n = graphRef.current.nodes.find(x => x.id === selectedNode);
|
||||
if (!n) return;
|
||||
clipboard.current = { nodes: [{ ...n, params: { ...n.params } }], wires: [] };
|
||||
}
|
||||
function pasteClipboard() {
|
||||
const clip = clipboard.current;
|
||||
if (!clip || clip.nodes.length === 0) return;
|
||||
const idMap = new Map<string, string>();
|
||||
const cur = graphRef.current;
|
||||
const newNodes = clip.nodes.map(n => {
|
||||
const id = genId();
|
||||
idMap.set(n.id, id);
|
||||
return { ...n, id, x: n.x + 30, y: n.y + 30, params: { ...n.params } };
|
||||
});
|
||||
const newWires = clip.wires
|
||||
.filter(w => idMap.has(w.from) && idMap.has(w.to))
|
||||
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
|
||||
setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
|
||||
if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); }
|
||||
}
|
||||
|
||||
// ── Pointer geometry ───────────────────────────────────────────────────────
|
||||
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
||||
const el = canvasRef.current!;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
|
||||
}
|
||||
|
||||
// ── Node dragging ──────────────────────────────────────────────────────────
|
||||
function startNodeDrag(e: MouseEvent, node: LogicNode) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
||||
setSelectedNode(node.id);
|
||||
setSelectedWire(null);
|
||||
window.addEventListener('mousemove', onNodeDragMove);
|
||||
window.addEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
function onNodeDragMove(e: MouseEvent) {
|
||||
const d = dragNode.current;
|
||||
if (!d) return;
|
||||
const p = toCanvas(e);
|
||||
// Record a single undo entry per drag (on the first move, so a plain
|
||||
// click-to-select doesn't create a spurious history step).
|
||||
const record = !d.pushed;
|
||||
d.pushed = true;
|
||||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
|
||||
}
|
||||
function onNodeDragUp() {
|
||||
dragNode.current = null;
|
||||
window.removeEventListener('mousemove', onNodeDragMove);
|
||||
window.removeEventListener('mouseup', onNodeDragUp);
|
||||
}
|
||||
|
||||
// ── Wire drawing ───────────────────────────────────────────────────────────
|
||||
function startWire(e: MouseEvent, node: LogicNode, port: string) {
|
||||
e.stopPropagation();
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ from: node.id, port, x: p.x, y: p.y });
|
||||
window.addEventListener('mousemove', onWireMove);
|
||||
window.addEventListener('mouseup', onWireUp);
|
||||
}
|
||||
function onWireMove(e: MouseEvent) {
|
||||
const cur = pendingRef.current;
|
||||
if (!cur) return;
|
||||
const p = toCanvas(e);
|
||||
setPendingWire({ ...cur, x: p.x, y: p.y });
|
||||
}
|
||||
// Tear down an in-progress wire drag. Called both on a plain release over
|
||||
// empty canvas (window mouseup) and after a connection is made — the latter
|
||||
// is essential because the target port's mouseup calls stopPropagation, so
|
||||
// the window listener never fires and would otherwise leave a phantom wire
|
||||
// following the cursor.
|
||||
function endWire() {
|
||||
pendingRef.current = null;
|
||||
window.removeEventListener('mousemove', onWireMove);
|
||||
window.removeEventListener('mouseup', onWireUp);
|
||||
setPendingWire(null);
|
||||
}
|
||||
function onWireUp() {
|
||||
endWire();
|
||||
}
|
||||
function finishWire(target: LogicNode) {
|
||||
const cur = pendingRef.current;
|
||||
if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
|
||||
endWire();
|
||||
}
|
||||
|
||||
// ── Palette drag-and-drop ────────────────────────────────────────────────
|
||||
const DRAG_MIME = 'application/x-uopi-logic-node';
|
||||
function onCanvasDragOver(e: DragEvent) {
|
||||
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
function onCanvasDrop(e: DragEvent) {
|
||||
const kind = e.dataTransfer?.getData(DRAG_MIME);
|
||||
if (!kind) return;
|
||||
e.preventDefault();
|
||||
const entry = PALETTE.find(p => p.kind === kind);
|
||||
if (!entry) return;
|
||||
const p = toCanvas(e);
|
||||
// Drop point becomes the node's top-left, offset so the cursor lands inside.
|
||||
addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts (delete / undo / redo / copy / paste) ────────────────
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as Element;
|
||||
// While typing in a field, leave native text editing shortcuts alone.
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
|
||||
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
if (selectedWire !== null) deleteWire(selectedWire);
|
||||
else if (selectedNode) deleteNode(selectedNode);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [selectedNode, selectedWire, nodes, wires]);
|
||||
|
||||
const byId = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
// Array names already referenced by accumulate/export/clear nodes, offered as
|
||||
// autocomplete suggestions so the same array is reused across nodes.
|
||||
const arrayNames = Array.from(new Set(
|
||||
nodes.map(n => n.params.array).filter((a): a is string => !!a)
|
||||
));
|
||||
|
||||
// Append a {ds:name} reference into a node's expression field.
|
||||
function insertRef(id: string, field: string, ds: string, sig: string) {
|
||||
const cur = (byId.get(id)?.params[field]) ?? '';
|
||||
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flow-editor">
|
||||
<div class="flow-palette">
|
||||
<div class="flow-palette-toolbar">
|
||||
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)">↩</button>
|
||||
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)">↪</button>
|
||||
</div>
|
||||
<div class="flow-palette-title">Triggers</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
||||
<div class="flow-palette-title">Logic</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
|
||||
<div class="flow-palette-title">Actions</div>
|
||||
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
|
||||
<div class="flow-palette-hint hint">
|
||||
Triggers start a flow. Drag a node's right port to another node's left port to connect.
|
||||
In expressions, reference signals as <code>{'{ds:name}'}</code> and local vars by name.
|
||||
</div>
|
||||
{onStateVarsChange && (
|
||||
<LocalVars statevars={statevars ?? []} onChange={onStateVarsChange} />
|
||||
)}
|
||||
<datalist id="flow-array-names">
|
||||
{arrayNames.map(a => <option key={a} value={a} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div class="flow-canvas" ref={canvasRef}
|
||||
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}>
|
||||
<div class="flow-canvas-inner">
|
||||
<svg class="flow-wires">
|
||||
{wires.map((w, idx) => {
|
||||
const a = byId.get(w.from);
|
||||
const b = byId.get(w.to);
|
||||
if (!a || !b) return null;
|
||||
const p1 = outAnchor(a, w.fromPort ?? 'out');
|
||||
const p2 = inAnchor(b);
|
||||
return (
|
||||
<path key={idx}
|
||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
|
||||
);
|
||||
})}
|
||||
{pendingWire && (() => {
|
||||
const a = byId.get(pendingWire.from);
|
||||
if (!a) return null;
|
||||
const p1 = outAnchor(a, pendingWire.port);
|
||||
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{nodes.map(node => (
|
||||
<div key={node.id}
|
||||
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
||||
<div class="flow-node-header">
|
||||
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
||||
<button class="flow-node-del" title="Delete node"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
|
||||
</div>
|
||||
<div class="flow-node-body hint">{nodeSummary(node)}</div>
|
||||
|
||||
{hasInput(node.kind) && (
|
||||
<div class="flow-port flow-port-in" title="Input"
|
||||
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
|
||||
)}
|
||||
{outputs(node.kind).map((port, i) => (
|
||||
<Fragment key={port.id}>
|
||||
{port.label && (
|
||||
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
|
||||
)}
|
||||
<div class={`flow-port flow-port-out flow-port-${port.id}`}
|
||||
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
|
||||
title={`Output: ${port.id}`}
|
||||
onMouseDown={(e) => startWire(e, node, port.id)} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-inspector">
|
||||
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
|
||||
{selected && (
|
||||
<Fragment>
|
||||
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
|
||||
|
||||
{selected.kind === 'trigger.button' && (
|
||||
<div class="wizard-field">
|
||||
<label>Action name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="e.g. open_valve"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Fires when a button widget's Action is set to this name.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
|
||||
const { ds, name } = splitRef(selected.params.signal ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
{selected.kind === 'trigger.threshold' && (
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field" style="flex:0 0 4.5rem;">
|
||||
<label>Op</label>
|
||||
<select class="prop-select" value={selected.params.op ?? '>'}
|
||||
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
|
||||
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Value</label>
|
||||
<input class="prop-input" value={selected.params.value ?? ''}
|
||||
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{(selected.kind === 'trigger.timer' || selected.kind === 'trigger.loop') && (
|
||||
<div class="wizard-field">
|
||||
<label>Interval (ms)</label>
|
||||
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
|
||||
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
|
||||
{selected.kind === 'trigger.loop' && <p class="hint">Runs once on panel load, then on every interval.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'gate.and' && (
|
||||
<p class="hint">Wire two or more triggers into this gate. The flow continues only when all
|
||||
inputs are satisfied (e.g. a Button click while a Threshold is currently true).</p>
|
||||
)}
|
||||
|
||||
{selected.kind === 'flow.if' && (
|
||||
<ExprField label="Condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
)}
|
||||
|
||||
{selected.kind === 'flow.loop' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Mode</label>
|
||||
<select class="prop-select" value={selected.params.mode ?? 'count'}
|
||||
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="count">Repeat N times</option>
|
||||
<option value="while">While condition</option>
|
||||
</select>
|
||||
</div>
|
||||
{(selected.params.mode ?? 'count') === 'count' ? (
|
||||
<div class="wizard-field">
|
||||
<label>Repeat count</label>
|
||||
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
|
||||
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
) : (
|
||||
<ExprField label="While condition" value={selected.params.cond ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { cond: v })}
|
||||
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
|
||||
)}
|
||||
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.write' && (() => {
|
||||
const { ds, name } = splitRef(selected.params.target ?? '');
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Target data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
{selected.kind === 'action.delay' && (
|
||||
<div class="wizard-field">
|
||||
<label>Delay (ms)</label>
|
||||
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
|
||||
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.accumulate' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Array name</label>
|
||||
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
|
||||
placeholder="e.g. data"
|
||||
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.export' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Array name</label>
|
||||
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
|
||||
placeholder="e.g. data"
|
||||
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>File name</label>
|
||||
<input class="prop-input" value={selected.params.filename ?? ''}
|
||||
placeholder="defaults to the array name"
|
||||
onInput={(e) => patchParams(selected.id, { filename: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Downloads the array as CSV (timestamp_ms, iso, value).</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.clear' && (
|
||||
<div class="wizard-field">
|
||||
<label>Array name</label>
|
||||
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
|
||||
placeholder="e.g. data"
|
||||
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Empties the named array (e.g. to start a fresh capture).</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.log' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Label (optional)</label>
|
||||
<input class="prop-input" value={selected.params.label ?? ''}
|
||||
placeholder="prefix for the console line"
|
||||
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Logs this value to the browser console — handy for debugging a flow." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function paletteBtn(entry: PaletteEntry) {
|
||||
return (
|
||||
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
|
||||
title={`${entry.kind} — drag onto the canvas or click to add`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
onClick={() => addNode(entry)}>{entry.label}</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Expression input with a signal-insert helper and live validation.
|
||||
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onInsert: (ds: string, sig: string) => void;
|
||||
dsOptions: string[];
|
||||
signalOptions: (ds: string) => string[];
|
||||
loadSignals: (ds: string) => void;
|
||||
hint: string;
|
||||
}) {
|
||||
const [insDs, setInsDs] = useState('');
|
||||
const err = checkExpr(value);
|
||||
return (
|
||||
<div class="wizard-field">
|
||||
<label>{label}</label>
|
||||
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
|
||||
placeholder="expression"
|
||||
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
|
||||
{err && <p class="wizard-error">{err}</p>}
|
||||
<div class="flow-expr-insert">
|
||||
<SearchableSelect value={insDs} options={dsOptions}
|
||||
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
|
||||
<SearchableSelect value="" options={signalOptions(insDs)}
|
||||
onSelect={(sig) => onInsert(insDs, sig)}
|
||||
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
|
||||
</div>
|
||||
<p class="hint">{hint}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One-line summary shown in a node block's body.
|
||||
function nodeSummary(n: LogicNode): string {
|
||||
switch (n.kind) {
|
||||
case 'trigger.button': return n.params.name || '(unnamed)';
|
||||
case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
|
||||
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
|
||||
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
|
||||
case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`;
|
||||
case 'gate.and': return 'all inputs';
|
||||
case 'flow.if': return n.params.cond || '(no condition)';
|
||||
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
|
||||
? `repeat ${n.params.count || '0'}×`
|
||||
: `while ${n.params.cond || '?'}`;
|
||||
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
|
||||
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
|
||||
case 'action.accumulate': return `${n.params.array || '?'} ← ${n.params.expr || ''}`;
|
||||
case 'action.export': return `export ${n.params.array || '?'} → csv`;
|
||||
case 'action.clear': return `clear ${n.params.array || '?'}`;
|
||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Inline editor for the panel's local state variables, shown in the logic
|
||||
// palette so they can be created without leaving the (full-width) logic tab.
|
||||
function LocalVars({ statevars, onChange }: {
|
||||
statevars: StateVar[];
|
||||
onChange: (vars: StateVar[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<'number' | 'bool' | 'string'>('number');
|
||||
const [initial, setInitial] = useState('0');
|
||||
|
||||
function add() {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
onChange([...statevars.filter(v => v.name !== n), { name: n, type, initial }]);
|
||||
setName('');
|
||||
setInitial('0');
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flow-localvars">
|
||||
<div class="flow-palette-title">Local vars</div>
|
||||
{statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
|
||||
{statevars.map(v => (
|
||||
<div key={v.name} class="flow-localvar-row">
|
||||
<span class="flow-localvar-name" title={`${v.type ?? 'number'} = ${v.initial}`}>{v.name}</span>
|
||||
<button class="flow-node-del" title="Remove"
|
||||
onClick={() => onChange(statevars.filter(x => x.name !== v.name))}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{!open && (
|
||||
<button class="flow-palette-btn flow-localvar-add" onClick={() => setOpen(true)}>+ Add variable</button>
|
||||
)}
|
||||
{open && (
|
||||
<div class="flow-localvar-form">
|
||||
<input class="prop-input" value={name} placeholder="name"
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
<select class="prop-select" value={type}
|
||||
onChange={(e) => setType((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string')}>
|
||||
<option value="number">number</option>
|
||||
<option value="bool">bool</option>
|
||||
<option value="string">string</option>
|
||||
</select>
|
||||
<input class="prop-input" value={initial} placeholder="initial"
|
||||
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
|
||||
<div class="flow-localvar-actions">
|
||||
<button class="panel-btn" onClick={add}>Add</button>
|
||||
<button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import { getSignalStore, getMetaStore } from './lib/stores';
|
||||
import type { SignalRef, SignalMeta } from './lib/types';
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlotSignalStyle {
|
||||
color: string;
|
||||
lineWidth: number; // px; 0 = no line (markers-only)
|
||||
lineDash: number[]; // [] = solid, [8,4] = dashed, [2,4] = dotted
|
||||
markerSize: number; // diameter px; 0 = no markers
|
||||
}
|
||||
|
||||
export interface PlotSignal {
|
||||
ref: SignalRef;
|
||||
style: PlotSignalStyle;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signals: PlotSignal[];
|
||||
onRemove: (ref: SignalRef) => void;
|
||||
onStyleChange: (ref: SignalRef, style: PlotSignalStyle) => void;
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Buf { ts: number[]; vals: number[]; }
|
||||
|
||||
const RING_MAX = 200_000;
|
||||
|
||||
const TIME_WINDOWS = [
|
||||
{ label: '10s', secs: 10 },
|
||||
{ label: '30s', secs: 30 },
|
||||
{ label: '1m', secs: 60 },
|
||||
{ label: '5m', secs: 300 },
|
||||
{ label: '15m', secs: 900 },
|
||||
{ label: '1h', secs: 3600 },
|
||||
];
|
||||
|
||||
const LINE_WIDTHS: Array<{ label: string; value: number }> = [
|
||||
{ label: '✕', value: 0 },
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '1.5', value: 1.5 },
|
||||
{ label: '2', value: 2 },
|
||||
{ label: '3', value: 3 },
|
||||
];
|
||||
|
||||
const LINE_DASHES: Array<{ label: string; value: number[] }> = [
|
||||
{ label: '━━', value: [] },
|
||||
{ label: '╍╍', value: [8, 4] },
|
||||
{ label: '⋯', value: [2, 4] },
|
||||
];
|
||||
|
||||
const MARKER_SIZES: Array<{ label: string; value: number }> = [
|
||||
{ label: '✕', value: 0 },
|
||||
{ label: 'S', value: 3 },
|
||||
{ label: 'M', value: 5 },
|
||||
{ label: 'L', value: 8 },
|
||||
];
|
||||
|
||||
// ── Pure helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface Stat { min: number; max: number; mean: number; last: number; }
|
||||
|
||||
function pushSample(buf: Buf, t: number, v: number) {
|
||||
buf.ts.push(t);
|
||||
buf.vals.push(v);
|
||||
if (buf.ts.length > RING_MAX) {
|
||||
buf.ts.splice(0, buf.ts.length - RING_MAX);
|
||||
buf.vals.splice(0, buf.vals.length - RING_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
function buildData(signals: PlotSignal[], bufs: Map<string, Buf>, windowSec: number): uPlot.AlignedData {
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
|
||||
const allTs = new Set<number>();
|
||||
for (const s of signals) {
|
||||
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
|
||||
if (buf) for (const t of buf.ts) if (t >= cutoff) allTs.add(t);
|
||||
}
|
||||
const sorted = Array.from(allTs).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData;
|
||||
|
||||
return [
|
||||
sorted,
|
||||
...signals.map(s => {
|
||||
const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`);
|
||||
if (!buf || buf.ts.length === 0) return sorted.map(() => null);
|
||||
|
||||
// Step-hold: carry the most recent sample forward so signals with
|
||||
// different update rates align correctly on the shared time axis.
|
||||
let bi = 0;
|
||||
let hold: number | null = null;
|
||||
while (bi < buf.ts.length && buf.ts[bi] < cutoff) hold = buf.vals[bi++];
|
||||
|
||||
return sorted.map(t => {
|
||||
while (bi < buf.ts.length && buf.ts[bi] <= t) hold = buf.vals[bi++];
|
||||
return hold;
|
||||
});
|
||||
}),
|
||||
] as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
function computeStats(buf: Buf | undefined, windowSec: number): Stat | null {
|
||||
if (!buf || buf.ts.length === 0) return null;
|
||||
const cutoff = Date.now() / 1000 - windowSec;
|
||||
let min = Infinity, max = -Infinity, sum = 0, n = 0, last = NaN;
|
||||
buf.ts.forEach((t, i) => {
|
||||
if (t >= cutoff) { const v = buf.vals[i]; if (v < min) min = v; if (v > max) max = v; sum += v; n++; last = v; }
|
||||
});
|
||||
return n > 0 ? { min, max, mean: sum / n, last } : null;
|
||||
}
|
||||
|
||||
function fmtN(v: number | undefined | null): string {
|
||||
if (v === undefined || v === null || !isFinite(v)) return '—';
|
||||
const a = Math.abs(v);
|
||||
if (a === 0) return '0';
|
||||
if (a >= 10000 || (a < 0.001 && a > 0)) return v.toExponential(3);
|
||||
return v.toPrecision(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function dashEq(a: number[], b: number[]): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
function styleKey(s: PlotSignal): string {
|
||||
return `${s.ref.ds}:${s.ref.name}:${s.style.color}:${s.style.lineWidth}:${s.style.lineDash.join(',')}:${s.style.markerSize}`;
|
||||
}
|
||||
|
||||
function makeSeries(s: PlotSignal): uPlot.Series {
|
||||
const { color, lineWidth, lineDash, markerSize } = s.style;
|
||||
return {
|
||||
label: s.ref.name,
|
||||
stroke: color,
|
||||
width: lineWidth,
|
||||
dash: lineDash.length > 0 ? lineDash : undefined,
|
||||
points: {
|
||||
show: markerSize > 0,
|
||||
size: markerSize,
|
||||
stroke: color,
|
||||
fill: color,
|
||||
},
|
||||
spanGaps: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlotPanel({ signals, onRemove, onStyleChange }: Props) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const [windowSec, setWindowSec] = useState(60);
|
||||
const [stats, setStats] = useState<Array<Stat | null>>([]);
|
||||
const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map());
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
|
||||
const bufsRef = useRef<Map<string, Buf>>(new Map());
|
||||
const signalsKey = signals.map(styleKey).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const el = chartRef.current;
|
||||
|
||||
for (const s of signals) {
|
||||
const key = `${s.ref.ds}\0${s.ref.name}`;
|
||||
if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] });
|
||||
}
|
||||
|
||||
if (signals.length === 0) return;
|
||||
|
||||
let currentWindow = windowSec;
|
||||
|
||||
const uplot = new uPlot(
|
||||
{
|
||||
width: Math.max(el.clientWidth, 200),
|
||||
height: Math.max(el.clientHeight, 100),
|
||||
legend: { show: false },
|
||||
cursor: { show: true },
|
||||
axes: [
|
||||
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
|
||||
{
|
||||
stroke: '#94a3b8',
|
||||
grid: { stroke: '#2d3748' },
|
||||
ticks: { stroke: '#475569' },
|
||||
size: 55,
|
||||
values: (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)),
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
range: (): [number, number] => {
|
||||
const now = Date.now() / 1000;
|
||||
return [now - currentWindow, now];
|
||||
},
|
||||
},
|
||||
y: {
|
||||
auto: true,
|
||||
range: (_u: uPlot, min: number, max: number): [number, number] => {
|
||||
if (!isFinite(min) || !isFinite(max) || min === max) {
|
||||
const mid = isFinite(min) ? min : 0;
|
||||
return [mid - 1, mid + 1];
|
||||
}
|
||||
return [min, max];
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [{}, ...signals.map(makeSeries)],
|
||||
},
|
||||
buildData(signals, bufsRef.current, windowSec),
|
||||
el,
|
||||
);
|
||||
|
||||
const unsubs: Array<() => void> = [];
|
||||
let dirty = false;
|
||||
|
||||
signals.forEach(s => {
|
||||
const key = `${s.ref.ds}\0${s.ref.name}`;
|
||||
|
||||
const unsubV = getSignalStore(s.ref).subscribe(sv => {
|
||||
if (sv.value === null || sv.ts === null) return;
|
||||
const v = typeof sv.value === 'number' ? sv.value :
|
||||
Array.isArray(sv.value) ? sv.value[0] :
|
||||
parseFloat(String(sv.value));
|
||||
if (!isFinite(v)) return;
|
||||
const buf = bufsRef.current.get(key);
|
||||
if (buf) { pushSample(buf, new Date(sv.ts).getTime() / 1000, v); dirty = true; }
|
||||
});
|
||||
unsubs.push(unsubV);
|
||||
|
||||
const unsubM = getMetaStore(s.ref).subscribe(m => {
|
||||
if (m) setMetas(prev => new Map(prev).set(key, m));
|
||||
});
|
||||
unsubs.push(unsubM);
|
||||
});
|
||||
|
||||
(el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; };
|
||||
|
||||
let lastDrawTime = 0;
|
||||
let wasVisible = false;
|
||||
let rafId = requestAnimationFrame(function tick(ts) {
|
||||
const isVisible = el.clientWidth > 0 && el.clientHeight > 0;
|
||||
const becameVisible = isVisible && !wasVisible;
|
||||
wasVisible = isVisible;
|
||||
if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) {
|
||||
dirty = false;
|
||||
lastDrawTime = ts;
|
||||
if (becameVisible) {
|
||||
const nw = el.clientWidth, nh = el.clientHeight;
|
||||
if (nw > 0 && nh > 0) uplot.setSize({ width: nw, height: nh });
|
||||
}
|
||||
uplot.setData(buildData(signals, bufsRef.current, currentWindow));
|
||||
setStats(signals.map(s =>
|
||||
computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow)
|
||||
));
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = el.clientWidth, h = el.clientHeight;
|
||||
if (w > 0 && h > 0) uplot.setSize({ width: w, height: h });
|
||||
});
|
||||
ro.observe(el);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
unsubs.forEach(u => u());
|
||||
uplot.destroy();
|
||||
delete (el as any)._setWindow;
|
||||
};
|
||||
}, [signalsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec);
|
||||
}, [windowSec]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div class="plot-panel">
|
||||
<div class="plot-panel-toolbar">
|
||||
<span class="plot-panel-title">Live Plot</span>
|
||||
<div class="plot-window-btns">
|
||||
{TIME_WINDOWS.map(tw => (
|
||||
<button
|
||||
key={tw.secs}
|
||||
class={`toolbar-btn${windowSec === tw.secs ? ' toolbar-btn-active' : ''}`}
|
||||
onClick={() => setWindowSec(tw.secs)}
|
||||
>
|
||||
{tw.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plot-panel-body">
|
||||
{signals.length === 0 ? (
|
||||
<div class="plot-empty-hint">
|
||||
Right-click any widget in the <b>HMI</b> view and choose <b>Plot</b> to add signals here.
|
||||
</div>
|
||||
) : (
|
||||
<div class="plot-panel-content">
|
||||
<div class="plot-panel-legend">
|
||||
{signals.map((s, i) => {
|
||||
const key = `${s.ref.ds}\0${s.ref.name}`;
|
||||
const st = stats[i] ?? null;
|
||||
const meta = metas.get(key);
|
||||
const unit = meta?.unit ?? '';
|
||||
const isEditing = editingKey === key;
|
||||
const { color, lineWidth, lineDash, markerSize } = s.style;
|
||||
|
||||
function update(patch: Partial<PlotSignalStyle>) {
|
||||
onStyleChange(s.ref, { ...s.style, ...patch });
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="plot-legend-item" key={key}>
|
||||
{/* Header row */}
|
||||
<div class="plot-legend-hdr">
|
||||
<span class="plot-legend-swatch" style={`background:${color};`} />
|
||||
<span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}>
|
||||
{s.ref.name}
|
||||
</span>
|
||||
<button
|
||||
class={`plot-legend-icon-btn${isEditing ? ' active' : ''}`}
|
||||
onClick={() => setEditingKey(isEditing ? null : key)}
|
||||
title="Edit style"
|
||||
>✎</button>
|
||||
<button
|
||||
class="plot-legend-icon-btn"
|
||||
onClick={() => onRemove(s.ref)}
|
||||
title="Remove"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<table class="plot-stats-tbl">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Last</td>
|
||||
<td>{st ? `${fmtN(st.last)}${unit ? ' ' + unit : ''}` : '—'}</td>
|
||||
</tr>
|
||||
<tr><td>Min</td><td>{st ? fmtN(st.min) : '—'}</td></tr>
|
||||
<tr><td>Max</td><td>{st ? fmtN(st.max) : '—'}</td></tr>
|
||||
<tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Inline style editor */}
|
||||
{isEditing && (
|
||||
<div class="plot-style-editor">
|
||||
{/* Color */}
|
||||
<div class="plot-style-row">
|
||||
<span class="plot-style-label">Color</span>
|
||||
<input
|
||||
type="color"
|
||||
class="plot-style-color"
|
||||
value={color}
|
||||
onInput={e => update({ color: (e.target as HTMLInputElement).value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line width */}
|
||||
<div class="plot-style-row">
|
||||
<span class="plot-style-label">Width</span>
|
||||
<div class="plot-style-btns">
|
||||
{LINE_WIDTHS.map(lw => (
|
||||
<button
|
||||
key={lw.value}
|
||||
class={`plot-style-btn${lineWidth === lw.value ? ' active' : ''}`}
|
||||
onClick={() => update({ lineWidth: lw.value })}
|
||||
title={lw.value === 0 ? 'No line' : `${lw.value}px`}
|
||||
>{lw.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line dash */}
|
||||
<div class="plot-style-row">
|
||||
<span class="plot-style-label">Line</span>
|
||||
<div class="plot-style-btns">
|
||||
{LINE_DASHES.map(ld => (
|
||||
<button
|
||||
key={ld.label}
|
||||
class={`plot-style-btn${dashEq(lineDash, ld.value) ? ' active' : ''}`}
|
||||
onClick={() => update({ lineDash: ld.value })}
|
||||
title={ld.value.length === 0 ? 'Solid' : ld.value[0] > 4 ? 'Dashed' : 'Dotted'}
|
||||
>{ld.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markers */}
|
||||
<div class="plot-style-row">
|
||||
<span class="plot-style-label">Markers</span>
|
||||
<div class="plot-style-btns">
|
||||
{MARKER_SIZES.map(ms => (
|
||||
<button
|
||||
key={ms.value}
|
||||
class={`plot-style-btn${markerSize === ms.value ? ' active' : ''}`}
|
||||
onClick={() => update({ markerSize: ms.value })}
|
||||
title={ms.value === 0 ? 'None' : `Size ${ms.value}`}
|
||||
>{ms.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div class="plot-panel-chart" ref={chartRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { Interface, Widget, SignalRef, PlotLayout } from './lib/types';
|
||||
import PlotWidget from './widgets/PlotWidget';
|
||||
import SplitLayout from './SplitLayout';
|
||||
import { genWidgetId } from './EditCanvas';
|
||||
import { splitLeaf, removeLeaf, setRatioAtPath, countLeaves } from './lib/plotLayout';
|
||||
|
||||
interface Props {
|
||||
iface: Interface;
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[]) => void;
|
||||
/** Apply a combined widgets + layout change as one undoable step. */
|
||||
onChange: (widgets: Widget[], layout: PlotLayout) => void;
|
||||
}
|
||||
|
||||
function newPlotWidget(): Widget {
|
||||
return {
|
||||
id: genWidgetId(),
|
||||
type: 'plot',
|
||||
x: 0, y: 0, w: 800, h: 500,
|
||||
signals: [],
|
||||
options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' },
|
||||
};
|
||||
}
|
||||
|
||||
export default function PlotPanelCanvas({ iface, selectedIds, onSelect, onChange }: Props) {
|
||||
const layout = iface.layout ?? { type: 'leaf', widget: iface.widgets[0]?.id ?? '' };
|
||||
const byId = new Map(iface.widgets.map(w => [w.id, w]));
|
||||
const single = countLeaves(layout) <= 1;
|
||||
|
||||
// When there is only one pane, it is always the selected one. Also recover
|
||||
// from a selection that points at a widget no longer present in this panel.
|
||||
useEffect(() => {
|
||||
const onlyId = single ? (layout.type === 'leaf' ? layout.widget : iface.widgets[0]?.id) : null;
|
||||
if (onlyId && selectedIds[0] !== onlyId) {
|
||||
onSelect([onlyId]);
|
||||
} else if (!single && selectedIds.length === 1 && !byId.has(selectedIds[0])) {
|
||||
onSelect([]);
|
||||
}
|
||||
}, [single, layout, selectedIds.join(',')]);
|
||||
|
||||
function handleSplit(widgetId: string, dir: 'h' | 'v') {
|
||||
const w = newPlotWidget();
|
||||
const nextLayout = splitLeaf(layout, widgetId, dir, w.id);
|
||||
onChange([...iface.widgets, w], nextLayout);
|
||||
onSelect([w.id]);
|
||||
}
|
||||
|
||||
function handleClose(widgetId: string) {
|
||||
if (single) return;
|
||||
const nextLayout = removeLeaf(layout, widgetId);
|
||||
onChange(iface.widgets.filter(w => w.id !== widgetId), nextLayout);
|
||||
onSelect([]);
|
||||
}
|
||||
|
||||
function handleResize(path: number[], ratio: number) {
|
||||
onChange(iface.widgets, setRatioAtPath(layout, path, ratio));
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, widget: Widget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const json = e.dataTransfer?.getData('application/json');
|
||||
if (!json) return;
|
||||
let sig: SignalRef;
|
||||
try { sig = JSON.parse(json); } catch { return; }
|
||||
if (widget.signals.some(s => s.ds === sig.ds && s.name === sig.name)) return;
|
||||
const updated = { ...widget, signals: [...widget.signals, sig] };
|
||||
onChange(iface.widgets.map(w => w.id === updated.id ? updated : w), layout);
|
||||
onSelect([updated.id]);
|
||||
}
|
||||
|
||||
function renderLeaf(widgetId: string) {
|
||||
const widget = byId.get(widgetId);
|
||||
const selected = selectedIds.includes(widgetId);
|
||||
return (
|
||||
<div
|
||||
class={`plot-pane${selected ? ' plot-pane-selected' : ''}`}
|
||||
onMouseDownCapture={() => onSelect([widgetId])}
|
||||
onDragOver={(e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }}
|
||||
onDrop={(e: DragEvent) => widget && handleDrop(e, widget)}
|
||||
>
|
||||
{selected && <div class="plot-pane-badge">editing</div>}
|
||||
{widget
|
||||
? <PlotWidget widget={widget} timeRange={null} />
|
||||
: <div class="plot-pane-empty">missing plot</div>}
|
||||
|
||||
<div class="plot-pane-overlay" onMouseDown={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<button class="plot-pane-btn" title="Split left/right" onClick={() => handleSplit(widgetId, 'h')}>⬌</button>
|
||||
<button class="plot-pane-btn" title="Split top/bottom" onClick={() => handleSplit(widgetId, 'v')}>⬍</button>
|
||||
<button class="plot-pane-btn" title="Remove this plot" disabled={single} onClick={() => handleClose(widgetId)}>✕</button>
|
||||
</div>
|
||||
|
||||
{widget && widget.signals.length === 0 && (
|
||||
<div class="plot-pane-hint">Drag a signal here, then configure it in the properties pane →</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="plot-panel-edit">
|
||||
<SplitLayout layout={layout} renderLeaf={renderLeaf} onResize={handleResize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+55
-24
@@ -1,5 +1,5 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { Widget, Interface } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -21,17 +21,25 @@ function Field({ label, children }: { label: string; children: any }) {
|
||||
}
|
||||
|
||||
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) {
|
||||
const [local, setLocal] = useState(value);
|
||||
const [local, setLocal] = useState(value || '');
|
||||
|
||||
// Sync when external value changes (e.g. different widget selected)
|
||||
if (local !== value && document.activeElement?.tagName !== 'INPUT') {
|
||||
setLocal(value);
|
||||
}
|
||||
useEffect(() => {
|
||||
setLocal(value || '');
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
class="prop-input"
|
||||
value={local}
|
||||
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
|
||||
onChange={(e) => onCommit((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onCommit((e.target as HTMLInputElement).value);
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,31 +87,37 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<div class="props-body">
|
||||
{/* Canvas-level properties */}
|
||||
<div class="props-section">
|
||||
<div class="props-section-title">Canvas</div>
|
||||
<div class="props-section-title">{iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}</div>
|
||||
<Field label="Name">
|
||||
<TextInput
|
||||
value={iface.name}
|
||||
onCommit={(v) => onIfaceChange({ ...iface, name: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Width">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.w}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.h}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
||||
/>
|
||||
</Field>
|
||||
{/* Plot panels fill the viewport via the split layout — fixed px size
|
||||
is not meaningful, so width/height are hidden. */}
|
||||
{iface.kind !== 'plot' && (
|
||||
<div>
|
||||
<Field label="Width">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.w}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height">
|
||||
<input
|
||||
class="prop-input prop-input-num"
|
||||
type="number"
|
||||
value={iface.h}
|
||||
min={100}
|
||||
onChange={(e) => onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{w && (
|
||||
@@ -253,6 +267,23 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Action">
|
||||
<div class="prop-field-col">
|
||||
<select
|
||||
class="prop-input"
|
||||
value={w.options['action'] ?? ''}
|
||||
onChange={(e) => setOpt('action', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{(iface.logic?.nodes ?? [])
|
||||
.filter(n => n.kind === 'trigger.button')
|
||||
.map(n => (
|
||||
<option key={n.id} value={n.params.name ?? ''}>{n.params.name || '(unnamed)'}</option>
|
||||
))}
|
||||
</select>
|
||||
<span class="prop-hint">Fires a Button trigger node (define flows in the Logic tab)</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
|
||||
// A dropdown with an inline search box. Extracted from SyntheticWizard so the
|
||||
// same searchable picker can be reused (e.g. the logic editor's signal field).
|
||||
export default function SearchableSelect({
|
||||
value,
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = 'Select…',
|
||||
}: {
|
||||
value: string;
|
||||
options: string[];
|
||||
onSelect: (val: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(o => o.toLowerCase().includes(f));
|
||||
}, [options, filter]);
|
||||
|
||||
return (
|
||||
<div class="search-select">
|
||||
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
|
||||
{value || <span class="search-select-placeholder">{placeholder}</span>}
|
||||
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div class="search-select-dropdown">
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="Search…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="search-select-list">
|
||||
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
|
||||
{filtered.map(opt => (
|
||||
<div
|
||||
key={opt}
|
||||
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
|
||||
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import type { PanelACL, Grant, Folder } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
ifaceId: string;
|
||||
ifaceName: string;
|
||||
folders: Folder[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
// ShareDialog edits a single panel's sharing settings: its folder, public
|
||||
// visibility, and explicit per-user / per-group grants. Only the owner can
|
||||
// reach it (the caller is gated upstream in InterfaceList).
|
||||
export default function ShareDialog({ ifaceId, ifaceName, folders, onClose, onSaved }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [folder, setFolder] = useState('');
|
||||
const [pub, setPub] = useState<'' | 'read' | 'write'>('');
|
||||
const [grants, setGrants] = useState<Grant[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<string[]>([]);
|
||||
|
||||
// New-grant row state.
|
||||
const [newKind, setNewKind] = useState<'user' | 'group'>('user');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newPerm, setNewPerm] = useState<'read' | 'write'>('read');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`).then(r => r.ok ? r.json() : null),
|
||||
fetch('/api/v1/usergroups').then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
.then(([acl, groups]: [PanelACL | null, string[]]) => {
|
||||
if (acl) {
|
||||
setOwner(acl.owner || '');
|
||||
setFolder(acl.folder || '');
|
||||
setPub(acl.public || '');
|
||||
setGrants(Array.isArray(acl.grants) ? acl.grants : []);
|
||||
}
|
||||
setUserGroups(Array.isArray(groups) ? groups : []);
|
||||
})
|
||||
.catch(() => setError('Failed to load sharing settings.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ifaceId]);
|
||||
|
||||
function addGrant() {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
// Replace an existing grant for the same kind+name rather than duplicating.
|
||||
const next = grants.filter(g => !(g.kind === newKind && g.name === name));
|
||||
next.push({ kind: newKind, name, perm: newPerm });
|
||||
setGrants(next);
|
||||
setNewName('');
|
||||
}
|
||||
|
||||
function removeGrant(idx: number) {
|
||||
setGrants(grants.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder, public: pub, grants }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.json().catch(() => null);
|
||||
throw new Error(msg?.error || `Save failed (${res.status})`);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 560px;">
|
||||
<div class="wizard-header">
|
||||
<span>Share — {ifaceName || ifaceId}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : (
|
||||
<div>
|
||||
{owner && (
|
||||
<p class="hint" style="padding: 0 0 0.75rem 0;">Owner: <strong>{owner}</strong></p>
|
||||
)}
|
||||
|
||||
<div class="wizard-section-title">Folder</div>
|
||||
<div class="wizard-field">
|
||||
<select class="prop-select" value={folder} onChange={e => setFolder((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">(root — no folder)</option>
|
||||
{folders.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1rem;">Public access</div>
|
||||
<div class="wizard-field">
|
||||
<select class="prop-select" value={pub} onChange={e => setPub((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="">Private (only owner & shares)</option>
|
||||
<option value="read">Everyone can view</option>
|
||||
<option value="write">Everyone can edit</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1rem;">Shared with</div>
|
||||
{grants.length === 0 ? (
|
||||
<p class="hint" style="padding: 0 0 0.5rem 0;">Not shared with anyone yet.</p>
|
||||
) : (
|
||||
<ul class="iface-list" style="margin-bottom: 0.5rem;">
|
||||
{grants.map((g, idx) => (
|
||||
<li key={`${g.kind}:${g.name}`} class="iface-item" style="cursor: default;">
|
||||
<span>
|
||||
<span style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155; margin-right: 6px;">
|
||||
{g.kind}
|
||||
</span>
|
||||
<span style="color: #e2e8f0;">{g.name}</span>
|
||||
<span style="color: #64748b; margin-left: 6px;">({g.perm})</span>
|
||||
</span>
|
||||
<button class="icon-btn iface-delete" title="Remove" onClick={() => removeGrant(idx)}>✕</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div class="wizard-field wizard-field-row" style="gap: 0.5rem; align-items: center;">
|
||||
<select class="prop-select" style="flex: 0 0 80px;" value={newKind} onChange={e => { setNewKind((e.target as HTMLSelectElement).value as any); setNewName(''); }}>
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
{newKind === 'group' ? (
|
||||
<select class="prop-select" style="flex: 1;" value={newName} onChange={e => setNewName((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">Select group…</option>
|
||||
{userGroups.map(g => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
class="prop-input"
|
||||
style="flex: 1;"
|
||||
placeholder="username"
|
||||
value={newName}
|
||||
onInput={e => setNewName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addGrant()}
|
||||
/>
|
||||
)}
|
||||
<select class="prop-select" style="flex: 0 0 80px;" value={newPerm} onChange={e => setNewPerm((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
<button class="panel-btn" onClick={addGrant} disabled={!newName.trim()}>Add</button>
|
||||
</div>
|
||||
|
||||
{error && <p class="wizard-error" style="margin-top: 0.75rem;">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={save} disabled={saving || loading}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+132
-7
@@ -1,6 +1,6 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef } from './lib/types';
|
||||
import type { SignalRef, StateVar } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticEditor from './SyntheticEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
@@ -46,9 +46,14 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
|
||||
interface Props {
|
||||
onDragStart?: (sig: SignalRef) => void;
|
||||
width?: number;
|
||||
// Current interface id — scopes panel-visibility synthetic signals.
|
||||
panelId?: string;
|
||||
// Panel-local state variables and a setter (edit mode only).
|
||||
statevars?: StateVar[];
|
||||
onStateVarsChange?: (vars: StateVar[]) => void;
|
||||
}
|
||||
|
||||
export default function SignalTree({ onDragStart, width }: Props) {
|
||||
export default function SignalTree({ onDragStart, width, panelId, statevars, onStateVarsChange }: Props) {
|
||||
const [treeTab, setTreeTab] = useState<TreeTab>('sources');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -79,9 +84,14 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
await Promise.all(
|
||||
dsList.map(async ({ name }) => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`);
|
||||
// Synthetic signals are filtered server-side by panel scope.
|
||||
const url = name === 'synthetic' && panelId
|
||||
? `/api/v1/signals?ds=synthetic&panel=${encodeURIComponent(panelId)}`
|
||||
: `/api/v1/signals?ds=${encodeURIComponent(name)}`;
|
||||
const r = await fetch(url);
|
||||
if (r.ok) {
|
||||
const sigs: SignalInfo[] = await r.json();
|
||||
const data = await r.json();
|
||||
const sigs = Array.isArray(data) ? data : (data && typeof data === 'object' && Array.isArray(data.signals) ? data.signals : []);
|
||||
all.push(...sigs.map(s => ({ ...s, ds: name })));
|
||||
}
|
||||
} catch (e) { console.error(`Failed to load ${name}:`, e); }
|
||||
@@ -90,8 +100,10 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
setRawSignals(all);
|
||||
|
||||
// Check if Channel Finder is available
|
||||
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null);
|
||||
setCfAvailable(cf?.status === 200);
|
||||
const cf = await fetch('/api/v1/channel-finder?q=')
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null);
|
||||
setCfAvailable(!!cf?.available);
|
||||
} catch (e) {
|
||||
console.error('Failed to load signals:', e);
|
||||
} finally {
|
||||
@@ -99,7 +111,41 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAllSignals(); }, []);
|
||||
useEffect(() => { loadAllSignals(); }, [panelId]);
|
||||
|
||||
// ── Local state variables (edit mode) ──────────────────────────────────────
|
||||
const [showAddLocal, setShowAddLocal] = useState(false);
|
||||
const [localOpen, setLocalOpen] = useState(true);
|
||||
const [lvName, setLvName] = useState('');
|
||||
const [lvType, setLvType] = useState<'number' | 'bool' | 'string'>('number');
|
||||
const [lvInitial, setLvInitial] = useState('0');
|
||||
|
||||
function addLocal() {
|
||||
const name = lvName.trim();
|
||||
if (!name || !onStateVarsChange) return;
|
||||
const next = (statevars ?? []).filter(v => v.name !== name);
|
||||
next.push({ name, type: lvType, initial: lvInitial });
|
||||
onStateVarsChange(next);
|
||||
setLvName('');
|
||||
setLvInitial('0');
|
||||
setShowAddLocal(false);
|
||||
}
|
||||
|
||||
function removeLocal(name: string) {
|
||||
if (!onStateVarsChange) return;
|
||||
onStateVarsChange((statevars ?? []).filter(v => v.name !== name));
|
||||
}
|
||||
|
||||
function makeLocalDraggable(name: string) {
|
||||
return {
|
||||
draggable: true as const,
|
||||
onDragStart: (e: DragEvent) => {
|
||||
const ref: SignalRef = { ds: 'local', name };
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(ref));
|
||||
onDragStart?.(ref);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Merge custom signals that might not be in ListSignals yet
|
||||
const allSignals = useMemo(() => {
|
||||
@@ -243,6 +289,84 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
<GroupsTree onDragStart={onDragStart} />
|
||||
)}
|
||||
|
||||
{treeTab === 'sources' && onStateVarsChange && (
|
||||
<div class="signal-group local-state-group">
|
||||
<div class="signal-group-header">
|
||||
<span class="signal-group-arrow" onClick={() => setLocalOpen(o => !o)}>
|
||||
{localOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="signal-group-name" title="Panel-local state variables" onClick={() => setLocalOpen(o => !o)}>
|
||||
Local State
|
||||
</span>
|
||||
<span class="signal-group-count">{(statevars ?? []).length}</span>
|
||||
<button
|
||||
class="icon-btn signal-add-btn"
|
||||
title="Add local state variable"
|
||||
onClick={(e) => { e.stopPropagation(); setShowAddLocal(s => !s); setLocalOpen(true); }}
|
||||
>+</button>
|
||||
</div>
|
||||
{localOpen && (
|
||||
<div>
|
||||
{showAddLocal && (
|
||||
<div class="signal-add-row" style="flex-wrap: wrap; gap: 4px;">
|
||||
<input
|
||||
class="signal-add-input"
|
||||
placeholder="Variable name…"
|
||||
autoFocus
|
||||
value={lvName}
|
||||
onInput={(e) => setLvName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') addLocal();
|
||||
if (e.key === 'Escape') setShowAddLocal(false);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
class="prop-select"
|
||||
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
|
||||
value={lvType}
|
||||
onChange={(e) => setLvType((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
<option value="number">number</option>
|
||||
<option value="bool">bool</option>
|
||||
<option value="string">string</option>
|
||||
</select>
|
||||
<input
|
||||
class="signal-add-input"
|
||||
style="flex: 1;"
|
||||
placeholder="initial"
|
||||
value={lvInitial}
|
||||
onInput={(e) => setLvInitial((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }}
|
||||
/>
|
||||
<button class="icon-btn" title="Add" onClick={addLocal}>✓</button>
|
||||
<button class="icon-btn" title="Cancel" onClick={() => setShowAddLocal(false)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
{(statevars ?? []).length === 0 && !showAddLocal && (
|
||||
<p class="hint" style="padding: 0.25rem 0.5rem;">No local variables.</p>
|
||||
)}
|
||||
{(statevars ?? []).map(v => (
|
||||
<div
|
||||
key={v.name}
|
||||
class="signal-item signal-custom"
|
||||
title={`local:${v.name} (${v.type ?? 'number'}, initial ${v.initial})`}
|
||||
{...makeLocalDraggable(v.name)}
|
||||
>
|
||||
<span class="signal-name">{v.name}</span>
|
||||
<span class="signal-unit">{v.type ?? 'number'}</span>
|
||||
<button
|
||||
class="icon-btn signal-remove-btn"
|
||||
title="Remove local variable"
|
||||
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => { e.stopPropagation(); removeLocal(v.name); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{treeTab === 'sources' && (
|
||||
<Fragment>
|
||||
<div class="signal-tree-search">
|
||||
@@ -368,6 +492,7 @@ export default function SignalTree({ onDragStart, width }: Props) {
|
||||
|
||||
{showWizard && (
|
||||
<SyntheticWizard
|
||||
currentIfaceId={panelId}
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={loadAllSignals}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { h } from 'preact';
|
||||
import type { VNode } from 'preact';
|
||||
import type { PlotLayout } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
layout: PlotLayout;
|
||||
/** Render the contents of a leaf pane for the given widget id. */
|
||||
renderLeaf: (widgetId: string) => VNode;
|
||||
/** When provided, dividers become draggable and report the new ratio for the
|
||||
* split node addressed by `path` (a list of 0/1 = a/b choices from the root). */
|
||||
onResize?: (path: number[], ratio: number) => void;
|
||||
}
|
||||
|
||||
const MIN_RATIO = 0.1;
|
||||
const MAX_RATIO = 0.9;
|
||||
|
||||
export default function SplitLayout({ layout, renderLeaf, onResize }: Props) {
|
||||
function startDrag(e: MouseEvent, container: HTMLElement | null, dir: 'h' | 'v', path: number[]) {
|
||||
if (!onResize || !container) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
function onMove(mv: MouseEvent) {
|
||||
const rect = container!.getBoundingClientRect();
|
||||
const r = dir === 'h'
|
||||
? (mv.clientX - rect.left) / rect.width
|
||||
: (mv.clientY - rect.top) / rect.height;
|
||||
onResize!(path, Math.min(MAX_RATIO, Math.max(MIN_RATIO, r)));
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function renderNode(node: PlotLayout, path: number[]): VNode {
|
||||
if (node.type === 'leaf') {
|
||||
return <div class="split-leaf">{renderLeaf(node.widget)}</div>;
|
||||
}
|
||||
const isRow = node.dir === 'h';
|
||||
let containerEl: HTMLElement | null = null;
|
||||
return (
|
||||
<div
|
||||
class={isRow ? 'split-row' : 'split-col'}
|
||||
ref={(el) => { containerEl = el as HTMLElement | null; }}
|
||||
>
|
||||
<div class="split-child" style={`flex:${node.ratio}`}>
|
||||
{renderNode(node.a, [...path, 0])}
|
||||
</div>
|
||||
<div
|
||||
class={`split-divider ${isRow ? 'split-divider-h' : 'split-divider-v'}${onResize ? '' : ' split-divider-static'}`}
|
||||
onMouseDown={(e: MouseEvent) => startDrag(e, containerEl, node.dir, path)}
|
||||
/>
|
||||
<div class="split-child" style={`flex:${1 - node.ratio}`}>
|
||||
{renderNode(node.b, [...path, 1])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div class="split-root">{renderNode(layout, [])}</div>;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
|
||||
+23
-58
@@ -1,11 +1,14 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useMemo } from 'preact/hooks';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import type { InputRef, SignalDef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
// Interface id the wizard was opened from — used to bind panel-scoped signals.
|
||||
currentIfaceId?: string;
|
||||
}
|
||||
|
||||
interface NodeParam {
|
||||
@@ -25,6 +28,7 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
@@ -39,69 +43,16 @@ interface SignalInfo {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Searchable Selector ──────────────────────────────────────────────────────
|
||||
|
||||
function SearchableSelect({
|
||||
value,
|
||||
options,
|
||||
onSelect,
|
||||
placeholder = 'Select…'
|
||||
}: {
|
||||
value: string;
|
||||
options: string[];
|
||||
onSelect: (val: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(o => o.toLowerCase().includes(f));
|
||||
}, [options, filter]);
|
||||
|
||||
return (
|
||||
<div class="search-select">
|
||||
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
|
||||
{value || <span class="hint">{placeholder}</span>}
|
||||
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div class="search-select-dropdown">
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="Search…"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="search-select-list">
|
||||
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
|
||||
{filtered.map(opt => (
|
||||
<div
|
||||
key={opt}
|
||||
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
|
||||
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
|
||||
>
|
||||
{opt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||||
const [nodeType, setNodeType] = useState('gain');
|
||||
const [params, setParams] = useState<Record<string, string>>({});
|
||||
// Visibility scope. Panel scope requires an interface to bind to, so when the
|
||||
// wizard is opened outside a saved panel it falls back to 'user'.
|
||||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [dispLow, setDispLow] = useState('0');
|
||||
@@ -180,6 +131,8 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
displayLow: parseFloat(dispLow) || 0,
|
||||
displayHigh: parseFloat(dispHigh) || 100,
|
||||
},
|
||||
visibility,
|
||||
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
@@ -222,6 +175,18 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label>Visibility</label>
|
||||
<select class="prop-select" value={visibility}
|
||||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="panel" disabled={!currentIfaceId}>
|
||||
This panel only{currentIfaceId ? '' : ' (save panel first)'}
|
||||
</option>
|
||||
<option value="user">All my panels</option>
|
||||
<option value="global">All panels (global)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Input signals</div>
|
||||
{inputs.map((inp, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
|
||||
+35
-62
@@ -2,11 +2,11 @@ import { h } from 'preact';
|
||||
import { useState, useMemo, useEffect } from 'preact/hooks';
|
||||
import InterfaceList from './InterfaceList';
|
||||
import Canvas from './Canvas';
|
||||
import PlotPanel from './PlotPanel';
|
||||
import type { PlotSignal, PlotSignalStyle } from './PlotPanel';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { parseInterface } from './lib/xml';
|
||||
import type { Interface, SignalRef } from './lib/types';
|
||||
import { newPlotPanel } from './lib/templates';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface } from './lib/types';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
@@ -14,6 +14,8 @@ import ZoomControl from './ZoomControl';
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
initialInterface?: Interface | null;
|
||||
/** Notifies the parent which interface is currently shown (for edit return). */
|
||||
onView?: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
|
||||
@@ -22,20 +24,17 @@ function toLocalInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const PLOT_COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||
|
||||
type ViewTab = 'hmi' | 'plot';
|
||||
|
||||
export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [wsStatus, setWsStatus] = useState('connecting');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
const [viewTab, setViewTab] = useState<ViewTab>('hmi');
|
||||
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [listCollapsed, setListCollapsed] = useState(false);
|
||||
const me = useAuth();
|
||||
const writable = canWrite(me.level);
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
@@ -70,6 +69,13 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
if (initialInterface) setCurrentInterface(initialInterface);
|
||||
}, [initialInterface]);
|
||||
|
||||
// Auto-hide the interface-selection pane whenever a panel is opened, and keep
|
||||
// the parent informed of what's shown (so closing the editor can return here).
|
||||
useEffect(() => {
|
||||
if (currentInterface) setListCollapsed(true);
|
||||
onView?.(currentInterface);
|
||||
}, [currentInterface]);
|
||||
|
||||
function handleLoad(xml: string) {
|
||||
try {
|
||||
setParseError(null);
|
||||
@@ -112,27 +118,6 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
setTimeRange(null);
|
||||
}
|
||||
|
||||
function addToPlot(ref: SignalRef) {
|
||||
const key = `${ref.ds}\0${ref.name}`;
|
||||
const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key);
|
||||
if (!already) {
|
||||
const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length];
|
||||
const style: PlotSignalStyle = { color, lineWidth: 1.5, lineDash: [], markerSize: 5 };
|
||||
setPlotSignals(prev => [...prev, { ref, style }]);
|
||||
}
|
||||
setViewTab('plot');
|
||||
}
|
||||
|
||||
function removeFromPlot(ref: SignalRef) {
|
||||
setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name)));
|
||||
}
|
||||
|
||||
function updateSignalStyle(ref: SignalRef, style: PlotSignalStyle) {
|
||||
setPlotSignals(prev => prev.map(s =>
|
||||
s.ref.ds === ref.ds && s.ref.name === ref.name ? { ...s, style } : s
|
||||
));
|
||||
}
|
||||
|
||||
const isLive = timeRange === null;
|
||||
|
||||
return (
|
||||
@@ -152,7 +137,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
<div class="toolbar-right">
|
||||
<div class={`status-chip ${wsStatus}`}>
|
||||
<span class="status-dot"></span>
|
||||
{wsStatus}
|
||||
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
||||
</div>
|
||||
<button
|
||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||
@@ -170,13 +155,20 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
📖
|
||||
</button>
|
||||
<ZoomControl />
|
||||
<button
|
||||
class="btn-edit"
|
||||
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||
title="Switch to Edit mode"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{writable && (
|
||||
<button
|
||||
class="btn-edit"
|
||||
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
||||
title="Switch to Edit mode"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{me.user && (
|
||||
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
|
||||
{me.user}{!writable && ' (read-only)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -219,14 +211,16 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
<InterfaceList
|
||||
onLoad={handleLoad}
|
||||
onEdit={onEdit}
|
||||
onNewPlot={() => onEdit?.(newPlotPanel())}
|
||||
onEditId={handleEditById}
|
||||
width={leftW}
|
||||
collapsed={listCollapsed}
|
||||
onToggleCollapse={() => setListCollapsed(c => !c)}
|
||||
onSelect={async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
handleLoad(await res.text());
|
||||
setViewTab('hmi');
|
||||
} catch (err) {
|
||||
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
@@ -235,34 +229,13 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
|
||||
<div class="panel-resize-handle" onMouseDown={startResize} />
|
||||
|
||||
<div class="view-content-area">
|
||||
{/* Tab bar */}
|
||||
<div class="view-tabs">
|
||||
<button
|
||||
class={`view-tab${viewTab === 'hmi' ? ' view-tab-active' : ''}`}
|
||||
onClick={() => setViewTab('hmi')}
|
||||
>
|
||||
HMI
|
||||
</button>
|
||||
<button
|
||||
class={`view-tab${viewTab === 'plot' ? ' view-tab-active' : ''}`}
|
||||
onClick={() => setViewTab('plot')}
|
||||
>
|
||||
Plot{plotSignals.length > 0 ? ` (${plotSignals.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keep both panels mounted so ring-buffer data is never lost on tab switch */}
|
||||
<div style={`display:${viewTab === 'hmi' ? 'contents' : 'none'}`}>
|
||||
<div class="view-panel-container active">
|
||||
<Canvas
|
||||
iface={currentInterface}
|
||||
onNavigate={handleNavigate}
|
||||
timeRange={timeRange}
|
||||
onPlot={addToPlot}
|
||||
/>
|
||||
</div>
|
||||
<div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}>
|
||||
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} onStyleChange={updateSignalStyle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext } from 'preact';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import type { Me, AccessLevel } from './types';
|
||||
|
||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||
// trusted LAN user with full access, matching the backend default.
|
||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [] };
|
||||
|
||||
// AuthContext carries the resolved identity + global access level for the
|
||||
// current user throughout the app.
|
||||
export const AuthContext = createContext<Me>(DEFAULT_ME);
|
||||
|
||||
export function useAuth(): Me {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
// canWrite reports whether the given level permits creating/modifying panels
|
||||
// and writing signal values.
|
||||
export function canWrite(level: AccessLevel): boolean {
|
||||
return level === 'write';
|
||||
}
|
||||
|
||||
// canRead reports whether the given level permits viewing panels at all.
|
||||
export function canRead(level: AccessLevel): boolean {
|
||||
return level !== 'none';
|
||||
}
|
||||
|
||||
// fetchMe loads the caller's identity from the backend. On failure it falls
|
||||
// back to the trusted-LAN default so the UI stays usable in dev/unproxied mode.
|
||||
export async function fetchMe(): Promise<Me> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/me');
|
||||
if (!res.ok) return DEFAULT_ME;
|
||||
const data = await res.json();
|
||||
return {
|
||||
user: typeof data.user === 'string' ? data.user : '',
|
||||
level: (data.level as AccessLevel) ?? 'write',
|
||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_ME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Small, safe expression evaluator for panel logic.
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
// {ds:name} a data-source signal value (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// bareIdent a panel-local state variable (data source 'local').
|
||||
//
|
||||
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
|
||||
// and any nonzero value is truthy. The evaluator never touches the DOM or eval;
|
||||
// it walks a parsed AST against a caller-supplied resolver.
|
||||
|
||||
export interface RefLite { ds: string; name: string }
|
||||
|
||||
type Node =
|
||||
| { t: 'num'; v: number }
|
||||
| { t: 'sig'; ds: string; name: string }
|
||||
| { t: 'var'; name: string }
|
||||
| { t: 'un'; op: string; a: Node }
|
||||
| { t: 'bin'; op: string; a: Node; b: Node }
|
||||
| { t: 'tern'; c: Node; a: Node; b: Node }
|
||||
| { t: 'call'; fn: string; args: Node[] };
|
||||
|
||||
const FUNCS: Record<string, (a: number[]) => number> = {
|
||||
abs: a => Math.abs(a[0]),
|
||||
min: a => Math.min(...a),
|
||||
max: a => Math.max(...a),
|
||||
sqrt: a => Math.sqrt(a[0]),
|
||||
floor: a => Math.floor(a[0]),
|
||||
ceil: a => Math.ceil(a[0]),
|
||||
round: a => Math.round(a[0]),
|
||||
sign: a => Math.sign(a[0]),
|
||||
pow: a => Math.pow(a[0], a[1]),
|
||||
log: a => Math.log(a[0]),
|
||||
exp: a => Math.exp(a[0]),
|
||||
sin: a => Math.sin(a[0]),
|
||||
cos: a => Math.cos(a[0]),
|
||||
};
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Tok { k: string; v?: string }
|
||||
|
||||
function tokenize(src: string): Tok[] {
|
||||
const toks: Tok[] = [];
|
||||
let i = 0;
|
||||
const two = ['<=', '>=', '==', '!=', '&&', '||'];
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
||||
// signal ref {ds:name}
|
||||
if (c === '{') {
|
||||
const end = src.indexOf('}', i);
|
||||
if (end < 0) throw new Error('unterminated { in expression');
|
||||
toks.push({ k: 'sig', v: src.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
// number
|
||||
if ((c >= '0' && c <= '9') || (c === '.' && /[0-9]/.test(src[i + 1] ?? ''))) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[0-9.]/.test(src[j])) j++;
|
||||
toks.push({ k: 'num', v: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// identifier
|
||||
if (/[A-Za-z_]/.test(c)) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
|
||||
toks.push({ k: 'ident', v: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// two-char operators
|
||||
const pair = src.slice(i, i + 2);
|
||||
if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; }
|
||||
// single-char operators / punctuation
|
||||
if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||
throw new Error(`unexpected character '${c}' in expression`);
|
||||
}
|
||||
return toks;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent) ────────────────────────────────────────────────
|
||||
|
||||
function parse(src: string): Node {
|
||||
const toks = tokenize(src);
|
||||
let p = 0;
|
||||
const peek = () => toks[p];
|
||||
const eat = (k?: string): Tok => {
|
||||
const t = toks[p];
|
||||
if (!t || (k && t.k !== k)) throw new Error(`expected '${k}' in expression`);
|
||||
p++;
|
||||
return t;
|
||||
};
|
||||
|
||||
function primary(): Node {
|
||||
const t = peek();
|
||||
if (!t) throw new Error('unexpected end of expression');
|
||||
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
||||
if (t.k === 'sig') {
|
||||
eat();
|
||||
const raw = t.v!;
|
||||
const idx = raw.indexOf(':');
|
||||
const ds = idx < 0 ? raw : raw.slice(0, idx);
|
||||
const name = idx < 0 ? '' : raw.slice(idx + 1);
|
||||
return { t: 'sig', ds, name };
|
||||
}
|
||||
if (t.k === 'ident') {
|
||||
eat();
|
||||
const id = t.v!;
|
||||
if (id === 'true') return { t: 'num', v: 1 };
|
||||
if (id === 'false') return { t: 'num', v: 0 };
|
||||
if (peek()?.k === '(') {
|
||||
eat('(');
|
||||
const args: Node[] = [];
|
||||
if (peek()?.k !== ')') {
|
||||
args.push(ternary());
|
||||
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
|
||||
}
|
||||
eat(')');
|
||||
return { t: 'call', fn: id, args };
|
||||
}
|
||||
return { t: 'var', name: id };
|
||||
}
|
||||
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
|
||||
throw new Error(`unexpected token '${t.k}' in expression`);
|
||||
}
|
||||
|
||||
function unary(): Node {
|
||||
const t = peek();
|
||||
if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; }
|
||||
return primary();
|
||||
}
|
||||
|
||||
function bin(next: () => Node, ops: string[]): () => Node {
|
||||
return () => {
|
||||
let a = next();
|
||||
while (peek() && ops.includes(peek().k)) {
|
||||
const op = eat().k;
|
||||
a = { t: 'bin', op, a, b: next() };
|
||||
}
|
||||
return a;
|
||||
};
|
||||
}
|
||||
|
||||
const mul = bin(unary, ['*', '/', '%']);
|
||||
const add = bin(mul, ['+', '-']);
|
||||
const cmp = bin(add, ['<', '<=', '>', '>=']);
|
||||
const eq = bin(cmp, ['==', '!=']);
|
||||
const and = bin(eq, ['&&']);
|
||||
const or = bin(and, ['||']);
|
||||
|
||||
function ternary(): Node {
|
||||
const c = or();
|
||||
if (peek()?.k === '?') {
|
||||
eat('?');
|
||||
const a = ternary();
|
||||
eat(':');
|
||||
const b = ternary();
|
||||
return { t: 'tern', c, a, b };
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
const root = ternary();
|
||||
if (p < toks.length) throw new Error('trailing tokens in expression');
|
||||
return root;
|
||||
}
|
||||
|
||||
// Parsed-AST cache so the engine can re-evaluate hot expressions cheaply.
|
||||
const cache = new Map<string, Node | Error>();
|
||||
|
||||
function parseCached(src: string): Node {
|
||||
let n = cache.get(src);
|
||||
if (n === undefined) {
|
||||
try { n = parse(src); } catch (e) { n = e as Error; }
|
||||
cache.set(src, n);
|
||||
}
|
||||
if (n instanceof Error) throw n;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Evaluation ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type Resolver = (ds: string, name: string) => number;
|
||||
|
||||
function ev(n: Node, R: Resolver): number {
|
||||
switch (n.t) {
|
||||
case 'num': return n.v;
|
||||
case 'sig': return R(n.ds, n.name);
|
||||
case 'var': return R('local', n.name);
|
||||
case 'un': return n.op === '-' ? -ev(n.a, R) : (ev(n.a, R) === 0 ? 1 : 0);
|
||||
case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||
case 'call': {
|
||||
const fn = FUNCS[n.fn];
|
||||
if (!fn) throw new Error(`unknown function '${n.fn}'`);
|
||||
return fn(n.args.map(a => ev(a, R)));
|
||||
}
|
||||
case 'bin': {
|
||||
const a = ev(n.a, R), b = ev(n.b, R);
|
||||
switch (n.op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return a / b;
|
||||
case '%': return a % b;
|
||||
case '<': return a < b ? 1 : 0;
|
||||
case '<=': return a <= b ? 1 : 0;
|
||||
case '>': return a > b ? 1 : 0;
|
||||
case '>=': return a >= b ? 1 : 0;
|
||||
case '==': return a === b ? 1 : 0;
|
||||
case '!=': return a !== b ? 1 : 0;
|
||||
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
||||
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
|
||||
default: throw new Error(`unknown operator '${n.op}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated. */
|
||||
export function evalExpr(src: string, resolve: Resolver): number {
|
||||
try {
|
||||
return ev(parseCached(src), resolve);
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the expression evaluates to a truthy (nonzero, non-NaN) value. */
|
||||
export function evalBool(src: string, resolve: Resolver): boolean {
|
||||
const v = evalExpr(src, resolve);
|
||||
return !isNaN(v) && v !== 0;
|
||||
}
|
||||
|
||||
/** Collect every signal/local reference an expression reads, for subscription. */
|
||||
export function collectRefs(src: string): RefLite[] {
|
||||
const out: RefLite[] = [];
|
||||
let root: Node;
|
||||
try { root = parseCached(src); } catch { return out; }
|
||||
const seen = new Set<string>();
|
||||
const add = (ds: string, name: string) => {
|
||||
const k = `${ds}\0${name}`;
|
||||
if (!seen.has(k)) { seen.add(k); out.push({ ds, name }); }
|
||||
};
|
||||
const walk = (n: Node) => {
|
||||
switch (n.t) {
|
||||
case 'sig': add(n.ds, n.name); break;
|
||||
case 'var': add('local', n.name); break;
|
||||
case 'un': walk(n.a); break;
|
||||
case 'bin': walk(n.a); walk(n.b); break;
|
||||
case 'tern': walk(n.c); walk(n.a); walk(n.b); break;
|
||||
case 'call': n.args.forEach(walk); break;
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate an expression; returns an error message or null if it parses. */
|
||||
export function checkExpr(src: string): string | null {
|
||||
if (!src.trim()) return null;
|
||||
try { parse(src); return null; } catch (e) { return e instanceof Error ? e.message : String(e); }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Small FFT helpers used by the FFT and waterfall (spectrogram) plot types.
|
||||
// Scalar live signals are irregularly sampled, so callers first resample onto a
|
||||
// uniform grid, then take the magnitude spectrum.
|
||||
|
||||
function nextPow2(n: number): number {
|
||||
let p = 1;
|
||||
while (p < n) p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
// In-place iterative Cooley-Tukey FFT. Array length must be a power of two.
|
||||
function transform(re: number[], im: number[]): void {
|
||||
const n = re.length;
|
||||
if (n <= 1) return;
|
||||
|
||||
// Bit-reversal permutation.
|
||||
for (let i = 1, j = 0; i < n; i++) {
|
||||
let bit = n >> 1;
|
||||
for (; j & bit; bit >>= 1) j ^= bit;
|
||||
j ^= bit;
|
||||
if (i < j) {
|
||||
const tr = re[i]; re[i] = re[j]; re[j] = tr;
|
||||
const ti = im[i]; im[i] = im[j]; im[j] = ti;
|
||||
}
|
||||
}
|
||||
|
||||
for (let len = 2; len <= n; len <<= 1) {
|
||||
const ang = -2 * Math.PI / len;
|
||||
const wr = Math.cos(ang), wi = Math.sin(ang);
|
||||
for (let i = 0; i < n; i += len) {
|
||||
let cr = 1, ci = 0;
|
||||
for (let k = 0; k < len >> 1; k++) {
|
||||
const a = i + k, b = i + k + (len >> 1);
|
||||
const tr = re[b] * cr - im[b] * ci;
|
||||
const ti = re[b] * ci + im[b] * cr;
|
||||
re[b] = re[a] - tr; im[b] = im[a] - ti;
|
||||
re[a] += tr; im[a] += ti;
|
||||
const ncr = cr * wr - ci * wi;
|
||||
ci = cr * wi + ci * wr;
|
||||
cr = ncr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Magnitude spectrum (first half, single-sided) of a uniformly sampled signal.
|
||||
// DC is removed and a Hann window applied to reduce spectral leakage.
|
||||
export function fftMag(values: number[]): number[] {
|
||||
const len = values.length;
|
||||
if (len < 2) return [];
|
||||
const n = nextPow2(len);
|
||||
const re = new Array<number>(n).fill(0);
|
||||
const im = new Array<number>(n).fill(0);
|
||||
let mean = 0;
|
||||
for (let i = 0; i < len; i++) mean += values[i];
|
||||
mean /= len;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (len - 1));
|
||||
re[i] = (values[i] - mean) * w;
|
||||
}
|
||||
transform(re, im);
|
||||
const half = n >> 1;
|
||||
const mag = new Array<number>(half);
|
||||
for (let k = 0; k < half; k++) mag[k] = Math.hypot(re[k], im[k]) / half;
|
||||
return mag;
|
||||
}
|
||||
|
||||
// Linearly resample (ts, vals) onto n evenly spaced points spanning the data.
|
||||
// Returns the sampled series and the uniform sample interval dt (seconds).
|
||||
export function resampleUniform(ts: number[], vals: number[], n: number): { sampled: number[]; dt: number } {
|
||||
if (ts.length < 2) return { sampled: vals.slice(0, n), dt: 0 };
|
||||
const t0 = ts[0];
|
||||
const t1 = ts[ts.length - 1];
|
||||
const dt = (t1 - t0) / (n - 1);
|
||||
const sampled = new Array<number>(n);
|
||||
let j = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = t0 + i * dt;
|
||||
while (j < ts.length - 1 && ts[j + 1] <= t) j++;
|
||||
if (j >= ts.length - 1) {
|
||||
sampled[i] = vals[ts.length - 1];
|
||||
} else {
|
||||
const span = ts[j + 1] - ts[j];
|
||||
const frac = span > 0 ? (t - ts[j]) / span : 0;
|
||||
sampled[i] = vals[j] + (vals[j + 1] - vals[j]) * frac;
|
||||
}
|
||||
}
|
||||
return { sampled, dt };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Panel-local state variables (ds === 'local').
|
||||
//
|
||||
// Definitions live in the interface XML and travel with the panel, but the live
|
||||
// value is instantiated per browser/panel instance starting from the variable's
|
||||
// initial value — nothing is persisted server-side and no WebSocket traffic is
|
||||
// involved. The panel logic (a future feature) and write-capable widgets can
|
||||
// update these values locally.
|
||||
//
|
||||
// This module deliberately depends only on the store primitives and types so it
|
||||
// can be imported by both stores.ts and ws.ts without creating an import cycle.
|
||||
|
||||
import { writable, type Readable, type Writable } from './store';
|
||||
import type { SignalValue, SignalMeta, StateVar } from './types';
|
||||
|
||||
const valueStores = new Map<string, Writable<SignalValue>>();
|
||||
const metaStores = new Map<string, Writable<SignalMeta | null>>();
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
|
||||
function valueW(name: string): Writable<SignalValue> {
|
||||
let s = valueStores.get(name);
|
||||
if (!s) {
|
||||
s = writable<SignalValue>(DEFAULT_VALUE);
|
||||
valueStores.set(name, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function metaW(name: string): Writable<SignalMeta | null> {
|
||||
let s = metaStores.get(name);
|
||||
if (!s) {
|
||||
s = writable<SignalMeta | null>(null);
|
||||
metaStores.set(name, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// coerce parses a state variable's stored string into a live value.
|
||||
function coerce(v: StateVar): any {
|
||||
switch (v.type) {
|
||||
case 'bool':
|
||||
return v.initial === 'true' || v.initial === '1';
|
||||
case 'string':
|
||||
return v.initial;
|
||||
default: {
|
||||
const n = parseFloat(v.initial);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initLocalState (re)instantiates every state variable to its initial value and
|
||||
// publishes metadata. Call this whenever a panel is loaded.
|
||||
export function initLocalState(vars: StateVar[] | undefined): void {
|
||||
for (const v of vars ?? []) {
|
||||
valueW(v.name).set({
|
||||
value: coerce(v),
|
||||
quality: 'good',
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
metaW(v.name).set({
|
||||
type: v.type ?? 'number',
|
||||
unit: v.unit,
|
||||
displayLow: v.low ?? 0,
|
||||
displayHigh: v.high ?? 100,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalValueStore(name: string): Readable<SignalValue> {
|
||||
return valueW(name);
|
||||
}
|
||||
|
||||
export function getLocalMetaStore(name: string): Readable<SignalMeta | null> {
|
||||
return metaW(name);
|
||||
}
|
||||
|
||||
// writeLocalState updates a local variable's live value in place.
|
||||
export function writeLocalState(name: string, value: any): void {
|
||||
valueW(name).set({ value, quality: 'good', ts: new Date().toISOString() });
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// Panel logic engine.
|
||||
//
|
||||
// A panel may define a LogicGraph (see types.ts): a Node-RED-style flow of
|
||||
// trigger, gate, control-flow and action nodes connected by wires. This
|
||||
// singleton engine runs the graph client-side in view mode only: Canvas calls
|
||||
// `load(iface.logic)` when a panel mounts and `clear()` when it unmounts.
|
||||
//
|
||||
// Triggers are flow entry points. When one activates, the engine follows the
|
||||
// outgoing wires executing downstream nodes:
|
||||
// gate.and — passes only when all incoming triggers are satisfied.
|
||||
// flow.if — evaluates `cond`, continues on the 'then' or 'else' port.
|
||||
// flow.loop — repeats the 'body' port (count / while, capped), then 'done'.
|
||||
// action.write— evaluates `expr` and writes the result to `target`.
|
||||
// action.delay— awaits `ms` before continuing.
|
||||
// action.accumulate / export / clear — collect expression values into a named
|
||||
// in-memory array and download it as CSV.
|
||||
// action.log — logs an expression value to the console.
|
||||
//
|
||||
// Expressions reference signals inline as {ds:name} and panel-local vars as
|
||||
// bare identifiers; their values are read from a live cache kept up to date by
|
||||
// subscriptions started at load() for every referenced signal. Two built-in
|
||||
// system signals are served synthetically (never subscribed): {sys:time} is the
|
||||
// current epoch time in seconds and {sys:dt} is the seconds elapsed since the
|
||||
// firing trigger last fired (useful in On change / Timer / Panel loop flows,
|
||||
// e.g. to compute a rate).
|
||||
|
||||
import { wsClient } from './ws';
|
||||
import { getSignalStore } from './stores';
|
||||
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
|
||||
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
|
||||
|
||||
// Guards against runaway flows (cycles / pathological loops).
|
||||
const MAX_STEPS = 100000;
|
||||
const MAX_LOOP = 100000;
|
||||
|
||||
// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':').
|
||||
function parseRef(target: string): SignalRef | null {
|
||||
const t = target.trim();
|
||||
if (!t) return null;
|
||||
const i = t.indexOf(':');
|
||||
if (i < 0) return { ds: 'local', name: t }; // bare name → panel-local var
|
||||
return { ds: t.slice(0, i), name: t.slice(i + 1) };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
const n = parseFloat(v);
|
||||
return isNaN(n) ? NaN : n;
|
||||
}
|
||||
|
||||
function refKey(ds: string, name: string): string {
|
||||
return `${ds}\0${name}`;
|
||||
}
|
||||
|
||||
interface WireOut { to: string; port: string }
|
||||
// One flow run, started by an activating trigger. `dt` is the seconds elapsed
|
||||
// since that trigger last fired (0 on its first fire); `resolve` is an
|
||||
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
|
||||
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
|
||||
|
||||
class LogicEngine {
|
||||
private graph: LogicGraph = { nodes: [], wires: [] };
|
||||
private byId = new Map<string, LogicNode>();
|
||||
// Outgoing wires grouped by source node id.
|
||||
private out = new Map<string, WireOut[]>();
|
||||
// Incoming source node ids grouped by target node id (for gate evaluation).
|
||||
private incoming = new Map<string, string[]>();
|
||||
// Live signal value cache (key "ds\0name"), kept fresh by subscriptions.
|
||||
private live = new Map<string, any>();
|
||||
// Current truth of each level-style trigger (threshold), for AND-gates.
|
||||
private levelState = new Map<string, boolean>();
|
||||
// Per-node previous values for edge/change detection.
|
||||
private prevBool = new Map<string, boolean>();
|
||||
private prevVal = new Map<string, any>();
|
||||
// signal key → trigger node ids that react to it (threshold / change).
|
||||
private watchers = new Map<string, string[]>();
|
||||
// Named in-memory data arrays, filled by action.accumulate and dumped by
|
||||
// action.export. Each sample keeps the wall-clock time it was recorded.
|
||||
private arrays = new Map<string, Array<{ t: number; v: number }>>();
|
||||
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
|
||||
private lastFire = new Map<string, number>();
|
||||
private cleanups: Array<() => void> = [];
|
||||
|
||||
// Resolve an expression reference. Two built-in system signals are served
|
||||
// synthetically (never subscribed): {sys:time} = current epoch seconds,
|
||||
// {sys:dt} = seconds since the firing trigger last fired. Everything else
|
||||
// reads from the live signal cache.
|
||||
private sysResolve(ds: string, name: string, dt: number): number {
|
||||
if (ds === 'sys') {
|
||||
if (name === 'time') return Date.now() / 1000;
|
||||
if (name === 'dt') return dt;
|
||||
return NaN;
|
||||
}
|
||||
return toNum(this.live.get(refKey(ds, name)));
|
||||
}
|
||||
|
||||
/** Activate a panel's logic graph. Replaces any previously loaded graph. */
|
||||
load(graph: LogicGraph | undefined): void {
|
||||
this.clear();
|
||||
this.graph = graph ?? { nodes: [], wires: [] };
|
||||
|
||||
this.byId = new Map(this.graph.nodes.map(n => [n.id, n]));
|
||||
this.out = new Map();
|
||||
this.incoming = new Map();
|
||||
for (const w of this.graph.wires) {
|
||||
(this.out.get(w.from) ?? this.out.set(w.from, []).get(w.from)!)
|
||||
.push({ to: w.to, port: w.fromPort ?? 'out' });
|
||||
(this.incoming.get(w.to) ?? this.incoming.set(w.to, []).get(w.to)!)
|
||||
.push(w.from);
|
||||
}
|
||||
|
||||
this.subscribeRefs();
|
||||
|
||||
for (const node of this.graph.nodes) {
|
||||
switch (node.kind) {
|
||||
case 'trigger.timer': {
|
||||
const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
|
||||
this.cleanups.push(() => clearInterval(id));
|
||||
break;
|
||||
}
|
||||
case 'trigger.loop': {
|
||||
this.activate(node.id); // run once on load
|
||||
const id = setInterval(() => this.activate(node.id), this.intervalOf(node));
|
||||
this.cleanups.push(() => clearInterval(id));
|
||||
break;
|
||||
}
|
||||
// button/threshold/change are driven imperatively / by subscriptions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
|
||||
clear(): void {
|
||||
for (const c of this.cleanups) { try { c(); } catch {} }
|
||||
this.cleanups = [];
|
||||
this.graph = { nodes: [], wires: [] };
|
||||
this.byId = new Map();
|
||||
this.out = new Map();
|
||||
this.incoming = new Map();
|
||||
this.live = new Map();
|
||||
this.levelState = new Map();
|
||||
this.prevBool = new Map();
|
||||
this.prevVal = new Map();
|
||||
this.watchers = new Map();
|
||||
this.arrays = new Map();
|
||||
this.lastFire = new Map();
|
||||
}
|
||||
|
||||
/** Fire every button-trigger node whose `name` param matches. */
|
||||
runAction(name: string): void {
|
||||
if (!name) return;
|
||||
for (const node of this.graph.nodes) {
|
||||
if (node.kind === 'trigger.button' && (node.params.name ?? '') === name) {
|
||||
this.activate(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subscriptions ──────────────────────────────────────────────────────────
|
||||
|
||||
// Subscribe to every signal referenced anywhere in the graph (explicit
|
||||
// threshold/change signals + inline {ds:name}/local refs in expressions),
|
||||
// feeding the live cache and waking threshold/change triggers.
|
||||
private subscribeRefs(): void {
|
||||
const refs = new Map<string, SignalRef>();
|
||||
// {sys:*} signals are served synthetically by sysResolve — never subscribed.
|
||||
const want = (r: SignalRef) => { if (r.ds === 'sys') return; if (r.name || r.ds === 'local') refs.set(refKey(r.ds, r.name), r); };
|
||||
|
||||
for (const node of this.graph.nodes) {
|
||||
switch (node.kind) {
|
||||
case 'trigger.threshold':
|
||||
case 'trigger.change': {
|
||||
const r = parseRef(node.params.signal ?? '');
|
||||
if (r) {
|
||||
want(r);
|
||||
const key = refKey(r.ds, r.name);
|
||||
(this.watchers.get(key) ?? this.watchers.set(key, []).get(key)!).push(node.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'flow.if': collectRefs(node.params.cond ?? '').forEach(want); break;
|
||||
case 'flow.loop': if ((node.params.mode ?? 'count') === 'while') collectRefs(node.params.cond ?? '').forEach(want); break;
|
||||
case 'action.write':
|
||||
case 'action.accumulate':
|
||||
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, ref] of refs) {
|
||||
const unsub = getSignalStore(ref).subscribe((v: SignalValue) => {
|
||||
this.live.set(key, v.value);
|
||||
this.onSignal(key, v.value);
|
||||
});
|
||||
this.cleanups.push(unsub);
|
||||
}
|
||||
}
|
||||
|
||||
// Drive threshold (rising edge) and change triggers when a watched signal updates.
|
||||
private onSignal(key: string, value: any): void {
|
||||
for (const id of this.watchers.get(key) ?? []) {
|
||||
const node = this.byId.get(id);
|
||||
if (!node) continue;
|
||||
if (node.kind === 'trigger.threshold') {
|
||||
const cur = this.testThreshold(toNum(value), node.params.op ?? '>', toNum(node.params.value ?? '0'));
|
||||
this.levelState.set(id, cur);
|
||||
if (cur && !(this.prevBool.get(id) ?? false)) this.activate(id);
|
||||
this.prevBool.set(id, cur);
|
||||
} else if (node.kind === 'trigger.change') {
|
||||
const had = this.prevVal.has(id);
|
||||
const prev = this.prevVal.get(id);
|
||||
this.prevVal.set(id, value);
|
||||
if (had && value !== prev) this.activate(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private testThreshold(val: number, op: string, cmp: number): boolean {
|
||||
if (isNaN(val)) return false;
|
||||
switch (op) {
|
||||
case '>': return val > cmp;
|
||||
case '<': return val < cmp;
|
||||
case '>=': return val >= cmp;
|
||||
case '<=': return val <= cmp;
|
||||
case '==': return val === cmp;
|
||||
case '!=': return val !== cmp;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private intervalOf(node: LogicNode): number {
|
||||
return Math.max(50, parseInt(node.params.interval ?? '1000', 10) || 1000);
|
||||
}
|
||||
|
||||
// ── Execution ───────────────────────────────────────────────────────────────
|
||||
|
||||
// A trigger activated: walk its outgoing 'out' wires.
|
||||
private activate(triggerId: string): void {
|
||||
const now = Date.now();
|
||||
const last = this.lastFire.get(triggerId);
|
||||
const dt = last === undefined ? 0 : (now - last) / 1000;
|
||||
this.lastFire.set(triggerId, now);
|
||||
const ctx: RunCtx = {
|
||||
firedTrigger: triggerId,
|
||||
steps: { n: 0 },
|
||||
dt,
|
||||
resolve: (ds, name) => this.sysResolve(ds, name, dt),
|
||||
};
|
||||
void this.follow(triggerId, 'out', ctx);
|
||||
}
|
||||
|
||||
// Run every wire leaving `fromId` on output port `port`.
|
||||
private async follow(fromId: string, port: string, ctx: RunCtx): Promise<void> {
|
||||
for (const w of this.out.get(fromId) ?? []) {
|
||||
if (w.port === port) await this.run(w.to, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute one node, then continue downstream as appropriate.
|
||||
private async run(nodeId: string, ctx: RunCtx): Promise<void> {
|
||||
if (ctx.steps.n++ > MAX_STEPS) return;
|
||||
const node = this.byId.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
switch (node.kind) {
|
||||
case 'gate.and':
|
||||
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
|
||||
case 'flow.if': {
|
||||
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
|
||||
await this.follow(node.id, branch, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'flow.loop': {
|
||||
const mode = node.params.mode ?? 'count';
|
||||
if (mode === 'count') {
|
||||
const n = Math.min(MAX_LOOP, Math.max(0, Math.floor(toNum(node.params.count ?? '0')) || 0));
|
||||
for (let i = 0; i < n && ctx.steps.n <= MAX_STEPS; i++) await this.follow(node.id, 'body', ctx);
|
||||
} else {
|
||||
let i = 0;
|
||||
while (i < MAX_LOOP && ctx.steps.n <= MAX_STEPS && evalBool(node.params.cond ?? '', ctx.resolve)) {
|
||||
await this.follow(node.id, 'body', ctx);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
await this.follow(node.id, 'done', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.write': {
|
||||
const ref = parseRef(node.params.target ?? '');
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (ref && !isNaN(val)) wsClient.write(ref, val);
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.delay':
|
||||
await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
|
||||
case 'action.accumulate': {
|
||||
const name = (node.params.array ?? '').trim();
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (name && !isNaN(val)) {
|
||||
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
|
||||
arr.push({ t: Date.now(), v: val });
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.export': {
|
||||
this.exportArray((node.params.array ?? '').trim(), node.params.filename ?? '');
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.clear': {
|
||||
const name = (node.params.array ?? '').trim();
|
||||
if (name) this.arrays.delete(name);
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.log': {
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
const label = (node.params.label ?? '').trim();
|
||||
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
// A trigger reached mid-walk (unusual): just pass through.
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Dump a named array to a CSV file the browser downloads (timestamp + value).
|
||||
private exportArray(name: string, filename: string): void {
|
||||
if (!name) return;
|
||||
const arr = this.arrays.get(name) ?? [];
|
||||
const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`);
|
||||
const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n';
|
||||
const safe = (filename || name).replace(/[^a-z0-9_.-]/gi, '_');
|
||||
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fname;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// An AND-gate fires when every incoming trigger is currently satisfied: the
|
||||
// activating trigger counts as satisfied, and level triggers (threshold) use
|
||||
// their current truth. Momentary triggers that are not firing now are false.
|
||||
private gateSatisfied(gateId: string, firedTrigger: string): boolean {
|
||||
const inputs = this.incoming.get(gateId) ?? [];
|
||||
if (inputs.length === 0) return false;
|
||||
return inputs.every(src => src === firedTrigger || this.levelState.get(src) === true);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance, mirrored on wsClient's module-level pattern.
|
||||
export const logicEngine = new LogicEngine();
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PlotLayout } from './types';
|
||||
|
||||
/** Collect all widget ids referenced by leaves, in layout order. */
|
||||
export function collectWidgetIds(layout: PlotLayout): string[] {
|
||||
if (layout.type === 'leaf') return [layout.widget];
|
||||
return [...collectWidgetIds(layout.a), ...collectWidgetIds(layout.b)];
|
||||
}
|
||||
|
||||
/** Count the number of leaves (panes). */
|
||||
export function countLeaves(layout: PlotLayout): number {
|
||||
if (layout.type === 'leaf') return 1;
|
||||
return countLeaves(layout.a) + countLeaves(layout.b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the leaf for `widgetId` with a split: the existing leaf becomes child
|
||||
* `a` and a new leaf for `newWidgetId` becomes child `b` (ratio 0.5).
|
||||
*/
|
||||
export function splitLeaf(
|
||||
layout: PlotLayout,
|
||||
widgetId: string,
|
||||
dir: 'h' | 'v',
|
||||
newWidgetId: string,
|
||||
): PlotLayout {
|
||||
if (layout.type === 'leaf') {
|
||||
if (layout.widget !== widgetId) return layout;
|
||||
return {
|
||||
type: 'split',
|
||||
dir,
|
||||
ratio: 0.5,
|
||||
a: { type: 'leaf', widget: widgetId },
|
||||
b: { type: 'leaf', widget: newWidgetId },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...layout,
|
||||
a: splitLeaf(layout.a, widgetId, dir, newWidgetId),
|
||||
b: splitLeaf(layout.b, widgetId, dir, newWidgetId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the leaf for `widgetId`, collapsing its parent split into the sibling
|
||||
* subtree. Returns the original layout if the leaf is the sole remaining pane.
|
||||
*/
|
||||
export function removeLeaf(layout: PlotLayout, widgetId: string): PlotLayout {
|
||||
if (layout.type === 'leaf') return layout; // can't remove the root leaf
|
||||
// If a direct child is the target leaf, collapse to the sibling.
|
||||
if (layout.a.type === 'leaf' && layout.a.widget === widgetId) return layout.b;
|
||||
if (layout.b.type === 'leaf' && layout.b.widget === widgetId) return layout.a;
|
||||
return {
|
||||
...layout,
|
||||
a: removeLeaf(layout.a, widgetId),
|
||||
b: removeLeaf(layout.b, widgetId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ratio of the split node addressed by `path` (a list of 0/1 = a/b
|
||||
* choices from the root).
|
||||
*/
|
||||
export function setRatioAtPath(
|
||||
layout: PlotLayout,
|
||||
path: number[],
|
||||
ratio: number,
|
||||
): PlotLayout {
|
||||
if (layout.type === 'leaf') return layout;
|
||||
if (path.length === 0) return { ...layout, ratio };
|
||||
const [head, ...rest] = path;
|
||||
if (head === 0) return { ...layout, a: setRatioAtPath(layout.a, rest, ratio) };
|
||||
return { ...layout, b: setRatioAtPath(layout.b, rest, ratio) };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readable, writable, type Readable } from './store';
|
||||
import { wsClient } from './ws';
|
||||
import { getLocalValueStore, getLocalMetaStore } from './localstate';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
// ── Default values ────────────────────────────────────────────────────────────
|
||||
@@ -52,6 +53,9 @@ function refKey(ref: SignalRef): string {
|
||||
* The store stays alive once created (simplest correct behaviour for Phase 4).
|
||||
*/
|
||||
export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
|
||||
// Panel-local variables are served entirely client-side (no WS subscription).
|
||||
if (ref.ds === 'local') return getLocalValueStore(ref.name);
|
||||
|
||||
const key = refKey(ref);
|
||||
const existing = valueStores.get(key);
|
||||
if (existing) return existing;
|
||||
@@ -72,6 +76,8 @@ export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
|
||||
* Metadata is sent once by the server on subscribe.
|
||||
*/
|
||||
export function getMetaStore(ref: SignalRef): Readable<SignalMeta | null> {
|
||||
if (ref.ds === 'local') return getLocalMetaStore(ref.name);
|
||||
|
||||
const key = refKey(ref);
|
||||
const existing = metaStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Interface } from './types';
|
||||
|
||||
/**
|
||||
* Build a blank "plot panel": a special interface whose viewport is filled by
|
||||
* plots arranged in a recursive split layout. It starts with a single
|
||||
* full-viewport plot; the user splits panes and drops signals in the editor.
|
||||
*/
|
||||
export function newPlotPanel(): Interface {
|
||||
const wid = `w${Date.now()}`;
|
||||
return {
|
||||
id: '',
|
||||
name: 'New Plot Panel',
|
||||
version: 1,
|
||||
w: 1200,
|
||||
h: 800,
|
||||
kind: 'plot',
|
||||
layout: { type: 'leaf', widget: wid },
|
||||
widgets: [
|
||||
{
|
||||
id: wid,
|
||||
type: 'plot',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 800,
|
||||
h: 500,
|
||||
signals: [],
|
||||
options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,107 @@ export type GroupNode =
|
||||
| { kind: 'folder'; id: string; label: string; children: GroupNode[] }
|
||||
| { kind: 'signal'; ds: string; name: string };
|
||||
|
||||
// Recursive split-layout tree for plot panels. Leaves reference a plot widget
|
||||
// by id; splits divide their container into child `a` (fraction `ratio`) and
|
||||
// child `b` (the remainder). dir 'h' = left/right (vertical divider), 'v' =
|
||||
// top/bottom (horizontal divider).
|
||||
export type PlotLayout =
|
||||
| { type: 'leaf'; widget: string }
|
||||
| { type: 'split'; dir: 'h' | 'v'; ratio: number; a: PlotLayout; b: PlotLayout };
|
||||
|
||||
// A panel-local state variable. Its definition travels with the interface XML,
|
||||
// but its live value is instantiated per browser/panel instance starting from
|
||||
// `initial` (the value is NOT persisted server-side). Referenced as a signal
|
||||
// via ds 'local'.
|
||||
export interface StateVar {
|
||||
name: string;
|
||||
type?: 'number' | 'bool' | 'string'; // default 'number'
|
||||
initial: string; // initial value, stored as a string
|
||||
unit?: string;
|
||||
low?: number;
|
||||
high?: number;
|
||||
}
|
||||
|
||||
// ── Panel logic (Node-RED–style flow graph) ──────────────────────────────────
|
||||
|
||||
// The kind of a logic node.
|
||||
//
|
||||
// Triggers are flow entry points:
|
||||
// trigger.button — fired when a button widget's Action names this node
|
||||
// (params: name).
|
||||
// trigger.threshold — fired on the rising edge of (signal `op` value)
|
||||
// (params: signal, op, value).
|
||||
// trigger.timer — fired every `interval` ms while the panel is in view
|
||||
// (params: interval).
|
||||
// trigger.loop — fired once on panel load, then every `interval` ms
|
||||
// (params: interval).
|
||||
// trigger.change — fired whenever a signal's value changes
|
||||
// (params: signal).
|
||||
//
|
||||
// Gates combine/guard flow:
|
||||
// gate.and — passes to its output only when ALL incoming triggers
|
||||
// are currently satisfied (no params).
|
||||
//
|
||||
// Control flow (use labelled output ports on the wires):
|
||||
// flow.if — evaluate `cond`; continue on the 'then' or 'else' port
|
||||
// (params: cond).
|
||||
// flow.loop — repeat the 'body' port then continue on 'done'
|
||||
// (params: mode 'count'|'while', count, cond).
|
||||
//
|
||||
// Actions:
|
||||
// action.write — evaluate `expr` and write the result to `target`
|
||||
// ("ds:name" or a bare local var) (params: target, expr).
|
||||
// action.delay — pause `ms` before continuing downstream (params: ms).
|
||||
// action.accumulate — evaluate `expr` and append the value (with a timestamp)
|
||||
// to the named in-memory data array (params: array, expr).
|
||||
// action.export — download the named array as a CSV file (params: array,
|
||||
// filename).
|
||||
// action.clear — empty the named array (params: array).
|
||||
// action.log — evaluate `expr` and log it to the browser console for
|
||||
// debugging a flow (params: expr, label).
|
||||
//
|
||||
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
|
||||
// local state variables as bare identifiers. See lib/expr.ts.
|
||||
export type LogicNodeKind =
|
||||
| 'trigger.button'
|
||||
| 'trigger.threshold'
|
||||
| 'trigger.timer'
|
||||
| 'trigger.loop'
|
||||
| 'trigger.change'
|
||||
| 'gate.and'
|
||||
| 'flow.if'
|
||||
| 'flow.loop'
|
||||
| 'action.write'
|
||||
| 'action.delay'
|
||||
| 'action.accumulate'
|
||||
| 'action.export'
|
||||
| 'action.clear'
|
||||
| 'action.log';
|
||||
|
||||
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
||||
export interface LogicNode {
|
||||
id: string;
|
||||
kind: LogicNodeKind;
|
||||
x: number;
|
||||
y: number;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
// A directed connection from one node's output to another node's input.
|
||||
// `fromPort` selects which labelled output it leaves (default 'out'); flow.if
|
||||
// uses 'then'/'else' and flow.loop uses 'body'/'done'.
|
||||
export interface LogicWire {
|
||||
from: string; // source node id
|
||||
fromPort?: string; // source output port (default 'out')
|
||||
to: string; // target node id
|
||||
}
|
||||
|
||||
// The panel's complete logic flow.
|
||||
export interface LogicGraph {
|
||||
nodes: LogicNode[];
|
||||
wires: LogicWire[];
|
||||
}
|
||||
|
||||
// A complete HMI interface definition
|
||||
export interface Interface {
|
||||
id: string;
|
||||
@@ -56,6 +157,74 @@ export interface Interface {
|
||||
w: number;
|
||||
h: number;
|
||||
widgets: Widget[];
|
||||
// 'panel' (default) = free-form canvas; 'plot' = split-layout plot panel.
|
||||
kind?: 'panel' | 'plot';
|
||||
// Present only when kind === 'plot': the viewport split tree.
|
||||
layout?: PlotLayout;
|
||||
// Panel-local state variable definitions (live value instantiated client-side).
|
||||
statevars?: StateVar[];
|
||||
// Panel logic flow graph (run client-side in view mode).
|
||||
logic?: LogicGraph;
|
||||
}
|
||||
|
||||
// ── Access control ───────────────────────────────────────────────────────────
|
||||
|
||||
// Global access level for the current user, as reported by /api/v1/me.
|
||||
// 'write' = full access (the default for any non-blacklisted user)
|
||||
// 'readonly' = may view but not create/modify/delete panels or write signals
|
||||
// 'none' = no access
|
||||
export type AccessLevel = 'none' | 'readonly' | 'write';
|
||||
|
||||
// Identity + access info for the calling user (from /api/v1/me).
|
||||
export interface Me {
|
||||
user: string;
|
||||
level: AccessLevel;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
// Effective permission a user holds on a single panel or folder.
|
||||
// 'none' = invisible
|
||||
// 'read' = may view
|
||||
// 'write' = may view and modify
|
||||
export type Perm = 'none' | 'read' | 'write';
|
||||
|
||||
// A share grant: gives a single user or a named user-group read/write access to
|
||||
// a panel or folder.
|
||||
export interface Grant {
|
||||
kind: 'user' | 'group';
|
||||
name: string;
|
||||
perm: 'read' | 'write';
|
||||
}
|
||||
|
||||
// Sharing record for a panel (from GET /api/v1/interfaces/{id}/acl).
|
||||
export interface PanelACL {
|
||||
owner: string;
|
||||
folder: string;
|
||||
public: '' | 'read' | 'write';
|
||||
grants: Grant[];
|
||||
perm: Perm; // the caller's own effective permission
|
||||
}
|
||||
|
||||
// A folder in the panel-organisation hierarchy (from GET /api/v1/folders).
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent: string;
|
||||
owner: string;
|
||||
public?: '' | 'read' | 'write';
|
||||
grants?: Grant[];
|
||||
perm: Perm; // the caller's own effective permission
|
||||
}
|
||||
|
||||
// An entry in the interface list (GET /api/v1/interfaces), enriched with the
|
||||
// owner, containing folder, and the caller's permission.
|
||||
export interface InterfaceListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
owner?: string;
|
||||
folder?: string;
|
||||
perm: Perm;
|
||||
}
|
||||
|
||||
// ── Synthetic signals ────────────────────────────────────────────────────────
|
||||
@@ -83,4 +252,9 @@ export interface SignalDef {
|
||||
displayHigh?: number;
|
||||
writable?: boolean;
|
||||
};
|
||||
// Visibility scope (default 'panel'). 'owner'/'panel' are stamped/filtered
|
||||
// server-side; owner is set from the trusted identity on create.
|
||||
visibility?: 'panel' | 'user' | 'global';
|
||||
owner?: string;
|
||||
panel?: string; // bound interface id when visibility === 'panel'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable, type Readable } from './store';
|
||||
import { writeLocalState } from './localstate';
|
||||
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -259,6 +260,11 @@ class WsClient {
|
||||
}
|
||||
|
||||
write(ref: SignalRef, value: any): void {
|
||||
// Panel-local variables never go to the server — update them in place.
|
||||
if (ref.ds === 'local') {
|
||||
writeLocalState(ref.name, value);
|
||||
return;
|
||||
}
|
||||
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
|
||||
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
||||
}
|
||||
|
||||
+144
-3
@@ -1,4 +1,4 @@
|
||||
import type { Interface, Widget, SignalRef } from './types';
|
||||
import type { Interface, Widget, SignalRef, PlotLayout, StateVar, LogicGraph } from './types';
|
||||
|
||||
/** Escape a string for use as an XML attribute value. */
|
||||
function xmlEsc(s: string): string {
|
||||
@@ -9,14 +9,67 @@ function xmlEsc(s: string): string {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** Serialize a panel-logic flow graph to indented XML lines. */
|
||||
function serializeLogic(graph: LogicGraph, lines: string[]): void {
|
||||
lines.push(` <logic>`);
|
||||
for (const node of graph.nodes) {
|
||||
lines.push(
|
||||
` <node id="${xmlEsc(node.id)}" kind="${xmlEsc(node.kind)}" ` +
|
||||
`x="${node.x}" y="${node.y}">`
|
||||
);
|
||||
for (const [key, value] of Object.entries(node.params)) {
|
||||
lines.push(` <param key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
|
||||
}
|
||||
lines.push(` </node>`);
|
||||
}
|
||||
for (const wire of graph.wires) {
|
||||
const portAttr = wire.fromPort && wire.fromPort !== 'out'
|
||||
? ` port="${xmlEsc(wire.fromPort)}"` : '';
|
||||
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
|
||||
}
|
||||
lines.push(` </logic>`);
|
||||
}
|
||||
|
||||
/** Serialize a PlotLayout subtree to indented XML lines. */
|
||||
function serializeLayout(node: PlotLayout, indent: string, lines: string[]): void {
|
||||
if (node.type === 'leaf') {
|
||||
lines.push(`${indent}<leaf widget="${xmlEsc(node.widget)}"/>`);
|
||||
return;
|
||||
}
|
||||
lines.push(`${indent}<split dir="${node.dir}" ratio="${node.ratio}">`);
|
||||
serializeLayout(node.a, indent + ' ', lines);
|
||||
serializeLayout(node.b, indent + ' ', lines);
|
||||
lines.push(`${indent}</split>`);
|
||||
}
|
||||
|
||||
/** Serialize an Interface object to an XML string. */
|
||||
export function serializeInterface(iface: Interface): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||||
const kindAttr = iface.kind === 'plot' ? ` kind="plot"` : '';
|
||||
lines.push(
|
||||
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
|
||||
`version="${iface.version}" width="${iface.w}" height="${iface.h}">`
|
||||
`version="${iface.version}" width="${iface.w}" height="${iface.h}"${kindAttr}>`
|
||||
);
|
||||
if (iface.kind === 'plot' && iface.layout) {
|
||||
lines.push(` <layout>`);
|
||||
serializeLayout(iface.layout, ' ', lines);
|
||||
lines.push(` </layout>`);
|
||||
}
|
||||
for (const sv of iface.statevars ?? []) {
|
||||
const attrs = [
|
||||
`name="${xmlEsc(sv.name)}"`,
|
||||
`type="${xmlEsc(sv.type ?? 'number')}"`,
|
||||
`initial="${xmlEsc(sv.initial ?? '')}"`,
|
||||
];
|
||||
if (sv.unit) attrs.push(`unit="${xmlEsc(sv.unit)}"`);
|
||||
if (sv.low !== undefined) attrs.push(`low="${sv.low}"`);
|
||||
if (sv.high !== undefined) attrs.push(`high="${sv.high}"`);
|
||||
lines.push(` <statevar ${attrs.join(' ')}/>`);
|
||||
}
|
||||
if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) {
|
||||
serializeLogic(iface.logic, lines);
|
||||
}
|
||||
for (const w of iface.widgets) {
|
||||
lines.push(
|
||||
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
|
||||
@@ -46,6 +99,56 @@ export function serializeInterface(iface: Interface): string {
|
||||
* </widget>
|
||||
* </interface>
|
||||
*/
|
||||
/** Parse a <split>/<leaf> element into a PlotLayout node. */
|
||||
function parseLayout(el: Element): PlotLayout {
|
||||
if (el.tagName === 'leaf') {
|
||||
return { type: 'leaf', widget: el.getAttribute('widget') ?? '' };
|
||||
}
|
||||
// split
|
||||
const dir = el.getAttribute('dir') === 'v' ? 'v' : 'h';
|
||||
const ratio = parseFloat(el.getAttribute('ratio') ?? '0.5');
|
||||
const children = Array.from(el.children).filter(
|
||||
c => c.tagName === 'split' || c.tagName === 'leaf'
|
||||
);
|
||||
const a = children[0] ? parseLayout(children[0]) : { type: 'leaf', widget: '' } as PlotLayout;
|
||||
const b = children[1] ? parseLayout(children[1]) : { type: 'leaf', widget: '' } as PlotLayout;
|
||||
return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b };
|
||||
}
|
||||
|
||||
/** Parse a <logic> element into a LogicGraph (nodes + wires). */
|
||||
function parseLogic(logicEl: Element): LogicGraph {
|
||||
const nodes: LogicGraph['nodes'] = [];
|
||||
const wires: LogicGraph['wires'] = [];
|
||||
|
||||
for (const el of Array.from(logicEl.children)) {
|
||||
if (el.tagName === 'node') {
|
||||
const id = el.getAttribute('id') ?? '';
|
||||
if (!id) continue;
|
||||
const params: Record<string, string> = {};
|
||||
for (const child of Array.from(el.children)) {
|
||||
if (child.tagName !== 'param') continue;
|
||||
const key = child.getAttribute('key');
|
||||
const value = child.getAttribute('value');
|
||||
if (key !== null && value !== null) params[key] = value;
|
||||
}
|
||||
nodes.push({
|
||||
id,
|
||||
kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'],
|
||||
x: parseFloat(el.getAttribute('x') ?? '0'),
|
||||
y: parseFloat(el.getAttribute('y') ?? '0'),
|
||||
params,
|
||||
});
|
||||
} else if (el.tagName === 'wire') {
|
||||
const from = el.getAttribute('from');
|
||||
const to = el.getAttribute('to');
|
||||
const port = el.getAttribute('port');
|
||||
if (from && to) wires.push({ from, to, ...(port ? { fromPort: port } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, wires };
|
||||
}
|
||||
|
||||
export function parseInterface(xml: string): Interface {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'application/xml');
|
||||
@@ -66,6 +169,38 @@ export function parseInterface(xml: string): Interface {
|
||||
const version = parseInt(root.getAttribute('version') ?? '1', 10);
|
||||
const w = parseInt(root.getAttribute('width') ?? '1200', 10);
|
||||
const h = parseInt(root.getAttribute('height') ?? '800', 10);
|
||||
const kind = root.getAttribute('kind') === 'plot' ? 'plot' : undefined;
|
||||
|
||||
let layout: PlotLayout | undefined;
|
||||
if (kind === 'plot') {
|
||||
const layoutEl = Array.from(root.children).find(el => el.tagName === 'layout');
|
||||
const rootNode = layoutEl
|
||||
? Array.from(layoutEl.children).find(el => el.tagName === 'split' || el.tagName === 'leaf')
|
||||
: undefined;
|
||||
if (rootNode) layout = parseLayout(rootNode);
|
||||
}
|
||||
|
||||
const statevars: StateVar[] = [];
|
||||
for (const el of Array.from(root.children)) {
|
||||
if (el.tagName !== 'statevar') continue;
|
||||
const name = el.getAttribute('name') ?? '';
|
||||
if (!name) continue;
|
||||
const type = el.getAttribute('type');
|
||||
const low = el.getAttribute('low');
|
||||
const high = el.getAttribute('high');
|
||||
const unit = el.getAttribute('unit');
|
||||
statevars.push({
|
||||
name,
|
||||
type: type === 'bool' || type === 'string' ? type : 'number',
|
||||
initial: el.getAttribute('initial') ?? '',
|
||||
...(unit ? { unit } : {}),
|
||||
...(low !== null ? { low: parseFloat(low) } : {}),
|
||||
...(high !== null ? { high: parseFloat(high) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const logicEl = Array.from(root.children).find(el => el.tagName === 'logic');
|
||||
const logic = logicEl ? parseLogic(logicEl) : null;
|
||||
|
||||
const widgets: Widget[] = [];
|
||||
|
||||
@@ -100,5 +235,11 @@ export function parseInterface(xml: string): Interface {
|
||||
widgets.push({ id, type, x, y, w, h, signals, options });
|
||||
}
|
||||
|
||||
return { id: ifaceId, name, version, w, h, widgets };
|
||||
return {
|
||||
id: ifaceId, name, version, w, h, widgets,
|
||||
...(kind ? { kind } : {}),
|
||||
...(layout ? { layout } : {}),
|
||||
...(statevars.length > 0 ? { statevars } : {}),
|
||||
...(logic && (logic.nodes.length > 0 || logic.wires.length > 0) ? { logic } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
+677
-249
@@ -164,6 +164,42 @@ body {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
/* ── User identity chip ──────────────────────────────────────────────────── */
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
border: 1px solid #334155;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Access denied screen ────────────────────────────────────────────────── */
|
||||
.access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
color: #e2e8f0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.access-denied h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.access-denied p {
|
||||
max-width: 32rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -257,8 +293,17 @@ body {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
@@ -834,6 +879,15 @@ body {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
/* Read-only: user lacks permission to write this signal (.sv-select shares
|
||||
.sv-input). */
|
||||
.sv-input:disabled,
|
||||
.sv-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sv-btn:disabled:hover { background: #2563eb; }
|
||||
|
||||
/* ── Button widget ─────────────────────────────────────────────────────── */
|
||||
|
||||
.button-widget {
|
||||
@@ -868,6 +922,19 @@ body {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Read-only: user lacks permission to write this signal. */
|
||||
.button-widget .btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.button-widget .btn:disabled:hover {
|
||||
background: #1e3a5f;
|
||||
border-color: #2d5a9e;
|
||||
}
|
||||
.button-widget .btn:disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── ImageWidget ───────────────────────────────────────────────────────── */
|
||||
|
||||
.image-widget {
|
||||
@@ -1001,6 +1068,7 @@ body {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover { background: #374151; }
|
||||
@@ -1042,6 +1110,384 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Center column tabs (Layout / Logic) ────────────────────────────────── */
|
||||
|
||||
.edit-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.center-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #0f1117;
|
||||
border-bottom: 1px solid #1e2433;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.center-tab {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid #1e2433;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center-tab:hover { color: #e2e8f0; background: #161b27; }
|
||||
|
||||
.center-tab-active {
|
||||
color: #e2e8f0;
|
||||
border-bottom-color: #3b82f6;
|
||||
background: #161b27;
|
||||
}
|
||||
|
||||
/* ── Logic flow editor (Node-RED style) ─────────────────────────────────── */
|
||||
|
||||
.flow-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Palette of node types to add */
|
||||
.flow-palette {
|
||||
width: 10rem;
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid #1e2433;
|
||||
background: #0f1117;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.flow-palette-toolbar {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.flow-palette-toolbar .toolbar-btn { flex: 1; }
|
||||
|
||||
.flow-palette-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.flow-palette-btn {
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: #e2e8f0;
|
||||
background: #161b27;
|
||||
border: 1px solid #1e2433;
|
||||
border-left-width: 3px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flow-palette-btn:hover { background: #1c2230; }
|
||||
.flow-palette-trigger { border-left-color: #f59e0b; }
|
||||
.flow-palette-gate { border-left-color: #a855f7; }
|
||||
.flow-palette-flow { border-left-color: #10b981; }
|
||||
.flow-palette-action { border-left-color: #3b82f6; }
|
||||
.flow-palette-hint { margin-top: 0.5rem; font-size: 0.7rem; line-height: 1.4; }
|
||||
.flow-palette-hint code { background: #1e2433; padding: 0 0.2em; border-radius: 3px; }
|
||||
|
||||
/* Scrollable node canvas */
|
||||
.flow-canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0 19px, #161b2733 19px 20px),
|
||||
repeating-linear-gradient(90deg, transparent 0 19px, #161b2733 19px 20px),
|
||||
#0b0e14;
|
||||
}
|
||||
|
||||
.flow-canvas-inner {
|
||||
position: relative;
|
||||
width: 125rem;
|
||||
height: 87.5rem;
|
||||
}
|
||||
|
||||
.flow-wires {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.flow-wire {
|
||||
fill: none;
|
||||
stroke: #475569;
|
||||
stroke-width: 2;
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flow-wire:hover { stroke: #64748b; }
|
||||
.flow-wire-selected { stroke: #ef4444; stroke-width: 2.5; }
|
||||
.flow-wire-pending { stroke: #3b82f6; stroke-dasharray: 5 4; }
|
||||
|
||||
/* Node block */
|
||||
.flow-node {
|
||||
position: absolute;
|
||||
background: #161b27;
|
||||
border: 1px solid #1e2433;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 6px #0006;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.flow-node-trigger { border-top: 3px solid #f59e0b; }
|
||||
.flow-node-gate { border-top: 3px solid #a855f7; }
|
||||
.flow-node-flow { border-top: 3px solid #10b981; }
|
||||
.flow-node-action { border-top: 3px solid #3b82f6; }
|
||||
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
|
||||
|
||||
.flow-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
.flow-node-title { font-size: 0.78rem; font-weight: 600; color: #e2e8f0; }
|
||||
.flow-node-del {
|
||||
background: none; border: none; color: #64748b;
|
||||
cursor: pointer; font-size: 0.75rem; line-height: 1; padding: 0;
|
||||
}
|
||||
.flow-node-del:hover { color: #ef4444; }
|
||||
|
||||
.flow-node-body {
|
||||
padding: 0 0.5rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ports — input/output positions are set inline (in rem-scaled px) so they
|
||||
stay aligned with the node geometry on any root font size. */
|
||||
.flow-port {
|
||||
position: absolute;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
background: #0b0e14;
|
||||
border: 2px solid #64748b;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.flow-port:hover { border-color: #3b82f6; background: #3b82f6; }
|
||||
.flow-port-then { border-color: #10b981; }
|
||||
.flow-port-body { border-color: #10b981; }
|
||||
.flow-port-else { border-color: #ef4444; }
|
||||
|
||||
.flow-port-label {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
font-size: 0.6rem;
|
||||
color: #64748b;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Expression field: signal-insert helper row */
|
||||
.flow-expr-insert {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.flow-expr-insert > * { flex: 1; min-width: 0; }
|
||||
.prop-input-error { border-color: #ef4444; }
|
||||
|
||||
/* Inspector */
|
||||
.flow-inspector {
|
||||
width: 15rem;
|
||||
flex: 0 0 auto;
|
||||
border-left: 1px solid #1e2433;
|
||||
background: #0f1117;
|
||||
padding: 0.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Local-variable editor in the palette */
|
||||
.flow-localvars {
|
||||
margin-top: 0.5rem;
|
||||
border-top: 1px solid #1e2433;
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.flow-localvars-empty { padding: 0; }
|
||||
.flow-localvar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
background: #161b27;
|
||||
border: 1px solid #1e2433;
|
||||
border-left: 3px solid #14b8a6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.flow-localvar-name {
|
||||
font-size: 0.78rem;
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow-localvar-add { text-align: center; border-left-color: #14b8a6; }
|
||||
.flow-localvar-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.flow-localvar-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.flow-localvar-actions .panel-btn { flex: 1; }
|
||||
|
||||
/* ── History pane ───────────────────────────────────────────────────────── */
|
||||
|
||||
.history-pane {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #11151f;
|
||||
border-left: 1px solid #1f2733;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #1f2733;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.history-save-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #1f2733;
|
||||
}
|
||||
|
||||
.history-tag-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: #0a0d14;
|
||||
border: 1px solid #2a3543;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
padding: 16px 12px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #1f2733;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
background: #0e1219;
|
||||
}
|
||||
|
||||
.history-item-current {
|
||||
border-color: #2563eb;
|
||||
background: #0f1830;
|
||||
}
|
||||
|
||||
.history-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.history-version {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.history-current-badge {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.history-item-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.history-action-btn {
|
||||
flex: 1 1 auto;
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
background: #1e293b;
|
||||
border: 1px solid #2a3543;
|
||||
border-radius: 4px;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-action-btn:hover {
|
||||
background: #2a3543;
|
||||
}
|
||||
|
||||
/* ── Edit Canvas ────────────────────────────────────────────────────────── */
|
||||
|
||||
.edit-canvas-container {
|
||||
@@ -1792,6 +2238,47 @@ body {
|
||||
.iface-delete { color: #ef4444 !important; }
|
||||
.iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; }
|
||||
|
||||
/* ── Panel sharing / folders (access control) ───────────────────────────── */
|
||||
|
||||
.iface-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 0 4px;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.3;
|
||||
color: #94a3b8;
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.iface-folder { list-style: none; }
|
||||
|
||||
.iface-folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.iface-folder-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.iface-folder:hover .iface-item-actions { display: flex; }
|
||||
|
||||
.iface-sublist {
|
||||
margin-left: 0.75rem;
|
||||
border-left: 1px solid #2d3748;
|
||||
padding-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Phase 7 additions ───────────────────────────────────────────────────── */
|
||||
|
||||
/* Rubber-band selection rectangle */
|
||||
@@ -2395,39 +2882,6 @@ kbd {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 0.5rem;
|
||||
height: 2.25rem;
|
||||
background: #0f1117;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-tab:hover {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.view-tab-active {
|
||||
color: #e2e8f0;
|
||||
border-bottom-color: #4a9eff;
|
||||
}
|
||||
|
||||
/* ── InfoPanel ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.info-panel {
|
||||
@@ -2527,220 +2981,6 @@ kbd {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── PlotPanel ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.plot-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #0f1117;
|
||||
}
|
||||
|
||||
.plot-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 2.5rem;
|
||||
background: #0f1117;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plot-panel-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plot-window-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.plot-panel-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plot-empty-hint {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #475569;
|
||||
font-size: 0.88rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.plot-panel-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plot-panel-legend {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #1e293b;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.plot-legend-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.plot-legend-item:hover { background: #1a1f2e; }
|
||||
|
||||
.plot-legend-hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.plot-legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plot-legend-name {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plot-legend-icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.15rem;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.plot-legend-icon-btn:hover { opacity: 1; color: #94a3b8; }
|
||||
.plot-legend-icon-btn.active { opacity: 1; color: #4a9eff; }
|
||||
|
||||
/* kept for back-compat — remove button is now .plot-legend-icon-btn */
|
||||
.plot-legend-rm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.15rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.plot-legend-rm:hover { opacity: 1; color: #f87171; }
|
||||
|
||||
/* ── Per-signal style editor ────────────────────────────────────────────── */
|
||||
|
||||
.plot-style-editor {
|
||||
margin-top: 0.4rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid #1e293b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.plot-style-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.plot-style-label {
|
||||
font-size: 0.68rem;
|
||||
color: #64748b;
|
||||
width: 2.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plot-style-color {
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.plot-style-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.plot-style-btn {
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3748;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
min-width: 1.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
.plot-style-btn:hover { color: #cbd5e1; border-color: #475569; }
|
||||
.plot-style-btn.active { background: #1e3a5f; border-color: #4a9eff; color: #e2e8f0; }
|
||||
|
||||
.plot-stats-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.plot-stats-tbl td {
|
||||
padding: 0.05rem 0;
|
||||
color: #475569;
|
||||
}
|
||||
.plot-stats-tbl td:first-child {
|
||||
width: 44px;
|
||||
color: #334155;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.plot-stats-tbl td:last-child {
|
||||
color: #94a3b8;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.plot-panel-chart {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* uPlot inside plot-panel needs full dimensions */
|
||||
.plot-panel-chart .uplot,
|
||||
.plot-panel-chart .u-wrap {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Zoom control ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.zoom-control {
|
||||
@@ -2800,6 +3040,15 @@ kbd {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Muted placeholder text inside the trigger. Unlike .hint it adds no padding,
|
||||
so an empty select is exactly as tall as one showing a selected value. */
|
||||
.search-select-placeholder {
|
||||
color: #475569;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-select-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -2893,11 +3142,190 @@ kbd {
|
||||
}
|
||||
|
||||
.panel-btn.icon-only {
|
||||
padding: 0;
|
||||
width: 1.6rem;
|
||||
padding: 0 0.35rem;
|
||||
min-width: 1.6rem;
|
||||
width: auto;
|
||||
height: 1.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-panel-container {
|
||||
display: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view-panel-container.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Plot panels (split layout) ─────────────────────────────────────────── */
|
||||
|
||||
/* Edit surface: fills the editor body flex slot. */
|
||||
.plot-panel-edit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: #0a0d14;
|
||||
}
|
||||
|
||||
/* View/fullscreen surface: fills the (relative) canvas container. */
|
||||
.plot-panel-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.split-root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.split-row { display: flex; flex-direction: row; flex: 1; min-width: 0; min-height: 0; }
|
||||
.split-col { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }
|
||||
|
||||
.split-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-leaf {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.split-divider {
|
||||
flex: 0 0 auto;
|
||||
background: #1e293b;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.split-divider-h { width: 6px; cursor: col-resize; }
|
||||
.split-divider-v { height: 6px; cursor: row-resize; }
|
||||
.split-divider:hover { background: #4a9eff; }
|
||||
.split-divider-static { pointer-events: none; }
|
||||
|
||||
/* Neutralize the free-form absolute positioning of plot widgets so they fill
|
||||
their split pane responsively (the chart uses a ResizeObserver). */
|
||||
.split-leaf .plot-widget {
|
||||
position: relative !important;
|
||||
left: auto !important;
|
||||
top: auto !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
.split-leaf .chart-area {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Editable pane chrome. */
|
||||
.plot-pane {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #0f1117;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
/* Dim panes that are not the one currently being edited. */
|
||||
.plot-panel-edit .plot-pane:not(.plot-pane-selected)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 14, 22, 0.45);
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plot-pane-selected {
|
||||
outline: 3px solid #4a9eff;
|
||||
outline-offset: -3px;
|
||||
box-shadow: inset 0 0 0 1px rgba(74, 158, 255, 0.6),
|
||||
inset 0 0 18px rgba(74, 158, 255, 0.35);
|
||||
}
|
||||
|
||||
.plot-pane-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 6;
|
||||
padding: 1px 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #0b1018;
|
||||
background: #4a9eff;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.plot-pane-empty,
|
||||
.plot-pane-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #475569;
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.plot-pane-hint { top: auto; bottom: 0.6rem; transform: translateX(-50%); }
|
||||
|
||||
.plot-pane-overlay {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.plot-pane:hover .plot-pane-overlay,
|
||||
.plot-pane-selected .plot-pane-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plot-pane-btn {
|
||||
background: #1e293b;
|
||||
border: 1px solid #2d3748;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; }
|
||||
.plot-pane-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
+38
-11
@@ -1,8 +1,10 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget } from '../lib/types';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { logicEngine } from '../lib/logic';
|
||||
import { useAuth, canWrite } from '../lib/auth';
|
||||
import type { Widget, SignalMeta } from '../lib/types';
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
@@ -15,8 +17,12 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
const mode = widget.options['mode'] ?? 'oneshot';
|
||||
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
|
||||
const confirmOpt = widget.options['confirm'] === 'true';
|
||||
// Optional panel-logic sequence fired on click (in addition to any signal write).
|
||||
const action = widget.options['action'] ?? '';
|
||||
|
||||
const me = useAuth();
|
||||
const [signalVal, setSignalVal] = useState<any>(null);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
|
||||
// Subscribe to signal only for persistent mode (to track whether it is active)
|
||||
useEffect(() => {
|
||||
@@ -24,6 +30,20 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
|
||||
}, [sigRef?.ds, sigRef?.name, mode]);
|
||||
|
||||
// Track the target signal's writability so the button can be disabled when
|
||||
// the user has no permission to write it (real signals only; local panel
|
||||
// vars are written client-side and have no backend metadata).
|
||||
useEffect(() => {
|
||||
if (!sigRef || sigRef.ds === 'local') return;
|
||||
return getMetaStore(sigRef).subscribe(setMeta);
|
||||
}, [sigRef?.ds, sigRef?.name]);
|
||||
|
||||
// A button that drives a real signal is disabled when the user cannot write
|
||||
// it (read-only access, or the signal reports it is not writable). Buttons
|
||||
// that only run a logic action — or target a local var — stay enabled.
|
||||
const drivesRealSignal = !!sigRef && sigRef.ds !== 'local';
|
||||
const writeAllowed = !drivesRealSignal || (canWrite(me.level) && meta?.writable !== false);
|
||||
|
||||
const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
|
||||
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
|
||||
|
||||
@@ -38,18 +58,23 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
}
|
||||
|
||||
function execute() {
|
||||
if (mode === 'setReset') {
|
||||
doWrite(parsedSend);
|
||||
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
|
||||
} else if (mode === 'persistent') {
|
||||
doWrite(isPressed ? parsedReset : parsedSend);
|
||||
} else {
|
||||
doWrite(parsedSend);
|
||||
if (sigRef) {
|
||||
if (mode === 'setReset') {
|
||||
doWrite(parsedSend);
|
||||
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
|
||||
} else if (mode === 'persistent') {
|
||||
doWrite(isPressed ? parsedReset : parsedSend);
|
||||
} else {
|
||||
doWrite(parsedSend);
|
||||
}
|
||||
}
|
||||
if (action) logicEngine.runAction(action);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!sigRef) return;
|
||||
// A button may drive a signal, a logic sequence, or both.
|
||||
if (!sigRef && !action) return;
|
||||
if (!writeAllowed) return;
|
||||
if (confirmOpt) {
|
||||
if (window.confirm(`Send ${label}?`)) execute();
|
||||
} else {
|
||||
@@ -63,7 +88,9 @@ export default function Button({ widget, onContextMenu }: Props) {
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}>
|
||||
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}
|
||||
disabled={!writeAllowed}
|
||||
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>
|
||||
{label}
|
||||
{mode === 'setReset' && (
|
||||
<span class="btn-mode-hint">{resetTimeSec}s</span>
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { formatValue } from '../lib/format';
|
||||
import { fftMag, resampleUniform } from '../lib/fft';
|
||||
import type { Widget, SignalRef } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -81,7 +82,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const showLegend = legendPos !== 'none';
|
||||
|
||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
||||
const histValues: number[][] = signals.map(() => []);
|
||||
const unsubs: (() => void)[] = [];
|
||||
const fmt = widget.options['format'] ?? '';
|
||||
|
||||
@@ -180,12 +180,14 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
|
||||
switch (plotType) {
|
||||
case 'histogram': {
|
||||
const { labels, counts } = buildHistogram(histValues);
|
||||
// Distribution over all buffered samples (ring-buffer bounded).
|
||||
const { labels, counts } = buildHistogram(buffers.map(b => b.values));
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
|
||||
xAxis: { type: 'category', name: 'Value', nameLocation: 'middle', nameGap: 24, data: labels, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', name: 'Count', axisLine, axisLabel, splitLine },
|
||||
series: counts.map((d, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'bar',
|
||||
@@ -198,6 +200,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
|
||||
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: [{
|
||||
@@ -210,32 +213,62 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
};
|
||||
}
|
||||
case 'fft': {
|
||||
// Single-sided magnitude spectrum per signal, computed from the
|
||||
// windowed samples resampled onto a uniform grid.
|
||||
const N = 256;
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => ({
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data: b.values.length ? b.values[b.values.length - 1] : [],
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
})),
|
||||
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
|
||||
xAxis: { type: 'value', name: 'Hz', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
|
||||
yAxis: { type: 'value', name: 'Mag', axisLine, axisLabel, splitLine },
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
let data: [number, number][] = [];
|
||||
if (vals.length >= 4) {
|
||||
const { sampled, dt } = resampleUniform(ts, vals, N);
|
||||
const mag = fftMag(sampled);
|
||||
const fs = dt > 0 ? 1 / dt : 1; // sample rate (Hz)
|
||||
data = mag.map((m, k) => [(k * fs) / N, m]);
|
||||
}
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line',
|
||||
data,
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
case 'logic': {
|
||||
// Logic-analyser style: each signal is a 0/1 step trace stacked on its
|
||||
// own lane, with a relative-time x-axis (seconds before now).
|
||||
const nowSec = Date.now() / 1000;
|
||||
const lane = 1.4;
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
legend: legendOpt,
|
||||
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel },
|
||||
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine },
|
||||
grid: { left: 90, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
|
||||
xAxis: {
|
||||
type: 'value', min: 'dataMin', max: 0, axisLine, splitLine,
|
||||
axisLabel: { color: '#64748b', formatter: (v: number) => `${v.toFixed(0)}s` },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', min: 0, max: Math.max(lane, signals.length * lane),
|
||||
interval: lane, axisLine, splitLine,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
formatter: (v: number) => signals[Math.round(v / lane)]?.name ?? '',
|
||||
},
|
||||
},
|
||||
series: buffers.map((b, i) => {
|
||||
const { ts, vals } = windowedSlice(b, timeWindow);
|
||||
const base = i * lane;
|
||||
return {
|
||||
name: signals[i]?.name ?? `S${i}`,
|
||||
type: 'line', step: 'end',
|
||||
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]),
|
||||
data: ts.map((t, j) => [t - nowSec, base + (vals[j] > 0 ? 1 : 0)]),
|
||||
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
|
||||
showSymbol: false,
|
||||
};
|
||||
@@ -243,22 +276,33 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
};
|
||||
}
|
||||
case 'waterfall': {
|
||||
const ROWS = 32;
|
||||
// Spectrogram of the first signal: split the windowed samples into
|
||||
// ROWS time frames and FFT each frame (freq on x, time on y).
|
||||
const ROWS = 24, FRAME = 32;
|
||||
const b = buffers[0];
|
||||
const step = Math.max(1, Math.floor(b.values.length / ROWS));
|
||||
const flatData: [number, number, number][] = [];
|
||||
for (let ri = 0; ri < ROWS; ri++) {
|
||||
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step;
|
||||
if (idx < 0) continue;
|
||||
const row = b.values[idx];
|
||||
const arr = Array.isArray(row) ? row : [row];
|
||||
arr.forEach((val, ci) => flatData.push([ci, ri, val]));
|
||||
let maxMag = 0;
|
||||
if (b && b.values.length >= FRAME) {
|
||||
const { sampled } = resampleUniform(b.timestamps, b.values, ROWS * FRAME);
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const mag = fftMag(sampled.slice(r * FRAME, (r + 1) * FRAME));
|
||||
for (let k = 0; k < mag.length; k++) {
|
||||
flatData.push([k, r, mag[k]]);
|
||||
if (mag[k] > maxMag) maxMag = mag[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
const bins = FRAME >> 1;
|
||||
return {
|
||||
backgroundColor: ECHARTS_DARK.backgroundColor,
|
||||
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } },
|
||||
xAxis: { type: 'value', axisLabel },
|
||||
yAxis: { type: 'value', axisLabel },
|
||||
grid: { left: 56, right: 70, top: 12, bottom: 36 },
|
||||
visualMap: {
|
||||
min: 0, max: maxMag || 1, calculable: true, right: 8, top: 'center',
|
||||
dimension: 2, textStyle: { color: '#94a3b8' },
|
||||
inRange: { color: ['#0f1117', '#1e3a8a', '#4a9eff', '#22c55e', '#f59e0b', '#ef4444'] },
|
||||
},
|
||||
xAxis: { type: 'category', name: 'Freq bin', nameLocation: 'middle', nameGap: 22, data: Array.from({ length: bins }, (_, k) => String(k)), axisLine, axisLabel },
|
||||
yAxis: { type: 'category', name: 'Time', data: Array.from({ length: ROWS }, (_, r) => String(r - ROWS + 1)), axisLine, axisLabel },
|
||||
series: [{ type: 'heatmap', data: flatData }],
|
||||
};
|
||||
}
|
||||
@@ -346,7 +390,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
|
||||
if (!isNaN(v)) {
|
||||
pushSample(buffers[i], ts, v);
|
||||
if (plotType === 'histogram') histValues[i].push(v);
|
||||
}
|
||||
if (echart) echart.setOption(echartsOption(), { notMerge: true });
|
||||
}
|
||||
@@ -372,7 +415,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
if (uplot) uplot.destroy();
|
||||
if (echart) echart.dispose();
|
||||
};
|
||||
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '']);
|
||||
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { useAuth, canWrite } from '../lib/auth';
|
||||
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||
|
||||
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||
@@ -19,6 +20,7 @@ interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const sigRef = widget.signals[0];
|
||||
const me = useAuth();
|
||||
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
@@ -38,6 +40,14 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0);
|
||||
const quality = sv.quality;
|
||||
|
||||
// The control is read-only when the current user cannot write this signal:
|
||||
// either their global access is read-only, or the signal's metadata reports
|
||||
// it is not writable. Panel-local vars are written client-side (no backend
|
||||
// meta) so they stay editable. meta.writable === false only once known, so
|
||||
// the control isn't prematurely disabled while metadata is still loading.
|
||||
const isLocal = sigRef?.ds === 'local';
|
||||
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
|
||||
|
||||
function displayValue(): string {
|
||||
const v = sv.value;
|
||||
if (v === null || v === undefined) return '---';
|
||||
@@ -52,7 +62,7 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
}
|
||||
|
||||
function doWrite(raw: string) {
|
||||
if (!sigRef || !raw.trim()) return;
|
||||
if (!sigRef || !writeAllowed || !raw.trim()) return;
|
||||
const val = isNumeric ? parseFloat(raw) : raw;
|
||||
wsClient.write(sigRef, val);
|
||||
setInputValue('');
|
||||
@@ -84,7 +94,7 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0');
|
||||
|
||||
function handleEnumSet() {
|
||||
if (!sigRef) return;
|
||||
if (!sigRef || !writeAllowed) return;
|
||||
const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); };
|
||||
if (confirm) {
|
||||
const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected;
|
||||
@@ -107,13 +117,15 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
<select
|
||||
class="sv-input sv-select"
|
||||
value={enumSelected}
|
||||
disabled={!writeAllowed}
|
||||
onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{meta!.enumStrings!.map((s, i) => (
|
||||
<option key={i} value={String(i)}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<button class="sv-btn" onClick={handleEnumSet}>Set</button>
|
||||
<button class="sv-btn" onClick={handleEnumSet} disabled={!writeAllowed}
|
||||
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -121,13 +133,15 @@ export default function SetValue({ widget, onContextMenu }: Props) {
|
||||
class="sv-input"
|
||||
type={isNumeric ? 'number' : 'text'}
|
||||
value={inputValue}
|
||||
disabled={!writeAllowed}
|
||||
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={handleKeydown}
|
||||
placeholder="value"
|
||||
/>
|
||||
<span class="sv-current">{displayValue()}</span>
|
||||
{unit && <span class="sv-unit">{unit}</span>}
|
||||
<button class="sv-btn" onClick={handleSet}>Set</button>
|
||||
<button class="sv-btn" onClick={handleSet} disabled={!writeAllowed}
|
||||
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user