mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
Compare commits
No commits in common. "094fd6d9571ae1419cecd9cc3e4b5784fb5b3192" and "ae552a9fbdca8dee7f10687f74dd975d8b2404b0" have entirely different histories.
094fd6d957
...
ae552a9fbd
15 changed files with 176 additions and 1179 deletions
|
|
@ -99,10 +99,8 @@ const AutocompleteLabelSelector = ({ value, onChange, scope }: Props) => {
|
||||||
|
|
||||||
// Check if ID already exists
|
// Check if ID already exists
|
||||||
if (labels.some((l) => l.id === id)) {
|
if (labels.some((l) => l.id === id)) {
|
||||||
// If label already exists, just select it (but only if not already selected)
|
// If label already exists, just select it
|
||||||
if (!value.includes(id)) {
|
|
||||||
onChange([...value, id]);
|
onChange([...value, id]);
|
||||||
}
|
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setHighlightedIndex(0);
|
setHighlightedIndex(0);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { getContrastColor } from '../../utils/colorUtils';
|
||||||
* Features:
|
* Features:
|
||||||
* - Pill-shaped design
|
* - Pill-shaped design
|
||||||
* - Auto-contrast text color
|
* - Auto-contrast text color
|
||||||
* - Optional truncation with ellipsis (if maxWidth is provided)
|
* - Truncation with ellipsis
|
||||||
* - Tooltip on hover (via title attribute)
|
* - Tooltip on hover (via title attribute)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface Props {
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
const LabelBadge = ({ name, color, maxWidth, size = 'sm' }: Props) => {
|
const LabelBadge = ({ name, color, maxWidth = '120px', size = 'sm' }: Props) => {
|
||||||
const textColor = getContrastColor(color);
|
const textColor = getContrastColor(color);
|
||||||
|
|
||||||
const sizeClasses = size === 'sm'
|
const sizeClasses = size === 'sm'
|
||||||
|
|
@ -26,11 +26,11 @@ const LabelBadge = ({ name, color, maxWidth, size = 'sm' }: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block rounded-full font-medium whitespace-nowrap ${maxWidth ? 'overflow-hidden text-ellipsis' : ''} ${sizeClasses}`}
|
className={`inline-block rounded-full font-medium whitespace-nowrap overflow-hidden text-ellipsis ${sizeClasses}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
...(maxWidth && { maxWidth }),
|
maxWidth,
|
||||||
}}
|
}}
|
||||||
title={name}
|
title={name}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
EdgeProps,
|
EdgeProps,
|
||||||
|
getBezierPath,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
useInternalNode,
|
useNodes,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { useGraphStore } from '../../stores/graphStore';
|
import { useGraphStore } from '../../stores/graphStore';
|
||||||
import type { Relation } from '../../types';
|
import type { Relation } from '../../types';
|
||||||
|
import type { Group } from '../../types';
|
||||||
import LabelBadge from '../Common/LabelBadge';
|
import LabelBadge from '../Common/LabelBadge';
|
||||||
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
|
import { getFloatingEdgeParams } from '../../utils/edgeUtils';
|
||||||
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
|
import { useActiveFilters, edgeMatchesFilters } from '../../hooks/useActiveFilters';
|
||||||
|
|
@ -32,72 +34,66 @@ const CustomEdge = ({
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
data,
|
data,
|
||||||
selected,
|
selected,
|
||||||
}: EdgeProps<Relation>) => {
|
}: EdgeProps<Relation>) => {
|
||||||
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
||||||
const labels = useGraphStore((state) => state.labels);
|
const labels = useGraphStore((state) => state.labels);
|
||||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
|
||||||
|
|
||||||
// Get active filters based on mode (editing vs presentation)
|
// Get active filters based on mode (editing vs presentation)
|
||||||
const filters = useActiveFilters();
|
const filters = useActiveFilters();
|
||||||
|
|
||||||
// Get internal nodes for floating edge calculations with correct absolute positioning
|
// Get all nodes to check if source/target are minimized groups
|
||||||
const sourceNode = useInternalNode(source);
|
const nodes = useNodes();
|
||||||
const targetNode = useInternalNode(target);
|
const sourceNode = nodes.find((n) => n.id === source);
|
||||||
|
const targetNode = nodes.find((n) => n.id === target);
|
||||||
|
|
||||||
// Determine node shapes from node type configuration
|
// Check if either endpoint is a minimized group
|
||||||
const sourceShape = useMemo(() => {
|
const sourceIsMinimizedGroup = sourceNode?.type === 'group' && (sourceNode.data as Group['data']).minimized;
|
||||||
if (!sourceNode) return 'rectangle';
|
const targetIsMinimizedGroup = targetNode?.type === 'group' && (targetNode.data as Group['data']).minimized;
|
||||||
// Groups always use rectangle shape
|
|
||||||
if (sourceNode.type === 'group') return 'rectangle';
|
|
||||||
const nodeData = sourceNode.data as { type?: string };
|
|
||||||
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type);
|
|
||||||
return nodeTypeConfig?.shape || 'rectangle';
|
|
||||||
}, [sourceNode, nodeTypes]);
|
|
||||||
|
|
||||||
const targetShape = useMemo(() => {
|
// Calculate floating edge parameters if needed
|
||||||
if (!targetNode) return 'rectangle';
|
// When connecting to groups (especially minimized ones), we need to use floating edges
|
||||||
// Groups always use rectangle shape
|
// because groups don't have specific handles
|
||||||
if (targetNode.type === 'group') return 'rectangle';
|
let finalSourceX = sourceX;
|
||||||
const nodeData = targetNode.data as { type?: string };
|
let finalSourceY = sourceY;
|
||||||
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeData?.type);
|
let finalTargetX = targetX;
|
||||||
return nodeTypeConfig?.shape || 'rectangle';
|
let finalTargetY = targetY;
|
||||||
}, [targetNode, nodeTypes]);
|
let finalSourcePosition = sourcePosition;
|
||||||
|
let finalTargetPosition = targetPosition;
|
||||||
|
|
||||||
// Calculate floating edge parameters with custom bezier control points
|
// Check if we need to use floating edge calculations
|
||||||
const edgeParams = useMemo(() => {
|
const needsFloatingEdge = (sourceIsMinimizedGroup || targetIsMinimizedGroup) && sourceNode && targetNode;
|
||||||
if (!sourceNode || !targetNode) {
|
|
||||||
// Fallback to default React Flow positioning
|
if (needsFloatingEdge) {
|
||||||
return {
|
const floatingParams = getFloatingEdgeParams(sourceNode, targetNode);
|
||||||
edgePath: `M ${sourceX},${sourceY} L ${targetX},${targetY}`,
|
|
||||||
labelX: (sourceX + targetX) / 2,
|
// When either endpoint is a minimized group, use floating positions for that side
|
||||||
labelY: (sourceY + targetY) / 2,
|
// IMPORTANT: When BOTH are groups, we must use floating for BOTH sides
|
||||||
};
|
if (sourceIsMinimizedGroup) {
|
||||||
|
finalSourceX = floatingParams.sx;
|
||||||
|
finalSourceY = floatingParams.sy;
|
||||||
|
finalSourcePosition = floatingParams.sourcePos;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = getFloatingEdgeParams(sourceNode, targetNode, sourceShape, targetShape);
|
if (targetIsMinimizedGroup) {
|
||||||
|
finalTargetX = floatingParams.tx;
|
||||||
|
finalTargetY = floatingParams.ty;
|
||||||
|
finalTargetPosition = floatingParams.targetPos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create cubic bezier path using custom control points
|
// Calculate the bezier path
|
||||||
const edgePath = `M ${params.sx},${params.sy} C ${params.sourceControlX},${params.sourceControlY} ${params.targetControlX},${params.targetControlY} ${params.tx},${params.ty}`;
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
|
sourceX: finalSourceX,
|
||||||
// Calculate label position at midpoint of the bezier curve (t=0.5)
|
sourceY: finalSourceY,
|
||||||
const t = 0.5;
|
sourcePosition: finalSourcePosition,
|
||||||
const labelX =
|
targetX: finalTargetX,
|
||||||
Math.pow(1 - t, 3) * params.sx +
|
targetY: finalTargetY,
|
||||||
3 * Math.pow(1 - t, 2) * t * params.sourceControlX +
|
targetPosition: finalTargetPosition,
|
||||||
3 * (1 - t) * Math.pow(t, 2) * params.targetControlX +
|
});
|
||||||
Math.pow(t, 3) * params.tx;
|
|
||||||
const labelY =
|
|
||||||
Math.pow(1 - t, 3) * params.sy +
|
|
||||||
3 * Math.pow(1 - t, 2) * t * params.sourceControlY +
|
|
||||||
3 * (1 - t) * Math.pow(t, 2) * params.targetControlY +
|
|
||||||
Math.pow(t, 3) * params.ty;
|
|
||||||
|
|
||||||
return { edgePath, labelX, labelY };
|
|
||||||
}, [sourceNode, targetNode, sourceShape, targetShape, sourceX, sourceY, targetX, targetY]);
|
|
||||||
|
|
||||||
const { edgePath, labelX, labelY } = edgeParams;
|
|
||||||
|
|
||||||
// Check if this is an aggregated edge
|
// Check if this is an aggregated edge
|
||||||
const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount;
|
const isAggregated = !!(data as { aggregatedCount?: number })?.aggregatedCount;
|
||||||
|
|
@ -226,7 +222,7 @@ const CustomEdge = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data?.labels && data.labels.length > 0 && (
|
{data?.labels && data.labels.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 justify-center" style={{ maxWidth: '200px' }}>
|
<div className="flex flex-wrap gap-1 justify-center">
|
||||||
{data.labels.map((labelId) => {
|
{data.labels.map((labelId) => {
|
||||||
const labelConfig = labels.find((l) => l.id === labelId);
|
const labelConfig = labels.find((l) => l.id === labelId);
|
||||||
if (!labelConfig) return null;
|
if (!labelConfig) return null;
|
||||||
|
|
@ -235,6 +231,7 @@ const CustomEdge = ({
|
||||||
key={labelId}
|
key={labelId}
|
||||||
name={labelConfig.name}
|
name={labelConfig.name}
|
||||||
color={labelConfig.color}
|
color={labelConfig.color}
|
||||||
|
maxWidth="80px"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Handle, Position, NodeProps } from "@xyflow/react";
|
import { Handle, Position, NodeProps, useConnection } from "@xyflow/react";
|
||||||
import { useGraphStore } from "../../stores/graphStore";
|
import { useGraphStore } from "../../stores/graphStore";
|
||||||
import {
|
import {
|
||||||
getContrastColor,
|
getContrastColor,
|
||||||
|
|
@ -9,17 +9,14 @@ import { getIconComponent } from "../../utils/iconUtils";
|
||||||
import type { Actor } from "../../types";
|
import type { Actor } from "../../types";
|
||||||
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
|
import NodeShapeRenderer from "./Shapes/NodeShapeRenderer";
|
||||||
import LabelBadge from "../Common/LabelBadge";
|
import LabelBadge from "../Common/LabelBadge";
|
||||||
import {
|
import { useActiveFilters, nodeMatchesFilters } from "../../hooks/useActiveFilters";
|
||||||
useActiveFilters,
|
|
||||||
nodeMatchesFilters,
|
|
||||||
} from "../../hooks/useActiveFilters";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CustomNode - Represents an actor in the constellation graph
|
* CustomNode - Represents an actor in the constellation graph
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Visual representation with type-based coloring
|
* - Visual representation with type-based coloring
|
||||||
* - Easy-connect: whole node is connectable, edges auto-route to nearest border point
|
* - Connection handles (top, right, bottom, left)
|
||||||
* - Label display
|
* - Label display
|
||||||
* - Type badge
|
* - Type badge
|
||||||
*
|
*
|
||||||
|
|
@ -32,6 +29,10 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
// Get active filters based on mode (editing vs presentation)
|
// Get active filters based on mode (editing vs presentation)
|
||||||
const filters = useActiveFilters();
|
const filters = useActiveFilters();
|
||||||
|
|
||||||
|
// Check if any connection is being made (to show handles)
|
||||||
|
const connection = useConnection();
|
||||||
|
const isConnecting = !!connection.inProgress;
|
||||||
|
|
||||||
// Find the node type configuration
|
// Find the node type configuration
|
||||||
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
|
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
|
||||||
const nodeColor = nodeTypeConfig?.color || "#6b7280";
|
const nodeColor = nodeTypeConfig?.color || "#6b7280";
|
||||||
|
|
@ -45,6 +46,9 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
? adjustColorBrightness(nodeColor, -20)
|
? adjustColorBrightness(nodeColor, -20)
|
||||||
: nodeColor;
|
: nodeColor;
|
||||||
|
|
||||||
|
// Show handles when selected or when connecting
|
||||||
|
const showHandles = selected || isConnecting;
|
||||||
|
|
||||||
// Check if this node matches the filter criteria
|
// Check if this node matches the filter criteria
|
||||||
const isMatch = useMemo(() => {
|
const isMatch = useMemo(() => {
|
||||||
return nodeMatchesFilters(
|
return nodeMatchesFilters(
|
||||||
|
|
@ -53,7 +57,7 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
data.label || "",
|
data.label || "",
|
||||||
data.description || "",
|
data.description || "",
|
||||||
nodeLabel,
|
nodeLabel,
|
||||||
filters,
|
filters
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
data.type,
|
data.type,
|
||||||
|
|
@ -81,150 +85,64 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
opacity: nodeOpacity,
|
opacity: nodeOpacity,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Invisible handles positioned around edges - center remains free for dragging */}
|
{/* Connection handles - shown only when selected or connecting */}
|
||||||
{/* Bidirectional handles (source + target overlapping at each edge) */}
|
|
||||||
|
|
||||||
{/* Top edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
id="top-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "30px",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
id="top-source"
|
id="top"
|
||||||
isConnectable={true}
|
isConnectable={true}
|
||||||
|
isConnectableStart={true}
|
||||||
|
isConnectableEnd={true}
|
||||||
|
className="w-2 h-2 transition-opacity"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
background: adjustColorBrightness(nodeColor, -30),
|
||||||
height: "30px",
|
opacity: showHandles ? 1 : 0,
|
||||||
top: 0,
|
border: `1px solid ${textColor}`,
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Right edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Right}
|
|
||||||
id="right-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: "30px",
|
|
||||||
height: "100%",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="right-source"
|
id="right"
|
||||||
isConnectable={true}
|
isConnectable={true}
|
||||||
|
isConnectableStart={true}
|
||||||
|
isConnectableEnd={true}
|
||||||
|
className="w-2 h-2 transition-opacity"
|
||||||
style={{
|
style={{
|
||||||
width: "30px",
|
background: adjustColorBrightness(nodeColor, -30),
|
||||||
height: "100%",
|
opacity: showHandles ? 1 : 0,
|
||||||
top: 0,
|
border: `1px solid ${textColor}`,
|
||||||
right: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id="bottom-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "30px",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="bottom-source"
|
id="bottom"
|
||||||
isConnectable={true}
|
isConnectable={true}
|
||||||
|
isConnectableStart={true}
|
||||||
|
isConnectableEnd={true}
|
||||||
|
className="w-2 h-2 transition-opacity"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
background: adjustColorBrightness(nodeColor, -30),
|
||||||
height: "30px",
|
opacity: showHandles ? 1 : 0,
|
||||||
bottom: 0,
|
border: `1px solid ${textColor}`,
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Left edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
id="left-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: "30px",
|
|
||||||
height: "100%",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="left-source"
|
id="left"
|
||||||
isConnectable={true}
|
isConnectable={true}
|
||||||
|
isConnectableStart={true}
|
||||||
|
isConnectableEnd={true}
|
||||||
|
className="w-2 h-2 transition-opacity"
|
||||||
style={{
|
style={{
|
||||||
width: "30px",
|
background: adjustColorBrightness(nodeColor, -30),
|
||||||
height: "100%",
|
opacity: showHandles ? 1 : 0,
|
||||||
top: 0,
|
border: `1px solid ${textColor}`,
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
transform: "none",
|
|
||||||
cursor: "crosshair",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -266,7 +184,7 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{data.labels && data.labels.length > 0 && (
|
{data.labels && data.labels.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 justify-center mt-2" style={{ maxWidth: '200px' }}>
|
<div className="flex flex-wrap gap-1 justify-center mt-2">
|
||||||
{data.labels.map((labelId) => {
|
{data.labels.map((labelId) => {
|
||||||
const labelConfig = labels.find((l) => l.id === labelId);
|
const labelConfig = labels.find((l) => l.id === labelId);
|
||||||
if (!labelConfig) return null;
|
if (!labelConfig) return null;
|
||||||
|
|
@ -275,6 +193,7 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
|
||||||
key={labelId}
|
key={labelId}
|
||||||
name={labelConfig.name}
|
name={labelConfig.name}
|
||||||
color={labelConfig.color}
|
color={labelConfig.color}
|
||||||
|
maxWidth="80px"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -220,153 +220,6 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Invisible handles positioned around edges - center remains free for dragging */}
|
|
||||||
{/* Bidirectional handles (source + target overlapping at each edge) */}
|
|
||||||
|
|
||||||
{/* Top edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Top}
|
|
||||||
id="top-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '30px',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Top}
|
|
||||||
id="top-source"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '30px',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Right edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Right}
|
|
||||||
id="right-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '100%',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="right-source"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '100%',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Bottom edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id="bottom-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '30px',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id="bottom-source"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '30px',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Left edge handles */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
id="left-target"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '100%',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="left-source"
|
|
||||||
isConnectable={true}
|
|
||||||
style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '100%',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
opacity: 0,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
transform: 'none',
|
|
||||||
cursor: 'crosshair',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Background color overlay - uses group's custom color */}
|
{/* Background color overlay - uses group's custom color */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { ROUNDED_RECTANGLE_RADIUS } from '../../../constants';
|
|
||||||
|
|
||||||
interface RoundedRectangleShapeProps {
|
interface RoundedRectangleShapeProps {
|
||||||
color: string;
|
color: string;
|
||||||
|
|
@ -42,7 +41,7 @@ const RoundedRectangleShape = ({
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderColor: borderColor,
|
borderColor: borderColor,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
borderRadius: `${ROUNDED_RECTANGLE_RADIUS}px`,
|
borderRadius: '24px', // More rounded than regular rectangle
|
||||||
boxShadow: shadowStyle,
|
boxShadow: shadowStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,3 @@ export const MINIMIZED_GROUP_HEIGHT = 80;
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_ACTOR_WIDTH = 150;
|
export const DEFAULT_ACTOR_WIDTH = 150;
|
||||||
export const DEFAULT_ACTOR_HEIGHT = 80;
|
export const DEFAULT_ACTOR_HEIGHT = 80;
|
||||||
|
|
||||||
/**
|
|
||||||
* Rounded rectangle corner radius (in pixels)
|
|
||||||
*/
|
|
||||||
export const ROUNDED_RECTANGLE_RADIUS = 24;
|
|
||||||
|
|
|
||||||
|
|
@ -1256,69 +1256,6 @@ describe('graphStore', () => {
|
||||||
const loadedNode = state.nodes[0];
|
const loadedNode = state.nodes[0];
|
||||||
expect(loadedNode.parentId).toBe('group-1');
|
expect(loadedNode.parentId).toBe('group-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should migrate old 4-position handle references by removing handles', () => {
|
|
||||||
const { loadGraphState } = useGraphStore.getState();
|
|
||||||
|
|
||||||
// Create edges with old handle format
|
|
||||||
const edgeWithOldHandles: Relation = {
|
|
||||||
...createMockEdge('edge-1', 'node-1', 'node-2'),
|
|
||||||
sourceHandle: 'right',
|
|
||||||
targetHandle: 'left',
|
|
||||||
};
|
|
||||||
|
|
||||||
const edgeWithTopBottom: Relation = {
|
|
||||||
...createMockEdge('edge-2', 'node-1', 'node-2'),
|
|
||||||
sourceHandle: 'top',
|
|
||||||
targetHandle: 'bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
loadGraphState({
|
|
||||||
nodes: [createMockNode('node-1'), createMockNode('node-2')],
|
|
||||||
edges: [edgeWithOldHandles, edgeWithTopBottom],
|
|
||||||
groups: [],
|
|
||||||
nodeTypes: [],
|
|
||||||
edgeTypes: [],
|
|
||||||
labels: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useGraphStore.getState();
|
|
||||||
|
|
||||||
// Both edges should have handles removed (undefined) for floating edge pattern
|
|
||||||
expect(state.edges[0].sourceHandle).toBeUndefined();
|
|
||||||
expect(state.edges[0].targetHandle).toBeUndefined();
|
|
||||||
expect(state.edges[1].sourceHandle).toBeUndefined();
|
|
||||||
expect(state.edges[1].targetHandle).toBeUndefined();
|
|
||||||
|
|
||||||
// Other fields should be preserved
|
|
||||||
expect(state.edges[0].id).toBe('edge-1');
|
|
||||||
expect(state.edges[0].source).toBe('node-1');
|
|
||||||
expect(state.edges[0].target).toBe('node-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve undefined/null handles', () => {
|
|
||||||
const { loadGraphState } = useGraphStore.getState();
|
|
||||||
|
|
||||||
// Create edge without handles (new format)
|
|
||||||
const edgeWithoutHandles: Relation = {
|
|
||||||
...createMockEdge('edge-1', 'node-1', 'node-2'),
|
|
||||||
};
|
|
||||||
|
|
||||||
loadGraphState({
|
|
||||||
nodes: [createMockNode('node-1'), createMockNode('node-2')],
|
|
||||||
edges: [edgeWithoutHandles],
|
|
||||||
groups: [],
|
|
||||||
nodeTypes: [],
|
|
||||||
edgeTypes: [],
|
|
||||||
labels: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useGraphStore.getState();
|
|
||||||
|
|
||||||
// Handles should remain undefined
|
|
||||||
expect(state.edges[0].sourceHandle).toBeUndefined();
|
|
||||||
expect(state.edges[0].targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type {
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
|
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
|
||||||
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
||||||
import { migrateRelationHandlesArray } from '../utils/handleMigration';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️
|
* ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️
|
||||||
|
|
@ -642,13 +641,10 @@ export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
||||||
? migrateTangibleConfigs(data.tangibles)
|
? migrateTangibleConfigs(data.tangibles)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Apply handle migration for backward compatibility (remove old 4-position handles)
|
|
||||||
const migratedEdges = migrateRelationHandlesArray(data.edges);
|
|
||||||
|
|
||||||
// Atomic update: all state changes happen in a single set() call
|
// Atomic update: all state changes happen in a single set() call
|
||||||
set({
|
set({
|
||||||
nodes: sanitizedNodes,
|
nodes: sanitizedNodes,
|
||||||
edges: migratedEdges,
|
edges: data.edges,
|
||||||
groups: data.groups || [],
|
groups: data.groups || [],
|
||||||
nodeTypes: data.nodeTypes,
|
nodeTypes: data.nodeTypes,
|
||||||
edgeTypes: data.edgeTypes,
|
edgeTypes: data.edgeTypes,
|
||||||
|
|
|
||||||
|
|
@ -225,18 +225,15 @@ export function serializeActors(actors: Actor[]): SerializedActor[] {
|
||||||
* Serialize relations for storage (strip React Flow internals)
|
* Serialize relations for storage (strip React Flow internals)
|
||||||
*/
|
*/
|
||||||
export function serializeRelations(relations: Relation[]): SerializedRelation[] {
|
export function serializeRelations(relations: Relation[]): SerializedRelation[] {
|
||||||
return relations.map(relation => {
|
return relations.map(relation => ({
|
||||||
// Omit handle fields entirely - edges use floating calculations
|
|
||||||
// The handle IDs (like "top-source", "right-target") are only for defining
|
|
||||||
// clickable areas and should not be persisted
|
|
||||||
return {
|
|
||||||
id: relation.id,
|
id: relation.id,
|
||||||
source: relation.source,
|
source: relation.source,
|
||||||
target: relation.target,
|
target: relation.target,
|
||||||
type: relation.type,
|
type: relation.type,
|
||||||
data: relation.data,
|
data: relation.data,
|
||||||
};
|
sourceHandle: relation.sourceHandle,
|
||||||
});
|
targetHandle: relation.targetHandle,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useGraphStore } from '../graphStore';
|
||||||
import { useTimelineStore } from '../timelineStore';
|
import { useTimelineStore } from '../timelineStore';
|
||||||
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types';
|
import type { Actor, Relation, Group, NodeTypeConfig, EdgeTypeConfig, LabelConfig, TangibleConfig } from '../../types';
|
||||||
import { getCurrentGraphFromDocument } from './documentUtils';
|
import { getCurrentGraphFromDocument } from './documentUtils';
|
||||||
import { migrateRelationHandlesArray } from '../../utils/handleMigration';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useActiveDocument Hook
|
* useActiveDocument Hook
|
||||||
|
|
@ -98,11 +97,8 @@ export function useActiveDocument() {
|
||||||
isLoadingRef.current = true;
|
isLoadingRef.current = true;
|
||||||
lastLoadedDocIdRef.current = activeDocumentId;
|
lastLoadedDocIdRef.current = activeDocumentId;
|
||||||
|
|
||||||
// Apply handle migration for backward compatibility (remove old 4-position handles)
|
|
||||||
const migratedEdges = migrateRelationHandlesArray(currentGraph.edges);
|
|
||||||
|
|
||||||
setNodes(currentGraph.nodes as never[]);
|
setNodes(currentGraph.nodes as never[]);
|
||||||
setEdges(migratedEdges as never[]);
|
setEdges(currentGraph.edges as never[]);
|
||||||
setGroups(currentGraph.groups as never[]);
|
setGroups(currentGraph.groups as never[]);
|
||||||
setNodeTypes(currentGraph.nodeTypes as never[]);
|
setNodeTypes(currentGraph.nodeTypes as never[]);
|
||||||
setEdgeTypes(currentGraph.edgeTypes as never[]);
|
setEdgeTypes(currentGraph.edgeTypes as never[]);
|
||||||
|
|
@ -113,7 +109,7 @@ export function useActiveDocument() {
|
||||||
lastSyncedStateRef.current = {
|
lastSyncedStateRef.current = {
|
||||||
documentId: activeDocumentId,
|
documentId: activeDocumentId,
|
||||||
nodes: currentGraph.nodes as Actor[],
|
nodes: currentGraph.nodes as Actor[],
|
||||||
edges: migratedEdges as Relation[],
|
edges: currentGraph.edges as Relation[],
|
||||||
groups: currentGraph.groups as Group[],
|
groups: currentGraph.groups as Group[],
|
||||||
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
|
nodeTypes: currentGraph.nodeTypes as NodeTypeConfig[],
|
||||||
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],
|
edgeTypes: currentGraph.edgeTypes as EdgeTypeConfig[],
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import { Cite } from '@citation-js/core';
|
||||||
import type { CSLReference } from '../types/bibliography';
|
import type { CSLReference } from '../types/bibliography';
|
||||||
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
|
import { needsStorageCleanup, cleanupAllStorage } from '../utils/cleanupStorage';
|
||||||
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
||||||
import { migrateRelationHandlesArray } from '../utils/handleMigration';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workspace Store
|
* Workspace Store
|
||||||
|
|
@ -319,16 +318,6 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply handle migration to all timeline states for backward compatibility
|
|
||||||
if (doc.timeline && doc.timeline.states) {
|
|
||||||
Object.keys(doc.timeline.states).forEach((stateId) => {
|
|
||||||
const state = doc.timeline.states[stateId];
|
|
||||||
if (state && state.graph && state.graph.edges) {
|
|
||||||
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load timeline if it exists
|
// Load timeline if it exists
|
||||||
if (doc.timeline) {
|
if (doc.timeline) {
|
||||||
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
|
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
|
||||||
|
|
@ -637,16 +626,6 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
|
importedDoc.tangibles = migrateTangibleConfigs(importedDoc.tangibles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply handle migration to all timeline states for backward compatibility
|
|
||||||
if (importedDoc.timeline && importedDoc.timeline.states) {
|
|
||||||
Object.keys(importedDoc.timeline.states).forEach((stateId) => {
|
|
||||||
const state = importedDoc.timeline.states[stateId];
|
|
||||||
if (state && state.graph && state.graph.edges) {
|
|
||||||
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata: DocumentMetadata = {
|
const metadata: DocumentMetadata = {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
title: importedDoc.metadata.title || 'Imported Analysis',
|
title: importedDoc.metadata.title || 'Imported Analysis',
|
||||||
|
|
@ -959,16 +938,6 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
doc.tangibles = migrateTangibleConfigs(doc.tangibles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply handle migration to all timeline states for backward compatibility
|
|
||||||
if (doc.timeline && doc.timeline.states) {
|
|
||||||
Object.keys(doc.timeline.states).forEach((stateId) => {
|
|
||||||
const state = doc.timeline.states[stateId];
|
|
||||||
if (state && state.graph && state.graph.edges) {
|
|
||||||
state.graph.edges = migrateRelationHandlesArray(state.graph.edges);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDocumentToStorage(docId, doc);
|
saveDocumentToStorage(docId, doc);
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
|
|
||||||
|
|
@ -1,291 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { migrateRelationHandles, migrateRelationHandlesArray } from '../handleMigration';
|
|
||||||
import type { SerializedRelation } from '../../stores/persistence/types';
|
|
||||||
|
|
||||||
describe('handleMigration', () => {
|
|
||||||
describe('migrateRelationHandles', () => {
|
|
||||||
it('should migrate old "top" source handle by removing handles', () => {
|
|
||||||
const oldFormat: SerializedRelation = {
|
|
||||||
id: 'edge-1',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'top',
|
|
||||||
targetHandle: 'bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(oldFormat);
|
|
||||||
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
expect(result.id).toBe('edge-1');
|
|
||||||
expect(result.source).toBe('node-1');
|
|
||||||
expect(result.target).toBe('node-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate old "right" source handle', () => {
|
|
||||||
const oldFormat: SerializedRelation = {
|
|
||||||
id: 'edge-2',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'right',
|
|
||||||
targetHandle: 'left',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(oldFormat);
|
|
||||||
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate old "bottom" source handle', () => {
|
|
||||||
const oldFormat: SerializedRelation = {
|
|
||||||
id: 'edge-3',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'bottom',
|
|
||||||
targetHandle: 'top',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(oldFormat);
|
|
||||||
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate old "left" source handle', () => {
|
|
||||||
const oldFormat: SerializedRelation = {
|
|
||||||
id: 'edge-4',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'left',
|
|
||||||
targetHandle: 'right',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(oldFormat);
|
|
||||||
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate when only source handle is old format', () => {
|
|
||||||
const mixed: SerializedRelation = {
|
|
||||||
id: 'edge-5',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'top',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(mixed);
|
|
||||||
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should migrate when only target handle is old format', () => {
|
|
||||||
const mixed: SerializedRelation = {
|
|
||||||
id: 'edge-6',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
targetHandle: 'bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(mixed);
|
|
||||||
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should leave relations with undefined handles unchanged', () => {
|
|
||||||
const newFormat: SerializedRelation = {
|
|
||||||
id: 'edge-7',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(newFormat);
|
|
||||||
|
|
||||||
expect(result).toEqual(newFormat);
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should leave relations with null handles unchanged', () => {
|
|
||||||
const newFormat: SerializedRelation = {
|
|
||||||
id: 'edge-8',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: null,
|
|
||||||
targetHandle: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(newFormat);
|
|
||||||
|
|
||||||
expect(result).toEqual(newFormat);
|
|
||||||
expect(result.sourceHandle).toBeNull();
|
|
||||||
expect(result.targetHandle).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should leave relations with custom handle IDs unchanged', () => {
|
|
||||||
const customHandles: SerializedRelation = {
|
|
||||||
id: 'edge-9',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'custom-source-1',
|
|
||||||
targetHandle: 'custom-target-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(customHandles);
|
|
||||||
|
|
||||||
expect(result).toEqual(customHandles);
|
|
||||||
expect(result.sourceHandle).toBe('custom-source-1');
|
|
||||||
expect(result.targetHandle).toBe('custom-target-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve type and data fields', () => {
|
|
||||||
const withData: SerializedRelation = {
|
|
||||||
id: 'edge-10',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'right',
|
|
||||||
targetHandle: 'left',
|
|
||||||
type: 'custom-edge',
|
|
||||||
data: {
|
|
||||||
label: 'Test Edge',
|
|
||||||
description: 'Test description',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(withData);
|
|
||||||
|
|
||||||
expect(result.type).toBe('custom-edge');
|
|
||||||
expect(result.data).toEqual({
|
|
||||||
label: 'Test Edge',
|
|
||||||
description: 'Test description',
|
|
||||||
});
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be idempotent (running twice produces same result)', () => {
|
|
||||||
const oldFormat: SerializedRelation = {
|
|
||||||
id: 'edge-11',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'top',
|
|
||||||
targetHandle: 'bottom',
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstMigration = migrateRelationHandles(oldFormat);
|
|
||||||
const secondMigration = migrateRelationHandles(firstMigration);
|
|
||||||
|
|
||||||
expect(firstMigration).toEqual(secondMigration);
|
|
||||||
expect(secondMigration.sourceHandle).toBeUndefined();
|
|
||||||
expect(secondMigration.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle mixed old and custom handles', () => {
|
|
||||||
const mixed: SerializedRelation = {
|
|
||||||
id: 'edge-12',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'top', // Old format
|
|
||||||
targetHandle: 'custom-target', // Custom handle
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = migrateRelationHandles(mixed);
|
|
||||||
|
|
||||||
// Should migrate because sourceHandle is old format (removes both handles)
|
|
||||||
expect(result.sourceHandle).toBeUndefined();
|
|
||||||
expect(result.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('migrateRelationHandlesArray', () => {
|
|
||||||
it('should migrate an array of relations', () => {
|
|
||||||
const relations: SerializedRelation[] = [
|
|
||||||
{
|
|
||||||
id: 'edge-1',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'right',
|
|
||||||
targetHandle: 'left',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edge-2',
|
|
||||||
source: 'node-2',
|
|
||||||
target: 'node-3',
|
|
||||||
// Already new format (undefined)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edge-3',
|
|
||||||
source: 'node-3',
|
|
||||||
target: 'node-4',
|
|
||||||
sourceHandle: 'custom-handle',
|
|
||||||
targetHandle: 'custom-target',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = migrateRelationHandlesArray(relations);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(3);
|
|
||||||
// First should be migrated (old format removed)
|
|
||||||
expect(result[0].sourceHandle).toBeUndefined();
|
|
||||||
expect(result[0].targetHandle).toBeUndefined();
|
|
||||||
// Second should remain unchanged
|
|
||||||
expect(result[1]).toEqual(relations[1]);
|
|
||||||
// Third should remain unchanged (custom handles)
|
|
||||||
expect(result[2]).toEqual(relations[2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty array', () => {
|
|
||||||
const result = migrateRelationHandlesArray([]);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle array with all old format relations', () => {
|
|
||||||
const relations: SerializedRelation[] = [
|
|
||||||
{
|
|
||||||
id: 'edge-1',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
sourceHandle: 'top',
|
|
||||||
targetHandle: 'bottom',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edge-2',
|
|
||||||
source: 'node-2',
|
|
||||||
target: 'node-3',
|
|
||||||
sourceHandle: 'right',
|
|
||||||
targetHandle: 'left',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = migrateRelationHandlesArray(relations);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
result.forEach((relation) => {
|
|
||||||
expect(relation.sourceHandle).toBeUndefined();
|
|
||||||
expect(relation.targetHandle).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle array with all new format relations', () => {
|
|
||||||
const relations: SerializedRelation[] = [
|
|
||||||
{
|
|
||||||
id: 'edge-1',
|
|
||||||
source: 'node-1',
|
|
||||||
target: 'node-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edge-2',
|
|
||||||
source: 'node-2',
|
|
||||||
target: 'node-3',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = migrateRelationHandlesArray(relations);
|
|
||||||
|
|
||||||
expect(result).toEqual(relations);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Relation, RelationData, NodeShape } from '../types';
|
import type { Relation, RelationData } from '../types';
|
||||||
import type { Node } from '@xyflow/react';
|
import type { Node } from '@xyflow/react';
|
||||||
import { ROUNDED_RECTANGLE_RADIUS } from '../constants';
|
import { Position } from '@xyflow/react';
|
||||||
|
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a unique ID for edges
|
* Generates a unique ID for edges
|
||||||
|
|
@ -10,357 +11,32 @@ export const generateEdgeId = (source: string, target: string): string => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate intersection point with a circle
|
* Calculate the intersection point between a line and a rectangle
|
||||||
* Returns both the intersection point and the normal vector (outward direction)
|
* Used for floating edges to connect at the closest point on the node
|
||||||
*/
|
*/
|
||||||
function getCircleIntersection(
|
function getNodeIntersection(intersectionNode: Node, targetNode: Node) {
|
||||||
centerX: number,
|
const {
|
||||||
centerY: number,
|
width: intersectionNodeWidth,
|
||||||
radius: number,
|
height: intersectionNodeHeight,
|
||||||
targetX: number,
|
position: intersectionNodePosition,
|
||||||
targetY: number,
|
} = intersectionNode;
|
||||||
offset: number = 3
|
const targetPosition = targetNode.position;
|
||||||
): { x: number; y: number; angle: number } {
|
|
||||||
// Guard against zero radius
|
// Use fallback dimensions if width/height are not set (e.g., for groups without measured dimensions)
|
||||||
if (radius === 0) {
|
const w = (intersectionNodeWidth ?? MINIMIZED_GROUP_WIDTH) / 2;
|
||||||
return { x: centerX + offset, y: centerY, angle: 0 };
|
const h = (intersectionNodeHeight ?? MINIMIZED_GROUP_HEIGHT) / 2;
|
||||||
|
|
||||||
|
const x2 = intersectionNodePosition.x + w;
|
||||||
|
const y2 = intersectionNodePosition.y + h;
|
||||||
|
const x1 = targetPosition.x + (targetNode.width ?? MINIMIZED_GROUP_WIDTH) / 2;
|
||||||
|
const y1 = targetPosition.y + (targetNode.height ?? MINIMIZED_GROUP_HEIGHT) / 2;
|
||||||
|
|
||||||
|
// Guard against division by zero
|
||||||
|
if (w === 0 || h === 0) {
|
||||||
|
// If node has no dimensions, return center point
|
||||||
|
return { x: x2, y: y2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const dx = targetX - centerX;
|
|
||||||
const dy = targetY - centerY;
|
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (distance === 0) {
|
|
||||||
return { x: centerX + radius + offset, y: centerY, angle: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalized direction vector
|
|
||||||
const nx = dx / distance;
|
|
||||||
const ny = dy / distance;
|
|
||||||
|
|
||||||
// Point on circle border in direction of target, with offset
|
|
||||||
return {
|
|
||||||
x: centerX + nx * (radius + offset),
|
|
||||||
y: centerY + ny * (radius + offset),
|
|
||||||
angle: Math.atan2(ny, nx), // Normal angle pointing outward
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate intersection point with an ellipse
|
|
||||||
* Returns both the intersection point and the normal vector (outward direction)
|
|
||||||
*/
|
|
||||||
function getEllipseIntersection(
|
|
||||||
centerX: number,
|
|
||||||
centerY: number,
|
|
||||||
radiusX: number,
|
|
||||||
radiusY: number,
|
|
||||||
targetX: number,
|
|
||||||
targetY: number,
|
|
||||||
offset: number = 3
|
|
||||||
): { x: number; y: number; angle: number } {
|
|
||||||
// Guard against zero radii
|
|
||||||
if (radiusX === 0 || radiusY === 0) {
|
|
||||||
return { x: centerX + offset, y: centerY, angle: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dx = targetX - centerX;
|
|
||||||
const dy = targetY - centerY;
|
|
||||||
|
|
||||||
if (dx === 0 && dy === 0) {
|
|
||||||
return { x: centerX + radiusX + offset, y: centerY, angle: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Angle to target point
|
|
||||||
const angle = Math.atan2(dy, dx);
|
|
||||||
|
|
||||||
// Point on ellipse border
|
|
||||||
const px = radiusX * Math.cos(angle);
|
|
||||||
const py = radiusY * Math.sin(angle);
|
|
||||||
|
|
||||||
// Normal vector at this point on the ellipse
|
|
||||||
// For ellipse, the gradient at point (px, py) is (px/radiusX^2, py/radiusY^2)
|
|
||||||
const normalX = px / (radiusX * radiusX);
|
|
||||||
const normalY = py / (radiusY * radiusY);
|
|
||||||
const normalLength = Math.sqrt(normalX * normalX + normalY * normalY);
|
|
||||||
const nx = normalX / normalLength;
|
|
||||||
const ny = normalY / normalLength;
|
|
||||||
|
|
||||||
// Normal angle
|
|
||||||
const normalAngle = Math.atan2(ny, nx);
|
|
||||||
|
|
||||||
// Offset point slightly outside ellipse border
|
|
||||||
return {
|
|
||||||
x: centerX + px + nx * offset,
|
|
||||||
y: centerY + py + ny * offset,
|
|
||||||
angle: normalAngle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate intersection point with a pill (stadium) shape
|
|
||||||
* A pill has rounded caps on the ends and straight sides
|
|
||||||
*/
|
|
||||||
function getPillIntersection(
|
|
||||||
centerX: number,
|
|
||||||
centerY: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
targetX: number,
|
|
||||||
targetY: number,
|
|
||||||
offset: number = 4
|
|
||||||
): { x: number; y: number; angle: number } {
|
|
||||||
const dx = targetX - centerX;
|
|
||||||
const dy = targetY - centerY;
|
|
||||||
|
|
||||||
if (dx === 0 && dy === 0) {
|
|
||||||
return { x: centerX + width / 2 + offset, y: centerY, angle: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine pill orientation and cap radius
|
|
||||||
const isHorizontal = width >= height;
|
|
||||||
const capRadius = isHorizontal ? height / 2 : width / 2;
|
|
||||||
|
|
||||||
if (isHorizontal) {
|
|
||||||
// Horizontal pill: semicircular caps on left and right
|
|
||||||
const leftCapX = centerX - (width / 2 - capRadius);
|
|
||||||
const rightCapX = centerX + (width / 2 - capRadius);
|
|
||||||
|
|
||||||
// Check if pointing toward left cap
|
|
||||||
if (dx < 0 && Math.abs(dx) >= Math.abs(dy)) {
|
|
||||||
return getCircleIntersection(leftCapX, centerY, capRadius, targetX, targetY, offset);
|
|
||||||
}
|
|
||||||
// Check if pointing toward right cap
|
|
||||||
else if (dx > 0 && Math.abs(dx) >= Math.abs(dy)) {
|
|
||||||
return getCircleIntersection(rightCapX, centerY, capRadius, targetX, targetY, offset);
|
|
||||||
}
|
|
||||||
// Otherwise it's pointing toward top or bottom straight edge
|
|
||||||
else {
|
|
||||||
const side = dy < 0 ? -1 : 1;
|
|
||||||
const intersectY = centerY + side * capRadius;
|
|
||||||
|
|
||||||
// Calculate x position where line from target to center intersects the horizontal edge
|
|
||||||
// Line equation: (y - centerY) / (x - centerX) = dy / dx
|
|
||||||
// Solving for x when y = intersectY: x = centerX + dx * (intersectY - centerY) / dy
|
|
||||||
let intersectX = Math.abs(dy) > 0.001
|
|
||||||
? centerX + dx * (intersectY - centerY) / dy
|
|
||||||
: centerX;
|
|
||||||
|
|
||||||
// Clamp intersection to the straight horizontal segment between the caps
|
|
||||||
intersectX = Math.min(Math.max(intersectX, leftCapX), rightCapX);
|
|
||||||
|
|
||||||
const normalAngle = side < 0 ? -Math.PI / 2 : Math.PI / 2;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: intersectX,
|
|
||||||
y: intersectY + side * offset,
|
|
||||||
angle: normalAngle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Vertical pill: semicircular caps on top and bottom
|
|
||||||
const topCapY = centerY - (height / 2 - capRadius);
|
|
||||||
const bottomCapY = centerY + (height / 2 - capRadius);
|
|
||||||
|
|
||||||
// Check if pointing toward top cap
|
|
||||||
if (dy < 0 && Math.abs(dy) >= Math.abs(dx)) {
|
|
||||||
return getCircleIntersection(centerX, topCapY, capRadius, targetX, targetY, offset);
|
|
||||||
}
|
|
||||||
// Check if pointing toward bottom cap
|
|
||||||
else if (dy > 0 && Math.abs(dy) >= Math.abs(dx)) {
|
|
||||||
return getCircleIntersection(centerX, bottomCapY, capRadius, targetX, targetY, offset);
|
|
||||||
}
|
|
||||||
// Otherwise it's pointing toward left or right straight edge
|
|
||||||
else {
|
|
||||||
const side = dx < 0 ? -1 : 1;
|
|
||||||
const intersectX = centerX + side * capRadius;
|
|
||||||
|
|
||||||
// Calculate y position where line from target to center intersects the vertical edge
|
|
||||||
// Line equation: (y - centerY) / (x - centerX) = dy / dx
|
|
||||||
// Solving for y when x = intersectX: y = centerY + dy * (intersectX - centerX) / dx
|
|
||||||
let intersectY = Math.abs(dx) > 0.001
|
|
||||||
? centerY + dy * (intersectX - centerX) / dx
|
|
||||||
: centerY;
|
|
||||||
|
|
||||||
// Clamp intersection to the straight vertical segment between the caps
|
|
||||||
intersectY = Math.min(Math.max(intersectY, topCapY), bottomCapY);
|
|
||||||
|
|
||||||
const normalAngle = side < 0 ? Math.PI : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: intersectX + side * offset,
|
|
||||||
y: intersectY,
|
|
||||||
angle: normalAngle,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate intersection point with a rounded rectangle
|
|
||||||
* Handles corners as circular arcs with specified radius
|
|
||||||
*/
|
|
||||||
function getRoundedRectangleIntersection(
|
|
||||||
centerX: number,
|
|
||||||
centerY: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
targetX: number,
|
|
||||||
targetY: number,
|
|
||||||
cornerRadius: number = ROUNDED_RECTANGLE_RADIUS,
|
|
||||||
offset: number = 2
|
|
||||||
): { x: number; y: number; angle: number } {
|
|
||||||
const w = width / 2;
|
|
||||||
const h = height / 2;
|
|
||||||
|
|
||||||
// Calculate basic rectangle intersection first
|
|
||||||
const dx = targetX - centerX;
|
|
||||||
const dy = targetY - centerY;
|
|
||||||
|
|
||||||
const xx1 = dx / (2 * w) - dy / (2 * h);
|
|
||||||
const yy1 = dx / (2 * w) + dy / (2 * h);
|
|
||||||
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
|
|
||||||
const xx3 = a * xx1;
|
|
||||||
const yy3 = a * yy1;
|
|
||||||
const x = w * (xx3 + yy3) + centerX;
|
|
||||||
const y = h * (-xx3 + yy3) + centerY;
|
|
||||||
|
|
||||||
// Determine which edge the intersection is on
|
|
||||||
const leftEdge = centerX - w;
|
|
||||||
const rightEdge = centerX + w;
|
|
||||||
const topEdge = centerY - h;
|
|
||||||
const bottomEdge = centerY + h;
|
|
||||||
|
|
||||||
// Check if intersection is near a corner (within corner radius distance from corner)
|
|
||||||
const isNearTopLeft = x < leftEdge + cornerRadius && y < topEdge + cornerRadius;
|
|
||||||
const isNearTopRight = x > rightEdge - cornerRadius && y < topEdge + cornerRadius;
|
|
||||||
const isNearBottomLeft = x < leftEdge + cornerRadius && y > bottomEdge - cornerRadius;
|
|
||||||
const isNearBottomRight = x > rightEdge - cornerRadius && y > bottomEdge - cornerRadius;
|
|
||||||
|
|
||||||
if (isNearTopLeft) {
|
|
||||||
// Top-left corner - circular arc
|
|
||||||
const cornerCenterX = leftEdge + cornerRadius;
|
|
||||||
const cornerCenterY = topEdge + cornerRadius;
|
|
||||||
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
|
|
||||||
} else if (isNearTopRight) {
|
|
||||||
// Top-right corner - circular arc
|
|
||||||
const cornerCenterX = rightEdge - cornerRadius;
|
|
||||||
const cornerCenterY = topEdge + cornerRadius;
|
|
||||||
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
|
|
||||||
} else if (isNearBottomLeft) {
|
|
||||||
// Bottom-left corner - circular arc
|
|
||||||
const cornerCenterX = leftEdge + cornerRadius;
|
|
||||||
const cornerCenterY = bottomEdge - cornerRadius;
|
|
||||||
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
|
|
||||||
} else if (isNearBottomRight) {
|
|
||||||
// Bottom-right corner - circular arc
|
|
||||||
const cornerCenterX = rightEdge - cornerRadius;
|
|
||||||
const cornerCenterY = bottomEdge - cornerRadius;
|
|
||||||
return getCircleIntersection(cornerCenterX, cornerCenterY, cornerRadius, targetX, targetY, offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Straight edge - use rectangle calculation
|
|
||||||
const angle = Math.atan2(y - centerY, x - centerX);
|
|
||||||
const offsetX = x + Math.cos(angle) * offset;
|
|
||||||
const offsetY = y + Math.sin(angle) * offset;
|
|
||||||
|
|
||||||
return { x: offsetX, y: offsetY, angle };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the intersection point between a line and a node shape
|
|
||||||
* Returns the intersection point and the normal angle at that point
|
|
||||||
*/
|
|
||||||
function getNodeIntersection(
|
|
||||||
intersectionNode: Node,
|
|
||||||
targetNode: Node,
|
|
||||||
intersectionShape: NodeShape = 'rectangle',
|
|
||||||
offset: number = 2
|
|
||||||
): { x: number; y: number; angle: number } {
|
|
||||||
// Use positionAbsolute for correct positioning of nodes inside groups
|
|
||||||
// positionAbsolute accounts for parent group offset, while position is relative
|
|
||||||
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
|
|
||||||
const intersectionNodePosition = intersectionNode.internals?.positionAbsolute ?? intersectionNode.position;
|
|
||||||
// @ts-expect-error - internals.positionAbsolute exists at runtime but not in public types
|
|
||||||
const targetPosition = targetNode.internals?.positionAbsolute ?? targetNode.position;
|
|
||||||
|
|
||||||
// Use measured dimensions from React Flow (stored in node.measured)
|
|
||||||
// If undefined, node hasn't been measured yet - return center
|
|
||||||
const intersectionNodeWidth = intersectionNode.measured?.width ?? intersectionNode.width;
|
|
||||||
const intersectionNodeHeight = intersectionNode.measured?.height ?? intersectionNode.height;
|
|
||||||
const targetNodeWidth = targetNode.measured?.width ?? targetNode.width;
|
|
||||||
const targetNodeHeight = targetNode.measured?.height ?? targetNode.height;
|
|
||||||
|
|
||||||
if (!intersectionNodeWidth || !intersectionNodeHeight || !targetNodeWidth || !targetNodeHeight) {
|
|
||||||
const centerX = intersectionNodePosition.x + (intersectionNodeWidth ?? 0) / 2;
|
|
||||||
const centerY = intersectionNodePosition.y + (intersectionNodeHeight ?? 0) / 2;
|
|
||||||
return { x: centerX, y: centerY, angle: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate centers
|
|
||||||
const intersectionCenterX = intersectionNodePosition.x + intersectionNodeWidth / 2;
|
|
||||||
const intersectionCenterY = intersectionNodePosition.y + intersectionNodeHeight / 2;
|
|
||||||
const targetCenterX = targetPosition.x + targetNodeWidth / 2;
|
|
||||||
const targetCenterY = targetPosition.y + targetNodeHeight / 2;
|
|
||||||
|
|
||||||
// Handle different shapes
|
|
||||||
if (intersectionShape === 'circle') {
|
|
||||||
// Use minimum dimension as radius for perfect circle
|
|
||||||
const radius = Math.min(intersectionNodeWidth, intersectionNodeHeight) / 2;
|
|
||||||
return getCircleIntersection(
|
|
||||||
intersectionCenterX,
|
|
||||||
intersectionCenterY,
|
|
||||||
radius,
|
|
||||||
targetCenterX,
|
|
||||||
targetCenterY,
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
} else if (intersectionShape === 'pill') {
|
|
||||||
// Pill shape has rounded caps and straight sides
|
|
||||||
return getPillIntersection(
|
|
||||||
intersectionCenterX,
|
|
||||||
intersectionCenterY,
|
|
||||||
intersectionNodeWidth,
|
|
||||||
intersectionNodeHeight,
|
|
||||||
targetCenterX,
|
|
||||||
targetCenterY,
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
} else if (intersectionShape === 'ellipse') {
|
|
||||||
// For ellipse, use width/height as radii
|
|
||||||
const radiusX = intersectionNodeWidth / 2;
|
|
||||||
const radiusY = intersectionNodeHeight / 2;
|
|
||||||
return getEllipseIntersection(
|
|
||||||
intersectionCenterX,
|
|
||||||
intersectionCenterY,
|
|
||||||
radiusX,
|
|
||||||
radiusY,
|
|
||||||
targetCenterX,
|
|
||||||
targetCenterY,
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
} else if (intersectionShape === 'roundedRectangle') {
|
|
||||||
// Rounded rectangle with circular corner arcs
|
|
||||||
return getRoundedRectangleIntersection(
|
|
||||||
intersectionCenterX,
|
|
||||||
intersectionCenterY,
|
|
||||||
intersectionNodeWidth,
|
|
||||||
intersectionNodeHeight,
|
|
||||||
targetCenterX,
|
|
||||||
targetCenterY,
|
|
||||||
ROUNDED_RECTANGLE_RADIUS,
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Rectangle uses the original algorithm with offset
|
|
||||||
const w = intersectionNodeWidth / 2;
|
|
||||||
const h = intersectionNodeHeight / 2;
|
|
||||||
|
|
||||||
const x2 = intersectionCenterX;
|
|
||||||
const y2 = intersectionCenterY;
|
|
||||||
const x1 = targetCenterX;
|
|
||||||
const y1 = targetCenterY;
|
|
||||||
|
|
||||||
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
|
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
|
||||||
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
|
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
|
||||||
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
|
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
|
||||||
|
|
@ -369,57 +45,57 @@ function getNodeIntersection(
|
||||||
const x = w * (xx3 + yy3) + x2;
|
const x = w * (xx3 + yy3) + x2;
|
||||||
const y = h * (-xx3 + yy3) + y2;
|
const y = h * (-xx3 + yy3) + y2;
|
||||||
|
|
||||||
// Calculate normal angle for rectangle edges
|
return { x, y };
|
||||||
const dx = x - x2;
|
|
||||||
const dy = y - y2;
|
|
||||||
const angle = Math.atan2(dy, dx);
|
|
||||||
|
|
||||||
// Apply offset
|
|
||||||
const offsetX = x + Math.cos(angle) * offset;
|
|
||||||
const offsetY = y + Math.sin(angle) * offset;
|
|
||||||
|
|
||||||
return { x: offsetX, y: offsetY, angle };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the position (top, right, bottom, left) of the handle based on the intersection point
|
||||||
|
*/
|
||||||
|
function getEdgePosition(node: Node, intersectionPoint: { x: number; y: number }) {
|
||||||
|
const n = { ...node.position, ...node };
|
||||||
|
const nx = Math.round(n.x);
|
||||||
|
const ny = Math.round(n.y);
|
||||||
|
const px = Math.round(intersectionPoint.x);
|
||||||
|
const py = Math.round(intersectionPoint.y);
|
||||||
|
|
||||||
|
// Use fallback dimensions if not set (same as getNodeIntersection)
|
||||||
|
const width = node.width ?? MINIMIZED_GROUP_WIDTH;
|
||||||
|
const height = node.height ?? MINIMIZED_GROUP_HEIGHT;
|
||||||
|
|
||||||
|
if (px <= nx + 1) {
|
||||||
|
return Position.Left;
|
||||||
|
}
|
||||||
|
if (px >= nx + width - 1) {
|
||||||
|
return Position.Right;
|
||||||
|
}
|
||||||
|
if (py <= ny + 1) {
|
||||||
|
return Position.Top;
|
||||||
|
}
|
||||||
|
if (py >= ny + height - 1) {
|
||||||
|
return Position.Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Position.Top;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the parameters for a floating edge between two nodes
|
* Calculate the parameters for a floating edge between two nodes
|
||||||
* Returns source/target coordinates with angles for smooth bezier curves
|
* Returns source/target coordinates and positions for dynamic edge routing
|
||||||
*/
|
*/
|
||||||
export function getFloatingEdgeParams(
|
export function getFloatingEdgeParams(sourceNode: Node, targetNode: Node) {
|
||||||
sourceNode: Node,
|
const sourceIntersectionPoint = getNodeIntersection(sourceNode, targetNode);
|
||||||
targetNode: Node,
|
const targetIntersectionPoint = getNodeIntersection(targetNode, sourceNode);
|
||||||
sourceShape: NodeShape = 'rectangle',
|
|
||||||
targetShape: NodeShape = 'rectangle'
|
|
||||||
) {
|
|
||||||
const sourceIntersection = getNodeIntersection(sourceNode, targetNode, sourceShape);
|
|
||||||
const targetIntersection = getNodeIntersection(targetNode, sourceNode, targetShape);
|
|
||||||
|
|
||||||
// Calculate control point distance based on distance between nodes
|
const sourcePos = getEdgePosition(sourceNode, sourceIntersectionPoint);
|
||||||
const distance = Math.sqrt(
|
const targetPos = getEdgePosition(targetNode, targetIntersectionPoint);
|
||||||
Math.pow(targetIntersection.x - sourceIntersection.x, 2) +
|
|
||||||
Math.pow(targetIntersection.y - sourceIntersection.y, 2)
|
|
||||||
);
|
|
||||||
// Use 40% of distance for more pronounced curves, with reasonable limits
|
|
||||||
const controlPointDistance = Math.min(Math.max(distance * 0.4, 40), 150);
|
|
||||||
|
|
||||||
// Calculate control points using the normal angles
|
|
||||||
const sourceControlX = sourceIntersection.x + Math.cos(sourceIntersection.angle) * controlPointDistance;
|
|
||||||
const sourceControlY = sourceIntersection.y + Math.sin(sourceIntersection.angle) * controlPointDistance;
|
|
||||||
const targetControlX = targetIntersection.x + Math.cos(targetIntersection.angle) * controlPointDistance;
|
|
||||||
const targetControlY = targetIntersection.y + Math.sin(targetIntersection.angle) * controlPointDistance;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sx: sourceIntersection.x,
|
sx: sourceIntersectionPoint.x,
|
||||||
sy: sourceIntersection.y,
|
sy: sourceIntersectionPoint.y,
|
||||||
tx: targetIntersection.x,
|
tx: targetIntersectionPoint.x,
|
||||||
ty: targetIntersection.y,
|
ty: targetIntersectionPoint.y,
|
||||||
sourceControlX,
|
sourcePos,
|
||||||
sourceControlY,
|
targetPos,
|
||||||
targetControlX,
|
|
||||||
targetControlY,
|
|
||||||
sourceAngle: sourceIntersection.angle,
|
|
||||||
targetAngle: targetIntersection.angle,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import type { SerializedRelation } from '../stores/persistence/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List of old 4-position handle identifiers that should be migrated
|
|
||||||
*/
|
|
||||||
const OLD_HANDLE_POSITIONS = ['top', 'right', 'bottom', 'left'] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a relation from the old 4-position handle system to the new easy-connect handle system.
|
|
||||||
* This function ensures backward compatibility with existing constellation files.
|
|
||||||
*
|
|
||||||
* Old format uses specific position handles: "top", "right", "bottom", "left"
|
|
||||||
* New format omits handle fields entirely, allowing floating edges to calculate connection points dynamically
|
|
||||||
*
|
|
||||||
* @param relation - The relation to migrate
|
|
||||||
* @returns The migrated relation
|
|
||||||
*/
|
|
||||||
export function migrateRelationHandles(relation: SerializedRelation): SerializedRelation {
|
|
||||||
// Check if either handle uses old format
|
|
||||||
const hasOldSourceHandle =
|
|
||||||
relation.sourceHandle != null && OLD_HANDLE_POSITIONS.includes(relation.sourceHandle as typeof OLD_HANDLE_POSITIONS[number]);
|
|
||||||
const hasOldTargetHandle =
|
|
||||||
relation.targetHandle != null && OLD_HANDLE_POSITIONS.includes(relation.targetHandle as typeof OLD_HANDLE_POSITIONS[number]);
|
|
||||||
|
|
||||||
// If old format detected, remove handle fields entirely for floating edge pattern
|
|
||||||
if (hasOldSourceHandle || hasOldTargetHandle) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { sourceHandle, targetHandle, ...relationWithoutHandles } = relation;
|
|
||||||
return relationWithoutHandles;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise return unchanged
|
|
||||||
return relation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates an array of relations.
|
|
||||||
*
|
|
||||||
* @param relations - Array of relations to migrate
|
|
||||||
* @returns Array of migrated relations
|
|
||||||
*/
|
|
||||||
export function migrateRelationHandlesArray(relations: SerializedRelation[]): SerializedRelation[] {
|
|
||||||
return relations.map(migrateRelationHandles);
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue