diff --git a/backend/nodes/particle_analysis.py b/backend/nodes/particle_analysis.py
index 049fb9f..75f26aa 100644
--- a/backend/nodes/particle_analysis.py
+++ b/backend/nodes/particle_analysis.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
from backend.data_types import DataField, RecordTable
+from backend.nodes.helpers import _square_unit
@register_node(display_name="Particle Analysis")
@@ -33,6 +34,8 @@ class ParticleAnalysis:
labeled, n_particles = label(binary)
pixel_area = field.dx * field.dy
+ xy_unit = str(field.si_unit_xy or "").strip()
+ z_unit = str(field.si_unit_z or "").strip()
rows = RecordTable()
for pid in range(1, n_particles + 1):
@@ -54,10 +57,15 @@ class ParticleAnalysis:
rows.append({
"particle_id": pid,
"area_px": area_px,
+ "area_px_unit": _square_unit("px"),
"area_m2": area_m2,
+ "area_m2_unit": _square_unit(xy_unit),
"equiv_diam_m": equiv_diam,
+ "equiv_diam_m_unit": xy_unit,
"mean_height": mean_h,
+ "mean_height_unit": z_unit,
"max_height": max_h,
+ "max_height_unit": z_unit,
"bbox": bbox,
})
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a28f1ff..7884a75 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -30,6 +30,11 @@ import {
getAutoRunnableNodes,
hasBlockingAutoRunInput,
} from './executionGraph';
+import {
+ beginTrackedNodeRequest,
+ isTrackedNodeRequestCurrent,
+ resolveLoadNodeChannelPath,
+} from './loadNodeOutputs.js';
import {
DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS,
@@ -823,6 +828,7 @@ function Flow() {
const activeDragNodeIdRef = useRef(null);
const canvasRightZoomRef = useRef(null);
const suppressPaneContextMenuUntilRef = useRef(0);
+ const loadNodeOutputRequestVersionsRef = useRef(new Map());
const reactFlow = useReactFlow();
// ── WebSocket ───────────────────────────────────────────────────────
@@ -1163,26 +1169,26 @@ function Flow() {
const refreshLoadNodeOutputs = useCallback(async (nodeId, explicitPath = null) => {
const node = reactFlow.getNode(nodeId);
- if (!node) return;
+ const resolvedPath = resolveLoadNodeChannelPath({
+ explicitPath,
+ resolvedPathInput: getResolvedPathInput(nodeId),
+ className: node?.data?.className || '',
+ widgetValues: node?.data?.widgetValues || {},
+ });
+ const requestVersion = beginTrackedNodeRequest(loadNodeOutputRequestVersionsRef.current, nodeId);
- let resolvedPath = typeof explicitPath === 'string' && explicitPath ? explicitPath : null;
if (!resolvedPath) {
- resolvedPath = getResolvedPathInput(nodeId);
- }
- if (!resolvedPath) {
- if (node.data.className === 'Image') {
- resolvedPath = node.data.widgetValues?.filename || '';
- } else if (node.data.className === 'ImageDemo') {
- resolvedPath = node.data.widgetValues?.name || '';
+ if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) {
+ return;
}
- }
-
- if (!resolvedPath) {
setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] });
return;
}
const channels = await api.getChannels(resolvedPath);
+ if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) {
+ return;
+ }
setNodeOutputs(
nodeId,
channels.map((channel) => channel.type),
@@ -1625,17 +1631,21 @@ function Flow() {
setNodes((ns) => [...ns, newNode]);
// Initialize dynamic outputs for nodes that depend on the selected path/folder.
- if (className === 'Folder' && widgetValues.folder) {
- refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
- }
+ setTimeout(() => {
+ if (className === 'Folder' && widgetValues.folder) {
+ refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
+ }
- // For Image/ImageDemo, auto-fetch channels for the default value
- if (className === 'ImageDemo' && widgetValues.name) {
- refreshLoadNodeOutputs(newNodeId, widgetValues.name);
- }
- if (className === 'Image' && widgetValues.filename) {
- refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
- }
+ // For Image/ImageDemo, auto-fetch channels for the default value.
+ // Delay this until after the node exists in React Flow so the async
+ // response cannot be dropped on creation.
+ if (className === 'ImageDemo' && widgetValues.name) {
+ refreshLoadNodeOutputs(newNodeId, widgetValues.name);
+ }
+ if (className === 'Image' && widgetValues.filename) {
+ refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
+ }
+ }, 0);
// Auto-connect if this was triggered by dropping a connection on blank space
if (contextMenu.pendingHandleId) {
diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx
index df8ea01..854c8da 100644
--- a/frontend/src/CustomNode.jsx
+++ b/frontend/src/CustomNode.jsx
@@ -12,19 +12,13 @@ import {
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
} from './constants';
import { getGroupMinimumSize } from './groupSizing.js';
+import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js';
+import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns } from './valueFormatting.js';
// ── Context (provided by App) ─────────────────────────────────────────
export const NodeContext = React.createContext(null);
-function formatUiLabel(text) {
- return String(text ?? '')
- .replace(/_/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- .toLowerCase();
-}
-
function parseProxyHandle(handleId) {
const text = String(handleId || '');
if (!text.startsWith('group-proxy::')) return null;
@@ -421,17 +415,6 @@ function LayerGalleryPreview({ overlay }) {
);
}
-function getTableColumns(rows) {
- const columns = [];
- for (const row of rows) {
- if (!row || typeof row !== 'object') continue;
- for (const key of Object.keys(row)) {
- if (!columns.includes(key)) columns.push(key);
- }
- }
- return columns;
-}
-
function getMeasurementChoices(rows) {
const names = [];
for (const row of rows || []) {
@@ -443,85 +426,6 @@ function getMeasurementChoices(rows) {
return names;
}
-const SI_PREFIXES = [
- { exp: -24, prefix: 'y' },
- { exp: -21, prefix: 'z' },
- { exp: -18, prefix: 'a' },
- { exp: -15, prefix: 'f' },
- { exp: -12, prefix: 'p' },
- { exp: -9, prefix: 'n' },
- { exp: -6, prefix: 'u' },
- { exp: -3, prefix: 'm' },
- { exp: 0, prefix: '' },
- { exp: 3, prefix: 'k' },
- { exp: 6, prefix: 'M' },
- { exp: 9, prefix: 'G' },
- { exp: 12, prefix: 'T' },
- { exp: 15, prefix: 'P' },
- { exp: 18, prefix: 'E' },
- { exp: 21, prefix: 'Z' },
- { exp: 24, prefix: 'Y' },
-];
-
-const PREFIXABLE_UNITS = new Set([
- 'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω',
-]);
-
-function formatNumericCell(value) {
- if (value == null) return '';
- if (typeof value === 'number') {
- if (!Number.isFinite(value)) return String(value);
- const abs = Math.abs(value);
- if (Number.isInteger(value) && abs < 1e6) return String(value);
- if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3);
- return value.toFixed(4).replace(/\.?0+$/, '');
- }
- if (Array.isArray(value)) return value.join(', ');
- return String(value);
-}
-
-function applySIPrefix(value, unit) {
- if (typeof value !== 'number' || !Number.isFinite(value)) {
- return { valueText: formatNumericCell(value), unitText: unit };
- }
- if (typeof unit !== 'string' || !PREFIXABLE_UNITS.has(unit)) {
- return { valueText: formatNumericCell(value), unitText: unit };
- }
- if (value === 0) {
- return { valueText: '0', unitText: unit };
- }
-
- const abs = Math.abs(value);
- let exp = Math.floor(Math.log10(abs) / 3) * 3;
- exp = Math.max(-24, Math.min(24, exp));
-
- let scaled = value / (10 ** exp);
- if (Math.abs(scaled) >= 999.5 && exp < 24) {
- exp += 3;
- scaled = value / (10 ** exp);
- }
-
- const prefix = SI_PREFIXES.find((entry) => entry.exp === exp)?.prefix ?? '';
- return {
- valueText: formatNumericCell(scaled),
- unitText: `${prefix}${unit}`,
- };
-}
-
-function formatTableCell(value) {
- return formatNumericCell(value);
-}
-
-function formatTableRowCell(row, column) {
- if (column === 'value' && typeof row?.unit === 'string') {
- return applySIPrefix(row?.value, row.unit).valueText;
- }
- if (column === 'unit' && typeof row?.unit === 'string') {
- return applySIPrefix(row?.value, row.unit).unitText;
- }
- return formatTableCell(row?.[column]);
-}
-
function formatScalarValue(value) {
if (value == null || Number.isNaN(Number(value))) return '—';
const numeric = Number(value);
@@ -550,8 +454,8 @@ function formatScalarDisplay(scalarValue) {
if (!payload) return null;
if (payload.unit) {
- if (PREFIXABLE_UNITS.has(payload.unit)) {
- const prefixed = applySIPrefix(payload.value, payload.unit);
+ const prefixed = applySIPrefix(payload.value, payload.unit);
+ if (prefixed.unitText !== payload.unit || prefixed.valueText !== formatNumericCell(payload.value)) {
return {
valueText: prefixed.valueText,
unitText: prefixed.unitText,
@@ -1061,20 +965,24 @@ function CustomNode({ id, data }) {
}
}
- const visibleWidgets = widgets.filter((w) => (
+ const dataInputByName = new Map(dataInputs.map((input) => [input.name, input]));
+
+ const widgetsVisibleByDefinition = widgets.filter((w) => (
widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)])
&& widgetVisibleForWidgetValues(w, data.widgetValues)
&& widgetVisibleForInputVisibility(w, visibleInputNames)
- && !widgetHiddenByConnectedInput(w, connectedInputs)
+ ));
+ const combinedInputNameByWidgetName = buildCombinedInputNameByWidgetName(
+ widgetsVisibleByDefinition,
+ dataInputs,
+ );
+ const visibleWidgets = widgetsVisibleByDefinition.filter((widget) => (
+ combinedInputNameByWidgetName.has(widget.name)
+ || !widgetHiddenByConnectedInput(widget, connectedInputs)
));
- const combinedTopInputNames = new Set(
- visibleWidgets
- .map((widget) => widget?.opts?.top_socket_input)
- .filter((name) => typeof name === 'string' && name.length > 0),
- );
- const renderedDataInputs = dataInputs.filter((input) => !combinedTopInputNames.has(input.name));
- const dataInputByName = new Map(dataInputs.map((input) => [input.name, input]));
+ const combinedInputNames = new Set(combinedInputNameByWidgetName.values());
+ const renderedDataInputs = dataInputs.filter((input) => !combinedInputNames.has(input.name));
const inlineWidgetsByInput = new Map();
const topWidgets = [];
@@ -1141,14 +1049,14 @@ function CustomNode({ id, data }) {
{topWidgets.length > 0 && (
- {topWidgets.map((w) => (
-
- {(w.socketType || w.opts?.top_socket_input) && (() => {
- const socketInput = w.opts?.top_socket_input ? dataInputByName.get(w.opts.top_socket_input) : null;
- const socketType = w.socketType || socketInput?.type;
- const socketName = w.socketType ? w.name : socketInput?.name;
- if (!socketType || !socketName) return null;
- return (
+ {topWidgets.map((w) => {
+ const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null;
+ const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null;
+ const socketType = w.socketType || socketInput?.type;
+ const socketName = w.socketType ? w.name : socketInput?.name;
+ return (
+
+ {socketType && socketName && (
- );
- })()}
- {!!(
- (w.socketType && connectedInputs?.has(w.name))
- || (w.opts?.top_socket_input && connectedInputs?.has(w.opts.top_socket_input))
- ) ? (
-
- ) : (
-
- )}
-
- ))}
+ )}
+ {!!(
+ (w.socketType && connectedInputs?.has(w.name))
+ || (combinedInputName && connectedInputs?.has(combinedInputName))
+ ) ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
)}
@@ -1247,31 +1155,38 @@ function CustomNode({ id, data }) {
)}
{/* Widget rows */}
- {standaloneWidgets.map((w) => (
-
- {w.socketType && (
-
- )}
- {w.socketType && connectedInputs?.has(w.name) ? (
-
- ) : (
-
- )}
-
- ))}
+ {standaloneWidgets.map((w) => {
+ const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null;
+ const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null;
+ const socketType = w.socketType || socketInput?.type;
+ const socketName = w.socketType ? w.name : socketInput?.name;
+ return (
+
+ {socketType && socketName && (
+
+ )}
+ {(w.socketType && connectedInputs?.has(w.name))
+ || (combinedInputName && connectedInputs?.has(combinedInputName)) ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
{/* Manual trigger button (Save) */}
{def.manual_trigger && (
diff --git a/frontend/src/loadNodeOutputs.js b/frontend/src/loadNodeOutputs.js
new file mode 100644
index 0000000..bd50b60
--- /dev/null
+++ b/frontend/src/loadNodeOutputs.js
@@ -0,0 +1,30 @@
+export function resolveLoadNodeChannelPath({
+ explicitPath = null,
+ resolvedPathInput = null,
+ className = '',
+ widgetValues = {},
+} = {}) {
+ if (typeof explicitPath === 'string' && explicitPath) {
+ return explicitPath;
+ }
+ if (typeof resolvedPathInput === 'string' && resolvedPathInput) {
+ return resolvedPathInput;
+ }
+ if (className === 'Image') {
+ return String(widgetValues?.filename || '');
+ }
+ if (className === 'ImageDemo') {
+ return String(widgetValues?.name || '');
+ }
+ return '';
+}
+
+export function beginTrackedNodeRequest(requestVersions, nodeId) {
+ const nextVersion = (requestVersions.get(nodeId) || 0) + 1;
+ requestVersions.set(nodeId, nextVersion);
+ return nextVersion;
+}
+
+export function isTrackedNodeRequestCurrent(requestVersions, nodeId, version) {
+ return requestVersions.get(nodeId) === version;
+}
diff --git a/frontend/src/nodeWidgetLayout.js b/frontend/src/nodeWidgetLayout.js
new file mode 100644
index 0000000..4e7a3d5
--- /dev/null
+++ b/frontend/src/nodeWidgetLayout.js
@@ -0,0 +1,49 @@
+export function formatUiLabel(text) {
+ return String(text ?? '')
+ .replace(/_/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .toLowerCase();
+}
+
+function normalizeInputNames(raw) {
+ if (!raw) return [];
+ return (Array.isArray(raw) ? raw : [raw])
+ .map((value) => String(value))
+ .filter((value) => value.length > 0);
+}
+
+export function getWidgetCombinedInputName(widget, dataInputByName) {
+ const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0];
+ if (explicitInputName && dataInputByName?.has(explicitInputName)) {
+ return explicitInputName;
+ }
+
+ const widgetLabel = formatUiLabel(widget?.opts?.label || widget?.name);
+ if (!widgetLabel) return null;
+
+ for (const inputName of normalizeInputNames(widget?.opts?.hide_when_input_connected)) {
+ const input = dataInputByName?.get(inputName);
+ if (!input) continue;
+ const inputLabel = formatUiLabel(input.label || input.name);
+ if (inputLabel === widgetLabel) {
+ return input.name;
+ }
+ }
+
+ return null;
+}
+
+export function buildCombinedInputNameByWidgetName(widgets, dataInputs) {
+ const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input]));
+ const combinedInputNameByWidgetName = new Map();
+
+ for (const widget of widgets || []) {
+ const combinedInputName = getWidgetCombinedInputName(widget, dataInputByName);
+ if (combinedInputName) {
+ combinedInputNameByWidgetName.set(widget.name, combinedInputName);
+ }
+ }
+
+ return combinedInputNameByWidgetName;
+}
diff --git a/frontend/src/valueFormatting.js b/frontend/src/valueFormatting.js
new file mode 100644
index 0000000..e1356e8
--- /dev/null
+++ b/frontend/src/valueFormatting.js
@@ -0,0 +1,171 @@
+const SI_PREFIXES = [
+ { exp: -24, prefix: 'y' },
+ { exp: -21, prefix: 'z' },
+ { exp: -18, prefix: 'a' },
+ { exp: -15, prefix: 'f' },
+ { exp: -12, prefix: 'p' },
+ { exp: -9, prefix: 'n' },
+ { exp: -6, prefix: 'u' },
+ { exp: -3, prefix: 'm' },
+ { exp: 0, prefix: '' },
+ { exp: 3, prefix: 'k' },
+ { exp: 6, prefix: 'M' },
+ { exp: 9, prefix: 'G' },
+ { exp: 12, prefix: 'T' },
+ { exp: 15, prefix: 'P' },
+ { exp: 18, prefix: 'E' },
+ { exp: 21, prefix: 'Z' },
+ { exp: 24, prefix: 'Y' },
+];
+
+const PREFIXABLE_UNITS = new Set([
+ 'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω',
+]);
+
+const SUPERSCRIPT_DIGITS = {
+ '-': '⁻',
+ '0': '⁰',
+ '1': '¹',
+ '2': '²',
+ '3': '³',
+ '4': '⁴',
+ '5': '⁵',
+ '6': '⁶',
+ '7': '⁷',
+ '8': '⁸',
+ '9': '⁹',
+};
+
+export function formatNumericCell(value) {
+ if (value == null) return '';
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) return String(value);
+ const abs = Math.abs(value);
+ if (Number.isInteger(value) && abs < 1e6) return String(value);
+ if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3);
+ return value.toFixed(4).replace(/\.?0+$/, '');
+ }
+ if (Array.isArray(value)) return value.join(', ');
+ return String(value);
+}
+
+function toSuperscript(text) {
+ return String(text)
+ .split('')
+ .map((char) => SUPERSCRIPT_DIGITS[char] || char)
+ .join('');
+}
+
+export function formatDisplayUnit(unit) {
+ return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent));
+}
+
+function parsePrefixableUnit(unit) {
+ const text = String(unit ?? '').trim();
+ if (!text) return null;
+
+ const poweredMatch = text.match(/^([A-Za-zΩ]+)\^([1-9]\d*)$/);
+ if (poweredMatch) {
+ const [, baseUnit, powerText] = poweredMatch;
+ if (!PREFIXABLE_UNITS.has(baseUnit)) return null;
+ return { baseUnit, power: Number.parseInt(powerText, 10) };
+ }
+
+ if (!PREFIXABLE_UNITS.has(text)) return null;
+ return { baseUnit: text, power: 1 };
+}
+
+function formatPrefixedUnit(baseUnit, prefix, power) {
+ return power === 1 ? `${prefix}${baseUnit}` : `${prefix}${baseUnit}${toSuperscript(power)}`;
+}
+
+function choosePrefixExponent(value, power) {
+ const abs = Math.abs(value);
+ const candidates = SI_PREFIXES.map(({ exp, prefix }) => {
+ const scaled = value / (10 ** (exp * power));
+ return {
+ exp,
+ prefix,
+ scaled,
+ absScaled: Math.abs(scaled),
+ };
+ });
+
+ const inRange = candidates.filter(({ absScaled }) => absScaled >= 1 && absScaled < 999.5);
+ if (inRange.length > 0) {
+ return inRange.reduce((best, candidate) => (candidate.absScaled < best.absScaled ? candidate : best));
+ }
+
+ const aboveOne = candidates.filter(({ absScaled }) => absScaled >= 1);
+ if (aboveOne.length > 0) {
+ return aboveOne.reduce((best, candidate) => (candidate.absScaled < best.absScaled ? candidate : best));
+ }
+
+ return candidates.reduce((best, candidate) => (candidate.absScaled > best.absScaled ? candidate : best));
+}
+
+export function applySIPrefix(value, unit) {
+ const formattedUnit = formatDisplayUnit(unit);
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return { valueText: formatNumericCell(value), unitText: formattedUnit };
+ }
+ if (value === 0) {
+ return { valueText: '0', unitText: formattedUnit };
+ }
+
+ const parsedUnit = parsePrefixableUnit(unit);
+ if (!parsedUnit) {
+ return { valueText: formatNumericCell(value), unitText: formattedUnit };
+ }
+
+ const chosenPrefix = choosePrefixExponent(value, parsedUnit.power);
+ return {
+ valueText: formatNumericCell(chosenPrefix.scaled),
+ unitText: formatPrefixedUnit(parsedUnit.baseUnit, chosenPrefix.prefix, parsedUnit.power),
+ };
+}
+
+function getCompanionUnitColumn(column, row) {
+ if (!row || typeof row !== 'object' || typeof column !== 'string' || column === 'unit') {
+ return null;
+ }
+ const unitColumn = `${column}_unit`;
+ return typeof row?.[unitColumn] === 'string' ? unitColumn : null;
+}
+
+export function getTableColumns(rows) {
+ const columns = [];
+ const hiddenColumns = new Set();
+
+ for (const row of rows || []) {
+ if (!row || typeof row !== 'object') continue;
+ for (const key of Object.keys(row)) {
+ if (
+ key.endsWith('_unit')
+ && key.length > 5
+ && Object.prototype.hasOwnProperty.call(row, key.slice(0, -5))
+ ) {
+ hiddenColumns.add(key);
+ continue;
+ }
+ if (!columns.includes(key)) columns.push(key);
+ }
+ }
+
+ return columns.filter((column) => !hiddenColumns.has(column));
+}
+
+export function formatTableRowCell(row, column) {
+ const companionUnitColumn = getCompanionUnitColumn(column, row);
+ if (companionUnitColumn) {
+ const formatted = applySIPrefix(row?.[column], row?.[companionUnitColumn]);
+ return formatted.unitText ? `${formatted.valueText} ${formatted.unitText}` : formatted.valueText;
+ }
+ if (column === 'value' && typeof row?.unit === 'string') {
+ return applySIPrefix(row?.value, row.unit).valueText;
+ }
+ if (column === 'unit' && typeof row?.unit === 'string') {
+ return applySIPrefix(row?.value, row.unit).unitText;
+ }
+ return formatNumericCell(row?.[column]);
+}
diff --git a/frontend/tests/loadNodeOutputs.test.mjs b/frontend/tests/loadNodeOutputs.test.mjs
new file mode 100644
index 0000000..0eb4b71
--- /dev/null
+++ b/frontend/tests/loadNodeOutputs.test.mjs
@@ -0,0 +1,40 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ beginTrackedNodeRequest,
+ isTrackedNodeRequestCurrent,
+ resolveLoadNodeChannelPath,
+} from '../src/loadNodeOutputs.js';
+
+test('resolveLoadNodeChannelPath can resolve a new ImageDemo node from its explicit selection before mount', () => {
+ const resolvedPath = resolveLoadNodeChannelPath({
+ explicitPath: 'APL_Figure4.ibw',
+ className: '',
+ widgetValues: {},
+ });
+
+ assert.equal(resolvedPath, 'APL_Figure4.ibw');
+});
+
+test('resolveLoadNodeChannelPath falls back to the current widget value for load nodes', () => {
+ assert.equal(resolveLoadNodeChannelPath({
+ className: 'Image',
+ widgetValues: { filename: 'scan.ibw' },
+ }), 'scan.ibw');
+
+ assert.equal(resolveLoadNodeChannelPath({
+ className: 'ImageDemo',
+ widgetValues: { name: 'demo.ibw' },
+ }), 'demo.ibw');
+});
+
+test('tracked load-node requests ignore stale async responses', () => {
+ const requestVersions = new Map();
+
+ const first = beginTrackedNodeRequest(requestVersions, '42');
+ const second = beginTrackedNodeRequest(requestVersions, '42');
+
+ assert.equal(isTrackedNodeRequestCurrent(requestVersions, '42', first), false);
+ assert.equal(isTrackedNodeRequestCurrent(requestVersions, '42', second), true);
+});
diff --git a/frontend/tests/nodeWidgetLayout.test.mjs b/frontend/tests/nodeWidgetLayout.test.mjs
new file mode 100644
index 0000000..11d2f1d
--- /dev/null
+++ b/frontend/tests/nodeWidgetLayout.test.mjs
@@ -0,0 +1,52 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ buildCombinedInputNameByWidgetName,
+ getWidgetCombinedInputName,
+} from '../src/nodeWidgetLayout.js';
+
+test('getWidgetCombinedInputName pairs same-label hide_when_input_connected widgets with their matching input', () => {
+ const dataInputByName = new Map([
+ ['colormap_map', { name: 'colormap_map', type: 'COLORMAP', label: 'colormap' }],
+ ]);
+
+ const combinedInputName = getWidgetCombinedInputName({
+ name: 'colormap',
+ opts: { hide_when_input_connected: 'colormap_map' },
+ }, dataInputByName);
+
+ assert.equal(combinedInputName, 'colormap_map');
+});
+
+test('getWidgetCombinedInputName leaves unrelated hidden inputs as standalone rows', () => {
+ const dataInputByName = new Map([
+ ['path', { name: 'path', type: 'FILE_PATH', label: 'path' }],
+ ]);
+
+ const combinedInputName = getWidgetCombinedInputName({
+ name: 'filename',
+ opts: { hide_when_input_connected: 'path' },
+ }, dataInputByName);
+
+ assert.equal(combinedInputName, null);
+});
+
+test('buildCombinedInputNameByWidgetName preserves explicit top_socket_input pairings', () => {
+ const combinedInputNameByWidgetName = buildCombinedInputNameByWidgetName(
+ [
+ {
+ name: 'directory',
+ opts: {
+ top_socket_input: 'directory',
+ hide_when_input_connected: 'directory',
+ },
+ },
+ ],
+ [
+ { name: 'directory', type: 'STRING', label: 'directory' },
+ ],
+ );
+
+ assert.equal(combinedInputNameByWidgetName.get('directory'), 'directory');
+});
diff --git a/frontend/tests/valueFormatting.test.mjs b/frontend/tests/valueFormatting.test.mjs
new file mode 100644
index 0000000..a970e84
--- /dev/null
+++ b/frontend/tests/valueFormatting.test.mjs
@@ -0,0 +1,47 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ applySIPrefix,
+ formatDisplayUnit,
+ formatTableRowCell,
+ getTableColumns,
+} from '../src/valueFormatting.js';
+
+test('getTableColumns hides companion record-table unit columns', () => {
+ const columns = getTableColumns([
+ {
+ particle_id: 1,
+ area_m2: 2.5e-12,
+ area_m2_unit: 'm^2',
+ mean_height: 1.5e-9,
+ mean_height_unit: 'm',
+ bbox: '(0,0)-(2,2)',
+ },
+ ]);
+
+ assert.deepEqual(columns, ['particle_id', 'area_m2', 'mean_height', 'bbox']);
+});
+
+test('formatTableRowCell appends companion units inline for record-table values', () => {
+ const row = {
+ area_m2: 2.5e-12,
+ area_m2_unit: 'm^2',
+ mean_height: 1.5e-9,
+ mean_height_unit: 'm',
+ };
+
+ assert.equal(formatTableRowCell(row, 'area_m2'), '2.5 um²');
+ assert.equal(formatTableRowCell(row, 'mean_height'), '1.5 nm');
+});
+
+test('formatDisplayUnit renders exponent markers as superscripts', () => {
+ assert.equal(formatDisplayUnit('px^2'), 'px²');
+ assert.equal(formatDisplayUnit('(V)^2 m^2'), '(V)² m²');
+});
+
+test('applySIPrefix scales squared units using squared SI prefixes', () => {
+ assert.deepEqual(applySIPrefix(3e-18, 'm^2'), { valueText: '3', unitText: 'nm²' });
+ assert.deepEqual(applySIPrefix(3e-9, 'm^2'), { valueText: '3000', unitText: 'um²' });
+ assert.deepEqual(applySIPrefix(144, 'px^2'), { valueText: '144', unitText: 'px²' });
+});
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
index 90623e9..6b835c5 100644
--- a/tests/test_nodes.py
+++ b/tests/test_nodes.py
@@ -867,6 +867,11 @@ def test_particle_analysis():
assert table[1]["area_px"] == 64 # 8x8
assert abs(table[0]["mean_height"] - 5.0) < 1e-10
assert abs(table[1]["mean_height"] - 3.0) < 1e-10
+ assert table[0]["area_px_unit"] == "px^2"
+ assert table[0]["area_m2_unit"] == "m^2"
+ assert table[0]["equiv_diam_m_unit"] == "m"
+ assert table[0]["mean_height_unit"] == "m"
+ assert table[0]["max_height_unit"] == "m"
# min_size filtering: only keep particles >= 80 px
table_filtered, = node.process(field, mask=mask, min_size=80)