get group resize, enter and exit working
This commit is contained in:
1
.tmp-tests/write-probe.txt
Normal file
1
.tmp-tests/write-probe.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
@@ -34,6 +34,7 @@ class Save:
|
|||||||
"choices_by_source_type": {
|
"choices_by_source_type": {
|
||||||
"DATA_FIELD": ["TIFF", "PNG", "NPZ"],
|
"DATA_FIELD": ["TIFF", "PNG", "NPZ"],
|
||||||
"IMAGE": ["PNG", "TIFF", "NPZ"],
|
"IMAGE": ["PNG", "TIFF", "NPZ"],
|
||||||
|
"ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"],
|
||||||
"LINE": ["CSV", "NPZ", "JSON"],
|
"LINE": ["CSV", "NPZ", "JSON"],
|
||||||
"MEASURE_TABLE": ["CSV", "JSON"],
|
"MEASURE_TABLE": ["CSV", "JSON"],
|
||||||
"RECORD_TABLE": ["CSV", "JSON"],
|
"RECORD_TABLE": ["CSV", "JSON"],
|
||||||
|
|||||||
@@ -89,8 +89,19 @@ function getConnectionHandleType(handleId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNodeDimension(node, axis) {
|
function getNodeDimension(node, axis) {
|
||||||
if (axis === 'width') return node.measured?.width || node.width || node.style?.width || 200;
|
if (axis === 'width') return node.measured?.width || node.style?.width || node.width || 200;
|
||||||
return node.measured?.height || node.height || node.style?.height || 120;
|
return node.measured?.height || node.style?.height || node.height || 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNodeSize(node, width, height) {
|
||||||
|
const nextWidth = Math.round(Number(width) || 0);
|
||||||
|
const nextHeight = Math.round(Number(height) || 0);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
width: nextWidth,
|
||||||
|
height: nextHeight,
|
||||||
|
style: { ...(node.style || {}), width: nextWidth, height: nextHeight },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeAbsolutePosition(node, nodeMap) {
|
function getNodeAbsolutePosition(node, nodeMap) {
|
||||||
@@ -195,6 +206,17 @@ function getNodeRect(node, nodeMap) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAbsoluteRectForNodePosition(node, absolutePosition) {
|
||||||
|
const width = Number(getNodeDimension(node, 'width')) || 200;
|
||||||
|
const height = Number(getNodeDimension(node, 'height')) || 120;
|
||||||
|
return {
|
||||||
|
left: absolutePosition.x,
|
||||||
|
top: absolutePosition.y,
|
||||||
|
right: absolutePosition.x + width,
|
||||||
|
bottom: absolutePosition.y + height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function rectContainsPoint(rect, point) {
|
function rectContainsPoint(rect, point) {
|
||||||
return point.x >= rect.left
|
return point.x >= rect.left
|
||||||
&& point.x <= rect.right
|
&& point.x <= rect.right
|
||||||
@@ -202,6 +224,13 @@ function rectContainsPoint(rect, point) {
|
|||||||
&& point.y <= rect.bottom;
|
&& point.y <= rect.bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rectContainsRect(outerRect, innerRect) {
|
||||||
|
return innerRect.left >= outerRect.left
|
||||||
|
&& innerRect.top >= outerRect.top
|
||||||
|
&& innerRect.right <= outerRect.right
|
||||||
|
&& innerRect.bottom <= outerRect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
function getEventClientPosition(event) {
|
function getEventClientPosition(event) {
|
||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
const point = 'changedTouches' in event && event.changedTouches?.[0]
|
const point = 'changedTouches' in event && event.changedTouches?.[0]
|
||||||
@@ -829,10 +858,11 @@ function Flow() {
|
|||||||
};
|
};
|
||||||
const collapsedHeight = Math.max(74, 38 + Math.max(proxyData.proxyInputs.length, proxyData.proxyOutputs.length, 1) * 24 + 26);
|
const collapsedHeight = Math.max(74, 38 + Math.max(proxyData.proxyInputs.length, proxyData.proxyOutputs.length, 1) * 24 + 26);
|
||||||
return {
|
return {
|
||||||
...node,
|
...applyNodeSize(
|
||||||
style: collapsed
|
node,
|
||||||
? { ...(node.style || {}), width: 260, height: collapsedHeight }
|
collapsed ? 260 : expandedSize.width,
|
||||||
: { ...(node.style || {}), width: expandedSize.width, height: expandedSize.height },
|
collapsed ? collapsedHeight : expandedSize.height,
|
||||||
|
),
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
collapsed,
|
collapsed,
|
||||||
@@ -1014,6 +1044,8 @@ function Flow() {
|
|||||||
type: 'custom',
|
type: 'custom',
|
||||||
className: 'group-shell',
|
className: 'group-shell',
|
||||||
position: groupPosition,
|
position: groupPosition,
|
||||||
|
width: groupWidth,
|
||||||
|
height: groupHeight,
|
||||||
dragHandle: '.drag-handle',
|
dragHandle: '.drag-handle',
|
||||||
style: { width: groupWidth, height: groupHeight },
|
style: { width: groupWidth, height: groupHeight },
|
||||||
data: {
|
data: {
|
||||||
@@ -1726,14 +1758,39 @@ function Flow() {
|
|||||||
setNodes,
|
setNodes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const resizeGroup = useCallback((groupId, size) => {
|
||||||
|
const nextWidth = Math.round(Number(size?.width) || 0);
|
||||||
|
const nextHeight = Math.round(Number(size?.height) || 0);
|
||||||
|
if (!nextWidth || !nextHeight) return;
|
||||||
|
|
||||||
|
setNodes((existing) => existing.map((node) => {
|
||||||
|
if (String(node.id) !== String(groupId) || node.data?.className !== 'Group') return node;
|
||||||
|
|
||||||
|
const sameSize = Math.abs((Number(node.style?.width) || 0) - nextWidth) < 0.5
|
||||||
|
&& Math.abs((Number(node.style?.height) || 0) - nextHeight) < 0.5;
|
||||||
|
if (sameSize) return node;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...applyNodeSize(node, nextWidth, nextHeight),
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
expandedSize: { width: nextWidth, height: nextHeight },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => reactFlow.updateNodeInternals(String(groupId)), 0);
|
||||||
|
}, [reactFlow, setNodes]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
onWidgetChange,
|
onWidgetChange,
|
||||||
onRuntimeValuesChange,
|
onRuntimeValuesChange,
|
||||||
openFileBrowser,
|
openFileBrowser,
|
||||||
onManualTrigger,
|
onManualTrigger,
|
||||||
onToggleGroupCollapse: toggleGroupCollapse,
|
onToggleGroupCollapse: toggleGroupCollapse,
|
||||||
|
onResizeGroup: resizeGroup,
|
||||||
onUngroup: ungroupGroup,
|
onUngroup: ungroupGroup,
|
||||||
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, toggleGroupCollapse, ungroupGroup]);
|
}), [onRuntimeValuesChange, onWidgetChange, openFileBrowser, onManualTrigger, resizeGroup, toggleGroupCollapse, ungroupGroup]);
|
||||||
|
|
||||||
const clearGraph = useCallback(() => {
|
const clearGraph = useCallback(() => {
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
@@ -2000,6 +2057,8 @@ function Flow() {
|
|||||||
anchorId: String(node.id),
|
anchorId: String(node.id),
|
||||||
anchorStartAbsolute: anchorAbsolute,
|
anchorStartAbsolute: anchorAbsolute,
|
||||||
absolutePositions,
|
absolutePositions,
|
||||||
|
releasedNodeIds: new Set(),
|
||||||
|
touchedGroupIds: new Set(),
|
||||||
pointerOffset: {
|
pointerOffset: {
|
||||||
x: pointerFlowPos.x - anchorAbsolute.x,
|
x: pointerFlowPos.x - anchorAbsolute.x,
|
||||||
y: pointerFlowPos.y - anchorAbsolute.y,
|
y: pointerFlowPos.y - anchorAbsolute.y,
|
||||||
@@ -2077,7 +2136,7 @@ function Flow() {
|
|||||||
initializeDynamicNodes(duplicated.nodes);
|
initializeDynamicNodes(duplicated.nodes);
|
||||||
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
|
}, [initializeDynamicNodes, reactFlow, setEdges, setNodes]);
|
||||||
|
|
||||||
const onNodeDrag = useCallback((_event, node) => {
|
const onNodeDrag = useCallback((event, node) => {
|
||||||
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
||||||
|
|
||||||
const duplicateState = duplicateDragRef.current;
|
const duplicateState = duplicateDragRef.current;
|
||||||
@@ -2121,7 +2180,103 @@ function Flow() {
|
|||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [setNodes]);
|
|
||||||
|
const dragState = dragStateRef.current;
|
||||||
|
if (!dragState || node.data?.className === 'Group') return;
|
||||||
|
|
||||||
|
const currentNodes = reactFlow.getNodes();
|
||||||
|
const draggedNodes = node.selected
|
||||||
|
? currentNodes.filter((candidate) => candidate.selected && candidate.data?.className !== 'Group')
|
||||||
|
: currentNodes.filter((candidate) => candidate.id === node.id);
|
||||||
|
if (draggedNodes.length === 0) return;
|
||||||
|
|
||||||
|
const dragIntent = getDragIntent(event, reactFlow, dragState);
|
||||||
|
if (!dragIntent?.pointerFlowPos) return;
|
||||||
|
|
||||||
|
const draggedIdSet = new Set(draggedNodes.map((candidate) => String(candidate.id)));
|
||||||
|
const nodeMap = new Map(currentNodes.map((candidate) => [String(candidate.id), candidate]));
|
||||||
|
const releasedNodeIds = dragState.releasedNodeIds instanceof Set
|
||||||
|
? new Set(dragState.releasedNodeIds)
|
||||||
|
: new Set();
|
||||||
|
const touchedGroupIds = dragState.touchedGroupIds instanceof Set
|
||||||
|
? new Set(dragState.touchedGroupIds)
|
||||||
|
: new Set();
|
||||||
|
|
||||||
|
let nextNodes = currentNodes;
|
||||||
|
let changed = false;
|
||||||
|
let structureChanged = false;
|
||||||
|
|
||||||
|
nextNodes = nextNodes.map((candidate) => {
|
||||||
|
const candidateId = String(candidate.id);
|
||||||
|
if (!draggedIdSet.has(candidateId)) return candidate;
|
||||||
|
|
||||||
|
const absolute = dragIntent.absolutePositions.get(candidateId)
|
||||||
|
|| getNodeAbsolutePosition(candidate, nodeMap);
|
||||||
|
if (!absolute) return candidate;
|
||||||
|
|
||||||
|
if (candidate.parentId) {
|
||||||
|
const parentId = String(candidate.parentId);
|
||||||
|
const parentNode = nodeMap.get(parentId);
|
||||||
|
if (parentNode?.data?.className === 'Group') {
|
||||||
|
const parentRect = getGroupWorkspaceBounds(parentNode, nodeMap);
|
||||||
|
const parentAbsolute = getNodeAbsolutePosition(parentNode, nodeMap);
|
||||||
|
const nextPosition = {
|
||||||
|
x: absolute.x - parentAbsolute.x,
|
||||||
|
y: absolute.y - parentAbsolute.y,
|
||||||
|
};
|
||||||
|
const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute);
|
||||||
|
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
|
||||||
|
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
|
||||||
|
|
||||||
|
if (!releasedNodeIds.has(candidateId) && !rectContainsRect(parentRect, candidateRect)) {
|
||||||
|
releasedNodeIds.add(candidateId);
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
extent: undefined,
|
||||||
|
hidden: false,
|
||||||
|
position: nextPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releasedNodeIds.has(candidateId)) {
|
||||||
|
if (!candidate.parentId && !candidate.extent && candidate.hidden !== true && samePosition) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
extent: undefined,
|
||||||
|
hidden: false,
|
||||||
|
position: nextPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!releasedNodeIds.has(candidateId)) return candidate;
|
||||||
|
return candidate;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
dragStateRef.current = {
|
||||||
|
...dragState,
|
||||||
|
releasedNodeIds,
|
||||||
|
touchedGroupIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
setNodes(structureChanged ? sortNodesForParentOrder(nextNodes) : nextNodes);
|
||||||
|
|
||||||
|
if (structureChanged) {
|
||||||
|
setTimeout(() => {
|
||||||
|
touchedGroupIds.forEach((groupId) => {
|
||||||
|
if (groupId) refreshGroupNode(groupId, nextNodes, reactFlow.getEdges());
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [reactFlow, refreshGroupNode, setNodes]);
|
||||||
|
|
||||||
const onNodeDragStop = useCallback((event, node) => {
|
const onNodeDragStop = useCallback((event, node) => {
|
||||||
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
if (String(node.id) !== activeDragNodeIdRef.current) return;
|
||||||
@@ -2183,7 +2338,9 @@ function Flow() {
|
|||||||
|
|
||||||
const currentNodes = reactFlow.getNodes();
|
const currentNodes = reactFlow.getNodes();
|
||||||
const dragIntent = getDragIntent(event, reactFlow, dragState);
|
const dragIntent = getDragIntent(event, reactFlow, dragState);
|
||||||
const touchedGroupIds = new Set();
|
const touchedGroupIds = dragState?.touchedGroupIds instanceof Set
|
||||||
|
? new Set(dragState.touchedGroupIds)
|
||||||
|
: new Set();
|
||||||
let nextNodes = currentNodes;
|
let nextNodes = currentNodes;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
@@ -2237,7 +2394,7 @@ function Flow() {
|
|||||||
const alreadyInTarget = String(candidate.parentId || '') === String(targetGroup.id);
|
const alreadyInTarget = String(candidate.parentId || '') === String(targetGroup.id);
|
||||||
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
|
const samePosition = Math.abs((Number(candidate.position?.x) || 0) - nextPosition.x) < 0.5
|
||||||
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
|
&& Math.abs((Number(candidate.position?.y) || 0) - nextPosition.y) < 0.5;
|
||||||
if (alreadyInTarget && samePosition) return candidate;
|
if (alreadyInTarget && candidate.extent === 'parent' && samePosition) return candidate;
|
||||||
|
|
||||||
if (candidate.parentId) {
|
if (candidate.parentId) {
|
||||||
touchedGroupIds.add(String(candidate.parentId));
|
touchedGroupIds.add(String(candidate.parentId));
|
||||||
@@ -2261,7 +2418,6 @@ function Flow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pointerFlowPos = dragIntent?.pointerFlowPos || getEventFlowPosition(event, reactFlow);
|
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
|
|
||||||
nextNodes = nextNodes.map((candidate) => {
|
nextNodes = nextNodes.map((candidate) => {
|
||||||
@@ -2270,13 +2426,20 @@ function Flow() {
|
|||||||
const parentId = String(candidate.parentId);
|
const parentId = String(candidate.parentId);
|
||||||
const parentNode = nodeMap.get(parentId);
|
const parentNode = nodeMap.get(parentId);
|
||||||
if (!parentNode || parentNode.data?.className !== 'Group') return candidate;
|
if (!parentNode || parentNode.data?.className !== 'Group') return candidate;
|
||||||
if (!pointerFlowPos) return candidate;
|
|
||||||
if (rectContainsPoint(getNodeRect(parentNode, nodeMap), pointerFlowPos)) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const absolute = dragIntent?.absolutePositions.get(String(candidate.id))
|
const absolute = dragIntent?.absolutePositions.get(String(candidate.id))
|
||||||
|| getNodeAbsolutePosition(candidate, nodeMap);
|
|| getNodeAbsolutePosition(candidate, nodeMap);
|
||||||
|
const parentWorkspaceRect = getGroupWorkspaceBounds(parentNode, nodeMap);
|
||||||
|
const candidateRect = getAbsoluteRectForNodePosition(candidate, absolute);
|
||||||
|
if (rectContainsRect(parentWorkspaceRect, candidateRect)) {
|
||||||
|
if (candidate.extent === 'parent') return candidate;
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...candidate,
|
||||||
|
extent: 'parent',
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
touchedGroupIds.add(parentId);
|
touchedGroupIds.add(parentId);
|
||||||
removedCount += 1;
|
removedCount += 1;
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -2298,7 +2461,16 @@ function Flow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changed) return;
|
if (!changed) {
|
||||||
|
const releasedCount = dragState?.releasedNodeIds instanceof Set ? dragState.releasedNodeIds.size : 0;
|
||||||
|
if (releasedCount > 0) {
|
||||||
|
setStatus({
|
||||||
|
text: `Removed ${releasedCount} node${releasedCount === 1 ? '' : 's'} from group.`,
|
||||||
|
level: 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setNodes(sortNodesForParentOrder(nextNodes));
|
setNodes(sortNodesForParentOrder(nextNodes));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react';
|
import React, { useContext, useRef, useCallback, useState, useEffect, memo, lazy, Suspense } from 'react';
|
||||||
import { Handle, Position, useStore } from '@xyflow/react';
|
import { Handle, NodeResizeControl, Position, useStore } from '@xyflow/react';
|
||||||
import LinePlotOverlay from './LinePlotOverlay';
|
import LinePlotOverlay from './LinePlotOverlay';
|
||||||
|
|
||||||
const SurfaceView = lazy(() => import('./SurfaceView'));
|
const SurfaceView = lazy(() => import('./SurfaceView'));
|
||||||
@@ -11,6 +11,7 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
|||||||
import {
|
import {
|
||||||
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import { getGroupMinimumSize } from './groupSizing.js';
|
||||||
|
|
||||||
// ── Context (provided by App) ─────────────────────────────────────────
|
// ── Context (provided by App) ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -44,10 +45,37 @@ function GroupNode({ id, data }) {
|
|||||||
const childCount = Number(data.childCount) || 0;
|
const childCount = Number(data.childCount) || 0;
|
||||||
const collapsed = !!data.collapsed;
|
const collapsed = !!data.collapsed;
|
||||||
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
|
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
|
||||||
|
const selected = useStore(
|
||||||
|
useCallback(
|
||||||
|
(s) => {
|
||||||
|
const node = s.nodeLookup?.get(id) || s.nodes?.find((candidate) => candidate.id === id);
|
||||||
|
return !!node?.selected;
|
||||||
|
},
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const groupMinSize = useStore(
|
||||||
|
useCallback(
|
||||||
|
(s) => getGroupMinimumSize(
|
||||||
|
(s.nodes || []).filter((candidate) => String(candidate.parentId || '') === String(id)),
|
||||||
|
),
|
||||||
|
[id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`custom-node group-node ${collapsed ? 'group-node-collapsed' : 'group-node-expanded'}`}>
|
<>
|
||||||
<div className="node-title drag-handle group-node-title">
|
{!collapsed && selected && (
|
||||||
|
<NodeResizeControl
|
||||||
|
position="bottom-right"
|
||||||
|
className="node-resize-handle"
|
||||||
|
minWidth={groupMinSize.width}
|
||||||
|
minHeight={groupMinSize.height}
|
||||||
|
onResizeEnd={(event, params) => ctx.onResizeGroup?.(id, params)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={`custom-node group-node ${collapsed ? 'group-node-collapsed' : 'group-node-expanded'}`}>
|
||||||
|
<div className="node-title drag-handle group-node-title">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="group-toggle group-toggle-collapse nodrag"
|
className="group-toggle group-toggle-collapse nodrag"
|
||||||
@@ -117,7 +145,8 @@ function GroupNode({ id, data }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const SOCKET_COMPATIBILITY = {
|
|||||||
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
|
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
|
||||||
ANNOTATION_SOURCE: new Set(['DATA_FIELD', 'IMAGE']),
|
ANNOTATION_SOURCE: new Set(['DATA_FIELD', 'IMAGE']),
|
||||||
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
|
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
|
||||||
SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']),
|
SAVE_VALUE: new Set(['DATA_FIELD', 'IMAGE', 'ANNOTATION_SOURCE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'MESH_MODEL', 'FLOAT']),
|
||||||
FLOAT: new Set(['INT']),
|
FLOAT: new Set(['INT']),
|
||||||
INT: new Set(['FLOAT']),
|
INT: new Set(['FLOAT']),
|
||||||
LINE: new Set(['COORDPAIR']),
|
LINE: new Set(['COORDPAIR']),
|
||||||
|
|||||||
18
frontend/src/groupDrag.js
Normal file
18
frontend/src/groupDrag.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const GROUP_DRAG_RELEASE_DISTANCE = 18;
|
||||||
|
|
||||||
|
export function getPointDistanceOutsideRect(rect, point) {
|
||||||
|
if (!rect || !point) return Infinity;
|
||||||
|
|
||||||
|
const dx = point.x < rect.left
|
||||||
|
? rect.left - point.x
|
||||||
|
: (point.x > rect.right ? point.x - rect.right : 0);
|
||||||
|
const dy = point.y < rect.top
|
||||||
|
? rect.top - point.y
|
||||||
|
: (point.y > rect.bottom ? point.y - rect.bottom : 0);
|
||||||
|
|
||||||
|
return Math.hypot(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldReleaseFromGroup(rect, point, threshold = GROUP_DRAG_RELEASE_DISTANCE) {
|
||||||
|
return getPointDistanceOutsideRect(rect, point) >= threshold;
|
||||||
|
}
|
||||||
35
frontend/src/groupSizing.js
Normal file
35
frontend/src/groupSizing.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const DEFAULT_CHILD_WIDTH = 200;
|
||||||
|
const DEFAULT_CHILD_HEIGHT = 120;
|
||||||
|
|
||||||
|
function getNodeSize(node, axis) {
|
||||||
|
const fallback = axis === 'width' ? DEFAULT_CHILD_WIDTH : DEFAULT_CHILD_HEIGHT;
|
||||||
|
const measured = Number(node?.measured?.[axis]);
|
||||||
|
if (Number.isFinite(measured) && measured > 0) return measured;
|
||||||
|
const direct = Number(node?.[axis]);
|
||||||
|
if (Number.isFinite(direct) && direct > 0) return direct;
|
||||||
|
const styled = Number(node?.style?.[axis]);
|
||||||
|
if (Number.isFinite(styled) && styled > 0) return styled;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupMinimumSize(memberNodes, {
|
||||||
|
minWidth = 260,
|
||||||
|
minHeight = 180,
|
||||||
|
paddingX = 24,
|
||||||
|
paddingY = 24,
|
||||||
|
} = {}) {
|
||||||
|
let maxRight = 0;
|
||||||
|
let maxBottom = 0;
|
||||||
|
|
||||||
|
for (const node of memberNodes || []) {
|
||||||
|
const x = Number(node?.position?.x) || 0;
|
||||||
|
const y = Number(node?.position?.y) || 0;
|
||||||
|
maxRight = Math.max(maxRight, x + getNodeSize(node, 'width'));
|
||||||
|
maxBottom = Math.max(maxBottom, y + getNodeSize(node, 'height'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.max(minWidth, Math.ceil(maxRight + paddingX)),
|
||||||
|
height: Math.max(minHeight, Math.ceil(maxBottom + paddingY)),
|
||||||
|
};
|
||||||
|
}
|
||||||
8
frontend/tests/constants.test.mjs
Normal file
8
frontend/tests/constants.test.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { SOCKET_COMPATIBILITY } from '../src/constants.js';
|
||||||
|
|
||||||
|
test('SAVE_VALUE accepts ANNOTATION_SOURCE inputs', () => {
|
||||||
|
assert.equal(SOCKET_COMPATIBILITY.SAVE_VALUE.has('ANNOTATION_SOURCE'), true);
|
||||||
|
});
|
||||||
26
frontend/tests/groupDrag.test.mjs
Normal file
26
frontend/tests/groupDrag.test.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GROUP_DRAG_RELEASE_DISTANCE,
|
||||||
|
getPointDistanceOutsideRect,
|
||||||
|
shouldReleaseFromGroup,
|
||||||
|
} from '../src/groupDrag.js';
|
||||||
|
|
||||||
|
test('getPointDistanceOutsideRect returns zero inside the rect', () => {
|
||||||
|
const rect = { left: 10, top: 20, right: 110, bottom: 120 };
|
||||||
|
assert.equal(getPointDistanceOutsideRect(rect, { x: 60, y: 70 }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldReleaseFromGroup waits for a small overshoot before releasing', () => {
|
||||||
|
const rect = { left: 10, top: 20, right: 110, bottom: 120 };
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldReleaseFromGroup(rect, { x: 110 + GROUP_DRAG_RELEASE_DISTANCE - 1, y: 70 }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldReleaseFromGroup(rect, { x: 110 + GROUP_DRAG_RELEASE_DISTANCE, y: 70 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
26
frontend/tests/groupSizing.test.mjs
Normal file
26
frontend/tests/groupSizing.test.mjs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { getGroupMinimumSize } from '../src/groupSizing.js';
|
||||||
|
|
||||||
|
test('getGroupMinimumSize keeps the base minimum for empty groups', () => {
|
||||||
|
assert.deepEqual(getGroupMinimumSize([]), { width: 260, height: 180 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getGroupMinimumSize grows to fit child bounds plus padding', () => {
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
position: { x: 24, y: 60 },
|
||||||
|
style: { width: 180, height: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: { x: 260, y: 150 },
|
||||||
|
style: { width: 220, height: 140 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(getGroupMinimumSize(nodes), {
|
||||||
|
width: 504,
|
||||||
|
height: 314,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2205,11 +2205,13 @@ def test_view3d():
|
|||||||
def test_save_generic():
|
def test_save_generic():
|
||||||
print("=== Test: Save ===")
|
print("=== Test: Save ===")
|
||||||
from backend.nodes.save import Save
|
from backend.nodes.save import Save
|
||||||
from backend.data_types import DataField, LineData, MeasureTable, MeshModel, RecordTable
|
from backend.data_types import DataField, ImageData, LineData, MeasureTable, MeshModel, RecordTable
|
||||||
import tifffile
|
import tifffile
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
node = Save()
|
node = Save()
|
||||||
|
format_choices = node.INPUT_TYPES()["required"]["format"][1]["choices_by_source_type"]
|
||||||
|
assert format_choices["ANNOTATION_SOURCE"] == format_choices["IMAGE"]
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
# Save scalar as TXT and JSON
|
# Save scalar as TXT and JSON
|
||||||
@@ -2282,6 +2284,26 @@ def test_save_generic():
|
|||||||
image_npz = np.load(Path(tmpdir, "image_npz.npz"))
|
image_npz = np.load(Path(tmpdir, "image_npz.npz"))
|
||||||
assert np.array_equal(image_npz["image"], image)
|
assert np.array_equal(image_npz["image"], image)
|
||||||
|
|
||||||
|
# Save ANNOTATION_SOURCE as PNG, TIFF, and NPZ
|
||||||
|
annotation_image = ImageData(
|
||||||
|
image,
|
||||||
|
metadata={"annotation_context": {"si_unit_xy": "um", "si_unit_z": "nm"}},
|
||||||
|
)
|
||||||
|
node.save(filename="annotation_png", directory_path=tmpdir, format="PNG", value=annotation_image)
|
||||||
|
annotation_png = np.asarray(PILImage.open(Path(tmpdir, "annotation_png.png")))
|
||||||
|
assert annotation_png.shape == image.shape
|
||||||
|
assert np.array_equal(annotation_png, image)
|
||||||
|
|
||||||
|
node.save(filename="annotation_tiff", directory_path=tmpdir, format="TIFF", value=annotation_image)
|
||||||
|
annotation_tiff = tifffile.imread(Path(tmpdir, "annotation_tiff.tiff"))
|
||||||
|
assert annotation_tiff.shape == image.shape
|
||||||
|
assert annotation_tiff.dtype == np.uint8
|
||||||
|
assert np.array_equal(annotation_tiff, image)
|
||||||
|
|
||||||
|
node.save(filename="annotation_npz", directory_path=tmpdir, format="NPZ", value=annotation_image)
|
||||||
|
annotation_npz = np.load(Path(tmpdir, "annotation_npz.npz"))
|
||||||
|
assert np.array_equal(annotation_npz["image"], image)
|
||||||
|
|
||||||
# Save tables as CSV and JSON
|
# Save tables as CSV and JSON
|
||||||
measure_table = MeasureTable([
|
measure_table = MeasureTable([
|
||||||
{"quantity": "Rq", "value": 1.23, "unit": "nm"},
|
{"quantity": "Rq", "value": 1.23, "unit": "nm"},
|
||||||
|
|||||||
Reference in New Issue
Block a user