fix value display node
This commit is contained in:
@@ -17,7 +17,6 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"Note",
|
||||
"ImageDemo",
|
||||
"Folder",
|
||||
"Number",
|
||||
"RangeSlider",
|
||||
"Coordinate",
|
||||
"CoordinatePair",
|
||||
@@ -27,7 +26,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"Font",
|
||||
"ColormapAdjust",
|
||||
"PreviewImage",
|
||||
"ValueDisplay",
|
||||
"ValueIO",
|
||||
"View3D",
|
||||
"Save",
|
||||
"SaveImage",
|
||||
|
||||
@@ -43,10 +43,9 @@ from backend.nodes import (
|
||||
markup,
|
||||
preview_image,
|
||||
statistics,
|
||||
value_io,
|
||||
view_3d,
|
||||
print_table,
|
||||
value_display,
|
||||
# Analysis
|
||||
curvature,
|
||||
fractal_dimension,
|
||||
histogram,
|
||||
|
||||
@@ -154,13 +154,12 @@ def _compute_curvature_results(
|
||||
+ coeffs[5] * y_norm * y_norm
|
||||
)
|
||||
|
||||
r1 = float("inf") if abs(kappa1) <= 1e-14 else float(1.0 / (q * q * kappa1))
|
||||
r2 = float("inf") if abs(kappa2) <= 1e-14 else float(1.0 / (q * q * kappa2))
|
||||
#todo: fix inf case
|
||||
r1 = float(np.inf) if abs(kappa1) <= 1e-14 else float(1.0 / (q * q * kappa1))
|
||||
r2 = float(np.inf) if abs(kappa2) <= 1e-14 else float(1.0 / (q * q * kappa2))
|
||||
x0 = float(xc / q + 0.5 * xreal + field.xoff)
|
||||
y0 = float(yc / q + 0.5 * yreal + field.yoff)
|
||||
|
||||
print(f"debug: {x0}, {y0}, {r1}, {r2}")
|
||||
|
||||
return {
|
||||
"degree": float(degree),
|
||||
"x0": x0,
|
||||
@@ -292,8 +291,8 @@ class Curvature:
|
||||
OUTPUTS = (
|
||||
('ANNOTATION_SOURCE', 'output'),
|
||||
('RECORD_TABLE', 'measurements'),
|
||||
('LINE', 'profile_x'),
|
||||
('LINE', 'profile_y'),
|
||||
('LINE', 'profile_a'),
|
||||
('LINE', 'profile_b'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
@@ -340,7 +339,7 @@ class Curvature:
|
||||
|
||||
profiles = []
|
||||
for pair in intersections[:2]:
|
||||
profiles.append(_profile_from_intersections(field, pair[1], pair[0]))
|
||||
profiles.append(_profile_from_intersections(field, pair[0], pair[1]))
|
||||
while len(profiles) < 2:
|
||||
profiles.append(_empty_profile(field.si_unit_xy, field.si_unit_z))
|
||||
|
||||
@@ -360,7 +359,7 @@ class Curvature:
|
||||
preview_base = render_datafield_preview(field, field.colormap)
|
||||
panels = []
|
||||
|
||||
for p, title in zip(profiles, ["X Principal Axis", "Y Principal Axis"]):
|
||||
for p, title in zip(profiles, ["Principal Axis A", "Principal Axis B"]):
|
||||
if len(p.data) > 0:
|
||||
panels.append({
|
||||
"title": title,
|
||||
@@ -376,7 +375,7 @@ class Curvature:
|
||||
})
|
||||
|
||||
emit_preview({"kind": "panels", "panels": panels})
|
||||
# emit_table(table)
|
||||
emit_table(table)
|
||||
|
||||
if warnings:
|
||||
emit_warning(warnings[0])
|
||||
|
||||
@@ -4,6 +4,7 @@ Shared helper functions for argonode nodes.
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
@@ -16,6 +17,62 @@ from backend.runtime_paths import demo_dir, input_dir, output_dir
|
||||
# Scalar payload helpers (from display.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SI_PREFIXES: dict[str, float] = {
|
||||
'Y': 1e24, 'Z': 1e21, 'E': 1e18, 'P': 1e15, '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, 'a': 1e-18, 'z': 1e-21, 'y': 1e-24,
|
||||
}
|
||||
|
||||
_PREFIXABLE_UNITS: frozenset[str] = frozenset({
|
||||
'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa',
|
||||
'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω',
|
||||
})
|
||||
|
||||
_NUMBER_RE = re.compile(
|
||||
r'^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)?$'
|
||||
)
|
||||
|
||||
|
||||
def parse_number_with_unit(text: str) -> tuple[float, str]:
|
||||
"""Parse a string like '1.5 nm' into (1.5e-9, 'm').
|
||||
|
||||
The numeric part may use scientific notation. The unit is stripped of any
|
||||
recognised SI prefix and the raw value is scaled accordingly, so the
|
||||
returned float is always in the base SI unit. Units that are not
|
||||
prefixable are returned unchanged alongside the unscaled value.
|
||||
|
||||
Examples::
|
||||
|
||||
parse_number_with_unit("1 um") → (1e-6, "m")
|
||||
parse_number_with_unit("500 nm") → (5e-7, "m")
|
||||
parse_number_with_unit("3.14") → (3.14, "")
|
||||
parse_number_with_unit("2 kHz") → (2000.0, "Hz")
|
||||
"""
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return 0.0, ""
|
||||
|
||||
m = _NUMBER_RE.match(text)
|
||||
if not m:
|
||||
raise ValueError(f"Cannot parse number: {text!r}")
|
||||
|
||||
numeric = float(m.group(1))
|
||||
unit_str = (m.group(2) or "").strip()
|
||||
|
||||
if not unit_str:
|
||||
return numeric, ""
|
||||
|
||||
# Try prefix + base-unit split (handle multi-byte µ as a prefix)
|
||||
if len(unit_str) >= 2:
|
||||
prefix_char = unit_str[0]
|
||||
rest = unit_str[1:]
|
||||
if prefix_char in _SI_PREFIXES and rest in _PREFIXABLE_UNITS:
|
||||
return numeric * _SI_PREFIXES[prefix_char], rest
|
||||
|
||||
return numeric, unit_str
|
||||
|
||||
|
||||
def _scalar_payload(value: float, unit: str = "") -> dict:
|
||||
payload = {"value": float(value)}
|
||||
if isinstance(unit, str) and unit.strip():
|
||||
@@ -24,7 +81,7 @@ def _scalar_payload(value: float, unit: str = "") -> dict:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Measurement helpers (from display.py — used by ValueDisplay)
|
||||
# Measurement helpers (from display.py — used by ValueIO)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _measurement_names(table: list) -> list[str]:
|
||||
|
||||
@@ -2,18 +2,21 @@ from __future__ import annotations
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_table, emit_value
|
||||
from backend.data_types import RecordTable
|
||||
from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload
|
||||
from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload, parse_number_with_unit
|
||||
|
||||
|
||||
@register_node(display_name="Value Display")
|
||||
class ValueDisplay:
|
||||
class ValueIO:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"value": ("FLOAT", {
|
||||
"accepted_types": ["RECORD_TABLE"],
|
||||
"socket_only": True,
|
||||
"number_input": ("STRING", {
|
||||
"text_input": True,
|
||||
"default": "0",
|
||||
"placeholder": "e.g. 1.5 nm",
|
||||
"hide_when_input_connected": "value",
|
||||
"hide_label": True,
|
||||
}),
|
||||
"measurement": ("STRING", {
|
||||
"default": "",
|
||||
@@ -22,7 +25,13 @@ class ValueDisplay:
|
||||
"value": ["RECORD_TABLE"],
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"value": ("FLOAT", {
|
||||
"accepted_types": ["RECORD_TABLE"],
|
||||
"socket_only": True,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
@@ -35,14 +44,16 @@ class ValueDisplay:
|
||||
_broadcast_value_fn = None
|
||||
_current_node_id: str = ""
|
||||
|
||||
def display_value(self, value, measurement: str = "") -> tuple:
|
||||
def display_value(self, number_input: str = "0", value=None, measurement: str = "") -> tuple:
|
||||
unit = ""
|
||||
if isinstance(value, RecordTable):
|
||||
emit_table(value)
|
||||
row = _measurement_entry(value, measurement)
|
||||
numeric = _measurement_value(value, measurement)
|
||||
unit = row.get("unit", "") if isinstance(row.get("unit"), str) else ""
|
||||
else:
|
||||
elif value is not None:
|
||||
numeric = float(value)
|
||||
else:
|
||||
numeric, unit = parse_number_with_unit(str(number_input))
|
||||
emit_value(_scalar_payload(numeric, unit))
|
||||
return (numeric,)
|
||||
@@ -31,6 +31,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
@@ -205,6 +206,10 @@ def create_app(
|
||||
value = payload
|
||||
unit = ""
|
||||
|
||||
# JSON cannot encode non-finite floats; convert to string representations.
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
value = "∞" if value > 0 else ("-∞" if math.isinf(value) else "NaN")
|
||||
|
||||
data = {"node_id": node_id, "value": value}
|
||||
if isinstance(unit, str) and unit.strip():
|
||||
data["unit"] = unit.strip()
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from './constants';
|
||||
import { getGroupMinimumSize } from './groupSizing.js';
|
||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js';
|
||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns } from './valueFormatting.js';
|
||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit } from './valueFormatting.js';
|
||||
|
||||
// ── Context (provided by App) ─────────────────────────────────────────
|
||||
|
||||
@@ -443,7 +443,11 @@ function getScalarPayload(scalarValue) {
|
||||
return Number.isFinite(scalarValue) ? { value: scalarValue, unit: '' } : null;
|
||||
}
|
||||
if (!scalarValue || typeof scalarValue !== 'object') return null;
|
||||
const numeric = Number(scalarValue.value);
|
||||
const raw = scalarValue.value;
|
||||
if (typeof raw === 'string') {
|
||||
return { valueText: raw, unitText: typeof scalarValue.unit === 'string' ? scalarValue.unit : '' };
|
||||
}
|
||||
const numeric = Number(raw);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return {
|
||||
value: numeric,
|
||||
@@ -454,6 +458,7 @@ function getScalarPayload(scalarValue) {
|
||||
function formatScalarDisplay(scalarValue) {
|
||||
const payload = getScalarPayload(scalarValue);
|
||||
if (!payload) return null;
|
||||
if ('valueText' in payload) return payload;
|
||||
|
||||
if (payload.unit) {
|
||||
const prefixed = applySIPrefix(payload.value, payload.unit);
|
||||
@@ -1471,6 +1476,55 @@ function CustomNode({ id, data }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editable value-box for text_input FLOAT widgets ──────────────────
|
||||
|
||||
function TextInputValueBox({ val, placeholder, nodeId, name, label, hideLabel, onChange }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const parsed = parseNumberWithUnit(val);
|
||||
const display = parsed ? formatScalarDisplay({ value: parsed.numeric, unit: parsed.unit }) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <label>{label}</label>}
|
||||
<div
|
||||
className="node-value-box nodrag"
|
||||
style={{ cursor: editing ? 'text' : 'pointer' }}
|
||||
onClick={() => !editing && setEditing(true)}
|
||||
>
|
||||
{editing ? (
|
||||
<input
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
className="nodrag"
|
||||
type="text"
|
||||
value={val}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(nodeId, name, e.target.value)}
|
||||
onBlur={() => setEditing(false)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
) : display ? (
|
||||
<>
|
||||
<span className="node-value-box-number">{display.valueText}</span>
|
||||
{display.unitText && <span className="node-value-box-unit">{display.unitText}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="node-value-box-number" style={{ opacity: 0.4 }}>{placeholder || '0'}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Widget renderer ───────────────────────────────────────────────────
|
||||
|
||||
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser, hideLabel = false, measurementChoices }) {
|
||||
@@ -1701,6 +1755,20 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
||||
);
|
||||
}
|
||||
|
||||
if (opts?.text_input) {
|
||||
return (
|
||||
<TextInputValueBox
|
||||
val={val}
|
||||
placeholder={placeholder || opts?.placeholder || ''}
|
||||
nodeId={nodeId}
|
||||
name={name}
|
||||
label={label}
|
||||
hideLabel={hideLabel || !!opts.hide_label}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'FLOAT') {
|
||||
if (opts?.slider) {
|
||||
const rawMin = opts?.min_widget ? widgetValues?.[opts.min_widget] : opts?.min;
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
const SI_PREFIX_MULTIPLIERS = {
|
||||
Y: 1e24, Z: 1e21, E: 1e18, P: 1e15, 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, a: 1e-18, z: 1e-21, y: 1e-24,
|
||||
};
|
||||
|
||||
const NUMBER_WITH_UNIT_RE = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(.*)?$/;
|
||||
|
||||
/**
|
||||
* Parse a string like "1.5 nm" into { numeric: 1.5e-9, unit: "m" }.
|
||||
* Returns null if the string does not start with a valid number.
|
||||
* The numeric value is scaled to the base SI unit via the prefix.
|
||||
*/
|
||||
export function parseNumberWithUnit(text) {
|
||||
const s = String(text ?? '').trim();
|
||||
if (!s) return { numeric: 0, unit: '' };
|
||||
|
||||
const m = s.match(NUMBER_WITH_UNIT_RE);
|
||||
if (!m) return null;
|
||||
|
||||
const numeric = parseFloat(m[1]);
|
||||
const unitStr = (m[2] ?? '').trim();
|
||||
|
||||
if (!unitStr) return { numeric, unit: '' };
|
||||
|
||||
if (unitStr.length >= 2) {
|
||||
const prefix = unitStr[0];
|
||||
const rest = unitStr.slice(1);
|
||||
const multiplier = SI_PREFIX_MULTIPLIERS[prefix];
|
||||
if (multiplier !== undefined && PREFIXABLE_UNITS.has(rest)) {
|
||||
return { numeric: numeric * multiplier, unit: rest };
|
||||
}
|
||||
}
|
||||
|
||||
return { numeric, unit: unitStr };
|
||||
}
|
||||
|
||||
const SI_PREFIXES = [
|
||||
{ exp: -24, prefix: 'y' },
|
||||
{ exp: -21, prefix: 'z' },
|
||||
|
||||
@@ -2017,17 +2017,17 @@ def test_print_table():
|
||||
|
||||
|
||||
def test_value_display():
|
||||
print("=== Test: ValueDisplay ===")
|
||||
from backend.nodes.value_display import ValueDisplay
|
||||
print("=== Test: ValueIO ===")
|
||||
from backend.nodes.value_io import ValueIO
|
||||
|
||||
node = ValueDisplay()
|
||||
value_spec = ValueDisplay.INPUT_TYPES()["required"]["value"]
|
||||
node = ValueIO()
|
||||
value_spec = ValueIO.INPUT_TYPES()["required"]["value"]
|
||||
assert value_spec[0] == "FLOAT"
|
||||
assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"]
|
||||
|
||||
captured = []
|
||||
ValueDisplay._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload))
|
||||
ValueDisplay._current_node_id = "test"
|
||||
ValueIO._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload))
|
||||
ValueIO._current_node_id = "test"
|
||||
|
||||
result = node.display_value(3.25)
|
||||
assert result == (3.25,)
|
||||
@@ -2041,7 +2041,7 @@ def test_value_display():
|
||||
assert result == (1.7e-7,)
|
||||
assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"})
|
||||
|
||||
ValueDisplay._broadcast_value_fn = None
|
||||
ValueIO._broadcast_value_fn = None
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user