fix particle analysis table units and load image missing channels
This commit is contained in:
@@ -30,6 +30,11 @@ import {
|
||||
getAutoRunnableNodes,
|
||||
hasBlockingAutoRunInput,
|
||||
} from './executionGraph';
|
||||
import {
|
||||
beginTrackedNodeRequest,
|
||||
isTrackedNodeRequestCurrent,
|
||||
resolveLoadNodeChannelPath,
|
||||
} from './loadNodeOutputs.js';
|
||||
|
||||
import {
|
||||
DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS,
|
||||
@@ -823,6 +828,7 @@ function Flow() {
|
||||
const activeDragNodeIdRef = useRef(null);
|
||||
const canvasRightZoomRef = useRef(null);
|
||||
const suppressPaneContextMenuUntilRef = useRef(0);
|
||||
const loadNodeOutputRequestVersionsRef = useRef(new Map());
|
||||
const reactFlow = useReactFlow();
|
||||
|
||||
// ── WebSocket ───────────────────────────────────────────────────────
|
||||
@@ -1163,26 +1169,26 @@ function Flow() {
|
||||
|
||||
const refreshLoadNodeOutputs = useCallback(async (nodeId, explicitPath = null) => {
|
||||
const node = reactFlow.getNode(nodeId);
|
||||
if (!node) return;
|
||||
const resolvedPath = resolveLoadNodeChannelPath({
|
||||
explicitPath,
|
||||
resolvedPathInput: getResolvedPathInput(nodeId),
|
||||
className: node?.data?.className || '',
|
||||
widgetValues: node?.data?.widgetValues || {},
|
||||
});
|
||||
const requestVersion = beginTrackedNodeRequest(loadNodeOutputRequestVersionsRef.current, nodeId);
|
||||
|
||||
let resolvedPath = typeof explicitPath === 'string' && explicitPath ? explicitPath : null;
|
||||
if (!resolvedPath) {
|
||||
resolvedPath = getResolvedPathInput(nodeId);
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
if (node.data.className === 'Image') {
|
||||
resolvedPath = node.data.widgetValues?.filename || '';
|
||||
} else if (node.data.className === 'ImageDemo') {
|
||||
resolvedPath = node.data.widgetValues?.name || '';
|
||||
if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const channels = await api.getChannels(resolvedPath);
|
||||
if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) {
|
||||
return;
|
||||
}
|
||||
setNodeOutputs(
|
||||
nodeId,
|
||||
channels.map((channel) => channel.type),
|
||||
@@ -1625,17 +1631,21 @@ function Flow() {
|
||||
setNodes((ns) => [...ns, newNode]);
|
||||
|
||||
// Initialize dynamic outputs for nodes that depend on the selected path/folder.
|
||||
if (className === 'Folder' && widgetValues.folder) {
|
||||
refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (className === 'Folder' && widgetValues.folder) {
|
||||
refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
|
||||
}
|
||||
|
||||
// For Image/ImageDemo, auto-fetch channels for the default value
|
||||
if (className === 'ImageDemo' && widgetValues.name) {
|
||||
refreshLoadNodeOutputs(newNodeId, widgetValues.name);
|
||||
}
|
||||
if (className === 'Image' && widgetValues.filename) {
|
||||
refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
|
||||
}
|
||||
// For Image/ImageDemo, auto-fetch channels for the default value.
|
||||
// Delay this until after the node exists in React Flow so the async
|
||||
// response cannot be dropped on creation.
|
||||
if (className === 'ImageDemo' && widgetValues.name) {
|
||||
refreshLoadNodeOutputs(newNodeId, widgetValues.name);
|
||||
}
|
||||
if (className === 'Image' && widgetValues.filename) {
|
||||
refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Auto-connect if this was triggered by dropping a connection on blank space
|
||||
if (contextMenu.pendingHandleId) {
|
||||
|
||||
@@ -12,19 +12,13 @@ import {
|
||||
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
||||
} from './constants';
|
||||
import { getGroupMinimumSize } from './groupSizing.js';
|
||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js';
|
||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns } from './valueFormatting.js';
|
||||
|
||||
// ── Context (provided by App) ─────────────────────────────────────────
|
||||
|
||||
export const NodeContext = React.createContext(null);
|
||||
|
||||
function formatUiLabel(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseProxyHandle(handleId) {
|
||||
const text = String(handleId || '');
|
||||
if (!text.startsWith('group-proxy::')) return null;
|
||||
@@ -421,17 +415,6 @@ function LayerGalleryPreview({ overlay }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getTableColumns(rows) {
|
||||
const columns = [];
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (!columns.includes(key)) columns.push(key);
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
function getMeasurementChoices(rows) {
|
||||
const names = [];
|
||||
for (const row of rows || []) {
|
||||
@@ -443,85 +426,6 @@ function getMeasurementChoices(rows) {
|
||||
return names;
|
||||
}
|
||||
|
||||
const SI_PREFIXES = [
|
||||
{ exp: -24, prefix: 'y' },
|
||||
{ exp: -21, prefix: 'z' },
|
||||
{ exp: -18, prefix: 'a' },
|
||||
{ exp: -15, prefix: 'f' },
|
||||
{ exp: -12, prefix: 'p' },
|
||||
{ exp: -9, prefix: 'n' },
|
||||
{ exp: -6, prefix: 'u' },
|
||||
{ exp: -3, prefix: 'm' },
|
||||
{ exp: 0, prefix: '' },
|
||||
{ exp: 3, prefix: 'k' },
|
||||
{ exp: 6, prefix: 'M' },
|
||||
{ exp: 9, prefix: 'G' },
|
||||
{ exp: 12, prefix: 'T' },
|
||||
{ exp: 15, prefix: 'P' },
|
||||
{ exp: 18, prefix: 'E' },
|
||||
{ exp: 21, prefix: 'Z' },
|
||||
{ exp: 24, prefix: 'Y' },
|
||||
];
|
||||
|
||||
const PREFIXABLE_UNITS = new Set([
|
||||
'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω',
|
||||
]);
|
||||
|
||||
function formatNumericCell(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
const abs = Math.abs(value);
|
||||
if (Number.isInteger(value) && abs < 1e6) return String(value);
|
||||
if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3);
|
||||
return value.toFixed(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
if (Array.isArray(value)) return value.join(', ');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function applySIPrefix(value, unit) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return { valueText: formatNumericCell(value), unitText: unit };
|
||||
}
|
||||
if (typeof unit !== 'string' || !PREFIXABLE_UNITS.has(unit)) {
|
||||
return { valueText: formatNumericCell(value), unitText: unit };
|
||||
}
|
||||
if (value === 0) {
|
||||
return { valueText: '0', unitText: unit };
|
||||
}
|
||||
|
||||
const abs = Math.abs(value);
|
||||
let exp = Math.floor(Math.log10(abs) / 3) * 3;
|
||||
exp = Math.max(-24, Math.min(24, exp));
|
||||
|
||||
let scaled = value / (10 ** exp);
|
||||
if (Math.abs(scaled) >= 999.5 && exp < 24) {
|
||||
exp += 3;
|
||||
scaled = value / (10 ** exp);
|
||||
}
|
||||
|
||||
const prefix = SI_PREFIXES.find((entry) => entry.exp === exp)?.prefix ?? '';
|
||||
return {
|
||||
valueText: formatNumericCell(scaled),
|
||||
unitText: `${prefix}${unit}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTableCell(value) {
|
||||
return formatNumericCell(value);
|
||||
}
|
||||
|
||||
function formatTableRowCell(row, column) {
|
||||
if (column === 'value' && typeof row?.unit === 'string') {
|
||||
return applySIPrefix(row?.value, row.unit).valueText;
|
||||
}
|
||||
if (column === 'unit' && typeof row?.unit === 'string') {
|
||||
return applySIPrefix(row?.value, row.unit).unitText;
|
||||
}
|
||||
return formatTableCell(row?.[column]);
|
||||
}
|
||||
|
||||
function formatScalarValue(value) {
|
||||
if (value == null || Number.isNaN(Number(value))) return '—';
|
||||
const numeric = Number(value);
|
||||
@@ -550,8 +454,8 @@ function formatScalarDisplay(scalarValue) {
|
||||
if (!payload) return null;
|
||||
|
||||
if (payload.unit) {
|
||||
if (PREFIXABLE_UNITS.has(payload.unit)) {
|
||||
const prefixed = applySIPrefix(payload.value, payload.unit);
|
||||
const prefixed = applySIPrefix(payload.value, payload.unit);
|
||||
if (prefixed.unitText !== payload.unit || prefixed.valueText !== formatNumericCell(payload.value)) {
|
||||
return {
|
||||
valueText: prefixed.valueText,
|
||||
unitText: prefixed.unitText,
|
||||
@@ -1061,20 +965,24 @@ function CustomNode({ id, data }) {
|
||||
}
|
||||
}
|
||||
|
||||
const visibleWidgets = widgets.filter((w) => (
|
||||
const dataInputByName = new Map(dataInputs.map((input) => [input.name, input]));
|
||||
|
||||
const widgetsVisibleByDefinition = widgets.filter((w) => (
|
||||
widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)])
|
||||
&& widgetVisibleForWidgetValues(w, data.widgetValues)
|
||||
&& widgetVisibleForInputVisibility(w, visibleInputNames)
|
||||
&& !widgetHiddenByConnectedInput(w, connectedInputs)
|
||||
));
|
||||
const combinedInputNameByWidgetName = buildCombinedInputNameByWidgetName(
|
||||
widgetsVisibleByDefinition,
|
||||
dataInputs,
|
||||
);
|
||||
const visibleWidgets = widgetsVisibleByDefinition.filter((widget) => (
|
||||
combinedInputNameByWidgetName.has(widget.name)
|
||||
|| !widgetHiddenByConnectedInput(widget, connectedInputs)
|
||||
));
|
||||
|
||||
const combinedTopInputNames = new Set(
|
||||
visibleWidgets
|
||||
.map((widget) => widget?.opts?.top_socket_input)
|
||||
.filter((name) => typeof name === 'string' && name.length > 0),
|
||||
);
|
||||
const renderedDataInputs = dataInputs.filter((input) => !combinedTopInputNames.has(input.name));
|
||||
const dataInputByName = new Map(dataInputs.map((input) => [input.name, input]));
|
||||
const combinedInputNames = new Set(combinedInputNameByWidgetName.values());
|
||||
const renderedDataInputs = dataInputs.filter((input) => !combinedInputNames.has(input.name));
|
||||
|
||||
const inlineWidgetsByInput = new Map();
|
||||
const topWidgets = [];
|
||||
@@ -1141,14 +1049,14 @@ function CustomNode({ id, data }) {
|
||||
<div className="node-body">
|
||||
{topWidgets.length > 0 && (
|
||||
<div className="top-widget-section">
|
||||
{topWidgets.map((w) => (
|
||||
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
||||
{(w.socketType || w.opts?.top_socket_input) && (() => {
|
||||
const socketInput = w.opts?.top_socket_input ? dataInputByName.get(w.opts.top_socket_input) : null;
|
||||
const socketType = w.socketType || socketInput?.type;
|
||||
const socketName = w.socketType ? w.name : socketInput?.name;
|
||||
if (!socketType || !socketName) return null;
|
||||
return (
|
||||
{topWidgets.map((w) => {
|
||||
const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null;
|
||||
const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null;
|
||||
const socketType = w.socketType || socketInput?.type;
|
||||
const socketName = w.socketType ? w.name : socketInput?.name;
|
||||
return (
|
||||
<div className={`widget-row${socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
||||
{socketType && socketName && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
@@ -1156,25 +1064,25 @@ function CustomNode({ id, data }) {
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[socketType] || 'var(--fallback-type)' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{!!(
|
||||
(w.socketType && connectedInputs?.has(w.name))
|
||||
|| (w.opts?.top_socket_input && connectedInputs?.has(w.opts.top_socket_input))
|
||||
) ? (
|
||||
<label>{formatUiLabel(w.opts?.label || w.name)}</label>
|
||||
) : (
|
||||
<WidgetControl
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{!!(
|
||||
(w.socketType && connectedInputs?.has(w.name))
|
||||
|| (combinedInputName && connectedInputs?.has(combinedInputName))
|
||||
) ? (
|
||||
<label>{formatUiLabel(w.opts?.label || w.name)}</label>
|
||||
) : (
|
||||
<WidgetControl
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1247,31 +1155,38 @@ function CustomNode({ id, data }) {
|
||||
)}
|
||||
|
||||
{/* Widget rows */}
|
||||
{standaloneWidgets.map((w) => (
|
||||
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
||||
{w.socketType && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={`input::${w.name}::${w.socketType}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[w.socketType] || 'var(--fallback-type)' }}
|
||||
/>
|
||||
)}
|
||||
{w.socketType && connectedInputs?.has(w.name) ? (
|
||||
<label>{formatUiLabel(w.opts?.label || w.name)}</label>
|
||||
) : (
|
||||
<WidgetControl
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{standaloneWidgets.map((w) => {
|
||||
const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null;
|
||||
const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null;
|
||||
const socketType = w.socketType || socketInput?.type;
|
||||
const socketName = w.socketType ? w.name : socketInput?.name;
|
||||
return (
|
||||
<div className={`widget-row${socketType ? ' widget-row-socket' : ''}`} key={w.name}>
|
||||
{socketType && socketName && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={`input::${socketName}::${socketType}`}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[socketType] || 'var(--fallback-type)' }}
|
||||
/>
|
||||
)}
|
||||
{(w.socketType && connectedInputs?.has(w.name))
|
||||
|| (combinedInputName && connectedInputs?.has(combinedInputName)) ? (
|
||||
<label>{formatUiLabel(w.opts?.label || w.name)}</label>
|
||||
) : (
|
||||
<WidgetControl
|
||||
widget={w}
|
||||
nodeId={id}
|
||||
value={data.widgetValues[w.name]}
|
||||
widgetValues={data.widgetValues}
|
||||
onChange={ctx.onWidgetChange}
|
||||
openFileBrowser={ctx.openFileBrowser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manual trigger button (Save) */}
|
||||
{def.manual_trigger && (
|
||||
|
||||
30
frontend/src/loadNodeOutputs.js
Normal file
30
frontend/src/loadNodeOutputs.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export function resolveLoadNodeChannelPath({
|
||||
explicitPath = null,
|
||||
resolvedPathInput = null,
|
||||
className = '',
|
||||
widgetValues = {},
|
||||
} = {}) {
|
||||
if (typeof explicitPath === 'string' && explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
if (typeof resolvedPathInput === 'string' && resolvedPathInput) {
|
||||
return resolvedPathInput;
|
||||
}
|
||||
if (className === 'Image') {
|
||||
return String(widgetValues?.filename || '');
|
||||
}
|
||||
if (className === 'ImageDemo') {
|
||||
return String(widgetValues?.name || '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function beginTrackedNodeRequest(requestVersions, nodeId) {
|
||||
const nextVersion = (requestVersions.get(nodeId) || 0) + 1;
|
||||
requestVersions.set(nodeId, nextVersion);
|
||||
return nextVersion;
|
||||
}
|
||||
|
||||
export function isTrackedNodeRequestCurrent(requestVersions, nodeId, version) {
|
||||
return requestVersions.get(nodeId) === version;
|
||||
}
|
||||
49
frontend/src/nodeWidgetLayout.js
Normal file
49
frontend/src/nodeWidgetLayout.js
Normal file
@@ -0,0 +1,49 @@
|
||||
export function formatUiLabel(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeInputNames(raw) {
|
||||
if (!raw) return [];
|
||||
return (Array.isArray(raw) ? raw : [raw])
|
||||
.map((value) => String(value))
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
export function getWidgetCombinedInputName(widget, dataInputByName) {
|
||||
const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0];
|
||||
if (explicitInputName && dataInputByName?.has(explicitInputName)) {
|
||||
return explicitInputName;
|
||||
}
|
||||
|
||||
const widgetLabel = formatUiLabel(widget?.opts?.label || widget?.name);
|
||||
if (!widgetLabel) return null;
|
||||
|
||||
for (const inputName of normalizeInputNames(widget?.opts?.hide_when_input_connected)) {
|
||||
const input = dataInputByName?.get(inputName);
|
||||
if (!input) continue;
|
||||
const inputLabel = formatUiLabel(input.label || input.name);
|
||||
if (inputLabel === widgetLabel) {
|
||||
return input.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCombinedInputNameByWidgetName(widgets, dataInputs) {
|
||||
const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input]));
|
||||
const combinedInputNameByWidgetName = new Map();
|
||||
|
||||
for (const widget of widgets || []) {
|
||||
const combinedInputName = getWidgetCombinedInputName(widget, dataInputByName);
|
||||
if (combinedInputName) {
|
||||
combinedInputNameByWidgetName.set(widget.name, combinedInputName);
|
||||
}
|
||||
}
|
||||
|
||||
return combinedInputNameByWidgetName;
|
||||
}
|
||||
171
frontend/src/valueFormatting.js
Normal file
171
frontend/src/valueFormatting.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const SI_PREFIXES = [
|
||||
{ exp: -24, prefix: 'y' },
|
||||
{ exp: -21, prefix: 'z' },
|
||||
{ exp: -18, prefix: 'a' },
|
||||
{ exp: -15, prefix: 'f' },
|
||||
{ exp: -12, prefix: 'p' },
|
||||
{ exp: -9, prefix: 'n' },
|
||||
{ exp: -6, prefix: 'u' },
|
||||
{ exp: -3, prefix: 'm' },
|
||||
{ exp: 0, prefix: '' },
|
||||
{ exp: 3, prefix: 'k' },
|
||||
{ exp: 6, prefix: 'M' },
|
||||
{ exp: 9, prefix: 'G' },
|
||||
{ exp: 12, prefix: 'T' },
|
||||
{ exp: 15, prefix: 'P' },
|
||||
{ exp: 18, prefix: 'E' },
|
||||
{ exp: 21, prefix: 'Z' },
|
||||
{ exp: 24, prefix: 'Y' },
|
||||
];
|
||||
|
||||
const PREFIXABLE_UNITS = new Set([
|
||||
'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω',
|
||||
]);
|
||||
|
||||
const SUPERSCRIPT_DIGITS = {
|
||||
'-': '⁻',
|
||||
'0': '⁰',
|
||||
'1': '¹',
|
||||
'2': '²',
|
||||
'3': '³',
|
||||
'4': '⁴',
|
||||
'5': '⁵',
|
||||
'6': '⁶',
|
||||
'7': '⁷',
|
||||
'8': '⁸',
|
||||
'9': '⁹',
|
||||
};
|
||||
|
||||
export function formatNumericCell(value) {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
const abs = Math.abs(value);
|
||||
if (Number.isInteger(value) && abs < 1e6) return String(value);
|
||||
if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3);
|
||||
return value.toFixed(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
if (Array.isArray(value)) return value.join(', ');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toSuperscript(text) {
|
||||
return String(text)
|
||||
.split('')
|
||||
.map((char) => SUPERSCRIPT_DIGITS[char] || char)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function formatDisplayUnit(unit) {
|
||||
return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent));
|
||||
}
|
||||
|
||||
function parsePrefixableUnit(unit) {
|
||||
const text = String(unit ?? '').trim();
|
||||
if (!text) return null;
|
||||
|
||||
const poweredMatch = text.match(/^([A-Za-zΩ]+)\^([1-9]\d*)$/);
|
||||
if (poweredMatch) {
|
||||
const [, baseUnit, powerText] = poweredMatch;
|
||||
if (!PREFIXABLE_UNITS.has(baseUnit)) return null;
|
||||
return { baseUnit, power: Number.parseInt(powerText, 10) };
|
||||
}
|
||||
|
||||
if (!PREFIXABLE_UNITS.has(text)) return null;
|
||||
return { baseUnit: text, power: 1 };
|
||||
}
|
||||
|
||||
function formatPrefixedUnit(baseUnit, prefix, power) {
|
||||
return power === 1 ? `${prefix}${baseUnit}` : `${prefix}${baseUnit}${toSuperscript(power)}`;
|
||||
}
|
||||
|
||||
function choosePrefixExponent(value, power) {
|
||||
const abs = Math.abs(value);
|
||||
const candidates = SI_PREFIXES.map(({ exp, prefix }) => {
|
||||
const scaled = value / (10 ** (exp * power));
|
||||
return {
|
||||
exp,
|
||||
prefix,
|
||||
scaled,
|
||||
absScaled: Math.abs(scaled),
|
||||
};
|
||||
});
|
||||
|
||||
const inRange = candidates.filter(({ absScaled }) => absScaled >= 1 && absScaled < 999.5);
|
||||
if (inRange.length > 0) {
|
||||
return inRange.reduce((best, candidate) => (candidate.absScaled < best.absScaled ? candidate : best));
|
||||
}
|
||||
|
||||
const aboveOne = candidates.filter(({ absScaled }) => absScaled >= 1);
|
||||
if (aboveOne.length > 0) {
|
||||
return aboveOne.reduce((best, candidate) => (candidate.absScaled < best.absScaled ? candidate : best));
|
||||
}
|
||||
|
||||
return candidates.reduce((best, candidate) => (candidate.absScaled > best.absScaled ? candidate : best));
|
||||
}
|
||||
|
||||
export function applySIPrefix(value, unit) {
|
||||
const formattedUnit = formatDisplayUnit(unit);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return { valueText: formatNumericCell(value), unitText: formattedUnit };
|
||||
}
|
||||
if (value === 0) {
|
||||
return { valueText: '0', unitText: formattedUnit };
|
||||
}
|
||||
|
||||
const parsedUnit = parsePrefixableUnit(unit);
|
||||
if (!parsedUnit) {
|
||||
return { valueText: formatNumericCell(value), unitText: formattedUnit };
|
||||
}
|
||||
|
||||
const chosenPrefix = choosePrefixExponent(value, parsedUnit.power);
|
||||
return {
|
||||
valueText: formatNumericCell(chosenPrefix.scaled),
|
||||
unitText: formatPrefixedUnit(parsedUnit.baseUnit, chosenPrefix.prefix, parsedUnit.power),
|
||||
};
|
||||
}
|
||||
|
||||
function getCompanionUnitColumn(column, row) {
|
||||
if (!row || typeof row !== 'object' || typeof column !== 'string' || column === 'unit') {
|
||||
return null;
|
||||
}
|
||||
const unitColumn = `${column}_unit`;
|
||||
return typeof row?.[unitColumn] === 'string' ? unitColumn : null;
|
||||
}
|
||||
|
||||
export function getTableColumns(rows) {
|
||||
const columns = [];
|
||||
const hiddenColumns = new Set();
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (
|
||||
key.endsWith('_unit')
|
||||
&& key.length > 5
|
||||
&& Object.prototype.hasOwnProperty.call(row, key.slice(0, -5))
|
||||
) {
|
||||
hiddenColumns.add(key);
|
||||
continue;
|
||||
}
|
||||
if (!columns.includes(key)) columns.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return columns.filter((column) => !hiddenColumns.has(column));
|
||||
}
|
||||
|
||||
export function formatTableRowCell(row, column) {
|
||||
const companionUnitColumn = getCompanionUnitColumn(column, row);
|
||||
if (companionUnitColumn) {
|
||||
const formatted = applySIPrefix(row?.[column], row?.[companionUnitColumn]);
|
||||
return formatted.unitText ? `${formatted.valueText} ${formatted.unitText}` : formatted.valueText;
|
||||
}
|
||||
if (column === 'value' && typeof row?.unit === 'string') {
|
||||
return applySIPrefix(row?.value, row.unit).valueText;
|
||||
}
|
||||
if (column === 'unit' && typeof row?.unit === 'string') {
|
||||
return applySIPrefix(row?.value, row.unit).unitText;
|
||||
}
|
||||
return formatNumericCell(row?.[column]);
|
||||
}
|
||||
Reference in New Issue
Block a user