From 2c3256fffcfd92960a2561456dd9200c0c30be00 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Thu, 26 Mar 2026 01:01:06 -0700 Subject: [PATCH] modularize style and add propagating widgets --- backend/data_types.py | 28 ++ backend/execution.py | 8 +- backend/node_menu.py | 5 +- backend/nodes/analysis.py | 110 +++--- backend/nodes/filters.py | 11 +- backend/nodes/io.py | 23 ++ frontend/src/App.jsx | 57 +--- frontend/src/CrossSectionOverlay.jsx | 6 +- frontend/src/CustomNode.jsx | 153 +++++---- frontend/src/LinePlotOverlay.jsx | 20 +- frontend/src/MarkupOverlay.jsx | 4 +- frontend/src/MaskPaintOverlay.jsx | 9 +- frontend/src/constants.js | 58 ++++ frontend/src/executionGraph.js | 5 +- frontend/src/styles.css | 480 +++++++++++++++++---------- frontend/src/workflowCapture.js | 3 +- tests/test_nodes.py | 48 ++- 17 files changed, 670 insertions(+), 358 deletions(-) create mode 100644 frontend/src/constants.js diff --git a/backend/data_types.py b/backend/data_types.py index cbbdee2..2754a4f 100644 --- a/backend/data_types.py +++ b/backend/data_types.py @@ -39,6 +39,34 @@ class MeasureTable(list): """Named scalar measurements, typically rows of quantity/value/unit.""" +@dataclass +class LineData: + data: np.ndarray + x_axis: np.ndarray | None = None + x_unit: str = "" + y_unit: str = "" + + def __post_init__(self) -> None: + self.data = np.asarray(self.data, dtype=np.float64).ravel() + if self.x_axis is not None: + axis = np.asarray(self.x_axis, dtype=np.float64).ravel() + self.x_axis = axis[: len(self.data)] + else: + self.x_axis = None + + def __array__(self, dtype=None): + return np.asarray(self.data, dtype=dtype) if dtype is not None else self.data + + def __len__(self) -> int: + return len(self.data) + + def __iter__(self): + return iter(self.data) + + def __getitem__(self, item): + return self.data[item] + + def _normalize_hex_color(color: Any, default: str = "#000000") -> str: if isinstance(color, str): text = color.strip() diff --git a/backend/execution.py b/backend/execution.py index 7c5bde3..223b136 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -272,7 +272,7 @@ class ExecutionEngine: """ import numpy as np from backend.data_types import ( - DataField, image_to_uint8, encode_preview, render_datafield_preview, + DataField, LineData, image_to_uint8, encode_preview, render_datafield_preview, ) from backend.nodes.io import Image, ImageDemo @@ -302,7 +302,7 @@ class ExecutionEngine: on_preview(node_id, encode_preview(arr)) return - if type_name == "LINE" and isinstance(value, np.ndarray) and on_preview: + if type_name == "LINE" and isinstance(value, (np.ndarray, LineData)) and on_preview: preview = self._render_line_preview(cls, slot, result) if preview: on_preview(node_id, preview) @@ -354,6 +354,7 @@ class ExecutionEngine: ) -> dict | None: """Return structured LINE preview data for responsive frontend rendering.""" import numpy as np + from backend.data_types import LineData return_types = getattr(cls, "RETURN_TYPES", ()) @@ -374,7 +375,10 @@ class ExecutionEngine: matplotlib.use("Agg") import matplotlib.pyplot as plt + y_meta = y if isinstance(y, LineData) else None y = np.asarray(y, dtype=np.float64).ravel() + if x is None and y_meta is not None and y_meta.x_axis is not None: + x = y_meta.x_axis if x is None: x = np.arange(len(y), dtype=np.float64) else: diff --git a/backend/node_menu.py b/backend/node_menu.py index c74bd50..45108b7 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -20,6 +20,7 @@ MENU_LAYOUT: dict[str, list[str]] = { "Number", "RangeSlider", "Coordinate", + "CoordinatePair", "Font", ], "Output": [ @@ -55,10 +56,10 @@ MENU_LAYOUT: dict[str, list[str]] = { "FixZero", ], "Measure": [ - "Statistics", - "Histogram", "CrossSection", + "Histogram", "Cursors", + "Statistics", "Stats", ], "Mask": [ diff --git a/backend/nodes/analysis.py b/backend/nodes/analysis.py index dfb54f1..9bb9f67 100644 --- a/backend/nodes/analysis.py +++ b/backend/nodes/analysis.py @@ -12,7 +12,8 @@ from __future__ import annotations import numpy as np from typing import Callable from backend.node_registry import register_node -from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, encode_preview, render_datafield_preview +from backend.data_types import DataField, LineData, MeasureTable, RecordTable, datafield_to_uint8, encode_preview, render_datafield_preview +from backend.nodes.io import Coordinate, CoordinatePair # --------------------------------------------------------------------------- @@ -62,7 +63,7 @@ class Statistics: # Histogram # --------------------------------------------------------------------------- -@register_node(display_name="Height Histogram") +@register_node(display_name="Histogram") class Histogram: @classmethod def INPUT_TYPES(cls): @@ -78,8 +79,8 @@ class Histogram: } } - RETURN_TYPES = ("MEASURE_TABLE",) - RETURN_NAMES = ("measurements",) + RETURN_TYPES = ("MEASURE_TABLE", "COORDPAIR",) + RETURN_NAMES = ("measurements", "marker pair",) FUNCTION = "process" CATEGORY = "analysis" DESCRIPTION = ( @@ -155,7 +156,7 @@ class Histogram: {"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z}, {"quantity": "delta Y", "value": yb - ya, "unit": count_unit}, ]) - return (table,) + return (table, ((x1, y1), (x2, y2))) # --------------------------------------------------------------------------- @@ -177,12 +178,12 @@ class Cursors: "y2": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), }, "optional": { - "x_axis": ("LINE",), + "coord_pair": ("COORDPAIR", {"label": "coord pair"}), }, } - RETURN_TYPES = ("MEASURE_TABLE",) - RETURN_NAMES = ("measurement",) + RETURN_TYPES = ("MEASURE_TABLE","COORDPAIR",) + RETURN_NAMES = ("measurement","coord pair",) FUNCTION = "process" CATEGORY = "analysis" DESCRIPTION = ( @@ -196,12 +197,17 @@ class Cursors: def process( self, line, x1: float, y1: float, x2: float, y2: float, - x_axis=None, + coord_pair=None, ) -> tuple: - if isinstance(line, DataField): - return self._process_field(line, x1=x1, y1=y1, x2=x2, y2=y2) + if coord_pair is not None: + (x1, y1), (x2, y2) = coord_pair - return self._process_line(line, x1=x1, y1=y1, x2=x2, y2=y2, x_axis=x_axis) + locked = coord_pair is not None + + if isinstance(line, DataField): + return self._process_field(line, x1=x1, y1=y1, x2=x2, y2=y2, locked=locked) + + return self._process_line(line, x1=x1, y1=y1, x2=x2, y2=y2, locked=locked) def _process_line( self, @@ -210,12 +216,14 @@ class Cursors: y1: float, x2: float, y2: float, - x_axis=None, + locked: bool = False, ) -> tuple: y = np.asarray(line, dtype=np.float64).ravel() + x_unit = line.x_unit if isinstance(line, LineData) else "" + y_unit = line.y_unit if isinstance(line, LineData) else "" n = len(y) - if x_axis is not None: - x = np.asarray(x_axis, dtype=np.float64).ravel()[:n] + if isinstance(line, LineData) and line.x_axis is not None: + x = np.asarray(line.x_axis, dtype=np.float64).ravel()[:n] else: x = np.arange(n, dtype=np.float64) x1 = float(np.clip(x1, 0.0, 1.0)) @@ -251,21 +259,21 @@ class Cursors: "x2": x2, "y1": float(y1), "y2": float(y2), - "a_locked": False, - "b_locked": False, + "a_locked": locked, + "b_locked": locked, }, ) # --- Output table --- table = MeasureTable([ - {"quantity": "A x", "value": xa, "unit": ""}, - {"quantity": "A y", "value": ya, "unit": ""}, - {"quantity": "B x", "value": xb, "unit": ""}, - {"quantity": "B y", "value": yb, "unit": ""}, - {"quantity": "dx", "value": xb - xa, "unit": ""}, - {"quantity": "dy", "value": yb - ya, "unit": ""}, + {"quantity": "A x", "value": xa, "unit": x_unit}, + {"quantity": "A y", "value": ya, "unit": y_unit}, + {"quantity": "B x", "value": xb, "unit": x_unit}, + {"quantity": "B y", "value": yb, "unit": y_unit}, + {"quantity": "dx", "value": xb - xa, "unit": x_unit}, + {"quantity": "dy", "value": yb - ya, "unit": y_unit}, ]) - return (table,) + return (table, ((x1, y1), (x2, y2))) def _process_field( self, @@ -274,6 +282,7 @@ class Cursors: y1: float, x2: float, y2: float, + locked: bool = False, ) -> tuple: from scipy.ndimage import map_coordinates @@ -306,8 +315,8 @@ class Cursors: "y1": y1, "x2": x2, "y2": y2, - "a_locked": False, - "b_locked": False, + "a_locked": locked, + "b_locked": locked, }, ) @@ -322,7 +331,7 @@ class Cursors: {"quantity": "dy", "value": by - ay, "unit": field.si_unit_xy}, {"quantity": "dz", "value": z2 - z1, "unit": field.si_unit_z}, ]) - return (table,) + return (table, ((x1, y1), (x2, y2))) # --------------------------------------------------------------------------- @@ -642,13 +651,12 @@ class CrossSection: "n_samples": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), }, "optional": { - "marker_A": ("COORD",), - "marker_B": ("COORD",), + "marker_pair": ("COORDPAIR", {"label": "marker pair"}), }, } - RETURN_TYPES = ("LINE",) - RETURN_NAMES = ("profile",) + RETURN_TYPES = ("LINE", "COORDPAIR",) + RETURN_NAMES = ("profile", "marker pair",) FUNCTION = "process" CATEGORY = "analysis" DESCRIPTION = ( @@ -664,15 +672,13 @@ class CrossSection: self, field: DataField, x1: float, y1: float, x2: float, y2: float, extend: str, n_samples: int, - marker_A=None, marker_B=None, + marker_pair=None, ) -> tuple: from scipy.ndimage import map_coordinates - # COORD inputs override widget values - if marker_A is not None: - x1, y1 = float(marker_A[0]), float(marker_A[1]) - if marker_B is not None: - x2, y2 = float(marker_B[0]), float(marker_B[1]) + # COORDPAIR input overrides widget values + if marker_pair is not None: + (x1, y1), (x2, y2) = marker_pair # Remember marker positions (before extend) marker_x1, marker_y1 = float(x1), float(y1) @@ -714,12 +720,24 @@ class CrossSection: "image": image_uri, "x1": marker_x1, "y1": marker_y1, "x2": marker_x2, "y2": marker_y2, - "a_locked": marker_A is not None, - "b_locked": marker_B is not None, + "a_locked": marker_pair is not None, + "b_locked": marker_pair is not None, }, ) - return (profile.astype(np.float64),) + dx_real = (x2 - x1) * field.xreal + dy_real = (y2 - y1) * field.yreal + distance_axis = np.linspace(0.0, float(np.hypot(dx_real, dy_real)), n_samples, dtype=np.float64) + + return ( + LineData( + data=profile.astype(np.float64), + x_axis=distance_axis, + x_unit=field.si_unit_xy, + y_unit=field.si_unit_z, + ), + ((marker_x1, marker_y1), (marker_x2, marker_y2)), + ) # --------------------------------------------------------------------------- @@ -1028,7 +1046,11 @@ class Stats: if source_type == "LINE": line_entry = LINE_OPS.get(operation) explicit_unit = line_entry[1] if isinstance(line_entry, tuple) and len(line_entry) > 1 else "" - return _apply_scalar_unit(explicit_unit, operation) + if explicit_unit: + return _apply_scalar_unit(explicit_unit, operation) + if isinstance(input_value, LineData): + return _apply_scalar_unit(input_value.y_unit, operation) + return "" if source_type == "RECORD_TABLE" and isinstance(input_value, list) and column: return _apply_scalar_unit(_common_table_unit(input_value, column), operation) @@ -1052,6 +1074,12 @@ class Stats: raise ValueError(f"Column '{column_name}' has no numeric values.") return ("RECORD_TABLE", np.asarray(values, dtype=np.float64), column_name) + if isinstance(input_value, LineData): + values = np.asarray(input_value.data, dtype=np.float64) + if values.size == 0: + raise ValueError("Stats requires a non-empty input.") + return ("LINE", values.ravel(), None) + if isinstance(input_value, np.ndarray): values = np.asarray(input_value, dtype=np.float64) if values.size == 0: diff --git a/backend/nodes/filters.py b/backend/nodes/filters.py index 916e963..ceaf745 100644 --- a/backend/nodes/filters.py +++ b/backend/nodes/filters.py @@ -13,7 +13,7 @@ from __future__ import annotations from functools import lru_cache import numpy as np from backend.node_registry import register_node -from backend.data_types import DataField +from backend.data_types import DataField, LineData # --------------------------------------------------------------------------- @@ -251,6 +251,15 @@ class FFTFilter1D: # Inverse FFT filtered = np.fft.irfft(Z, n=n) + if isinstance(line, LineData): + return ( + LineData( + data=filtered, + x_axis=line.x_axis.copy() if line.x_axis is not None else None, + x_unit=line.x_unit, + y_unit=line.y_unit, + ), + ) return (filtered,) diff --git a/backend/nodes/io.py b/backend/nodes/io.py index 26ad6f4..fc1a806 100644 --- a/backend/nodes/io.py +++ b/backend/nodes/io.py @@ -446,7 +446,30 @@ class Coordinate: def process(self, x: float, y: float) -> tuple: return ((float(x), float(y)),) + + +@register_node(display_name="Coordinate Pair") +class CoordinatePair: + """Provide a pair of Coordinates, for drawing lines between markers, etc.""" + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "a": ("COORD",), + "b": ("COORD",), + } + } + + RETURN_TYPES = ("COORDPAIR",) + RETURN_NAMES = ("coord pair",) + FUNCTION = "process" + CATEGORY = "io" + DESCRIPTION = "Output a pair of coordinates." + + def process(self, a: tuple, b: tuple) -> tuple: + return ((a, b),) + # --------------------------------------------------------------------------- # Number diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8afbb32..2d6cd2c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,42 +23,9 @@ import { hasBlockingAutoRunInput, } from './executionGraph'; -// ── Constants ───────────────────────────────────────────────────────── - -const DATA_TYPES = new Set([ - 'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE', - 'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY', -]); - -const SOCKET_COMPATIBILITY = { - STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE']), - CURSOR_SOURCE: new Set(['DATA_FIELD', 'LINE']), - ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']), - VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']), - SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']), - FLOAT: new Set(['INT']), - INT: new Set(['FLOAT']), -}; - -const TYPE_COLORS = { - DATA_FIELD: '#ff002f', - IMAGE: '#00ff08a0', - LINE: '#ffbe5c', - MEASURE_TABLE:'#35e2fd', - RECORD_TABLE:'#fbbf24', - ANY_TABLE: '#67e8f9', - COORD: '#e91ed1', - FLOAT: '#7dd3fc', - INT: '#38bdf8', - STATS_SOURCE:'#c084fc', - CURSOR_SOURCE:'#a78bfa', - VALUE_SOURCE:'#60a5fa', - COLORMAP: '#f472b6', - SAVE_LAYER: '#22c55e', - FONT: '#fb7185', - FILE_PATH: '#f59e0b', - DIRECTORY: '#f97316', -}; +import { + DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS, +} from './constants'; const NODE_TYPES = { custom: CustomNode }; @@ -378,7 +345,7 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti if (categories.length === 0) { return (
e.stopPropagation()}> -
No compatible nodes
+
No compatible nodes
); } @@ -415,7 +382,7 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti {searchResults ? (
{searchResults.length === 0 ? ( -
No matches
+
No matches
) : ( searchResults.map(({ className, def }) => (
{ const type = getHandleType(params.sourceHandle); - const color = TYPE_COLORS[type] || '#999'; + const color = TYPE_COLORS[type] || 'var(--fallback-type)'; setEdges((eds) => { // Enforce single connection per input handle @@ -864,7 +831,7 @@ function Flow() { return type; })(); const targetHandle = `input::${inputName}::${targetType}`; - const color = TYPE_COLORS[filterType] || '#999'; + const color = TYPE_COLORS[filterType] || 'var(--fallback-type)'; setEdges((eds) => addEdge({ source: contextMenu.pendingNodeId, sourceHandle: contextMenu.pendingHandleId, @@ -879,7 +846,7 @@ function Flow() { if (outputIdx !== -1) { const outputType = def.output[outputIdx]; const sourceHandle = `output::${outputIdx}::${outputType}`; - const color = TYPE_COLORS[outputType] || '#999'; + const color = TYPE_COLORS[outputType] || 'var(--fallback-type)'; setEdges((eds) => addEdge({ source: newNodeId, sourceHandle, @@ -1021,7 +988,7 @@ function Flow() { const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad); const blob = await captureWorkflowViewportBlob(viewportEl, { - backgroundColor: '#1a1a1a', + backgroundColor: CANVAS_COLORS.bgDeep, width: imageWidth, height: imageHeight, style: { @@ -1274,11 +1241,7 @@ function Flow() { { const cat = n.data?.definition?.category; - const colors = { - io: '#37474f', filters: '#1a237e', level: '#1b5e20', - analysis: '#4a148c', particles: '#bf360c', display: '#212121', - }; - return colors[cat] || '#333'; + return CAT_COLORS[cat] || 'var(--fallback-cat)'; }} /> diff --git a/frontend/src/CrossSectionOverlay.jsx b/frontend/src/CrossSectionOverlay.jsx index d061add..85f0e0f 100644 --- a/frontend/src/CrossSectionOverlay.jsx +++ b/frontend/src/CrossSectionOverlay.jsx @@ -68,7 +68,7 @@ export default function CrossSectionOverlay({ )} @@ -78,12 +78,12 @@ export default function CrossSectionOverlay({ className={`cs-marker ${aLocked ? 'cs-marker-locked' : ''}`} style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }} onPointerDown={onPointerDown('p1')} - /> + >A
+ >B
); } diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 2ec87e1..9220fa9 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -8,43 +8,9 @@ const CropBoxOverlay = lazy(() => import('./CropBoxOverlay')); const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay')); const MarkupOverlay = lazy(() => import('./MarkupOverlay')); -// ── Constants ───────────────────────────────────────────────────────── - -const DATA_TYPES = new Set([ - 'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE', - 'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY', -]); -const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']); - -const TYPE_COLORS = { - DATA_FIELD: '#3a7abf', - IMAGE: '#4caf50', - LINE: '#ff9800', - MEASURE_TABLE:'#35e2fd', - RECORD_TABLE:'#fbbf24', - ANY_TABLE: '#67e8f9', - COORD: '#e91e63', - FLOAT: '#7dd3fc', - INT: '#38bdf8', - STATS_SOURCE:'#c084fc', - CURSOR_SOURCE:'#a78bfa', - VALUE_SOURCE:'#60a5fa', - COLORMAP: '#f472b6', - SAVE_LAYER: '#22c55e', - FONT: '#fb7185', - FILE_PATH: '#f59e0b', - DIRECTORY: '#f97316', -}; - -const CAT_COLORS = { - io: '#37474f', - filters: '#1a237e', - modify: '#0f766e', - level: '#1b5e20', - analysis: '#4a148c', - particles:'#bf360c', - display: '#212121', -}; +import { + DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, +} from './constants'; // ── Context (provided by App) ───────────────────────────────────────── @@ -84,7 +50,7 @@ class PreviewBoundary extends React.Component { } return ( -
+
Preview unavailable.
); @@ -440,6 +406,58 @@ function getConnectedOutputInfo(store, nodeId, inputName) { }; } +/** + * Resolve live COORDPAIR values by walking edges back to upstream Coordinate + * nodes' widget values. Returns [x1, y1, x2, y2] (a flat array for stable + * equality comparison) or null if the chain can't be fully resolved. + * + * Uses store.nodes (the reactive array) rather than nodeLookup so that + * upstream widgetValues changes trigger re-renders. + */ +function resolveLiveCoordPair(store, nodeId, coordPairInputName) { + const nodes = store.nodes; + const edges = store.edges; + if (!nodes || !edges) return null; + + const findNode = (nid) => nodes.find((n) => n.id === nid); + + // 1. Find the edge feeding this node's COORDPAIR input + const cpEdge = edges.find( + (e) => e.target === nodeId && e.targetHandle?.startsWith(`input::${coordPairInputName}::`) + ); + if (!cpEdge) return null; + + const cpNode = findNode(cpEdge.source); + if (!cpNode) return null; + + // If the source node is a CoordinatePair, walk one more level to Coordinate nodes + if (cpNode.data?.className === 'CoordinatePair') { + const resolveCoord = (inputName) => { + const edge = edges.find( + (e) => e.target === cpNode.id && e.targetHandle?.startsWith(`input::${inputName}::`) + ); + if (!edge) return null; + const srcNode = findNode(edge.source); + if (!srcNode?.data?.widgetValues) return null; + const x = srcNode.data.widgetValues.x; + const y = srcNode.data.widgetValues.y; + return (x != null && y != null) ? [x, y] : null; + }; + const a = resolveCoord('a'); + const b = resolveCoord('b'); + if (!a || !b) return null; + return [a[0], a[1], b[0], b[1]]; + } + + // If the source is a node with x1/y1/x2/y2 widgets (e.g. another CrossSection output) + const wv = cpNode.data?.widgetValues; + if (wv && wv.x1 != null && wv.y1 != null && wv.x2 != null && wv.y2 != null) { + return [wv.x1, wv.y1, wv.x2, wv.y2]; + } + + return null; +} + function getBasename(value) { if (typeof value !== 'string') return ''; const trimmed = value.trim(); @@ -732,6 +750,29 @@ function CustomNode({ id, data }) { useCallback((s) => getConnectedOutputInfo(s, id, 'path'), [id]), ); + // Find the COORDPAIR input name (if any) so we can resolve live upstream positions + const coordPairInputName = React.useMemo(() => { + const allInputs = { ...def.input.required, ...def.input.optional }; + for (const [name, spec] of Object.entries(allInputs)) { + const type = Array.isArray(spec) ? spec[0] : spec; + if (type === 'COORDPAIR') return name; + } + return null; + }, [def]); + + // Returns [x1, y1, x2, y2] or null — flat array for cheap equality check + const liveCoordPair = useStore( + useCallback( + (s) => coordPairInputName ? resolveLiveCoordPair(s, id, coordPairInputName) : null, + [id, coordPairInputName], + ), + (a, b) => { + if (a === b) return true; + if (!a || !b) return false; + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; + }, + ); + // Parse inputs into data handles and widgets const required = def.input.required || {}; const optional = def.input.optional || {}; @@ -846,7 +887,7 @@ function CustomNode({ id, data }) { slot: i, })); - const catColor = CAT_COLORS[def.category] || '#333'; + const catColor = CAT_COLORS[def.category] || 'var(--fallback-cat)'; const maxIORows = Math.max(renderedDataInputs.length, outputs.length); const hasInteractiveLineOverlay = data.overlay?.kind === 'line_plot' && hiddenWidgets.has('x1'); const hasInteractiveOverlay = !!data.overlay && ( @@ -904,7 +945,7 @@ function CustomNode({ id, data }) { position={Position.Left} id={`input::${socketName}::${socketType}`} className="typed-handle" - style={{ background: TYPE_COLORS[socketType] || '#999' }} + style={{ background: TYPE_COLORS[socketType] || 'var(--fallback-type)' }} /> ); })()} @@ -939,7 +980,7 @@ function CustomNode({ id, data }) { position={Position.Left} id={`input::${inp.name}::${inp.type}`} className="typed-handle" - style={{ background: TYPE_COLORS[inp.type] || '#999' }} + style={{ background: TYPE_COLORS[inp.type] || 'var(--fallback-type)' }} /> {inp.label || inp.name} {inlineWidgetsByInput.has(inp.name) && ( @@ -967,7 +1008,7 @@ function CustomNode({ id, data }) { position={Position.Right} id={`output::${out.slot}::${out.type}`} className="typed-handle" - style={{ background: TYPE_COLORS[out.type] || '#999' }} + style={{ background: TYPE_COLORS[out.type] || 'var(--fallback-type)' }} /> )} @@ -1002,7 +1043,7 @@ function CustomNode({ id, data }) { position={Position.Left} id={`input::${w.name}::${w.socketType}`} className="typed-handle" - style={{ background: TYPE_COLORS[w.socketType] || '#999' }} + style={{ background: TYPE_COLORS[w.socketType] || 'var(--fallback-type)' }} /> )} - Loading 3D...
}> + Loading 3D...}> @@ -1068,12 +1109,12 @@ function CustomNode({ id, data }) { {/* Interactive cross-section overlay */} {hasInteractiveOverlay && ( - Loading...}> + Loading...}> {data.overlay.kind === 'line_plot' ? ( {!hideLabel && } diff --git a/frontend/src/LinePlotOverlay.jsx b/frontend/src/LinePlotOverlay.jsx index 9e8d499..421d509 100644 --- a/frontend/src/LinePlotOverlay.jsx +++ b/frontend/src/LinePlotOverlay.jsx @@ -214,14 +214,14 @@ export default function LinePlotOverlay({ onLostPointerCapture={onPointerUp} > - + {xTicks.map((tick) => { const x = scaleX(tick); return ( - - + + {formatTick(tick)} @@ -232,22 +232,22 @@ export default function LinePlotOverlay({ const y = scaleY(tick); return ( - - + + {formatTick(tick)} ); })} - - + + {interactive && ( <> - - - + + + ({ @@ -160,7 +161,7 @@ export default function MaskPaintOverlay({ cssHeight, imageWidth, imageHeight, - { strokeStyle: '#ffffff', fillStyle: '#ffffff' }, + { strokeStyle: CANVAS_COLORS.maskStroke, fillStyle: CANVAS_COLORS.maskStroke }, ); for (const stroke of committedStrokes) { @@ -172,7 +173,7 @@ export default function MaskPaintOverlay({ ctx.drawImage(maskCanvas, 0, 0); ctx.globalCompositeOperation = 'source-in'; - ctx.fillStyle = 'rgba(255, 59, 59, 0.16)'; + ctx.fillStyle = CANVAS_COLORS.maskOverlay; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation = 'source-over'; }, [imageHeight, imageWidth]); diff --git a/frontend/src/constants.js b/frontend/src/constants.js new file mode 100644 index 0000000..cf398aa --- /dev/null +++ b/frontend/src/constants.js @@ -0,0 +1,58 @@ +// ── Shared type & color constants ───────────────────────────────────── + +export const DATA_TYPES = new Set([ + 'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE', + 'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', + 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY', 'COORDPAIR', +]); + +export const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']); + +export const TYPE_COLORS = { + DATA_FIELD: '#3a7abf', + IMAGE: '#00ff08a0', + LINE: '#ffbe5c', + MEASURE_TABLE: '#35e2fd', + RECORD_TABLE: '#fbbf24', + ANY_TABLE: '#67e8f9', + COORD: '#e91ed1', + COORDPAIR: '#5c7cb8', + FLOAT: '#ab3197', + INT: '#38bdf8', + STATS_SOURCE: '#c084fc', + CURSOR_SOURCE: '#a78bfa', + VALUE_SOURCE: '#60a5fa', + COLORMAP: '#f472b6', + SAVE_LAYER: '#22c55e', + FONT: '#fb7185', + FILE_PATH: '#f59e0b', + DIRECTORY: '#f97316', +}; + +export const CAT_COLORS = { + io: '#37474f', + filters: '#1a237e', + modify: '#0f766e', + level: '#1b5e20', + analysis: '#4a148c', + particles: '#bf360c', + display: '#212121', +}; + +export const SOCKET_COMPATIBILITY = { + STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE']), + CURSOR_SOURCE: new Set(['DATA_FIELD', 'LINE']), + ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']), + VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']), + SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']), + FLOAT: new Set(['INT']), + INT: new Set(['FLOAT']), + LINE: new Set(['COORDPAIR']), +}; + +// Colors used in Canvas 2D / toBlob contexts where CSS var() is unavailable. +export const CANVAS_COLORS = { + bgDeep: '#0f172a', + maskStroke: '#ffffff', + maskOverlay: 'rgba(255, 59, 59, 0.16)', +}; diff --git a/frontend/src/executionGraph.js b/frontend/src/executionGraph.js index 36521a4..25e9b2f 100644 --- a/frontend/src/executionGraph.js +++ b/frontend/src/executionGraph.js @@ -1,7 +1,4 @@ -const DATA_TYPES = new Set([ - 'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE', - 'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY', -]); +import { DATA_TYPES } from './constants'; function getInputName(handleId) { return handleId.split('::')[1]; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 04c1bb3..045bdc1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,11 +1,135 @@ +/* ── Theme tokens ──────────────────────────────────────────────────── */ +:root { + /* Backgrounds */ + --bg-app: #1a1a2e; + --bg-toolbar: #242424; + --bg-canvas: #0d1117; + --bg-surface: #1e293b; + --bg-deep: #0f172a; + --bg-panel: #16213e; + --bg-backdrop: rgba(0, 0, 0, 0.6); + --bg-overlay-dim: rgba(2, 6, 23, 0.58); + + /* Borders */ + --border-default: #334155; + --border-strong: #0f3460; + --border-toolbar: #000000; + --border-subtle: rgba(51, 65, 85, 0.35); + --border-table: rgba(51, 65, 85, 0.75); + --border-title: rgba(0, 0, 0, 0.3); + + /* Text */ + --text-primary: #e0e0e0; + --text-bright: #e2e8f0; + --text-heading: #ffffff; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-faint: #475569; + --text-disabled: #607d8b; + --text-table: #cbd5e1; + --text-value: #e0f2fe; + --text-value-unit: rgba(224, 242, 254, 0.82); + + /* Accent */ + --accent: #3a7abf; + --accent-bg: #0f3460; + --accent-hover: #1a4a8a; + --accent-pressed: #0a2040; + --accent-light: #90caf9; + --accent-lighter: #7dd3fc; + --accent-lightest: #bae6fd; + --accent-deep: #172554; + --accent-deep-text:#dbeafe; + + /* Danger */ + --danger: #e94560; + --danger-hover: #ff6b81; + --danger-locked: #e91e63; + --error-text: #ef9a9a; + --error-bg: rgba(183, 28, 28, 0.2); + + /* Warning */ + --warning: #fbbf24; + --warning-bg: rgba(251, 191, 36, 0.1); + --warning-border: rgba(251, 191, 36, 0.2); + + /* Selection */ + --selection: #90caf9; + --selection-glow: rgba(144, 202, 249, 0.4); + --selection-edge: rgba(144, 202, 249, 0.6); + + /* Marker / cursor */ + --marker: #ffd700; + --marker-active: #ffeb3b; + --marker-border: #ffffff; + --marker-shadow: rgba(0, 0, 0, 0.6); + --marker-shadow-light: rgba(0, 0, 0, 0.45); + + /* Plot */ + --plot-line: #ff9800; + + /* Linked state */ + --linked-border: rgba(244, 114, 182, 0.45); + --linked-bg: rgba(30, 41, 59, 0.55); + --linked-text: #f9a8d4; + + /* Value display */ + --value-label: #7dd3fc; + --value-border: rgba(125, 211, 252, 0.45); + --value-grad-top: rgba(14, 116, 144, 0.2); + --value-grad-bot: rgba(8, 47, 73, 0.45); + --value-grad-a: rgba(125, 211, 252, 0.08); + --value-grad-b: rgba(56, 189, 248, 0.02); + --value-shadow-in: rgba(255, 255, 255, 0.05); + --value-shadow: rgba(2, 132, 199, 0.14); + + /* Node title meta */ + --meta-bg: rgba(15, 23, 42, 0.28); + --meta-text: rgba(255, 255, 255, 0.88); + + /* Mask paint cursor */ + --mask-cursor-border: rgba(255, 255, 255, 0.95); + --mask-cursor-bg: rgba(255, 255, 255, 0.08); + --mask-cursor-ring: rgba(239, 68, 68, 0.85); + --mask-cursor-shadow: rgba(15, 23, 42, 0.35); + + /* Shadows */ + --shadow-heavy: rgba(0, 0, 0, 0.5); + + /* Gallery */ + --gallery-name-border: rgba(51, 65, 85, 0.9); + --gallery-name-bg: rgba(15, 23, 42, 0.8); + + /* Benchmark */ + --benchmark-border: rgba(148, 163, 184, 0.28); + --benchmark-bg: rgba(15, 23, 42, 0.92); + + /* Markup toolbar */ + --markup-btn-border: rgba(148, 163, 184, 0.35); + --markup-btn-bg: rgba(15, 23, 42, 0.88); + + /* Table */ + --table-stripe: rgba(30, 41, 59, 0.38); + + /* Crop */ + --crop-inset: rgba(255, 255, 255, 0.22); + + /* Shape default */ + --shape-default: #ffd54f; + + /* Dynamic-lookup fallbacks */ + --fallback-type: #999; + --fallback-cat: #333; +} + /* ── Reset & base ──────────────────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body, #root { width: 100%; height: 100%; - background: #1a1a2e; - color: #e0e0e0; + background: var(--bg-app); + color: var(--text-primary); font-family: "Inter", "Segoe UI", system-ui, sans-serif; font-size: 13px; overflow: hidden; @@ -21,8 +145,8 @@ html, body, #root { /* ── Toolbar ───────────────────────────────────────────────────────── */ #toolbar { height: 44px; - background: #242424; - border-bottom: 1px solid #000000; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-toolbar); display: flex; align-items: center; padding: 0 12px; @@ -36,7 +160,7 @@ html, body, #root { font-size: 15px; font-weight: 700; letter-spacing: 0.5px; - color: #ffffff; + color: var(--text-heading); margin-right: 8px; flex-shrink: 0; } @@ -50,30 +174,30 @@ html, body, #root { /* ── Buttons ───────────────────────────────────────────────────────── */ .btn { padding: 5px 12px; - border: 1px solid #0f3460; + border: 1px solid var(--accent-bg); border-radius: 5px; - background: #0f3460; - color: #e0e0e0; + background: var(--accent-bg); + color: var(--text-primary); font-size: 12px; cursor: pointer; transition: background 0.15s, border-color 0.15s; white-space: nowrap; } .btn:hover { - background: #1a4a8a; - border-color: #3a7abf; + background: var(--accent-hover); + border-color: var(--accent); } .btn:active { - background: #0a2040; + background: var(--accent-pressed); } .btn-primary { - background: #e94560; - border-color: #e94560; + background: var(--danger); + border-color: var(--danger); font-weight: 600; } .btn-primary:hover { - background: #ff6b81; - border-color: #ff6b81; + background: var(--danger-hover); + border-color: var(--danger-hover); } /* ── Status bar ────────────────────────────────────────────────────── */ @@ -85,8 +209,8 @@ html, body, #root { max-width: 60%; flex-shrink: 1; } -.status-bar.info { color: #90caf9; } -.status-bar.error { color: #ef9a9a; background: rgba(183,28,28,0.2); } +.status-bar.info { color: var(--accent-light); } +.status-bar.error { color: var(--error-text); background: var(--error-bg); } /* ── React Flow container ──────────────────────────────────────────── */ .flow-container { @@ -96,16 +220,16 @@ html, body, #root { /* ── React Flow dark overrides ─────────────────────────────────────── */ .react-flow { - background: #0d1117 !important; + background: var(--bg-canvas) !important; } /* ── Custom node ───────────────────────────────────────────────────── */ .custom-node { - background: #1e293b; - border: 1px solid #334155; + background: var(--bg-surface); + border: 1px solid var(--border-default); border-radius: 6px; font-size: 11px; - color: #e0e0e0; + color: var(--text-primary); width: 200px; min-width: 160px; resize: horizontal; @@ -128,15 +252,15 @@ html, body, #root { /* Selected node — target via React Flow's wrapper class */ .react-flow__node.selected .custom-node { - border-color: #90caf9; - box-shadow: 0 0 0 1px #90caf9, 0 0 12px rgba(144, 202, 249, 0.4); + border-color: var(--selection); + box-shadow: 0 0 0 1px var(--selection), 0 0 12px var(--selection-glow); } /* Selected edge */ .react-flow__edge.selected .react-flow__edge-path { - stroke: #90caf9 !important; + stroke: var(--selection) !important; stroke-width: 3px !important; - filter: drop-shadow(0 0 4px rgba(144, 202, 249, 0.6)); + filter: drop-shadow(0 0 4px var(--selection-edge)); } .node-title { @@ -147,9 +271,9 @@ html, body, #root { gap: 8px; font-weight: 600; font-size: 12px; - color: white; + color: var(--text-heading); border-radius: 5px 5px 0 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.3); + border-bottom: 1px solid var(--border-title); } .node-title-main { @@ -161,8 +285,8 @@ html, body, #root { min-width: 0; padding: 1px 6px; border-radius: 999px; - background: rgba(15, 23, 42, 0.28); - color: rgba(255, 255, 255, 0.88); + background: var(--meta-bg); + color: var(--meta-text); font-size: 10px; font-weight: 500; white-space: nowrap; @@ -178,17 +302,17 @@ html, body, #root { .top-widget-section { padding-bottom: 2px; - border-bottom: 1px solid rgba(51, 65, 85, 0.35); + border-bottom: 1px solid var(--border-subtle); margin-bottom: 2px; } .node-warning { padding: 3px 10px; font-size: 10px; - color: #fbbf24; - background: rgba(251, 191, 36, 0.1); - border-top: 1px solid rgba(251, 191, 36, 0.2); - border-bottom: 1px solid rgba(251, 191, 36, 0.2); + color: var(--warning); + background: var(--warning-bg); + border-top: 1px solid var(--warning-border); + border-bottom: 1px solid var(--warning-border); } .node-value-display { @@ -200,21 +324,21 @@ html, body, #root { font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; - color: #7dd3fc; + color: var(--value-label); margin-bottom: 5px; } .node-value-box { padding: 10px 12px; border-radius: 8px; - border: 1px solid rgba(125, 211, 252, 0.45); + border: 1px solid var(--value-border); background: - linear-gradient(180deg, rgba(14, 116, 144, 0.2), rgba(8, 47, 73, 0.45)), - linear-gradient(135deg, rgba(125, 211, 252, 0.08), rgba(56, 189, 248, 0.02)); + linear-gradient(180deg, var(--value-grad-top), var(--value-grad-bot)), + linear-gradient(135deg, var(--value-grad-a), var(--value-grad-b)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.05), - 0 8px 20px rgba(2, 132, 199, 0.14); - color: #e0f2fe; + inset 0 1px 0 var(--value-shadow-in), + 0 8px 20px var(--value-shadow); + color: var(--text-value); font-size: 22px; font-weight: 700; line-height: 1.1; @@ -234,7 +358,7 @@ html, body, #root { font-size: 0.58em; font-weight: 600; letter-spacing: 0.03em; - color: rgba(224, 242, 254, 0.82); + color: var(--text-value-unit); vertical-align: baseline; } @@ -261,14 +385,14 @@ html, body, #root { .io-label { font-size: 10px; - color: #94a3b8; + color: var(--text-secondary); } /* ── Handles ───────────────────────────────────────────────────────── */ .typed-handle { width: 10px !important; height: 10px !important; - border: 2px solid #1e293b !important; + border: 2px solid var(--bg-surface) !important; border-radius: 50% !important; position: absolute; overflow: visible !important; @@ -308,7 +432,7 @@ html, body, #root { .widget-row label { font-size: 10px; - color: #64748b; + color: var(--text-muted); min-width: 40px; flex-shrink: 0; } @@ -330,9 +454,9 @@ html, body, #root { .io-inline-widget input[type="number"], .io-inline-widget input[type="color"], .io-inline-widget select { - background: #0f172a; - color: #e0e0e0; - border: 1px solid #334155; + background: var(--bg-deep); + color: var(--text-primary); + border: 1px solid var(--border-default); border-radius: 3px; padding: 2px 5px; font-size: 11px; @@ -344,9 +468,9 @@ html, body, #root { .widget-row input[type="number"], .widget-row input[type="color"], .widget-row select { - background: #0f172a; - color: #e0e0e0; - border: 1px solid #334155; + background: var(--bg-deep); + color: var(--text-primary); + border: 1px solid var(--border-default); border-radius: 3px; padding: 2px 5px; font-size: 11px; @@ -360,15 +484,15 @@ html, body, #root { } .widget-row input[type="checkbox"] { - accent-color: #3a7abf; + accent-color: var(--accent); } .widget-button { flex: 1; min-width: 0; - background: #0f3460; - color: #e0e0e0; - border: 1px solid #334155; + background: var(--accent-bg); + color: var(--text-primary); + border: 1px solid var(--border-default); border-radius: 3px; padding: 4px 8px; font-size: 11px; @@ -376,18 +500,18 @@ html, body, #root { } .widget-button:hover { - background: #1a4a8a; - border-color: #3a7abf; + background: var(--accent-hover); + border-color: var(--accent); } .widget-linked-state { flex: 1; min-width: 0; padding: 4px 8px; - border: 1px dashed rgba(244, 114, 182, 0.45); + border: 1px dashed var(--linked-border); border-radius: 4px; - background: rgba(30, 41, 59, 0.55); - color: #f9a8d4; + background: var(--linked-bg); + color: var(--linked-text); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; @@ -406,8 +530,8 @@ html, body, #root { width: 100%; height: 18px; border-radius: 999px; - border: 1px solid #334155; - background-color: #0f172a; + border: 1px solid var(--border-default); + background-color: var(--bg-deep); } .colormap-stop-list { @@ -426,16 +550,16 @@ html, body, #root { .colormap-stop-label, .colormap-stop-boundary { font-size: 10px; - color: #94a3b8; + color: var(--text-secondary); } .colormap-stop-color { width: 34px; height: 24px; padding: 0; - border: 1px solid #334155; + border: 1px solid var(--border-default); border-radius: 4px; - background: #0f172a; + background: var(--bg-deep); } .colormap-stop-position { @@ -443,9 +567,9 @@ html, body, #root { } .colormap-stop-action { - background: #172554; - color: #dbeafe; - border: 1px solid #334155; + background: var(--accent-deep); + color: var(--accent-deep-text); + border: 1px solid var(--border-default); border-radius: 4px; padding: 4px 8px; font-size: 10px; @@ -472,13 +596,13 @@ html, body, #root { .slider-input { flex: 1; min-width: 0; - accent-color: #7dd3fc; + accent-color: var(--accent-lighter); } .slider-value { font-family: "SF Mono", "Fira Code", monospace; font-size: 10px; - color: #cbd5e1; + color: var(--text-table); min-width: 52px; text-align: right; } @@ -486,7 +610,7 @@ html, body, #root { .widget-row input:focus, .widget-row select:focus { outline: none; - border-color: #3a7abf; + border-color: var(--accent); } .file-picker-row { @@ -505,19 +629,19 @@ html, body, #root { .drag-number { flex: 1; min-width: 0; - background: #0f172a; - border: 1px solid #334155; + background: var(--bg-deep); + border: 1px solid var(--border-default); border-radius: 3px; padding: 2px 6px; cursor: ew-resize; user-select: none; text-align: center; font-size: 11px; - color: #e0e0e0; + color: var(--text-primary); touch-action: none; } .drag-number:hover { - border-color: #3a7abf; + border-color: var(--accent); } .drag-number-val { pointer-events: none; @@ -525,20 +649,20 @@ html, body, #root { .drag-number-edit { flex: 1; min-width: 0; - background: #0f172a; - border: 1px solid #3a7abf; + background: var(--bg-deep); + border: 1px solid var(--accent); border-radius: 3px; padding: 2px 5px; font-size: 11px; - color: #e0e0e0; + color: var(--text-primary); text-align: center; outline: none; } .browse-btn { - background: #0f3460; - color: #e0e0e0; - border: 1px solid #334155; + background: var(--accent-bg); + color: var(--text-primary); + border: 1px solid var(--border-default); border-radius: 3px; padding: 2px 6px; font-size: 10px; @@ -546,12 +670,12 @@ html, body, #root { white-space: nowrap; } .browse-btn:hover { - background: #1a4a8a; + background: var(--accent-hover); } /* ── Collapsible section ───────────────────────────────────────────── */ .collapsible { - border-top: 1px solid #334155; + border-top: 1px solid var(--border-default); margin-top: 4px; } .collapsible-toggle { @@ -561,14 +685,14 @@ html, body, #root { width: 100%; background: none; border: none; - color: #64748b; + color: var(--text-muted); font-size: 10px; padding: 3px 10px; cursor: pointer; text-align: left; } .collapsible-toggle:hover { - color: #94a3b8; + color: var(--text-secondary); } .collapsible-arrow { font-size: 9px; @@ -600,10 +724,10 @@ html, body, #root { .layer-gallery-btn { height: 26px; - border: 1px solid #334155; + border: 1px solid var(--border-default); border-radius: 6px; - background: #0f172a; - color: #e2e8f0; + background: var(--bg-deep); + color: var(--text-bright); font-size: 14px; cursor: pointer; } @@ -616,10 +740,10 @@ html, body, #root { .layer-gallery-name { min-width: 0; padding: 4px 8px; - border: 1px solid rgba(51, 65, 85, 0.9); + border: 1px solid var(--gallery-name-border); border-radius: 6px; - background: rgba(15, 23, 42, 0.8); - color: #cbd5e1; + background: var(--gallery-name-bg); + color: var(--text-table); font-size: 10px; text-align: center; white-space: nowrap; @@ -629,7 +753,7 @@ html, body, #root { .layer-gallery-count { font-size: 10px; - color: #64748b; + color: var(--text-muted); text-align: center; } @@ -657,21 +781,27 @@ html, body, #root { width: 14px; height: 14px; border-radius: 50%; - background: #ffd700; - border: 2px solid #fff; + background: var(--marker); + border: 1px solid var(--marker-border); transform: translate(-50%, -50%); cursor: grab; - box-shadow: 0 0 4px rgba(0,0,0,0.6); + box-shadow: 0 0 4px var(--marker-shadow); z-index: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + color: var(--bg-deep); } .cs-marker:active:not(.cs-marker-locked) { cursor: grabbing; - background: #ffeb3b; + background: var(--marker-active); transform: translate(-50%, -50%) scale(1.2); } .cs-marker-locked { - background: #e91e63; - border-color: #e91e63; + background: var(--danger-locked); + border-color: var(--danger-locked); cursor: default; opacity: 0.9; } @@ -679,8 +809,8 @@ html, body, #root { .lineplot-overlay { width: 100%; aspect-ratio: 32 / 22; - background: #0f172a; - border: 1px solid #334155; + background: var(--bg-deep); + border: 1px solid var(--border-default); border-radius: 6px; overflow: hidden; user-select: none; @@ -694,11 +824,11 @@ html, body, #root { } .lineplot-marker { - fill: #ffd700; - stroke: #fff; + fill: var(--marker); + stroke: var(--marker-border); stroke-width: 2px; cursor: grab; - filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.45)); + filter: drop-shadow(0 0 4px var(--marker-shadow-light)); } .lineplot-marker:active { @@ -706,8 +836,8 @@ html, body, #root { } .lineplot-marker-locked { - fill: #e91e63; - stroke: #e91e63; + fill: var(--danger-locked); + stroke: var(--danger-locked); cursor: default; } @@ -725,14 +855,14 @@ html, body, #root { .crop-dim { position: absolute; - background: rgba(2, 6, 23, 0.58); + background: var(--bg-overlay-dim); pointer-events: none; } .crop-rect { position: absolute; - border: 2px solid #7dd3fc; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22); + border: 2px solid var(--accent-lighter); + box-shadow: inset 0 0 0 1px var(--crop-inset); background: transparent; pointer-events: none; } @@ -742,23 +872,23 @@ html, body, #root { width: 14px; height: 14px; border-radius: 50%; - background: #7dd3fc; - border: 2px solid #fff; + background: var(--accent-lighter); + border: 2px solid var(--marker-border); transform: translate(-50%, -50%); cursor: grab; - box-shadow: 0 0 4px rgba(0,0,0,0.6); + box-shadow: 0 0 4px var(--marker-shadow); z-index: 1; } .crop-marker:active:not(.crop-marker-locked) { cursor: grabbing; - background: #bae6fd; + background: var(--accent-lightest); transform: translate(-50%, -50%) scale(1.15); } .crop-marker-locked { - background: #e91e63; - border-color: #e91e63; + background: var(--danger-locked); + border-color: var(--danger-locked); cursor: default; opacity: 0.9; } @@ -768,8 +898,8 @@ html, body, #root { overflow: hidden; user-select: none; touch-action: none; - background: #0f172a; - border: 1px solid #334155; + background: var(--bg-deep); + border: 1px solid var(--border-default); border-radius: 6px; cursor: crosshair; } @@ -793,12 +923,12 @@ html, body, #root { .mask-paint-cursor { position: absolute; - border: 1.5px solid rgba(255, 255, 255, 0.95); + border: 1.5px solid var(--mask-cursor-border); border-radius: 50%; - background: rgba(255, 255, 255, 0.08); + background: var(--mask-cursor-bg); box-shadow: - 0 0 0 1px rgba(239, 68, 68, 0.85), - 0 0 10px rgba(15, 23, 42, 0.35); + 0 0 0 1px var(--mask-cursor-ring), + 0 0 10px var(--mask-cursor-shadow); transform: translate(-50%, -50%); pointer-events: none; z-index: 2; @@ -809,8 +939,8 @@ html, body, #root { overflow: hidden; user-select: none; touch-action: none; - background: #0f172a; - border: 1px solid #334155; + background: var(--bg-deep); + border: 1px solid var(--border-default); border-radius: 6px; cursor: crosshair; } @@ -843,9 +973,9 @@ html, body, #root { } .markup-tool-btn { - border: 1px solid rgba(148, 163, 184, 0.35); - background: rgba(15, 23, 42, 0.88); - color: #e2e8f0; + border: 1px solid var(--markup-btn-border); + background: var(--markup-btn-bg); + color: var(--text-bright); border-radius: 999px; padding: 4px 9px; font-size: 10px; @@ -880,9 +1010,9 @@ html, body, #root { .node-table-scroll { max-height: 220px; overflow: auto; - border: 1px solid #334155; + border: 1px solid var(--border-default); border-radius: 6px; - background: #0f172a; + background: var(--bg-deep); } .node-table-grid { @@ -890,7 +1020,7 @@ html, body, #root { border-collapse: collapse; font-family: "SF Mono", "Fira Code", monospace; font-size: 10px; - color: #cbd5e1; + color: var(--text-table); table-layout: auto; font-variant-numeric: tabular-nums lining-nums; } @@ -898,7 +1028,7 @@ html, body, #root { .node-table-grid th, .node-table-grid td { padding: 6px 8px; - border-bottom: 1px solid rgba(51, 65, 85, 0.75); + border-bottom: 1px solid var(--border-table); white-space: nowrap; text-align: left; vertical-align: top; @@ -908,15 +1038,15 @@ html, body, #root { position: sticky; top: 0; z-index: 1; - background: #16213e; - color: #94a3b8; + background: var(--bg-panel); + color: var(--text-secondary); font-size: 9px; letter-spacing: 0.04em; text-transform: uppercase; } .node-table-grid tbody tr:nth-child(even) { - background: rgba(30, 41, 59, 0.38); + background: var(--table-stripe); } .node-table-grid tbody tr:last-child td { @@ -945,10 +1075,10 @@ html, body, #root { align-self: flex-end; margin: 8px 10px 4px; padding: 3px 7px; - border: 1px solid rgba(148, 163, 184, 0.28); + border: 1px solid var(--benchmark-border); border-radius: 999px; - background: rgba(15, 23, 42, 0.92); - color: #94a3b8; + background: var(--benchmark-bg); + color: var(--text-secondary); font-family: "SF Mono", "Fira Code", monospace; font-size: 10px; line-height: 1; @@ -957,10 +1087,10 @@ html, body, #root { /* ── Node resize handles ───────────────────────────────────────────── */ .node-resize-line { - border-color: #90caf9 !important; + border-color: var(--selection) !important; } .node-resize-handle { - background: #90caf9 !important; + background: var(--selection) !important; width: 8px !important; height: 8px !important; } @@ -969,11 +1099,11 @@ html, body, #root { .context-menu { position: fixed; z-index: 1000; - background: #16213e; - border: 1px solid #0f3460; + background: var(--bg-panel); + border: 1px solid var(--border-strong); border-radius: 6px; min-width: 180px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + box-shadow: 0 4px 16px var(--shadow-heavy); padding: 4px 0; } @@ -981,29 +1111,29 @@ html, body, #root { padding: 6px 12px; font-size: 13px; font-weight: 600; - color: #94a3b8; - border-bottom: 1px solid #0f3460; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-strong); } .ctx-search-row { padding: 6px 8px; - border-bottom: 1px solid #0f3460; + border-bottom: 1px solid var(--border-strong); } .ctx-search { width: 100%; - background: #0f172a; - color: #e0e0e0; - border: 1px solid #334155; + background: var(--bg-deep); + color: var(--text-primary); + border: 1px solid var(--border-default); border-radius: 4px; padding: 4px 8px; font-size: 12px; outline: none; } .ctx-search:focus { - border-color: #3a7abf; + border-color: var(--accent); } .ctx-search::placeholder { - color: #475569; + color: var(--text-faint); } .ctx-list { @@ -1019,23 +1149,23 @@ html, body, #root { padding: 5px 12px; font-size: 12px; cursor: pointer; - color: #e0e0e0; + color: var(--text-primary); } .ctx-cat-item:hover, .ctx-cat-active { - background: #0f3460; + background: var(--accent-bg); } .ctx-cat-label { text-transform: capitalize; } .ctx-cat-arrow { font-size: 8px; - color: #64748b; + color: var(--text-muted); margin-left: 12px; flex-shrink: 0; } .ctx-cat-active .ctx-cat-arrow { - color: #e0e0e0; + color: var(--text-primary); } /* ── Submenu panel (separate fixed-position sibling) ── */ @@ -1049,43 +1179,43 @@ html, body, #root { padding: 5px 20px; font-size: 12px; cursor: pointer; - color: #e0e0e0; + color: var(--text-primary); white-space: nowrap; } .context-item:hover { - background: #0f3460; + background: var(--accent-bg); } /* ── File browser dialog ──────────────────────────────────────────── */ .fb-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: var(--bg-backdrop); z-index: 2000; display: flex; align-items: center; justify-content: center; } .fb-dialog { - background: #16213e; - border: 1px solid #0f3460; + background: var(--bg-panel); + border: 1px solid var(--border-strong); border-radius: 8px; width: 520px; max-height: 70vh; display: flex; flex-direction: column; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + box-shadow: 0 8px 32px var(--shadow-heavy); } .fb-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; - border-bottom: 1px solid #0f3460; + border-bottom: 1px solid var(--border-strong); } .fb-path { font-size: 12px; - color: #90caf9; + color: var(--accent-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1095,23 +1225,23 @@ html, body, #root { .fb-close { background: none; border: none; - color: #e0e0e0; + color: var(--text-primary); font-size: 16px; cursor: pointer; padding: 2px 6px; } -.fb-close:hover { color: #e94560; } +.fb-close:hover { color: var(--danger); } .fb-select-btn { - background: #0f3460; - color: #e0e0e0; - border: 1px solid #334155; + background: var(--accent-bg); + color: var(--text-primary); + border: 1px solid var(--border-default); border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; white-space: nowrap; } -.fb-select-btn:hover { background: #1a4a8a; } +.fb-select-btn:hover { background: var(--accent-hover); } .fb-list { overflow-y: auto; padding: 6px 0; @@ -1125,9 +1255,9 @@ html, body, #root { overflow: hidden; text-overflow: ellipsis; } -.fb-entry:hover { background: #0f3460; } -.fb-dir { color: #90caf9; } -.fb-file { color: #e0e0e0; } +.fb-entry:hover { background: var(--accent-bg); } +.fb-dir { color: var(--accent-light); } +.fb-file { color: var(--text-primary); } .fb-file-disabled { cursor: default; opacity: 0.5; @@ -1138,18 +1268,18 @@ html, body, #root { .fb-loading { padding: 16px; text-align: center; - color: #607d8b; + color: var(--text-disabled); } /* ── Scrollbar styling ─────────────────────────────────────────────── */ ::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: #1a1a2e; } -::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #3a7abf; } +::-webkit-scrollbar-track { background: var(--bg-app); } +::-webkit-scrollbar-thumb { background: var(--accent-bg); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--accent); } /* ── React Flow MiniMap ────────────────────────────────────────────── */ .react-flow__minimap { - background: #16213e !important; - border: 1px solid #0f3460 !important; + background: var(--bg-panel) !important; + border: 1px solid var(--border-strong) !important; border-radius: 4px !important; } diff --git a/frontend/src/workflowCapture.js b/frontend/src/workflowCapture.js index e5bd537..b75a17f 100644 --- a/frontend/src/workflowCapture.js +++ b/frontend/src/workflowCapture.js @@ -1,4 +1,5 @@ import { toBlob } from 'html-to-image'; +import { CANVAS_COLORS } from './constants'; export const OVERLAY_CAPTURE_SELECTORS = [ '.lineplot-overlay', @@ -115,7 +116,7 @@ async function renderElementToDataUrl(el, toBlobImpl) { const blob = await toBlobImpl(el, { width, height, - backgroundColor: '#0f172a', + backgroundColor: CANVAS_COLORS.bgDeep, style: { width: `${width}px`, height: `${height}px`, diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 1de3594..8e057c9 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -12,7 +12,7 @@ from pathlib import Path import numpy as np sys.path.insert(0, ".") -from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, render_datafield_preview +from backend.data_types import DataField, LineData, MeasureTable, RecordTable, datafield_to_uint8, render_datafield_preview def make_field(data=None, shape=(64, 64), xreal=1e-6, yreal=1e-6): @@ -518,7 +518,7 @@ def test_height_histogram(): Histogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data) Histogram._current_node_id = "test" - table, = node.process( + table, coord_pair = node.process( field, n_bins=10, y_scale="linear", @@ -527,6 +527,7 @@ def test_height_histogram(): x2=0.8, y2=0.5, ) + assert isinstance(coord_pair, tuple) and len(coord_pair) == 2 measurements = {row["quantity"]: row for row in table} assert "A position" in measurements assert "A count" in measurements @@ -565,24 +566,30 @@ def test_cross_section(): field = make_field(data=data, xreal=1e-6, yreal=1e-6) # Horizontal cross section at y=0.5 - (profile,) = node.process( + profile, marker_pair = node.process( field, x1=0.0, y1=0.5, x2=1.0, y2=0.5, extend="none", n_samples=100, ) + assert isinstance(marker_pair, tuple) and len(marker_pair) == 2 + assert isinstance(profile, LineData) assert len(profile) == 100 + assert profile.x_unit == field.si_unit_xy + assert profile.y_unit == field.si_unit_z + assert np.isclose(profile.x_axis[0], 0.0) + assert np.isclose(profile.x_axis[-1], field.xreal) # Profile should be a linear ramp from ~0 to ~10 assert profile[0] < 0.5, f"Start of profile: {profile[0]}" assert profile[-1] > 9.5, f"End of profile: {profile[-1]}" # n_samples=0 should auto-calculate - (profile_auto,) = node.process( + profile_auto, _ = node.process( field, x1=0.0, y1=0.5, x2=1.0, y2=0.5, extend="none", n_samples=0, ) assert len(profile_auto) >= 2 # Test extend to edges — a short segment should be extended - (profile_ext,) = node.process( + profile_ext, _ = node.process( field, x1=0.3, y1=0.5, x2=0.7, y2=0.5, extend="to_edges", n_samples=100, ) @@ -591,11 +598,29 @@ def test_cross_section(): assert profile_ext[-1] > 9.5 # Diagonal cross section - (profile_diag,) = node.process( + profile_diag, _ = node.process( field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, extend="none", n_samples=50, ) assert len(profile_diag) == 50 + + from backend.nodes.analysis import Cursors, Stats + + cursors = Cursors() + table, _ = cursors.process(profile, x1=0.25, y1=0.5, x2=0.75, y2=0.5) + rows = {row["quantity"]: row for row in table} + assert rows["dx"]["unit"] == field.si_unit_xy + assert rows["dy"]["unit"] == field.si_unit_z + + captured = [] + Stats._broadcast_value_fn = lambda nid, payload: captured.append(payload) + Stats._current_node_id = "test" + stats = Stats() + mean_value, = stats.process(profile, operation="mean", column="value") + assert mean_value > 0 + assert captured[-1]["unit"] == field.si_unit_z + Stats._broadcast_value_fn = None + print(" PASS\n") @@ -1629,7 +1654,8 @@ def test_line_cursors(): Cursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data) Cursors._current_node_id = "test" - table, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5) + table, coord_pair = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5) + assert isinstance(coord_pair, tuple) and len(coord_pair) == 2 # Should produce a 6-row table assert len(table) == 6 @@ -1656,9 +1682,9 @@ def test_line_cursors(): assert 0.0 <= overlays[0]["x1"] <= 1.0 assert 0.0 <= overlays[0]["x2"] <= 1.0 - # With x_axis provided - x_axis = np.linspace(0, 1, 100).astype(np.float64) - table2, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5, x_axis=x_axis) + # With LineData input (which carries its own x_axis) + line_data = LineData(data=line, x_axis=np.linspace(0, 1, 100)) + table2, _ = node.process(line_data, x1=0.25, y1=0.5, x2=0.75, y2=0.5) assert len(table2) == 6 # Field input should report dx/dy/dz and broadcast an image overlay @@ -1670,7 +1696,7 @@ def test_line_cursors(): si_unit_z="nm", ) overlays.clear() - table3, = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75) + table3, _ = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75) assert len(table3) == 9 field_rows = {row["quantity"]: row for row in table3} assert field_rows["dx"]["unit"] == "um"