deduplication pass
This commit is contained in:
@@ -86,7 +86,7 @@ class Annotations:
|
|||||||
|
|
||||||
context = _annotation_context_from_image(input)
|
context = _annotation_context_from_image(input)
|
||||||
if context is None:
|
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."
|
"Annotations image input has no scale metadata, so scale bar and color-map legend cannot be added."
|
||||||
)
|
)
|
||||||
return (ImageData(image_to_uint8(input)),)
|
return (ImageData(image_to_uint8(input)),)
|
||||||
@@ -111,7 +111,7 @@ class Annotations:
|
|||||||
if not (has_legend_values and str(context.get("legend_unit", "")).strip()):
|
if not (has_legend_values and str(context.get("legend_unit", "")).strip()):
|
||||||
missing_features.append("color-map legend")
|
missing_features.append("color-map legend")
|
||||||
if missing_features:
|
if missing_features:
|
||||||
self._send_warning(
|
emit_warning(
|
||||||
f"Annotations image input is missing metadata for: {', '.join(missing_features)}."
|
f"Annotations image input is missing metadata for: {', '.join(missing_features)}."
|
||||||
)
|
)
|
||||||
annotated = _apply_annotation_overlay_from_context(
|
annotated = _apply_annotation_overlay_from_context(
|
||||||
@@ -120,6 +120,3 @@ class Annotations:
|
|||||||
annotation_spec,
|
annotation_spec,
|
||||||
)
|
)
|
||||||
return (ImageData(annotated, metadata={"annotation_context": context}),)
|
return (ImageData(annotated, metadata={"annotation_context": context}),)
|
||||||
|
|
||||||
def _send_warning(self, message: str):
|
|
||||||
emit_warning(message)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import numpy as np
|
|||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_overlay
|
from backend.execution_context import emit_overlay
|
||||||
from backend.data_types import DataField, LineData, RecordTable, encode_preview, render_datafield_preview
|
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")
|
@register_node(display_name="Cursors")
|
||||||
@@ -76,16 +77,8 @@ class Cursors:
|
|||||||
xmin = float(np.min(x)) if len(x) else 0.0
|
xmin = float(np.min(x)) if len(x) else 0.0
|
||||||
xmax = float(np.max(x)) if len(x) else 1.0
|
xmax = float(np.max(x)) if len(x) else 1.0
|
||||||
|
|
||||||
def x_frac_to_idx(frac):
|
idx_a = frac_to_index(x, x1)
|
||||||
if n <= 1:
|
idx_b = frac_to_index(x, x2)
|
||||||
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)
|
|
||||||
|
|
||||||
xa, ya = float(x[idx_a]), float(y[idx_a])
|
xa, ya = float(x[idx_a]), float(y[idx_a])
|
||||||
xb, yb = float(x[idx_b]), float(y[idx_b])
|
xb, yb = float(x[idx_b]), float(y[idx_b])
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from backend.data_types import (
|
|||||||
from backend.execution_context import emit_preview, emit_table, emit_warning
|
from backend.execution_context import emit_preview, emit_table, emit_warning
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.nodes.surface_common import require_compatible_xy_z_units
|
from backend.nodes.surface_common import require_compatible_xy_z_units
|
||||||
|
from backend.nodes.helpers import normalize_mask, apply_masking
|
||||||
|
|
||||||
_CURVATURE_COLOR = "#ff9800"
|
_CURVATURE_COLOR = "#ff9800"
|
||||||
_CENTER_COLOR = "#8bd3ff"
|
_CENTER_COLOR = "#8bd3ff"
|
||||||
@@ -28,16 +29,6 @@ class _Intersection:
|
|||||||
y: float
|
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:
|
def _canonicalize_half_pi(angle: float) -> float:
|
||||||
wrapped = (float(angle) + 0.5 * np.pi) % np.pi - 0.5 * np.pi
|
wrapped = (float(angle) + 0.5 * np.pi) % np.pi - 0.5 * np.pi
|
||||||
if wrapped <= -0.5 * np.pi + 1e-15:
|
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
|
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
|
y = 2.0 * yy.astype(np.float64) / max(yres - 1, 1) - 1.0
|
||||||
|
|
||||||
valid = np.ones(data.shape, dtype=bool)
|
valid = apply_masking(data, mask, masking)
|
||||||
if mask is not None and masking != "ignore":
|
|
||||||
valid = mask if masking == "include" else ~mask
|
|
||||||
|
|
||||||
if np.count_nonzero(valid) < 6:
|
if np.count_nonzero(valid) < 6:
|
||||||
return None
|
return None
|
||||||
@@ -309,7 +298,7 @@ class Curvature:
|
|||||||
mask: np.ndarray | None = None,
|
mask: np.ndarray | None = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
require_compatible_xy_z_units(field, "Curvature")
|
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)
|
results = _compute_curvature_results(field, mask_array, masking)
|
||||||
|
|
||||||
if results is None:
|
if results is None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from scipy.ndimage import map_coordinates
|
|||||||
from backend.data_types import LineData, RecordTable
|
from backend.data_types import LineData, RecordTable
|
||||||
from backend.execution_context import emit_overlay, emit_table, emit_warning
|
from backend.execution_context import emit_overlay, emit_table, emit_warning
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.spectral_common import _window_vector
|
||||||
|
|
||||||
|
|
||||||
_LOG_TINY = float(np.finfo(np.float64).tiny)
|
_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)
|
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:
|
def _window_with_rms_compensation(values: np.ndarray, window: np.ndarray) -> np.ndarray:
|
||||||
row = np.asarray(values, dtype=np.float64)
|
row = np.asarray(values, dtype=np.float64)
|
||||||
rms = float(np.sqrt(np.mean(row * row)))
|
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:
|
if width < 2 or rows < 1:
|
||||||
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
|
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)
|
accum = np.zeros(width // 2 + 1, dtype=np.float64)
|
||||||
for row in np.asarray(data, dtype=np.float64):
|
for row in np.asarray(data, dtype=np.float64):
|
||||||
leveled = _row_level2(row)
|
leveled = _row_level2(row)
|
||||||
|
|||||||
@@ -31,30 +31,20 @@ class Gradient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def process(self, field: DataField, component: str) -> tuple:
|
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
|
gx, gy = physical_sobel_gradient(field)
|
||||||
# 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)
|
|
||||||
|
|
||||||
if component == "magnitude":
|
if component == "magnitude":
|
||||||
result = np.hypot(gx, gy)
|
result = np.hypot(gx, gy)
|
||||||
z = str(field.si_unit_z or "").strip()
|
out_unit_z = slope_unit(field)
|
||||||
xy = str(field.si_unit_xy or "").strip()
|
|
||||||
out_unit_z = f"{z}/{xy}" if z and xy else (z or xy)
|
|
||||||
elif component == "x":
|
elif component == "x":
|
||||||
result = gx
|
result = gx
|
||||||
z = str(field.si_unit_z or "").strip()
|
out_unit_z = slope_unit(field)
|
||||||
xy = str(field.si_unit_xy or "").strip()
|
|
||||||
out_unit_z = f"{z}/{xy}" if z and xy else (z or xy)
|
|
||||||
elif component == "y":
|
elif component == "y":
|
||||||
result = gy
|
result = gy
|
||||||
z = str(field.si_unit_z or "").strip()
|
out_unit_z = slope_unit(field)
|
||||||
xy = str(field.si_unit_xy or "").strip()
|
|
||||||
out_unit_z = f"{z}/{xy}" if z and xy else (z or xy)
|
|
||||||
elif component == "azimuth":
|
elif component == "azimuth":
|
||||||
# Azimuth: local slope direction, radians, matches Gwyddion's filter_azimuth
|
|
||||||
result = np.arctan2(gy, gx)
|
result = np.arctan2(gy, gx)
|
||||||
out_unit_z = "rad"
|
out_unit_z = "rad"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.data_types import DataField, DataTable
|
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")
|
@register_node(display_name="Grain Analysis")
|
||||||
@@ -31,7 +31,7 @@ class GrainAnalysis:
|
|||||||
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||||
from scipy.ndimage import label
|
from scipy.ndimage import label
|
||||||
|
|
||||||
binary = (mask > 127).astype(np.int32)
|
binary = mask_to_bool(mask).astype(np.int32)
|
||||||
labeled, n_grains = label(binary)
|
labeled, n_grains = label(binary)
|
||||||
|
|
||||||
pixel_area = field.dx * field.dy
|
pixel_area = field.dx * field.dy
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ from scipy.ndimage import binary_erosion, distance_transform_edt
|
|||||||
|
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.helpers import mask_to_bool
|
||||||
|
|
||||||
|
|
||||||
def _normalize_mask(mask: np.ndarray) -> np.ndarray:
|
def _normalize_mask(mask: np.ndarray) -> np.ndarray:
|
||||||
data = np.asarray(mask)
|
data = np.asarray(mask)
|
||||||
if data.ndim != 2:
|
if data.ndim != 2:
|
||||||
raise ValueError("Grain Distance Transform requires a 2-D mask.")
|
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]]:
|
def _prepare_mask(binary: np.ndarray, from_border: bool) -> tuple[np.ndarray, tuple[slice, slice]]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Grain Filter")
|
@register_node(display_name="Grain Filter")
|
||||||
@@ -40,7 +41,7 @@ class GrainFilter:
|
|||||||
) -> tuple:
|
) -> tuple:
|
||||||
from scipy.ndimage import label
|
from scipy.ndimage import label
|
||||||
|
|
||||||
binary = np.asarray(mask) > 127
|
binary = mask_to_bool(mask)
|
||||||
labeled, n_grains = label(binary)
|
labeled, n_grains = label(binary)
|
||||||
|
|
||||||
# Build per-grain keep table (index 0 = background, always False)
|
# Build per-grain keep table (index 0 = background, always False)
|
||||||
@@ -60,7 +61,7 @@ class GrainFilter:
|
|||||||
keep[gid] = True
|
keep[gid] = True
|
||||||
|
|
||||||
result = keep[labeled]
|
result = keep[labeled]
|
||||||
return (result.astype(np.uint8) * 255,)
|
return (bool_to_mask(result),)
|
||||||
|
|
||||||
|
|
||||||
def _touches_border(grain: np.ndarray) -> bool:
|
def _touches_border(grain: np.ndarray) -> bool:
|
||||||
|
|||||||
@@ -309,10 +309,34 @@ def _render_markup_image(image, shapes):
|
|||||||
# Mask helpers (from mask.py — used by multiple mask nodes)
|
# 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):
|
def _mask_overlay(field, mask):
|
||||||
from backend.data_types import datafield_to_uint8
|
from backend.data_types import datafield_to_uint8
|
||||||
grey = datafield_to_uint8(field, "gray")
|
grey = datafield_to_uint8(field, "gray")
|
||||||
mask_bool = mask > 127
|
mask_bool = mask_to_bool(mask)
|
||||||
if not np.any(mask_bool):
|
if not np.any(mask_bool):
|
||||||
return grey
|
return grey
|
||||||
|
|
||||||
@@ -728,6 +752,62 @@ def _square_unit(unit: str) -> str:
|
|||||||
return f"{unit}^2"
|
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:
|
def _apply_scalar_unit(base_unit: str, operation: str) -> str:
|
||||||
unit = str(base_unit or "").strip()
|
unit = str(base_unit or "").strip()
|
||||||
if operation == "count":
|
if operation == "count":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import numpy as np
|
|||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_overlay
|
from backend.execution_context import emit_overlay
|
||||||
from backend.data_types import DataField, RecordTable
|
from backend.data_types import DataField, RecordTable
|
||||||
|
from backend.nodes.helpers import frac_to_index, histogram_with_centers
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Histogram")
|
@register_node(display_name="Histogram")
|
||||||
@@ -44,9 +45,7 @@ class Histogram:
|
|||||||
x2: float = 0.75,
|
x2: float = 0.75,
|
||||||
y2: float = 0.5,
|
y2: float = 0.5,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
raw_counts, bin_edges = np.histogram(field.data.ravel(), bins=int(n_bins))
|
counts, bin_centers = histogram_with_centers(field.data, n_bins)
|
||||||
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
|
||||||
counts = raw_counts.astype(np.float64)
|
|
||||||
if y_scale == "log":
|
if y_scale == "log":
|
||||||
counts = np.log10(1.0 + counts)
|
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
|
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
|
xmax = float(np.max(bin_centers)) if len(bin_centers) else 1.0
|
||||||
|
|
||||||
def x_frac_to_idx(frac):
|
idx_a = frac_to_index(bin_centers, x1)
|
||||||
if len(bin_centers) <= 1:
|
idx_b = frac_to_index(bin_centers, x2)
|
||||||
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)
|
|
||||||
xa = float(bin_centers[idx_a]) if len(bin_centers) else 0.0
|
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
|
xb = float(bin_centers[idx_b]) if len(bin_centers) else 0.0
|
||||||
ya = float(counts[idx_a]) if len(counts) else 0.0
|
ya = float(counts[idx_a]) if len(counts) else 0.0
|
||||||
|
|||||||
@@ -5,16 +5,7 @@ import numpy as np
|
|||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.nodes.surface_common import require_compatible_xy_z_units
|
from backend.nodes.surface_common import require_compatible_xy_z_units
|
||||||
|
from backend.nodes.helpers import normalize_mask
|
||||||
|
|
||||||
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 _facet_cell_mask(mask: np.ndarray | None, masking: str, shape: tuple[int, int]) -> np.ndarray:
|
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,
|
mask: np.ndarray | None = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
require_compatible_xy_z_units(field, "Facet Level")
|
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)
|
leveled = _facet_level_data(field, mask_array, masking, max_iterations=100)
|
||||||
return (field.replace(data=leveled),)
|
return (field.replace(data=leveled),)
|
||||||
|
|||||||
@@ -2,16 +2,7 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import normalize_mask, apply_masking
|
||||||
|
|
||||||
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 _fit_plane(
|
def _fit_plane(
|
||||||
@@ -24,14 +15,7 @@ def _fit_plane(
|
|||||||
y = np.linspace(0.0, 1.0, yres)
|
y = np.linspace(0.0, 1.0, yres)
|
||||||
xx, yy = np.meshgrid(x, y)
|
xx, yy = np.meshgrid(x, y)
|
||||||
|
|
||||||
if mask is None or masking == "ignore":
|
valid = apply_masking(data, mask, masking)
|
||||||
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}")
|
|
||||||
|
|
||||||
if np.count_nonzero(valid) < 3:
|
if np.count_nonzero(valid) < 3:
|
||||||
raise ValueError("Plane Level requires at least three usable pixels for fitting.")
|
raise ValueError("Plane Level requires at least three usable pixels for fitting.")
|
||||||
@@ -78,7 +62,7 @@ class PlaneLevelField:
|
|||||||
mask: np.ndarray | None = None,
|
mask: np.ndarray | None = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
data = field.data.copy()
|
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)
|
pa, pbx, pby, xx, yy = _fit_plane(data, mask_array, masking)
|
||||||
|
|
||||||
plane = (pa + pbx * xx + pby * yy)
|
plane = (pa + pbx * xx + pby * yy)
|
||||||
|
|||||||
@@ -4,16 +4,7 @@ import numpy as np
|
|||||||
|
|
||||||
from backend.data_types import DataField, LineData
|
from backend.data_types import DataField, LineData
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.helpers import normalize_mask, apply_masking, masked_values
|
||||||
|
|
||||||
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 _trimmed_mean_or_median(values: np.ndarray, trim_fraction: float) -> float:
|
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))
|
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:
|
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:
|
if selected.size == 0:
|
||||||
selected = np.asarray(data, dtype=np.float64).ravel()
|
selected = np.asarray(data, dtype=np.float64).ravel()
|
||||||
return float(np.median(selected))
|
return float(np.median(selected))
|
||||||
@@ -75,7 +56,7 @@ def _find_row_shifts_trimmed_mean(
|
|||||||
shifts[i] = _trimmed_mean_or_median(row, trim_fraction)
|
shifts[i] = _trimmed_mean_or_median(row, trim_fraction)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
values = _masked_values(row, row_mask, masking)
|
values = masked_values(row, row_mask, masking)
|
||||||
if values.size >= mincount:
|
if values.size >= mincount:
|
||||||
shifts[i] = _trimmed_mean_or_median(values, trim_fraction)
|
shifts[i] = _trimmed_mean_or_median(values, trim_fraction)
|
||||||
else:
|
else:
|
||||||
@@ -162,12 +143,7 @@ def _row_level_poly(
|
|||||||
row = data[i]
|
row = data[i]
|
||||||
row_mask = None if mask is None else mask[i]
|
row_mask = None if mask is None else mask[i]
|
||||||
|
|
||||||
if row_mask is None or masking == "ignore":
|
valid = apply_masking(row, row_mask, masking)
|
||||||
valid = np.ones(xres, dtype=bool)
|
|
||||||
elif masking == "include":
|
|
||||||
valid = row_mask
|
|
||||||
else:
|
|
||||||
valid = ~row_mask
|
|
||||||
|
|
||||||
coeffs = np.zeros(degree + 1, dtype=np.float64)
|
coeffs = np.zeros(degree + 1, dtype=np.float64)
|
||||||
if np.count_nonzero(valid) > degree:
|
if np.count_nonzero(valid) > degree:
|
||||||
@@ -331,7 +307,7 @@ class LineCorrection:
|
|||||||
mask: np.ndarray | None = None,
|
mask: np.ndarray | None = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
data = np.asarray(field.data, dtype=np.float64)
|
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"}:
|
if direction not in {"horizontal", "vertical"}:
|
||||||
raise ValueError(f"Unknown direction: {direction}")
|
raise ValueError(f"Unknown direction: {direction}")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import numpy as np
|
|||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_overlay
|
from backend.execution_context import emit_overlay
|
||||||
from backend.data_types import DataField, datafield_to_uint8, encode_preview
|
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")
|
@register_node(display_name="Draw Mask")
|
||||||
@@ -37,7 +37,7 @@ class DrawMask:
|
|||||||
strokes = _parse_mask_strokes(mask_paths)
|
strokes = _parse_mask_strokes(mask_paths)
|
||||||
mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size)
|
mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size)
|
||||||
if invert:
|
if invert:
|
||||||
mask = np.where(mask > 127, np.uint8(0), np.uint8(255))
|
mask = bool_to_mask(~mask_to_bool(mask))
|
||||||
|
|
||||||
emit_overlay({
|
emit_overlay({
|
||||||
"kind": "mask_paint",
|
"kind": "mask_paint",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_preview
|
from backend.data_types import DataField
|
||||||
from backend.data_types import DataField, encode_preview
|
from backend.nodes.helpers import bool_to_mask, mask_to_bool, emit_mask_preview
|
||||||
from backend.nodes.helpers import _mask_overlay
|
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Mask Invert")
|
@register_node(display_name="Mask Invert")
|
||||||
@@ -29,10 +28,8 @@ class MaskInvert:
|
|||||||
DESCRIPTION = "Invert a binary mask — swap masked and unmasked regions."
|
DESCRIPTION = "Invert a binary mask — swap masked and unmasked regions."
|
||||||
|
|
||||||
def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple:
|
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:
|
emit_mask_preview(field, out)
|
||||||
overlay = _mask_overlay(field, out)
|
|
||||||
emit_preview(encode_preview(overlay))
|
|
||||||
|
|
||||||
return (out,)
|
return (out,)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_preview
|
from backend.data_types import DataField
|
||||||
from backend.data_types import DataField, encode_preview
|
from backend.nodes.helpers import _mask_structure, mask_to_bool, bool_to_mask, emit_mask_preview
|
||||||
from backend.nodes.helpers import _mask_overlay, _mask_structure
|
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Mask Morphology")
|
@register_node(display_name="Mask Morphology")
|
||||||
@@ -45,7 +44,7 @@ class MaskMorphology:
|
|||||||
field: DataField | None = None) -> tuple:
|
field: DataField | None = None) -> tuple:
|
||||||
from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening
|
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)
|
struct = _mask_structure(radius, shape)
|
||||||
|
|
||||||
if operation == "dilate":
|
if operation == "dilate":
|
||||||
@@ -59,10 +58,8 @@ class MaskMorphology:
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown morphological operation: {operation}")
|
raise ValueError(f"Unknown morphological operation: {operation}")
|
||||||
|
|
||||||
out = result.astype(np.uint8) * 255
|
out = bool_to_mask(result)
|
||||||
|
|
||||||
if field is not None:
|
emit_mask_preview(field, out)
|
||||||
overlay = _mask_overlay(field, out)
|
|
||||||
emit_preview(encode_preview(overlay))
|
|
||||||
|
|
||||||
return (out,)
|
return (out,)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.helpers import mask_to_bool, bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
_MASK_BOOLEAN_OPERATIONS = {
|
_MASK_BOOLEAN_OPERATIONS = {
|
||||||
@@ -53,14 +54,14 @@ class MaskOperations:
|
|||||||
mask_b: np.ndarray,
|
mask_b: np.ndarray,
|
||||||
operation: str,
|
operation: str,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
a = mask_a > 127
|
a = mask_to_bool(mask_a)
|
||||||
b = mask_b > 127
|
b = mask_to_bool(mask_b)
|
||||||
|
|
||||||
op = _MASK_BOOLEAN_OPERATIONS.get(operation)
|
op = _MASK_BOOLEAN_OPERATIONS.get(operation)
|
||||||
if op is None:
|
if op is None:
|
||||||
raise ValueError(f"Unknown mask operation: {operation}")
|
raise ValueError(f"Unknown mask operation: {operation}")
|
||||||
result = op(a, b)
|
result = op(a, b)
|
||||||
|
|
||||||
out = result.astype(np.uint8) * 255
|
out = bool_to_mask(result)
|
||||||
|
|
||||||
return (out,)
|
return (out,)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.execution_context import emit_preview, emit_overlay
|
from backend.execution_context import emit_overlay
|
||||||
from backend.data_types import DataField, encode_preview, RecordTable
|
from backend.data_types import DataField, RecordTable
|
||||||
from backend.nodes.helpers import _mask_overlay
|
from backend.nodes.helpers import bool_to_mask, histogram_with_centers, emit_mask_preview
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Threshold Mask")
|
@register_node(display_name="Threshold Mask")
|
||||||
@@ -36,9 +36,7 @@ class ThresholdMask:
|
|||||||
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
||||||
data = field.data
|
data = field.data
|
||||||
|
|
||||||
raw_counts, bin_edges = np.histogram(data.ravel(), bins=256)
|
counts, bin_centers = histogram_with_centers(data)
|
||||||
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
|
||||||
counts = raw_counts.astype(np.float64)
|
|
||||||
xmin = float(bin_centers[0]) if len(bin_centers) else 0.0
|
xmin = float(bin_centers[0]) if len(bin_centers) else 0.0
|
||||||
xmax = float(bin_centers[-1]) if len(bin_centers) else 1.0
|
xmax = float(bin_centers[-1]) if len(bin_centers) else 1.0
|
||||||
|
|
||||||
@@ -70,11 +68,11 @@ class ThresholdMask:
|
|||||||
})
|
})
|
||||||
|
|
||||||
if direction == "above":
|
if direction == "above":
|
||||||
mask = (data >= t).astype(np.uint8) * 255
|
mask = bool_to_mask(data >= t)
|
||||||
else:
|
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([
|
table = RecordTable([
|
||||||
{"quantity": "threshold", "value": threshold, "unit": field.si_unit_xy},
|
{"quantity": "threshold", "value": threshold, "unit": field.si_unit_xy},
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class RotateField:
|
|||||||
expand_canvas: bool,
|
expand_canvas: bool,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
if field.overlays:
|
if field.overlays:
|
||||||
self._send_warning("Rotate clears annotation/markup overlays!")
|
emit_warning("Rotate clears annotation/markup overlays!")
|
||||||
|
|
||||||
angle = float(angle)
|
angle = float(angle)
|
||||||
order_map = {
|
order_map = {
|
||||||
@@ -82,9 +82,6 @@ class RotateField:
|
|||||||
)
|
)
|
||||||
return (result,)
|
return (result,)
|
||||||
|
|
||||||
def _send_warning(self, message: str):
|
|
||||||
emit_warning(message)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]:
|
def _rotated_extents(field: DataField, angle: float, expand_canvas: bool) -> tuple[float, float]:
|
||||||
if not expand_canvas:
|
if not expand_canvas:
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class Save:
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Save does not support input type: {type(value).__name__}")
|
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))
|
emit_file_download(str(path))
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
@@ -373,6 +373,3 @@ class Save:
|
|||||||
lines.append(" endfacet")
|
lines.append(" endfacet")
|
||||||
lines.append("endsolid tono")
|
lines.append("endsolid tono")
|
||||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
def _send_warning(self, message: str):
|
|
||||||
emit_warning(message)
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class SaveImage:
|
|||||||
else:
|
else:
|
||||||
self._save_npz(path, layers, layer_names)
|
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))
|
emit_file_download(str(path))
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
@@ -181,6 +181,3 @@ class SaveImage:
|
|||||||
if isinstance(layer, np.ndarray):
|
if isinstance(layer, np.ndarray):
|
||||||
return np.asarray(layer)
|
return np.asarray(layer)
|
||||||
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
|
raise ValueError(f"Unsupported save layer type: {type(layer).__name__}")
|
||||||
|
|
||||||
def _send_warning(self, message: str):
|
|
||||||
emit_warning(message)
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import numpy as np
|
|||||||
|
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.helpers import bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
def _mark_scars_one_sign(
|
def _mark_scars_one_sign(
|
||||||
@@ -218,4 +219,4 @@ class ScarRemoval:
|
|||||||
)
|
)
|
||||||
scar_mask = marks > 0.0
|
scar_mask = marks > 0.0
|
||||||
corrected = _laplace_inpaint(np.asarray(field.data, dtype=np.float64), scar_mask)
|
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))
|
||||||
|
|||||||
@@ -32,26 +32,20 @@ class SlopeDistribution:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def process(self, field: DataField, distribution: str, n_bins: int) -> tuple:
|
def process(self, field: DataField, distribution: str, n_bins: int) -> tuple:
|
||||||
from scipy.ndimage import sobel
|
from backend.nodes.surface_common import physical_sobel_gradient, slope_unit as _slope_unit
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
gx, gy = physical_sobel_gradient(field)
|
||||||
gx = gx.ravel()
|
gx = gx.ravel()
|
||||||
gy = gy.ravel()
|
gy = gy.ravel()
|
||||||
n = len(gx)
|
|
||||||
|
|
||||||
z = str(field.si_unit_z or "").strip()
|
su = _slope_unit(field)
|
||||||
xy = str(field.si_unit_xy or "").strip()
|
|
||||||
slope_unit = f"{z}/{xy}" if z and xy else (z or xy)
|
|
||||||
|
|
||||||
if distribution == "phi":
|
if distribution == "phi":
|
||||||
return self._phi(gx, gy, n_bins, slope_unit)
|
return self._phi(gx, gy, n_bins, su)
|
||||||
elif distribution == "theta":
|
elif distribution == "theta":
|
||||||
return self._theta(gx, gy, n_bins)
|
return self._theta(gx, gy, n_bins)
|
||||||
elif distribution == "gradient":
|
elif distribution == "gradient":
|
||||||
return self._gradient(gx, gy, n_bins, slope_unit)
|
return self._gradient(gx, gy, n_bins, su)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown distribution type: {distribution!r}. "
|
raise ValueError(f"Unknown distribution type: {distribution!r}. "
|
||||||
f"Choose from: theta, phi, gradient")
|
f"Choose from: theta, phi, gradient")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
|
from backend.nodes.helpers import _square_unit
|
||||||
|
|
||||||
|
|
||||||
def _level_data(data: np.ndarray, level: str) -> np.ndarray:
|
def _level_data(data: np.ndarray, level: str) -> np.ndarray:
|
||||||
@@ -78,15 +79,6 @@ def _inverse_unit(unit: str) -> str:
|
|||||||
return f"1/{text}"
|
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:
|
def _product_unit(*units: str) -> str:
|
||||||
parts = [str(unit).strip() for unit in units if str(unit or "").strip()]
|
parts = [str(unit).strip() for unit in units if str(unit or "").strip()]
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +17,21 @@ def unit_dimension_key(unit: str) -> str:
|
|||||||
return text
|
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:
|
def require_compatible_xy_z_units(field: DataField, node_name: str) -> None:
|
||||||
xy_key = unit_dimension_key(field.si_unit_xy)
|
xy_key = unit_dimension_key(field.si_unit_xy)
|
||||||
z_key = unit_dimension_key(field.si_unit_z)
|
z_key = unit_dimension_key(field.si_unit_z)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import numpy as np
|
|||||||
|
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
|
from backend.nodes.helpers import bool_to_mask
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Template Match")
|
@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])
|
# Clip to [0, 1] for display (match_template returns values in [-1, 1])
|
||||||
score_clipped = np.clip(score, 0.0, 1.0)
|
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)
|
score_field = image.replace(data=score_clipped)
|
||||||
return (score_field, detections)
|
return (score_field, detections)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from scipy.ndimage import label
|
|||||||
from backend.execution_context import emit_preview
|
from backend.execution_context import emit_preview
|
||||||
from backend.data_types import DataField, encode_preview
|
from backend.data_types import DataField, encode_preview
|
||||||
from backend.node_registry import register_node
|
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:
|
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":
|
if existing_mask is None or combine_mode == "replace":
|
||||||
return result_mask
|
return result_mask
|
||||||
|
|
||||||
existing = np.asarray(existing_mask) > 127
|
existing = mask_to_bool(existing_mask)
|
||||||
current = np.asarray(result_mask, dtype=bool)
|
current = np.asarray(result_mask, dtype=bool)
|
||||||
if existing.shape != current.shape:
|
if existing.shape != current.shape:
|
||||||
raise ValueError("Existing mask must have the same shape as the watershed output.")
|
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:
|
else:
|
||||||
raise ValueError(f"Unsupported combine mode: {combine_mode}")
|
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")
|
@register_node(display_name="Watershed Segmentation")
|
||||||
@@ -262,7 +262,7 @@ class WatershedSegmentation:
|
|||||||
_watershed_step(watershed_field, water, labels, seeds, watershed_drop)
|
_watershed_step(watershed_field, water, labels, seeds, watershed_drop)
|
||||||
|
|
||||||
labels = _mark_boundaries(labels)
|
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)
|
result_mask = _combine_masks(result_mask, mask, combine_mode)
|
||||||
|
|
||||||
emit_preview(encode_preview(_mask_overlay(field, result_mask)))
|
emit_preview(encode_preview(_mask_overlay(field, result_mask)))
|
||||||
|
|||||||
@@ -9,16 +9,7 @@ import {
|
|||||||
moveAngleWidget,
|
moveAngleWidget,
|
||||||
round3,
|
round3,
|
||||||
} from './angleMeasureGeometry';
|
} from './angleMeasureGeometry';
|
||||||
|
import { clampFraction as clamp01, sanitizeHexColor, pointerToFraction } from './overlayUtils';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgb(value: string) {
|
function hexToRgb(value: string) {
|
||||||
const color = sanitizeHexColor(value);
|
const color = sanitizeHexColor(value);
|
||||||
@@ -112,11 +103,7 @@ export default function AngleMeasureOverlay({
|
|||||||
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
|
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
|
||||||
|
|
||||||
const getCoords = useCallback((event: React.PointerEvent<Element>) => {
|
const getCoords = useCallback((event: React.PointerEvent<Element>) => {
|
||||||
const rect = containerRef.current!.getBoundingClientRect();
|
return pointerToFraction(event, containerRef.current!);
|
||||||
return {
|
|
||||||
fx: clamp01((event.clientX - rect.left) / rect.width),
|
|
||||||
fy: clamp01((event.clientY - rect.top) / rect.height),
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateWidgets = useCallback((updates: Record<string, unknown>) => {
|
const updateWidgets = useCallback((updates: Record<string, unknown>) => {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import {
|
|||||||
GROUP_HEADER_HEIGHT,
|
GROUP_HEADER_HEIGHT,
|
||||||
GROUP_MIN_WIDTH,
|
GROUP_MIN_WIDTH,
|
||||||
GROUP_MIN_HEIGHT,
|
GROUP_MIN_HEIGHT,
|
||||||
getNodeDimension,
|
getNodeSize,
|
||||||
applyNodeSize,
|
applyNodeSize,
|
||||||
getNodeAbsolutePosition,
|
getNodeAbsolutePosition,
|
||||||
collectGroupDescendantIds,
|
collectGroupDescendantIds,
|
||||||
@@ -127,6 +127,36 @@ const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5;
|
|||||||
|
|
||||||
const DEBUG = false; // set to true for verbose logging
|
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<string, any>;
|
||||||
|
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) ────────────
|
// ── Main flow component (needs ReactFlowProvider ancestor) ────────────
|
||||||
|
|
||||||
function Flow() {
|
function Flow() {
|
||||||
@@ -212,6 +242,14 @@ function Flow() {
|
|||||||
reactFlow.updateNodeInternals(groupId);
|
reactFlow.updateNodeInternals(groupId);
|
||||||
}, [reactFlow, setNodes]);
|
}, [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 toggleGroupCollapse = useCallback((groupId: string) => {
|
||||||
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||||
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
|
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
|
||||||
@@ -296,30 +334,7 @@ function Flow() {
|
|||||||
return edge;
|
return edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((edge.data as any)?.groupInternalHiddenBy === groupId) {
|
return restoreGroupEdges([edge], groupId)[0];
|
||||||
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<string, any>;
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setNodes(nextNodes as TonoNode[]);
|
setNodes(nextNodes as TonoNode[]);
|
||||||
@@ -352,44 +367,13 @@ function Flow() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextEdges = currentEdges
|
const nextEdges = restoreGroupEdges(currentEdges, groupId)
|
||||||
.map((edge) => {
|
.filter((edge: any) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId));
|
||||||
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<string, any>;
|
|
||||||
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));
|
|
||||||
|
|
||||||
setNodes(nextNodes);
|
setNodes(nextNodes);
|
||||||
setEdges(nextEdges);
|
setEdges(nextEdges);
|
||||||
setTimeout(() => {
|
refreshAllGroups(nextNodes, nextEdges);
|
||||||
(reactFlow.getNodes() as TonoNode[])
|
}, [reactFlow, refreshAllGroups, setEdges, setNodes]);
|
||||||
.filter((node) => node.data?.className === 'Group')
|
|
||||||
.forEach((node) => refreshGroupNode(node.id, nextNodes, nextEdges));
|
|
||||||
}, 0);
|
|
||||||
}, [reactFlow, refreshGroupNode, setEdges, setNodes]);
|
|
||||||
|
|
||||||
const createGroupFromSelection = useCallback(() => {
|
const createGroupFromSelection = useCallback(() => {
|
||||||
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||||
@@ -808,12 +792,8 @@ function Flow() {
|
|||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
refreshAllGroups();
|
||||||
(reactFlow.getNodes() as TonoNode[])
|
}, [onEdgesChange, reactFlow, refreshAllGroups, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]);
|
||||||
.filter((node) => node.data?.className === 'Group')
|
|
||||||
.forEach((node) => refreshGroupNode(node.id));
|
|
||||||
}, 0);
|
|
||||||
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
|
|
||||||
|
|
||||||
const handleNodesChange = useCallback((changes: NodeChange[]) => {
|
const handleNodesChange = useCallback((changes: NodeChange[]) => {
|
||||||
// Stash undo snapshot when a drag begins
|
// Stash undo snapshot when a drag begins
|
||||||
@@ -887,12 +867,8 @@ function Flow() {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
refreshAllGroups();
|
||||||
(reactFlow.getNodes() as TonoNode[])
|
}, [onNodesChange, reactFlow, refreshAllGroups, setEdges, setNodes]);
|
||||||
.filter((node) => node.data?.className === 'Group')
|
|
||||||
.forEach((node) => refreshGroupNode(node.id));
|
|
||||||
}, 0);
|
|
||||||
}, [onNodesChange, reactFlow, refreshGroupNode, setEdges, setNodes]);
|
|
||||||
|
|
||||||
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
||||||
|
|
||||||
@@ -1583,7 +1559,7 @@ function Flow() {
|
|||||||
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getWorkflowBlob = useCallback(async () => {
|
const captureWorkflowImage = useCallback(async () => {
|
||||||
const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null;
|
const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null;
|
||||||
if (!viewportEl) throw new Error('Flow element not found');
|
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');
|
if (allNodes.length === 0) throw new Error('No nodes to capture');
|
||||||
|
|
||||||
const bounds = getRenderedNodeBounds(allNodes);
|
const bounds = getRenderedNodeBounds(allNodes);
|
||||||
if (!bounds) {
|
if (!bounds) throw new Error('Could not determine rendered node bounds');
|
||||||
throw new Error('Could not determine rendered node bounds');
|
const pad = 0.1;
|
||||||
}
|
|
||||||
const pad = 0.1; // 10% margin on each side
|
|
||||||
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
|
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
|
||||||
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
||||||
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
||||||
@@ -1610,176 +1584,97 @@ function Flow() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!blob) throw new Error('Capture returned empty');
|
if (!blob) throw new Error('Capture returned empty');
|
||||||
|
return await stampLogoOnBlob(blob) as Blob;
|
||||||
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);
|
|
||||||
}, [reactFlow]);
|
}, [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<string | null> => {
|
||||||
|
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 () => {
|
const saveWorkflow = useCallback(async () => {
|
||||||
setStatus({ text: 'Saving…', level: 'info' });
|
setStatus({ text: 'Saving…', level: 'info' });
|
||||||
try {
|
try {
|
||||||
const finalBlob = await getWorkflowBlob();
|
const finalBlob = await getWorkflowBlob();
|
||||||
|
const saved = await saveBlobToFile(finalBlob, 'workflow.png');
|
||||||
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
if (!saved) {
|
||||||
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path('workflow.png');
|
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||||
if (!requestedPath) {
|
} else {
|
||||||
setStatus({ text: 'Save cancelled.', level: 'info' });
|
setStatus({ text: `Workflow saved to ${saved}.`, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (err: any) {
|
||||||
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
||||||
}
|
}
|
||||||
}, [getWorkflowBlob]);
|
}, [getWorkflowBlob, saveBlobToFile]);
|
||||||
|
|
||||||
const savePackedWorkflow = useCallback(async () => {
|
const savePackedWorkflow = useCallback(async () => {
|
||||||
setStatus({ text: 'Packing files…', level: 'info' });
|
setStatus({ text: 'Packing files…', level: 'info' });
|
||||||
try {
|
try {
|
||||||
const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null;
|
const imageBlob = await captureWorkflowImage();
|
||||||
if (!viewportEl) throw new Error('Flow element not found');
|
|
||||||
|
|
||||||
const allNodes = (reactFlow.getNodes() as TonoNode[]);
|
const allNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||||
if (allNodes.length === 0) throw new Error('No nodes to capture');
|
const workflow: any = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[]));
|
||||||
|
|
||||||
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[]));
|
|
||||||
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
||||||
|
|
||||||
if (DEBUG) console.log('[pack] packing files…');
|
const packed = await packWorkflow(workflow, nodeDefsRef.current, (done: number, total: number) => {
|
||||||
workflow = await packWorkflow(workflow, nodeDefsRef.current, (packed: number, total: number) => {
|
setStatus({ text: `Packing files… (${done}/${total})`, level: 'info' });
|
||||||
setStatus({ text: `Packing files… (${packed}/${total})`, level: 'info' });
|
|
||||||
});
|
});
|
||||||
if (DEBUG) console.log('[pack] packed, embedding into PNG…', workflow.packed ? 'has packed files' : 'no packed files');
|
const finalBlob = await embedWorkflow(imageBlob, packed as any);
|
||||||
const finalBlob = await embedWorkflow(stampedBlob as Blob, workflow);
|
|
||||||
if (DEBUG) console.log('[pack] embed complete, blob size:', finalBlob.size);
|
|
||||||
const defaultName = 'workflow-packed.png';
|
|
||||||
|
|
||||||
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
const saved = await saveBlobToFile(finalBlob, 'workflow-packed.png');
|
||||||
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path(defaultName);
|
if (!saved) {
|
||||||
if (!requestedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||||
const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, {
|
} else {
|
||||||
method: 'POST', headers: { 'Content-Type': 'image/png' }, body: finalBlob,
|
setStatus({ text: `Packed workflow saved to ${saved}.`, level: 'info' });
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
} catch (err: any) {
|
||||||
setStatus({ text: 'Pack failed: ' + err.message, level: 'error' });
|
setStatus({ text: 'Pack failed: ' + err.message, level: 'error' });
|
||||||
}
|
}
|
||||||
}, [reactFlow]);
|
}, [reactFlow, captureWorkflowImage, saveBlobToFile]);
|
||||||
|
|
||||||
const copySnapshot = useCallback(() => {
|
const copySnapshot = useCallback(() => {
|
||||||
setStatus({ text: 'Copying snapshot…', level: 'info' });
|
setStatus({ text: 'Copying snapshot…', level: 'info' });
|
||||||
@@ -2201,10 +2096,11 @@ function Flow() {
|
|||||||
const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id));
|
const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id));
|
||||||
const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id))
|
const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id))
|
||||||
|| (anchorNode ? getNodeAbsolutePosition(anchorNode, nodeMap) : null);
|
|| (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,
|
x: intendedAnchorAbsolute.x + anchorSize.width / 2,
|
||||||
y: intendedAnchorAbsolute.y + (Number(getNodeDimension(anchorNode, 'height')) || 120) / 2,
|
y: intendedAnchorAbsolute.y + anchorSize.height / 2,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const targetGroup = findExpandedGroupDropTarget(
|
const targetGroup = findExpandedGroupDropTarget(
|
||||||
@@ -2222,8 +2118,7 @@ function Flow() {
|
|||||||
if (!draggedIdSet.has(String(candidate.id))) return candidate;
|
if (!draggedIdSet.has(String(candidate.id))) return candidate;
|
||||||
|
|
||||||
const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id));
|
const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id));
|
||||||
const width = Number(getNodeDimension(candidate, 'width')) || 200;
|
const { width, height } = getNodeSize(candidate);
|
||||||
const height = Number(getNodeDimension(candidate, 'height')) || 120;
|
|
||||||
const center = intendedAbsolute
|
const center = intendedAbsolute
|
||||||
? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 }
|
? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 }
|
||||||
: getNodeCenter(candidate, nodeMap);
|
: getNodeCenter(candidate, nodeMap);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useRef, useState, useCallback } from 'react';
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
import { pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
export const CAPTURE_SELECTOR = '.crop-overlay';
|
export const CAPTURE_SELECTOR = '.crop-overlay';
|
||||||
|
|
||||||
@@ -23,11 +24,7 @@ export default function CropBoxOverlay({
|
|||||||
const [dragging, setDragging] = useState<string | null>(null);
|
const [dragging, setDragging] = useState<string | null>(null);
|
||||||
|
|
||||||
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
const rect = containerRef.current!.getBoundingClientRect();
|
return pointerToFraction(e, containerRef.current!);
|
||||||
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: string) => (e: React.PointerEvent<Element>) => {
|
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useRef, useState, useCallback } from 'react';
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
import { pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
export const CAPTURE_SELECTOR = '.cs-overlay';
|
export const CAPTURE_SELECTOR = '.cs-overlay';
|
||||||
|
|
||||||
@@ -34,11 +35,7 @@ export default function CrossSectionOverlay({
|
|||||||
const [dragging, setDragging] = useState<string | null>(null); // 'p1' or 'p2'
|
const [dragging, setDragging] = useState<string | null>(null); // 'p1' or 'p2'
|
||||||
|
|
||||||
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
const rect = containerRef.current!.getBoundingClientRect();
|
return pointerToFraction(e, containerRef.current!);
|
||||||
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: string) => (e: React.PointerEvent<Element>) => {
|
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from './constants';
|
} from './constants';
|
||||||
import { getGroupMinimumSize } from './groupSizing';
|
import { getGroupMinimumSize } from './groupSizing';
|
||||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
|
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';
|
import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types';
|
||||||
|
|
||||||
@@ -302,51 +302,6 @@ class PreviewBoundary extends React.Component<PreviewBoundaryProps, PreviewBound
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SI prefix helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const _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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map of suffix characters → multiplier (accept both 'u' and 'μ' for micro)
|
|
||||||
const _SI_PARSE_MAP = { T:1e12, G:1e9, M:1e6, k:1e3, m:1e-3, u:1e-6, μ:1e-6, n:1e-9, p:1e-12, f:1e-15 };
|
|
||||||
|
|
||||||
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);
|
|
||||||
// Pick the largest SI prefix whose factor is ≤ |v| (gives value in [1, 1000))
|
|
||||||
let chosen = _SI_PREFIXES[_SI_PREFIXES.length - 1];
|
|
||||||
for (const p of _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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 ────────────────────────────────────────────
|
// ── Draggable number input ────────────────────────────────────────────
|
||||||
|
|
||||||
function DraggableNumber({ value, step, min, max, precision, onChange }: {
|
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
|
// Parse inputs into data handles and widgets
|
||||||
const required = def?.input?.required || {};
|
const required = def?.input?.required || {};
|
||||||
const optional = def?.input?.optional || {};
|
const optional = def?.input?.optional || {};
|
||||||
@@ -1495,8 +1458,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
{data.overlay!.kind === 'line_plot' ? (
|
{data.overlay!.kind === 'line_plot' ? (
|
||||||
<LinePlotOverlay
|
<LinePlotOverlay
|
||||||
overlay={data.overlay!}
|
overlay={data.overlay!}
|
||||||
x1={(data.overlay!.a_locked ? (liveCoordPair?.[0] ?? data.overlay!.x1) : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||||
x2={(data.overlay!.b_locked ? (liveCoordPair?.[2] ?? data.overlay!.x2) : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||||
aLocked={!!data.overlay!.a_locked}
|
aLocked={!!data.overlay!.a_locked}
|
||||||
bLocked={!!data.overlay!.b_locked}
|
bLocked={!!data.overlay!.b_locked}
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
@@ -1505,10 +1468,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
) : data.overlay!.kind === 'crop_box' ? (
|
) : data.overlay!.kind === 'crop_box' ? (
|
||||||
<CropBoxOverlay
|
<CropBoxOverlay
|
||||||
image={data.overlay!.image ?? ''}
|
image={data.overlay!.image ?? ''}
|
||||||
x1={(data.overlay!.a_locked ? (liveCoordPair?.[0] ?? data.overlay!.x1) : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||||
y1={(data.overlay!.a_locked ? (liveCoordPair?.[1] ?? data.overlay!.y1) : (data.widgetValues.y1 ?? data.overlay!.y1)) as number}
|
y1={overlayCoord('y1', 1, !!data.overlay!.a_locked)}
|
||||||
x2={(data.overlay!.b_locked ? (liveCoordPair?.[2] ?? data.overlay!.x2) : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||||
y2={(data.overlay!.b_locked ? (liveCoordPair?.[3] ?? data.overlay!.y2) : (data.widgetValues.y2 ?? data.overlay!.y2)) as number}
|
y2={overlayCoord('y2', 3, !!data.overlay!.b_locked)}
|
||||||
aLocked={!!data.overlay!.a_locked}
|
aLocked={!!data.overlay!.a_locked}
|
||||||
bLocked={!!data.overlay!.b_locked}
|
bLocked={!!data.overlay!.b_locked}
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
@@ -1517,10 +1480,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
) : data.overlay!.kind === 'cursor_points' ? (
|
) : data.overlay!.kind === 'cursor_points' ? (
|
||||||
<CrossSectionOverlay
|
<CrossSectionOverlay
|
||||||
image={data.overlay!.image ?? ''}
|
image={data.overlay!.image ?? ''}
|
||||||
x1={(data.overlay!.a_locked ? (liveCoordPair?.[0] ?? data.overlay!.x1) : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||||
y1={(data.overlay!.a_locked ? (liveCoordPair?.[1] ?? data.overlay!.y1) : (data.widgetValues.y1 ?? data.overlay!.y1)) as number}
|
y1={overlayCoord('y1', 1, !!data.overlay!.a_locked)}
|
||||||
x2={(data.overlay!.b_locked ? (liveCoordPair?.[2] ?? data.overlay!.x2) : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||||
y2={(data.overlay!.b_locked ? (liveCoordPair?.[3] ?? data.overlay!.y2) : (data.widgetValues.y2 ?? data.overlay!.y2)) as number}
|
y2={overlayCoord('y2', 3, !!data.overlay!.b_locked)}
|
||||||
aLocked={!!data.overlay!.a_locked}
|
aLocked={!!data.overlay!.a_locked}
|
||||||
bLocked={!!data.overlay!.b_locked}
|
bLocked={!!data.overlay!.b_locked}
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
@@ -1577,10 +1540,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
) : (
|
) : (
|
||||||
<CrossSectionOverlay
|
<CrossSectionOverlay
|
||||||
image={data.overlay!.image ?? ''}
|
image={data.overlay!.image ?? ''}
|
||||||
x1={(data.overlay!.a_locked ? data.overlay!.x1 : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||||
y1={(data.overlay!.a_locked ? data.overlay!.y1 : (data.widgetValues.y1 ?? data.overlay!.y1)) as number}
|
y1={overlayCoord('y1', 1, !!data.overlay!.a_locked)}
|
||||||
x2={(data.overlay!.b_locked ? data.overlay!.x2 : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||||
y2={(data.overlay!.b_locked ? data.overlay!.y2 : (data.widgetValues.y2 ?? data.overlay!.y2)) as number}
|
y2={overlayCoord('y2', 3, !!data.overlay!.b_locked)}
|
||||||
aLocked={!!data.overlay!.a_locked}
|
aLocked={!!data.overlay!.a_locked}
|
||||||
bLocked={!!data.overlay!.b_locked}
|
bLocked={!!data.overlay!.b_locked}
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
@@ -1767,76 +1730,37 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Select dropdown helper ──────────────────────────────────────────
|
||||||
|
const renderSelect = (options: string[], selected: string) => (
|
||||||
|
<>
|
||||||
|
{!hideLabel && <label>{label}</label>}
|
||||||
|
<select
|
||||||
|
className="nodrag"
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
// Combo / enum — type itself is the array of options
|
// Combo / enum — type itself is the array of options
|
||||||
if (Array.isArray(type)) {
|
if (Array.isArray(type)) {
|
||||||
return (
|
return renderSelect(type, (val || type[0]) as string);
|
||||||
<>
|
|
||||||
{!hideLabel && <label>{label}</label>}
|
|
||||||
<select
|
|
||||||
className="nodrag"
|
|
||||||
value={(val || type[0]) as string}
|
|
||||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
||||||
>
|
|
||||||
{type.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'STRING' && dynamicTypeChoices.length > 0) {
|
if (type === 'STRING' && dynamicTypeChoices.length > 0) {
|
||||||
const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0];
|
return renderSelect(dynamicTypeChoices, dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0]);
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!hideLabel && <label>{label}</label>}
|
|
||||||
<select
|
|
||||||
className="nodrag"
|
|
||||||
value={selected}
|
|
||||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
||||||
>
|
|
||||||
{dynamicTypeChoices.map((choice) => (
|
|
||||||
<option key={choice} value={choice}>{choice}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) {
|
if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) {
|
||||||
const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0];
|
return renderSelect(dynamicTableColumns, dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]);
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!hideLabel && <label>{label}</label>}
|
|
||||||
<select
|
|
||||||
className="nodrag"
|
|
||||||
value={selected}
|
|
||||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
||||||
>
|
|
||||||
{dynamicTableColumns.map((column) => (
|
|
||||||
<option key={column} value={column}>{column}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'STRING' && opts?.choices_from_measure_input && dynamicMeasurementChoices.length > 0) {
|
if (type === 'STRING' && opts?.choices_from_measure_input && dynamicMeasurementChoices.length > 0) {
|
||||||
const selected = dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0];
|
return renderSelect(dynamicMeasurementChoices, dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0]);
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!hideLabel && <label>{label}</label>}
|
|
||||||
<select
|
|
||||||
className="nodrag"
|
|
||||||
value={selected}
|
|
||||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
|
||||||
>
|
|
||||||
{dynamicMeasurementChoices.map((choice) => (
|
|
||||||
<option key={choice} value={choice}>{choice}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { getAxisScale } from './valueFormatting';
|
import { getAxisScale } from './valueFormatting';
|
||||||
|
import { clamp, formatTick, makeTicks, getExtent } from './overlayUtils';
|
||||||
|
|
||||||
export const CAPTURE_SELECTOR = '.lineplot-overlay';
|
export const CAPTURE_SELECTOR = '.lineplot-overlay';
|
||||||
|
|
||||||
@@ -13,59 +14,10 @@ const MARKER_STROKE = '#ffffff';
|
|||||||
const MARKER_LOCKED_COLOR = '#e91e63';
|
const MARKER_LOCKED_COLOR = '#e91e63';
|
||||||
const MARKER_LABEL_FILL = '#0f172a';
|
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) {
|
function round3(v: number) {
|
||||||
return parseFloat(v.toFixed(3));
|
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 {
|
interface LinePlotOverlayProps {
|
||||||
overlay: any;
|
overlay: any;
|
||||||
x1: number;
|
x1: number;
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import {
|
|||||||
sanitizeMarkupColor,
|
sanitizeMarkupColor,
|
||||||
sanitizeMarkupShape,
|
sanitizeMarkupShape,
|
||||||
} from './markupShapeGeometry';
|
} from './markupShapeGeometry';
|
||||||
|
import { clampFraction, pointerToFraction } from './overlayUtils';
|
||||||
function clampFraction(value: number) {
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return 0;
|
|
||||||
return Math.max(0, Math.min(1, numeric));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkupShape {
|
interface MarkupShape {
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -150,11 +145,11 @@ export default function MarkupOverlay({
|
|||||||
}, [image]);
|
}, [image]);
|
||||||
|
|
||||||
const getPoint = useCallback((event: React.PointerEvent<Element>) => {
|
const getPoint = useCallback((event: React.PointerEvent<Element>) => {
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
if (!containerRef.current) return null;
|
||||||
if (!rect) return null;
|
const { fx, fy } = pointerToFraction(event, containerRef.current);
|
||||||
return {
|
return {
|
||||||
x: Number(clampFraction((event.clientX - rect.left) / rect.width).toFixed(4)),
|
x: Number(fx.toFixed(4)),
|
||||||
y: Number(clampFraction((event.clientY - rect.top) / rect.height).toFixed(4)),
|
y: Number(fy.toFixed(4)),
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { CANVAS_COLORS } from './constants';
|
import { CANVAS_COLORS } from './constants';
|
||||||
|
import { clampFraction, pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
interface StrokePoint {
|
interface StrokePoint {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -16,12 +17,6 @@ interface DrawStrokeStyles {
|
|||||||
fillStyle?: string;
|
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 {
|
function sanitizeStroke(stroke: any, fallbackPenSize: number): Stroke | null {
|
||||||
if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points) || stroke.points.length === 0) {
|
if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points) || stroke.points.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -219,12 +214,9 @@ export default function MaskPaintOverlay({
|
|||||||
}, [draftStroke, redrawCanvas]);
|
}, [draftStroke, redrawCanvas]);
|
||||||
|
|
||||||
const getPoint = useCallback((event: React.PointerEvent<Element>): StrokePoint | null => {
|
const getPoint = useCallback((event: React.PointerEvent<Element>): StrokePoint | null => {
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
if (!containerRef.current) return null;
|
||||||
if (!rect) return null;
|
const { fx, fy } = pointerToFraction(event, containerRef.current);
|
||||||
return {
|
return { x: fx, y: fy };
|
||||||
x: clampFraction((event.clientX - rect.left) / rect.width),
|
|
||||||
y: clampFraction((event.clientY - rect.top) / rect.height),
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getBrushDisplaySize = useCallback(() => {
|
const getBrushDisplaySize = useCallback(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { getAxisScale } from './valueFormatting';
|
import { getAxisScale } from './valueFormatting';
|
||||||
|
import { clamp, formatTick, makeTicks, getExtent } from './overlayUtils';
|
||||||
|
|
||||||
export const CAPTURE_SELECTOR = '.lineplot-overlay';
|
export const CAPTURE_SELECTOR = '.lineplot-overlay';
|
||||||
|
|
||||||
@@ -13,31 +14,7 @@ const MARKER_STROKE = '#ffffff';
|
|||||||
const MARKER_LOCKED_COLOR = '#e91e63';
|
const MARKER_LOCKED_COLOR = '#e91e63';
|
||||||
const MARKER_LABEL_FILL = '#0f172a';
|
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 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 {
|
interface ThresholdHistogramProps {
|
||||||
overlay: any;
|
overlay: any;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { clampFraction as clamp01 } from './overlayUtils.ts';
|
||||||
|
|
||||||
interface AnglePoints {
|
interface AnglePoints {
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
@@ -7,10 +9,6 @@ interface AnglePoints {
|
|||||||
y2: number;
|
y2: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp01(value: number): number {
|
|
||||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function round3(value: number): number {
|
export function round3(value: number): number {
|
||||||
return Number.parseFloat(Number(value).toFixed(3));
|
return Number.parseFloat(Number(value).toFixed(3));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getNodeCenter, getGroupWorkspaceBounds, rectContainsPoint } from './nodeGeometry';
|
import { getNodeCenter, getGroupWorkspaceBounds, rectContainsPoint } from './nodeGeometry';
|
||||||
|
import { clamp } from './overlayUtils.ts';
|
||||||
|
|
||||||
export function getEventClientPosition(event: any) {
|
export function getEventClientPosition(event: any) {
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
@@ -52,7 +53,7 @@ export function isEditableTarget(target: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clampNumber(value: number, min: number, max: number) {
|
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) {
|
export function canStartCanvasRightDragZoom(target: any) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { clampFraction, sanitizeHexColor } from './overlayUtils.ts';
|
||||||
|
|
||||||
export const MARKUP_DEFAULT_SHAPE = 'arrow';
|
export const MARKUP_DEFAULT_SHAPE = 'arrow';
|
||||||
export const MARKUP_DEFAULT_COLOR = '#ff0000';
|
export const MARKUP_DEFAULT_COLOR = '#ff0000';
|
||||||
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
|
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
|
||||||
@@ -12,16 +14,8 @@ export interface MarkupShape {
|
|||||||
color: string;
|
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 {
|
export function sanitizeMarkupColor(color: unknown, fallback: string = MARKUP_DEFAULT_COLOR): string {
|
||||||
if (typeof color !== 'string') return fallback;
|
return sanitizeHexColor(color, fallback);
|
||||||
const value = color.trim();
|
|
||||||
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeMarkupShape(
|
export function sanitizeMarkupShape(
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export function getNodeDimension(node: any, axis: string): number {
|
|||||||
return node.measured?.height || node.style?.height || node.height || 120;
|
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) {
|
export function applyNodeSize(node: any, width: any, height: any) {
|
||||||
const nextWidth = Math.round(Number(width) || 0);
|
const nextWidth = Math.round(Number(width) || 0);
|
||||||
const nextHeight = Math.round(Number(height) || 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));
|
const node = nodeMap.get(String(id));
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
const pos = getNodeAbsolutePosition(node, nodeMap);
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
const { width, height } = getNodeSize(node);
|
||||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
|
||||||
minX = Math.min(minX, pos.x);
|
minX = Math.min(minX, pos.x);
|
||||||
minY = Math.min(minY, pos.y);
|
minY = Math.min(minY, pos.y);
|
||||||
maxX = Math.max(maxX, pos.x + width);
|
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<string, any>) {
|
export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map<string, any>) {
|
||||||
const pos = getNodeAbsolutePosition(groupNode, nodeMap);
|
const pos = getNodeAbsolutePosition(groupNode, nodeMap);
|
||||||
const width = Number(getNodeDimension(groupNode, 'width')) || 200;
|
const { width, height } = getNodeSize(groupNode);
|
||||||
const height = Number(getNodeDimension(groupNode, 'height')) || 120;
|
|
||||||
return {
|
return {
|
||||||
left: pos.x + GROUP_WORKSPACE_INSET,
|
left: pos.x + GROUP_WORKSPACE_INSET,
|
||||||
top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET,
|
top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET,
|
||||||
@@ -111,8 +116,7 @@ export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map<string, any
|
|||||||
|
|
||||||
export function getNodeCenter(node: any, nodeMap: Map<string, any>) {
|
export function getNodeCenter(node: any, nodeMap: Map<string, any>) {
|
||||||
const pos = getNodeAbsolutePosition(node, nodeMap);
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
const { width, height } = getNodeSize(node);
|
||||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
|
||||||
return {
|
return {
|
||||||
x: pos.x + width / 2,
|
x: pos.x + width / 2,
|
||||||
y: pos.y + height / 2,
|
y: pos.y + height / 2,
|
||||||
@@ -121,8 +125,7 @@ export function getNodeCenter(node: any, nodeMap: Map<string, any>) {
|
|||||||
|
|
||||||
export function getNodeRect(node: any, nodeMap: Map<string, any>) {
|
export function getNodeRect(node: any, nodeMap: Map<string, any>) {
|
||||||
const pos = getNodeAbsolutePosition(node, nodeMap);
|
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
const { width, height } = getNodeSize(node);
|
||||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
|
||||||
return {
|
return {
|
||||||
left: pos.x,
|
left: pos.x,
|
||||||
top: pos.y,
|
top: pos.y,
|
||||||
@@ -132,8 +135,7 @@ export function getNodeRect(node: any, nodeMap: Map<string, any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAbsoluteRectForNodePosition(node: any, absolutePosition: { x: number; y: number }) {
|
export function getAbsoluteRectForNodePosition(node: any, absolutePosition: { x: number; y: number }) {
|
||||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
const { width, height } = getNodeSize(node);
|
||||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
|
||||||
return {
|
return {
|
||||||
left: absolutePosition.x,
|
left: absolutePosition.x,
|
||||||
top: absolutePosition.y,
|
top: absolutePosition.y,
|
||||||
|
|||||||
69
frontend/src/overlayUtils.ts
Normal file
69
frontend/src/overlayUtils.ts
Normal file
@@ -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<Element>,
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -222,3 +222,42 @@ export function formatTableRowCell(row: Record<string, unknown>, column: string)
|
|||||||
}
|
}
|
||||||
return formatNumericCell(row?.[column]);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user