diff --git a/backend/nodes/annotations.py b/backend/nodes/annotations.py index 365ff9c..ebb4b2d 100644 --- a/backend/nodes/annotations.py +++ b/backend/nodes/annotations.py @@ -86,7 +86,7 @@ class Annotations: context = _annotation_context_from_image(input) if context is None: - self._send_warning( + emit_warning( "Annotations image input has no scale metadata, so scale bar and color-map legend cannot be added." ) return (ImageData(image_to_uint8(input)),) @@ -111,7 +111,7 @@ class Annotations: if not (has_legend_values and str(context.get("legend_unit", "")).strip()): missing_features.append("color-map legend") if missing_features: - self._send_warning( + emit_warning( f"Annotations image input is missing metadata for: {', '.join(missing_features)}." ) annotated = _apply_annotation_overlay_from_context( @@ -120,6 +120,3 @@ class Annotations: annotation_spec, ) return (ImageData(annotated, metadata={"annotation_context": context}),) - - def _send_warning(self, message: str): - emit_warning(message) diff --git a/backend/nodes/cursors.py b/backend/nodes/cursors.py index 1b25e37..c335562 100644 --- a/backend/nodes/cursors.py +++ b/backend/nodes/cursors.py @@ -3,6 +3,7 @@ import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay from backend.data_types import DataField, LineData, RecordTable, encode_preview, render_datafield_preview +from backend.nodes.helpers import frac_to_index @register_node(display_name="Cursors") @@ -76,16 +77,8 @@ class Cursors: xmin = float(np.min(x)) if len(x) else 0.0 xmax = float(np.max(x)) if len(x) else 1.0 - def x_frac_to_idx(frac): - if n <= 1: - return 0 - if xmax == xmin: - return 0 - target_x = xmin + frac * (xmax - xmin) - return int(np.argmin(np.abs(x - target_x))) - - idx_a = x_frac_to_idx(x1) - idx_b = x_frac_to_idx(x2) + idx_a = frac_to_index(x, x1) + idx_b = frac_to_index(x, x2) xa, ya = float(x[idx_a]), float(y[idx_a]) xb, yb = float(x[idx_b]), float(y[idx_b]) diff --git a/backend/nodes/curvature.py b/backend/nodes/curvature.py index d6954ab..44ff3f3 100644 --- a/backend/nodes/curvature.py +++ b/backend/nodes/curvature.py @@ -16,6 +16,7 @@ from backend.data_types import ( from backend.execution_context import emit_preview, emit_table, emit_warning from backend.node_registry import register_node from backend.nodes.surface_common import require_compatible_xy_z_units +from backend.nodes.helpers import normalize_mask, apply_masking _CURVATURE_COLOR = "#ff9800" _CENTER_COLOR = "#8bd3ff" @@ -28,16 +29,6 @@ class _Intersection: y: float -def _normalize_mask(mask: np.ndarray | None, shape: tuple[int, int]) -> np.ndarray | None: - if mask is None: - return None - - mask_array = np.asarray(mask) - if mask_array.shape[:2] != shape: - raise ValueError(f"Mask shape {mask_array.shape} does not match field shape {shape}.") - return mask_array > 127 - - def _canonicalize_half_pi(angle: float) -> float: wrapped = (float(angle) + 0.5 * np.pi) % np.pi - 0.5 * np.pi if wrapped <= -0.5 * np.pi + 1e-15: @@ -52,9 +43,7 @@ def _fit_quadratic_surface(data: np.ndarray, mask: np.ndarray | None, masking: s x = 2.0 * xx.astype(np.float64) / max(xres - 1, 1) - 1.0 y = 2.0 * yy.astype(np.float64) / max(yres - 1, 1) - 1.0 - valid = np.ones(data.shape, dtype=bool) - if mask is not None and masking != "ignore": - valid = mask if masking == "include" else ~mask + valid = apply_masking(data, mask, masking) if np.count_nonzero(valid) < 6: return None @@ -309,7 +298,7 @@ class Curvature: mask: np.ndarray | None = None, ) -> tuple: require_compatible_xy_z_units(field, "Curvature") - mask_array = _normalize_mask(mask, field.data.shape) + mask_array = normalize_mask(mask, field.data.shape) results = _compute_curvature_results(field, mask_array, masking) if results is None: diff --git a/backend/nodes/fractal_dimension.py b/backend/nodes/fractal_dimension.py index cb0dad9..b27eedb 100644 --- a/backend/nodes/fractal_dimension.py +++ b/backend/nodes/fractal_dimension.py @@ -8,6 +8,7 @@ from scipy.ndimage import map_coordinates from backend.data_types import LineData, RecordTable from backend.execution_context import emit_overlay, emit_table, emit_warning from backend.node_registry import register_node +from backend.nodes.spectral_common import _window_vector _LOG_TINY = float(np.finfo(np.float64).tiny) @@ -79,13 +80,6 @@ def _row_level2(row: np.ndarray) -> np.ndarray: return values - (coeffs[0] + coeffs[1] * x) -def _hann_window(size: int) -> np.ndarray: - if size <= 0: - return np.ones(0, dtype=np.float64) - t = (np.arange(size, dtype=np.float64) + 0.5) / float(size) - return 0.5 - 0.5 * np.cos(2.0 * np.pi * t) - - def _window_with_rms_compensation(values: np.ndarray, window: np.ndarray) -> np.ndarray: row = np.asarray(values, dtype=np.float64) rms = float(np.sqrt(np.mean(row * row))) @@ -207,7 +201,7 @@ def _fractal_psdf(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: if width < 2 or rows < 1: return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64) - window = _hann_window(width) + window = _window_vector(width, "hann") accum = np.zeros(width // 2 + 1, dtype=np.float64) for row in np.asarray(data, dtype=np.float64): leveled = _row_level2(row) diff --git a/backend/nodes/gradient.py b/backend/nodes/gradient.py index 0638d55..59a33b6 100644 --- a/backend/nodes/gradient.py +++ b/backend/nodes/gradient.py @@ -31,30 +31,20 @@ class Gradient: ) def process(self, field: DataField, component: str) -> tuple: - from scipy.ndimage import sobel + from backend.nodes.surface_common import physical_sobel_gradient, slope_unit - data = field.data - # Sobel kernel sums to ±8 over 2-pixel span; divide by 8·dx to get z/xy slope. - gx = sobel(data, axis=1) / (8.0 * field.dx) - gy = sobel(data, axis=0) / (8.0 * field.dy) + gx, gy = physical_sobel_gradient(field) if component == "magnitude": result = np.hypot(gx, gy) - z = str(field.si_unit_z or "").strip() - xy = str(field.si_unit_xy or "").strip() - out_unit_z = f"{z}/{xy}" if z and xy else (z or xy) + out_unit_z = slope_unit(field) elif component == "x": result = gx - z = str(field.si_unit_z or "").strip() - xy = str(field.si_unit_xy or "").strip() - out_unit_z = f"{z}/{xy}" if z and xy else (z or xy) + out_unit_z = slope_unit(field) elif component == "y": result = gy - z = str(field.si_unit_z or "").strip() - xy = str(field.si_unit_xy or "").strip() - out_unit_z = f"{z}/{xy}" if z and xy else (z or xy) + out_unit_z = slope_unit(field) elif component == "azimuth": - # Azimuth: local slope direction, radians, matches Gwyddion's filter_azimuth result = np.arctan2(gy, gx) out_unit_z = "rad" else: diff --git a/backend/nodes/grain_analysis.py b/backend/nodes/grain_analysis.py index f91c249..4d7138a 100644 --- a/backend/nodes/grain_analysis.py +++ b/backend/nodes/grain_analysis.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.data_types import DataField, DataTable -from backend.nodes.helpers import _square_unit +from backend.nodes.helpers import _square_unit, mask_to_bool @register_node(display_name="Grain Analysis") @@ -31,7 +31,7 @@ class GrainAnalysis: def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple: from scipy.ndimage import label - binary = (mask > 127).astype(np.int32) + binary = mask_to_bool(mask).astype(np.int32) labeled, n_grains = label(binary) pixel_area = field.dx * field.dy diff --git a/backend/nodes/grain_distance_transform.py b/backend/nodes/grain_distance_transform.py index cb2b355..7b596b4 100644 --- a/backend/nodes/grain_distance_transform.py +++ b/backend/nodes/grain_distance_transform.py @@ -7,13 +7,14 @@ from scipy.ndimage import binary_erosion, distance_transform_edt from backend.data_types import DataField from backend.node_registry import register_node +from backend.nodes.helpers import mask_to_bool def _normalize_mask(mask: np.ndarray) -> np.ndarray: data = np.asarray(mask) if data.ndim != 2: raise ValueError("Grain Distance Transform requires a 2-D mask.") - return data > 127 + return mask_to_bool(data) def _prepare_mask(binary: np.ndarray, from_border: bool) -> tuple[np.ndarray, tuple[slice, slice]]: diff --git a/backend/nodes/grain_filter.py b/backend/nodes/grain_filter.py index 3ed5eaf..83fcb6e 100644 --- a/backend/nodes/grain_filter.py +++ b/backend/nodes/grain_filter.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node +from backend.nodes.helpers import mask_to_bool, bool_to_mask @register_node(display_name="Grain Filter") @@ -40,7 +41,7 @@ class GrainFilter: ) -> tuple: from scipy.ndimage import label - binary = np.asarray(mask) > 127 + binary = mask_to_bool(mask) labeled, n_grains = label(binary) # Build per-grain keep table (index 0 = background, always False) @@ -60,7 +61,7 @@ class GrainFilter: keep[gid] = True result = keep[labeled] - return (result.astype(np.uint8) * 255,) + return (bool_to_mask(result),) def _touches_border(grain: np.ndarray) -> bool: diff --git a/backend/nodes/helpers.py b/backend/nodes/helpers.py index cf77bdc..6271d83 100644 --- a/backend/nodes/helpers.py +++ b/backend/nodes/helpers.py @@ -309,10 +309,34 @@ def _render_markup_image(image, shapes): # Mask helpers (from mask.py — used by multiple mask nodes) # --------------------------------------------------------------------------- +def mask_to_bool(mask: np.ndarray) -> np.ndarray: + """Convert a uint8 mask (0/255) to a boolean array.""" + return np.asarray(mask) > 127 + + +def bool_to_mask(binary: np.ndarray) -> np.ndarray: + """Convert a boolean array to a uint8 mask (0/255).""" + return np.asarray(binary, dtype=np.uint8) * 255 + + +def normalize_mask( + mask: np.ndarray | None, shape: tuple[int, int], +) -> np.ndarray | None: + """Validate mask shape and convert from uint8 to boolean.""" + if mask is None: + return None + mask_array = np.asarray(mask) + if mask_array.shape[:2] != shape: + raise ValueError( + f"Mask shape {mask_array.shape} does not match field shape {shape}." + ) + return mask_to_bool(mask_array) + + def _mask_overlay(field, mask): from backend.data_types import datafield_to_uint8 grey = datafield_to_uint8(field, "gray") - mask_bool = mask > 127 + mask_bool = mask_to_bool(mask) if not np.any(mask_bool): return grey @@ -728,6 +752,62 @@ def _square_unit(unit: str) -> str: return f"{unit}^2" +def apply_masking(data: np.ndarray, mask: np.ndarray | None, masking: str) -> np.ndarray: + """Return a boolean validity array from a mask and masking mode. + + Returns a bool array the same shape as *data* indicating which pixels + should be included in calculations. + """ + if mask is None or masking == "ignore": + return np.ones(data.shape, dtype=bool) + if masking == "include": + return np.asarray(mask, dtype=bool) + if masking == "exclude": + return ~np.asarray(mask, dtype=bool) + raise ValueError(f"Unknown masking mode: {masking}") + + +def masked_values(data: np.ndarray, mask: np.ndarray | None, masking: str) -> np.ndarray: + """Return the 1-D subset of *data* selected by the masking mode.""" + if mask is None or masking == "ignore": + return data + if masking == "include": + return data[mask] + if masking == "exclude": + return data[~mask] + raise ValueError(f"Unknown masking mode: {masking}") + + +def emit_mask_preview(field, mask_uint8: np.ndarray) -> None: + """Emit a standard mask-on-field preview if *field* is not None.""" + if field is None: + return + from backend.execution_context import emit_preview + from backend.data_types import encode_preview + emit_preview(encode_preview(_mask_overlay(field, mask_uint8))) + + +def histogram_with_centers(data: np.ndarray, bins: int = 256): + """Compute histogram and return (counts_float64, bin_centers).""" + raw_counts, bin_edges = np.histogram(data.ravel(), bins=int(bins)) + bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) + counts = raw_counts.astype(np.float64) + return counts, bin_centers + + +def frac_to_index(axis: np.ndarray, frac: float) -> int: + """Map a fractional position [0, 1] to the nearest index in *axis*.""" + n = len(axis) + if n <= 1: + return 0 + lo = float(axis[0]) + hi = float(axis[-1]) + if hi == lo: + return 0 + target = lo + frac * (hi - lo) + return int(np.argmin(np.abs(axis - target))) + + def _apply_scalar_unit(base_unit: str, operation: str) -> str: unit = str(base_unit or "").strip() if operation == "count": diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index 2b6bd30..cf91b9a 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -3,6 +3,7 @@ import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay from backend.data_types import DataField, RecordTable +from backend.nodes.helpers import frac_to_index, histogram_with_centers @register_node(display_name="Histogram") @@ -44,9 +45,7 @@ class Histogram: x2: float = 0.75, y2: float = 0.5, ) -> tuple: - raw_counts, bin_edges = np.histogram(field.data.ravel(), bins=int(n_bins)) - bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) - counts = raw_counts.astype(np.float64) + counts, bin_centers = histogram_with_centers(field.data, n_bins) if y_scale == "log": counts = np.log10(1.0 + counts) @@ -56,16 +55,8 @@ class Histogram: xmin = float(np.min(bin_centers)) if len(bin_centers) else 0.0 xmax = float(np.max(bin_centers)) if len(bin_centers) else 1.0 - def x_frac_to_idx(frac): - if len(bin_centers) <= 1: - return 0 - if xmax == xmin: - return 0 - target_x = xmin + frac * (xmax - xmin) - return int(np.argmin(np.abs(bin_centers - target_x))) - - idx_a = x_frac_to_idx(x1) - idx_b = x_frac_to_idx(x2) + idx_a = frac_to_index(bin_centers, x1) + idx_b = frac_to_index(bin_centers, x2) xa = float(bin_centers[idx_a]) if len(bin_centers) else 0.0 xb = float(bin_centers[idx_b]) if len(bin_centers) else 0.0 ya = float(counts[idx_a]) if len(counts) else 0.0 diff --git a/backend/nodes/level_facet.py b/backend/nodes/level_facet.py index 1680eda..366e78d 100644 --- a/backend/nodes/level_facet.py +++ b/backend/nodes/level_facet.py @@ -5,16 +5,7 @@ import numpy as np from backend.data_types import DataField from backend.node_registry import register_node from backend.nodes.surface_common import require_compatible_xy_z_units - - -def _normalize_mask(mask: np.ndarray | None, shape: tuple[int, int]) -> np.ndarray | None: - if mask is None: - return None - - mask_array = np.asarray(mask) - if mask_array.shape[:2] != shape: - raise ValueError(f"Mask shape {mask_array.shape} does not match field shape {shape}.") - return mask_array > 127 +from backend.nodes.helpers import normalize_mask def _facet_cell_mask(mask: np.ndarray | None, masking: str, shape: tuple[int, int]) -> np.ndarray: @@ -141,6 +132,6 @@ class FacetLevelField: mask: np.ndarray | None = None, ) -> tuple: require_compatible_xy_z_units(field, "Facet Level") - mask_array = _normalize_mask(mask, field.data.shape) + mask_array = normalize_mask(mask, field.data.shape) leveled = _facet_level_data(field, mask_array, masking, max_iterations=100) return (field.replace(data=leveled),) diff --git a/backend/nodes/level_plane.py b/backend/nodes/level_plane.py index 99dd763..ad49083 100644 --- a/backend/nodes/level_plane.py +++ b/backend/nodes/level_plane.py @@ -2,16 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.data_types import DataField - - -def _normalize_mask(mask: np.ndarray | None, shape: tuple[int, int]) -> np.ndarray | None: - if mask is None: - return None - - mask_array = np.asarray(mask) - if mask_array.shape[:2] != shape: - raise ValueError(f"Mask shape {mask_array.shape} does not match field shape {shape}.") - return mask_array > 127 +from backend.nodes.helpers import normalize_mask, apply_masking def _fit_plane( @@ -24,14 +15,7 @@ def _fit_plane( y = np.linspace(0.0, 1.0, yres) xx, yy = np.meshgrid(x, y) - if mask is None or masking == "ignore": - valid = np.ones(data.shape, dtype=bool) - elif masking == "include": - valid = mask - elif masking == "exclude": - valid = ~mask - else: - raise ValueError(f"Unknown masking mode: {masking}") + valid = apply_masking(data, mask, masking) if np.count_nonzero(valid) < 3: raise ValueError("Plane Level requires at least three usable pixels for fitting.") @@ -78,7 +62,7 @@ class PlaneLevelField: mask: np.ndarray | None = None, ) -> tuple: data = field.data.copy() - mask_array = _normalize_mask(mask, data.shape) + mask_array = normalize_mask(mask, data.shape) pa, pbx, pby, xx, yy = _fit_plane(data, mask_array, masking) plane = (pa + pbx * xx + pby * yy) diff --git a/backend/nodes/line_correction.py b/backend/nodes/line_correction.py index d47b5db..0464c10 100644 --- a/backend/nodes/line_correction.py +++ b/backend/nodes/line_correction.py @@ -4,16 +4,7 @@ import numpy as np from backend.data_types import DataField, LineData from backend.node_registry import register_node - - -def _normalize_mask(mask: np.ndarray | None, shape: tuple[int, int]) -> np.ndarray | None: - if mask is None: - return None - - mask_array = np.asarray(mask) - if mask_array.shape[:2] != shape: - raise ValueError(f"Mask shape {mask_array.shape} does not match field shape {shape}.") - return mask_array > 127 +from backend.nodes.helpers import normalize_mask, apply_masking, masked_values def _trimmed_mean_or_median(values: np.ndarray, trim_fraction: float) -> float: @@ -33,18 +24,8 @@ def _trimmed_mean_or_median(values: np.ndarray, trim_fraction: float) -> float: return float(trimmed.mean()) if trimmed.size else float(np.median(sorted_values)) -def _masked_values(data: np.ndarray, mask: np.ndarray | None, masking: str) -> np.ndarray: - if mask is None or masking == "ignore": - return data - if masking == "include": - return data[mask] - if masking == "exclude": - return data[~mask] - raise ValueError(f"Unknown masking mode: {masking}") - - def _global_masked_median(data: np.ndarray, mask: np.ndarray | None, masking: str) -> float: - selected = _masked_values(data, mask, masking) + selected = masked_values(data, mask, masking) if selected.size == 0: selected = np.asarray(data, dtype=np.float64).ravel() return float(np.median(selected)) @@ -75,7 +56,7 @@ def _find_row_shifts_trimmed_mean( shifts[i] = _trimmed_mean_or_median(row, trim_fraction) continue - values = _masked_values(row, row_mask, masking) + values = masked_values(row, row_mask, masking) if values.size >= mincount: shifts[i] = _trimmed_mean_or_median(values, trim_fraction) else: @@ -162,12 +143,7 @@ def _row_level_poly( row = data[i] row_mask = None if mask is None else mask[i] - if row_mask is None or masking == "ignore": - valid = np.ones(xres, dtype=bool) - elif masking == "include": - valid = row_mask - else: - valid = ~row_mask + valid = apply_masking(row, row_mask, masking) coeffs = np.zeros(degree + 1, dtype=np.float64) if np.count_nonzero(valid) > degree: @@ -331,7 +307,7 @@ class LineCorrection: mask: np.ndarray | None = None, ) -> tuple: data = np.asarray(field.data, dtype=np.float64) - mask_array = _normalize_mask(mask, data.shape) + mask_array = normalize_mask(mask, data.shape) if direction not in {"horizontal", "vertical"}: raise ValueError(f"Unknown direction: {direction}") diff --git a/backend/nodes/mask_draw.py b/backend/nodes/mask_draw.py index 3ca27c4..210651e 100644 --- a/backend/nodes/mask_draw.py +++ b/backend/nodes/mask_draw.py @@ -3,7 +3,7 @@ import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay from backend.data_types import DataField, datafield_to_uint8, encode_preview -from backend.nodes.helpers import _parse_mask_strokes, _rasterize_mask +from backend.nodes.helpers import _parse_mask_strokes, _rasterize_mask, bool_to_mask, mask_to_bool @register_node(display_name="Draw Mask") @@ -37,7 +37,7 @@ class DrawMask: strokes = _parse_mask_strokes(mask_paths) mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size) if invert: - mask = np.where(mask > 127, np.uint8(0), np.uint8(255)) + mask = bool_to_mask(~mask_to_bool(mask)) emit_overlay({ "kind": "mask_paint", diff --git a/backend/nodes/mask_invert.py b/backend/nodes/mask_invert.py index c0028da..e578a85 100644 --- a/backend/nodes/mask_invert.py +++ b/backend/nodes/mask_invert.py @@ -1,9 +1,8 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.execution_context import emit_preview -from backend.data_types import DataField, encode_preview -from backend.nodes.helpers import _mask_overlay +from backend.data_types import DataField +from backend.nodes.helpers import bool_to_mask, mask_to_bool, emit_mask_preview @register_node(display_name="Mask Invert") @@ -29,10 +28,8 @@ class MaskInvert: DESCRIPTION = "Invert a binary mask — swap masked and unmasked regions." def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple: - out = np.where(mask > 127, np.uint8(0), np.uint8(255)) + out = bool_to_mask(~mask_to_bool(mask)) - if field is not None: - overlay = _mask_overlay(field, out) - emit_preview(encode_preview(overlay)) + emit_mask_preview(field, out) return (out,) diff --git a/backend/nodes/mask_morphology.py b/backend/nodes/mask_morphology.py index 89dfd46..2aaf8ac 100644 --- a/backend/nodes/mask_morphology.py +++ b/backend/nodes/mask_morphology.py @@ -1,9 +1,8 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.execution_context import emit_preview -from backend.data_types import DataField, encode_preview -from backend.nodes.helpers import _mask_overlay, _mask_structure +from backend.data_types import DataField +from backend.nodes.helpers import _mask_structure, mask_to_bool, bool_to_mask, emit_mask_preview @register_node(display_name="Mask Morphology") @@ -45,7 +44,7 @@ class MaskMorphology: field: DataField | None = None) -> tuple: from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening - binary = mask > 127 + binary = mask_to_bool(mask) struct = _mask_structure(radius, shape) if operation == "dilate": @@ -59,10 +58,8 @@ class MaskMorphology: else: raise ValueError(f"Unknown morphological operation: {operation}") - out = result.astype(np.uint8) * 255 + out = bool_to_mask(result) - if field is not None: - overlay = _mask_overlay(field, out) - emit_preview(encode_preview(overlay)) + emit_mask_preview(field, out) return (out,) diff --git a/backend/nodes/mask_operations.py b/backend/nodes/mask_operations.py index a9d535c..d018af8 100644 --- a/backend/nodes/mask_operations.py +++ b/backend/nodes/mask_operations.py @@ -1,6 +1,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node +from backend.nodes.helpers import mask_to_bool, bool_to_mask _MASK_BOOLEAN_OPERATIONS = { @@ -53,14 +54,14 @@ class MaskOperations: mask_b: np.ndarray, operation: str, ) -> tuple: - a = mask_a > 127 - b = mask_b > 127 + a = mask_to_bool(mask_a) + b = mask_to_bool(mask_b) op = _MASK_BOOLEAN_OPERATIONS.get(operation) if op is None: raise ValueError(f"Unknown mask operation: {operation}") result = op(a, b) - out = result.astype(np.uint8) * 255 + out = bool_to_mask(result) return (out,) diff --git a/backend/nodes/mask_threshold.py b/backend/nodes/mask_threshold.py index 90bf2e9..f06fc98 100644 --- a/backend/nodes/mask_threshold.py +++ b/backend/nodes/mask_threshold.py @@ -1,9 +1,9 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.execution_context import emit_preview, emit_overlay -from backend.data_types import DataField, encode_preview, RecordTable -from backend.nodes.helpers import _mask_overlay +from backend.execution_context import emit_overlay +from backend.data_types import DataField, RecordTable +from backend.nodes.helpers import bool_to_mask, histogram_with_centers, emit_mask_preview @register_node(display_name="Threshold Mask") @@ -36,9 +36,7 @@ class ThresholdMask: def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple: data = field.data - raw_counts, bin_edges = np.histogram(data.ravel(), bins=256) - bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) - counts = raw_counts.astype(np.float64) + counts, bin_centers = histogram_with_centers(data) xmin = float(bin_centers[0]) if len(bin_centers) else 0.0 xmax = float(bin_centers[-1]) if len(bin_centers) else 1.0 @@ -70,11 +68,11 @@ class ThresholdMask: }) if direction == "above": - mask = (data >= t).astype(np.uint8) * 255 + mask = bool_to_mask(data >= t) else: - mask = (data < t).astype(np.uint8) * 255 + mask = bool_to_mask(data < t) - emit_preview(encode_preview(_mask_overlay(field, mask))) + emit_mask_preview(field, mask) table = RecordTable([ {"quantity": "threshold", "value": threshold, "unit": field.si_unit_xy}, diff --git a/backend/nodes/rotate.py b/backend/nodes/rotate.py index 535d94a..e6e676c 100644 --- a/backend/nodes/rotate.py +++ b/backend/nodes/rotate.py @@ -36,7 +36,7 @@ class RotateField: expand_canvas: bool, ) -> tuple: if field.overlays: - self._send_warning("Rotate clears annotation/markup overlays!") + emit_warning("Rotate clears annotation/markup overlays!") angle = float(angle) order_map = { @@ -82,9 +82,6 @@ class RotateField: ) return (result,) - def _send_warning(self, message: str): - emit_warning(message) - @staticmethod def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]: if not expand_canvas: diff --git a/backend/nodes/save.py b/backend/nodes/save.py index 1f68876..b5b4dcc 100644 --- a/backend/nodes/save.py +++ b/backend/nodes/save.py @@ -101,7 +101,7 @@ class Save: else: raise ValueError(f"Save does not support input type: {type(value).__name__}") - self._send_warning(f"Saved to {path.name}") + emit_warning(f"Saved to {path.name}") emit_file_download(str(path)) return () @@ -373,6 +373,3 @@ class Save: lines.append(" endfacet") lines.append("endsolid tono") path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - def _send_warning(self, message: str): - emit_warning(message) diff --git a/backend/nodes/save_layers.py b/backend/nodes/save_layers.py index 36bf274..0ce918d 100644 --- a/backend/nodes/save_layers.py +++ b/backend/nodes/save_layers.py @@ -89,7 +89,7 @@ class SaveImage: else: self._save_npz(path, layers, layer_names) - self._send_warning(f"Saved {len(layers)} layer(s) to {path.name}") + emit_warning(f"Saved {len(layers)} layer(s) to {path.name}") emit_file_download(str(path)) return () @@ -181,6 +181,3 @@ class SaveImage: if isinstance(layer, np.ndarray): return np.asarray(layer) raise ValueError(f"Unsupported save layer type: {type(layer).__name__}") - - def _send_warning(self, message: str): - emit_warning(message) diff --git a/backend/nodes/scar_removal.py b/backend/nodes/scar_removal.py index 0939111..4896da8 100644 --- a/backend/nodes/scar_removal.py +++ b/backend/nodes/scar_removal.py @@ -6,6 +6,7 @@ import numpy as np from backend.data_types import DataField from backend.node_registry import register_node +from backend.nodes.helpers import bool_to_mask def _mark_scars_one_sign( @@ -218,4 +219,4 @@ class ScarRemoval: ) scar_mask = marks > 0.0 corrected = _laplace_inpaint(np.asarray(field.data, dtype=np.float64), scar_mask) - return (field.replace(data=corrected), scar_mask.astype(np.uint8) * 255) + return (field.replace(data=corrected), bool_to_mask(scar_mask)) diff --git a/backend/nodes/slope_distribution.py b/backend/nodes/slope_distribution.py index 1b7a63e..5eaecd4 100644 --- a/backend/nodes/slope_distribution.py +++ b/backend/nodes/slope_distribution.py @@ -32,26 +32,20 @@ class SlopeDistribution: ) def process(self, field: DataField, distribution: str, n_bins: int) -> tuple: - from scipy.ndimage import sobel - - # Physical slopes in z_unit/xy_unit — matches Gwyddion's gwy_data_field_filter_sobel - gx = sobel(field.data, axis=1) / (8.0 * field.dx) - gy = sobel(field.data, axis=0) / (8.0 * field.dy) + from backend.nodes.surface_common import physical_sobel_gradient, slope_unit as _slope_unit + gx, gy = physical_sobel_gradient(field) gx = gx.ravel() gy = gy.ravel() - n = len(gx) - z = str(field.si_unit_z or "").strip() - xy = str(field.si_unit_xy or "").strip() - slope_unit = f"{z}/{xy}" if z and xy else (z or xy) + su = _slope_unit(field) if distribution == "phi": - return self._phi(gx, gy, n_bins, slope_unit) + return self._phi(gx, gy, n_bins, su) elif distribution == "theta": return self._theta(gx, gy, n_bins) elif distribution == "gradient": - return self._gradient(gx, gy, n_bins, slope_unit) + return self._gradient(gx, gy, n_bins, su) else: raise ValueError(f"Unknown distribution type: {distribution!r}. " f"Choose from: theta, phi, gradient") diff --git a/backend/nodes/spectral_common.py b/backend/nodes/spectral_common.py index 96223b0..ae4e194 100644 --- a/backend/nodes/spectral_common.py +++ b/backend/nodes/spectral_common.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np from backend.data_types import DataField +from backend.nodes.helpers import _square_unit def _level_data(data: np.ndarray, level: str) -> np.ndarray: @@ -78,15 +79,6 @@ def _inverse_unit(unit: str) -> str: return f"1/{text}" -def _square_unit(unit: str) -> str: - text = str(unit or "").strip() - if not text: - return "" - if text.isalnum() or text in {"m", "nm", "um", "pm", "V", "A", "Hz", "px"}: - return f"{text}^2" - return f"({text})^2" - - def _product_unit(*units: str) -> str: parts = [str(unit).strip() for unit in units if str(unit or "").strip()] return " ".join(parts) diff --git a/backend/nodes/surface_common.py b/backend/nodes/surface_common.py index 06ecffb..c430c0e 100644 --- a/backend/nodes/surface_common.py +++ b/backend/nodes/surface_common.py @@ -1,5 +1,7 @@ from __future__ import annotations +import numpy as np + from backend.data_types import DataField @@ -15,6 +17,21 @@ def unit_dimension_key(unit: str) -> str: return text +def slope_unit(field: DataField) -> str: + """Return the physical slope unit string (z_unit/xy_unit).""" + z = str(field.si_unit_z or "").strip() + xy = str(field.si_unit_xy or "").strip() + return f"{z}/{xy}" if z and xy else (z or xy) + + +def physical_sobel_gradient(field: DataField) -> tuple[np.ndarray, np.ndarray]: + """Compute physical Sobel gradient (gx, gy) in z_unit/xy_unit.""" + from scipy.ndimage import sobel + gx = sobel(field.data, axis=1) / (8.0 * field.dx) + gy = sobel(field.data, axis=0) / (8.0 * field.dy) + return gx, gy + + def require_compatible_xy_z_units(field: DataField, node_name: str) -> None: xy_key = unit_dimension_key(field.si_unit_xy) z_key = unit_dimension_key(field.si_unit_z) diff --git a/backend/nodes/template_match.py b/backend/nodes/template_match.py index 10eee19..83d36df 100644 --- a/backend/nodes/template_match.py +++ b/backend/nodes/template_match.py @@ -4,6 +4,7 @@ import numpy as np from backend.data_types import DataField from backend.node_registry import register_node +from backend.nodes.helpers import bool_to_mask @register_node(display_name="Template Match") @@ -46,7 +47,7 @@ class TemplateMatch: # Clip to [0, 1] for display (match_template returns values in [-1, 1]) score_clipped = np.clip(score, 0.0, 1.0) - detections = (score_clipped >= float(threshold)).astype(np.uint8) * 255 + detections = bool_to_mask(score_clipped >= float(threshold)) score_field = image.replace(data=score_clipped) return (score_field, detections) diff --git a/backend/nodes/watershed_segmentation.py b/backend/nodes/watershed_segmentation.py index b9fcbee..1fad463 100644 --- a/backend/nodes/watershed_segmentation.py +++ b/backend/nodes/watershed_segmentation.py @@ -8,7 +8,7 @@ from scipy.ndimage import label from backend.execution_context import emit_preview from backend.data_types import DataField, encode_preview from backend.node_registry import register_node -from backend.nodes.helpers import _mask_overlay +from backend.nodes.helpers import _mask_overlay, mask_to_bool, bool_to_mask def _working_height(field: DataField, invert_height: bool) -> np.ndarray: @@ -184,7 +184,7 @@ def _combine_masks(result_mask: np.ndarray, existing_mask: np.ndarray | None, co if existing_mask is None or combine_mode == "replace": return result_mask - existing = np.asarray(existing_mask) > 127 + existing = mask_to_bool(existing_mask) current = np.asarray(result_mask, dtype=bool) if existing.shape != current.shape: raise ValueError("Existing mask must have the same shape as the watershed output.") @@ -196,7 +196,7 @@ def _combine_masks(result_mask: np.ndarray, existing_mask: np.ndarray | None, co else: raise ValueError(f"Unsupported combine mode: {combine_mode}") - return merged.astype(np.uint8) * 255 + return bool_to_mask(merged) @register_node(display_name="Watershed Segmentation") @@ -262,7 +262,7 @@ class WatershedSegmentation: _watershed_step(watershed_field, water, labels, seeds, watershed_drop) labels = _mark_boundaries(labels) - result_mask = (labels > 0).astype(np.uint8) * 255 + result_mask = bool_to_mask(labels > 0) result_mask = _combine_masks(result_mask, mask, combine_mode) emit_preview(encode_preview(_mask_overlay(field, result_mask))) diff --git a/frontend/src/AngleMeasureOverlay.tsx b/frontend/src/AngleMeasureOverlay.tsx index 87b17e3..ef7c7cf 100644 --- a/frontend/src/AngleMeasureOverlay.tsx +++ b/frontend/src/AngleMeasureOverlay.tsx @@ -9,16 +9,7 @@ import { moveAngleWidget, round3, } from './angleMeasureGeometry'; - -function clamp01(value: number) { - return Math.max(0, Math.min(1, Number(value) || 0)); -} - -function sanitizeHexColor(value: unknown, fallback = '#ff9800') { - if (typeof value !== 'string') return fallback; - const text = value.trim(); - return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback; -} +import { clampFraction as clamp01, sanitizeHexColor, pointerToFraction } from './overlayUtils'; function hexToRgb(value: string) { const color = sanitizeHexColor(value); @@ -112,11 +103,7 @@ export default function AngleMeasureOverlay({ const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32); const getCoords = useCallback((event: React.PointerEvent) => { - const rect = containerRef.current!.getBoundingClientRect(); - return { - fx: clamp01((event.clientX - rect.left) / rect.width), - fy: clamp01((event.clientY - rect.top) / rect.height), - }; + return pointerToFraction(event, containerRef.current!); }, []); const updateWidgets = useCallback((updates: Record) => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c981fb3..a45cf59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -68,7 +68,7 @@ import { GROUP_HEADER_HEIGHT, GROUP_MIN_WIDTH, GROUP_MIN_HEIGHT, - getNodeDimension, + getNodeSize, applyNodeSize, getNodeAbsolutePosition, collectGroupDescendantIds, @@ -127,6 +127,36 @@ const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5; const DEBUG = false; // set to true for verbose logging +function restoreGroupEdges(edges: any[], groupId: string) { + return edges.map((edge: any) => { + if ((edge.data as any)?.groupInternalHiddenBy === groupId) { + const nextData: any = { ...(edge.data || {}) }; + delete nextData.groupInternalHiddenBy; + return { + ...edge, + hidden: false, + data: Object.keys(nextData).length > 0 ? nextData : undefined, + }; + } + if (edge.data?.groupProxyOwner === groupId) { + const nextData: any = { ...(edge.data || {}) }; + const original = (nextData.groupProxyOriginal || {}) as Record; + delete nextData.groupProxyOwner; + delete nextData.groupProxyOriginal; + return { + ...edge, + source: original.source || edge.source, + sourceHandle: original.sourceHandle || edge.sourceHandle, + target: original.target || edge.target, + targetHandle: original.targetHandle || edge.targetHandle, + hidden: false, + data: Object.keys(nextData).length > 0 ? nextData : undefined, + }; + } + return edge; + }); +} + // ── Main flow component (needs ReactFlowProvider ancestor) ──────────── function Flow() { @@ -212,6 +242,14 @@ function Flow() { reactFlow.updateNodeInternals(groupId); }, [reactFlow, setNodes]); + const refreshAllGroups = useCallback((explicitNodes: any[] | null = null, explicitEdges: any[] | null = null) => { + setTimeout(() => { + (reactFlow.getNodes() as TonoNode[]) + .filter((node) => node.data?.className === 'Group') + .forEach((node) => refreshGroupNode(node.id, explicitNodes, explicitEdges)); + }, 0); + }, [reactFlow, refreshGroupNode]); + const toggleGroupCollapse = useCallback((groupId: string) => { const currentNodes = (reactFlow.getNodes() as TonoNode[]); const currentEdges = (reactFlow.getEdges() as TonoEdge[]); @@ -296,30 +334,7 @@ function Flow() { return edge; } - if ((edge.data as any)?.groupInternalHiddenBy === groupId) { - const nextData: any = { ...(edge.data || {}) }; - delete nextData.groupInternalHiddenBy; - return { - ...edge, - hidden: false, - data: Object.keys(nextData).length > 0 ? nextData : undefined, - }; - } - if (edge.data?.groupProxyOwner === groupId) { - const nextData: any = { ...(edge.data || {}) }; - const original = (nextData.groupProxyOriginal || {}) as Record; - delete nextData.groupProxyOwner; - delete nextData.groupProxyOriginal; - return { - ...edge, - source: original.source || edge.source, - sourceHandle: original.sourceHandle || edge.sourceHandle, - target: original.target || edge.target, - targetHandle: original.targetHandle || edge.targetHandle, - data: Object.keys(nextData).length > 0 ? nextData : undefined, - }; - } - return edge; + return restoreGroupEdges([edge], groupId)[0]; }); setNodes(nextNodes as TonoNode[]); @@ -352,44 +367,13 @@ function Flow() { }; }); - const nextEdges = currentEdges - .map((edge) => { - if ((edge.data as any)?.groupInternalHiddenBy === groupId) { - const nextData: any = { ...(edge.data || {}) }; - delete nextData.groupInternalHiddenBy; - return { - ...edge, - hidden: false, - data: Object.keys(nextData).length > 0 ? nextData : undefined, - }; - } - if (edge.data?.groupProxyOwner === groupId) { - const nextData: any = { ...(edge.data || {}) }; - const original = (nextData.groupProxyOriginal || {}) as Record; - delete nextData.groupProxyOwner; - delete nextData.groupProxyOriginal; - return { - ...edge, - source: original.source || edge.source, - sourceHandle: original.sourceHandle || edge.sourceHandle, - target: original.target || edge.target, - targetHandle: original.targetHandle || edge.targetHandle, - hidden: false, - data: Object.keys(nextData).length > 0 ? nextData : undefined, - }; - } - return edge; - }) - .filter((edge) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId)); + const nextEdges = restoreGroupEdges(currentEdges, groupId) + .filter((edge: any) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId)); setNodes(nextNodes); setEdges(nextEdges); - setTimeout(() => { - (reactFlow.getNodes() as TonoNode[]) - .filter((node) => node.data?.className === 'Group') - .forEach((node) => refreshGroupNode(node.id, nextNodes, nextEdges)); - }, 0); - }, [reactFlow, refreshGroupNode, setEdges, setNodes]); + refreshAllGroups(nextNodes, nextEdges); + }, [reactFlow, refreshAllGroups, setEdges, setNodes]); const createGroupFromSelection = useCallback(() => { const currentNodes = (reactFlow.getNodes() as TonoNode[]); @@ -808,12 +792,8 @@ function Flow() { }); }, 0); } - setTimeout(() => { - (reactFlow.getNodes() as TonoNode[]) - .filter((node) => node.data?.className === 'Group') - .forEach((node) => refreshGroupNode(node.id)); - }, 0); - }, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]); + refreshAllGroups(); + }, [onEdgesChange, reactFlow, refreshAllGroups, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]); const handleNodesChange = useCallback((changes: NodeChange[]) => { // Stash undo snapshot when a drag begins @@ -887,12 +867,8 @@ function Flow() { ))); } - setTimeout(() => { - (reactFlow.getNodes() as TonoNode[]) - .filter((node) => node.data?.className === 'Group') - .forEach((node) => refreshGroupNode(node.id)); - }, 0); - }, [onNodesChange, reactFlow, refreshGroupNode, setEdges, setNodes]); + refreshAllGroups(); + }, [onNodesChange, reactFlow, refreshAllGroups, setEdges, setNodes]); // ── Drop-on-blank: open filtered context menu ────────────────────── @@ -1583,7 +1559,7 @@ function Flow() { return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); }, []); - const getWorkflowBlob = useCallback(async () => { + const captureWorkflowImage = useCallback(async () => { const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null; if (!viewportEl) throw new Error('Flow element not found'); @@ -1591,10 +1567,8 @@ function Flow() { if (allNodes.length === 0) throw new Error('No nodes to capture'); const bounds = getRenderedNodeBounds(allNodes); - if (!bounds) { - throw new Error('Could not determine rendered node bounds'); - } - const pad = 0.1; // 10% margin on each side + if (!bounds) throw new Error('Could not determine rendered node bounds'); + const pad = 0.1; const imageWidth = Math.ceil(bounds.width * (1 + pad * 2)); const imageHeight = Math.ceil(bounds.height * (1 + pad * 2)); const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad); @@ -1610,176 +1584,97 @@ function Flow() { }, }); if (!blob) throw new Error('Capture returned empty'); - - const stampedBlob = await stampLogoOnBlob(blob); - const workflow = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[])) as any; - if (journalContentRef.current) workflow.journalContent = journalContentRef.current; - return embedWorkflow(stampedBlob as Blob, workflow); + return await stampLogoOnBlob(blob) as Blob; }, [reactFlow]); + const getWorkflowBlob = useCallback(async () => { + const imageBlob = await captureWorkflowImage(); + const workflow = serializeWorkflowState( + (reactFlow.getNodes() as TonoNode[]), + (reactFlow.getEdges() as TonoEdge[]), + ) as any; + if (journalContentRef.current) workflow.journalContent = journalContentRef.current; + return embedWorkflow(imageBlob, workflow); + }, [reactFlow, captureWorkflowImage]); + + const saveBlobToFile = useCallback(async (blob: Blob, filename: string): Promise => { + if (window.pywebview?.api?.choose_save_workflow_png_path) { + const requestedPath = await window.pywebview.api.choose_save_workflow_png_path(filename); + if (!requestedPath) return null; + const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, { + method: 'POST', + headers: { 'Content-Type': 'image/png' }, + body: blob, + }); + if (!resp.ok) throw new Error(await resp.text() || `Save failed (${resp.status})`); + const { path: savedPath } = await resp.json(); + return savedPath || null; + } + + if ('showSaveFilePicker' in window) { + try { + const handle = await window.showSaveFilePicker!({ + suggestedName: filename, + types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }], + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return filename; + } catch (err: any) { + if (err?.name === 'AbortError') return null; + throw err; + } + } + + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + return filename; + }, []); + const saveWorkflow = useCallback(async () => { setStatus({ text: 'Saving…', level: 'info' }); try { const finalBlob = await getWorkflowBlob(); - - if (window.pywebview?.api?.choose_save_workflow_png_path) { - const requestedPath = await window.pywebview.api.choose_save_workflow_png_path('workflow.png'); - if (!requestedPath) { - setStatus({ text: 'Save cancelled.', level: 'info' }); - return; - } - const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, { - method: 'POST', - headers: { - 'Content-Type': 'image/png', - }, - body: finalBlob, - }); - if (!resp.ok) { - throw new Error(await resp.text() || `Save failed (${resp.status})`); - } - const { path: savedPath } = await resp.json(); - if (!savedPath) { - setStatus({ text: 'Save cancelled.', level: 'info' }); - return; - } - setStatus({ text: `Workflow saved to ${savedPath}.`, level: 'info' }); - return; + const saved = await saveBlobToFile(finalBlob, 'workflow.png'); + if (!saved) { + setStatus({ text: 'Save cancelled.', level: 'info' }); + } else { + setStatus({ text: `Workflow saved to ${saved}.`, level: 'info' }); } - - if ('showSaveFilePicker' in window) { - try { - const handle = await window.showSaveFilePicker!({ - suggestedName: 'workflow.png', - types: [ - { - description: 'PNG image', - accept: { 'image/png': ['.png'] }, - }, - ], - }); - const writable = await handle.createWritable(); - await writable.write(finalBlob); - await writable.close(); - setStatus({ text: 'Workflow saved as workflow.png.', level: 'info' }); - return; - } catch (err: any) { - if (err?.name === 'AbortError') { - setStatus({ text: 'Save cancelled.', level: 'info' }); - return; - } - throw err; - } - } - - // Final fallback: trigger a browser download and tell the user where it went. - const resp = await fetch('/download?filename=workflow.png', { - method: 'POST', - body: finalBlob, - }); - const dlBlob = await resp.blob(); - const a = document.createElement('a'); - a.href = URL.createObjectURL(dlBlob); - a.download = 'workflow.png'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); - - setStatus({ - text: 'Workflow downloaded as workflow.png to your browser default downloads folder.', - level: 'info', - }); } catch (err: any) { setStatus({ text: 'Save failed: ' + err.message, level: 'error' }); } - }, [getWorkflowBlob]); + }, [getWorkflowBlob, saveBlobToFile]); const savePackedWorkflow = useCallback(async () => { setStatus({ text: 'Packing files…', level: 'info' }); try { - const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null; - if (!viewportEl) throw new Error('Flow element not found'); - + const imageBlob = await captureWorkflowImage(); const allNodes = (reactFlow.getNodes() as TonoNode[]); - if (allNodes.length === 0) throw new Error('No nodes to capture'); - - const bounds = getRenderedNodeBounds(allNodes); - if (!bounds) throw new Error('Could not determine rendered node bounds'); - const pad = 0.1; - const imageWidth = Math.ceil(bounds.width * (1 + pad * 2)); - const imageHeight = Math.ceil(bounds.height * (1 + pad * 2)); - const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad); - - if (DEBUG) console.log('[pack] capturing viewport…'); - const blob = await captureWorkflowViewportBlob(viewportEl, { - backgroundColor: CANVAS_COLORS.bgDeep, - width: imageWidth, - height: imageHeight, - style: { - width: `${imageWidth}px`, - height: `${imageHeight}px`, - transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`, - }, - }); - if (!blob) throw new Error('Capture returned empty'); - - if (DEBUG) console.log('[pack] stamping logo…'); - const stampedBlob = await stampLogoOnBlob(blob); - let workflow: any = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[])); + const workflow: any = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[])); if (journalContentRef.current) workflow.journalContent = journalContentRef.current; - if (DEBUG) console.log('[pack] packing files…'); - workflow = await packWorkflow(workflow, nodeDefsRef.current, (packed: number, total: number) => { - setStatus({ text: `Packing files… (${packed}/${total})`, level: 'info' }); + const packed = await packWorkflow(workflow, nodeDefsRef.current, (done: number, total: number) => { + setStatus({ text: `Packing files… (${done}/${total})`, level: 'info' }); }); - if (DEBUG) console.log('[pack] packed, embedding into PNG…', workflow.packed ? 'has packed files' : 'no packed files'); - const finalBlob = await embedWorkflow(stampedBlob as Blob, workflow); - if (DEBUG) console.log('[pack] embed complete, blob size:', finalBlob.size); - const defaultName = 'workflow-packed.png'; + const finalBlob = await embedWorkflow(imageBlob, packed as any); - if (window.pywebview?.api?.choose_save_workflow_png_path) { - const requestedPath = await window.pywebview.api.choose_save_workflow_png_path(defaultName); - if (!requestedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; } - const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, { - method: 'POST', headers: { 'Content-Type': 'image/png' }, body: finalBlob, - }); - if (!resp.ok) throw new Error(await resp.text() || `Save failed (${resp.status})`); - const { path: savedPath } = await resp.json(); - if (!savedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; } - setStatus({ text: `Packed workflow saved to ${savedPath}.`, level: 'info' }); - return; + const saved = await saveBlobToFile(finalBlob, 'workflow-packed.png'); + if (!saved) { + setStatus({ text: 'Save cancelled.', level: 'info' }); + } else { + setStatus({ text: `Packed workflow saved to ${saved}.`, level: 'info' }); } - - if ('showSaveFilePicker' in window) { - try { - const handle = await window.showSaveFilePicker!({ - suggestedName: defaultName, - types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }], - }); - const writable = await handle.createWritable(); - await writable.write(finalBlob); - await writable.close(); - setStatus({ text: 'Packed workflow saved.', level: 'info' }); - return; - } catch (err: any) { - if (err?.name === 'AbortError') { setStatus({ text: 'Save cancelled.', level: 'info' }); return; } - throw err; - } - } - - const a = document.createElement('a'); - a.href = URL.createObjectURL(finalBlob); - a.download = defaultName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); - setStatus({ text: `Packed workflow downloaded as ${defaultName}.`, level: 'info' }); } catch (err: any) { setStatus({ text: 'Pack failed: ' + err.message, level: 'error' }); } - }, [reactFlow]); + }, [reactFlow, captureWorkflowImage, saveBlobToFile]); const copySnapshot = useCallback(() => { setStatus({ text: 'Copying snapshot…', level: 'info' }); @@ -2201,10 +2096,11 @@ function Flow() { const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id)); const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id)) || (anchorNode ? getNodeAbsolutePosition(anchorNode, nodeMap) : null); - const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute + const anchorSize = anchorNode ? getNodeSize(anchorNode) : null; + const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute && anchorSize ? { - x: intendedAnchorAbsolute.x + (Number(getNodeDimension(anchorNode, 'width')) || 200) / 2, - y: intendedAnchorAbsolute.y + (Number(getNodeDimension(anchorNode, 'height')) || 120) / 2, + x: intendedAnchorAbsolute.x + anchorSize.width / 2, + y: intendedAnchorAbsolute.y + anchorSize.height / 2, } : null; const targetGroup = findExpandedGroupDropTarget( @@ -2222,8 +2118,7 @@ function Flow() { if (!draggedIdSet.has(String(candidate.id))) return candidate; const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id)); - const width = Number(getNodeDimension(candidate, 'width')) || 200; - const height = Number(getNodeDimension(candidate, 'height')) || 120; + const { width, height } = getNodeSize(candidate); const center = intendedAbsolute ? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 } : getNodeCenter(candidate, nodeMap); diff --git a/frontend/src/CropBoxOverlay.tsx b/frontend/src/CropBoxOverlay.tsx index 65a5c96..5b4a15b 100644 --- a/frontend/src/CropBoxOverlay.tsx +++ b/frontend/src/CropBoxOverlay.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState, useCallback } from 'react'; +import { pointerToFraction } from './overlayUtils'; export const CAPTURE_SELECTOR = '.crop-overlay'; @@ -23,11 +24,7 @@ export default function CropBoxOverlay({ const [dragging, setDragging] = useState(null); const getCoords = useCallback((e: React.PointerEvent) => { - 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)), - }; + return pointerToFraction(e, containerRef.current!); }, []); const onPointerDown = useCallback((point: string) => (e: React.PointerEvent) => { diff --git a/frontend/src/CrossSectionOverlay.tsx b/frontend/src/CrossSectionOverlay.tsx index de871a8..eefd60e 100644 --- a/frontend/src/CrossSectionOverlay.tsx +++ b/frontend/src/CrossSectionOverlay.tsx @@ -1,4 +1,5 @@ import React, { useRef, useState, useCallback } from 'react'; +import { pointerToFraction } from './overlayUtils'; export const CAPTURE_SELECTOR = '.cs-overlay'; @@ -34,11 +35,7 @@ export default function CrossSectionOverlay({ const [dragging, setDragging] = useState(null); // 'p1' or 'p2' const getCoords = useCallback((e: React.PointerEvent) => { - 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)), - }; + return pointerToFraction(e, containerRef.current!); }, []); const onPointerDown = useCallback((point: string) => (e: React.PointerEvent) => { diff --git a/frontend/src/CustomNode.tsx b/frontend/src/CustomNode.tsx index 20d7a6e..f56b933 100644 --- a/frontend/src/CustomNode.tsx +++ b/frontend/src/CustomNode.tsx @@ -20,7 +20,7 @@ import { } from './constants'; import { getGroupMinimumSize } from './groupSizing'; import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout'; -import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit } from './valueFormatting'; +import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting'; import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types'; @@ -302,51 +302,6 @@ class PreviewBoundary extends React.Component= p.factor * (1 - 1e-10)) { chosen = p; break; } - } - const scaled = v / chosen.factor; - return (prec != null ? scaled.toFixed(prec) : String(scaled)) + chosen.prefix; -} - -// Parse a string that may carry an SI suffix (e.g. "20n", "1.5μ", "500p") -// Falls back to standard parseFloat for plain numbers and scientific notation. -function parseSI(text: string) { - const t = (text || '').trim(); - if (!t) return NaN; - const lastChar = t.slice(-1); - const factor = _SI_PARSE_MAP[lastChar as keyof typeof _SI_PARSE_MAP]; - if (factor != null) { - const num = parseFloat(t.slice(0, -1)); - if (!isNaN(num)) return num * factor; - } - return parseFloat(t); -} - // ── Draggable number input ──────────────────────────────────────────── function DraggableNumber({ value, step, min, max, precision, onChange }: { @@ -1072,6 +1027,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { }, ); + const overlayCoord = useCallback( + (key: 'x1' | 'y1' | 'x2' | 'y2', liveIdx: number, locked: boolean) => { + if (locked) return (liveCoordPair?.[liveIdx] ?? data.overlay?.[key]) as number; + return (data.widgetValues[key] ?? data.overlay?.[key]) as number; + }, + [liveCoordPair, data.overlay, data.widgetValues], + ); + // Parse inputs into data handles and widgets const required = def?.input?.required || {}; const optional = def?.input?.optional || {}; @@ -1495,8 +1458,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { {data.overlay!.kind === 'line_plot' ? ( ( + <> + {!hideLabel && } + + + ); + // Combo / enum — type itself is the array of options if (Array.isArray(type)) { - return ( - <> - {!hideLabel && } - - - ); + return renderSelect(type, (val || type[0]) as string); } if (type === 'STRING' && dynamicTypeChoices.length > 0) { - const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0]; - return ( - <> - {!hideLabel && } - - - ); + return renderSelect(dynamicTypeChoices, dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0]); } if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) { - const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]; - return ( - <> - {!hideLabel && } - - - ); + return renderSelect(dynamicTableColumns, dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]); } if (type === 'STRING' && opts?.choices_from_measure_input && dynamicMeasurementChoices.length > 0) { - const selected = dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0]; - return ( - <> - {!hideLabel && } - - - ); + return renderSelect(dynamicMeasurementChoices, dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0]); } if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') { diff --git a/frontend/src/LinePlotOverlay.tsx b/frontend/src/LinePlotOverlay.tsx index 0cf604b..4a8f193 100644 --- a/frontend/src/LinePlotOverlay.tsx +++ b/frontend/src/LinePlotOverlay.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { getAxisScale } from './valueFormatting'; +import { clamp, formatTick, makeTicks, getExtent } from './overlayUtils'; export const CAPTURE_SELECTOR = '.lineplot-overlay'; @@ -13,59 +14,10 @@ const MARKER_STROKE = '#ffffff'; const MARKER_LOCKED_COLOR = '#e91e63'; const MARKER_LABEL_FILL = '#0f172a'; -function clamp(v: number, min: number, max: number) { - return Math.max(min, Math.min(max, v)); -} - function round3(v: number) { return parseFloat(v.toFixed(3)); } -function trimZeros(text: string) { - return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); -} - -function formatTick(value: number) { - const abs = Math.abs(value); - if (abs === 0) return '0'; - if (abs >= 1e4 || abs < 1e-3) { - return value.toExponential(1).replace('e+', 'e'); - } - if (abs >= 100) return trimZeros(value.toFixed(0)); - if (abs >= 10) return trimZeros(value.toFixed(1)); - if (abs >= 1) return trimZeros(value.toFixed(2)); - return trimZeros(value.toFixed(3)); -} - -function makeTicks(min: number, max: number, count = 5) { - if (!Number.isFinite(min) || !Number.isFinite(max)) return []; - if (min === max) return [min]; - const ticks: number[] = []; - for (let i = 0; i < count; i += 1) { - ticks.push(min + ((max - min) * i) / (count - 1)); - } - return ticks; -} - -function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) { - if (!Array.isArray(values) || values.length === 0) { - return [fallbackMin, fallbackMax]; - } - - let min = Infinity; - let max = -Infinity; - for (const value of values) { - if (!Number.isFinite(value)) continue; - if (value < min) min = value; - if (value > max) max = value; - } - - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return [fallbackMin, fallbackMax]; - } - return [min, max]; -} - interface LinePlotOverlayProps { overlay: any; x1: number; diff --git a/frontend/src/MarkupOverlay.tsx b/frontend/src/MarkupOverlay.tsx index bc26e3e..21d4f05 100644 --- a/frontend/src/MarkupOverlay.tsx +++ b/frontend/src/MarkupOverlay.tsx @@ -10,12 +10,7 @@ import { sanitizeMarkupColor, sanitizeMarkupShape, } from './markupShapeGeometry'; - -function clampFraction(value: number) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 0; - return Math.max(0, Math.min(1, numeric)); -} +import { clampFraction, pointerToFraction } from './overlayUtils'; interface MarkupShape { kind: string; @@ -150,11 +145,11 @@ export default function MarkupOverlay({ }, [image]); const getPoint = useCallback((event: React.PointerEvent) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return null; + if (!containerRef.current) return null; + const { fx, fy } = pointerToFraction(event, containerRef.current); return { - x: Number(clampFraction((event.clientX - rect.left) / rect.width).toFixed(4)), - y: Number(clampFraction((event.clientY - rect.top) / rect.height).toFixed(4)), + x: Number(fx.toFixed(4)), + y: Number(fy.toFixed(4)), }; }, []); diff --git a/frontend/src/MaskPaintOverlay.tsx b/frontend/src/MaskPaintOverlay.tsx index f7be866..3c0286f 100644 --- a/frontend/src/MaskPaintOverlay.tsx +++ b/frontend/src/MaskPaintOverlay.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { CANVAS_COLORS } from './constants'; +import { clampFraction, pointerToFraction } from './overlayUtils'; interface StrokePoint { x: number; @@ -16,12 +17,6 @@ interface DrawStrokeStyles { fillStyle?: string; } -function clampFraction(value: number) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 0; - return Math.max(0, Math.min(1, numeric)); -} - function sanitizeStroke(stroke: any, fallbackPenSize: number): Stroke | null { if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points) || stroke.points.length === 0) { return null; @@ -219,12 +214,9 @@ export default function MaskPaintOverlay({ }, [draftStroke, redrawCanvas]); const getPoint = useCallback((event: React.PointerEvent): StrokePoint | null => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return null; - return { - x: clampFraction((event.clientX - rect.left) / rect.width), - y: clampFraction((event.clientY - rect.top) / rect.height), - }; + if (!containerRef.current) return null; + const { fx, fy } = pointerToFraction(event, containerRef.current); + return { x: fx, y: fy }; }, []); const getBrushDisplaySize = useCallback(() => { diff --git a/frontend/src/ThresholdHistogram.tsx b/frontend/src/ThresholdHistogram.tsx index 0dbc4ba..19c3b7e 100644 --- a/frontend/src/ThresholdHistogram.tsx +++ b/frontend/src/ThresholdHistogram.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { getAxisScale } from './valueFormatting'; +import { clamp, formatTick, makeTicks, getExtent } from './overlayUtils'; export const CAPTURE_SELECTOR = '.lineplot-overlay'; @@ -13,31 +14,7 @@ const MARKER_STROKE = '#ffffff'; const MARKER_LOCKED_COLOR = '#e91e63'; const MARKER_LABEL_FILL = '#0f172a'; -function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); } function round4(v: number) { return parseFloat(v.toFixed(4)); } -function trimZeros(t: string) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); } - -function formatTick(value: number) { - const abs = Math.abs(value); - if (abs === 0) return '0'; - if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e'); - if (abs >= 100) return trimZeros(value.toFixed(0)); - if (abs >= 10) return trimZeros(value.toFixed(1)); - if (abs >= 1) return trimZeros(value.toFixed(2)); - return trimZeros(value.toFixed(3)); -} - -function makeTicks(min: number, max: number, count = 5) { - if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min]; - return Array.from({ length: count }, (_: unknown, i: number) => min + (max - min) * i / (count - 1)); -} - -function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) { - if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax]; - let min = Infinity, max = -Infinity; - for (const v of values) { if (Number.isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } } - return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax]; -} interface ThresholdHistogramProps { overlay: any; diff --git a/frontend/src/angleMeasureGeometry.ts b/frontend/src/angleMeasureGeometry.ts index 73d9230..86474c5 100644 --- a/frontend/src/angleMeasureGeometry.ts +++ b/frontend/src/angleMeasureGeometry.ts @@ -1,3 +1,5 @@ +import { clampFraction as clamp01 } from './overlayUtils.ts'; + interface AnglePoints { x1: number; y1: number; @@ -7,10 +9,6 @@ interface AnglePoints { y2: number; } -function clamp01(value: number): number { - return Math.max(0, Math.min(1, Number(value) || 0)); -} - export function round3(value: number): number { return Number.parseFloat(Number(value).toFixed(3)); } diff --git a/frontend/src/canvasEvents.ts b/frontend/src/canvasEvents.ts index 70669db..c729a76 100644 --- a/frontend/src/canvasEvents.ts +++ b/frontend/src/canvasEvents.ts @@ -1,4 +1,5 @@ import { getNodeCenter, getGroupWorkspaceBounds, rectContainsPoint } from './nodeGeometry'; +import { clamp } from './overlayUtils.ts'; export function getEventClientPosition(event: any) { if (!event) return null; @@ -52,7 +53,7 @@ export function isEditableTarget(target: any) { } export function clampNumber(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); + return clamp(value, min, max); } export function canStartCanvasRightDragZoom(target: any) { diff --git a/frontend/src/markupShapeGeometry.ts b/frontend/src/markupShapeGeometry.ts index 4c257fd..13873b5 100644 --- a/frontend/src/markupShapeGeometry.ts +++ b/frontend/src/markupShapeGeometry.ts @@ -1,3 +1,5 @@ +import { clampFraction, sanitizeHexColor } from './overlayUtils.ts'; + export const MARKUP_DEFAULT_SHAPE = 'arrow'; export const MARKUP_DEFAULT_COLOR = '#ff0000'; export const MARKUP_PREVIEW_REFERENCE_DIM = 512; @@ -12,16 +14,8 @@ export interface MarkupShape { color: string; } -function clampFraction(value: unknown): number { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 0; - return Math.max(0, Math.min(1, numeric)); -} - export function sanitizeMarkupColor(color: unknown, fallback: string = MARKUP_DEFAULT_COLOR): string { - if (typeof color !== 'string') return fallback; - const value = color.trim(); - return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback; + return sanitizeHexColor(color, fallback); } export function sanitizeMarkupShape( diff --git a/frontend/src/nodeGeometry.ts b/frontend/src/nodeGeometry.ts index 154f462..56443a7 100644 --- a/frontend/src/nodeGeometry.ts +++ b/frontend/src/nodeGeometry.ts @@ -18,6 +18,13 @@ export function getNodeDimension(node: any, axis: string): number { return node.measured?.height || node.style?.height || node.height || 120; } +export function getNodeSize(node: any): { width: number; height: number } { + return { + width: Number(getNodeDimension(node, 'width')) || 200, + height: Number(getNodeDimension(node, 'height')) || 120, + }; +} + export function applyNodeSize(node: any, width: any, height: any) { const nextWidth = Math.round(Number(width) || 0); const nextHeight = Math.round(Number(height) || 0); @@ -82,8 +89,7 @@ export function getGroupDisplayBounds(nodes: any[], selectedIds: any[]) { const node = nodeMap.get(String(id)); if (!node) continue; const pos = getNodeAbsolutePosition(node, nodeMap); - const width = Number(getNodeDimension(node, 'width')) || 200; - const height = Number(getNodeDimension(node, 'height')) || 120; + const { width, height } = getNodeSize(node); minX = Math.min(minX, pos.x); minY = Math.min(minY, pos.y); maxX = Math.max(maxX, pos.x + width); @@ -99,8 +105,7 @@ export function getGroupDisplayBounds(nodes: any[], selectedIds: any[]) { export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map) { const pos = getNodeAbsolutePosition(groupNode, nodeMap); - const width = Number(getNodeDimension(groupNode, 'width')) || 200; - const height = Number(getNodeDimension(groupNode, 'height')) || 120; + const { width, height } = getNodeSize(groupNode); return { left: pos.x + GROUP_WORKSPACE_INSET, top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET, @@ -111,8 +116,7 @@ export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map) { const pos = getNodeAbsolutePosition(node, nodeMap); - const width = Number(getNodeDimension(node, 'width')) || 200; - const height = Number(getNodeDimension(node, 'height')) || 120; + const { width, height } = getNodeSize(node); return { x: pos.x + width / 2, y: pos.y + height / 2, @@ -121,8 +125,7 @@ export function getNodeCenter(node: any, nodeMap: Map) { export function getNodeRect(node: any, nodeMap: Map) { const pos = getNodeAbsolutePosition(node, nodeMap); - const width = Number(getNodeDimension(node, 'width')) || 200; - const height = Number(getNodeDimension(node, 'height')) || 120; + const { width, height } = getNodeSize(node); return { left: pos.x, top: pos.y, @@ -132,8 +135,7 @@ export function getNodeRect(node: any, nodeMap: Map) { } export function getAbsoluteRectForNodePosition(node: any, absolutePosition: { x: number; y: number }) { - const width = Number(getNodeDimension(node, 'width')) || 200; - const height = Number(getNodeDimension(node, 'height')) || 120; + const { width, height } = getNodeSize(node); return { left: absolutePosition.x, top: absolutePosition.y, diff --git a/frontend/src/overlayUtils.ts b/frontend/src/overlayUtils.ts new file mode 100644 index 0000000..c74f768 --- /dev/null +++ b/frontend/src/overlayUtils.ts @@ -0,0 +1,69 @@ +import React from 'react'; + +// ── Clamping ───────────────────────────────────────────────────────── + +export function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + +export function clampFraction(value: number | unknown): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.max(0, Math.min(1, numeric)); +} + +// ── Color validation ───────────────────────────────────────────────── + +const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/; + +export function sanitizeHexColor(color: unknown, fallback: string = '#ff9800'): string { + if (typeof color !== 'string') return fallback; + const value = color.trim(); + return HEX_COLOR_RE.test(value) ? value.toLowerCase() : fallback; +} + +// ── Pointer coordinate extraction ──────────────────────────────────── + +export function pointerToFraction( + event: React.PointerEvent, + container: HTMLElement, +): { fx: number; fy: number } { + const rect = container.getBoundingClientRect(); + return { + fx: clampFraction((event.clientX - rect.left) / rect.width), + fy: clampFraction((event.clientY - rect.top) / rect.height), + }; +} + +// ── Chart helpers ──────────────────────────────────────────────────── + +export function trimZeros(text: string) { + return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); +} + +export function formatTick(value: number) { + const abs = Math.abs(value); + if (abs === 0) return '0'; + if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e'); + if (abs >= 100) return trimZeros(value.toFixed(0)); + if (abs >= 10) return trimZeros(value.toFixed(1)); + if (abs >= 1) return trimZeros(value.toFixed(2)); + return trimZeros(value.toFixed(3)); +} + +export function makeTicks(min: number, max: number, count = 5) { + if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min]; + return Array.from({ length: count }, (_: unknown, i: number) => min + (max - min) * i / (count - 1)); +} + +export function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) { + if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax]; + let min = Infinity, max = -Infinity; + for (const v of values) { + if (Number.isFinite(v)) { + if (v < min) min = v; + if (v > max) max = v; + } + } + return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax]; +} diff --git a/frontend/src/valueFormatting.ts b/frontend/src/valueFormatting.ts index 5e5ac7c..db6bedc 100644 --- a/frontend/src/valueFormatting.ts +++ b/frontend/src/valueFormatting.ts @@ -222,3 +222,42 @@ export function formatTableRowCell(row: Record, column: string) } return formatNumericCell(row?.[column]); } + +// ── Bare SI prefix formatting (no unit awareness) ──────────────────── + +const _BARE_SI_PREFIXES = [ + { prefix: 'T', factor: 1e12 }, + { prefix: 'G', factor: 1e9 }, + { prefix: 'M', factor: 1e6 }, + { prefix: 'k', factor: 1e3 }, + { prefix: '', factor: 1 }, + { prefix: 'm', factor: 1e-3 }, + { prefix: 'μ', factor: 1e-6 }, + { prefix: 'n', factor: 1e-9 }, + { prefix: 'p', factor: 1e-12 }, + { prefix: 'f', factor: 1e-15 }, +]; + +export function formatSI(v: number, prec: number | null | undefined) { + if (!Number.isFinite(v)) return String(v); + if (v === 0) return prec != null ? `0.${'0'.repeat(prec)}` : '0'; + const abs = Math.abs(v); + let chosen = _BARE_SI_PREFIXES[_BARE_SI_PREFIXES.length - 1]; + for (const p of _BARE_SI_PREFIXES) { + if (abs >= p.factor * (1 - 1e-10)) { chosen = p; break; } + } + const scaled = v / chosen.factor; + return (prec != null ? scaled.toFixed(prec) : String(scaled)) + chosen.prefix; +} + +export function parseSI(text: string) { + const t = (text || '').trim(); + if (!t) return NaN; + const lastChar = t.slice(-1); + const factor = SI_PREFIX_MULTIPLIERS[lastChar]; + if (factor != null) { + const num = parseFloat(t.slice(0, -1)); + if (!isNaN(num)) return num * factor; + } + return parseFloat(t); +}