deduplication pass
This commit is contained in:
@@ -9,16 +9,7 @@ import {
|
||||
moveAngleWidget,
|
||||
round3,
|
||||
} from './angleMeasureGeometry';
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function sanitizeHexColor(value: unknown, fallback = '#ff9800') {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const text = value.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback;
|
||||
}
|
||||
import { clampFraction as clamp01, sanitizeHexColor, pointerToFraction } from './overlayUtils';
|
||||
|
||||
function hexToRgb(value: string) {
|
||||
const color = sanitizeHexColor(value);
|
||||
@@ -112,11 +103,7 @@ export default function AngleMeasureOverlay({
|
||||
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
|
||||
|
||||
const getCoords = useCallback((event: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return {
|
||||
fx: clamp01((event.clientX - rect.left) / rect.width),
|
||||
fy: clamp01((event.clientY - rect.top) / rect.height),
|
||||
};
|
||||
return pointerToFraction(event, containerRef.current!);
|
||||
}, []);
|
||||
|
||||
const updateWidgets = useCallback((updates: Record<string, unknown>) => {
|
||||
|
||||
@@ -68,7 +68,7 @@ import {
|
||||
GROUP_HEADER_HEIGHT,
|
||||
GROUP_MIN_WIDTH,
|
||||
GROUP_MIN_HEIGHT,
|
||||
getNodeDimension,
|
||||
getNodeSize,
|
||||
applyNodeSize,
|
||||
getNodeAbsolutePosition,
|
||||
collectGroupDescendantIds,
|
||||
@@ -127,6 +127,36 @@ const CANVAS_RIGHT_DRAG_ZOOM_THRESHOLD = 5;
|
||||
|
||||
const DEBUG = false; // set to true for verbose logging
|
||||
|
||||
function restoreGroupEdges(edges: any[], groupId: string) {
|
||||
return edges.map((edge: any) => {
|
||||
if ((edge.data as any)?.groupInternalHiddenBy === groupId) {
|
||||
const nextData: any = { ...(edge.data || {}) };
|
||||
delete nextData.groupInternalHiddenBy;
|
||||
return {
|
||||
...edge,
|
||||
hidden: false,
|
||||
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||
};
|
||||
}
|
||||
if (edge.data?.groupProxyOwner === groupId) {
|
||||
const nextData: any = { ...(edge.data || {}) };
|
||||
const original = (nextData.groupProxyOriginal || {}) as Record<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) ────────────
|
||||
|
||||
function Flow() {
|
||||
@@ -212,6 +242,14 @@ function Flow() {
|
||||
reactFlow.updateNodeInternals(groupId);
|
||||
}, [reactFlow, setNodes]);
|
||||
|
||||
const refreshAllGroups = useCallback((explicitNodes: any[] | null = null, explicitEdges: any[] | null = null) => {
|
||||
setTimeout(() => {
|
||||
(reactFlow.getNodes() as TonoNode[])
|
||||
.filter((node) => node.data?.className === 'Group')
|
||||
.forEach((node) => refreshGroupNode(node.id, explicitNodes, explicitEdges));
|
||||
}, 0);
|
||||
}, [reactFlow, refreshGroupNode]);
|
||||
|
||||
const toggleGroupCollapse = useCallback((groupId: string) => {
|
||||
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||
const currentEdges = (reactFlow.getEdges() as TonoEdge[]);
|
||||
@@ -296,30 +334,7 @@ function Flow() {
|
||||
return edge;
|
||||
}
|
||||
|
||||
if ((edge.data as any)?.groupInternalHiddenBy === groupId) {
|
||||
const nextData: any = { ...(edge.data || {}) };
|
||||
delete nextData.groupInternalHiddenBy;
|
||||
return {
|
||||
...edge,
|
||||
hidden: false,
|
||||
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||
};
|
||||
}
|
||||
if (edge.data?.groupProxyOwner === groupId) {
|
||||
const nextData: any = { ...(edge.data || {}) };
|
||||
const original = (nextData.groupProxyOriginal || {}) as Record<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;
|
||||
return restoreGroupEdges([edge], groupId)[0];
|
||||
});
|
||||
|
||||
setNodes(nextNodes as TonoNode[]);
|
||||
@@ -352,44 +367,13 @@ function Flow() {
|
||||
};
|
||||
});
|
||||
|
||||
const nextEdges = currentEdges
|
||||
.map((edge) => {
|
||||
if ((edge.data as any)?.groupInternalHiddenBy === groupId) {
|
||||
const nextData: any = { ...(edge.data || {}) };
|
||||
delete nextData.groupInternalHiddenBy;
|
||||
return {
|
||||
...edge,
|
||||
hidden: false,
|
||||
data: Object.keys(nextData).length > 0 ? nextData : undefined,
|
||||
};
|
||||
}
|
||||
if (edge.data?.groupProxyOwner === groupId) {
|
||||
const nextData: any = { ...(edge.data || {}) };
|
||||
const original = (nextData.groupProxyOriginal || {}) as Record<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));
|
||||
const nextEdges = restoreGroupEdges(currentEdges, groupId)
|
||||
.filter((edge: any) => String(edge.source) !== String(groupId) && String(edge.target) !== String(groupId));
|
||||
|
||||
setNodes(nextNodes);
|
||||
setEdges(nextEdges);
|
||||
setTimeout(() => {
|
||||
(reactFlow.getNodes() as TonoNode[])
|
||||
.filter((node) => node.data?.className === 'Group')
|
||||
.forEach((node) => refreshGroupNode(node.id, nextNodes, nextEdges));
|
||||
}, 0);
|
||||
}, [reactFlow, refreshGroupNode, setEdges, setNodes]);
|
||||
refreshAllGroups(nextNodes, nextEdges);
|
||||
}, [reactFlow, refreshAllGroups, setEdges, setNodes]);
|
||||
|
||||
const createGroupFromSelection = useCallback(() => {
|
||||
const currentNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||
@@ -808,12 +792,8 @@ function Flow() {
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
setTimeout(() => {
|
||||
(reactFlow.getNodes() as TonoNode[])
|
||||
.filter((node) => node.data?.className === 'Group')
|
||||
.forEach((node) => refreshGroupNode(node.id));
|
||||
}, 0);
|
||||
}, [onEdgesChange, reactFlow, refreshAnnotationNodeOutputs, refreshGroupNode, refreshLoadNodeOutputs]);
|
||||
refreshAllGroups();
|
||||
}, [onEdgesChange, reactFlow, refreshAllGroups, refreshAnnotationNodeOutputs, refreshLoadNodeOutputs]);
|
||||
|
||||
const handleNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
// Stash undo snapshot when a drag begins
|
||||
@@ -887,12 +867,8 @@ function Flow() {
|
||||
)));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
(reactFlow.getNodes() as TonoNode[])
|
||||
.filter((node) => node.data?.className === 'Group')
|
||||
.forEach((node) => refreshGroupNode(node.id));
|
||||
}, 0);
|
||||
}, [onNodesChange, reactFlow, refreshGroupNode, setEdges, setNodes]);
|
||||
refreshAllGroups();
|
||||
}, [onNodesChange, reactFlow, refreshAllGroups, setEdges, setNodes]);
|
||||
|
||||
// ── Drop-on-blank: open filtered context menu ──────────────────────
|
||||
|
||||
@@ -1583,7 +1559,7 @@ function Flow() {
|
||||
return new Promise<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;
|
||||
if (!viewportEl) throw new Error('Flow element not found');
|
||||
|
||||
@@ -1591,10 +1567,8 @@ function Flow() {
|
||||
if (allNodes.length === 0) throw new Error('No nodes to capture');
|
||||
|
||||
const bounds = getRenderedNodeBounds(allNodes);
|
||||
if (!bounds) {
|
||||
throw new Error('Could not determine rendered node bounds');
|
||||
}
|
||||
const pad = 0.1; // 10% margin on each side
|
||||
if (!bounds) throw new Error('Could not determine rendered node bounds');
|
||||
const pad = 0.1;
|
||||
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
|
||||
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
||||
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
||||
@@ -1610,176 +1584,97 @@ function Flow() {
|
||||
},
|
||||
});
|
||||
if (!blob) throw new Error('Capture returned empty');
|
||||
|
||||
const stampedBlob = await stampLogoOnBlob(blob);
|
||||
const workflow = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[])) as any;
|
||||
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
||||
return embedWorkflow(stampedBlob as Blob, workflow);
|
||||
return await stampLogoOnBlob(blob) as Blob;
|
||||
}, [reactFlow]);
|
||||
|
||||
const getWorkflowBlob = useCallback(async () => {
|
||||
const imageBlob = await captureWorkflowImage();
|
||||
const workflow = serializeWorkflowState(
|
||||
(reactFlow.getNodes() as TonoNode[]),
|
||||
(reactFlow.getEdges() as TonoEdge[]),
|
||||
) as any;
|
||||
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
||||
return embedWorkflow(imageBlob, workflow);
|
||||
}, [reactFlow, captureWorkflowImage]);
|
||||
|
||||
const saveBlobToFile = useCallback(async (blob: Blob, filename: string): Promise<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 () => {
|
||||
setStatus({ text: 'Saving…', level: 'info' });
|
||||
try {
|
||||
const finalBlob = await getWorkflowBlob();
|
||||
|
||||
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
||||
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path('workflow.png');
|
||||
if (!requestedPath) {
|
||||
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
body: finalBlob,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text() || `Save failed (${resp.status})`);
|
||||
}
|
||||
const { path: savedPath } = await resp.json();
|
||||
if (!savedPath) {
|
||||
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||
return;
|
||||
}
|
||||
setStatus({ text: `Workflow saved to ${savedPath}.`, level: 'info' });
|
||||
return;
|
||||
const saved = await saveBlobToFile(finalBlob, 'workflow.png');
|
||||
if (!saved) {
|
||||
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||
} else {
|
||||
setStatus({ text: `Workflow saved to ${saved}.`, level: 'info' });
|
||||
}
|
||||
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker!({
|
||||
suggestedName: 'workflow.png',
|
||||
types: [
|
||||
{
|
||||
description: 'PNG image',
|
||||
accept: { 'image/png': ['.png'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(finalBlob);
|
||||
await writable.close();
|
||||
setStatus({ text: 'Workflow saved as workflow.png.', level: 'info' });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError') {
|
||||
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: trigger a browser download and tell the user where it went.
|
||||
const resp = await fetch('/download?filename=workflow.png', {
|
||||
method: 'POST',
|
||||
body: finalBlob,
|
||||
});
|
||||
const dlBlob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(dlBlob);
|
||||
a.download = 'workflow.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
|
||||
setStatus({
|
||||
text: 'Workflow downloaded as workflow.png to your browser default downloads folder.',
|
||||
level: 'info',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatus({ text: 'Save failed: ' + err.message, level: 'error' });
|
||||
}
|
||||
}, [getWorkflowBlob]);
|
||||
}, [getWorkflowBlob, saveBlobToFile]);
|
||||
|
||||
const savePackedWorkflow = useCallback(async () => {
|
||||
setStatus({ text: 'Packing files…', level: 'info' });
|
||||
try {
|
||||
const viewportEl = document.querySelector('.react-flow__viewport') as HTMLElement | null;
|
||||
if (!viewportEl) throw new Error('Flow element not found');
|
||||
|
||||
const imageBlob = await captureWorkflowImage();
|
||||
const allNodes = (reactFlow.getNodes() as TonoNode[]);
|
||||
if (allNodes.length === 0) throw new Error('No nodes to capture');
|
||||
|
||||
const bounds = getRenderedNodeBounds(allNodes);
|
||||
if (!bounds) throw new Error('Could not determine rendered node bounds');
|
||||
const pad = 0.1;
|
||||
const imageWidth = Math.ceil(bounds.width * (1 + pad * 2));
|
||||
const imageHeight = Math.ceil(bounds.height * (1 + pad * 2));
|
||||
const vp = getViewportForBounds(bounds, imageWidth, imageHeight, 0.5, 1, pad);
|
||||
|
||||
if (DEBUG) console.log('[pack] capturing viewport…');
|
||||
const blob = await captureWorkflowViewportBlob(viewportEl, {
|
||||
backgroundColor: CANVAS_COLORS.bgDeep,
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
style: {
|
||||
width: `${imageWidth}px`,
|
||||
height: `${imageHeight}px`,
|
||||
transform: `translate(${vp.x}px, ${vp.y}px) scale(${vp.zoom})`,
|
||||
},
|
||||
});
|
||||
if (!blob) throw new Error('Capture returned empty');
|
||||
|
||||
if (DEBUG) console.log('[pack] stamping logo…');
|
||||
const stampedBlob = await stampLogoOnBlob(blob);
|
||||
let workflow: any = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[]));
|
||||
const workflow: any = serializeWorkflowState(allNodes, (reactFlow.getEdges() as TonoEdge[]));
|
||||
if (journalContentRef.current) workflow.journalContent = journalContentRef.current;
|
||||
|
||||
if (DEBUG) console.log('[pack] packing files…');
|
||||
workflow = await packWorkflow(workflow, nodeDefsRef.current, (packed: number, total: number) => {
|
||||
setStatus({ text: `Packing files… (${packed}/${total})`, level: 'info' });
|
||||
const packed = await packWorkflow(workflow, nodeDefsRef.current, (done: number, total: number) => {
|
||||
setStatus({ text: `Packing files… (${done}/${total})`, level: 'info' });
|
||||
});
|
||||
if (DEBUG) console.log('[pack] packed, embedding into PNG…', workflow.packed ? 'has packed files' : 'no packed files');
|
||||
const finalBlob = await embedWorkflow(stampedBlob as Blob, workflow);
|
||||
if (DEBUG) console.log('[pack] embed complete, blob size:', finalBlob.size);
|
||||
const defaultName = 'workflow-packed.png';
|
||||
const finalBlob = await embedWorkflow(imageBlob, packed as any);
|
||||
|
||||
if (window.pywebview?.api?.choose_save_workflow_png_path) {
|
||||
const requestedPath = await window.pywebview.api.choose_save_workflow_png_path(defaultName);
|
||||
if (!requestedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
||||
const resp = await fetch(`/save-workflow-png?path=${encodeURIComponent(requestedPath)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'image/png' }, body: finalBlob,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text() || `Save failed (${resp.status})`);
|
||||
const { path: savedPath } = await resp.json();
|
||||
if (!savedPath) { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
||||
setStatus({ text: `Packed workflow saved to ${savedPath}.`, level: 'info' });
|
||||
return;
|
||||
const saved = await saveBlobToFile(finalBlob, 'workflow-packed.png');
|
||||
if (!saved) {
|
||||
setStatus({ text: 'Save cancelled.', level: 'info' });
|
||||
} else {
|
||||
setStatus({ text: `Packed workflow saved to ${saved}.`, level: 'info' });
|
||||
}
|
||||
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker!({
|
||||
suggestedName: defaultName,
|
||||
types: [{ description: 'PNG image', accept: { 'image/png': ['.png'] } }],
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
await writable.write(finalBlob);
|
||||
await writable.close();
|
||||
setStatus({ text: 'Packed workflow saved.', level: 'info' });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError') { setStatus({ text: 'Save cancelled.', level: 'info' }); return; }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(finalBlob);
|
||||
a.download = defaultName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
setStatus({ text: `Packed workflow downloaded as ${defaultName}.`, level: 'info' });
|
||||
} catch (err: any) {
|
||||
setStatus({ text: 'Pack failed: ' + err.message, level: 'error' });
|
||||
}
|
||||
}, [reactFlow]);
|
||||
}, [reactFlow, captureWorkflowImage, saveBlobToFile]);
|
||||
|
||||
const copySnapshot = useCallback(() => {
|
||||
setStatus({ text: 'Copying snapshot…', level: 'info' });
|
||||
@@ -2201,10 +2096,11 @@ function Flow() {
|
||||
const anchorNode = nodeMap.get(String(dragState?.anchorId || node.id));
|
||||
const intendedAnchorAbsolute = dragIntent?.absolutePositions.get(String(anchorNode?.id || node.id))
|
||||
|| (anchorNode ? getNodeAbsolutePosition(anchorNode, nodeMap) : null);
|
||||
const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute
|
||||
const anchorSize = anchorNode ? getNodeSize(anchorNode) : null;
|
||||
const intendedAnchorCenter = anchorNode && intendedAnchorAbsolute && anchorSize
|
||||
? {
|
||||
x: intendedAnchorAbsolute.x + (Number(getNodeDimension(anchorNode, 'width')) || 200) / 2,
|
||||
y: intendedAnchorAbsolute.y + (Number(getNodeDimension(anchorNode, 'height')) || 120) / 2,
|
||||
x: intendedAnchorAbsolute.x + anchorSize.width / 2,
|
||||
y: intendedAnchorAbsolute.y + anchorSize.height / 2,
|
||||
}
|
||||
: null;
|
||||
const targetGroup = findExpandedGroupDropTarget(
|
||||
@@ -2222,8 +2118,7 @@ function Flow() {
|
||||
if (!draggedIdSet.has(String(candidate.id))) return candidate;
|
||||
|
||||
const intendedAbsolute = dragIntent?.absolutePositions.get(String(candidate.id));
|
||||
const width = Number(getNodeDimension(candidate, 'width')) || 200;
|
||||
const height = Number(getNodeDimension(candidate, 'height')) || 120;
|
||||
const { width, height } = getNodeSize(candidate);
|
||||
const center = intendedAbsolute
|
||||
? { x: intendedAbsolute.x + width / 2, y: intendedAbsolute.y + height / 2 }
|
||||
: getNodeCenter(candidate, nodeMap);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { pointerToFraction } from './overlayUtils';
|
||||
|
||||
export const CAPTURE_SELECTOR = '.crop-overlay';
|
||||
|
||||
@@ -23,11 +24,7 @@ export default function CropBoxOverlay({
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
|
||||
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return {
|
||||
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
return pointerToFraction(e, containerRef.current!);
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { pointerToFraction } from './overlayUtils';
|
||||
|
||||
export const CAPTURE_SELECTOR = '.cs-overlay';
|
||||
|
||||
@@ -34,11 +35,7 @@ export default function CrossSectionOverlay({
|
||||
const [dragging, setDragging] = useState<string | null>(null); // 'p1' or 'p2'
|
||||
|
||||
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
return {
|
||||
fx: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)),
|
||||
fy: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)),
|
||||
};
|
||||
return pointerToFraction(e, containerRef.current!);
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from './constants';
|
||||
import { getGroupMinimumSize } from './groupSizing';
|
||||
import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout';
|
||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit } from './valueFormatting';
|
||||
import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns, parseNumberWithUnit, formatSI, parseSI } from './valueFormatting';
|
||||
|
||||
import type { NodeData, NodeContextValue, InputSpec, InputOptions, WidgetDescriptor, PreviewPayload, OverlayData } from './types';
|
||||
|
||||
@@ -302,51 +302,6 @@ class PreviewBoundary extends React.Component<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 ────────────────────────────────────────────
|
||||
|
||||
function DraggableNumber({ value, step, min, max, precision, onChange }: {
|
||||
@@ -1072,6 +1027,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
},
|
||||
);
|
||||
|
||||
const overlayCoord = useCallback(
|
||||
(key: 'x1' | 'y1' | 'x2' | 'y2', liveIdx: number, locked: boolean) => {
|
||||
if (locked) return (liveCoordPair?.[liveIdx] ?? data.overlay?.[key]) as number;
|
||||
return (data.widgetValues[key] ?? data.overlay?.[key]) as number;
|
||||
},
|
||||
[liveCoordPair, data.overlay, data.widgetValues],
|
||||
);
|
||||
|
||||
// Parse inputs into data handles and widgets
|
||||
const required = def?.input?.required || {};
|
||||
const optional = def?.input?.optional || {};
|
||||
@@ -1495,8 +1458,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
{data.overlay!.kind === 'line_plot' ? (
|
||||
<LinePlotOverlay
|
||||
overlay={data.overlay!}
|
||||
x1={(data.overlay!.a_locked ? (liveCoordPair?.[0] ?? data.overlay!.x1) : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
||||
x2={(data.overlay!.b_locked ? (liveCoordPair?.[2] ?? data.overlay!.x2) : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
||||
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||
aLocked={!!data.overlay!.a_locked}
|
||||
bLocked={!!data.overlay!.b_locked}
|
||||
nodeId={id}
|
||||
@@ -1505,10 +1468,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
) : data.overlay!.kind === 'crop_box' ? (
|
||||
<CropBoxOverlay
|
||||
image={data.overlay!.image ?? ''}
|
||||
x1={(data.overlay!.a_locked ? (liveCoordPair?.[0] ?? data.overlay!.x1) : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
||||
y1={(data.overlay!.a_locked ? (liveCoordPair?.[1] ?? data.overlay!.y1) : (data.widgetValues.y1 ?? data.overlay!.y1)) as number}
|
||||
x2={(data.overlay!.b_locked ? (liveCoordPair?.[2] ?? data.overlay!.x2) : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
||||
y2={(data.overlay!.b_locked ? (liveCoordPair?.[3] ?? data.overlay!.y2) : (data.widgetValues.y2 ?? data.overlay!.y2)) as number}
|
||||
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||
y1={overlayCoord('y1', 1, !!data.overlay!.a_locked)}
|
||||
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||
y2={overlayCoord('y2', 3, !!data.overlay!.b_locked)}
|
||||
aLocked={!!data.overlay!.a_locked}
|
||||
bLocked={!!data.overlay!.b_locked}
|
||||
nodeId={id}
|
||||
@@ -1517,10 +1480,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
) : data.overlay!.kind === 'cursor_points' ? (
|
||||
<CrossSectionOverlay
|
||||
image={data.overlay!.image ?? ''}
|
||||
x1={(data.overlay!.a_locked ? (liveCoordPair?.[0] ?? data.overlay!.x1) : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
||||
y1={(data.overlay!.a_locked ? (liveCoordPair?.[1] ?? data.overlay!.y1) : (data.widgetValues.y1 ?? data.overlay!.y1)) as number}
|
||||
x2={(data.overlay!.b_locked ? (liveCoordPair?.[2] ?? data.overlay!.x2) : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
||||
y2={(data.overlay!.b_locked ? (liveCoordPair?.[3] ?? data.overlay!.y2) : (data.widgetValues.y2 ?? data.overlay!.y2)) as number}
|
||||
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||
y1={overlayCoord('y1', 1, !!data.overlay!.a_locked)}
|
||||
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||
y2={overlayCoord('y2', 3, !!data.overlay!.b_locked)}
|
||||
aLocked={!!data.overlay!.a_locked}
|
||||
bLocked={!!data.overlay!.b_locked}
|
||||
nodeId={id}
|
||||
@@ -1577,10 +1540,10 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
||||
) : (
|
||||
<CrossSectionOverlay
|
||||
image={data.overlay!.image ?? ''}
|
||||
x1={(data.overlay!.a_locked ? data.overlay!.x1 : (data.widgetValues.x1 ?? data.overlay!.x1)) as number}
|
||||
y1={(data.overlay!.a_locked ? data.overlay!.y1 : (data.widgetValues.y1 ?? data.overlay!.y1)) as number}
|
||||
x2={(data.overlay!.b_locked ? data.overlay!.x2 : (data.widgetValues.x2 ?? data.overlay!.x2)) as number}
|
||||
y2={(data.overlay!.b_locked ? data.overlay!.y2 : (data.widgetValues.y2 ?? data.overlay!.y2)) as number}
|
||||
x1={overlayCoord('x1', 0, !!data.overlay!.a_locked)}
|
||||
y1={overlayCoord('y1', 1, !!data.overlay!.a_locked)}
|
||||
x2={overlayCoord('x2', 2, !!data.overlay!.b_locked)}
|
||||
y2={overlayCoord('y2', 3, !!data.overlay!.b_locked)}
|
||||
aLocked={!!data.overlay!.a_locked}
|
||||
bLocked={!!data.overlay!.b_locked}
|
||||
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
|
||||
if (Array.isArray(type)) {
|
||||
return (
|
||||
<>
|
||||
{!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>
|
||||
</>
|
||||
);
|
||||
return renderSelect(type, (val || type[0]) as string);
|
||||
}
|
||||
|
||||
if (type === 'STRING' && dynamicTypeChoices.length > 0) {
|
||||
const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0];
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <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>
|
||||
</>
|
||||
);
|
||||
return renderSelect(dynamicTypeChoices, dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0]);
|
||||
}
|
||||
|
||||
if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) {
|
||||
const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0];
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <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>
|
||||
</>
|
||||
);
|
||||
return renderSelect(dynamicTableColumns, dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]);
|
||||
}
|
||||
|
||||
if (type === 'STRING' && opts?.choices_from_measure_input && dynamicMeasurementChoices.length > 0) {
|
||||
const selected = dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0];
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && <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>
|
||||
</>
|
||||
);
|
||||
return renderSelect(dynamicMeasurementChoices, dynamicMeasurementChoices.includes(String(val)) ? String(val) : dynamicMeasurementChoices[0]);
|
||||
}
|
||||
|
||||
if (type === 'FILE_PICKER' || type === 'FOLDER_PICKER') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getAxisScale } from './valueFormatting';
|
||||
import { clamp, formatTick, makeTicks, getExtent } from './overlayUtils';
|
||||
|
||||
export const CAPTURE_SELECTOR = '.lineplot-overlay';
|
||||
|
||||
@@ -13,59 +14,10 @@ const MARKER_STROKE = '#ffffff';
|
||||
const MARKER_LOCKED_COLOR = '#e91e63';
|
||||
const MARKER_LABEL_FILL = '#0f172a';
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
function round3(v: number) {
|
||||
return parseFloat(v.toFixed(3));
|
||||
}
|
||||
|
||||
function trimZeros(text: string) {
|
||||
return text.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1');
|
||||
}
|
||||
|
||||
function formatTick(value: number) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs === 0) return '0';
|
||||
if (abs >= 1e4 || abs < 1e-3) {
|
||||
return value.toExponential(1).replace('e+', 'e');
|
||||
}
|
||||
if (abs >= 100) return trimZeros(value.toFixed(0));
|
||||
if (abs >= 10) return trimZeros(value.toFixed(1));
|
||||
if (abs >= 1) return trimZeros(value.toFixed(2));
|
||||
return trimZeros(value.toFixed(3));
|
||||
}
|
||||
|
||||
function makeTicks(min: number, max: number, count = 5) {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return [];
|
||||
if (min === max) return [min];
|
||||
const ticks: number[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
ticks.push(min + ((max - min) * i) / (count - 1));
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return [fallbackMin, fallbackMax];
|
||||
}
|
||||
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const value of values) {
|
||||
if (!Number.isFinite(value)) continue;
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return [fallbackMin, fallbackMax];
|
||||
}
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
interface LinePlotOverlayProps {
|
||||
overlay: any;
|
||||
x1: number;
|
||||
|
||||
@@ -10,12 +10,7 @@ import {
|
||||
sanitizeMarkupColor,
|
||||
sanitizeMarkupShape,
|
||||
} from './markupShapeGeometry';
|
||||
|
||||
function clampFraction(value: number) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
import { clampFraction, pointerToFraction } from './overlayUtils';
|
||||
|
||||
interface MarkupShape {
|
||||
kind: string;
|
||||
@@ -150,11 +145,11 @@ export default function MarkupOverlay({
|
||||
}, [image]);
|
||||
|
||||
const getPoint = useCallback((event: React.PointerEvent<Element>) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
if (!containerRef.current) return null;
|
||||
const { fx, fy } = pointerToFraction(event, containerRef.current);
|
||||
return {
|
||||
x: Number(clampFraction((event.clientX - rect.left) / rect.width).toFixed(4)),
|
||||
y: Number(clampFraction((event.clientY - rect.top) / rect.height).toFixed(4)),
|
||||
x: Number(fx.toFixed(4)),
|
||||
y: Number(fy.toFixed(4)),
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { CANVAS_COLORS } from './constants';
|
||||
import { clampFraction, pointerToFraction } from './overlayUtils';
|
||||
|
||||
interface StrokePoint {
|
||||
x: number;
|
||||
@@ -16,12 +17,6 @@ interface DrawStrokeStyles {
|
||||
fillStyle?: string;
|
||||
}
|
||||
|
||||
function clampFraction(value: number) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
function sanitizeStroke(stroke: any, fallbackPenSize: number): Stroke | null {
|
||||
if (!stroke || typeof stroke !== 'object' || !Array.isArray(stroke.points) || stroke.points.length === 0) {
|
||||
return null;
|
||||
@@ -219,12 +214,9 @@ export default function MaskPaintOverlay({
|
||||
}, [draftStroke, redrawCanvas]);
|
||||
|
||||
const getPoint = useCallback((event: React.PointerEvent<Element>): StrokePoint | null => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
return {
|
||||
x: clampFraction((event.clientX - rect.left) / rect.width),
|
||||
y: clampFraction((event.clientY - rect.top) / rect.height),
|
||||
};
|
||||
if (!containerRef.current) return null;
|
||||
const { fx, fy } = pointerToFraction(event, containerRef.current);
|
||||
return { x: fx, y: fy };
|
||||
}, []);
|
||||
|
||||
const getBrushDisplaySize = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getAxisScale } from './valueFormatting';
|
||||
import { clamp, formatTick, makeTicks, getExtent } from './overlayUtils';
|
||||
|
||||
export const CAPTURE_SELECTOR = '.lineplot-overlay';
|
||||
|
||||
@@ -13,31 +14,7 @@ const MARKER_STROKE = '#ffffff';
|
||||
const MARKER_LOCKED_COLOR = '#e91e63';
|
||||
const MARKER_LABEL_FILL = '#0f172a';
|
||||
|
||||
function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); }
|
||||
function round4(v: number) { return parseFloat(v.toFixed(4)); }
|
||||
function trimZeros(t: string) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
|
||||
|
||||
function formatTick(value: number) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs === 0) return '0';
|
||||
if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e');
|
||||
if (abs >= 100) return trimZeros(value.toFixed(0));
|
||||
if (abs >= 10) return trimZeros(value.toFixed(1));
|
||||
if (abs >= 1) return trimZeros(value.toFixed(2));
|
||||
return trimZeros(value.toFixed(3));
|
||||
}
|
||||
|
||||
function makeTicks(min: number, max: number, count = 5) {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min];
|
||||
return Array.from({ length: count }, (_: unknown, i: number) => min + (max - min) * i / (count - 1));
|
||||
}
|
||||
|
||||
function getExtent(values: number[], fallbackMin = 0, fallbackMax = 1) {
|
||||
if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax];
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (const v of values) { if (Number.isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } }
|
||||
return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax];
|
||||
}
|
||||
|
||||
interface ThresholdHistogramProps {
|
||||
overlay: any;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampFraction as clamp01 } from './overlayUtils.ts';
|
||||
|
||||
interface AnglePoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
@@ -7,10 +9,6 @@ interface AnglePoints {
|
||||
y2: number;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
export function round3(value: number): number {
|
||||
return Number.parseFloat(Number(value).toFixed(3));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getNodeCenter, getGroupWorkspaceBounds, rectContainsPoint } from './nodeGeometry';
|
||||
import { clamp } from './overlayUtils.ts';
|
||||
|
||||
export function getEventClientPosition(event: any) {
|
||||
if (!event) return null;
|
||||
@@ -52,7 +53,7 @@ export function isEditableTarget(target: any) {
|
||||
}
|
||||
|
||||
export function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
return clamp(value, min, max);
|
||||
}
|
||||
|
||||
export function canStartCanvasRightDragZoom(target: any) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampFraction, sanitizeHexColor } from './overlayUtils.ts';
|
||||
|
||||
export const MARKUP_DEFAULT_SHAPE = 'arrow';
|
||||
export const MARKUP_DEFAULT_COLOR = '#ff0000';
|
||||
export const MARKUP_PREVIEW_REFERENCE_DIM = 512;
|
||||
@@ -12,16 +14,8 @@ export interface MarkupShape {
|
||||
color: string;
|
||||
}
|
||||
|
||||
function clampFraction(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
export function sanitizeMarkupColor(color: unknown, fallback: string = MARKUP_DEFAULT_COLOR): string {
|
||||
if (typeof color !== 'string') return fallback;
|
||||
const value = color.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
||||
return sanitizeHexColor(color, fallback);
|
||||
}
|
||||
|
||||
export function sanitizeMarkupShape(
|
||||
|
||||
@@ -18,6 +18,13 @@ export function getNodeDimension(node: any, axis: string): number {
|
||||
return node.measured?.height || node.style?.height || node.height || 120;
|
||||
}
|
||||
|
||||
export function getNodeSize(node: any): { width: number; height: number } {
|
||||
return {
|
||||
width: Number(getNodeDimension(node, 'width')) || 200,
|
||||
height: Number(getNodeDimension(node, 'height')) || 120,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyNodeSize(node: any, width: any, height: any) {
|
||||
const nextWidth = Math.round(Number(width) || 0);
|
||||
const nextHeight = Math.round(Number(height) || 0);
|
||||
@@ -82,8 +89,7 @@ export function getGroupDisplayBounds(nodes: any[], selectedIds: any[]) {
|
||||
const node = nodeMap.get(String(id));
|
||||
if (!node) continue;
|
||||
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||
const { width, height } = getNodeSize(node);
|
||||
minX = Math.min(minX, pos.x);
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxX = Math.max(maxX, pos.x + width);
|
||||
@@ -99,8 +105,7 @@ export function getGroupDisplayBounds(nodes: any[], selectedIds: any[]) {
|
||||
|
||||
export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map<string, any>) {
|
||||
const pos = getNodeAbsolutePosition(groupNode, nodeMap);
|
||||
const width = Number(getNodeDimension(groupNode, 'width')) || 200;
|
||||
const height = Number(getNodeDimension(groupNode, 'height')) || 120;
|
||||
const { width, height } = getNodeSize(groupNode);
|
||||
return {
|
||||
left: pos.x + GROUP_WORKSPACE_INSET,
|
||||
top: pos.y + GROUP_HEADER_HEIGHT + GROUP_WORKSPACE_INSET,
|
||||
@@ -111,8 +116,7 @@ export function getGroupWorkspaceBounds(groupNode: any, nodeMap: Map<string, any
|
||||
|
||||
export function getNodeCenter(node: any, nodeMap: Map<string, any>) {
|
||||
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||
const { width, height } = getNodeSize(node);
|
||||
return {
|
||||
x: pos.x + width / 2,
|
||||
y: pos.y + height / 2,
|
||||
@@ -121,8 +125,7 @@ export function getNodeCenter(node: any, nodeMap: Map<string, any>) {
|
||||
|
||||
export function getNodeRect(node: any, nodeMap: Map<string, any>) {
|
||||
const pos = getNodeAbsolutePosition(node, nodeMap);
|
||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||
const { width, height } = getNodeSize(node);
|
||||
return {
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
@@ -132,8 +135,7 @@ export function getNodeRect(node: any, nodeMap: Map<string, any>) {
|
||||
}
|
||||
|
||||
export function getAbsoluteRectForNodePosition(node: any, absolutePosition: { x: number; y: number }) {
|
||||
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||
const { width, height } = getNodeSize(node);
|
||||
return {
|
||||
left: absolutePosition.x,
|
||||
top: absolutePosition.y,
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// ── 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