diff --git a/.gitignore b/.gitignore index abd32e8..5d28cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -*__pycache__* \ No newline at end of file +*__pycache__* +frontend/node_modules/ +frontend/dist/ \ No newline at end of file diff --git a/GWYDDION_FEATURE_GAP.md b/GWYDDION_FEATURE_GAP.md new file mode 100644 index 0000000..b393f82 --- /dev/null +++ b/GWYDDION_FEATURE_GAP.md @@ -0,0 +1,87 @@ +# Gwyddion Features Not Yet in Argonode + +Reference for future implementation. Grouped by value to typical SPM workflows. + +--- + +## High Value + +| # | Feature | Gwyddion Source | Description | +|---|---------|---------------|-------------| +| 1 | Line Correction | linecorrect.c, linematch.c | Row-by-row median/polynomial alignment. Essential for raw SPM data with scan-line artifacts. | +| 2 | Scar Removal | scars.c | Detect and interpolate scan-line defects (horizontal streaks). | +| 3 | Facet Leveling | facet-level.c | Orient the dominant surface facet to horizontal. Better than plane level for terraced/stepped surfaces. | +| 4 | Morphological Mask Ops | mask_morph.c | Erode, dilate, open, close on grain masks. Needed to clean up thresholded masks. | +| 5 | 1D FFT Filter | fft_filter_1d.c | Bandpass/lowpass/highpass filtering of LINE profiles. | +| 6 | 2D FFT Filter | fft_filter_2d.c | Frequency-domain filtering of DATA_FIELDs (remove periodic noise, etc.). | +| 7 | Autocorrelation (ACF) | acf2d.c | 2D autocorrelation function. Reveals periodic structures and correlation lengths. | +| 8 | PSDF | psdf2d.c | Radial/2D power spectral density function. Complementary to ACF for roughness characterization. | +| 9 | Fractal Dimension | fractal.c | Multiple methods: partitioning, cube counting, triangulation, PSDF, HHCF. Quantifies surface complexity. | +| 10 | Curvature | curvature.c | Local mean/Gaussian curvature maps. Useful for feature identification. | +| 11 | Grain Distance Transform | mask_edt.c | Euclidean distance from grain boundaries. Useful for spatial distribution analysis. | +| 12 | Watershed Segmentation | grain_wshed.c | Automatic grain detection without manual threshold. More robust than simple thresholding. | +| 13 | Rotate / Flip | rotate.c, basicops.c | Basic geometric transforms (90°, arbitrary angle, mirror). | +| 14 | Crop | crop.c | Extract sub-region of a field. | + +## Medium Value + +| # | Feature | Gwyddion Source | Description | +|---|---------|---------------|-------------| +| 15 | Correlation / Pattern Matching | crosscor.c, maskcor.c | Find repeated features or align images via cross-correlation. | +| 16 | Slope Distribution | slope_dist.c | Angular histogram of surface slopes. Characterizes surface texture directionality. | +| 17 | Grain Filtering | grain_filter.c | Remove grains by size, height, or border contact. Refine grain masks post-detection. | +| 18 | Field Arithmetic | arithmetic.c | Add/subtract/multiply/divide two DATA_FIELDs. Useful for difference maps, normalization. | +| 19 | Spot Removal | spotremove.c | Interpolate over selected point defects (dust, spikes). | +| 20 | Tip Modeling / Deconvolution | tip_blind.c, tip_model.c | Estimate tip shape from image, deconvolve to recover true surface. | +| 21 | Radial Profile | rprofile tool | Azimuthally averaged profile from a center point. Good for circular features. | +| 22 | Wavelet Transform | dwt.c, cwt.c | Discrete/continuous wavelet analysis. Multi-scale roughness decomposition. | +| 23 | Scale / Resample | scale.c, resample.c | Resize fields with interpolation. | +| 24 | Gradient | gradient.c | Compute x/y gradient magnitude maps. | +| 25 | Custom Convolution | convolution_filter.c | User-defined kernel convolution. | +| 26 | Local Contrast Enhancement | local_contrast.c | Enhance visibility of local features in images. | + +## Lower Priority + +| # | Feature | Gwyddion Source | Description | +|---|---------|---------------|-------------| +| 27 | Drift Correction | drift.c | Compensate for thermal/piezo drift between scan lines. | +| 28 | Affine / Perspective Correction | correct_affine.c, correct_perspective.c | Fix geometric distortions from scanner nonlinearity. | +| 29 | MFM Analysis | mfm_*.c | Magnetic force microscopy: field calculation, shift finding. | +| 30 | Lattice Measurement | measure_lattice.c | Detect and measure periodic lattice structures from ACF/FFT. | +| 31 | Hough Transform | hough.c | Detect lines and circles in images. | +| 32 | Image Stitching / Merging | merge.c, stitch.c | Combine multiple overlapping scans into one image. | +| 33 | Facet Analysis | facet_analysis.c | Orientation distribution of surface facets (stereographic projection). | +| 34 | Shape Fitting | fit-shape.c | Fit geometric primitives: sphere, paraboloid, cylinder, etc. | +| 35 | Synthetic Surface Generation | *_synth.c (~20 modules) | Generate test surfaces: FBM, noise, lattice, waves, particles, fibers, etc. | +| 36 | Entropy | entropy.c | Information entropy of height distribution. | +| 37 | Indentation Analysis | indent_analyze.c, hertz.c | Nanoindentation curve fitting (Hertz model). | +| 38 | Deconvolution | deconvolve.c | Blind/regularized deconvolution for image restoration. | +| 39 | Canny / Harris Detection | filters.c | Corner and edge feature detection beyond basic Sobel/Prewitt. | +| 40 | Kuwahara Filter | filters.c | Edge-preserving smoothing filter. | + +--- + +## Already Implemented in Argonode + +For reference, these Gwyddion equivalents are already covered: + +| Argonode Node | Category | Gwyddion Equivalent | +|--------------|----------|-------------------| +| Load Image / Load SPM File | io | File import (gwy, sxm, ibw) | +| Save Image | io | File export | +| Coordinate | io | — | +| Plane Level | level | level.c | +| Polynomial Level | level | polylevel.c | +| Fix Zero | level | level.c (fix_zero) | +| Gaussian Filter | filters | filters.c (gaussian) | +| Median Filter | filters | filters.c (median) | +| Edge Detect | filters | edge.c (sobel, prewitt, laplacian, LoG) | +| Statistics | analysis | stats.c | +| Height Histogram | analysis | linestats.c (dh) | +| 2D FFT | analysis | fft.c | +| Cross Section | analysis | profile tool | +| Profile Roughness | analysis | roughness.c (Ra, Rq, Rsk, Rku, Rp, Rv, Rt) | +| Line Math | analysis | linestats.c | +| Threshold Mask | grains | threshold.c, otsu_threshold.c | +| Grain Analysis | grains | grain_stat.c | +| Preview / 3D View / Print Table | display | Presentation, 3D view | diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/data_types.py b/backend/data_types.py new file mode 100644 index 0000000..f1bdbba --- /dev/null +++ b/backend/data_types.py @@ -0,0 +1,134 @@ +""" +Core data types for argonode. + +DataField mirrors Gwyddion's GwyDataField structure: + xres, yres – pixel dimensions + xreal, yreal – physical dimensions in metres + xoff, yoff – position offset in metres + si_unit_xy – lateral unit string (e.g. "m", "nm") + si_unit_z – height/value unit string (e.g. "m", "V", "A") + domain – "spatial" or "frequency" (set by FFT nodes) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import numpy as np + + +@dataclass +class DataField: + data: np.ndarray # shape (yres, xres), dtype float64 + xres: int = 0 + yres: int = 0 + xreal: float = 1e-6 # physical width in metres + yreal: float = 1e-6 # physical height in metres + xoff: float = 0.0 + yoff: float = 0.0 + si_unit_xy: str = "m" + si_unit_z: str = "m" + domain: str = "spatial" # "spatial" or "frequency" + + def __post_init__(self) -> None: + self.data = np.asarray(self.data, dtype=np.float64) + if self.data.ndim != 2: + raise ValueError(f"DataField.data must be 2-D, got shape {self.data.shape}") + self.yres, self.xres = self.data.shape + + def copy(self) -> "DataField": + """Return a deep copy with independent data array.""" + return DataField( + data=self.data.copy(), + xres=self.xres, + yres=self.yres, + xreal=self.xreal, + yreal=self.yreal, + xoff=self.xoff, + yoff=self.yoff, + si_unit_xy=self.si_unit_xy, + si_unit_z=self.si_unit_z, + domain=self.domain, + ) + + def replace(self, **kwargs) -> "DataField": + """Return a copy with selected fields replaced. data is deep-copied unless provided.""" + base = { + "data": self.data.copy(), + "xres": self.xres, + "yres": self.yres, + "xreal": self.xreal, + "yreal": self.yreal, + "xoff": self.xoff, + "yoff": self.yoff, + "si_unit_xy": self.si_unit_xy, + "si_unit_z": self.si_unit_z, + "domain": self.domain, + } + base.update(kwargs) + return DataField(**base) + + @property + def dx(self) -> float: + """Physical pixel size in x (metres).""" + return self.xreal / self.xres if self.xres else 1.0 + + @property + def dy(self) -> float: + """Physical pixel size in y (metres).""" + return self.yreal / self.yres if self.yres else 1.0 + + +# --------------------------------------------------------------------------- +# Utility helpers shared across nodes +# --------------------------------------------------------------------------- + +def datafield_to_uint8(df: DataField, colormap: str = "gray") -> np.ndarray: + """ + Normalize a DataField to a uint8 (H, W, 3) RGB array using matplotlib colormap. + Returns shape (H, W, 3) uint8. + """ + import matplotlib.cm as cm + import matplotlib.colors as mcolors + + data = df.data + dmin, dmax = data.min(), data.max() + if dmax > dmin: + normalized = (data - dmin) / (dmax - dmin) + else: + normalized = np.zeros_like(data) + + cmap = cm.get_cmap(colormap) + rgba = cmap(normalized) # (H, W, 4) float [0,1] + rgb = (rgba[:, :, :3] * 255).astype(np.uint8) + return rgb + + +def image_to_uint8(image: np.ndarray) -> np.ndarray: + """ + Convert an IMAGE (float or uint8, 2-D or 3-D) to uint8 (H,W,3) or (H,W) for PIL. + """ + if image.dtype == np.uint8: + return image + # float — normalize to [0, 255] + imin, imax = image.min(), image.max() + if imax > imin: + out = (image - imin) / (imax - imin) * 255.0 + else: + out = np.zeros_like(image) + return out.astype(np.uint8) + + +def encode_preview(arr: np.ndarray) -> str: + """ + Encode a uint8 numpy array as a base64 data URI (PNG). + arr: (H, W) grayscale or (H, W, 3) RGB, uint8. + """ + import base64 + import io + from PIL import Image + + img = Image.fromarray(arr) + buf = io.BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/png;base64,{b64}" diff --git a/backend/execution.py b/backend/execution.py new file mode 100644 index 0000000..cb11b62 --- /dev/null +++ b/backend/execution.py @@ -0,0 +1,294 @@ +""" +Graph execution engine for argonode. + +Prompt format (same as ComfyUI): + { + "node_id": { + "class_type": "GaussianFilter", + "inputs": { + "field": ["upstream_node_id", 0], # link: [src_id, output_slot] + "sigma": 2.0 # constant widget value + } + }, + ... + } + +The engine: +1. Topologically sorts nodes (Kahn's algorithm). +2. Resolves input links to actual Python objects from earlier outputs. +3. Calls each node's FUNCTION method. +4. Emits progress callbacks after each node. +""" + +from __future__ import annotations +import uuid +from collections import defaultdict, deque +from typing import Any, Callable + +from backend.node_registry import NODE_CLASS_MAPPINGS + + +def _is_link(value: Any) -> bool: + """A value is a link if it's a [node_id_str, slot_int] pair.""" + return ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], int) + ) + + +class ExecutionEngine: + """Synchronous (blocking) graph executor. Run inside a thread pool from async code.""" + + def execute( + self, + prompt: dict[str, dict], + on_node_start: Callable[[str], None] | None = None, + on_node_done: Callable[[str], None] | None = None, + on_preview: Callable[[str, str], None] | None = None, + on_table: Callable[[str, list], None] | None = None, + on_mesh: Callable[[str, dict], None] | None = None, + on_overlay: Callable[[str, str], None] | None = None, + ) -> dict[str, tuple]: + """ + Execute the workflow described by `prompt`. + + Parameters + ---------- + prompt : workflow dict (node_id → {class_type, inputs}) + on_node_start : called with node_id just before a node executes + on_node_done : called with node_id just after a node executes + on_preview : called with (node_id, data_uri) when a display node runs + on_table : called with (node_id, table_list) when PrintTable runs + on_overlay : called with (node_id, data_uri) for interactive overlays + + Returns + ------- + node_outputs : {node_id → tuple-of-outputs} for every executed node + """ + order = self._topological_sort(prompt) + node_outputs: dict[str, tuple] = {} + + # Inject display callbacks before execution + self._inject_display_callbacks(on_preview, on_table, on_mesh, on_overlay) + + for node_id in order: + node_def = prompt[node_id] + class_name = node_def["class_type"] + + if class_name not in NODE_CLASS_MAPPINGS: + raise ValueError(f"Unknown node type: '{class_name}'") + + cls = NODE_CLASS_MAPPINGS[class_name] + raw_inputs = node_def.get("inputs", {}) + inputs = self._resolve_inputs(raw_inputs, node_outputs) + + # Let display nodes know their node_id so they can tag WS messages + self._set_node_id_on_display(cls, node_id) + + if on_node_start: + on_node_start(node_id) + + instance = cls() + func = getattr(instance, cls.FUNCTION) + result = func(**inputs) + + # Nodes must return a tuple; coerce single values just in case + if not isinstance(result, tuple): + result = (result,) + + node_outputs[node_id] = result + + # Auto-preview: broadcast a thumbnail for any DATA_FIELD, + # IMAGE, or TABLE output so every node shows its result. + if on_preview or on_table: + self._auto_preview(cls, node_id, result, on_preview, on_table) + + if on_node_done: + on_node_done(node_id) + + return node_outputs + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _topological_sort(self, prompt: dict) -> list[str]: + """Kahn's algorithm — returns node IDs in dependency order.""" + in_degree: dict[str, int] = {nid: 0 for nid in prompt} + dependents: dict[str, list[str]] = defaultdict(list) + + for node_id, node_def in prompt.items(): + for value in node_def.get("inputs", {}).values(): + if _is_link(value): + src_id = value[0] + if src_id in prompt: + in_degree[node_id] += 1 + dependents[src_id].append(node_id) + + queue: deque[str] = deque(nid for nid, deg in in_degree.items() if deg == 0) + order: list[str] = [] + + while queue: + nid = queue.popleft() + order.append(nid) + for dep in dependents[nid]: + in_degree[dep] -= 1 + if in_degree[dep] == 0: + queue.append(dep) + + if len(order) != len(prompt): + raise ValueError("Cycle detected in workflow graph — cannot execute.") + + return order + + def _resolve_inputs( + self, + raw_inputs: dict[str, Any], + node_outputs: dict[str, tuple], + ) -> dict[str, Any]: + """Replace [src_id, slot] links with actual output values.""" + resolved = {} + for key, value in raw_inputs.items(): + if _is_link(value): + src_id, slot = value[0], int(value[1]) + if src_id not in node_outputs: + raise KeyError( + f"Node '{src_id}' has no output yet — dependency ordering bug?" + ) + outputs = node_outputs[src_id] + if slot >= len(outputs): + raise IndexError( + f"Node '{src_id}' only has {len(outputs)} outputs, " + f"but slot {slot} was requested." + ) + resolved[key] = outputs[slot] + else: + resolved[key] = value + return resolved + + def _inject_display_callbacks( + self, + on_preview: Callable | None, + on_table: Callable | None, + on_mesh: Callable | None = None, + on_overlay: Callable | None = None, + ) -> None: + """Wire up broadcast callbacks on display node classes.""" + from backend.nodes.display import PreviewImage, PrintTable, View3D + from backend.nodes.analysis import CrossSection + from backend.nodes.io import SaveImage + + PreviewImage._broadcast_fn = on_preview + View3D._broadcast_mesh_fn = on_mesh + PrintTable._broadcast_table_fn = on_table + CrossSection._broadcast_overlay_fn = on_overlay + SaveImage._broadcast_preview = ( + (lambda data_uri: on_preview("save", data_uri)) if on_preview else None + ) + + def _set_node_id_on_display(self, cls: type, node_id: str) -> None: + """Inform display nodes of their current node_id for WS tagging.""" + from backend.nodes.display import PreviewImage, PrintTable, View3D + from backend.nodes.analysis import CrossSection + if cls in (PreviewImage, PrintTable, View3D, CrossSection): + cls._current_node_id = node_id + + def _auto_preview( + self, + cls: type, + node_id: str, + result: tuple, + on_preview: Callable | None, + on_table: Callable | None, + ) -> None: + """ + After every node executes, inspect its outputs and broadcast + a preview for the first DATA_FIELD, IMAGE, or TABLE found. + """ + import numpy as np + from backend.data_types import ( + DataField, datafield_to_uint8, image_to_uint8, encode_preview, + ) + + return_types = getattr(cls, "RETURN_TYPES", ()) + + for slot, type_name in enumerate(return_types): + if slot >= len(result): + break + value = result[slot] + + if type_name == "DATA_FIELD" and isinstance(value, DataField) and on_preview: + arr = datafield_to_uint8(value, "viridis") + on_preview(node_id, encode_preview(arr)) + return # one preview per node is enough + + if type_name == "IMAGE" and isinstance(value, np.ndarray) and on_preview: + arr = image_to_uint8(value) + on_preview(node_id, encode_preview(arr)) + return + + if type_name == "LINE" and isinstance(value, np.ndarray) and on_preview: + preview = self._render_line_preview(cls, slot, result) + if preview: + on_preview(node_id, preview) + return + + if type_name == "TABLE" and isinstance(value, list) and on_table: + on_table(node_id, value) + return + + + def _render_line_preview( + self, + cls: type, + slot: int, + result: tuple, + ) -> str | None: + """Render a LINE output as a small matplotlib plot, returned as a data URI.""" + import numpy as np + import base64 + import io as _io + + return_types = getattr(cls, "RETURN_TYPES", ()) + + # Find the y-values (current slot) and try to find an x-axis + y = result[slot] + x = None + # If the next output is also LINE, use it as x-axis + if slot + 1 < len(return_types) and return_types[slot + 1] == "LINE": + x = result[slot + 1] + # Or if slot > 0 and previous is LINE, this slot is the x-axis — skip + if slot > 0 and return_types[slot - 1] == "LINE": + return None # the first LINE already plotted both + + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(3.2, 1.8), dpi=100) + fig.patch.set_facecolor("#1e293b") + ax.set_facecolor("#0f172a") + if x is not None: + ax.plot(x, y, color="#ff9800", linewidth=1.2) + else: + ax.plot(y, color="#ff9800", linewidth=1.2) + ax.tick_params(colors="#94a3b8", labelsize=7) + for spine in ax.spines.values(): + spine.set_color("#334155") + ax.grid(True, color="#334155", linewidth=0.3, alpha=0.5) + fig.tight_layout(pad=0.4) + + buf = _io.BytesIO() + fig.savefig(buf, format="png", facecolor=fig.get_facecolor()) + plt.close(fig) + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/png;base64,{b64}" + except Exception: + return None + + +def new_prompt_id() -> str: + return str(uuid.uuid4()) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..657bd14 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,47 @@ +""" +Entry point for argonode. + +Run with: + python -m backend.main +or simply: + python backend/main.py +from the argonode/ directory. +""" + +import asyncio +import logging +import sys +from pathlib import Path + +# Allow running as `python backend/main.py` from the project root +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from aiohttp import web +from backend.server import create_app + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(name)s — %(message)s", +) +log = logging.getLogger(__name__) + +HOST = "127.0.0.1" +PORT = 8188 + + +def main() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + app = create_app(loop) + + log.info("=" * 60) + log.info(" Argonode — Node-based image analysis") + log.info(" Open your browser at http://%s:%d", HOST, PORT) + log.info("=" * 60) + + web.run_app(app, host=HOST, port=PORT, loop=loop, access_log=None) + + +if __name__ == "__main__": + main() diff --git a/backend/node_registry.py b/backend/node_registry.py new file mode 100644 index 0000000..3510544 --- /dev/null +++ b/backend/node_registry.py @@ -0,0 +1,56 @@ +""" +Node registry for argonode. + +Nodes are plain Python classes decorated with @register_node. +NODE_CLASS_MAPPINGS is the single source of truth consumed by +the execution engine and the /nodes REST endpoint. +""" + +from __future__ import annotations +from typing import Any + +NODE_CLASS_MAPPINGS: dict[str, type] = {} +NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {} + + +def register_node(display_name: str | None = None): + """ + Class decorator that registers a node class into NODE_CLASS_MAPPINGS. + + Usage: + @register_node(display_name="Gaussian Filter") + class GaussianFilter: + ... + """ + def decorator(cls: type) -> type: + name = cls.__name__ + NODE_CLASS_MAPPINGS[name] = cls + NODE_DISPLAY_NAME_MAPPINGS[name] = display_name or name + return cls + return decorator + + +def get_node_info(class_name: str) -> dict[str, Any]: + """ + Return a JSON-serialisable dict describing a node — consumed by GET /nodes. + Shape is compatible with what LiteGraph.js expects from the frontend. + """ + cls = NODE_CLASS_MAPPINGS[class_name] + input_types: dict = cls.INPUT_TYPES() + + return { + "name": class_name, + "display_name": NODE_DISPLAY_NAME_MAPPINGS.get(class_name, class_name), + "category": getattr(cls, "CATEGORY", "uncategorized"), + "input": input_types, + "input_order": {k: list(v.keys()) for k, v in input_types.items()}, + "output": list(cls.RETURN_TYPES), + "output_name": list(getattr(cls, "RETURN_NAMES", cls.RETURN_TYPES)), + "output_node": bool(getattr(cls, "OUTPUT_NODE", False)), + "description": getattr(cls, "DESCRIPTION", ""), + } + + +def get_all_node_info() -> dict[str, dict[str, Any]]: + """Return info dicts for every registered node.""" + return {name: get_node_info(name) for name in NODE_CLASS_MAPPINGS} diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py new file mode 100644 index 0000000..588daa7 --- /dev/null +++ b/backend/nodes/__init__.py @@ -0,0 +1,2 @@ +# Import all node modules to trigger @register_node decorators. +from . import io, filters, level, analysis, grains, display diff --git a/backend/nodes/analysis.py b/backend/nodes/analysis.py new file mode 100644 index 0000000..d112a95 --- /dev/null +++ b/backend/nodes/analysis.py @@ -0,0 +1,471 @@ +""" +Analysis nodes — statistics, histograms, FFT, cross sections. + +Gwyddion equivalents: + StatisticsNode → gwy_data_field_get_min/max/avg/rms (libprocess/stats.h) + HeightHistogram → DH (height distribution), gwy_data_field_dh + FFT2D → gwy_data_field_2dfft + gwy_data_field_2dpsdf + CrossSection → gwy_data_field_get_profile (libprocess/datafield.c) +""" + +from __future__ import annotations +import numpy as np +from backend.node_registry import register_node +from backend.data_types import DataField + + +# --------------------------------------------------------------------------- +# StatisticsNode +# --------------------------------------------------------------------------- + +@register_node(display_name="Statistics") +class StatisticsNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + } + } + + RETURN_TYPES = ("TABLE",) + RETURN_NAMES = ("stats",) + FUNCTION = "process" + CATEGORY = "analysis" + DESCRIPTION = ( + "Compute basic surface statistics: min, max, mean, RMS roughness, median, " + "and skewness. Equivalent to gwy_data_field_get_min/max/avg/rms." + ) + + def process(self, field: DataField) -> tuple: + d = field.data + mean = float(d.mean()) + rms = float(np.sqrt(np.mean((d - mean) ** 2))) + 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 = [ + {"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}, + {"quantity": "RMS", "value": rms, "unit": field.si_unit_z}, + {"quantity": "median", "value": float(np.median(d)), "unit": field.si_unit_z}, + {"quantity": "skewness", "value": skewness, "unit": ""}, + {"quantity": "kurtosis", "value": kurtosis, "unit": ""}, + {"quantity": "range", "value": float(d.max() - d.min()), "unit": field.si_unit_z}, + ] + return (table,) + + +# --------------------------------------------------------------------------- +# HeightHistogram +# --------------------------------------------------------------------------- + +@register_node(display_name="Height Histogram") +class HeightHistogram: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "n_bins": ("INT", {"default": 256, "min": 10, "max": 1000, "step": 1}), + } + } + + RETURN_TYPES = ("LINE", "LINE") + RETURN_NAMES = ("counts", "bin_centers") + FUNCTION = "process" + CATEGORY = "analysis" + DESCRIPTION = ( + "Compute the height distribution histogram (DH). " + "Equivalent to gwy_data_field_dh." + ) + + def process(self, field: DataField, n_bins: int) -> tuple: + counts, bin_edges = np.histogram(field.data.ravel(), bins=int(n_bins)) + bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) + return (counts.astype(np.float64), bin_centers) + + +# --------------------------------------------------------------------------- +# FFT2D +# --------------------------------------------------------------------------- + +@register_node(display_name="2D FFT") +class FFT2D: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "windowing": (["hann", "hamming", "blackman", "none"],), + "level": (["mean", "plane", "none"],), + "output": (["log_magnitude", "magnitude", "phase", "psdf"],), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("spectrum",) + FUNCTION = "process" + CATEGORY = "analysis" + DESCRIPTION = ( + "Compute the 2D FFT with optional windowing and mean/plane subtraction. " + "Output can be log magnitude, magnitude, phase, or PSDF. " + "Equivalent to gwy_data_field_2dfft / gwy_data_field_2dpsdf." + ) + + def process(self, field: DataField, windowing: str, level: str, output: str) -> tuple: + data = field.data.copy() + yres, xres = data.shape + + # Level subtraction (Gwyddion-style, before windowing) + if level == "mean": + data -= data.mean() + elif level == "plane": + # Fit and subtract a plane: z = a + b*x + c*y + yy, xx = np.mgrid[0:yres, 0:xres] + xx_f = xx.ravel().astype(np.float64) + yy_f = yy.ravel().astype(np.float64) + zz_f = data.ravel() + A = np.column_stack([np.ones_like(xx_f), xx_f, yy_f]) + coeffs, _, _, _ = np.linalg.lstsq(A, zz_f, rcond=None) + plane = (coeffs[0] + coeffs[1] * xx + coeffs[2] * yy) + data -= plane + + # Windowing (Gwyddion uses (i+0.5)/n centred formulation) + if windowing != "none": + t_y = (np.arange(yres) + 0.5) / yres + t_x = (np.arange(xres) + 0.5) / xres + if windowing == "hann": + wy = 0.5 - 0.5 * np.cos(2 * np.pi * t_y) + wx = 0.5 - 0.5 * np.cos(2 * np.pi * t_x) + elif windowing == "hamming": + wy = 0.54 - 0.46 * np.cos(2 * np.pi * t_y) + wx = 0.54 - 0.46 * np.cos(2 * np.pi * t_x) + elif windowing == "blackman": + wy = 0.42 - 0.5 * np.cos(2 * np.pi * t_y) + 0.08 * np.cos(4 * np.pi * t_y) + wx = 0.42 - 0.5 * np.cos(2 * np.pi * t_x) + 0.08 * np.cos(4 * np.pi * t_x) + else: + wy = np.ones(yres) + wx = np.ones(xres) + data *= np.outer(wy, wx) + + # 2D FFT, shifted so DC is at centre + F = np.fft.fftshift(np.fft.fft2(data)) + n = xres * yres + + if output == "log_magnitude": + mag = np.abs(F) + # Log scale with floor to avoid log(0) + result = np.log1p(mag) + elif output == "magnitude": + result = np.abs(F) + elif output == "phase": + result = np.angle(F) + elif output == "psdf": + # Gwyddion-equivalent PSDF: |F|^2 * dx * dy / (n * 4π²) + dx = field.xreal / xres + dy = field.yreal / yres + result = (np.abs(F) ** 2) * dx * dy / (n * 4.0 * np.pi ** 2) + else: + result = np.abs(F) + + # Calibrate the output field in spatial-frequency units + if output == "psdf": + # Gwyddion uses angular frequency: 2π/dx, 2π/dy + freq_xreal = 2.0 * np.pi * xres / field.xreal + freq_yreal = 2.0 * np.pi * yres / field.yreal + z_unit = f"({field.si_unit_z})^2 m^2" + else: + freq_xreal = xres / field.xreal + freq_yreal = yres / field.yreal + z_unit = field.si_unit_z + + out_field = DataField( + data=result, + xreal=freq_xreal, + yreal=freq_yreal, + si_unit_xy="1/m", + si_unit_z=z_unit, + domain="frequency", + ) + return (out_field,) + + +# --------------------------------------------------------------------------- +# CrossSection +# --------------------------------------------------------------------------- + +def _extend_to_edges(x1, y1, x2, y2): + """ + Extend the line through (x1,y1)-(x2,y2) to the boundaries of [0,1]x[0,1]. + Returns the two intersection points (clipped to the unit square). + """ + dx = x2 - x1 + dy = y2 - y1 + + # Collect parametric t values where line hits each boundary + t_candidates = [] + if abs(dx) > 1e-12: + for bx in (0.0, 1.0): + t = (bx - x1) / dx + y_at_t = y1 + t * dy + if -1e-9 <= y_at_t <= 1.0 + 1e-9: + t_candidates.append(t) + if abs(dy) > 1e-12: + for by in (0.0, 1.0): + t = (by - y1) / dy + x_at_t = x1 + t * dx + if -1e-9 <= x_at_t <= 1.0 + 1e-9: + t_candidates.append(t) + + if len(t_candidates) < 2: + return x1, y1, x2, y2 + + t_min = min(t_candidates) + t_max = max(t_candidates) + + return ( + np.clip(x1 + t_min * dx, 0, 1), + np.clip(y1 + t_min * dy, 0, 1), + np.clip(x1 + t_max * dx, 0, 1), + np.clip(y1 + t_max * dy, 0, 1), + ) + + +@register_node(display_name="Cross Section") +class CrossSection: + """Extract a 1-D height profile along an arbitrary line across the image.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "x1": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "y1": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "x2": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "y2": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "extend": (["none", "to_edges"],), + "n_samples": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), + }, + "optional": { + "point_a": ("COORD",), + "point_b": ("COORD",), + }, + } + + RETURN_TYPES = ("LINE",) + RETURN_NAMES = ("profile",) + FUNCTION = "process" + CATEGORY = "analysis" + DESCRIPTION = ( + "Extract a cross-section profile along a line between two points. " + "Drag the markers on the image to set the line endpoints. " + "Equivalent to gwy_data_field_get_profile." + ) + + _broadcast_overlay_fn = None + _current_node_id: str = "" + + def process( + self, field: DataField, + x1: float, y1: float, x2: float, y2: float, + extend: str, n_samples: int, + point_a=None, point_b=None, + ) -> tuple: + from scipy.ndimage import map_coordinates + import io, base64 + from matplotlib.figure import Figure + + # COORD inputs override widget values + if point_a is not None: + x1, y1 = float(point_a[0]), float(point_a[1]) + if point_b is not None: + x2, y2 = float(point_b[0]), float(point_b[1]) + + # Remember marker positions (before extend) + marker_x1, marker_y1 = float(x1), float(y1) + marker_x2, marker_y2 = float(x2), float(y2) + + xres, yres = field.xres, field.yres + + if extend == "to_edges": + x1, y1, x2, y2 = _extend_to_edges( + float(x1), float(y1), float(x2), float(y2), + ) + + # Convert fractional [0,1] to pixel indices [0, res-1] + px1, py1 = float(x1) * (xres - 1), float(y1) * (yres - 1) + px2, py2 = float(x2) * (xres - 1), float(y2) * (yres - 1) + + # Number of sample points + line_len_px = np.hypot(px2 - px1, py2 - py1) + if n_samples <= 0: + n_samples = max(2, int(np.ceil(line_len_px))) + + # Sample coordinates along the line + t = np.linspace(0, 1, n_samples) + coords_y = py1 + t * (py2 - py1) + coords_x = px1 + t * (px2 - px1) + + # Interpolate values along the line (cubic spline) + profile = map_coordinates(field.data, [coords_y, coords_x], order=3, mode="nearest") + + # Broadcast overlay image with marker positions + if CrossSection._broadcast_overlay_fn is not None: + fig = Figure(figsize=(3, 3), dpi=100) + ax = fig.add_axes([0, 0, 1, 1]) + ax.imshow(field.data, cmap="viridis", aspect="auto") + ax.axis("off") + buf = io.BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0) + buf.seek(0) + image_uri = "data:image/png;base64," + base64.b64encode(buf.read()).decode() + + CrossSection._broadcast_overlay_fn( + CrossSection._current_node_id, + { + "image": image_uri, + "x1": marker_x1, "y1": marker_y1, + "x2": marker_x2, "y2": marker_y2, + "a_locked": point_a is not None, + "b_locked": point_b is not None, + }, + ) + + return (profile.astype(np.float64),) + + +# --------------------------------------------------------------------------- +# LineMath — single scalar measurement from a LINE profile +# --------------------------------------------------------------------------- + +def _safe_rq(d): + """RMS of deviations from mean.""" + return float(np.sqrt(np.mean(d * d))) + +# Registry: name → (function(z) → float, unit_label) +# All functions receive the raw 1-D profile as float64. +LINE_OPS: dict[str, tuple] = {} + + +def _line_op(name, unit=""): + """Decorator to register a LINE operation.""" + def decorator(fn): + LINE_OPS[name] = (fn, unit) + return fn + return decorator + + +# ── Basic statistics ────────────────────────────────────────────────────── + +@_line_op("min") +def _op_min(z): + return float(z.min()) + +@_line_op("max") +def _op_max(z): + return float(z.max()) + +@_line_op("mean") +def _op_mean(z): + return float(z.mean()) + +@_line_op("median") +def _op_median(z): + return float(np.median(z)) + +@_line_op("sum") +def _op_sum(z): + return float(z.sum()) + +@_line_op("range") +def _op_range(z): + return float(z.max() - z.min()) + +@_line_op("length", unit="pts") +def _op_length(z): + return float(len(z)) + +@_line_op("rms") +def _op_rms(z): + return float(np.sqrt(np.mean(z * z))) + + +# ── Roughness parameters ────────────────────────── + +@_line_op("Ra") +def _op_ra(z): + return float(np.mean(np.abs(z - z.mean()))) + +@_line_op("Rq") +def _op_rq(z): + d = z - z.mean() + return _safe_rq(d) + +@_line_op("Rsk") +def _op_rsk(z): + d = z - z.mean() + rq = _safe_rq(d) + return float(np.mean(d**3) / rq**3) if rq > 0 else 0.0 + +@_line_op("Rku") +def _op_rku(z): + d = z - z.mean() + rq = _safe_rq(d) + return float(np.mean(d**4) / rq**4) if rq > 0 else 0.0 + +@_line_op("Rp") +def _op_rp(z): + return float((z - z.mean()).max()) + +@_line_op("Rv") +def _op_rv(z): + return float(-(z - z.mean()).min()) + +@_line_op("Rt") +def _op_rt(z): + d = z - z.mean() + return float(d.max() - d.min()) + +@_line_op("Dq") +def _op_dq(z): + """RMS slope (first derivative RMS).""" + dz = np.diff(z) + return float(np.sqrt(np.mean(dz * dz))) + +@_line_op("Da") +def _op_da(z): + """Mean absolute slope.""" + return float(np.mean(np.abs(np.diff(z)))) + + +@register_node(display_name="Line Math") +class LineMath: + """Compute a single scalar value from a LINE profile.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "line": ("LINE",), + "operation": (list(LINE_OPS.keys()),), + } + } + + RETURN_TYPES = ("TABLE",) + RETURN_NAMES = ("result",) + FUNCTION = "process" + CATEGORY = "analysis" + DESCRIPTION = ( + "Compute a single scalar measurement from a LINE profile. " + "Includes basic stats and Gwyddion-convention roughness parameters." + ) + + def process(self, line, operation: str) -> tuple: + z = np.asarray(line, dtype=np.float64).ravel() + fn, unit = LINE_OPS[operation] + value = fn(z) + table = [{"quantity": operation, "value": value, "unit": unit}] + return (table,) diff --git a/backend/nodes/display.py b/backend/nodes/display.py new file mode 100644 index 0000000..c12c7ab --- /dev/null +++ b/backend/nodes/display.py @@ -0,0 +1,165 @@ +""" +Display / output nodes. + +Preview accepts both DATA_FIELD and IMAGE via optional inputs — +connect whichever type you have. The server injects _broadcast_fn +before execution begins. +""" + +from __future__ import annotations +import numpy as np +from backend.node_registry import register_node +from backend.data_types import DataField, datafield_to_uint8, image_to_uint8, encode_preview + + +@register_node(display_name="Preview") +class PreviewImage: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "colormap": (["gray", "hot", "jet", "viridis", "plasma", "inferno"],), + }, + "optional": { + "image": ("IMAGE",), + "field": ("DATA_FIELD",), + } + } + + RETURN_TYPES = () + FUNCTION = "preview" + CATEGORY = "display" + OUTPUT_NODE = True + DESCRIPTION = "Display an IMAGE or DATA_FIELD as a coloured thumbnail. Connect either input." + + _broadcast_fn = None + _current_node_id: str = "" + + def preview(self, colormap: str, image: np.ndarray | None = None, field=None) -> tuple: + # Prefer field if both are connected; accept whichever is provided + if field is not None: + arr_u8 = datafield_to_uint8(field, colormap) + elif image is not None: + if image.dtype != np.uint8: + imin, imax = image.min(), image.max() + if imax > imin: + norm = (image - imin) / (imax - imin) + else: + norm = np.zeros_like(image) + arr_u8 = (norm * 255).astype(np.uint8) + else: + arr_u8 = image + + if arr_u8.ndim == 2 and colormap != "gray": + import matplotlib.cm as cm + cmap = cm.get_cmap(colormap) + rgba = cmap(arr_u8.astype(np.float32) / 255.0) + arr_u8 = (rgba[:, :, :3] * 255).astype(np.uint8) + else: + raise ValueError("Connect either an IMAGE or DATA_FIELD input to Preview.") + + data_uri = encode_preview(arr_u8) + + if PreviewImage._broadcast_fn is not None: + PreviewImage._broadcast_fn(PreviewImage._current_node_id, data_uri) + + return () + + +@register_node(display_name="3D View") +class View3D: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "colormap": (["viridis", "gray", "hot", "jet", "plasma", "inferno", "terrain"],), + "z_scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 20.0, "step": 0.1}), + "resolution": ("INT", {"default": 128, "min": 32, "max": 512, "step": 16}), + } + } + + RETURN_TYPES = () + FUNCTION = "render" + CATEGORY = "display" + OUTPUT_NODE = True + DESCRIPTION = ( + "Interactive 3D surface view of a DATA_FIELD. " + "Drag to rotate, scroll to zoom. z_scale exaggerates height." + ) + + _broadcast_mesh_fn = None + _current_node_id: str = "" + + def render( + self, field: DataField, + colormap: str, z_scale: float, resolution: int, + ) -> tuple: + import matplotlib.cm as cm + import base64 + + data = field.data + yres, xres = data.shape + + # Downsample if larger than resolution + step_y = max(1, yres // resolution) + step_x = max(1, xres // resolution) + z = data[::step_y, ::step_x].astype(np.float32) + ny, nx = z.shape + + # Normalize for colormap + zmin, zmax = float(z.min()), float(z.max()) + if zmax > zmin: + z_norm = (z - zmin) / (zmax - zmin) + else: + z_norm = np.zeros_like(z) + + cmap = cm.get_cmap(colormap) + rgba = cmap(z_norm) # (ny, nx, 4) float [0,1] + colors_u8 = (rgba[:, :, :3] * 255).astype(np.uint8) + + # Base64-encode arrays for efficient WS transport + z_b64 = base64.b64encode(z.tobytes()).decode() + colors_b64 = base64.b64encode(colors_u8.tobytes()).decode() + + mesh_data = { + "width": nx, + "height": ny, + "z_data": z_b64, + "colors": colors_b64, + "z_min": zmin, + "z_max": zmax, + "z_scale": float(z_scale), + "x_range": [float(field.xoff), float(field.xoff + field.xreal)], + "y_range": [float(field.yoff), float(field.yoff + field.yreal)], + } + + if View3D._broadcast_mesh_fn is not None: + View3D._broadcast_mesh_fn(View3D._current_node_id, mesh_data) + + return () + + +@register_node(display_name="Print Table") +class PrintTable: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "table": ("TABLE",), + } + } + + RETURN_TYPES = () + FUNCTION = "print_table" + CATEGORY = "display" + OUTPUT_NODE = True + DESCRIPTION = "Send a TABLE to the browser as a WebSocket message for display." + + _broadcast_table_fn = None + _current_node_id: str = "" + + def print_table(self, table: list) -> tuple: + if PrintTable._broadcast_table_fn is not None: + PrintTable._broadcast_table_fn(PrintTable._current_node_id, table) + return () diff --git a/backend/nodes/filters.py b/backend/nodes/filters.py new file mode 100644 index 0000000..783a04b --- /dev/null +++ b/backend/nodes/filters.py @@ -0,0 +1,115 @@ +""" +Filter nodes — Gwyddion-equivalent image filters. + +Gwyddion equivalents: + GaussianFilter → gwy_data_field_filter_gaussian + MedianFilter → gwy_data_field_filter_median + EdgeDetect → gwy_data_field_filter_sobel / laplacian / log +""" + +from __future__ import annotations +import numpy as np +from backend.node_registry import register_node +from backend.data_types import DataField + + +# --------------------------------------------------------------------------- +# GaussianFilter +# --------------------------------------------------------------------------- + +@register_node(display_name="Gaussian Filter") +class GaussianFilter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "sigma": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 50.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("filtered",) + FUNCTION = "process" + CATEGORY = "filters" + DESCRIPTION = "Apply a Gaussian blur. Equivalent to gwy_data_field_filter_gaussian." + + def process(self, field: DataField, sigma: float) -> tuple: + from scipy.ndimage import gaussian_filter + data = gaussian_filter(field.data.copy(), sigma=float(sigma)) + return (field.replace(data=data),) + + +# --------------------------------------------------------------------------- +# MedianFilter +# --------------------------------------------------------------------------- + +@register_node(display_name="Median Filter") +class MedianFilter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "size": ("INT", {"default": 3, "min": 1, "max": 21, "step": 2}), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("filtered",) + FUNCTION = "process" + CATEGORY = "filters" + DESCRIPTION = "Apply a median filter. Equivalent to gwy_data_field_filter_median." + + def process(self, field: DataField, size: int) -> tuple: + from scipy.ndimage import median_filter + size = max(1, int(size)) + data = median_filter(field.data.copy(), size=size) + return (field.replace(data=data),) + + +# --------------------------------------------------------------------------- +# EdgeDetect +# --------------------------------------------------------------------------- + +@register_node(display_name="Edge Detect") +class EdgeDetect: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "method": (["sobel", "prewitt", "laplacian", "log"],), + "sigma": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("edges",) + FUNCTION = "process" + CATEGORY = "filters" + DESCRIPTION = ( + "Detect edges using Sobel, Prewitt, Laplacian, or LoG operators. " + "Equivalent to gwy_data_field_filter_sobel / gwy_data_field_filter_laplacian." + ) + + def process(self, field: DataField, method: str, sigma: float) -> tuple: + from scipy.ndimage import sobel, prewitt, gaussian_laplace, laplace + data = field.data.copy() + + if method == "sobel": + sx = sobel(data, axis=1) + sy = sobel(data, axis=0) + result = np.hypot(sx, sy) + elif method == "prewitt": + px = prewitt(data, axis=1) + py = prewitt(data, axis=0) + result = np.hypot(px, py) + elif method == "laplacian": + result = laplace(data) + elif method == "log": + result = gaussian_laplace(data, sigma=float(sigma)) + else: + raise ValueError(f"Unknown edge detection method: {method}") + + return (field.replace(data=result),) diff --git a/backend/nodes/grains.py b/backend/nodes/grains.py new file mode 100644 index 0000000..6228955 --- /dev/null +++ b/backend/nodes/grains.py @@ -0,0 +1,127 @@ +""" +Grain/feature detection nodes. + +Gwyddion equivalents: + ThresholdMask → threshold.c / otsu_threshold.c + GrainAnalysis → gwy_data_field_grains_get_values (grains-values.c) +""" + +from __future__ import annotations +import numpy as np +from backend.node_registry import register_node +from backend.data_types import DataField + + +# --------------------------------------------------------------------------- +# ThresholdMask +# --------------------------------------------------------------------------- + +@register_node(display_name="Threshold Mask") +class ThresholdMask: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "method": (["otsu", "absolute", "relative"],), + "threshold": ("FLOAT", {"default": 0.0, "min": -1e9, "max": 1e9, "step": 0.001}), + "direction": (["above", "below"],), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("mask",) + FUNCTION = "process" + CATEGORY = "grains" + DESCRIPTION = ( + "Create a binary mask by thresholding data. " + "Otsu automatically finds the optimal threshold. " + "Equivalent to Gwyddion's threshold and otsu_threshold modules." + ) + + def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple: + data = field.data + + if method == "otsu": + from skimage.filters import threshold_otsu + t = threshold_otsu(data) + elif method == "absolute": + t = float(threshold) + elif method == "relative": + # threshold is a fraction [0, 1] of the data range + dmin, dmax = data.min(), data.max() + t = dmin + float(threshold) * (dmax - dmin) + else: + raise ValueError(f"Unknown threshold method: {method}") + + if direction == "above": + mask = (data >= t).astype(np.uint8) * 255 + else: + mask = (data < t).astype(np.uint8) * 255 + + return (mask,) + + +# --------------------------------------------------------------------------- +# GrainAnalysis +# --------------------------------------------------------------------------- + +@register_node(display_name="Grain Analysis") +class GrainAnalysis: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "mask": ("IMAGE",), + "min_size": ("INT", {"default": 10, "min": 1, "max": 100000, "step": 1}), + } + } + + RETURN_TYPES = ("TABLE",) + RETURN_NAMES = ("grain_stats",) + FUNCTION = "process" + CATEGORY = "grains" + DESCRIPTION = ( + "Label connected grain regions in a binary mask and compute per-grain statistics: " + "area, equivalent diameter, mean/max height, bounding box. " + "Equivalent to gwy_data_field_grains_get_values." + ) + + def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple: + from scipy.ndimage import label, find_objects + + binary = (mask > 127).astype(np.int32) + labeled, n_grains = label(binary) + + pixel_area = field.dx * field.dy # m^2 per pixel + + rows = [] + for grain_id in range(1, n_grains + 1): + grain_pixels = labeled == grain_id + area_px = int(grain_pixels.sum()) + if area_px < min_size: + continue + + area_m2 = area_px * pixel_area + equiv_diam = float(2.0 * np.sqrt(area_m2 / np.pi)) + + heights = field.data[grain_pixels] + mean_h = float(heights.mean()) + max_h = float(heights.max()) + + # Bounding box + ys, xs = np.where(grain_pixels) + bbox = f"({int(xs.min())},{int(ys.min())})-({int(xs.max())},{int(ys.max())})" + + rows.append({ + "grain_id": grain_id, + "area_px": area_px, + "area_m2": area_m2, + "equiv_diam_m": equiv_diam, + "mean_height": mean_h, + "max_height": max_h, + "bbox": bbox, + }) + + return (rows,) diff --git a/backend/nodes/io.py b/backend/nodes/io.py new file mode 100644 index 0000000..2a89049 --- /dev/null +++ b/backend/nodes/io.py @@ -0,0 +1,277 @@ +""" +I/O nodes: load and save images and SPM data. +""" + +from __future__ import annotations +import os +import numpy as np +from pathlib import Path + +from backend.node_registry import register_node +from backend.data_types import DataField, encode_preview, image_to_uint8 + +# Resolved at server startup so nodes know where to look +INPUT_DIR = Path(__file__).parent.parent.parent / "input" +OUTPUT_DIR = Path(__file__).parent.parent.parent / "output" + + +# --------------------------------------------------------------------------- +# LoadImage +# --------------------------------------------------------------------------- + +@register_node(display_name="Load Image") +class LoadImage: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "filename": ("FILE_PICKER", {"default": ""}), + } + } + + RETURN_TYPES = ("IMAGE", "DATA_FIELD") + RETURN_NAMES = ("image", "field") + FUNCTION = "load" + CATEGORY = "io" + DESCRIPTION = "Load a PNG, TIFF, JPG image or .npy/.npz array from the input folder. Outputs both IMAGE and DATA_FIELD." + + def load(self, filename: str): + # Accept absolute paths or filenames relative to input/ + path = Path(filename) + if not path.is_absolute(): + path = INPUT_DIR / filename + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + + ext = path.suffix.lower() + if ext in (".npy",): + arr = np.load(str(path)).astype(np.float64) + elif ext in (".npz",): + npz = np.load(str(path)) + key = list(npz.files)[0] + arr = npz[key].astype(np.float64) + else: + from PIL import Image + img = Image.open(str(path)) + arr = np.array(img) + if arr.dtype != np.uint8: + arr = arr.astype(np.float64) + + # Convert to float64 grayscale for the DATA_FIELD output + if arr.ndim == 3: + gray = np.mean(arr.astype(np.float64), axis=2) + else: + gray = arr.astype(np.float64) + + field = DataField(data=gray) + return (arr, field) + + +# --------------------------------------------------------------------------- +# LoadSPM +# --------------------------------------------------------------------------- + +@register_node(display_name="Load SPM File") +class LoadSPM: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "filename": ("FILE_PICKER", {"default": ""}), + "channel": ("STRING", {"default": "Z"}), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("field",) + FUNCTION = "load" + CATEGORY = "io" + DESCRIPTION = "Load SPM/AFM data from .gwy, .sxm, or .ibw files into a calibrated DataField." + + def load(self, filename: str, channel: str = "Z"): + path = Path(filename) + if not path.is_absolute(): + path = INPUT_DIR / filename + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + + ext = path.suffix.lower() + + if ext == ".gwy": + return (self._load_gwy(path, channel),) + elif ext == ".sxm": + return (self._load_sxm(path, channel),) + elif ext in (".ibw",): + return (self._load_ibw(path),) + elif ext in (".npy",): + data = np.load(str(path)).astype(np.float64) + return (DataField(data=data),) + elif ext in (".npz",): + npz = np.load(str(path)) + key = list(npz.files)[0] + return (DataField(data=npz[key].astype(np.float64)),) + else: + raise ValueError(f"Unsupported SPM format: {ext}. Supported: .gwy, .sxm, .ibw, .npy, .npz") + + def _load_gwy(self, path: Path, channel: str) -> 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: + raise ValueError(f"No data channels found in {path.name}") + + # Try requested channel name, fall back to first available + ch = None + for key, df in channels.items(): + if channel.lower() in key.lower(): + ch = df + break + if ch is None: + ch = next(iter(channels.values())) + + data = np.array(ch.data, dtype=np.float64).reshape(ch.yres, ch.xres) + return DataField( + data=data, + xreal=float(ch.xreal), + yreal=float(ch.yreal), + xoff=float(getattr(ch, "xoff", 0.0)), + yoff=float(getattr(ch, "yoff", 0.0)), + si_unit_xy="m", + si_unit_z="m", + ) + + def _load_sxm(self, path: Path, channel: str) -> 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 + + # Pick channel + ch_key = None + for key in signals: + if channel.upper() in key.upper(): + ch_key = key + break + if ch_key is None: + ch_key = next(iter(signals)) + + data = signals[ch_key].get("forward", list(signals[ch_key].values())[0]) + data = np.asarray(data, dtype=np.float64) + if data.ndim != 2: + data = data.reshape(data.shape[-2], data.shape[-1]) + + header = sxm.header + scan_range = header.get("scan_range", [1e-6, 1e-6]) + return DataField( + data=data, + xreal=float(scan_range[0]), + yreal=float(scan_range[1]), + si_unit_xy="m", + si_unit_z="m", + ) + + def _load_ibw(self, path: Path) -> DataField: + try: + import igor.igorpy as igorpy + wave = igorpy.load(str(path)) + data = wave.wave["wData"].squeeze().astype(np.float64) + except ImportError: + raise ImportError("Install 'igor' package to load .ibw files: pip install igor") + + if data.ndim == 1: + data = data.reshape(1, -1) + elif data.ndim != 2: + data = data[:, :, 0] if data.ndim == 3 else data.reshape(data.shape[0], -1) + + return DataField(data=data, si_unit_xy="m", si_unit_z="m") + + +# --------------------------------------------------------------------------- +# Coordinate +# --------------------------------------------------------------------------- + +@register_node(display_name="Coordinate") +class Coordinate: + """Provide a fractional (x, y) point for use with Cross Section or other nodes.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "x": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + "y": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), + } + } + + RETURN_TYPES = ("COORD",) + RETURN_NAMES = ("point",) + FUNCTION = "process" + CATEGORY = "io" + DESCRIPTION = "Output a fractional (x, y) coordinate pair in [0, 1]." + + def process(self, x: float, y: float) -> tuple: + return ((float(x), float(y)),) + + +# --------------------------------------------------------------------------- +# SaveImage +# --------------------------------------------------------------------------- + +@register_node(display_name="Save Image") +class SaveImage: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "filename_prefix": ("STRING", {"default": "output"}), + "format": (["PNG", "TIFF", "NPY"],), + } + } + + RETURN_TYPES = () + FUNCTION = "save" + CATEGORY = "io" + OUTPUT_NODE = True + DESCRIPTION = "Save an image or array to the output folder." + + # Injected by server.py before execution begins + _broadcast_preview = None + + def save(self, image: np.ndarray, filename_prefix: str = "output", format: str = "PNG"): + OUTPUT_DIR.mkdir(exist_ok=True) + + # Find next available filename + idx = 1 + while True: + name = f"{filename_prefix}_{idx:04d}" + candidate = OUTPUT_DIR / f"{name}.{format.lower()}" + if not candidate.exists(): + break + idx += 1 + + if format == "NPY": + np.save(str(OUTPUT_DIR / f"{name}.npy"), image) + else: + from PIL import Image + arr = image_to_uint8(image) + if arr.ndim == 2: + pil_img = Image.fromarray(arr, mode="L") + else: + pil_img = Image.fromarray(arr, mode="RGB") + pil_img.save(str(OUTPUT_DIR / f"{name}.{format.lower()}")) + + # Emit preview over WebSocket if callback is set + if SaveImage._broadcast_preview is not None: + arr_u8 = image_to_uint8(image) + data_uri = encode_preview(arr_u8) + SaveImage._broadcast_preview(data_uri) + + return () diff --git a/backend/nodes/level.py b/backend/nodes/level.py new file mode 100644 index 0000000..7c3736c --- /dev/null +++ b/backend/nodes/level.py @@ -0,0 +1,150 @@ +""" +Leveling nodes — background removal and zero correction. + +Gwyddion equivalents: + PlaneLevelField → gwy_data_field_fit_plane + gwy_data_field_plane_level + PolyLevelField → gwy_data_field_fit_polynom (via level.c polylevel module) + FixZero → fix_zero in level.c + +Plane-fit algorithm follows Gwyddion's level.h definition: + z_fit = pa + pbx * x + pby * y (least-squares over all pixels) +""" + +from __future__ import annotations +import numpy as np +from backend.node_registry import register_node +from backend.data_types import DataField + + +# --------------------------------------------------------------------------- +# PlaneLevelField +# --------------------------------------------------------------------------- + +@register_node(display_name="Plane Level") +class PlaneLevelField: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("leveled",) + FUNCTION = "process" + CATEGORY = "level" + DESCRIPTION = ( + "Fit and subtract a least-squares plane from the data. " + "Equivalent to gwy_data_field_fit_plane + gwy_data_field_plane_level." + ) + + def process(self, field: DataField) -> tuple: + data = field.data.copy() + yres, xres = data.shape + + # Normalised coordinate grids in [0, 1] + x = np.linspace(0.0, 1.0, xres) + y = np.linspace(0.0, 1.0, yres) + xx, yy = np.meshgrid(x, y) + + # Design matrix: [1, x, y] shape (N, 3) + A = np.column_stack([ + np.ones(xres * yres), + xx.ravel(), + yy.ravel(), + ]) + z = data.ravel() + + # Least-squares: solve A @ [pa, pbx, pby] = z + coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None) + pa, pbx, pby = coeffs + + plane = (pa + pbx * xx + pby * yy) + return (field.replace(data=data - plane),) + + +# --------------------------------------------------------------------------- +# PolyLevelField +# --------------------------------------------------------------------------- + +@register_node(display_name="Polynomial Level") +class PolyLevelField: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "degree_x": ("INT", {"default": 2, "min": 0, "max": 5, "step": 1}), + "degree_y": ("INT", {"default": 2, "min": 0, "max": 5, "step": 1}), + } + } + + RETURN_TYPES = ("DATA_FIELD", "DATA_FIELD") + RETURN_NAMES = ("leveled", "background") + FUNCTION = "process" + CATEGORY = "level" + DESCRIPTION = ( + "Fit and subtract a polynomial background of given degree in x and y. " + "Equivalent to gwy_data_field_fit_polynom." + ) + + def process(self, field: DataField, degree_x: int, degree_y: int) -> tuple: + data = field.data.copy() + yres, xres = data.shape + + x = np.linspace(0.0, 1.0, xres) + y = np.linspace(0.0, 1.0, yres) + xx, yy = np.meshgrid(x, y) + + # Build Vandermonde-style design matrix with all monomials x^i * y^j + cols = [] + for i in range(degree_x + 1): + for j in range(degree_y + 1): + cols.append((xx ** i * yy ** j).ravel()) + A = np.column_stack(cols) + z = data.ravel() + + coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None) + + background = (A @ coeffs).reshape(yres, xres) + leveled = data - background + + return (field.replace(data=leveled), field.replace(data=background)) + + +# --------------------------------------------------------------------------- +# FixZero +# --------------------------------------------------------------------------- + +@register_node(display_name="Fix Zero") +class FixZero: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "field": ("DATA_FIELD",), + "method": (["min", "mean", "median"],), + } + } + + RETURN_TYPES = ("DATA_FIELD",) + RETURN_NAMES = ("zeroed",) + FUNCTION = "process" + CATEGORY = "level" + DESCRIPTION = ( + "Shift data so that the minimum (or mean/median) is zero. " + "Equivalent to fix_zero in Gwyddion's level.c." + ) + + def process(self, field: DataField, method: str) -> tuple: + data = field.data.copy() + if method == "min": + data -= data.min() + elif method == "mean": + data -= data.mean() + elif method == "median": + data -= np.median(data) + else: + raise ValueError(f"Unknown method: {method}") + return (field.replace(data=data),) diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..31a0e91 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,267 @@ +""" +aiohttp web server for argonode. + +Routes +------ +GET / → serve frontend/index.html +GET /static/{path} → serve frontend JS/CSS +GET /nodes → JSON dict of all registered node definitions +POST /upload → multipart file upload to input/ +POST /prompt → submit a workflow; returns {prompt_id} +GET /ws → WebSocket upgrade + +WebSocket message types sent to clients +---------------------------------------- +{"type": "execution_start", "data": {"prompt_id": "..."}} +{"type": "executing", "data": {"node": "...", "prompt_id": "..."}} +{"type": "preview", "data": {"node_id": "...", "image": "data:..."}} +{"type": "table", "data": {"node_id": "...", "rows": [...]}} +{"type": "execution_error", "data": {"node_id": "...", "message": "..."}} +{"type": "execution_complete", "data": {"prompt_id": "..."}} +""" + +from __future__ import annotations +import asyncio +import json +import logging +import uuid +from pathlib import Path + +from aiohttp import web, WSMsgType + +log = logging.getLogger(__name__) + +FRONTEND_DIR = Path(__file__).parent.parent / "frontend" +DIST_DIR = FRONTEND_DIR / "dist" +INPUT_DIR = Path(__file__).parent.parent / "input" +OUTPUT_DIR = Path(__file__).parent.parent / "output" + + +# --------------------------------------------------------------------------- +# JSON helper — numpy scalars are not serialisable by default +# --------------------------------------------------------------------------- + +class _SafeEncoder(json.JSONEncoder): + def default(self, obj): + import numpy as np + if isinstance(obj, (np.integer,)): + return int(obj) + if isinstance(obj, (np.floating,)): + return float(obj) + if isinstance(obj, np.ndarray): + return obj.tolist() + return super().default(obj) + + +def _dumps(obj) -> str: + return json.dumps(obj, cls=_SafeEncoder) + + +# --------------------------------------------------------------------------- +# Application factory +# --------------------------------------------------------------------------- + +def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: + # Import nodes to trigger registration decorators + import backend.nodes # noqa: F401 + from backend.node_registry import get_all_node_info + from backend.execution import ExecutionEngine, new_prompt_id + + INPUT_DIR.mkdir(exist_ok=True) + OUTPUT_DIR.mkdir(exist_ok=True) + + engine = ExecutionEngine() + websockets: set[web.WebSocketResponse] = set() + + # ------------------------------------------------------------------ + # WebSocket broadcast helpers + # ------------------------------------------------------------------ + + def broadcast(msg: dict) -> None: + """Schedule a broadcast to all connected WebSocket clients.""" + payload = _dumps(msg) + for ws in list(websockets): + if not ws.closed: + asyncio.run_coroutine_threadsafe(ws.send_str(payload), loop) + + def on_preview(node_id: str, data_uri: str) -> None: + broadcast({"type": "preview", "data": {"node_id": node_id, "image": data_uri}}) + + def on_table(node_id: str, rows: list) -> None: + broadcast({"type": "table", "data": {"node_id": node_id, "rows": rows}}) + + def on_mesh(node_id: str, mesh_data: dict) -> None: + broadcast({"type": "mesh3d", "data": {"node_id": node_id, "mesh": mesh_data}}) + + def on_overlay(node_id: str, overlay_data) -> None: + broadcast({"type": "overlay", "data": {"node_id": node_id, "overlay": overlay_data}}) + + # ------------------------------------------------------------------ + # Route handlers + # ------------------------------------------------------------------ + + async def index(request: web.Request) -> web.Response: + # Serve Vite build output if available, else raw frontend + if (DIST_DIR / "index.html").exists(): + return web.FileResponse(DIST_DIR / "index.html") + return web.FileResponse(FRONTEND_DIR / "index.html") + + async def get_nodes(request: web.Request) -> web.Response: + info = get_all_node_info() + return web.Response( + text=_dumps(info), + content_type="application/json", + ) + + async def list_files(request: web.Request) -> web.Response: + """List files in the input/ directory for the file picker widget.""" + files = sorted( + f.name for f in INPUT_DIR.iterdir() + if f.is_file() and not f.name.startswith(".") + ) if INPUT_DIR.exists() else [] + return web.Response(text=_dumps(files), content_type="application/json") + + async def browse_dir(request: web.Request) -> web.Response: + """ + Server-side directory browser for local file picking. + GET /browse?dir=/some/path → {parent, dirs[], files[]} + """ + dir_path = request.query.get("dir", str(Path.home())) + p = Path(dir_path).expanduser().resolve() + + if not p.is_dir(): + raise web.HTTPBadRequest(reason=f"Not a directory: {p}") + + dirs = [] + files = [] + try: + for entry in sorted(p.iterdir(), key=lambda e: e.name.lower()): + if entry.name.startswith("."): + continue + if entry.is_dir(): + dirs.append(entry.name) + elif entry.is_file(): + files.append(entry.name) + except PermissionError: + pass + + return web.Response( + text=_dumps({ + "path": str(p), + "parent": str(p.parent) if p.parent != p else None, + "dirs": dirs, + "files": files, + }), + content_type="application/json", + ) + + async def upload_file(request: web.Request) -> web.Response: + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "file": + raise web.HTTPBadRequest(reason="Expected a 'file' field in multipart body") + + filename = Path(field.filename).name # strip any path traversal + dest = INPUT_DIR / filename + with open(dest, "wb") as f: + while True: + chunk = await field.read_chunk(65536) + if not chunk: + break + f.write(chunk) + + return web.Response(text=_dumps({"filename": filename}), content_type="application/json") + + async def submit_prompt(request: web.Request) -> web.Response: + body = await request.json() + prompt = body.get("prompt") + if not isinstance(prompt, dict) or not prompt: + raise web.HTTPBadRequest(reason="'prompt' must be a non-empty dict") + + prompt_id = new_prompt_id() + + # Run execution in a thread pool so scipy doesn't block the event loop + async def run(): + broadcast({"type": "execution_start", "data": {"prompt_id": prompt_id}}) + + def on_start(node_id: str) -> None: + broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}}) + + try: + await loop.run_in_executor( + None, + lambda: engine.execute( + prompt, + on_node_start=on_start, + on_preview=on_preview, + on_table=on_table, + on_mesh=on_mesh, + on_overlay=on_overlay, + ), + ) + broadcast({"type": "execution_complete", "data": {"prompt_id": prompt_id}}) + except Exception as exc: + log.exception("Execution error") + broadcast({ + "type": "execution_error", + "data": {"node_id": "", "message": str(exc)}, + }) + + asyncio.ensure_future(run()) + return web.Response( + text=_dumps({"prompt_id": prompt_id}), + content_type="application/json", + ) + + async def websocket_handler(request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + websockets.add(ws) + log.info("WebSocket client connected (%d total)", len(websockets)) + try: + async for msg in ws: + if msg.type == WSMsgType.TEXT: + pass # clients don't need to send anything currently + elif msg.type in (WSMsgType.ERROR, WSMsgType.CLOSE): + break + finally: + websockets.discard(ws) + log.info("WebSocket client disconnected (%d total)", len(websockets)) + return ws + + # ------------------------------------------------------------------ + # App assembly + # ------------------------------------------------------------------ + + app = web.Application() + + app.router.add_get("/", index) + app.router.add_get("/nodes", get_nodes) + app.router.add_get("/files", list_files) + app.router.add_get("/browse", browse_dir) + app.router.add_post("/upload", upload_file) + app.router.add_post("/prompt", submit_prompt) + app.router.add_get("/ws", websocket_handler) + + # Serve frontend static files (Vite build or raw) + if DIST_DIR.exists(): + app.router.add_static("/assets", DIST_DIR / "assets") + app.router.add_static("/static", FRONTEND_DIR) + + # CORS — allow any origin (local dev only) + async def _cors_middleware(app_, handler): + async def middleware(request): + if request.method == "OPTIONS": + return web.Response(headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }) + response = await handler(request) + response.headers["Access-Control-Allow-Origin"] = "*" + return response + return middleware + + app.middlewares.append(_cors_middleware) + + return app diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7418eec --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Argonode — Image Analysis + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5075927 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1951 @@ +{ + "name": "argonode-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "argonode-frontend", + "dependencies": { + "@xyflow/react": "^12.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "three": "^0.183.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6720568 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "argonode-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@xyflow/react": "^12.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "three": "^0.183.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..a75acc7 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,601 @@ +import React, { + useState, useCallback, useEffect, useRef, useMemo, +} from 'react'; +import { + ReactFlow, Background, Controls, MiniMap, + useNodesState, useEdgesState, addEdge, useReactFlow, + ReactFlowProvider, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import CustomNode, { NodeContext } from './CustomNode'; +import FileBrowser from './FileBrowser'; +import * as api from './api'; + +// ── Constants ───────────────────────────────────────────────────────── + +const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); + +const TYPE_COLORS = { + DATA_FIELD: '#3a7abf', + IMAGE: '#4caf50', + LINE: '#ff9800', + TABLE: '#fdd835', + COORD: '#e91e63', +}; + +const NODE_TYPES = { custom: CustomNode }; + +// ── Handle ID helpers ───────────────────────────────────────────────── + +function getHandleType(handleId) { + return handleId.split('::')[2]; +} + +function getInputName(handleId) { + return handleId.split('::')[1]; +} + +function getOutputSlot(handleId) { + return parseInt(handleId.split('::')[1], 10); +} + +// ── Graph serialisation → backend prompt format ─────────────────────── + +function serializeGraph(nodes, edges) { + const prompt = {}; + + for (const node of nodes) { + const { className, definition, widgetValues } = node.data; + if (!definition) continue; + + const inputs = {}; + + // Widget (scalar) values + const required = definition.input.required || {}; + for (const [name, spec] of Object.entries(required)) { + const [type] = Array.isArray(spec) ? spec : [spec]; + if (DATA_TYPES.has(type)) continue; // socket, handled via edges + if (widgetValues[name] !== undefined) { + inputs[name] = widgetValues[name]; + } + } + + // Connected (socket) inputs from edges + const incoming = edges.filter((e) => e.target === node.id); + for (const edge of incoming) { + const inputName = getInputName(edge.targetHandle); + const outputSlot = getOutputSlot(edge.sourceHandle); + inputs[inputName] = [edge.source, outputSlot]; + } + + prompt[node.id] = { class_type: className, inputs }; + } + + return prompt; +} + +// ── Context menu component ──────────────────────────────────────────── + +function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirection }) { + // Group by category, optionally filtering to compatible nodes + const categories = {}; + for (const [className, def] of Object.entries(nodeDefs)) { + // If filtering: only show nodes with a matching input or output + if (filterType && filterDirection) { + if (filterDirection === 'source') { + // Dragged from an output — show nodes that have a matching INPUT + const req = def.input.required || {}; + const opt = def.input.optional || {}; + const allInputs = { ...req, ...opt }; + const hasMatch = Object.values(allInputs).some((spec) => { + const [type] = Array.isArray(spec) ? spec : [spec]; + return type === filterType; + }); + if (!hasMatch) continue; + } else { + // Dragged from an input — show nodes that have a matching OUTPUT + if (!def.output.includes(filterType)) continue; + } + } + + const cat = def.category || 'uncategorized'; + if (!categories[cat]) categories[cat] = []; + categories[cat].push({ className, def }); + } + + if (Object.keys(categories).length === 0) { + return ( +
e.stopPropagation()}> +
No compatible nodes
+
+ ); + } + + return ( +
e.stopPropagation()} + > + {Object.entries(categories).map(([cat, items]) => ( +
+
{cat}
+ {items.map(({ className, def }) => ( +
{ onAdd(className, def); onClose(); }} + > + {def.display_name || className} +
+ ))} +
+ ))} +
+ ); +} + +// ── Main flow component (needs ReactFlowProvider ancestor) ──────────── + +function Flow() { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [status, setStatus] = useState({ text: 'Connecting…', level: 'info' }); + const [contextMenu, setContextMenu] = useState(null); + const [fileBrowserCb, setFileBrowserCb] = useState(null); + + const nodeDefsRef = useRef({}); + const nextIdRef = useRef(1); + const autoRunTimer = useRef(null); + const autoRunRef = useRef(null); + const reactFlow = useReactFlow(); + + // ── Load node definitions ─────────────────────────────────────────── + + useEffect(() => { + api.getNodes().then((defs) => { + nodeDefsRef.current = defs; + setStatus({ text: `Loaded ${Object.keys(defs).length} nodes.`, level: 'info' }); + }).catch((err) => { + setStatus({ text: 'Failed to load nodes: ' + err.message, level: 'error' }); + }); + }, []); + + // ── WebSocket ─────────────────────────────────────────────────────── + + const updateNodeData = useCallback((nodeId, patch) => { + setNodes((ns) => ns.map((n) => + n.id !== nodeId ? n : { ...n, data: { ...n.data, ...patch } } + )); + }, [setNodes]); + + useEffect(() => { + api.setMessageHandler((msg) => { + console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || ''); + switch (msg.type) { + case 'execution_start': + setStatus({ text: 'Running workflow…', level: 'info' }); + break; + case 'executing': + setStatus({ text: `Executing node ${msg.data.node}…`, level: 'info' }); + break; + case 'execution_complete': + setStatus({ text: 'Done.', level: 'info' }); + break; + case 'execution_error': + setStatus({ text: 'Error: ' + msg.data.message, level: 'error' }); + console.error('[argonode] execution error', msg.data); + break; + case 'preview': + updateNodeData(msg.data.node_id, { previewImage: msg.data.image }); + break; + case 'table': + updateNodeData(msg.data.node_id, { tableRows: msg.data.rows }); + break; + case 'mesh3d': + updateNodeData(msg.data.node_id, { meshData: msg.data.mesh }); + break; + case 'overlay': + updateNodeData(msg.data.node_id, { overlay: msg.data.overlay }); + break; + } + }); + api.initWS(); + return () => api.closeWS(); + }, [updateNodeData]); + + // ── Connection handling ───────────────────────────────────────────── + + const isValidConnection = useCallback((connection) => { + const srcType = getHandleType(connection.sourceHandle); + const tgtType = getHandleType(connection.targetHandle); + return srcType === tgtType; + }, []); + + const onConnect = useCallback((params) => { + const type = getHandleType(params.sourceHandle); + const color = TYPE_COLORS[type] || '#999'; + + setEdges((eds) => { + // Enforce single connection per input handle + const filtered = eds.filter( + (e) => !(e.target === params.target && e.targetHandle === params.targetHandle) + ); + return addEdge( + { ...params, style: { stroke: color, strokeWidth: 2 } }, + filtered + ); + }); + scheduleAutoRun(); + }, [setEdges]); + + // ── Drop-on-blank: open filtered context menu ────────────────────── + + const onConnectEnd = useCallback((event, connectionState) => { + // If the connection was completed (dropped on a valid handle), do nothing + if (connectionState.isValid) return; + + const fromHandle = connectionState.fromHandle; + if (!fromHandle || !fromHandle.id) return; + + const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event; + const handleType = getHandleType(fromHandle.id); + + setContextMenu({ + x: clientX, + y: clientY, + filterType: handleType, + filterDirection: fromHandle.type, + pendingNodeId: fromHandle.nodeId, + pendingHandleId: fromHandle.id, + pendingHandleType: fromHandle.type, + }); + }, []); + + // ── Widget change callback ────────────────────────────────────────── + + const onWidgetChange = useCallback((nodeId, name, value) => { + setNodes((ns) => ns.map((n) => { + if (n.id !== nodeId) return n; + return { + ...n, + data: { + ...n.data, + widgetValues: { ...n.data.widgetValues, [name]: value }, + }, + }; + })); + scheduleAutoRun(); + }, [setNodes]); // scheduleAutoRun is stable (no deps) + + // ── File browser ──────────────────────────────────────────────────── + + const openFileBrowser = useCallback((callback) => { + setFileBrowserCb(() => callback); + }, []); + + // ── Node context value (stable) ───────────────────────────────────── + + const contextValue = useMemo(() => ({ + onWidgetChange, + openFileBrowser, + }), [onWidgetChange, openFileBrowser]); + + // ── Add node from context menu ────────────────────────────────────── + + const addNode = useCallback((className, def) => { + if (!contextMenu) return; + const position = reactFlow.screenToFlowPosition({ + x: contextMenu.x, + y: contextMenu.y, + }); + + // Build default widget values + const widgetValues = {}; + const required = def.input.required || {}; + for (const [name, spec] of Object.entries(required)) { + const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; + if (DATA_TYPES.has(type)) continue; + if (Array.isArray(type)) { + widgetValues[name] = type[0]; // combo default = first option + } else { + widgetValues[name] = opts?.default ?? ''; + } + } + + const newNodeId = String(nextIdRef.current++); + const newNode = { + id: newNodeId, + type: 'custom', + position, + dragHandle: '.drag-handle', + data: { + label: def.display_name || className, + className, + definition: def, + widgetValues, + previewImage: null, + tableRows: null, + meshData: null, + overlay: null, + }, + }; + + setNodes((ns) => [...ns, newNode]); + + // Auto-connect if this was triggered by dropping a connection on blank space + if (contextMenu.pendingHandleId) { + const filterType = contextMenu.filterType; + + if (contextMenu.pendingHandleType === 'source') { + // Dragged from an output → connect to the first matching input on the new node + const allInputs = { ...(def.input.required || {}), ...(def.input.optional || {}) }; + const inputName = Object.entries(allInputs).find(([, spec]) => { + const [type] = Array.isArray(spec) ? spec : [spec]; + return type === filterType; + })?.[0]; + if (inputName) { + const targetHandle = `input::${inputName}::${filterType}`; + const color = TYPE_COLORS[filterType] || '#999'; + setEdges((eds) => addEdge({ + source: contextMenu.pendingNodeId, + sourceHandle: contextMenu.pendingHandleId, + target: newNodeId, + targetHandle, + style: { stroke: color, strokeWidth: 2 }, + }, eds)); + } + } else { + // Dragged from an input → connect from the first matching output on the new node + const outputIdx = def.output.indexOf(filterType); + if (outputIdx !== -1) { + const sourceHandle = `output::${outputIdx}::${filterType}`; + const color = TYPE_COLORS[filterType] || '#999'; + setEdges((eds) => addEdge({ + source: newNodeId, + sourceHandle, + target: contextMenu.pendingNodeId, + targetHandle: contextMenu.pendingHandleId, + style: { stroke: color, strokeWidth: 2 }, + }, eds)); + } + } + } + + setContextMenu(null); + scheduleAutoRun(); + }, [contextMenu, reactFlow, setNodes, setEdges]); + + // ── Toolbar actions ───────────────────────────────────────────────── + + const runWorkflow = useCallback(async () => { + // Read current state via functional ref to avoid stale closure + const currentNodes = reactFlow.getNodes(); + const currentEdges = reactFlow.getEdges(); + const prompt = serializeGraph(currentNodes, currentEdges); + + if (!prompt || Object.keys(prompt).length === 0) { + setStatus({ text: 'Graph is empty — add some nodes first.', level: 'error' }); + return; + } + setStatus({ text: 'Running…', level: 'info' }); + try { + await api.runPrompt(prompt); + } catch (err) { + setStatus({ text: 'Failed: ' + err.message, level: 'error' }); + } + }, [reactFlow]); + + // Debounced auto-run via ref to avoid dependency chains + autoRunRef.current = () => { + const currentNodes = reactFlow.getNodes(); + const currentEdges = reactFlow.getEdges(); + + // Don't run if any node has unconnected required data inputs + for (const node of currentNodes) { + const def = node.data?.definition; + if (!def) continue; + const required = def.input.required || {}; + for (const [name, spec] of Object.entries(required)) { + const [type] = Array.isArray(spec) ? spec : [spec]; + if (!DATA_TYPES.has(type)) continue; + const hasEdge = currentEdges.some( + (e) => e.target === node.id && getInputName(e.targetHandle) === name + ); + if (!hasEdge) return; // incomplete graph, skip auto-run + } + } + + const prompt = serializeGraph(currentNodes, currentEdges); + if (!prompt || Object.keys(prompt).length === 0) return; + setStatus({ text: 'Running…', level: 'info' }); + api.runPrompt(prompt).catch((err) => { + setStatus({ text: 'Failed: ' + err.message, level: 'error' }); + }); + }; + + const scheduleAutoRun = useCallback(() => { + clearTimeout(autoRunTimer.current); + autoRunTimer.current = setTimeout(() => autoRunRef.current?.(), 300); + }, []); + + const clearGraph = useCallback(() => { + setNodes([]); + setEdges([]); + nextIdRef.current = 1; + setStatus({ text: 'Graph cleared.', level: 'info' }); + }, [setNodes, setEdges]); + + const saveWorkflow = useCallback(() => { + const currentNodes = reactFlow.getNodes().map((n) => ({ + ...n, + data: { ...n.data, previewImage: null, tableRows: null, meshData: null, overlay: null }, + })); + const data = { version: 1, nodes: currentNodes, edges: reactFlow.getEdges() }; + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'workflow.json'; + a.click(); + }, [reactFlow]); + + const loadWorkflow = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + const text = await file.text(); + try { + const data = JSON.parse(text); + const loadedNodes = data.nodes || []; + const loadedEdges = data.edges || []; + + // Re-populate definitions from current nodeDefs + const defs = nodeDefsRef.current; + const hydrated = loadedNodes.map((n) => ({ + ...n, + data: { + ...n.data, + definition: defs[n.data.className] || n.data.definition, + previewImage: null, + tableRows: null, + meshData: null, + overlay: null, + }, + })); + + setNodes(hydrated); + setEdges(loadedEdges); + + // Update ID counter to avoid collisions + const maxId = Math.max(0, ...loadedNodes.map((n) => parseInt(n.id, 10) || 0)); + nextIdRef.current = maxId + 1; + + setStatus({ text: 'Workflow loaded.', level: 'info' }); + } catch { + setStatus({ text: 'Invalid workflow JSON.', level: 'error' }); + } + }; + input.click(); + }, [setNodes, setEdges]); + + // ── Keyboard shortcut ─────────────────────────────────────────────── + + useEffect(() => { + const handler = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + runWorkflow(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [runWorkflow]); + + // ── Context menu ──────────────────────────────────────────────────── + + const onPaneContextMenu = useCallback((event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY }); + }, []); + + // ── Render ────────────────────────────────────────────────────────── + + return ( + +
+ {/* Toolbar */} +
+ Argonode + +
+ + +
+ +
+ + +
+ +
{status.text}
+
+ + {/* React Flow canvas */} +
{ + if (!e.target.closest('.context-menu')) setContextMenu(null); + }}> + + + + { + const cat = n.data?.definition?.category; + const colors = { + io: '#37474f', filters: '#1a237e', level: '#1b5e20', + analysis: '#4a148c', grains: '#bf360c', display: '#212121', + }; + return colors[cat] || '#333'; + }} + /> + + + {contextMenu && ( + setContextMenu(null)} + filterType={contextMenu.filterType} + filterDirection={contextMenu.filterDirection} + /> + )} +
+ + {/* File browser modal */} + {fileBrowserCb && ( + { fileBrowserCb(path); setFileBrowserCb(null); }} + onClose={() => setFileBrowserCb(null)} + /> + )} +
+
+ ); +} + +// ── App wrapper with ReactFlowProvider ──────────────────────────────── + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/CrossSectionOverlay.jsx b/frontend/src/CrossSectionOverlay.jsx new file mode 100644 index 0000000..576e165 --- /dev/null +++ b/frontend/src/CrossSectionOverlay.jsx @@ -0,0 +1,86 @@ +import React, { useRef, useState, useCallback } from 'react'; + +/** + * Image preview with two endpoint markers for cross-section line control. + * Markers are draggable when unlocked (no COORD input connected), + * and fixed when locked (COORD input provides the position). + * + * Marker positions are driven by widget values (immediate React state), + * not by backend overlay coords, so they move instantly during drag. + */ +export default function CrossSectionOverlay({ + image, x1, y1, x2, y2, + aLocked, bLocked, + nodeId, onWidgetChange, +}) { + const containerRef = useRef(null); + const [dragging, setDragging] = useState(null); // 'p1' or 'p2' + + const getCoords = useCallback((e) => { + const rect = containerRef.current.getBoundingClientRect(); + return { + fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)), + fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)), + }; + }, []); + + const onPointerDown = useCallback((point) => (e) => { + if (point === 'p1' && aLocked) return; + if (point === 'p2' && bLocked) return; + e.stopPropagation(); + e.preventDefault(); + e.target.setPointerCapture(e.pointerId); + setDragging(point); + }, [aLocked, bLocked]); + + const onPointerMove = useCallback((e) => { + if (!dragging || !containerRef.current) return; + const { fx, fy } = getCoords(e); + const vx = parseFloat(fx.toFixed(3)); + const vy = parseFloat(fy.toFixed(3)); + if (dragging === 'p1') { + onWidgetChange(nodeId, 'x1', vx); + onWidgetChange(nodeId, 'y1', vy); + } else { + onWidgetChange(nodeId, 'x2', vx); + onWidgetChange(nodeId, 'y2', vy); + } + }, [dragging, nodeId, onWidgetChange, getCoords]); + + const onPointerUp = useCallback(() => { + setDragging(null); + }, []); + + return ( +
+ field + + {/* Line connecting the two markers */} + + + + + {/* Endpoint markers — locked markers get a different style */} +
+
+
+ ); +} diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx new file mode 100644 index 0000000..b26eef4 --- /dev/null +++ b/frontend/src/CustomNode.jsx @@ -0,0 +1,396 @@ +import React, { useContext, useRef, useCallback, useState, memo, lazy, Suspense } from 'react'; +import { Handle, Position } from '@xyflow/react'; + +const SurfaceView = lazy(() => import('./SurfaceView')); +const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay')); + +// ── Constants ───────────────────────────────────────────────────────── + +const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); + +const TYPE_COLORS = { + DATA_FIELD: '#3a7abf', + IMAGE: '#4caf50', + LINE: '#ff9800', + TABLE: '#fdd835', + COORD: '#e91e63', +}; + +const CAT_COLORS = { + io: '#37474f', + filters: '#1a237e', + level: '#1b5e20', + analysis: '#4a148c', + grains: '#bf360c', + display: '#212121', +}; + +// ── Context (provided by App) ───────────────────────────────────────── + +export const NodeContext = React.createContext(null); + +// ── Draggable number input ──────────────────────────────────────────── + +function DraggableNumber({ value, step, min, max, precision, onChange }) { + const [editing, setEditing] = useState(false); + const [editText, setEditText] = useState(''); + const dragState = useRef(null); + const elRef = useRef(null); + + const display = precision != null ? Number(value).toFixed(precision) : String(value); + + const clamp = useCallback((v) => { + if (min != null && v < min) v = min; + if (max != null && v > max) v = max; + return v; + }, [min, max]); + + const onPointerDown = useCallback((e) => { + if (editing) return; + e.preventDefault(); + dragState.current = { startX: e.clientX, startVal: Number(value) }; + elRef.current?.setPointerCapture(e.pointerId); + }, [editing, value]); + + const onPointerMove = useCallback((e) => { + if (!dragState.current) return; + const dx = e.clientX - dragState.current.startX; + const delta = dx * (step || 0.01); + const raw = dragState.current.startVal + delta; + const rounded = precision != null + ? parseFloat(raw.toFixed(precision)) + : Math.round(raw); + onChange(clamp(rounded)); + }, [step, precision, clamp, onChange]); + + const onPointerUp = useCallback((e) => { + if (!dragState.current) return; + const dx = Math.abs(e.clientX - dragState.current.startX); + dragState.current = null; + // If barely moved, enter text-edit mode + if (dx < 3) { + setEditText(display); + setEditing(true); + } + }, [display]); + + const commitEdit = useCallback(() => { + setEditing(false); + const parsed = parseFloat(editText); + if (!isNaN(parsed)) onChange(clamp(precision != null ? parseFloat(parsed.toFixed(precision)) : Math.round(parsed))); + }, [editText, precision, clamp, onChange]); + + if (editing) { + return ( + setEditText(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }} + /> + ); + } + + return ( +
+ {display} +
+ ); +} + +// ── Collapsible section ─────────────────────────────────────────────── + +function CollapsibleSection({ title, defaultOpen, children }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open && children} +
+ ); +} + +// ── CustomNode component ────────────────────────────────────────────── + +function CustomNode({ id, data }) { + const ctx = useContext(NodeContext); + const def = data.definition; + + // Parse inputs into data handles and widgets + const required = def.input.required || {}; + const optional = def.input.optional || {}; + + const dataInputs = []; + const widgets = []; + + const hiddenWidgets = new Set(); + + for (const [name, spec] of Object.entries(required)) { + const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; + if (DATA_TYPES.has(type)) { + dataInputs.push({ name, type }); + } else if (opts?.hidden) { + hiddenWidgets.add(name); + } else { + widgets.push({ name, type, opts: opts || {} }); + } + } + + for (const [name, spec] of Object.entries(optional)) { + const [type] = Array.isArray(spec) ? spec : [spec]; + dataInputs.push({ name, type }); + } + + const outputs = def.output.map((type, i) => ({ + name: def.output_name[i] || type, + type, + slot: i, + })); + + const catColor = CAT_COLORS[def.category] || '#333'; + const maxIORows = Math.max(dataInputs.length, outputs.length); + + return ( +
+ {/* Title */} +
+ {data.label} +
+ +
+ {/* I/O rows — pair inputs[i] with outputs[i] */} + {Array.from({ length: maxIORows }, (_, i) => { + const inp = dataInputs[i]; + const out = outputs[i]; + return ( +
+
+ {inp && ( + <> + + {inp.name} + + )} +
+
+ {out && ( + <> + {out.name} + + + )} +
+
+ ); + })} + + {/* Widget rows */} + {widgets.map((w) => ( +
+ +
+ ))} + + {/* Interactive 3D surface view */} + {data.meshData && ( + + Loading 3D...
}> + + + + )} + + {/* Collapsible preview image */} + {data.previewImage && ( + +
+ preview +
+
+ )} + + {/* Interactive cross-section overlay */} + {data.overlay && hiddenWidgets.has('x1') && ( + + Loading...
}> + + + + )} + + {/* Collapsible table data */} + {data.tableRows && data.tableRows.length > 0 && ( + +
+ {data.tableRows.map((row, i) => { + let line; + if (row.quantity !== undefined) { + const val = typeof row.value === 'number' ? row.value.toExponential(3) : row.value; + line = `${row.quantity}: ${val} ${row.unit || ''}`; + } else { + line = Object.entries(row) + .slice(0, 3) + .map(([k, v]) => `${k}: ${typeof v === 'number' ? v.toExponential(2) : v}`) + .join(' '); + } + return
{line}
; + })} +
+
+ )} +
+
+ ); +} + +// ── Widget renderer ─────────────────────────────────────────────────── + +function WidgetControl({ widget, nodeId, value, onChange, openFileBrowser }) { + const { name, type, opts } = widget; + const val = value ?? opts?.default ?? ''; + + // Combo / enum — type itself is the array of options + if (Array.isArray(type)) { + return ( + <> + + + + ); + } + + if (type === 'FILE_PICKER') { + return ( + <> + +
+ onChange(nodeId, name, e.target.value)} + placeholder="Select file…" + /> + +
+ + ); + } + + if (type === 'FLOAT') { + return ( + <> + + onChange(nodeId, name, v)} + /> + + ); + } + + if (type === 'INT') { + return ( + <> + + onChange(nodeId, name, v)} + /> + + ); + } + + if (type === 'BOOLEAN') { + return ( + <> + + onChange(nodeId, name, e.target.checked)} + /> + + ); + } + + // STRING and anything else + return ( + <> + + onChange(nodeId, name, e.target.value)} + /> + + ); +} + +export default memo(CustomNode); diff --git a/frontend/src/FileBrowser.jsx b/frontend/src/FileBrowser.jsx new file mode 100644 index 0000000..31477b8 --- /dev/null +++ b/frontend/src/FileBrowser.jsx @@ -0,0 +1,94 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import * as api from './api'; + +/** + * Server-side file browser modal. + * + * Props: + * onSelect(absolutePath) — called when user picks a file + * onClose() — called when user dismisses the dialog + */ +export default function FileBrowser({ onSelect, onClose }) { + const [path, setPath] = useState(''); + const [parent, setParent] = useState(null); + const [dirs, setDirs] = useState([]); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const navigate = useCallback(async (dir) => { + setLoading(true); + setError(null); + try { + const data = await api.browse(dir); + setPath(data.path); + setParent(data.parent); + setDirs(data.dirs); + setFiles(data.files); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + // Start at home directory on mount + useEffect(() => { + navigate(null); + }, [navigate]); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+ {/* Header */} +
+ {path} + +
+ + {/* File list */} +
+ {loading &&
Loading…
} + {error &&
Error: {error}
} + + {!loading && !error && ( + <> + {/* Parent directory */} + {parent && ( +
navigate(parent)}> + ⬆ .. +
+ )} + + {/* Directories */} + {dirs.map((d) => ( +
navigate(path + '/' + d)} + > + 📁 {d} +
+ ))} + + {/* Files */} + {files.map((f) => ( +
{ onSelect(path + '/' + f); onClose(); }} + > + {f} +
+ ))} + + {dirs.length === 0 && files.length === 0 && ( +
Empty directory
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/SurfaceView.jsx b/frontend/src/SurfaceView.jsx new file mode 100644 index 0000000..2a3d5bb --- /dev/null +++ b/frontend/src/SurfaceView.jsx @@ -0,0 +1,183 @@ +import React, { useRef, useEffect, useCallback } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +/** + * Interactive 3D surface viewer using Three.js. + * Props: + * meshData: { width, height, z_data (b64 float32), colors (b64 uint8 RGB), + * z_min, z_max, z_scale, x_range, y_range } + */ +export default function SurfaceView({ meshData }) { + const containerRef = useRef(null); + const threeRef = useRef(null); // { renderer, scene, camera, controls, mesh } + + // Decode base64 to typed arrays + const decode = useCallback((b64, ArrayType) => { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new ArrayType(bytes.buffer); + }, []); + + // Initialize Three.js scene once + useEffect(() => { + const container = containerRef.current; + if (!container || threeRef.current) return; + + const width = container.clientWidth; + const height = width; // 1:1 aspect + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + renderer.setSize(width, height); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setClearColor(0x0f172a); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + + const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000); + camera.position.set(1.2, 0.8, 1.2); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.minDistance = 0.3; + controls.maxDistance = 10; + + // Lighting + const ambient = new THREE.AmbientLight(0xffffff, 0.4); + scene.add(ambient); + const dir = new THREE.DirectionalLight(0xffffff, 0.8); + dir.position.set(1, 2, 1.5); + scene.add(dir); + const dir2 = new THREE.DirectionalLight(0xffffff, 0.3); + dir2.position.set(-1, 0.5, -1); + scene.add(dir2); + + // Animation loop + let animId; + const animate = () => { + animId = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + threeRef.current = { renderer, scene, camera, controls, mesh: null, animId }; + + // Resize observer to maintain 1:1 aspect when node width changes + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry || !threeRef.current) return; + const w = entry.contentRect.width; + if (w < 1) return; + const { renderer: r, camera: c } = threeRef.current; + r.setSize(w, w); + c.aspect = 1; + c.updateProjectionMatrix(); + }); + ro.observe(container); + + return () => { + ro.disconnect(); + cancelAnimationFrame(animId); + controls.dispose(); + renderer.dispose(); + if (container.contains(renderer.domElement)) { + container.removeChild(renderer.domElement); + } + threeRef.current = null; + }; + }, []); + + // Update mesh when data changes + useEffect(() => { + if (!threeRef.current || !meshData) return; + + const { scene, camera, controls } = threeRef.current; + const { width: nx, height: ny, z_data, colors, z_min, z_max, z_scale, x_range, y_range } = meshData; + + // Decode arrays + const zArr = decode(z_data, Float32Array); + const colArr = decode(colors, Uint8Array); + + // Remove old mesh + if (threeRef.current.mesh) { + scene.remove(threeRef.current.mesh); + threeRef.current.mesh.geometry.dispose(); + threeRef.current.mesh.material.dispose(); + } + + // Build geometry + const geom = new THREE.BufferGeometry(); + const positions = new Float32Array(nx * ny * 3); + const colorAttr = new Float32Array(nx * ny * 3); + + // Normalize coordinates to roughly [-0.5, 0.5] for good camera framing + const zRange = z_max - z_min || 1; + + for (let iy = 0; iy < ny; iy++) { + for (let ix = 0; ix < nx; ix++) { + const idx = iy * nx + ix; + const px = ix / (nx - 1) - 0.5; // [-0.5, 0.5] + const py = iy / (ny - 1) - 0.5; + const pz = ((zArr[idx] - z_min) / zRange - 0.5) * z_scale; + + positions[idx * 3] = px; + positions[idx * 3 + 1] = pz; // height on Y axis + positions[idx * 3 + 2] = py; + + colorAttr[idx * 3] = colArr[idx * 3] / 255; + colorAttr[idx * 3 + 1] = colArr[idx * 3 + 1] / 255; + colorAttr[idx * 3 + 2] = colArr[idx * 3 + 2] / 255; + } + } + + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geom.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3)); + + // Build index (triangles from grid) + const indices = []; + for (let iy = 0; iy < ny - 1; iy++) { + for (let ix = 0; ix < nx - 1; ix++) { + const a = iy * nx + ix; + const b = a + 1; + const c = a + nx; + const d = c + 1; + indices.push(a, c, b); + indices.push(b, c, d); + } + } + geom.setIndex(indices); + geom.computeVertexNormals(); + + const mat = new THREE.MeshPhongMaterial({ + vertexColors: true, + side: THREE.DoubleSide, + shininess: 30, + flatShading: false, + }); + + const mesh = new THREE.Mesh(geom, mat); + scene.add(mesh); + threeRef.current.mesh = mesh; + + // Reset camera target to center of mesh + controls.target.set(0, 0, 0); + controls.update(); + }, [meshData, decode]); + + // Prevent scroll events from propagating to React Flow + const onWheel = useCallback((e) => { + e.stopPropagation(); + }, []); + + return ( +
+ ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..b90acae --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,93 @@ +/** + * api.js — REST + WebSocket client for argonode backend. + * + * Uses relative URLs so the Vite dev proxy (port 5173 → 8188) + * and production same-origin serving both work transparently. + */ + +// ── REST helpers ────────────────────────────────────────────────────── + +export async function getNodes() { + const r = await fetch('/nodes'); + if (!r.ok) throw new Error(`GET /nodes failed: ${r.status}`); + return r.json(); +} + +export async function getFiles() { + const r = await fetch('/files'); + if (!r.ok) return []; + return r.json(); +} + +export async function browse(dir) { + const url = dir ? `/browse?dir=${encodeURIComponent(dir)}` : '/browse'; + const r = await fetch(url); + if (!r.ok) throw new Error(`Browse failed: ${r.status}`); + return r.json(); +} + +export async function uploadFile(file) { + const fd = new FormData(); + fd.append('file', file); + const r = await fetch('/upload', { method: 'POST', body: fd }); + if (!r.ok) throw new Error(`Upload failed: ${r.status}`); + return r.json(); +} + +export async function runPrompt(prompt) { + const r = await fetch('/prompt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + if (!r.ok) { + const text = await r.text(); + throw new Error(`POST /prompt failed (${r.status}): ${text}`); + } + return r.json(); +} + +// ── WebSocket ───────────────────────────────────────────────────────── + +let _ws = null; +let _handler = null; +let _reconnectTimer = null; + +export function setMessageHandler(fn) { + _handler = fn; +} + +export function initWS() { + if (_ws && _ws.readyState < 2) return; // already open or connecting + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + _ws = new WebSocket(`${protocol}//${window.location.host}/ws`); + + _ws.onopen = () => { + console.log('[argonode] WebSocket connected'); + }; + + _ws.onclose = () => { + console.log('[argonode] WebSocket closed, reconnecting in 3s…'); + clearTimeout(_reconnectTimer); + _reconnectTimer = setTimeout(() => initWS(), 3000); + }; + + _ws.onerror = (e) => { + console.error('[argonode] WebSocket error', e); + }; + + _ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (_handler) _handler(msg); + } catch { + // ignore malformed messages + } + }; +} + +export function closeWS() { + clearTimeout(_reconnectTimer); + if (_ws) _ws.close(); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..02a0552 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')).render(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..27a0fab --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,509 @@ +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, #root { + width: 100%; + height: 100%; + background: #1a1a2e; + color: #e0e0e0; + font-family: "Inter", "Segoe UI", system-ui, sans-serif; + font-size: 13px; + overflow: hidden; +} + +.app-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +/* ── Toolbar ───────────────────────────────────────────────────────── */ +#toolbar { + height: 44px; + background: #16213e; + border-bottom: 1px solid #0f3460; + display: flex; + align-items: center; + padding: 0 12px; + gap: 10px; + z-index: 100; + user-select: none; + flex-shrink: 0; +} + +#app-title { + font-size: 15px; + font-weight: 700; + letter-spacing: 0.5px; + color: #e94560; + margin-right: 8px; + flex-shrink: 0; +} + +.toolbar-group { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* ── Buttons ───────────────────────────────────────────────────────── */ +.btn { + padding: 5px 12px; + border: 1px solid #0f3460; + border-radius: 5px; + background: #0f3460; + color: #e0e0e0; + font-size: 12px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + white-space: nowrap; +} +.btn:hover { + background: #1a4a8a; + border-color: #3a7abf; +} +.btn:active { + background: #0a2040; +} +.btn-primary { + background: #e94560; + border-color: #e94560; + font-weight: 600; +} +.btn-primary:hover { + background: #ff6b81; + border-color: #ff6b81; +} + +/* ── Status bar ────────────────────────────────────────────────────── */ +.status-bar { + margin-left: auto; + padding: 4px 10px; + border-radius: 4px; + font-size: 11px; + max-width: 400px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 1; +} +.status-bar.info { color: #90caf9; } +.status-bar.error { color: #ef9a9a; background: rgba(183,28,28,0.2); } + +/* ── React Flow container ──────────────────────────────────────────── */ +.flow-container { + flex: 1; + position: relative; +} + +/* ── React Flow dark overrides ─────────────────────────────────────── */ +.react-flow { + background: #0d1117 !important; +} + +/* ── Custom node ───────────────────────────────────────────────────── */ +.custom-node { + background: #1e293b; + border: 1px solid #334155; + border-radius: 6px; + font-size: 11px; + color: #e0e0e0; + width: 200px; + min-width: 160px; + resize: horizontal; + overflow: hidden; +} + +/* Let React Flow node wrapper fit to the custom-node's size */ +.react-flow__node-custom { + width: auto !important; + height: auto !important; +} + +/* Title bar is the drag handle for moving the node */ +.drag-handle { + cursor: grab; +} +.drag-handle:active { + cursor: grabbing; +} + +.custom-node.selected { + border-color: #90caf9; +} + +.node-title { + padding: 5px 10px; + font-weight: 600; + font-size: 12px; + color: white; + border-radius: 5px 5px 0 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.3); +} + +.node-body { + padding: 4px 0; +} + +/* ── I/O rows ──────────────────────────────────────────────────────── */ +.io-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 3px 12px; + min-height: 22px; + position: relative; +} + +.io-left, .io-right { + display: flex; + align-items: center; + gap: 4px; +} + +.io-label { + font-size: 10px; + color: #94a3b8; +} + +/* ── Handles ───────────────────────────────────────────────────────── */ +.typed-handle { + width: 10px !important; + height: 10px !important; + border: 2px solid #1e293b !important; + border-radius: 50% !important; +} + +/* ── Widget rows ───────────────────────────────────────────────────── */ +.widget-row { + padding: 3px 10px; + display: flex; + align-items: center; + gap: 6px; +} + +.widget-row label { + font-size: 10px; + color: #64748b; + min-width: 40px; + flex-shrink: 0; +} + +.widget-row input[type="text"], +.widget-row input[type="number"], +.widget-row select { + background: #0f172a; + color: #e0e0e0; + border: 1px solid #334155; + border-radius: 3px; + padding: 2px 5px; + font-size: 11px; + flex: 1; + min-width: 0; +} + +.widget-row input[type="checkbox"] { + accent-color: #3a7abf; +} + +.widget-row input:focus, +.widget-row select:focus { + outline: none; + border-color: #3a7abf; +} + +.file-picker-row { + display: flex; + gap: 4px; + flex: 1; + min-width: 0; +} + +.file-picker-row input { + flex: 1; + min-width: 0; +} + +/* ── Draggable number ──────────────────────────────────────────────── */ +.drag-number { + flex: 1; + min-width: 0; + background: #0f172a; + border: 1px solid #334155; + border-radius: 3px; + padding: 2px 6px; + cursor: ew-resize; + user-select: none; + text-align: center; + font-size: 11px; + color: #e0e0e0; + touch-action: none; +} +.drag-number:hover { + border-color: #3a7abf; +} +.drag-number-val { + pointer-events: none; +} +.drag-number-edit { + flex: 1; + min-width: 0; + background: #0f172a; + border: 1px solid #3a7abf; + border-radius: 3px; + padding: 2px 5px; + font-size: 11px; + color: #e0e0e0; + text-align: center; + outline: none; +} + +.browse-btn { + background: #0f3460; + color: #e0e0e0; + border: 1px solid #334155; + border-radius: 3px; + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + white-space: nowrap; +} +.browse-btn:hover { + background: #1a4a8a; +} + +/* ── Collapsible section ───────────────────────────────────────────── */ +.collapsible { + border-top: 1px solid #334155; + margin-top: 4px; +} +.collapsible-toggle { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + background: none; + border: none; + color: #64748b; + font-size: 10px; + padding: 3px 10px; + cursor: pointer; + text-align: left; +} +.collapsible-toggle:hover { + color: #94a3b8; +} +.collapsible-arrow { + font-size: 9px; +} + +/* ── Node preview ──────────────────────────────────────────────────── */ +.node-preview { + overflow: hidden; +} + +.node-preview img { + width: 100%; + max-width: 100%; + display: block; +} + +/* ── Cross-section overlay ────────────────────────────────────────── */ +.cs-overlay { + position: relative; + user-select: none; + touch-action: none; + overflow: hidden; +} +.cs-image { + width: 100%; + display: block; +} +.cs-svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} +.cs-marker { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #ffd700; + border: 2px solid #fff; + transform: translate(-50%, -50%); + cursor: grab; + box-shadow: 0 0 4px rgba(0,0,0,0.6); + z-index: 1; +} +.cs-marker:active:not(.cs-marker-locked) { + cursor: grabbing; + background: #ffeb3b; + transform: translate(-50%, -50%) scale(1.2); +} +.cs-marker-locked { + background: #e91e63; + border-color: #e91e63; + cursor: default; + opacity: 0.9; +} + +/* ── 3D surface view ──────────────────────────────────────────────── */ +.surface-view-container { + width: 100%; + aspect-ratio: 1 / 1; + cursor: grab; + overflow: hidden; +} +.surface-view-container:active { + cursor: grabbing; +} +.surface-view-container canvas { + display: block; +} + +/* ── Node table ────────────────────────────────────────────────────── */ +.node-table { + padding: 4px 10px; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 10px; + color: #cbd5e1; +} + +.table-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5; +} + +/* ── Node resize handles ───────────────────────────────────────────── */ +.node-resize-line { + border-color: #90caf9 !important; +} +.node-resize-handle { + background: #90caf9 !important; + width: 8px !important; + height: 8px !important; +} + +/* ── Context menu ──────────────────────────────────────────────────── */ +.context-menu { + position: fixed; + z-index: 1000; + background: #16213e; + border: 1px solid #0f3460; + border-radius: 6px; + min-width: 180px; + max-height: 60vh; + overflow-y: auto; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + padding: 4px 0; +} + +.context-category { + padding: 6px 12px 3px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #64748b; + border-top: 1px solid #0f3460; +} +.context-category:first-child { + border-top: none; +} + +.context-item { + padding: 5px 20px; + font-size: 12px; + cursor: pointer; + color: #e0e0e0; +} +.context-item:hover { + background: #0f3460; +} + +/* ── File browser dialog ──────────────────────────────────────────── */ +.fb-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} +.fb-dialog { + background: #16213e; + border: 1px solid #0f3460; + border-radius: 8px; + width: 520px; + max-height: 70vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.fb-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid #0f3460; +} +.fb-path { + font-size: 12px; + color: #90caf9; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 10px; +} +.fb-close { + background: none; + border: none; + color: #e0e0e0; + font-size: 16px; + cursor: pointer; + padding: 2px 6px; +} +.fb-close:hover { color: #e94560; } +.fb-list { + overflow-y: auto; + padding: 6px 0; + flex: 1; +} +.fb-entry { + padding: 6px 14px; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.fb-entry:hover { background: #0f3460; } +.fb-dir { color: #90caf9; } +.fb-file { color: #e0e0e0; } +.fb-loading { + padding: 16px; + text-align: center; + color: #607d8b; +} + +/* ── 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; } + +/* ── React Flow MiniMap ────────────────────────────────────────────── */ +.react-flow__minimap { + background: #16213e !important; + border: 1px solid #0f3460 !important; + border-radius: 4px !important; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..2182b78 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/nodes': 'http://127.0.0.1:8188', + '/files': 'http://127.0.0.1:8188', + '/browse': 'http://127.0.0.1:8188', + '/upload': 'http://127.0.0.1:8188', + '/prompt': 'http://127.0.0.1:8188', + '/ws': { + target: 'http://127.0.0.1:8188', + ws: true, + }, + }, + }, + build: { + outDir: 'dist', + }, +}); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/output/01_sines_input.png b/tests/output/01_sines_input.png new file mode 100644 index 0000000..4e6a8cc Binary files /dev/null and b/tests/output/01_sines_input.png differ diff --git a/tests/output/01_sines_log_magnitude.png b/tests/output/01_sines_log_magnitude.png new file mode 100644 index 0000000..f386c38 Binary files /dev/null and b/tests/output/01_sines_log_magnitude.png differ diff --git a/tests/output/01_sines_magnitude.png b/tests/output/01_sines_magnitude.png new file mode 100644 index 0000000..ab54fbc Binary files /dev/null and b/tests/output/01_sines_magnitude.png differ diff --git a/tests/output/01_sines_psdf.png b/tests/output/01_sines_psdf.png new file mode 100644 index 0000000..e4e4253 Binary files /dev/null and b/tests/output/01_sines_psdf.png differ diff --git a/tests/output/02_surface_fft_level_mean.png b/tests/output/02_surface_fft_level_mean.png new file mode 100644 index 0000000..2058929 Binary files /dev/null and b/tests/output/02_surface_fft_level_mean.png differ diff --git a/tests/output/02_surface_fft_level_none.png b/tests/output/02_surface_fft_level_none.png new file mode 100644 index 0000000..e35f80d Binary files /dev/null and b/tests/output/02_surface_fft_level_none.png differ diff --git a/tests/output/02_surface_fft_level_plane.png b/tests/output/02_surface_fft_level_plane.png new file mode 100644 index 0000000..c274b44 Binary files /dev/null and b/tests/output/02_surface_fft_level_plane.png differ diff --git a/tests/output/02_surface_input.png b/tests/output/02_surface_input.png new file mode 100644 index 0000000..91c2922 Binary files /dev/null and b/tests/output/02_surface_input.png differ diff --git a/tests/output/03_checker_fft.png b/tests/output/03_checker_fft.png new file mode 100644 index 0000000..25f5fdc Binary files /dev/null and b/tests/output/03_checker_fft.png differ diff --git a/tests/output/03_checker_input.png b/tests/output/03_checker_input.png new file mode 100644 index 0000000..94d22b4 Binary files /dev/null and b/tests/output/03_checker_input.png differ diff --git a/tests/output/04_rings_fft.png b/tests/output/04_rings_fft.png new file mode 100644 index 0000000..18f6908 Binary files /dev/null and b/tests/output/04_rings_fft.png differ diff --git a/tests/output/04_rings_input.png b/tests/output/04_rings_input.png new file mode 100644 index 0000000..37f8c94 Binary files /dev/null and b/tests/output/04_rings_input.png differ diff --git a/tests/output/05_window_blackman.png b/tests/output/05_window_blackman.png new file mode 100644 index 0000000..f1c399d Binary files /dev/null and b/tests/output/05_window_blackman.png differ diff --git a/tests/output/05_window_hamming.png b/tests/output/05_window_hamming.png new file mode 100644 index 0000000..198949b Binary files /dev/null and b/tests/output/05_window_hamming.png differ diff --git a/tests/output/05_window_hann.png b/tests/output/05_window_hann.png new file mode 100644 index 0000000..5c2b0be Binary files /dev/null and b/tests/output/05_window_hann.png differ diff --git a/tests/output/05_window_input.png b/tests/output/05_window_input.png new file mode 100644 index 0000000..ab8ee0e Binary files /dev/null and b/tests/output/05_window_input.png differ diff --git a/tests/output/05_window_none.png b/tests/output/05_window_none.png new file mode 100644 index 0000000..3086205 Binary files /dev/null and b/tests/output/05_window_none.png differ diff --git a/tests/test_fft.py b/tests/test_fft.py new file mode 100644 index 0000000..930daf4 --- /dev/null +++ b/tests/test_fft.py @@ -0,0 +1,258 @@ +""" +Test the FFT2D node against known inputs and Gwyddion-equivalent results. + +Run from project root: + python -m tests.test_fft +""" +import sys +import numpy as np + +sys.path.insert(0, ".") +from backend.data_types import DataField +from backend.nodes.analysis import FFT2D + + +def make_field(data, xreal=1e-6, yreal=1e-6): + """Create a DataField from a 2D array.""" + return DataField(data=data, xreal=xreal, yreal=yreal, si_unit_xy="m", si_unit_z="m") + + +def test_dc_removal(): + """A constant image should produce near-zero FFT after mean subtraction.""" + print("=== Test: DC removal ===") + data = np.ones((64, 64)) * 42.0 + field = make_field(data) + node = FFT2D() + + result, = node.process(field, windowing="none", level="mean", output="magnitude") + peak = result.data.max() + print(f" Peak magnitude after mean subtraction of constant image: {peak:.2e}") + assert peak < 1e-10, f"Expected ~0, got {peak}" + print(" PASS\n") + + +def test_single_frequency(): + """A pure sine wave should produce two peaks at the known frequency.""" + print("=== Test: Single frequency detection ===") + N = 128 + xreal = 1e-6 # 1 micron + freq_cycles = 10 # 10 cycles across the image + + x = np.linspace(0, 1, N, endpoint=False) + data = np.sin(2 * np.pi * freq_cycles * x)[np.newaxis, :] * np.ones((N, 1)) + field = make_field(data, xreal=xreal, yreal=xreal) + + node = FFT2D() + result, = node.process(field, windowing="none", level="mean", output="magnitude") + + # The peak should be at column offset = freq_cycles from center + mag = result.data + cy, cx = N // 2, N // 2 # center (DC) + + # Find the peak location (excluding DC which should be ~0 after mean sub) + mag_copy = mag.copy() + mag_copy[cy, cx] = 0 + peak_idx = np.unravel_index(np.argmax(mag_copy), mag.shape) + peak_col_offset = abs(peak_idx[1] - cx) + + print(f" Image: {N}x{N}, {freq_cycles} horizontal cycles") + print(f" Expected peak at column offset {freq_cycles} from center") + print(f" Found peak at {peak_idx} (offset {peak_col_offset})") + print(f" DC value: {mag[cy, cx]:.2e}") + print(f" Peak value: {mag[peak_idx]:.2e}") + assert peak_col_offset == freq_cycles, f"Expected offset {freq_cycles}, got {peak_col_offset}" + assert peak_idx[0] == cy, f"Expected peak on center row, got row {peak_idx[0]}" + print(" PASS\n") + + +def test_2d_frequency(): + """A 2D sine should produce peaks at the correct (kx, ky) position.""" + print("=== Test: 2D frequency detection ===") + N = 128 + fx, fy = 8, 5 # cycles in x and y + + y, x = np.mgrid[0:N, 0:N] / N + data = np.sin(2 * np.pi * (fx * x + fy * y)) + field = make_field(data) + + node = FFT2D() + result, = node.process(field, windowing="none", level="mean", output="magnitude") + mag = result.data + + cy, cx = N // 2, N // 2 + mag_copy = mag.copy() + mag_copy[cy, cx] = 0 + peak_idx = np.unravel_index(np.argmax(mag_copy), mag.shape) + + dx = abs(peak_idx[1] - cx) + dy = abs(peak_idx[0] - cy) + + print(f" Input: sin(2π({fx}x + {fy}y))") + print(f" Expected peak offset: ({fy}, {fx}) from center") + print(f" Found peak at {peak_idx} (offset dy={dy}, dx={dx})") + assert dx == fx and dy == fy, f"Expected ({fy},{fx}), got ({dy},{dx})" + print(" PASS\n") + + +def test_psdf_normalization(): + """ + PSDF of white noise should integrate to the variance. + + Parseval's theorem: sum of PSDF * dk_x * dk_y ≈ variance of the signal. + """ + print("=== Test: PSDF normalization (Parseval) ===") + N = 256 + xreal = 1e-6 + rng = np.random.default_rng(42) + data = rng.standard_normal((N, N)) + variance = data.var() + + field = make_field(data, xreal=xreal, yreal=xreal) + node = FFT2D() + + result, = node.process(field, windowing="none", level="none", output="psdf") + psdf = result.data + + # Integrate: sum of PSDF * dk_x * dk_y + # Our output field has xreal = 2π*N/xreal (angular freq range) + dk_x = result.xreal / N + dk_y = result.yreal / N + integral = psdf.sum() * dk_x * dk_y + + ratio = integral / variance + print(f" Signal variance: {variance:.6f}") + print(f" PSDF integral: {integral:.6f}") + print(f" Ratio (should be ~1.0): {ratio:.4f}") + # Allow 20% tolerance for finite-size effects + assert 0.8 < ratio < 1.2, f"Parseval's theorem violated: ratio = {ratio}" + print(" PASS\n") + + +def test_windowing_reduces_leakage(): + """Windowing should reduce spectral leakage from a non-integer frequency.""" + print("=== Test: Windowing reduces leakage ===") + N = 128 + freq = 10.5 # non-integer → spectral leakage without windowing + + x = np.linspace(0, 1, N, endpoint=False) + data = np.sin(2 * np.pi * freq * x)[np.newaxis, :] * np.ones((N, 1)) + field = make_field(data) + + node = FFT2D() + + # Without windowing + r_none, = node.process(field, windowing="none", level="mean", output="magnitude") + mag_none = r_none.data[N // 2, :] # center row + + # With Hann windowing + r_hann, = node.process(field, windowing="hann", level="mean", output="magnitude") + mag_hann = r_hann.data[N // 2, :] + + # Measure leakage: ratio of energy far from peak vs total + peak_col = np.argmax(mag_none) + far_mask = np.ones(N, dtype=bool) + far_mask[max(0, peak_col - 3):peak_col + 4] = False + # Also mask the symmetric peak + sym_col = N - peak_col + far_mask[max(0, sym_col - 3):sym_col + 4] = False + + leakage_none = mag_none[far_mask].sum() / mag_none.sum() + leakage_hann = mag_hann[far_mask].sum() / mag_hann.sum() + + print(f" Non-integer frequency: {freq}") + print(f" Leakage without windowing: {leakage_none:.4f}") + print(f" Leakage with Hann window: {leakage_hann:.4f}") + assert leakage_hann < leakage_none, "Hann window should reduce leakage" + print(" PASS\n") + + +def test_plane_subtraction(): + """Plane subtraction should remove linear gradients.""" + print("=== Test: Plane subtraction ===") + N = 64 + y, x = np.mgrid[0:N, 0:N] / N + # Tilted plane + sine wave + data = 100 * x + 50 * y + np.sin(2 * np.pi * 8 * x) + field = make_field(data) + + node = FFT2D() + + # Without leveling — huge DC and low-freq energy + r_none, = node.process(field, windowing="none", level="none", output="magnitude") + dc_none = r_none.data[N // 2, N // 2] + + # With mean subtraction — DC removed but gradient leaks + r_mean, = node.process(field, windowing="none", level="mean", output="magnitude") + dc_mean = r_mean.data[N // 2, N // 2] + + # With plane subtraction — gradient removed + r_plane, = node.process(field, windowing="none", level="plane", output="magnitude") + dc_plane = r_plane.data[N // 2, N // 2] + + # With plane subtraction, check the low-freq energy near DC is reduced + # (plane subtraction removes gradients that leak into low frequencies) + r = 3 # radius around DC to check + cy, cx = N // 2, N // 2 + lowfreq_none = r_none.data[cy-r:cy+r+1, cx-r:cx+r+1].sum() + lowfreq_plane = r_plane.data[cy-r:cy+r+1, cx-r:cx+r+1].sum() + + print(f" DC magnitude (no leveling): {dc_none:.2e}") + print(f" DC magnitude (mean subtract): {dc_mean:.2e}") + print(f" DC magnitude (plane subtract): {dc_plane:.2e}") + print(f" Low-freq energy (no level): {lowfreq_none:.2e}") + print(f" Low-freq energy (plane sub): {lowfreq_plane:.2e}") + assert dc_mean < dc_none, "Mean subtraction should reduce DC" + assert lowfreq_plane < lowfreq_none * 0.01, "Plane subtraction should reduce low-freq energy" + print(" PASS\n") + + +def test_non_square(): + """FFT should work on non-square, non-power-of-2 images.""" + print("=== Test: Non-square image ===") + data = np.random.default_rng(99).standard_normal((100, 150)) + field = make_field(data, xreal=1.5e-6, yreal=1.0e-6) + node = FFT2D() + + result, = node.process(field, windowing="hann", level="mean", output="log_magnitude") + assert result.data.shape == (100, 150), f"Shape mismatch: {result.data.shape}" + assert np.all(np.isfinite(result.data)), "Non-finite values in output" + print(f" Shape: {result.data.shape}") + print(f" Output range: [{result.data.min():.4f}, {result.data.max():.4f}]") + print(" PASS\n") + + +def test_log_magnitude_visual_range(): + """Log magnitude should produce a reasonable dynamic range for display.""" + print("=== Test: Log magnitude visual range ===") + N = 128 + x = np.linspace(0, 1, N, endpoint=False) + # Multi-frequency test image + y, x = np.mgrid[0:N, 0:N] / N + data = (np.sin(2 * np.pi * 5 * x) + + 0.5 * np.sin(2 * np.pi * 15 * x + 2 * np.pi * 10 * y) + + 0.1 * np.random.default_rng(7).standard_normal((N, N))) + field = make_field(data) + + node = FFT2D() + result, = node.process(field, windowing="hann", level="mean", output="log_magnitude") + + vmin, vmax = result.data.min(), result.data.max() + dynamic_range = vmax - vmin if vmin > 0 else vmax / max(abs(vmin), 1e-30) + + print(f" Log magnitude range: [{vmin:.4f}, {vmax:.4f}]") + print(f" Dynamic range: {dynamic_range:.2f}") + assert vmax > vmin, "Log magnitude should have nonzero range" + assert np.all(np.isfinite(result.data)), "Non-finite values in log magnitude" + print(" PASS\n") + + +if __name__ == "__main__": + test_dc_removal() + test_single_frequency() + test_2d_frequency() + test_psdf_normalization() + test_windowing_reduces_leakage() + test_plane_subtraction() + test_non_square() + test_log_magnitude_visual_range() + print("All tests passed!") diff --git a/tests/test_fft_visual.py b/tests/test_fft_visual.py new file mode 100644 index 0000000..e8c2cb9 --- /dev/null +++ b/tests/test_fft_visual.py @@ -0,0 +1,97 @@ +""" +Generate test images and their FFT outputs for visual comparison with Gwyddion. +Saves PNG files to tests/output/. + +Run: .venv/bin/python -m tests.test_fft_visual +""" +import sys +import os +import numpy as np + +sys.path.insert(0, ".") +from backend.data_types import DataField, datafield_to_uint8, encode_preview +from backend.nodes.analysis import FFT2D + +OUT_DIR = os.path.join(os.path.dirname(__file__), "output") +os.makedirs(OUT_DIR, exist_ok=True) + + +def save_field(field, name, colormap="viridis"): + """Save a DataField as a PNG for visual inspection.""" + from PIL import Image + arr = datafield_to_uint8(field, colormap) + img = Image.fromarray(arr) + path = os.path.join(OUT_DIR, f"{name}.png") + img.save(path) + print(f" Saved {path} (range: [{field.data.min():.4g}, {field.data.max():.4g}])") + + +def make_field(data, xreal=1e-6, yreal=1e-6): + return DataField(data=data, xreal=xreal, yreal=yreal) + + +def main(): + node = FFT2D() + N = 256 + + # --- Test 1: Multi-frequency sine waves --- + print("Test 1: Multi-frequency sine waves") + y, x = np.mgrid[0:N, 0:N] / N + data = (np.sin(2 * np.pi * 10 * x) + + 0.7 * np.sin(2 * np.pi * 25 * y) + + 0.3 * np.sin(2 * np.pi * (15 * x + 8 * y))) + field = make_field(data) + save_field(field, "01_sines_input") + + for output_mode in ["log_magnitude", "magnitude", "psdf"]: + result, = node.process(field, windowing="hann", level="mean", output=output_mode) + save_field(result, f"01_sines_{output_mode}") + + # --- Test 2: Real-world-like surface with noise + tilt --- + print("\nTest 2: Tilted surface with features") + rng = np.random.default_rng(42) + data = (50 * x + 30 * y # tilt + + np.sin(2 * np.pi * 20 * x) # periodic feature + + 0.5 * rng.standard_normal((N, N))) # noise + field = make_field(data) + save_field(field, "02_surface_input") + + for level_mode in ["none", "mean", "plane"]: + result, = node.process(field, windowing="hann", level=level_mode, output="log_magnitude") + save_field(result, f"02_surface_fft_level_{level_mode}") + + # --- Test 3: Checkerboard pattern --- + print("\nTest 3: Checkerboard") + freq = 16 + data = np.sign(np.sin(2 * np.pi * freq * x) * np.sin(2 * np.pi * freq * y)) + field = make_field(data) + save_field(field, "03_checker_input") + + result, = node.process(field, windowing="none", level="mean", output="log_magnitude") + save_field(result, "03_checker_fft") + + # --- Test 4: Concentric rings (radial frequency) --- + print("\nTest 4: Concentric rings") + r = np.sqrt((x - 0.5)**2 + (y - 0.5)**2) + data = np.sin(2 * np.pi * 30 * r) + field = make_field(data) + save_field(field, "04_rings_input") + + result, = node.process(field, windowing="hann", level="mean", output="log_magnitude") + save_field(result, "04_rings_fft") + + # --- Test 5: Compare windowing effects --- + print("\nTest 5: Windowing comparison") + data = np.sin(2 * np.pi * 10.5 * x) + 0.5 * np.sin(2 * np.pi * 30.3 * y) + field = make_field(data) + save_field(field, "05_window_input") + + for win in ["none", "hann", "hamming", "blackman"]: + result, = node.process(field, windowing=win, level="mean", output="log_magnitude") + save_field(result, f"05_window_{win}") + + print(f"\nAll outputs saved to {OUT_DIR}/") + + +if __name__ == "__main__": + main() diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..5f90f4d --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,488 @@ +""" +Tests for all argonode backend nodes (excluding FFT2D which has its own test file). + +Run from project root: + .venv/bin/python -m tests.test_nodes +""" +import sys +import os +import tempfile +import numpy as np + +sys.path.insert(0, ".") +from backend.data_types import DataField + + +def make_field(data=None, shape=(64, 64), xreal=1e-6, yreal=1e-6): + """Create a DataField, optionally from given data or a random field.""" + if data is None: + data = np.random.default_rng(42).standard_normal(shape) + return DataField(data=data, xreal=xreal, yreal=yreal, si_unit_xy="m", si_unit_z="m") + + +# ========================================================================= +# Filters +# ========================================================================= + +def test_gaussian_filter(): + print("=== Test: GaussianFilter ===") + from backend.nodes.filters import GaussianFilter + node = GaussianFilter() + field = make_field() + + result, = node.process(field, sigma=2.0) + assert result.data.shape == field.data.shape + assert result.xreal == field.xreal + assert result.si_unit_z == field.si_unit_z + # Gaussian blur should reduce variance + assert result.data.std() < field.data.std() + # With very small sigma, output should be nearly unchanged + result_tiny, = node.process(field, sigma=0.01) + assert np.allclose(result_tiny.data, field.data, atol=1e-6) + print(" PASS\n") + + +def test_median_filter(): + print("=== Test: MedianFilter ===") + from backend.nodes.filters import MedianFilter + node = MedianFilter() + + # Median filter should remove salt-and-pepper noise + data = np.zeros((64, 64)) + rng = np.random.default_rng(7) + noise_idx = rng.choice(64 * 64, size=100, replace=False) + data.ravel()[noise_idx] = 1.0 + field = make_field(data=data) + + result, = node.process(field, size=3) + assert result.data.shape == field.data.shape + # Should remove most impulse noise + assert result.data.sum() < field.data.sum() + # Size=1 should be identity + result_1, = node.process(field, size=1) + assert np.array_equal(result_1.data, field.data) + print(" PASS\n") + + +def test_edge_detect(): + print("=== Test: EdgeDetect ===") + from backend.nodes.filters import EdgeDetect + node = EdgeDetect() + + # Create an image with a sharp vertical edge + data = np.zeros((64, 64)) + data[:, 32:] = 1.0 + field = make_field(data=data) + + for method in ["sobel", "prewitt", "laplacian", "log"]: + result, = node.process(field, method=method, sigma=1.0) + assert result.data.shape == field.data.shape + # Edge response should be strongest near column 32 + col_energy = np.abs(result.data).sum(axis=0) + peak_col = np.argmax(col_energy) + assert abs(peak_col - 32) <= 2, f"{method}: peak at col {peak_col}, expected ~32" + + print(" PASS\n") + + +# ========================================================================= +# Level +# ========================================================================= + +def test_plane_level(): + print("=== Test: PlaneLevelField ===") + from backend.nodes.level import PlaneLevelField + node = PlaneLevelField() + + # Create a tilted plane + small signal + N = 64 + y, x = np.mgrid[0:N, 0:N] / N + signal = np.sin(2 * np.pi * 5 * x) + data = 100 * x + 50 * y + signal + field = make_field(data=data) + + result, = node.process(field) + assert result.data.shape == field.data.shape + # After plane leveling, mean should be near zero + assert abs(result.data.mean()) < 1e-10 + # The signal should remain (correlation with original sine) + corr = np.corrcoef(result.data.ravel(), signal.ravel())[0, 1] + assert corr > 0.98, f"Signal correlation after leveling: {corr}" + print(" PASS\n") + + +def test_poly_level(): + print("=== Test: PolyLevelField ===") + from backend.nodes.level import PolyLevelField + node = PolyLevelField() + + N = 64 + y, x = np.mgrid[0:N, 0:N] / N + # Quadratic background + signal + background = 50 * x**2 + 30 * y**2 + 10 * x * y + signal = np.sin(2 * np.pi * 8 * x) + data = background + signal + field = make_field(data=data) + + leveled, bg = node.process(field, degree_x=2, degree_y=2) + assert leveled.data.shape == field.data.shape + assert bg.data.shape == field.data.shape + # leveled + bg should reconstruct original + assert np.allclose(leveled.data + bg.data, field.data, atol=1e-10) + # Signal should be preserved after leveling + corr = np.corrcoef(leveled.data.ravel(), signal.ravel())[0, 1] + assert corr > 0.95, f"Signal correlation after poly leveling: {corr}" + # Degree 0 should just subtract the mean + leveled_0, bg_0 = node.process(field, degree_x=0, degree_y=0) + assert abs(leveled_0.data.mean()) < 1e-10 + print(" PASS\n") + + +def test_fix_zero(): + print("=== Test: FixZero ===") + from backend.nodes.level import FixZero + node = FixZero() + field = make_field(data=np.array([[10, 20], [30, 40]], dtype=np.float64)) + + result_min, = node.process(field, method="min") + assert result_min.data.min() == 0.0 + assert result_min.data.max() == 30.0 + + result_mean, = node.process(field, method="mean") + assert abs(result_mean.data.mean()) < 1e-10 + + result_median, = node.process(field, method="median") + assert abs(np.median(result_median.data)) < 1e-10 + print(" PASS\n") + + +# ========================================================================= +# Analysis (non-FFT) +# ========================================================================= + +def test_statistics(): + print("=== Test: StatisticsNode ===") + from backend.nodes.analysis import StatisticsNode + node = StatisticsNode() + + data = np.array([[1, 2], [3, 4]], dtype=np.float64) + field = make_field(data=data) + + table, = node.process(field) + stats = {row["quantity"]: row["value"] for row in table} + + assert stats["min"] == 1.0 + assert stats["max"] == 4.0 + assert stats["mean"] == 2.5 + assert stats["median"] == 2.5 + assert stats["range"] == 3.0 + # RMS = sqrt(mean((x - mean)^2)) + expected_rms = np.sqrt(np.mean((data - 2.5) ** 2)) + assert abs(stats["RMS"] - expected_rms) < 1e-10 + + # Constant data should have RMS=0, skewness=0, kurtosis=0 + const_field = make_field(data=np.ones((4, 4)) * 5.0) + table_const, = node.process(const_field) + const_stats = {row["quantity"]: row["value"] for row in table_const} + assert const_stats["RMS"] == 0.0 + assert const_stats["skewness"] == 0.0 + assert const_stats["kurtosis"] == 0.0 + print(" PASS\n") + + +def test_height_histogram(): + print("=== Test: HeightHistogram ===") + from backend.nodes.analysis import HeightHistogram + node = HeightHistogram() + + # Uniform data should give a roughly flat histogram + data = np.linspace(0, 1, 1000).reshape(25, 40) + field = make_field(data=data) + + counts, bin_centers = node.process(field, n_bins=10) + assert len(counts) == 10 + assert len(bin_centers) == 10 + assert counts.dtype == np.float64 + # Total counts should equal number of pixels + assert counts.sum() == 1000 + # For uniform data, each bin should have ~100 counts + assert np.std(counts) < 10, f"Histogram not flat enough: std={np.std(counts)}" + # Bin centers should span the data range + assert bin_centers[0] > 0.0 + assert bin_centers[-1] < 1.0 + print(" PASS\n") + + +def test_cross_section(): + print("=== Test: CrossSection ===") + from backend.nodes.analysis import CrossSection + node = CrossSection() + + # Create a field with a known horizontal gradient + N = 100 + y, x = np.mgrid[0:N, 0:N] / N + data = x * 10.0 # value = 10 * x_fraction + field = make_field(data=data, xreal=1e-6, yreal=1e-6) + + # Horizontal cross section at y=0.5 + (profile,) = node.process( + field, x1=0.0, y1=0.5, x2=1.0, y2=0.5, + extend="none", n_samples=100, + ) + assert len(profile) == 100 + # 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( + 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( + field, x1=0.3, y1=0.5, x2=0.7, y2=0.5, + extend="to_edges", n_samples=100, + ) + # Extended profile should start near 0 and end near 10 + assert profile_ext[0] < 0.5 + assert profile_ext[-1] > 9.5 + + # Diagonal cross section + (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 + print(" PASS\n") + + +# ========================================================================= +# Grains +# ========================================================================= + +def test_threshold_mask(): + print("=== Test: ThresholdMask ===") + from backend.nodes.grains import ThresholdMask + node = ThresholdMask() + + # Clear bimodal data: left half = 0, right half = 1 + data = np.zeros((64, 64)) + data[:, 32:] = 1.0 + field = make_field(data=data) + + # Absolute threshold at 0.5 + mask, = node.process(field, method="absolute", threshold=0.5, direction="above") + assert mask.dtype == np.uint8 + assert mask.shape == (64, 64) + assert np.all(mask[:, :32] == 0) + assert np.all(mask[:, 32:] == 255) + + # Direction "below" + mask_below, = node.process(field, method="absolute", threshold=0.5, direction="below") + assert np.all(mask_below[:, :32] == 255) + assert np.all(mask_below[:, 32:] == 0) + + # Relative threshold at 0.5 (midpoint of range) + mask_rel, = node.process(field, method="relative", threshold=0.5, direction="above") + assert np.all(mask_rel[:, 32:] == 255) + + # Otsu should find the bimodal threshold + mask_otsu, = node.process(field, method="otsu", threshold=0.0, direction="above") + assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum() + print(" PASS\n") + + +def test_grain_analysis(): + print("=== Test: GrainAnalysis ===") + from backend.nodes.grains import GrainAnalysis + node = GrainAnalysis() + + # Create a field with two distinct "grains" + N = 64 + data = np.zeros((N, N)) + # Grain 1: 10x10 block at top-left with height 5 + data[5:15, 5:15] = 5.0 + # Grain 2: 8x8 block at bottom-right with height 3 + data[45:53, 45:53] = 3.0 + field = make_field(data=data, xreal=1e-6, yreal=1e-6) + + # Create matching mask + mask = np.zeros((N, N), dtype=np.uint8) + mask[5:15, 5:15] = 255 + mask[45:53, 45:53] = 255 + + table, = node.process(field, mask=mask, min_size=10) + assert len(table) == 2, f"Expected 2 grains, got {len(table)}" + + # Sort by area descending + table.sort(key=lambda r: r["area_px"], reverse=True) + assert table[0]["area_px"] == 100 # 10x10 + 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 + + # min_size filtering: only keep grains >= 80 px + table_filtered, = node.process(field, mask=mask, min_size=80) + assert len(table_filtered) == 1 + assert table_filtered[0]["area_px"] == 100 + print(" PASS\n") + + +# ========================================================================= +# I/O +# ========================================================================= + +def test_load_image(): + print("=== Test: LoadImage ===") + from backend.nodes.io import LoadImage + from PIL import Image + node = LoadImage() + + with tempfile.TemporaryDirectory() as tmpdir: + # Test loading a grayscale PNG + arr = np.random.default_rng(1).integers(0, 256, (48, 64), dtype=np.uint8) + img = Image.fromarray(arr, mode="L") + path = os.path.join(tmpdir, "test_gray.png") + img.save(path) + + image, field = node.load(filename=path) + assert image.shape == (48, 64) + assert field.data.shape == (48, 64) + assert field.data.dtype == np.float64 + + # Test loading an RGB PNG (should average to grayscale for field) + arr_rgb = np.random.default_rng(2).integers(0, 256, (32, 32, 3), dtype=np.uint8) + img_rgb = Image.fromarray(arr_rgb, mode="RGB") + path_rgb = os.path.join(tmpdir, "test_rgb.png") + img_rgb.save(path_rgb) + + image_rgb, field_rgb = node.load(filename=path_rgb) + assert image_rgb.shape == (32, 32, 3) + assert field_rgb.data.shape == (32, 32) + + # Test loading a .npy file + data_npy = np.random.default_rng(3).standard_normal((50, 60)) + path_npy = os.path.join(tmpdir, "test.npy") + np.save(path_npy, data_npy) + + image_npy, field_npy = node.load(filename=path_npy) + assert np.allclose(field_npy.data, data_npy) + + print(" PASS\n") + + +def test_save_image(): + print("=== Test: SaveImage ===") + from backend.nodes.io import SaveImage + node = SaveImage() + + with tempfile.TemporaryDirectory() as tmpdir: + # Monkey-patch OUTPUT_DIR for testing + from pathlib import Path + import backend.nodes.io as io_mod + orig_dir = io_mod.OUTPUT_DIR + io_mod.OUTPUT_DIR = Path(tmpdir) + + try: + arr = np.random.default_rng(4).integers(0, 256, (32, 32), dtype=np.uint8) + + # Save as PNG + node.save(image=arr, filename_prefix="test", format="PNG") + saved = os.listdir(tmpdir) + assert any(f.endswith(".png") for f in saved), f"No PNG file found in {saved}" + + # Save as NPY + node.save(image=arr.astype(np.float64), filename_prefix="test", format="NPY") + saved = os.listdir(tmpdir) + assert any(f.endswith(".npy") for f in saved), f"No NPY file found in {saved}" + finally: + io_mod.OUTPUT_DIR = orig_dir + + print(" PASS\n") + + +# ========================================================================= +# Display (limited testing — these are output nodes with WS callbacks) +# ========================================================================= + +def test_preview_image(): + print("=== Test: PreviewImage ===") + from backend.nodes.display import PreviewImage + node = PreviewImage() + + # Set up a capture for the broadcast + captured = [] + PreviewImage._broadcast_fn = lambda node_id, data_uri: captured.append(data_uri) + PreviewImage._current_node_id = "test" + + # Preview with a DataField + field = make_field() + node.preview(colormap="viridis", field=field) + assert len(captured) == 1 + assert captured[0].startswith("data:image/png;base64,") + + # Preview with an IMAGE array + captured.clear() + arr = np.random.default_rng(5).integers(0, 256, (32, 32), dtype=np.uint8) + node.preview(colormap="gray", image=arr) + assert len(captured) == 1 + + # Clean up + PreviewImage._broadcast_fn = None + print(" PASS\n") + + +def test_print_table(): + print("=== Test: PrintTable ===") + from backend.nodes.display import PrintTable + node = PrintTable() + + captured = [] + PrintTable._broadcast_table_fn = lambda node_id, rows: captured.append(rows) + PrintTable._current_node_id = "test" + + table = [{"quantity": "test", "value": 42.0, "unit": "m"}] + node.print_table(table=table) + assert len(captured) == 1 + assert captured[0] == table + + PrintTable._broadcast_table_fn = None + print(" PASS\n") + + +# ========================================================================= +# Run all tests +# ========================================================================= + +if __name__ == "__main__": + # Filters + test_gaussian_filter() + test_median_filter() + test_edge_detect() + + # Level + test_plane_level() + test_poly_level() + test_fix_zero() + + # Analysis + test_statistics() + test_height_histogram() + test_cross_section() + + # Grains + test_threshold_mask() + test_grain_analysis() + + # I/O + test_load_image() + test_save_image() + + # Display + test_preview_image() + test_print_table() + + print("All tests passed!")