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}
>