+ previous.id === next.id &&
+ previous.data === next.data &&
+ previous.isConnectable === next.isConnectable &&
+ previous.selected === next.selected,
+);
+SimulationComponentNode.displayName = "SimulationComponentNode";
+
function normalizeNodeRotation(value: unknown): NodeRotation {
return value === 90 || value === 180 || value === 270 ? value : 0;
}
@@ -2135,6 +2240,53 @@ type ContactSnapPair = ContactSnapMatch & {
targetPortOrder: number;
};
+type ContactSnapPlacement = ReturnType
[number];
+
+type ContactSnapIndexedTarget = {
+ key: string;
+ placement: ContactSnapPlacement;
+ targetNode: SimulationNode;
+ targetNodeOrder: number;
+ targetPortOrder: number;
+};
+
+type ContactSnapSearchContext = {
+ buckets: Map;
+ cellSize: number;
+ draggedNodeId: string;
+ occupiedPorts: Set;
+ targetByPortKey: Map;
+};
+
+function contactSnapCell(value: number, cellSize: number) {
+ return Math.floor(value / cellSize);
+}
+
+function contactSnapBucketKey(cellX: number, cellY: number) {
+ return `${cellX}:${cellY}`;
+}
+
+function contactSnapTargetsNear(
+ context: ContactSnapSearchContext,
+ flowX: number,
+ flowY: number,
+ radius: number,
+) {
+ const minCellX = contactSnapCell(flowX - radius, context.cellSize);
+ const maxCellX = contactSnapCell(flowX + radius, context.cellSize);
+ const minCellY = contactSnapCell(flowY - radius, context.cellSize);
+ const maxCellY = contactSnapCell(flowY + radius, context.cellSize);
+ const targets: ContactSnapIndexedTarget[] = [];
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
+ targets.push(
+ ...(context.buckets.get(contactSnapBucketKey(cellX, cellY)) ?? []),
+ );
+ }
+ }
+ return targets;
+}
+
function compareContactSnapPairs(
first: ContactSnapPair,
second: ContactSnapPair,
@@ -2150,12 +2302,11 @@ function compareContactSnapPairs(
);
}
-function availableContactSnapPairs(
- draggedNode: SimulationNode,
+function buildContactSnapSearchContext(
+ draggedNodeId: string,
nodes: SimulationNode[],
edges: SimulationEdge[],
- maximumDistance: number,
-) {
+): ContactSnapSearchContext {
const occupiedPorts = new Set();
edges.forEach((edge) => {
if (edge.sourceHandle) {
@@ -2165,67 +2316,110 @@ function availableContactSnapPairs(
occupiedPorts.add(portConnectionKey(edge.target, edge.targetHandle));
}
});
+ const cellSize = CONTACT_SNAP_RADIUS_FLOW;
+ const buckets = new Map();
+ const targetByPortKey = new Map();
+ nodes.forEach((targetNode, targetNodeOrder) => {
+ if (targetNode.id === draggedNodeId) {
+ return;
+ }
+ const targetPortOrder = new Map(
+ targetNode.data.ports.map((port, index) => [port.name, index] as const),
+ );
+ nodePortFlowPlacements(targetNode).forEach((placement) => {
+ const key = portConnectionKey(targetNode.id, placement.port.name);
+ if (occupiedPorts.has(key)) {
+ return;
+ }
+ const indexedTarget: ContactSnapIndexedTarget = {
+ key,
+ placement,
+ targetNode,
+ targetNodeOrder,
+ targetPortOrder:
+ targetPortOrder.get(placement.port.name) ?? Number.MAX_SAFE_INTEGER,
+ };
+ const bucketKey = contactSnapBucketKey(
+ contactSnapCell(placement.flowX, cellSize),
+ contactSnapCell(placement.flowY, cellSize),
+ );
+ const bucket = buckets.get(bucketKey);
+ if (bucket) {
+ bucket.push(indexedTarget);
+ } else {
+ buckets.set(bucketKey, [indexedTarget]);
+ }
+ targetByPortKey.set(key, indexedTarget);
+ });
+ });
+ return {
+ buckets,
+ cellSize,
+ draggedNodeId,
+ occupiedPorts,
+ targetByPortKey,
+ };
+}
+
+function availableContactSnapPairs(
+ draggedNode: SimulationNode,
+ nodes: SimulationNode[],
+ edges: SimulationEdge[],
+ maximumDistance: number,
+ searchContext?: ContactSnapSearchContext,
+) {
+ const resolvedContext =
+ searchContext?.draggedNodeId === draggedNode.id
+ ? searchContext
+ : buildContactSnapSearchContext(draggedNode.id, nodes, edges);
+ const { occupiedPorts } = resolvedContext;
const pairs: ContactSnapPair[] = [];
const draggedPortOrder = new Map(
draggedNode.data.ports.map((port, index) => [port.name, index] as const),
);
- const draggedPlacements = nodePortFlowPlacements(draggedNode);
+ const draggedPlacements = nodePortFlowPlacements(draggedNode).filter(
+ (placement) =>
+ !occupiedPorts.has(
+ portConnectionKey(draggedNode.id, placement.port.name),
+ ),
+ );
+ const maximumDistanceSquared = maximumDistance * maximumDistance;
for (const draggedPlacement of draggedPlacements) {
- if (
- occupiedPorts.has(
- portConnectionKey(draggedNode.id, draggedPlacement.port.name),
- )
- ) {
- continue;
- }
- for (const [targetNodeOrder, targetNode] of nodes.entries()) {
- if (targetNode.id === draggedNode.id) {
+ for (const indexedTarget of contactSnapTargetsNear(
+ resolvedContext,
+ draggedPlacement.flowX,
+ draggedPlacement.flowY,
+ maximumDistance,
+ )) {
+ const { placement: targetPlacement, targetNode } = indexedTarget;
+ if (!arePortsCompatible(draggedPlacement.port, targetPlacement.port)) {
continue;
}
- const targetPortOrder = new Map(
- targetNode.data.ports.map((port, index) => [port.name, index] as const),
- );
- for (const targetPlacement of nodePortFlowPlacements(targetNode)) {
- if (
- occupiedPorts.has(
- portConnectionKey(targetNode.id, targetPlacement.port.name),
- ) ||
- !arePortsCompatible(draggedPlacement.port, targetPlacement.port)
- ) {
- continue;
- }
- const distance = Math.hypot(
- targetPlacement.flowX - draggedPlacement.flowX,
- targetPlacement.flowY - draggedPlacement.flowY,
- );
- if (distance > maximumDistance) {
- continue;
- }
- const connection = connectionForContactPorts(
- draggedNode,
- draggedPlacement.port,
- targetNode,
- targetPlacement.port,
- );
- if (!canConnectPorts(nodes, edges, connection)) {
- continue;
- }
- pairs.push({
- connection,
- distance,
- draggedPortOrder:
- draggedPortOrder.get(draggedPlacement.port.name) ??
- Number.MAX_SAFE_INTEGER,
- draggedPortName: draggedPlacement.port.name,
- targetNodeId: targetNode.id,
- targetNodeOrder,
- targetPortOrder:
- targetPortOrder.get(targetPlacement.port.name) ??
- Number.MAX_SAFE_INTEGER,
- targetPortName: targetPlacement.port.name,
- });
+ const deltaX = targetPlacement.flowX - draggedPlacement.flowX;
+ const deltaY = targetPlacement.flowY - draggedPlacement.flowY;
+ const distanceSquared = deltaX * deltaX + deltaY * deltaY;
+ if (distanceSquared > maximumDistanceSquared) {
+ continue;
}
+ const connection = connectionForContactPorts(
+ draggedNode,
+ draggedPlacement.port,
+ targetNode,
+ targetPlacement.port,
+ );
+ pairs.push({
+ connection,
+ distance: Math.sqrt(distanceSquared),
+ draggedPortOrder:
+ draggedPortOrder.get(draggedPlacement.port.name) ??
+ Number.MAX_SAFE_INTEGER,
+ draggedPortName: draggedPlacement.port.name,
+ targetNodeId: targetNode.id,
+ targetNodeOrder: indexedTarget.targetNodeOrder,
+ targetPortOrder: indexedTarget.targetPortOrder,
+ targetPortName: targetPlacement.port.name,
+ });
}
}
@@ -2277,12 +2471,18 @@ function findContactSnapCandidate(
nodes: SimulationNode[],
edges: SimulationEdge[],
maximumDistance: number,
+ searchContext?: ContactSnapSearchContext,
) {
+ const resolvedContext =
+ searchContext?.draggedNodeId === draggedNode.id
+ ? searchContext
+ : buildContactSnapSearchContext(draggedNode.id, nodes, edges);
const nearbyPairs = availableContactSnapPairs(
draggedNode,
nodes,
edges,
maximumDistance,
+ resolvedContext,
);
const anchor = nearbyPairs[0];
if (!anchor) {
@@ -2292,10 +2492,10 @@ function findContactSnapCandidate(
draggedNode,
anchor.draggedPortName,
);
- const targetNode = nodes.find((node) => node.id === anchor.targetNodeId);
- const targetPlacement = targetNode
- ? nodePortFlowPlacement(targetNode, anchor.targetPortName)
- : undefined;
+ const indexedTarget = resolvedContext.targetByPortKey.get(
+ portConnectionKey(anchor.targetNodeId, anchor.targetPortName),
+ );
+ const targetPlacement = indexedTarget?.placement;
if (!draggedPlacement || !targetPlacement) {
return null;
}
@@ -2313,16 +2513,14 @@ function findContactSnapCandidate(
...draggedNode,
position: snappedPosition,
};
- const snappedNodes = nodes.map((node) =>
- node.id === draggedNode.id ? snappedDraggedNode : node,
- );
// 多个接口必须共享同一个组件落点;只连接该落点下真正重合的接口,
// 避免一个组件同时被互相冲突的接口位置“拉向”不同方向。
const exactPairs = availableContactSnapPairs(
snappedDraggedNode,
- snappedNodes,
+ nodes,
edges,
CONTACT_GEOMETRY_TOLERANCE,
+ resolvedContext,
);
const matches = maximumContactSnapMatches(exactPairs).map(
({
@@ -2384,6 +2582,43 @@ function isContactEdgeGeometry(nodes: SimulationNode[], edge: SimulationEdge) {
);
}
+type NodePortGeometryIndex = Map<
+ string,
+ Map
+>;
+
+function buildNodePortGeometryIndex(nodes: SimulationNode[]): NodePortGeometryIndex {
+ return new Map(
+ nodes.map((node) => [
+ node.id,
+ new Map(
+ nodePortFlowPlacements(node).map((placement) => [
+ placement.port.name,
+ { flowX: placement.flowX, flowY: placement.flowY },
+ ] as const),
+ ),
+ ] as const),
+ );
+}
+
+function isContactEdgeGeometryInIndex(
+ geometryIndex: NodePortGeometryIndex,
+ edge: SimulationEdge,
+) {
+ const source = edge.sourceHandle
+ ? geometryIndex.get(edge.source)?.get(edge.sourceHandle)
+ : undefined;
+ const target = edge.targetHandle
+ ? geometryIndex.get(edge.target)?.get(edge.targetHandle)
+ : undefined;
+ return Boolean(
+ source &&
+ target &&
+ Math.hypot(target.flowX - source.flowX, target.flowY - source.flowY) <=
+ CONTACT_GEOMETRY_TOLERANCE,
+ );
+}
+
function separateDeletedContactEdges(
nodes: SimulationNode[],
deletedEdges: SimulationEdge[],
@@ -2453,13 +2688,15 @@ function disconnectInvalidatedContactEdges(
transformedNodeIds: Set,
) {
const removedEdges: SimulationEdge[] = [];
+ const previousGeometry = buildNodePortGeometryIndex(previousNodes);
+ const nextGeometry = buildNodePortGeometryIndex(nextNodes);
const remainingEdges = edges.filter((edge) => {
const affected =
transformedNodeIds.has(edge.source) || transformedNodeIds.has(edge.target);
const invalidated =
affected &&
- isContactEdgeGeometry(previousNodes, edge) &&
- !isContactEdgeGeometry(nextNodes, edge);
+ isContactEdgeGeometryInIndex(previousGeometry, edge) &&
+ !isContactEdgeGeometryInIndex(nextGeometry, edge);
if (invalidated) {
removedEdges.push(edge);
}
@@ -3242,7 +3479,13 @@ function defaultFlowViewportWorldBounds(): FlowViewportWorldBounds {
};
}
-function FlowViewportScrollbars({ nodes }: { nodes: SimulationNode[] }) {
+function FlowViewportScrollbars({
+ deferBounds = false,
+ nodes,
+}: {
+ deferBounds?: boolean;
+ nodes: SimulationNode[];
+}) {
const reactFlow = useReactFlow();
const transform = useStore((flowState) => flowState.transform);
const canvasWidth = useStore((flowState) => flowState.width);
@@ -3256,13 +3499,20 @@ function FlowViewportScrollbars({ nodes }: { nodes: SimulationNode[] }) {
const [worldBounds, setWorldBounds] = useState(
defaultFlowViewportWorldBounds,
);
- const nodeBounds = useMemo(
- () =>
- nodes.length > 0
- ? reactFlow.getNodesBounds(nodes)
- : { x: 0, y: 0, width: 0, height: 0 },
- [nodes, reactFlow],
- );
+ const nodeBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
+ const nodeBounds = useMemo(() => {
+ if (deferBounds) {
+ return nodeBoundsRef.current;
+ }
+ return nodes.length > 0
+ ? reactFlow.getNodesBounds(nodes)
+ : { x: 0, y: 0, width: 0, height: 0 };
+ }, [deferBounds, nodes, reactFlow]);
+ useLayoutEffect(() => {
+ if (!deferBounds) {
+ nodeBoundsRef.current = nodeBounds;
+ }
+ }, [deferBounds, nodeBounds]);
const zoom = Math.max(transform[2], 0.01);
const visibleLeft = -transform[0] / zoom;
const visibleTop = -transform[1] / zoom;
@@ -3539,6 +3789,16 @@ function FlowWorkbench() {
() => componentLibraries.flatMap((library) => library.components),
[componentLibraries],
);
+ const defaultParametersByComponentType = useMemo(
+ () =>
+ new Map(
+ componentDefinitions.map((definition) => [
+ definition.type,
+ defaultParameters(definition),
+ ] as const),
+ ),
+ [componentDefinitions],
+ );
const visibleComponentLibraries = useMemo(
() => componentLibraries.filter(isVisibleComponentLibrary),
[componentLibraries],
@@ -3553,6 +3813,7 @@ function FlowWorkbench() {
useState(null);
const [contactSnapCandidate, setContactSnapCandidate] =
useState(null);
+ const [nodeDragActive, setNodeDragActive] = useState(false);
const [projectName, setProjectName] = useState("demo-system");
const [simulationConfig, setSimulationConfig] = useState(
defaultSimulationConfig,
@@ -3599,6 +3860,8 @@ function FlowWorkbench() {
: defaultModelingPaneLayout().componentParametersHeight;
const [validationIssues, setValidationIssues] = useState([]);
const [validatedSignature, setValidatedSignature] = useState("");
+ const projectChangeSignatureRef = useRef("");
+ const validationSignatureRef = useRef("");
const [saveState, setSaveState] = useState("正在检查本地草稿...");
const [autosaveReady, setAutosaveReady] = useState(false);
const [recoveryProject, setRecoveryProject] = useState(null);
@@ -3608,15 +3871,15 @@ function FlowWorkbench() {
});
const reactFlow = useReactFlow();
const reactFlowStore = useStoreApi();
- const multipleNodesSelected = useStore((flowState) => {
+ const multipleNodesSelected = useMemo(() => {
let selectedCount = 0;
- for (const node of flowState.nodeLookup.values()) {
+ for (const node of nodes) {
if (node.selected && (selectedCount += 1) > 1) {
return true;
}
}
return false;
- });
+ }, [nodes]);
const fileInputRef = useRef(null);
const workbenchRef = useRef(null);
const modelingPaneResizeRef = useRef(null);
@@ -3632,6 +3895,23 @@ function FlowWorkbench() {
const pendingPasteMiddlePointerRef = useRef(
null,
);
+ const renderedNodeCacheRef = useRef