diff --git a/backend/data_types.py b/backend/data_types.py index e199387..b06152d 100644 --- a/backend/data_types.py +++ b/backend/data_types.py @@ -31,11 +31,11 @@ CUSTOM_FILE_FONT = "Custom File" PREVIEW_MARKUP_REFERENCE_DIM = 512 -class RecordTable(list): +class DataTable(list): """Tabular rows with a shared schema, e.g. grain statistics.""" -class MeasureTable(list): +class RecordTable(list): """Named scalar measurements, typically rows of quantity/value/unit.""" diff --git a/backend/execution.py b/backend/execution.py index 0365bed..7fbcf12 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -318,7 +318,7 @@ class ExecutionEngine: def _fingerprint_bytes(self, value: Any) -> bytes: import numpy as np - from backend.data_types import DataField, ImageData, LineData, MeasureTable, MeshModel, RecordTable + from backend.data_types import DataField, ImageData, LineData, RecordTable, MeshModel, DataTable if value is None: return b"null" @@ -381,7 +381,7 @@ class ExecutionEngine: ).encode() return b"|".join([b"ndarray", header, memoryview(array).tobytes()]) - if isinstance(value, (MeasureTable, RecordTable, list)): + if isinstance(value, (RecordTable, DataTable, list)): return b"[" + b",".join(self._fingerprint_bytes(item) for item in value) + b"]" if isinstance(value, tuple): @@ -494,7 +494,7 @@ class ExecutionEngine: on_preview(node_id, preview) return - if type_name in ("TABLE", "MEASURE_TABLE", "RECORD_TABLE") and isinstance(value, list) and on_table: + if type_name in ("TABLE", "RECORD_TABLE", "DATA_TABLE") and isinstance(value, list) and on_table: on_table(node_id, value) return diff --git a/backend/nodes/acf_1d.py b/backend/nodes/acf_1d.py index c866cdb..0fc9961 100644 --- a/backend/nodes/acf_1d.py +++ b/backend/nodes/acf_1d.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import LineData, MeasureTable +from backend.data_types import LineData, RecordTable from backend.nodes.spectral_common import acf_line_from_data @@ -34,7 +34,7 @@ class ACF1D: OUTPUTS = ( ('LINE', 'acf'), - ('MEASURE_TABLE', 'measurement'), + ('RECORD_TABLE', 'measurement'), ) FUNCTION = "process" @@ -58,4 +58,4 @@ class ACF1D: if peak_lag is not None: rows.append({"quantity": "Peak period", "value": peak_lag, "unit": x_unit}) - return (acf_line, MeasureTable(rows)) + return (acf_line, RecordTable(rows)) diff --git a/backend/nodes/angle_measure.py b/backend/nodes/angle_measure.py index 1f01ff7..104e139 100644 --- a/backend/nodes/angle_measure.py +++ b/backend/nodes/angle_measure.py @@ -5,7 +5,7 @@ import numpy as np from backend.data_types import ( DataField, ImageData, - MeasureTable, + RecordTable, _apply_angle_measure_overlay, encode_preview, image_metadata, @@ -114,7 +114,7 @@ class AngleMeasure: OUTPUTS = ( ('ANNOTATION_SOURCE', 'output'), - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ) FUNCTION = "process" @@ -189,7 +189,7 @@ class AngleMeasure: stroke_width=resolved_stroke_width, color=resolved_color, ) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Angle", "value": angle_deg, "unit": "deg"}, {"quantity": "Arm A length", "value": length_a, "unit": length_unit}, {"quantity": "Arm B length", "value": length_b, "unit": length_unit}, diff --git a/backend/nodes/cursors.py b/backend/nodes/cursors.py index c551c81..38e891e 100644 --- a/backend/nodes/cursors.py +++ b/backend/nodes/cursors.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay -from backend.data_types import DataField, LineData, MeasureTable, encode_preview, render_datafield_preview +from backend.data_types import DataField, LineData, RecordTable, encode_preview, render_datafield_preview @register_node(display_name="Cursors") @@ -28,7 +28,7 @@ class Cursors: } OUTPUTS = ( - ('MEASURE_TABLE', 'measurement'), + ('RECORD_TABLE', 'measurement'), ('COORDPAIR', 'coord_pair'), ) FUNCTION = "process" @@ -107,7 +107,7 @@ class Cursors: }) length = float(np.hypot(xb - xa, yb - ya)) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Length", "value": length, "unit": x_unit}, {"quantity": "dx", "value": xb - xa, "unit": x_unit}, {"quantity": "dy", "value": yb - ya, "unit": y_unit}, @@ -159,7 +159,7 @@ class Cursors: "b_locked": locked, }) - table = MeasureTable([ + table = RecordTable([ {"quantity": "A x", "value": ax, "unit": field.si_unit_xy}, {"quantity": "A y", "value": ay, "unit": field.si_unit_xy}, {"quantity": "A z", "value": z1, "unit": field.si_unit_z}, diff --git a/backend/nodes/curvature.py b/backend/nodes/curvature.py index 3bc8e8e..643bdfe 100644 --- a/backend/nodes/curvature.py +++ b/backend/nodes/curvature.py @@ -8,7 +8,7 @@ from scipy.ndimage import map_coordinates from backend.data_types import ( DataField, LineData, - MeasureTable, + RecordTable, _apply_markup_overlay, encode_preview, render_datafield_preview, @@ -289,7 +289,7 @@ class Curvature: OUTPUTS = ( ('ANNOTATION_SOURCE', 'output'), - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ('LINE', 'profile_1'), ('LINE', 'profile_2'), ) @@ -313,7 +313,7 @@ class Curvature: if results is None: emit_warning("Curvature requires at least six usable pixels for the quadratic fit.") - table = MeasureTable([]) + table = RecordTable([]) emit_table(table) emit_preview(encode_preview(render_datafield_preview(field, field.colormap))) empty = _empty_profile(field.si_unit_xy, field.si_unit_z) @@ -345,7 +345,7 @@ class Curvature: markup_spec = _curvature_markup(field, results["x0"], results["y0"], intersections) output = field.replace(overlays=[*field.overlays, markup_spec]) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Center x position", "value": float(results["x0"]), "unit": field.si_unit_xy}, {"quantity": "Center y position", "value": float(results["y0"]), "unit": field.si_unit_xy}, {"quantity": "Center value", "value": float(results["z0"]), "unit": field.si_unit_z}, diff --git a/backend/nodes/fft_1d.py b/backend/nodes/fft_1d.py index 160d9ec..7a29a14 100644 --- a/backend/nodes/fft_1d.py +++ b/backend/nodes/fft_1d.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import LineData, MeasureTable +from backend.data_types import LineData, RecordTable @register_node(display_name="FFT 1D") @@ -21,7 +21,7 @@ class FFT1D: OUTPUTS = ( ("LINE", "frequency_plot"), - ('MEASURE_TABLE', 'measurement'), + ('RECORD_TABLE', 'measurement'), ) FUNCTION = "process" @@ -55,7 +55,7 @@ class FFT1D: peak_period = float(period_axis[np.argmax(spectrum)]) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Peak period", "value": peak_period, "unit": spatial_unit}, ]) diff --git a/backend/nodes/fractal_dimension.py b/backend/nodes/fractal_dimension.py index 4a0bef7..cb0dad9 100644 --- a/backend/nodes/fractal_dimension.py +++ b/backend/nodes/fractal_dimension.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import numpy as np from scipy.ndimage import map_coordinates -from backend.data_types import LineData, MeasureTable +from backend.data_types import LineData, RecordTable from backend.execution_context import emit_overlay, emit_table, emit_warning from backend.node_registry import register_node @@ -307,7 +307,7 @@ class FractalDimension: OUTPUTS = ( ('FLOAT', 'dimension'), ('LINE', 'curve'), - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ) FUNCTION = "process" @@ -348,7 +348,7 @@ class FractalDimension: dimension = float("nan") emit_warning("Fractal fit range contains fewer than two usable points.") - table = MeasureTable([ + table = RecordTable([ {"quantity": "Dimension", "value": float(dimension), "unit": ""}, {"quantity": "Fit slope", "value": float(slope), "unit": ""}, {"quantity": "Fit intercept", "value": float(intercept), "unit": ""}, diff --git a/backend/nodes/grain_analysis.py b/backend/nodes/grain_analysis.py index 39b5361..f91c249 100644 --- a/backend/nodes/grain_analysis.py +++ b/backend/nodes/grain_analysis.py @@ -1,7 +1,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.data_types import DataField, DataTable from backend.nodes.helpers import _square_unit @@ -18,7 +18,7 @@ class GrainAnalysis: } OUTPUTS = ( - ('RECORD_TABLE', 'grain_stats'), + ('DATA_TABLE', 'grain_stats'), ) FUNCTION = "process" @@ -38,7 +38,7 @@ class GrainAnalysis: xy_unit = str(field.si_unit_xy or "").strip() z_unit = str(field.si_unit_z or "").strip() - rows = RecordTable() + rows = DataTable() for gid in range(1, n_grains + 1): grain_pixels = labeled == gid area_px = int(grain_pixels.sum()) diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index 1e3191c..4f610c4 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay -from backend.data_types import DataField, MeasureTable +from backend.data_types import DataField, RecordTable @register_node(display_name="Histogram") @@ -22,7 +22,7 @@ class Histogram: } OUTPUTS = ( - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ('COORDPAIR', 'marker_pair'), ) FUNCTION = "process" @@ -88,7 +88,7 @@ class Histogram: "b_locked": False, }) - table = MeasureTable([ + table = RecordTable([ {"quantity": "delta Y", "value": yb - ya, "unit": count_unit}, {"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z}, {"quantity": "A position", "value": xa, "unit": field.si_unit_z}, diff --git a/backend/nodes/ibw_note.py b/backend/nodes/ibw_note.py index e2ac97d..7711be5 100644 --- a/backend/nodes/ibw_note.py +++ b/backend/nodes/ibw_note.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from backend.node_registry import register_node -from backend.data_types import MeasureTable +from backend.data_types import DataTable from backend.nodes.helpers import _resolve_path, _import_ibw_loader @@ -22,14 +22,10 @@ def _parse_ibw_note(note_bytes: bytes) -> list[dict]: if not match: continue key = match.group(1).strip() - raw_val = match.group(2).strip() + value = match.group(2).strip() if not key: continue - try: - value = float(raw_val) - except (ValueError, TypeError): - continue - rows.append({"quantity": key, "value": value, "unit": ""}) + rows.append({"key": key, "value": value}) return rows @@ -48,13 +44,13 @@ class IBWNote: } OUTPUTS = ( - ('MEASURE_TABLE', 'note'), + ('DATA_TABLE', 'note'), ) FUNCTION = "load" DESCRIPTION = ( - "Read the Note metadata from an .ibw file and display numeric entries " - "as a measurement table. Non-numeric note entries are skipped." + "Read the Note metadata from an .ibw file and display all entries " + "as a table of key/value pairs." ) def load(self, filename: str = "", path: str | None = None) -> tuple: @@ -73,6 +69,6 @@ class IBWNote: rows = _parse_ibw_note(note_bytes) if not rows: - raise ValueError("No numeric metadata found in the .ibw note.") + raise ValueError("No metadata found in the .ibw note.") - return (MeasureTable(rows),) + return (DataTable(rows),) diff --git a/backend/nodes/image.py b/backend/nodes/image.py index 6bd38ad..a072ba7 100644 --- a/backend/nodes/image.py +++ b/backend/nodes/image.py @@ -2,6 +2,8 @@ from __future__ import annotations from functools import lru_cache import numpy as np from pathlib import Path +import nanonispy as nap +import gwyfile from backend.node_registry import register_node from backend.execution_context import emit_warning @@ -25,6 +27,7 @@ class Image: } OUTPUTS = ( + ("FILE_PATH", 'path'), ('DATA_FIELD', 'field'), ) FUNCTION = "load" @@ -65,7 +68,7 @@ class Image: if ext not in _SPM_EXTENSIONS: self._send_warning("Uncalibrated data — no physical dimensions.") - return fields + return (str(path_obj.resolve()),) + fields def _send_warning(self, message: str): emit_warning(message) @@ -92,11 +95,6 @@ class Image: @staticmethod def _load_gwy_all(path: Path) -> list[DataField]: - try: - import gwyfile - except ImportError: - raise ImportError("Install 'gwyfile' package to load .gwy files: pip install gwyfile") - obj = gwyfile.load(str(path)) channels = gwyfile.util.get_datafields(obj) if not channels: @@ -118,11 +116,6 @@ class Image: @staticmethod def _load_sxm_all(path: Path) -> list[DataField]: - try: - import nanonispy as nap - except ImportError: - raise ImportError("Install 'nanonispy' package to load .sxm files: pip install nanonispy") - sxm = nap.read.Scan(str(path)) signals = sxm.signals if not signals: diff --git a/backend/nodes/preview_image.py b/backend/nodes/preview_image.py index 1ab5c0c..bfcd9c4 100644 --- a/backend/nodes/preview_image.py +++ b/backend/nodes/preview_image.py @@ -45,8 +45,20 @@ class PreviewImage: input=None, colormap_map=None, ) -> tuple: - field = input if isinstance(input, DataField) else None - image = None if field is not None else input + if isinstance(input, DataField): + field = input + image = None + elif isinstance(input, np.ndarray): + field = None + image = input + elif input is not None: + raise TypeError( + f"Preview expects an IMAGE or DATA_FIELD — got {type(input).__name__}. " + "Check that you are connected to the DATA_FIELD output, not the path socket." + ) + else: + field = None + image = None resolved_colormap = resolve_colormap_input( colormap, diff --git a/backend/nodes/print_table.py b/backend/nodes/print_table.py index 15ebfa2..e6caf53 100644 --- a/backend/nodes/print_table.py +++ b/backend/nodes/print_table.py @@ -9,8 +9,8 @@ class PrintTable: def INPUT_TYPES(cls): return { "required": { - "table": ("MEASURE_TABLE", { - "accepted_types": ["RECORD_TABLE"], + "table": ("RECORD_TABLE", { + "accepted_types": ["DATA_TABLE"], }), } } diff --git a/backend/nodes/save.py b/backend/nodes/save.py index 4257ce7..be13b14 100644 --- a/backend/nodes/save.py +++ b/backend/nodes/save.py @@ -35,8 +35,8 @@ class Save: "IMAGE", "ANNOTATION_SOURCE", "LINE", - "MEASURE_TABLE", "RECORD_TABLE", + "DATA_TABLE", "MESH_MODEL", "FLOAT", ], @@ -48,8 +48,8 @@ class Save: "IMAGE": ["PNG", "TIFF", "NPZ"], "ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"], "LINE": ["CSV", "NPZ", "JSON"], - "MEASURE_TABLE": ["CSV", "JSON"], "RECORD_TABLE": ["CSV", "JSON"], + "DATA_TABLE": ["CSV", "JSON"], "FLOAT": ["TXT", "JSON"], "MESH_MODEL": ["OBJ", "STL"], }, diff --git a/backend/nodes/statistics_node.py b/backend/nodes/statistics_node.py index c89e18d..2e26059 100644 --- a/backend/nodes/statistics_node.py +++ b/backend/nodes/statistics_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import DataField, MeasureTable +from backend.data_types import DataField, RecordTable @register_node(display_name="Statistics") @@ -15,7 +15,7 @@ class Statistics: } OUTPUTS = ( - ('MEASURE_TABLE', 'stats'), + ('RECORD_TABLE', 'stats'), ) FUNCTION = "process" @@ -31,7 +31,7 @@ class Statistics: skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0 kurtosis = float(np.mean(((d - mean) / rms) ** 4)) if rms > 0 else 0.0 - table = MeasureTable([ + table = RecordTable([ {"quantity": "min", "value": float(d.min()), "unit": field.si_unit_z}, {"quantity": "max", "value": float(d.max()), "unit": field.si_unit_z}, {"quantity": "mean", "value": mean, "unit": field.si_unit_z}, diff --git a/backend/nodes/stats.py b/backend/nodes/stats.py index 77e5850..05c83c1 100644 --- a/backend/nodes/stats.py +++ b/backend/nodes/stats.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_value -from backend.data_types import DataField, LineData, MeasureTable +from backend.data_types import DataField, LineData, RecordTable from backend.nodes.helpers import ( LINE_OPS, TABLE_OPS, @@ -17,7 +17,7 @@ from backend.nodes.helpers import ( @register_node(display_name="Stats") class Stats: - """Polymorphic scalar stats node for LINE, RECORD_TABLE, DATA_FIELD, or IMAGE inputs.""" + """Polymorphic scalar stats node for LINE, DATA_TABLE, DATA_FIELD, or IMAGE inputs.""" _broadcast_value_fn = None _current_node_id: str = "" @@ -27,20 +27,20 @@ class Stats: return { "required": { "input": ("DATA_FIELD", { - "accepted_types": ["IMAGE", "LINE", "RECORD_TABLE"], + "accepted_types": ["IMAGE", "LINE", "DATA_TABLE"], }), "column": ("STRING", { "default": "value", "choices_from_table_input": "input", "show_when_source_type": { - "input": ["RECORD_TABLE"], + "input": ["DATA_TABLE"], }, }), "operation": ("STRING", { "default": "mean", "choices_by_source_type": { "LINE": list(LINE_OPS.keys()), - "RECORD_TABLE": list(TABLE_OPS.keys()), + "DATA_TABLE": list(TABLE_OPS.keys()), "DATA_FIELD": list(ARRAY_OPS.keys()), "IMAGE": list(ARRAY_OPS.keys()), }, @@ -62,7 +62,7 @@ class Stats: def process(self, input, operation: str, column: str = "value") -> tuple: source_type, values, resolved_column = self._resolve_input_values(input, column) - if source_type == "RECORD_TABLE": + if source_type == "DATA_TABLE": ops = TABLE_OPS elif source_type == "LINE": ops = LINE_OPS @@ -93,7 +93,7 @@ class Stats: return _apply_scalar_unit(input_value.y_unit, operation) return "" - if source_type == "RECORD_TABLE" and isinstance(input_value, list) and column: + if source_type == "DATA_TABLE" and isinstance(input_value, list) and column: return _apply_scalar_unit(_common_table_unit(input_value, column), operation) return "" @@ -103,7 +103,7 @@ class Stats: values = np.asarray(input_value.data, dtype=np.float64) return ("DATA_FIELD", values.ravel(), None) - if isinstance(input_value, MeasureTable): + if isinstance(input_value, RecordTable): raise ValueError("Stats only accepts record tables, not measurement tables.") if isinstance(input_value, list): @@ -113,7 +113,7 @@ class Stats: values = extract_numeric_table_values(input_value, column_name) if not values: raise ValueError(f"Column '{column_name}' has no numeric values.") - return ("RECORD_TABLE", np.asarray(values, dtype=np.float64), column_name) + return ("DATA_TABLE", np.asarray(values, dtype=np.float64), column_name) if isinstance(input_value, LineData): values = np.asarray(input_value.data, dtype=np.float64) diff --git a/backend/nodes/value_display.py b/backend/nodes/value_display.py index e8fccee..2eecaa7 100644 --- a/backend/nodes/value_display.py +++ b/backend/nodes/value_display.py @@ -1,7 +1,7 @@ from __future__ import annotations from backend.node_registry import register_node from backend.execution_context import emit_value -from backend.data_types import MeasureTable +from backend.data_types import RecordTable from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload @@ -12,13 +12,13 @@ class ValueDisplay: return { "required": { "value": ("FLOAT", { - "accepted_types": ["MEASURE_TABLE"], + "accepted_types": ["RECORD_TABLE"], }), "measurement": ("STRING", { "default": "", "choices_from_measure_input": "value", "show_when_source_type": { - "value": ["MEASURE_TABLE"], + "value": ["RECORD_TABLE"], }, }), } @@ -36,7 +36,7 @@ class ValueDisplay: def display_value(self, value, measurement: str = "") -> tuple: unit = "" - if isinstance(value, MeasureTable): + if isinstance(value, RecordTable): row = _measurement_entry(value, measurement) numeric = _measurement_value(value, measurement) unit = row.get("unit", "") if isinstance(row.get("unit"), str) else "" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0732bc7..06a2f87 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1271,7 +1271,7 @@ function Flow() { if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) { return; } - setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] }); + setNodeOutputs(nodeId, ['FILE_PATH', 'DATA_FIELD'], ['path', 'field'], { output_paths: [] }); return; } @@ -1281,8 +1281,8 @@ function Flow() { } setNodeOutputs( nodeId, - channels.map((channel) => channel.type), - channels.map((channel) => channel.name), + ['FILE_PATH', ...channels.map((channel) => channel.type)], + ['path', ...channels.map((channel) => channel.name)], { output_paths: [] }, ); }, [getResolvedPathInput, reactFlow, setNodeOutputs]); diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 8e8ecf9..d97adc5 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -786,6 +786,17 @@ function ColorMapStopsEditor({ nodeId, name, value, onChange }) { } function NodeTable({ rows }) { + const [query, setQuery] = useState(''); + const scrollRef = useRef(null); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const handler = (e) => e.stopPropagation(); + el.addEventListener('wheel', handler, { passive: false }); + return () => el.removeEventListener('wheel', handler); + }, []); + const columns = getTableColumns(rows); if (columns.length === 0) return null; const lowerColumns = columns.map((column) => String(column).toLowerCase()); @@ -804,9 +815,32 @@ function NodeTable({ rows }) { return ''; }; + const filteredRows = query.trim() + ? rows.filter((row) => + columns.some((col) => { + const cell = formatTableRowCell(row, col); + return String(cell).toLowerCase().includes(query.toLowerCase()); + }) + ) + : rows; + return (
-
+ {rows.length > 5 && ( +
e.stopPropagation()} + > + setQuery(e.target.value)} + /> +
+ )} +
{hasMeasurementLayout && ( @@ -823,7 +857,7 @@ function NodeTable({ rows }) { - {rows.map((row, rowIndex) => ( + {filteredRows.map((row, rowIndex) => ( {columns.map((column) => { const value = row?.[column]; @@ -1379,7 +1413,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile const tableInputName = opts?.choices_from_table_input; if (!tableInputName) return []; const sourceType = getSourceTypeForInput(s, nodeId, tableInputName); - if (sourceType !== 'RECORD_TABLE') return []; + if (sourceType !== 'DATA_TABLE') return []; const sourceNode = getSourceNodeForInput(s, nodeId, tableInputName); const rows = sourceNode?.data?.tableRows; return Array.isArray(rows) ? getTableColumns(rows) : []; @@ -1393,7 +1427,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile const measurementInputName = opts?.choices_from_measure_input; if (!measurementInputName) return []; const sourceType = getSourceTypeForInput(s, nodeId, measurementInputName); - if (sourceType !== 'MEASURE_TABLE') return []; + if (sourceType !== 'RECORD_TABLE') return []; const sourceNode = getSourceNodeForInput(s, nodeId, measurementInputName); const rows = sourceNode?.data?.tableRows; return Array.isArray(rows) ? getMeasurementChoices(rows) : []; diff --git a/frontend/src/constants.js b/frontend/src/constants.js index fc1c0ae..d1df73c 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -1,7 +1,7 @@ // ── Shared type & color constants ───────────────────────────────────── export const DATA_TYPES = new Set([ - 'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', + 'DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE', 'DATA_TABLE', 'COORD', 'ANNOTATION_SOURCE', 'COLORMAP', 'MESH_MODEL', 'FONT', 'FILE_PATH', 'DIRECTORY', 'COORDPAIR', ]); @@ -12,8 +12,8 @@ export const TYPE_COLORS = { DATA_FIELD: '#3a7abf', IMAGE: '#00ff08a0', LINE: '#ffbe5c', - MEASURE_TABLE: '#35e2fd', - RECORD_TABLE: '#ff7474', + RECORD_TABLE: '#35e2fd', + DATA_TABLE: '#ff7474', COORD: '#e91ed1', COORDPAIR: '#5cb861', FLOAT: '#ab3197', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e1b5a1a..09f4763 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1296,6 +1296,26 @@ html, body, #root { padding: 4px 10px 8px; } +.node-table-search { + padding: 0 0 4px; +} + +.node-table-search-input { + width: 100%; + box-sizing: border-box; + padding: 3px 7px; + font-size: 11px; + background: var(--bg-deep); + border: 1px solid var(--border-default); + border-radius: 4px; + color: var(--text-primary); + outline: none; +} + +.node-table-search-input:focus { + border-color: var(--accent); +} + .node-table-scroll { max-height: 220px; overflow: auto; @@ -1310,7 +1330,7 @@ html, body, #root { font-family: "SF Mono", "Fira Code", monospace; font-size: 10px; color: var(--text-table); - table-layout: auto; + table-layout: fixed; font-variant-numeric: tabular-nums lining-nums; } @@ -1319,6 +1339,8 @@ html, body, #root { padding: 6px 8px; border-bottom: 1px solid var(--border-table); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; text-align: left; vertical-align: top; } diff --git a/frontend/tests/constants.test.mjs b/frontend/tests/constants.test.mjs index ce4fb6b..d22f034 100644 --- a/frontend/tests/constants.test.mjs +++ b/frontend/tests/constants.test.mjs @@ -19,13 +19,13 @@ test('retired save alias types are no longer first-class socket types', () => { }); test('accepted_types extend canonical socket compatibility without reintroducing alias types', () => { - const spec = ['MEASURE_TABLE', { accepted_types: ['RECORD_TABLE'] }]; + const spec = ['RECORD_TABLE', { accepted_types: ['DATA_TABLE'] }]; assert.equal(isDataSocketSpec(spec), true); assert.deepEqual( Array.from(getAcceptedSocketTypes(spec)).sort(), - ['MEASURE_TABLE', 'RECORD_TABLE'], + ['RECORD_TABLE', 'DATA_TABLE'], ); - assert.equal(socketSpecAcceptsType('RECORD_TABLE', spec), true); + assert.equal(socketSpecAcceptsType('DATA_TABLE', spec), true); assert.equal(socketSpecAcceptsType('LINE', spec), false); }); diff --git a/frontend/tests/executionGraph.test.mjs b/frontend/tests/executionGraph.test.mjs index 258bf9e..a4aee59 100644 --- a/frontend/tests/executionGraph.test.mjs +++ b/frontend/tests/executionGraph.test.mjs @@ -487,7 +487,7 @@ test('serializeExecutionGraph treats accepted_types inputs as sockets, not widge className: 'TableSource', definition: { input: { required: {}, optional: {} }, - output: ['RECORD_TABLE'], + output: ['DATA_TABLE'], output_name: ['rows'], manual_trigger: false, }, @@ -501,7 +501,7 @@ test('serializeExecutionGraph treats accepted_types inputs as sockets, not widge definition: { input: { required: { - table: ['MEASURE_TABLE', { accepted_types: ['RECORD_TABLE'] }], + table: ['RECORD_TABLE', { accepted_types: ['DATA_TABLE'] }], }, optional: {}, }, @@ -514,9 +514,9 @@ test('serializeExecutionGraph treats accepted_types inputs as sockets, not widge const edges = [ { source: '1', - sourceHandle: 'output::0::RECORD_TABLE', + sourceHandle: 'output::0::DATA_TABLE', target: '2', - targetHandle: 'input::table::MEASURE_TABLE', + targetHandle: 'input::table::RECORD_TABLE', }, ]; @@ -542,7 +542,7 @@ test('hasBlockingAutoRunInput still blocks unconnected accepted_types sockets', manual_trigger: false, input: { required: { - input: ['DATA_FIELD', { accepted_types: ['IMAGE', 'LINE', 'RECORD_TABLE'] }], + input: ['DATA_FIELD', { accepted_types: ['IMAGE', 'LINE', 'DATA_TABLE'] }], }, optional: {}, }, @@ -556,7 +556,7 @@ test('hasBlockingAutoRunInput still blocks unconnected accepted_types sockets', hasBlockingAutoRunInput(node, [ { source: '1', - sourceHandle: 'output::0::RECORD_TABLE', + sourceHandle: 'output::0::DATA_TABLE', target: '2', targetHandle: 'input::input::DATA_FIELD', }, diff --git a/frontend/tests/nodeWidgetDefaults.test.mjs b/frontend/tests/nodeWidgetDefaults.test.mjs index 8ae06ee..c22df55 100644 --- a/frontend/tests/nodeWidgetDefaults.test.mjs +++ b/frontend/tests/nodeWidgetDefaults.test.mjs @@ -16,7 +16,7 @@ test('buildDefaultWidgetValues keeps non-data required widget defaults', () => { input: { required: { input: ['ANNOTATION_SOURCE', { label: 'Input' }], - table: ['MEASURE_TABLE', { accepted_types: ['RECORD_TABLE'] }], + table: ['RECORD_TABLE', { accepted_types: ['DATA_TABLE'] }], shape: [['line', 'rectangle', 'circle', 'arrow'], { default: 'arrow' }], stroke_color: ['STRING', { default: '#ff0000', color_picker: true }], stroke_width: ['INT', { default: 3 }], diff --git a/tests/test_nodes.py b/tests/test_nodes.py index b7fa58c..945258a 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, LineData, MeasureTable, RecordTable, datafield_to_uint8, render_datafield_preview +from backend.data_types import DataField, LineData, RecordTable, DataTable, datafield_to_uint8, render_datafield_preview def make_field(data=None, shape=(64, 64), xreal=1e-6, yreal=1e-6): @@ -2000,8 +2000,8 @@ def test_print_table(): node = PrintTable() table_spec = PrintTable.INPUT_TYPES()["required"]["table"] - assert table_spec[0] == "MEASURE_TABLE" - assert table_spec[1]["accepted_types"] == ["RECORD_TABLE"] + assert table_spec[0] == "RECORD_TABLE" + assert table_spec[1]["accepted_types"] == ["DATA_TABLE"] captured = [] PrintTable._broadcast_table_fn = lambda node_id, rows: captured.append(rows) @@ -2023,7 +2023,7 @@ def test_value_display(): node = ValueDisplay() value_spec = ValueDisplay.INPUT_TYPES()["required"]["value"] assert value_spec[0] == "FLOAT" - assert value_spec[1]["accepted_types"] == ["MEASURE_TABLE"] + assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"] captured = [] ValueDisplay._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) @@ -2033,7 +2033,7 @@ def test_value_display(): assert result == (3.25,) assert captured == [("test", {"value": 3.25})] - measurements = MeasureTable([ + measurements = RecordTable([ {"quantity": "delta X", "value": 1.7e-7, "unit": "m"}, {"quantity": "delta Y", "value": 463, "unit": "count"}, ]) @@ -2845,7 +2845,7 @@ def test_stats(): node = Stats() input_spec = Stats.INPUT_TYPES()["required"]["input"] assert input_spec[0] == "DATA_FIELD" - assert input_spec[1]["accepted_types"] == ["IMAGE", "LINE", "RECORD_TABLE"] + assert input_spec[1]["accepted_types"] == ["IMAGE", "LINE", "DATA_TABLE"] captured = [] Stats._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) @@ -2858,7 +2858,7 @@ def test_stats(): roughness, = node.process(line, operation="Rq", column="value") assert np.isclose(roughness, np.sqrt(np.mean((line - line.mean()) ** 2))) - table = RecordTable([ + table = DataTable([ {"name": "a", "value": 3.0, "unit": "m", "other": 10.0}, {"name": "b", "value": 7.0, "unit": "m", "other": 20.0}, ]) @@ -2894,7 +2894,7 @@ def test_stats(): try: node.process( - MeasureTable([{"quantity": "min", "value": 1.0, "unit": "m"}]), + RecordTable([{"quantity": "min", "value": 1.0, "unit": "m"}]), operation="max", column="value", ) @@ -3026,7 +3026,7 @@ def test_view3d(): def test_save_generic(): print("=== Test: Save ===") from backend.nodes.save import Save - from backend.data_types import DataField, ImageData, LineData, MeasureTable, MeshModel, RecordTable + from backend.data_types import DataField, ImageData, LineData, RecordTable, MeshModel, DataTable import tifffile from PIL import Image as PILImage @@ -3037,8 +3037,8 @@ def test_save_generic(): "IMAGE", "ANNOTATION_SOURCE", "LINE", - "MEASURE_TABLE", "RECORD_TABLE", + "DATA_TABLE", "MESH_MODEL", "FLOAT", ] @@ -3137,7 +3137,7 @@ def test_save_generic(): assert np.array_equal(annotation_npz["image"], image) # Save tables as CSV and JSON - measure_table = MeasureTable([ + measure_table = RecordTable([ {"quantity": "Rq", "value": 1.23, "unit": "nm"}, {"quantity": "Ra", "value": 0.98, "unit": "nm"}, ]) @@ -3148,7 +3148,7 @@ def test_save_generic(): node.save(filename="measurements_json", directory_path=tmpdir, format="JSON", value=measure_table) assert json.loads(Path(tmpdir, "measurements_json.json").read_text(encoding="utf-8")) == list(measure_table) - record_table = RecordTable([ + record_table = DataTable([ {"label": "particle-1", "height": 12.0, "area": 44.0}, {"label": "particle-2", "height": 8.0, "area": 21.0}, ])