From 7671418582a49b2d7bad7438232e5412a12086d2 Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Thu, 6 Aug 2026 23:35:50 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=BB=84=E4=BB=B6=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E5=B8=83=E5=B1=80=E5=B9=B6=E5=AE=8C=E5=96=84=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=90=B8=E9=99=84=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 2 + frontend/src/App.tsx | 1430 ++++++++++-- frontend/src/ComponentSymbol.tsx | 668 ++++-- frontend/src/SimulationResultsView.tsx | 4 + frontend/src/componentSymbols/boundary.tsx | 43 +- frontend/src/componentSymbols/mechanical.tsx | 248 ++- frontend/src/componentSymbols/media.tsx | 21 +- frontend/src/componentSymbols/pneumatic.tsx | 124 +- frontend/src/componentSymbols/primitives.tsx | 31 +- frontend/src/componentSymbols/signals.tsx | 97 +- frontend/src/componentSymbols/storage.tsx | 148 +- frontend/src/componentSymbols/types.ts | 37 + frontend/src/styles.css | 58 +- frontend/tests/e2e/component-symbols.spec.ts | 1909 ++++++++++++++++- frontend/tests/e2e/palette-display.spec.ts | 103 + ...test_reactflow_project_medium_reference.py | 34 + 16 files changed, 4359 insertions(+), 598 deletions(-) diff --git a/app/main.py b/app/main.py index 0622293..558e084 100644 --- a/app/main.py +++ b/app/main.py @@ -139,6 +139,8 @@ class ReactFlowSimulationConfig(BaseModel): class ReactFlowProjectPayload(BaseModel): name: str = "untitled" mediumReferenceVersion: Literal[1] | None = None + # 仅用于前端展示布局迁移;求解器和 System XML 不读取该字段。 + presentationLayoutVersion: Literal[1] | None = None # Missing/None is reserved for projects written before canonical AMESim # option codes were introduced. Every current writer emits version 1. amesimParameterEncodingVersion: Literal[1] | None = Field( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ce5a12..57122bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,6 +69,7 @@ import { useEdgesState, useNodesState, useReactFlow, + useStore, useStoreApi, useUpdateNodeInternals, } from "@xyflow/react"; @@ -81,11 +82,17 @@ import { import { AutoFitView } from "./AutoFitView"; import { ComponentSymbol, - componentSymbolPortAnchors, + componentSymbolLayout, componentSymbolPresentation, + componentSymbolVariantKey, hasComponentSymbol, } from "./ComponentSymbol"; -import type { ComponentSymbolPortAnchors } from "./componentSymbols/types"; +import type { + ComponentSymbolLayout, + ComponentSymbolPortAnchor, + ComponentSymbolSize, + ComponentSymbolViewBox, +} from "./componentSymbols/types"; import { CONTACT_AWARE_EDGE_TYPE, contactAwareEdgeTypes, @@ -141,15 +148,19 @@ type ActivePortConnection = { mode: "drag" | "click"; }; -type ContactSnapCandidate = { +type ContactSnapMatch = { connection: Connection; - draggedNodeId: string; draggedPortName: string; - snappedPosition: { x: number; y: number }; targetNodeId: string; targetPortName: string; }; +type ContactSnapCandidate = { + draggedNodeId: string; + matches: ContactSnapMatch[]; + snappedPosition: { x: number; y: number }; +}; + type ContactDragSession = { contactEdgeIds: Set; disconnected: boolean; @@ -288,6 +299,7 @@ type SimulationNodeData = { rotation: NodeRotation; mirrored: boolean; portConnectionStates?: Record; + hideConnectedPortIndexLabels?: boolean; }; type SimulationNode = Node; @@ -339,6 +351,7 @@ type ReactFlowProjectPayload = { name: string; mediumReferenceVersion?: 1 | null; amesimParameterEncodingVersion?: 1 | null; + presentationLayoutVersion?: 1 | null; nodes: ProjectNodePayload[]; edges: ProjectEdgePayload[]; simulation: SimulationConfig; @@ -449,12 +462,9 @@ const MIN_SIMULATION_SETTINGS_HEIGHT = 120; const MIN_MODELING_CANVAS_WIDTH = 420; const MODELING_PANE_KEYBOARD_STEP = 20; const HISTORY_LIMIT = 50; -const NODE_FRAME_WIDTH = 132; -const NODE_FRAME_HEIGHT = 84; -const NODE_SYMBOL_WIDTH = 112; -const NODE_SYMBOL_HEIGHT = 84; -const SYMBOL_VIEWBOX_WIDTH = 64; -const SYMBOL_VIEWBOX_HEIGHT = 48; +const PRESENTATION_LAYOUT_VERSION = 1 as const; +const LEGACY_NODE_FRAME_SIZE: ComponentSymbolSize = { width: 132, height: 84 }; +const LEGACY_NODE_SYMBOL_SIZE: ComponentSymbolSize = { width: 112, height: 84 }; const SYMBOL_ENVELOPE_PADDING_PX = 2; const PORT_INDEX_OUTSET_PX = 18; const PORT_INDEX_TANGENT_OFFSET_PX = 12; @@ -463,7 +473,8 @@ const PORT_INDEX_LABEL_MIN_WIDTH_PX = 20; const PORT_INDEX_LABEL_CHARACTER_WIDTH_PX = 10; const PORT_INDEX_LABEL_GAP_PX = 1; const PORT_INDEX_LABEL_LANE_GAP_PX = 2; -const CONTACT_SNAP_RADIUS_PX = 12; +// 接触吸附以屏幕像素定义,调用处再按 React Flow 缩放换算为画布距离。 +const CONTACT_SNAP_RADIUS_PX = 6; const CONTACT_DISCONNECT_DISTANCE_PX = 8; const CONTACT_GEOMETRY_TOLERANCE = 0.5; const CONTACT_DELETE_SEPARATION = 16; @@ -475,14 +486,82 @@ type SymbolEnvelope = { height: number; }; -const DEFAULT_SYMBOL_ENVELOPE: SymbolEnvelope = { - left: 0, - top: 0, - width: NODE_SYMBOL_WIDTH, - height: NODE_SYMBOL_HEIGHT, +type CanvasSelectionRect = { + x: number; + y: number; + width: number; + height: number; }; -function measuredSymbolEnvelope(svg: SVGSVGElement): SymbolEnvelope { +type ScreenRectangle = { + left: number; + top: number; + right: number; + bottom: number; +}; + +function screenRectanglesOverlap( + selection: ScreenRectangle, + target: ScreenRectangle, +): boolean { + return ( + selection.left < target.right && + selection.right > target.left && + selection.top < target.bottom && + selection.bottom > target.top + ); +} + +function defaultSymbolEnvelope(layout: ComponentSymbolLayout): SymbolEnvelope { + return { + left: 0, + top: 0, + width: layout.symbolSize.width, + height: layout.symbolSize.height, + }; +} + +type SymbolViewBoxTransform = { + offsetX: number; + offsetY: number; + scale: number; +}; + +/** 与 SVG 默认的 xMidYMid meet 规则保持一致,接口和图形共用同一换算。 */ +function symbolViewBoxTransform( + viewBox: ComponentSymbolViewBox, + symbolSize: ComponentSymbolSize, +): SymbolViewBoxTransform { + const scale = Math.min( + symbolSize.width / viewBox.width, + symbolSize.height / viewBox.height, + ); + return { + scale, + offsetX: (symbolSize.width - viewBox.width * scale) / 2, + offsetY: (symbolSize.height - viewBox.height * scale) / 2, + }; +} + +function symbolPointToPixels( + point: ComponentSymbolPortAnchor, + layout: ComponentSymbolLayout, +) { + const transform = symbolViewBoxTransform(layout.viewBox, layout.symbolSize); + return { + x: + transform.offsetX + + (point.x - layout.viewBox.x) * transform.scale, + y: + transform.offsetY + + (point.y - layout.viewBox.y) * transform.scale, + }; +} + +function measuredSymbolEnvelope( + svg: SVGSVGElement, + layout: ComponentSymbolLayout, +): SymbolEnvelope { const viewBox = svg.viewBox.baseVal; const geometryBounds = svg.getBBox(); let bounds: Pick = geometryBounds; @@ -516,12 +595,31 @@ function measuredSymbolEnvelope(svg: SVGSVGElement): SymbolEnvelope { 0, ); if (maximumStrokeWidth > 0) { - const strokeRadius = maximumStrokeWidth / 2; + const strokeScaling = svg.dataset.symbolStrokeScaling; + const viewBoxScale = + viewBox.width > 0 && viewBox.height > 0 + ? symbolViewBoxTransform( + { + x: viewBox.x, + y: viewBox.y, + width: viewBox.width, + height: viewBox.height, + }, + layout.symbolSize, + ).scale + : 1; + // non-scaling-stroke 的 computed strokeWidth 是最终 CSS 像素; + // 选择包络在 viewBox 坐标中计算,需要先换回 SVG 逻辑单位。 + const strokeWidthInViewBox = + strokeScaling === "independent" && viewBoxScale > 0 + ? maximumStrokeWidth / viewBoxScale + : maximumStrokeWidth; + const strokeRadius = strokeWidthInViewBox / 2; bounds = { x: geometryBounds.x - strokeRadius, y: geometryBounds.y - strokeRadius, - width: geometryBounds.width + maximumStrokeWidth, - height: geometryBounds.height + maximumStrokeWidth, + width: geometryBounds.width + strokeWidthInViewBox, + height: geometryBounds.height + strokeWidthInViewBox, }; } } @@ -531,18 +629,28 @@ function measuredSymbolEnvelope(svg: SVGSVGElement): SymbolEnvelope { bounds.width <= 0 || bounds.height <= 0 ) { - return DEFAULT_SYMBOL_ENVELOPE; + return defaultSymbolEnvelope(layout); } - const scaleX = NODE_SYMBOL_WIDTH / viewBox.width; - const scaleY = NODE_SYMBOL_HEIGHT / viewBox.height; - const left = (bounds.x - viewBox.x) * scaleX - SYMBOL_ENVELOPE_PADDING_PX; - const top = (bounds.y - viewBox.y) * scaleY - SYMBOL_ENVELOPE_PADDING_PX; + const transform = symbolViewBoxTransform( + { x: viewBox.x, y: viewBox.y, width: viewBox.width, height: viewBox.height }, + layout.symbolSize, + ); + const left = + transform.offsetX + + (bounds.x - viewBox.x) * transform.scale - + SYMBOL_ENVELOPE_PADDING_PX; + const top = + transform.offsetY + + (bounds.y - viewBox.y) * transform.scale - + SYMBOL_ENVELOPE_PADDING_PX; const right = - (bounds.x + bounds.width - viewBox.x) * scaleX + + transform.offsetX + + (bounds.x + bounds.width - viewBox.x) * transform.scale + SYMBOL_ENVELOPE_PADDING_PX; const bottom = - (bounds.y + bounds.height - viewBox.y) * scaleY + + transform.offsetY + + (bounds.y + bounds.height - viewBox.y) * transform.scale + SYMBOL_ENVELOPE_PADDING_PX; const roundPixel = (value: number) => Math.round(value * 100) / 100; @@ -1574,31 +1682,46 @@ function assignMissingAmesimGasDefinitionIndices( function SimulationComponentNode({ id, data, selected }: NodeProps) { const updateNodeInternals = useUpdateNodeInternals(); const symbolContainerRef = useRef(null); - const [symbolEnvelope, setSymbolEnvelope] = useState( - DEFAULT_SYMBOL_ENVELOPE, - ); const rotation = normalizeNodeRotation(data.rotation); const mirrored = Boolean(data.mirrored); const vertical = rotation === 90 || rotation === 270; const symbol = data.symbol ?? data.componentType; const hasDedicatedSymbol = hasComponentSymbol(symbol); const symbolPresentation = componentSymbolPresentation(symbol); - const portAnchors = hasDedicatedSymbol - ? componentSymbolPortAnchors(symbol) - : undefined; + const layout = componentSymbolLayout(symbol); + const renderedSize = nodeFrameDimensions(layout, rotation); + const layoutSignature = `${layout.viewBox.x}:${layout.viewBox.y}:${layout.viewBox.width}:${layout.viewBox.height}:${layout.symbolSize.width}:${layout.symbolSize.height}:${layout.nodeSize.width}:${layout.nodeSize.height}`; + const [symbolEnvelope, setSymbolEnvelope] = useState(() => + defaultSymbolEnvelope(layout), + ); const portPlacements = transformedPortPlacements( data.ports, rotation, mirrored, - portAnchors, + layout, ); + const visiblePortIndexPlacements = data.hideConnectedPortIndexLabels + ? portPlacements.filter( + ({ port }) => data.portConnectionStates?.[port.name] !== "connected", + ) + : portPlacements; const portIndexLabels = selected - ? portIndexLabelPlacements(data.ports, portPlacements, rotation) + ? portIndexLabelPlacements( + data.ports, + visiblePortIndexPlacements, + layout, + rotation, + ) : []; - const symbolVariantKey = - symbol === "amesim_mecmas21" - ? `${data.parameters.useFriction ?? ""}:${data.parameters.stoptype ?? ""}` - : ""; + const symbolVariantKey = componentSymbolVariantKey(symbol, data.parameters); + const nodeLayoutStyle = { + width: renderedSize.width, + height: renderedSize.height, + "--node-frame-width": `${layout.nodeSize.width}px`, + "--node-frame-height": `${layout.nodeSize.height}px`, + "--node-symbol-width": `${layout.symbolSize.width}px`, + "--node-symbol-height": `${layout.symbolSize.height}px`, + } as CSSProperties; useLayoutEffect(() => { if (!hasDedicatedSymbol) { @@ -1610,7 +1733,7 @@ function SimulationComponentNode({ id, data, selected }: NodeProps currentEnvelope.left === nextEnvelope.left && currentEnvelope.top === nextEnvelope.top && @@ -1619,15 +1742,19 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { updateNodeInternals(id); - }, [mirrored, rotation, id, updateNodeInternals]); + }, [id, layoutSignature, mirrored, rotation, updateNodeInternals]); return (
{ const sidePorts = ports.filter((port) => port.side === side); @@ -1762,15 +1899,16 @@ function transformedPortPlacements( const usesSymbolAnchors = sideAnchors?.length === sidePorts.length; return sidePorts.map((port, index) => { const anchor = usesSymbolAnchors ? sideAnchors[index] : undefined; - let x = anchor - ? (anchor.x / SYMBOL_VIEWBOX_WIDTH - 0.5) * NODE_SYMBOL_WIDTH + const symbolPoint = anchor ? symbolPointToPixels(anchor, layout) : undefined; + let x = symbolPoint + ? symbolPoint.x - layout.symbolSize.width / 2 : side === "left" - ? -NODE_FRAME_WIDTH / 2 - : NODE_FRAME_WIDTH / 2; - let y = anchor - ? (anchor.y / SYMBOL_VIEWBOX_HEIGHT - 0.5) * NODE_SYMBOL_HEIGHT - : -NODE_FRAME_HEIGHT / 2 + - ((index + 1) * NODE_FRAME_HEIGHT) / (sidePorts.length + 1); + ? -layout.nodeSize.width / 2 + : layout.nodeSize.width / 2; + let y = symbolPoint + ? symbolPoint.y - layout.symbolSize.height / 2 + : -layout.nodeSize.height / 2 + + ((index + 1) * layout.nodeSize.height) / (sidePorts.length + 1); if (mirrored) { x = -x; @@ -1782,7 +1920,13 @@ function transformedPortPlacements( return { port, - position: transformedPortPosition(side, rotation, mirrored, anchor), + position: transformedPortPosition( + side, + rotation, + mirrored, + layout.viewBox, + anchor, + ), x: renderedX, y: renderedY, }; @@ -1941,6 +2085,7 @@ function spreadPortIndexLabels( function portIndexLabelPlacements( ports: PortDefinition[], placements: TransformedPortPlacement[], + layout: ComponentSymbolLayout, rotation: NodeRotation, ) { const indexByPortName = new Map( @@ -1963,7 +2108,7 @@ function portIndexLabelPlacements( } return label; }); - const renderedSize = nodeFrameDimensions(rotation); + const renderedSize = nodeFrameDimensions(layout, rotation); const spreadByOrder = new Map(); for (const position of [ Position.Left, @@ -2000,17 +2145,26 @@ function transformedPortPosition( side: PortSide, rotation: NodeRotation, mirrored: boolean, - anchor?: { x: number; y: number }, + viewBox: ComponentSymbolViewBox, + anchor?: ComponentSymbolPortAnchor, ) { let directionX = side === "left" ? -1 : 1; let directionY = 0; if (anchor) { const closestEdge = [ - { distance: anchor.x, x: -1, y: 0 }, - { distance: SYMBOL_VIEWBOX_WIDTH - anchor.x, x: 1, y: 0 }, - { distance: anchor.y, x: 0, y: -1 }, - { distance: SYMBOL_VIEWBOX_HEIGHT - anchor.y, x: 0, y: 1 }, + { distance: anchor.x - viewBox.x, x: -1, y: 0 }, + { + distance: viewBox.x + viewBox.width - anchor.x, + x: 1, + y: 0, + }, + { distance: anchor.y - viewBox.y, x: 0, y: -1 }, + { + distance: viewBox.y + viewBox.height - anchor.y, + x: 0, + y: 1, + }, ].reduce((closest, candidate) => candidate.distance < closest.distance ? candidate : closest, ); @@ -2034,17 +2188,16 @@ function transformedPortPosition( return Position.Bottom; } -function nodePortFlowPlacements(node: SimulationNode) { +function nodePortFlowPlacementsWithLayout( + node: SimulationNode, + layout: ComponentSymbolLayout, +) { const rotation = normalizeNodeRotation(node.data.rotation); - const symbol = node.data.symbol ?? node.data.componentType; - const anchors = hasComponentSymbol(symbol) - ? componentSymbolPortAnchors(symbol) - : undefined; return transformedPortPlacements( node.data.ports, rotation, Boolean(node.data.mirrored), - anchors, + layout, ).map((placement) => ({ ...placement, flowX: node.position.x + placement.x, @@ -2052,6 +2205,14 @@ function nodePortFlowPlacements(node: SimulationNode) { })); } +function nodePortFlowPlacements(node: SimulationNode) { + const symbol = node.data.symbol ?? node.data.componentType; + return nodePortFlowPlacementsWithLayout( + node, + componentSymbolLayout(symbol), + ); +} + function nodePortFlowPlacement(node: SimulationNode, portName: string | null) { if (!portName) { return undefined; @@ -2061,6 +2222,260 @@ function nodePortFlowPlacement(node: SimulationNode, portName: string | null) { ); } +function legacyComponentSymbolLayout(symbol: string): ComponentSymbolLayout { + const currentLayout = componentSymbolLayout(symbol); + return { + ...currentLayout, + tier: "large", + // 版本 1 之前所有建模区图标都使用这一套固定几何。 + viewBox: { x: 0, y: 0, width: 64, height: 48 }, + symbolSize: LEGACY_NODE_SYMBOL_SIZE, + nodeSize: LEGACY_NODE_FRAME_SIZE, + portAnchors: currentLayout.legacyPortAnchors ?? currentLayout.portAnchors, + }; +} + +type PresentationLayoutMigrationResult = { + nodes: SimulationNode[]; + migrated: boolean; + preservedContactEdgeCount: number; + conflictingContactConstraintCount: number; +}; + +type ContactLayoutConstraint = { + neighborId: string; + ownPortName: string; + neighborPortName: string; +}; + +function nodePortPlacementWithLayout( + node: SimulationNode, + portName: string, + layout: ComponentSymbolLayout, +) { + return nodePortFlowPlacementsWithLayout(node, layout).find( + (placement) => placement.port.name === portName, + ); +} + +/** + * 将旧版固定 132×84 布局迁移到逐元件布局。 + * 先用旧几何识别无连线接触边,再按新接口位置重排接触连通组。 + */ +function migrateLegacyPresentationLayout( + nodes: SimulationNode[], + edges: SimulationEdge[], + version?: 1 | null, +): PresentationLayoutMigrationResult { + if (version === PRESENTATION_LAYOUT_VERSION) { + return { + nodes, + migrated: false, + preservedContactEdgeCount: 0, + conflictingContactConstraintCount: 0, + }; + } + + const originalById = new Map(nodes.map((node) => [node.id, node])); + const preferredById = new Map( + nodes.map((node) => { + const rotation = normalizeNodeRotation(node.data.rotation); + const symbol = node.data.symbol ?? node.data.componentType; + const oldSize = rotatedDimensions(LEGACY_NODE_FRAME_SIZE, rotation); + const newSize = nodeFrameDimensions(componentSymbolLayout(symbol), rotation); + return [ + node.id, + { + ...node, + position: { + x: node.position.x + (oldSize.width - newSize.width) / 2, + y: node.position.y + (oldSize.height - newSize.height) / 2, + }, + } satisfies SimulationNode, + ] as const; + }), + ); + + const constraints = new Map(); + let preservedContactEdgeCount = 0; + for (const edge of edges) { + if (!edge.sourceHandle || !edge.targetHandle) { + continue; + } + const sourceNode = originalById.get(edge.source); + const targetNode = originalById.get(edge.target); + if (!sourceNode || !targetNode) { + continue; + } + const sourceSymbol = sourceNode.data.symbol ?? sourceNode.data.componentType; + const targetSymbol = targetNode.data.symbol ?? targetNode.data.componentType; + const sourcePort = nodePortPlacementWithLayout( + sourceNode, + edge.sourceHandle, + legacyComponentSymbolLayout(sourceSymbol), + ); + const targetPort = nodePortPlacementWithLayout( + targetNode, + edge.targetHandle, + legacyComponentSymbolLayout(targetSymbol), + ); + if ( + !sourcePort || + !targetPort || + Math.hypot( + targetPort.flowX - sourcePort.flowX, + targetPort.flowY - sourcePort.flowY, + ) > CONTACT_GEOMETRY_TOLERANCE + ) { + continue; + } + + preservedContactEdgeCount += 1; + const sourceConstraints = constraints.get(sourceNode.id) ?? []; + sourceConstraints.push({ + neighborId: targetNode.id, + ownPortName: edge.sourceHandle, + neighborPortName: edge.targetHandle, + }); + constraints.set(sourceNode.id, sourceConstraints); + const targetConstraints = constraints.get(targetNode.id) ?? []; + targetConstraints.push({ + neighborId: sourceNode.id, + ownPortName: edge.targetHandle, + neighborPortName: edge.sourceHandle, + }); + constraints.set(targetNode.id, targetConstraints); + } + + const migratedById = new Map(preferredById); + const visited = new Set(); + let conflictingContactConstraintCount = 0; + for (const rootNode of nodes) { + if (visited.has(rootNode.id) || !constraints.has(rootNode.id)) { + continue; + } + const assignedPositions = new Map(); + const componentNodeIds: string[] = []; + const rootPreferred = preferredById.get(rootNode.id); + if (!rootPreferred) { + continue; + } + assignedPositions.set(rootNode.id, { ...rootPreferred.position }); + const queue = [rootNode.id]; + visited.add(rootNode.id); + + while (queue.length > 0) { + const nodeId = queue.shift() as string; + componentNodeIds.push(nodeId); + const node = originalById.get(nodeId); + const nodePosition = assignedPositions.get(nodeId); + if (!node || !nodePosition) { + continue; + } + const nodeLayout = componentSymbolLayout( + node.data.symbol ?? node.data.componentType, + ); + for (const constraint of constraints.get(nodeId) ?? []) { + const neighbor = originalById.get(constraint.neighborId); + if (!neighbor) { + continue; + } + const neighborLayout = componentSymbolLayout( + neighbor.data.symbol ?? neighbor.data.componentType, + ); + const ownPort = nodePortPlacementWithLayout( + node, + constraint.ownPortName, + nodeLayout, + ); + const neighborPort = nodePortPlacementWithLayout( + neighbor, + constraint.neighborPortName, + neighborLayout, + ); + if (!ownPort || !neighborPort) { + continue; + } + const proposedNeighborPosition = { + x: nodePosition.x + ownPort.x - neighborPort.x, + y: nodePosition.y + ownPort.y - neighborPort.y, + }; + const assignedNeighborPosition = assignedPositions.get(neighbor.id); + if (assignedNeighborPosition) { + if ( + Math.hypot( + assignedNeighborPosition.x - proposedNeighborPosition.x, + assignedNeighborPosition.y - proposedNeighborPosition.y, + ) > CONTACT_GEOMETRY_TOLERANCE + ) { + conflictingContactConstraintCount += 1; + } + continue; + } + assignedPositions.set(neighbor.id, proposedNeighborPosition); + visited.add(neighbor.id); + queue.push(neighbor.id); + } + } + + if (componentNodeIds.length === 0) { + continue; + } + const preferredCenter = componentNodeIds.reduce( + (sum, nodeId) => { + const node = originalById.get(nodeId) as SimulationNode; + const preferred = preferredById.get(nodeId) as SimulationNode; + const size = nodeFrameDimensions( + componentSymbolLayout(node.data.symbol ?? node.data.componentType), + normalizeNodeRotation(node.data.rotation), + ); + return { + x: sum.x + preferred.position.x + size.width / 2, + y: sum.y + preferred.position.y + size.height / 2, + }; + }, + { x: 0, y: 0 }, + ); + const assignedCenter = componentNodeIds.reduce( + (sum, nodeId) => { + const node = originalById.get(nodeId) as SimulationNode; + const position = assignedPositions.get(nodeId) as { x: number; y: number }; + const size = nodeFrameDimensions( + componentSymbolLayout(node.data.symbol ?? node.data.componentType), + normalizeNodeRotation(node.data.rotation), + ); + return { + x: sum.x + position.x + size.width / 2, + y: sum.y + position.y + size.height / 2, + }; + }, + { x: 0, y: 0 }, + ); + const translation = { + x: (preferredCenter.x - assignedCenter.x) / componentNodeIds.length, + y: (preferredCenter.y - assignedCenter.y) / componentNodeIds.length, + }; + for (const nodeId of componentNodeIds) { + const preferred = preferredById.get(nodeId) as SimulationNode; + const position = assignedPositions.get(nodeId) as { x: number; y: number }; + migratedById.set(nodeId, { + ...preferred, + position: { + x: position.x + translation.x, + y: position.y + translation.y, + }, + }); + } + } + + return { + nodes: nodes.map((node) => migratedById.get(node.id) ?? node), + migrated: true, + preservedContactEdgeCount, + conflictingContactConstraintCount, + }; +} + function connectionForContactPorts( draggedNode: SimulationNode, draggedPort: PortDefinition, @@ -2083,7 +2498,29 @@ function connectionForContactPorts( }; } -function findContactSnapCandidate( +type ContactSnapPair = ContactSnapMatch & { + distance: number; + draggedPortOrder: number; + targetNodeOrder: number; + targetPortOrder: number; +}; + +function compareContactSnapPairs( + first: ContactSnapPair, + second: ContactSnapPair, +) { + return ( + first.distance - second.distance || + first.draggedPortOrder - second.draggedPortOrder || + first.targetNodeOrder - second.targetNodeOrder || + first.targetPortOrder - second.targetPortOrder || + first.draggedPortName.localeCompare(second.draggedPortName) || + first.targetNodeId.localeCompare(second.targetNodeId) || + first.targetPortName.localeCompare(second.targetPortName) + ); +} + +function availableContactSnapPairs( draggedNode: SimulationNode, nodes: SimulationNode[], edges: SimulationEdge[], @@ -2099,7 +2536,10 @@ function findContactSnapCandidate( } }); - let nearest: (ContactSnapCandidate & { distance: number }) | null = null; + const pairs: ContactSnapPair[] = []; + const draggedPortOrder = new Map( + draggedNode.data.ports.map((port, index) => [port.name, index] as const), + ); const draggedPlacements = nodePortFlowPlacements(draggedNode); for (const draggedPlacement of draggedPlacements) { if ( @@ -2109,10 +2549,13 @@ function findContactSnapCandidate( ) { continue; } - for (const targetNode of nodes) { + for (const [targetNodeOrder, targetNode] of nodes.entries()) { if (targetNode.id === draggedNode.id) { 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( @@ -2126,7 +2569,7 @@ function findContactSnapCandidate( targetPlacement.flowX - draggedPlacement.flowX, targetPlacement.flowY - draggedPlacement.flowY, ); - if (distance > maximumDistance || (nearest && distance >= nearest.distance)) { + if (distance > maximumDistance) { continue; } const connection = connectionForContactPorts( @@ -2138,32 +2581,135 @@ function findContactSnapCandidate( if (!canConnectPorts(nodes, edges, connection)) { continue; } - nearest = { + pairs.push({ connection, distance, - draggedNodeId: draggedNode.id, + draggedPortOrder: + draggedPortOrder.get(draggedPlacement.port.name) ?? + Number.MAX_SAFE_INTEGER, draggedPortName: draggedPlacement.port.name, - snappedPosition: { - x: - draggedNode.position.x + - targetPlacement.flowX - - draggedPlacement.flowX, - y: - draggedNode.position.y + - targetPlacement.flowY - - draggedPlacement.flowY, - }, targetNodeId: targetNode.id, + targetNodeOrder, + targetPortOrder: + targetPortOrder.get(targetPlacement.port.name) ?? + Number.MAX_SAFE_INTEGER, targetPortName: targetPlacement.port.name, - }; + }); } } } - if (!nearest) { + + return pairs.sort(compareContactSnapPairs); +} + +function maximumContactSnapMatches(pairs: ContactSnapPair[]) { + // 用二分图最大匹配保证同一批吸附中,每个拖动端口和目标端口都只占用一次。 + const pairsByDraggedPort = new Map(); + pairs.forEach((pair) => { + const current = pairsByDraggedPort.get(pair.draggedPortName) ?? []; + current.push(pair); + pairsByDraggedPort.set(pair.draggedPortName, current); + }); + const pairByTargetPort = new Map(); + + const assignDraggedPort = ( + draggedPortName: string, + visitedTargetPorts: Set, + ): boolean => { + for (const pair of pairsByDraggedPort.get(draggedPortName) ?? []) { + const targetKey = portConnectionKey(pair.targetNodeId, pair.targetPortName); + if (visitedTargetPorts.has(targetKey)) { + continue; + } + visitedTargetPorts.add(targetKey); + const currentPair = pairByTargetPort.get(targetKey); + if ( + currentPair && + !assignDraggedPort(currentPair.draggedPortName, visitedTargetPorts) + ) { + continue; + } + pairByTargetPort.set(targetKey, pair); + return true; + } + return false; + }; + + for (const draggedPortName of pairsByDraggedPort.keys()) { + assignDraggedPort(draggedPortName, new Set()); + } + + return [...pairByTargetPort.values()].sort(compareContactSnapPairs); +} + +function findContactSnapCandidate( + draggedNode: SimulationNode, + nodes: SimulationNode[], + edges: SimulationEdge[], + maximumDistance: number, +) { + const nearbyPairs = availableContactSnapPairs( + draggedNode, + nodes, + edges, + maximumDistance, + ); + const anchor = nearbyPairs[0]; + if (!anchor) { return null; } - const { distance: _distance, ...candidate } = nearest; - return candidate; + const draggedPlacement = nodePortFlowPlacement( + draggedNode, + anchor.draggedPortName, + ); + const targetNode = nodes.find((node) => node.id === anchor.targetNodeId); + const targetPlacement = targetNode + ? nodePortFlowPlacement(targetNode, anchor.targetPortName) + : undefined; + if (!draggedPlacement || !targetPlacement) { + return null; + } + const snappedPosition = { + x: + draggedNode.position.x + + targetPlacement.flowX - + draggedPlacement.flowX, + y: + draggedNode.position.y + + targetPlacement.flowY - + draggedPlacement.flowY, + }; + const snappedDraggedNode = { + ...draggedNode, + position: snappedPosition, + }; + const snappedNodes = nodes.map((node) => + node.id === draggedNode.id ? snappedDraggedNode : node, + ); + // 多个接口必须共享同一个组件落点;只连接该落点下真正重合的接口, + // 避免一个组件同时被互相冲突的接口位置“拉向”不同方向。 + const exactPairs = availableContactSnapPairs( + snappedDraggedNode, + snappedNodes, + edges, + CONTACT_GEOMETRY_TOLERANCE, + ); + const matches = maximumContactSnapMatches(exactPairs).map( + ({ + connection, + draggedPortName, + targetNodeId, + targetPortName, + }) => ({ connection, draggedPortName, targetNodeId, targetPortName }), + ); + if (matches.length === 0) { + return null; + } + return { + draggedNodeId: draggedNode.id, + matches, + snappedPosition, + }; } function sameContactSnapCandidate( @@ -2175,9 +2721,20 @@ function sameContactSnapCandidate( (Boolean(first) && Boolean(second) && first?.draggedNodeId === second?.draggedNodeId && - first?.draggedPortName === second?.draggedPortName && - first?.targetNodeId === second?.targetNodeId && - first?.targetPortName === second?.targetPortName) + first?.snappedPosition.x === second?.snappedPosition.x && + first?.snappedPosition.y === second?.snappedPosition.y && + first?.matches.length === second?.matches.length && + first?.matches.every((match, index) => { + const other = second?.matches[index]; + if (!other) { + return false; + } + return ( + match.draggedPortName === other.draggedPortName && + match.targetNodeId === other.targetNodeId && + match.targetPortName === other.targetPortName + ); + })) ); } @@ -2212,9 +2769,15 @@ function separateDeletedContactEdges( return; } const sourceSize = nodeFrameDimensions( + componentSymbolLayout( + sourceNode.data.symbol ?? sourceNode.data.componentType, + ), normalizeNodeRotation(sourceNode.data.rotation), ); const targetSize = nodeFrameDimensions( + componentSymbolLayout( + targetNode.data.symbol ?? targetNode.data.componentType, + ), normalizeNodeRotation(targetNode.data.rotation), ); const centerDelta = { @@ -3129,6 +3692,15 @@ function FlowWorkbench() { }); const reactFlow = useReactFlow(); const reactFlowStore = useStoreApi(); + const multipleNodesSelected = useStore((flowState) => { + let selectedCount = 0; + for (const node of flowState.nodeLookup.values()) { + if (node.selected && (selectedCount += 1) > 1) { + return true; + } + } + return false; + }); const fileInputRef = useRef(null); const workbenchRef = useRef(null); const modelingPaneResizeRef = useRef(null); @@ -3146,6 +3718,11 @@ function FlowWorkbench() { const paletteIconPreviewTimerRef = useRef(null); const suppressCancelledConnectionRef = useRef(false); const contactDragSessionRef = useRef(null); + const tightSelectionRectRef = useRef(null); + const tightSelectionFrameRef = useRef(null); + const tightSelectionNodeIdsRef = useRef>(new Set()); + const tightSelectionPointerIdRef = useRef(null); + const tightSelectionPointerTargetRef = useRef(null); nodesRef.current = nodes; edgesRef.current = edges; @@ -3169,6 +3746,293 @@ function FlowWorkbench() { clearContactSnapCandidate(); }, [clearContactSnapCandidate, reactFlowStore]); + const applyTightSelectionRect = useCallback( + (selectionRect: CanvasSelectionRect) => { + const flowState = reactFlowStore.getState(); + const flowRoot = flowState.domNode; + if (!flowRoot) { + return; + } + + const selectedNodeIds = new Set(); + if (selectionRect.width > 0 && selectionRect.height > 0) { + const flowBounds = flowRoot.getBoundingClientRect(); + const selectionBounds: ScreenRectangle = { + left: flowBounds.left + selectionRect.x, + top: flowBounds.top + selectionRect.y, + right: flowBounds.left + selectionRect.x + selectionRect.width, + bottom: flowBounds.top + selectionRect.y + selectionRect.height, + }; + const nodeElements = new Map( + Array.from( + flowRoot.querySelectorAll( + ".react-flow__node[data-id]", + ), + ).flatMap((element) => + element.dataset.id ? [[element.dataset.id, element] as const] : [], + ), + ); + + for (const internalNode of flowState.nodeLookup.values()) { + if (internalNode.hidden || internalNode.selectable === false) { + continue; + } + const nodeElement = nodeElements.get(internalNode.id); + if (!nodeElement) { + continue; + } + const selectionTarget = + nodeElement.querySelector( + '[data-symbol-envelope="true"]', + ) ?? nodeElement; + const targetBounds = selectionTarget.getBoundingClientRect(); + if ( + targetBounds.width > 0 && + targetBounds.height > 0 && + screenRectanglesOverlap(selectionBounds, targetBounds) + ) { + selectedNodeIds.add(internalNode.id); + } + } + } + + tightSelectionNodeIdsRef.current = selectedNodeIds; + // React Flow 内部状态和受控 nodes 的更新时序不同;两侧任一不一致都 + // 强制写回,避免选中框、工具栏及接口编号各自看到不同的选择集合。 + const nodesNeedCorrection = + nodesRef.current.some( + (node) => Boolean(node.selected) !== selectedNodeIds.has(node.id), + ) || + Array.from(flowState.nodeLookup.values()).some( + (node) => Boolean(node.selected) !== selectedNodeIds.has(node.id), + ); + if (nodesNeedCorrection) { + setNodes((currentNodes) => { + let changed = false; + const nextNodes = currentNodes.map((node) => { + const selected = selectedNodeIds.has(node.id); + const internalSelected = Boolean( + flowState.nodeLookup.get(node.id)?.selected, + ); + if ( + Boolean(node.selected) === selected && + internalSelected === selected + ) { + return node; + } + changed = true; + return { ...node, selected }; + }); + const resolvedNodes = changed ? nextNodes : currentNodes; + nodesRef.current = resolvedNodes; + return resolvedNodes; + }); + } + const internallySelectedEdgeIds = new Set( + Array.from(flowState.edgeLookup.values()) + .filter((edge) => Boolean(edge.selected)) + .map((edge) => edge.id), + ); + const edgesNeedCorrection = + internallySelectedEdgeIds.size > 0 || + edgesRef.current.some((edge) => Boolean(edge.selected)); + if (edgesNeedCorrection) { + setEdges((currentEdges) => { + let changed = false; + const nextEdges = currentEdges.map((edge) => { + // 批量框选只选择组件,不额外高亮其连接线。 + if (!edge.selected && !internallySelectedEdgeIds.has(edge.id)) { + return edge; + } + changed = true; + return { ...edge, selected: false }; + }); + const resolvedEdges = changed ? nextEdges : currentEdges; + edgesRef.current = resolvedEdges; + return resolvedEdges; + }); + } + + setSelectedNodeId( + selectedNodeIds.size === 1 + ? (selectedNodeIds.values().next().value ?? null) + : null, + ); + setSelectedEdgeId(null); + }, + [reactFlowStore, setEdges, setNodes], + ); + + const flushTightSelection = useCallback(() => { + if (tightSelectionFrameRef.current !== null) { + window.cancelAnimationFrame(tightSelectionFrameRef.current); + tightSelectionFrameRef.current = null; + } + const selectionRect = tightSelectionRectRef.current; + if (selectionRect) { + applyTightSelectionRect(selectionRect); + } + }, [applyTightSelectionRect]); + + const finishTightSelection = useCallback(() => { + flushTightSelection(); + const hasSelectedNodes = tightSelectionNodeIdsRef.current.size > 0; + tightSelectionPointerIdRef.current = null; + tightSelectionPointerTargetRef.current = null; + // React Flow 会在 onSelectionEnd 返回后再次写入原始节点框结果,因此延后一拍校正内部状态。 + window.queueMicrotask(() => { + const completedRect = tightSelectionRectRef.current; + if (completedRect) { + applyTightSelectionRect(completedRect); + } + const flowState = reactFlowStore.getState(); + if ( + !flowState.userSelectionActive && + flowState.nodesSelectionActive !== hasSelectedNodes + ) { + reactFlowStore.setState({ nodesSelectionActive: hasSelectedNodes }); + } + }); + }, [applyTightSelectionRect, flushTightSelection, reactFlowStore]); + + const cancelTightSelection = useCallback(() => { + const flowState = reactFlowStore.getState(); + if (!flowState.userSelectionActive && !flowState.userSelectionRect) { + return; + } + flushTightSelection(); + const hasSelectedNodes = tightSelectionRectRef.current + ? tightSelectionNodeIdsRef.current.size > 0 + : nodesRef.current.some((node) => Boolean(node.selected)); + reactFlowStore.setState({ + userSelectionActive: false, + userSelectionRect: null, + nodesSelectionActive: hasSelectedNodes, + }); + tightSelectionPointerIdRef.current = null; + tightSelectionPointerTargetRef.current = null; + }, [flushTightSelection, reactFlowStore]); + + const handleTightSelectionPointerCancel = useCallback(() => { + const flowState = reactFlowStore.getState(); + if (flowState.userSelectionActive) { + // React Flow 12.11 的 pointercancel 不会重置其私有 selectionInProgress; + // 在状态仍处于框选模式时补发一次被内部 capture 吞掉的 click,只用于清理该标记。 + flowState.domNode + ?.querySelector(".react-flow__pane") + ?.click(); + } + cancelTightSelection(); + }, [cancelTightSelection, reactFlowStore]); + + const rememberFlowPointer = useCallback( + (event: ReactPointerEvent) => { + if (event.button === 0 && event.isPrimary) { + tightSelectionPointerIdRef.current = event.pointerId; + tightSelectionPointerTargetRef.current = + event.target instanceof Element ? event.target : null; + } + }, + [], + ); + + const cancelTightSelectionOnWindowBlur = useCallback(() => { + const flowState = reactFlowStore.getState(); + if (!flowState.userSelectionActive && !flowState.userSelectionRect) { + return; + } + const pointerId = tightSelectionPointerIdRef.current; + const pointerTarget = tightSelectionPointerTargetRef.current; + if ( + pointerTarget && + pointerId !== null && + pointerTarget.hasPointerCapture(pointerId) + ) { + try { + // React Flow 会在实际 pointer target 上建立捕获;向同一元素发取消事件, + // 事件冒泡到 pane 后可停止其私有自动平移循环。 + pointerTarget.dispatchEvent( + new PointerEvent("pointercancel", { + bubbles: true, + isPrimary: true, + pointerId, + pointerType: "mouse", + }), + ); + } finally { + const currentState = reactFlowStore.getState(); + if (currentState.userSelectionActive || currentState.userSelectionRect) { + cancelTightSelection(); + } + } + return; + } + cancelTightSelection(); + }, [cancelTightSelection, reactFlowStore]); + + useEffect(() => { + const unsubscribe = reactFlowStore.subscribe((flowState, previousState) => { + const selectionRect = flowState.userSelectionRect; + if (selectionRect === previousState.userSelectionRect) { + return; + } + if ( + selectionRect && + !flowState.userSelectionActive && + selectionRect.width === 0 && + selectionRect.height === 0 + ) { + if (tightSelectionFrameRef.current !== null) { + window.cancelAnimationFrame(tightSelectionFrameRef.current); + tightSelectionFrameRef.current = null; + } + tightSelectionRectRef.current = null; + tightSelectionNodeIdsRef.current = new Set( + nodesRef.current + .filter((node) => Boolean(node.selected)) + .map((node) => node.id), + ); + return; + } + if ( + !selectionRect || + !flowState.userSelectionActive + ) { + return; + } + + tightSelectionRectRef.current = { + x: selectionRect.x, + y: selectionRect.y, + width: selectionRect.width, + height: selectionRect.height, + }; + if (tightSelectionFrameRef.current === null) { + tightSelectionFrameRef.current = window.requestAnimationFrame(() => { + tightSelectionFrameRef.current = null; + const pendingRect = tightSelectionRectRef.current; + if (pendingRect) { + applyTightSelectionRect(pendingRect); + } + }); + } + }); + + return () => { + unsubscribe(); + if (tightSelectionFrameRef.current !== null) { + window.cancelAnimationFrame(tightSelectionFrameRef.current); + tightSelectionFrameRef.current = null; + } + }; + }, [applyTightSelectionRect, reactFlowStore]); + + useEffect(() => { + window.addEventListener("blur", cancelTightSelectionOnWindowBlur); + return () => + window.removeEventListener("blur", cancelTightSelectionOnWindowBlur); + }, [cancelTightSelectionOnWindowBlur]); + const dismissPaletteIconPreview = useCallback(() => { if (paletteIconPreviewTimerRef.current !== null) { window.clearTimeout(paletteIconPreviewTimerRef.current); @@ -3558,9 +4422,13 @@ function FlowWorkbench() { const loadSimulationResultsSnapshot = useCallback( (snapshot: SimulationResultsSnapshot) => { - setResultSnapshot(snapshot); - storeResultSnapshot(snapshot); - appendConsoleEntry("success", `已加载结果文件:${snapshot.project.name}`); + const migratedSnapshot = migrateSimulationResultsSnapshot(snapshot); + setResultSnapshot(migratedSnapshot); + storeResultSnapshot(migratedSnapshot); + appendConsoleEntry( + "success", + `已加载结果文件:${migratedSnapshot.project.name}`, + ); changeWorkspaceView("results"); }, [appendConsoleEntry, changeWorkspaceView], @@ -3627,6 +4495,27 @@ function FlowWorkbench() { }); return keys; }, [edges]); + const contactSnapOriginPortKeys = useMemo( + () => + new Set( + contactSnapCandidate?.matches.map((match) => + portConnectionKey( + contactSnapCandidate.draggedNodeId, + match.draggedPortName, + ), + ) ?? [], + ), + [contactSnapCandidate], + ); + const contactSnapTargetPortKeys = useMemo( + () => + new Set( + contactSnapCandidate?.matches.map((match) => + portConnectionKey(match.targetNodeId, match.targetPortName), + ) ?? [], + ), + [contactSnapCandidate], + ); const activeConnectionPort = useMemo( () => activePortConnection @@ -3650,15 +4539,9 @@ function FlowWorkbench() { let state: PortConnectionState = "idle"; if (connectedPortKeys.has(key)) { state = "connected"; - } else if ( - contactSnapCandidate?.draggedNodeId === node.id && - contactSnapCandidate.draggedPortName === port.name - ) { + } else if (contactSnapOriginPortKeys.has(key)) { state = "origin"; - } else if ( - contactSnapCandidate?.targetNodeId === node.id && - contactSnapCandidate.targetPortName === port.name - ) { + } else if (contactSnapTargetPortKeys.has(key)) { state = "compatible"; } else if ( activePortConnection?.nodeId === node.id && @@ -3689,6 +4572,7 @@ function FlowWorkbench() { className: className || undefined, data: { ...node.data, + hideConnectedPortIndexLabels: multipleNodesSelected, portConnectionStates, }, }; @@ -3696,8 +4580,10 @@ function FlowWorkbench() { [ activeConnectionPort, activePortConnection, - contactSnapCandidate, + contactSnapOriginPortKeys, + contactSnapTargetPortKeys, connectedPortKeys, + multipleNodesSelected, nodes, validationSeverityByNode, ], @@ -4079,15 +4965,27 @@ function FlowWorkbench() { } : node, ); - if (!canConnectPorts(snappedNodes, session.effectiveEdges, candidate.connection)) { + const createdEdges: SimulationEdge[] = []; + const timestamp = Date.now(); + let connectedEdges = session.effectiveEdges; + candidate.matches.forEach((match, index) => { + const contactEdge: SimulationEdge = { + ...match.connection, + id: `edge-contact-${match.connection.source}-${match.connection.sourceHandle}-${match.connection.target}-${match.connection.targetHandle}-${timestamp}-${index}`, + type: CONTACT_AWARE_EDGE_TYPE, + }; + if ( + !canConnectPorts(snappedNodes, connectedEdges, contactEdge) || + !isContactEdgeGeometry(snappedNodes, contactEdge) + ) { + return; + } + connectedEdges = addEdge(contactEdge, connectedEdges); + createdEdges.push(contactEdge); + }); + if (createdEdges.length === 0) { return; } - const contactEdge: SimulationEdge = { - ...candidate.connection, - id: `edge-contact-${candidate.connection.source}-${candidate.connection.sourceHandle}-${candidate.connection.target}-${candidate.connection.targetHandle}-${Date.now()}`, - type: CONTACT_AWARE_EDGE_TYPE, - }; - const connectedEdges = addEdge(contactEdge, session.effectiveEdges); nodesRef.current = snappedNodes; edgesRef.current = connectedEdges; setNodes(snappedNodes); @@ -4095,7 +4993,9 @@ function FlowWorkbench() { setSelectedEdgeId(null); appendConsoleEntry( "info", - `接口已吸附连接 ${candidate.connection.source}.${candidate.connection.sourceHandle ?? "port"} 与 ${candidate.connection.target}.${candidate.connection.targetHandle ?? "port"}`, + createdEdges.length === 1 + ? `接口已吸附连接 ${createdEdges[0].source}.${createdEdges[0].sourceHandle ?? "port"} 与 ${createdEdges[0].target}.${createdEdges[0].targetHandle ?? "port"}` + : `已吸附连接 ${createdEdges.length} 组接口`, ); }, [appendConsoleEntry, clearContactSnapCandidate, reactFlow, setEdges, setNodes], @@ -4174,16 +5074,34 @@ function FlowWorkbench() { dismissPaletteIconPreview(); event.dataTransfer.setData("application/system-component", type); event.dataTransfer.effectAllowed = "move"; - const icon = event.currentTarget.querySelector(".palette-icon"); - if (icon) { + const definition = componentDefinitions.find((item) => item.type === type); + const paletteSymbol = event.currentTarget.querySelector( + ".palette-icon > .component-symbol", + ); + if (definition && paletteSymbol) { + const layout = componentSymbolLayout(definition.symbol); + const viewportZoom = Math.max(reactFlow.getZoom(), 0.01); + const canvasViewBox = `${layout.viewBox.x} ${layout.viewBox.y} ${layout.viewBox.width} ${layout.viewBox.height}`; const preview = document.createElement("div"); - const previewIcon = icon.cloneNode(true) as HTMLElement; - const previewLabel = document.createElement("span"); + const previewSymbol = paletteSymbol.cloneNode(true) as HTMLElement; + // 模型库图标有独立的缩略图占比;待拖入态切回工作区 viewBox 和当前缩放,避免落下时跳变。 preview.className = "component-drag-preview"; preview.setAttribute("aria-hidden", "true"); - previewLabel.className = "component-drag-preview-label"; - previewLabel.textContent = peekNextNodeId(type); - preview.append(previewIcon, previewLabel); + preview.dataset.componentType = type; + preview.style.width = `${layout.nodeSize.width * viewportZoom}px`; + preview.style.height = `${layout.nodeSize.height * viewportZoom}px`; + previewSymbol.classList.remove("component-symbol-palette"); + previewSymbol.classList.add("component-symbol-canvas"); + previewSymbol.removeAttribute("data-palette-occupancy"); + previewSymbol.dataset.symbolViewBox = canvasViewBox; + previewSymbol.style.width = `${layout.symbolSize.width}px`; + previewSymbol.style.height = `${layout.symbolSize.height}px`; + previewSymbol.style.transform = `scale(${viewportZoom})`; + previewSymbol.style.transformOrigin = "center"; + previewSymbol + .querySelector(".component-symbol-svg") + ?.setAttribute("viewBox", canvasViewBox); + preview.append(previewSymbol); document.body.appendChild(preview); event.dataTransfer.setDragImage( preview, @@ -4203,7 +5121,7 @@ function FlowWorkbench() { return; } - const position = reactFlow.screenToFlowPosition({ + const dropPoint = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY, }); @@ -4227,6 +5145,21 @@ function FlowWorkbench() { gi: mediumIndex, }; } + const layout = componentSymbolLayout(data.symbol ?? data.componentType); + const initialSize = nodeFrameDimensions( + layout, + normalizeNodeRotation(data.rotation), + ); + const centeredPosition = { + x: dropPoint.x - initialSize.width / 2, + y: dropPoint.y - initialSize.height / 2, + }; + const position = snapEnabled + ? { + x: Math.round(centeredPosition.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(centeredPosition.y / GRID_SIZE) * GRID_SIZE, + } + : centeredPosition; const node: SimulationNode = { id, type: "simulationComponent", @@ -4256,6 +5189,7 @@ function FlowWorkbench() { recordHistory, setEdges, setNodes, + snapEnabled, ], ); @@ -4424,7 +5358,23 @@ function FlowWorkbench() { recordHistory(); pasteCountRef.current += 1; - const offset = GRID_SIZE * 2 * pasteCountRef.current; + const largestMinimumDimension = Math.max( + ...clipboard.nodes.map((node) => { + const layout = componentSymbolLayout( + node.data.symbol ?? node.data.componentType, + ); + const size = nodeFrameDimensions( + layout, + normalizeNodeRotation(node.data.rotation), + ); + return Math.min(size.width, size.height); + }), + ); + const offsetStep = Math.max( + GRID_SIZE * 2, + Math.ceil(largestMinimumDimension / 2 / GRID_SIZE) * GRID_SIZE, + ); + const offset = offsetStep * pasteCountRef.current; const idMap = new Map(); const usedNames = new Set(nodesRef.current.map((node) => node.data.label.trim())); let repairedPastedReferenceCount = 0; @@ -4570,9 +5520,19 @@ function FlowWorkbench() { componentDefinitions, project.mediumReferenceVersion, ); - const loadedNodes = legacyGasMigration.nodes; + const edgeNormalization = normalizeLoadedEdges( + legacyGasMigration.nodes, + project.edges, + ); + const loadedEdges = edgeNormalization.edges; + const presentationMigration = migrateLegacyPresentationLayout( + legacyGasMigration.nodes, + loadedEdges, + project.presentationLayoutVersion, + ); + const loadedNodes = presentationMigration.nodes; setNodes(loadedNodes); - setEdges(project.edges.map(normalizeLoadedEdge)); + setEdges(loadedEdges); setProjectName(project.name || "untitled"); setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation }); setSelectedNodeId(null); @@ -4593,6 +5553,30 @@ function FlowWorkbench() { `检测到无版本且没有介质定义的旧工程:已将 ${legacyGasMigration.repairedReferenceCount} 个历史默认介质物性引用 1 迁移到 0 — 空气(理想气体,内置默认)`, ); } + if (edgeNormalization.repairedEdgeCount > 0) { + appendConsoleEntry( + "info", + `已修复 ${edgeNormalization.repairedEdgeCount} 条旧连接的接口标识`, + ); + } + if (edgeNormalization.droppedEdgeCount > 0) { + appendConsoleEntry( + "warning", + `${edgeNormalization.droppedEdgeCount} 条连接无法唯一定位有效接口,已忽略`, + ); + } + if (presentationMigration.migrated) { + appendConsoleEntry( + "info", + `已将旧版固定尺寸布局迁移为逐元件布局,并保持 ${presentationMigration.preservedContactEdgeCount} 条接触连接`, + ); + } + if (presentationMigration.conflictingContactConstraintCount > 0) { + appendConsoleEntry( + "warning", + `${presentationMigration.conflictingContactConstraintCount} 个旧接触约束形成不一致闭环,已保留逻辑连接,请检查局部布局`, + ); + } if (indexedMedia.exhaustedDefinitionCount > 0) { appendConsoleEntry( "error", @@ -4989,12 +5973,16 @@ function FlowWorkbench() { const selectAllElements = useCallback(() => { setNodes((currentNodes) => currentNodes.map((node) => ({ ...node, selected: true }))); - setEdges((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: true }))); + setEdges((currentEdges) => + currentEdges.map((edge) => + edge.selected ? { ...edge, selected: false } : edge, + ), + ); setSelectedNodeId(null); setSelectedEdgeId(null); appendConsoleEntry( "info", - `已选择 ${nodesRef.current.length} 个组件和 ${edgesRef.current.length} 条连接`, + `已选择 ${nodesRef.current.length} 个组件`, ); }, [appendConsoleEntry, setEdges, setNodes]); @@ -6034,6 +7022,8 @@ function FlowWorkbench() { event.dataTransfer.dropEffect = "move"; }} onDrop={onDrop} + onPointerCancelCapture={handleTightSelectionPointerCancel} + onPointerDownCapture={rememberFlowPointer} > ({ id: node.id, type: "simulationComponent", @@ -6520,10 +7512,11 @@ function normalizeLoadedPorts( rawPort !== null && typeof rawPort === "object" ? (rawPort as Record) : undefined; - const name = typeof rawPort === "string" ? rawPort : record?.name; - if (typeof name !== "string" || !name.trim()) { + const rawName = typeof rawPort === "string" ? rawPort : record?.name; + if (typeof rawName !== "string" || !rawName.trim()) { return []; } + const name = rawName.trim(); const registered = definition?.ports.find((port) => port.name === name); const kind: PortKind = @@ -6544,7 +7537,7 @@ function normalizeLoadedPorts( kind, domain: typeof record?.domain === "string" && record.domain.trim() - ? record.domain + ? record.domain.trim() : registered?.domain ?? (kind === "physical" ? "pneumatic" : "signal"), nominalRole, ...(kind === "physical" ? { positiveFlowDirection: "intoComponent" as const } : {}), @@ -6553,7 +7546,7 @@ function normalizeLoadedPorts( ]; }); if (!definition) { - return loadedPorts; + return [...new Map(loadedPorts.map((port) => [port.name, port])).values()]; } const loadedByName = new Map(loadedPorts.map((port) => [port.name, port])); @@ -6658,15 +7651,109 @@ function connectionIdentity(edge: SimulationEdge, unordered: boolean) { return endpoints.join(unordered ? "--" : "->"); } -function normalizeLoadedEdge(edge: ProjectEdgePayload): SimulationEdge { - return { - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: "step", - }; +type LoadedEdgeNormalizationResult = { + edges: SimulationEdge[]; + repairedEdgeCount: number; + droppedEdgeCount: number; +}; + +/** + * 旧工程可能包含空白、缺失或已经失效的 Handle 名称。只有接口配对唯一时才 + * 自动补全;存在歧义的边直接忽略,避免画面有连线但端口仍被当作未连接。 + */ +function normalizeLoadedEdges( + nodes: SimulationNode[], + rawEdges: unknown[], +): LoadedEdgeNormalizationResult { + const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); + const occupiedPortKeys = new Set(); + const usedEdgeIds = new Set(); + const edges: SimulationEdge[] = []; + let repairedEdgeCount = 0; + let droppedEdgeCount = 0; + + rawEdges.forEach((rawEdge, index) => { + const record = + rawEdge !== null && typeof rawEdge === "object" + ? (rawEdge as Record) + : undefined; + const source = typeof record?.source === "string" ? record.source.trim() : ""; + const target = typeof record?.target === "string" ? record.target.trim() : ""; + const sourceNode = nodeById.get(source); + const targetNode = nodeById.get(target); + if (!sourceNode || !targetNode || source === target) { + droppedEdgeCount += 1; + return; + } + + const rawSourceHandle = + typeof record?.sourceHandle === "string" ? record.sourceHandle.trim() : ""; + const rawTargetHandle = + typeof record?.targetHandle === "string" ? record.targetHandle.trim() : ""; + const explicitSourcePort = sourceNode.data.ports.find( + (port) => port.name === rawSourceHandle, + ); + const explicitTargetPort = targetNode.data.ports.find( + (port) => port.name === rawTargetHandle, + ); + const sourceCandidates = explicitSourcePort + ? [explicitSourcePort] + : sourceNode.data.ports; + const targetCandidates = explicitTargetPort + ? [explicitTargetPort] + : targetNode.data.ports; + const compatiblePairs = sourceCandidates.flatMap((sourcePort) => + targetCandidates.flatMap((targetPort) => { + const sourceKey = portConnectionKey(source, sourcePort.name); + const targetKey = portConnectionKey(target, targetPort.name); + return arePortsCompatible(sourcePort, targetPort) && + !occupiedPortKeys.has(sourceKey) && + !occupiedPortKeys.has(targetKey) + ? [{ sourceKey, sourcePort, targetKey, targetPort }] + : []; + }), + ); + if (compatiblePairs.length !== 1) { + droppedEdgeCount += 1; + return; + } + + const pair = compatiblePairs[0]; + let id = + typeof record?.id === "string" && record.id.trim() + ? record.id.trim() + : `edge-loaded-${index + 1}`; + if (usedEdgeIds.has(id)) { + const baseId = id; + let suffix = 2; + while (usedEdgeIds.has(`${baseId}-${suffix}`)) { + suffix += 1; + } + id = `${baseId}-${suffix}`; + } + usedEdgeIds.add(id); + occupiedPortKeys.add(pair.sourceKey); + occupiedPortKeys.add(pair.targetKey); + edges.push({ + id, + source, + sourceHandle: pair.sourcePort.name, + target, + targetHandle: pair.targetPort.name, + type: "step", + }); + if ( + id !== record?.id || + source !== record?.source || + target !== record?.target || + pair.sourcePort.name !== record?.sourceHandle || + pair.targetPort.name !== record?.targetHandle + ) { + repairedEdgeCount += 1; + } + }); + + return { edges, repairedEdgeCount, droppedEdgeCount }; } function takeNextNodeId(componentType: string) { @@ -6675,10 +7762,6 @@ function takeNextNodeId(componentType: string) { return `${componentType}_${nextNumber}`; } -function peekNextNodeId(componentType: string) { - return `${componentType}_${nextNodeNumberByType[componentType] ?? 1}`; -} - function syncNextNodeNumbers(nodes: SimulationNode[]) { Object.keys(nextNodeNumberByType).forEach((type) => { delete nextNodeNumberByType[type]; @@ -7372,6 +8455,54 @@ function workspaceViewFromLocation(): WorkspaceView { return window.location.hash === "#/results" ? "results" : "modeling"; } +function migrateSimulationResultsSnapshot( + snapshot: SimulationResultsSnapshot, +): SimulationResultsSnapshot { + if (snapshot.project.presentationLayoutVersion === PRESENTATION_LAYOUT_VERSION) { + return snapshot; + } + const migrationNodes: SimulationNode[] = snapshot.project.nodes.map((node) => ({ + id: node.id, + type: "simulationComponent", + position: { ...node.position }, + data: { + label: node.data.label, + componentType: node.data.componentType, + modelType: node.data.modelType, + symbol: node.data.componentType, + ports: normalizeLoadedPorts(node.data.ports), + parameters: { ...node.data.parameters }, + parameterUnits: { ...(node.data.parameterUnits ?? {}) }, + parameterScientificNotation: {}, + rotation: normalizeNodeRotation(node.data.rotation), + mirrored: Boolean(node.data.mirrored), + }, + })); + const migrationEdges = normalizeLoadedEdges( + migrationNodes, + snapshot.project.edges, + ).edges; + const migration = migrateLegacyPresentationLayout( + migrationNodes, + migrationEdges, + snapshot.project.presentationLayoutVersion, + ); + const positionByNodeId = new Map( + migration.nodes.map((node) => [node.id, node.position] as const), + ); + return { + ...snapshot, + project: { + ...snapshot.project, + presentationLayoutVersion: PRESENTATION_LAYOUT_VERSION, + nodes: snapshot.project.nodes.map((node) => ({ + ...node, + position: { ...(positionByNodeId.get(node.id) ?? node.position) }, + })), + }, + }; +} + function loadStoredResultSnapshot(): SimulationResultsSnapshot | null { try { const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY); @@ -7383,7 +8514,7 @@ function loadStoredResultSnapshot(): SimulationResultsSnapshot | null { sessionStorage.removeItem(RESULT_SNAPSHOT_KEY); return null; } - return value; + return migrateSimulationResultsSnapshot(value); } catch { return null; } @@ -7409,6 +8540,10 @@ function isSimulationResultsSnapshot( typeof snapshot.createdAt === "string" && Boolean(snapshot.project) && typeof snapshot.project?.name === "string" && + (snapshot.project?.presentationLayoutVersion === undefined || + snapshot.project.presentationLayoutVersion === null || + snapshot.project.presentationLayoutVersion === + PRESENTATION_LAYOUT_VERSION) && Array.isArray(snapshot.project?.nodes) && Array.isArray(snapshot.project?.edges) && Boolean(snapshot.result) && @@ -7464,7 +8599,10 @@ function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null project.mediumReferenceVersion !== 1) || (project.amesimParameterEncodingVersion !== undefined && project.amesimParameterEncodingVersion !== null && - project.amesimParameterEncodingVersion !== 1) + project.amesimParameterEncodingVersion !== 1) || + (project.presentationLayoutVersion !== undefined && + project.presentationLayoutVersion !== null && + project.presentationLayoutVersion !== PRESENTATION_LAYOUT_VERSION) ) { return null; } diff --git a/frontend/src/ComponentSymbol.tsx b/frontend/src/ComponentSymbol.tsx index 703abb0..8f83cc2 100644 --- a/frontend/src/ComponentSymbol.tsx +++ b/frontend/src/ComponentSymbol.tsx @@ -1,8 +1,16 @@ -import { AmesimPnpl01Symbol } from "./componentSymbols/boundary"; +import { useLayoutEffect, useRef, useState } from "react"; + +import { + AmesimPnpl01Symbol, + PNPL01_PORT_ANCHOR, +} from "./componentSymbols/boundary"; import { AmesimF000Symbol, + F000_PORT_ANCHOR, AmesimForcSymbol, + FORC_PORT_ANCHORS, AmesimLmechn1Symbol, + MECMAS21_PORT_ANCHORS, AmesimPnrp17Symbol, AmesimLstp00aSymbol, AmesimMecmas21Symbol, @@ -21,14 +29,31 @@ import { AmesimPnor001Symbol, AmesimPnvo001FixedSymbol, AmesimPnvo001SignalSymbol, + PNVO001_PORT_ANCHORS, } from "./componentSymbols/pneumatic"; -import { SymbolSvg } from "./componentSymbols/primitives"; -import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals"; -import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage"; +import { + SymbolSvg, + SymbolViewBoxProvider, +} from "./componentSymbols/primitives"; +import { + AmesimStep0Symbol, + AmesimUd00Symbol, + SIGNAL_OUTPUT_PORT_ANCHOR, +} from "./componentSymbols/signals"; +import { + AmesimPnch012Symbol, + AmesimPnch023Symbol, + PNCH012_PORT_ANCHORS, + PNCH023_PORT_ANCHORS, +} from "./componentSymbols/storage"; import type { + ComponentSymbolLayout, ComponentSymbolParameters, ComponentSymbolPortAnchors, ComponentSymbolPresentation, + ComponentSymbolSize, + ComponentSymbolSizeTier, + ComponentSymbolViewBox, SymbolDefinition, } from "./componentSymbols/types"; @@ -42,6 +67,125 @@ type ComponentSymbolProps = { parameters?: ComponentSymbolParameters; }; +const DEFAULT_SYMBOL_VIEW_BOX: ComponentSymbolViewBox = { + x: 0, + // 既有图形使用 y=0..48;上下各保留 8 个逻辑单位,统一为 64×64 方形画布。 + y: -8, + width: 64, + height: 64, +}; + +const SYMBOL_SIZE_BY_TIER: Record< + Exclude, + ComponentSymbolSize +> = { + compact: { width: 80, height: 80 }, + small: { width: 96, height: 96 }, + standard: { width: 112, height: 112 }, + large: { width: 128, height: 128 }, +}; + +/** 组件库按工作区尺寸档位表达元件的相对大小,工作区画布不使用这组比例。 */ +const PALETTE_OCCUPANCY_BY_TIER: Record = { + compact: 0.45, + small: 0.75, + standard: 0.9, + large: 0.9, + custom: 0.9, +}; + +function fittedPaletteViewBox( + bounds: Pick, + occupancy: number, +): ComponentSymbolViewBox | null { + const longestSide = Math.max(bounds.width, bounds.height); + if (!Number.isFinite(longestSide) || longestSide <= 0) { + return null; + } + const side = longestSide / occupancy; + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + const round = (value: number) => Math.round(value * 10_000) / 10_000; + return { + x: round(centerX - side / 2), + y: round(centerY - side / 2), + width: round(side), + height: round(side), + }; +} + +function sameViewBox( + first: ComponentSymbolViewBox, + second: ComponentSymbolViewBox, +) { + return ( + first.x === second.x && + first.y === second.y && + first.width === second.width && + first.height === second.height + ); +} + +const LEGACY_HORIZONTAL_PORT_ANCHORS: ComponentSymbolPortAnchors = { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], +}; +const LEGACY_PNVO001_SIGNAL_PORT_ANCHORS: ComponentSymbolPortAnchors = { + left: [ + { x: 26, y: 46 }, + { x: 2, y: 24 }, + ], + right: [{ x: 62, y: 24 }], +}; + +type CustomSymbolLayout = { + viewBox?: ComponentSymbolViewBox; + symbolSize: ComponentSymbolSize; + nodeSize?: ComponentSymbolSize; +}; + +function makeSymbolLayout( + tier: Exclude, + portAnchors?: ComponentSymbolPortAnchors, +): ComponentSymbolLayout; +function makeSymbolLayout( + tier: "custom", + portAnchors: ComponentSymbolPortAnchors | undefined, + custom: CustomSymbolLayout, +): ComponentSymbolLayout; +function makeSymbolLayout( + tier: ComponentSymbolSizeTier, + portAnchors?: ComponentSymbolPortAnchors, + custom?: CustomSymbolLayout, +): ComponentSymbolLayout { + const symbolSize = + tier === "custom" + ? custom?.symbolSize ?? SYMBOL_SIZE_BY_TIER.large + : SYMBOL_SIZE_BY_TIER[tier]; + return { + tier, + viewBox: custom?.viewBox ?? DEFAULT_SYMBOL_VIEW_BOX, + symbolSize, + // 两侧各保留 10px 给接口 Handle;无接口的自定义图标可显式收紧节点框。 + nodeSize: custom?.nodeSize ?? { + width: symbolSize.width + 20, + height: symbolSize.height, + }, + portAnchors, + }; +} + +/** 无接口元件不需要预留 Handle 空间,节点占地与对应档位的方形图标画布一致。 */ +function makePortlessSymbolLayout( + tier: Exclude, +): ComponentSymbolLayout { + const layout = makeSymbolLayout(tier); + return { + ...layout, + nodeSize: layout.symbolSize, + }; +} + function CylinderSymbol() { return ( @@ -164,175 +308,315 @@ function JunctionSymbol() { } const symbolRegistry = { - cylinder: { presentation: "framed", render: CylinderSymbol }, - tank: { presentation: "framed", render: TankSymbol }, - pipe: { presentation: "framed", render: PipeSymbol }, - orifice: { presentation: "framed", render: OrificeSymbol }, - tee: { presentation: "framed", render: TeeSymbol }, - generic: { presentation: "framed", render: GenericSymbol }, - signal: { presentation: "framed", render: SignalSymbol }, - junction: { presentation: "framed", render: JunctionSymbol }, - amesim_pnpl01: { presentation: "framed", render: AmesimPnpl01Symbol }, - amesim_pnch023: { presentation: "framed", render: AmesimPnch023Symbol }, - amesim_pnch012: { presentation: "framed", render: AmesimPnch012Symbol }, - amesim_pnor001: { presentation: "framed", render: AmesimPnor001Symbol }, + cylinder: { + presentation: "framed", + render: CylinderSymbol, + layout: makeSymbolLayout("small", { + left: [{ x: 3, y: 24 }], + right: [{ x: 61, y: 24 }], + }), + }, + tank: { + presentation: "framed", + render: TankSymbol, + layout: makeSymbolLayout("small", { left: [{ x: 2, y: 24 }] }), + }, + pipe: { + presentation: "framed", + render: PipeSymbol, + layout: makeSymbolLayout("compact", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + orifice: { + presentation: "framed", + render: OrificeSymbol, + layout: makeSymbolLayout("compact", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + tee: { + presentation: "framed", + render: TeeSymbol, + layout: makeSymbolLayout("compact", { + left: [{ x: 2, y: 24 }], + right: [ + { x: 62, y: 12 }, + { x: 62, y: 36 }, + ], + }), + }, + generic: { + presentation: "framed", + render: GenericSymbol, + layout: makeSymbolLayout("small", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + signal: { + presentation: "framed", + render: SignalSymbol, + layout: makeSymbolLayout("small", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + junction: { + presentation: "framed", + render: JunctionSymbol, + layout: makeSymbolLayout("compact", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + amesim_pnpl01: { + presentation: "framed", + render: AmesimPnpl01Symbol, + layout: { + ...makeSymbolLayout("compact", { left: [PNPL01_PORT_ANCHOR] }), + legacyPortAnchors: { left: [{ x: 2, y: 24 }] }, + }, + }, + amesim_pnch023: { + presentation: "framed", + render: AmesimPnch023Symbol, + layout: { + ...makeSymbolLayout("small", { + left: [PNCH023_PORT_ANCHORS.left], + right: [PNCH023_PORT_ANCHORS.right], + }), + legacyPortAnchors: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + }, + }, + amesim_pnch012: { + presentation: "framed", + render: AmesimPnch012Symbol, + layout: { + ...makeSymbolLayout("small", { + // 保留第一对左右接口,将第二对对应到参考图的上下接口。 + left: [ + PNCH012_PORT_ANCHORS.left, + PNCH012_PORT_ANCHORS.top, + ], + right: [ + PNCH012_PORT_ANCHORS.right, + PNCH012_PORT_ANCHORS.bottom, + ], + }), + legacyPortAnchors: { + left: [ + { x: 2, y: 16 }, + { x: 2, y: 32 }, + ], + right: [ + { x: 62, y: 16 }, + { x: 62, y: 32 }, + ], + }, + }, + }, + amesim_pnor001: { + presentation: "framed", + render: AmesimPnor001Symbol, + layout: { + ...makeSymbolLayout("small", { + left: [PNVO001_PORT_ANCHORS.physicalLeft], + right: [PNVO001_PORT_ANCHORS.physicalRight], + }), + legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS, + }, + }, amesim_pnvo001_fixed: { presentation: "framed", render: AmesimPnvo001FixedSymbol, + layout: { + ...makeSymbolLayout("small", { + left: [PNVO001_PORT_ANCHORS.physicalLeft], + right: [PNVO001_PORT_ANCHORS.physicalRight], + }), + legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS, + }, }, amesim_pnvo001: { presentation: "framed", render: AmesimPnvo001SignalSymbol, + layout: { + ...makeSymbolLayout("small", { + left: [ + PNVO001_PORT_ANCHORS.signal, + PNVO001_PORT_ANCHORS.physicalLeft, + ], + right: [PNVO001_PORT_ANCHORS.physicalRight], + }), + legacyPortAnchors: LEGACY_PNVO001_SIGNAL_PORT_ANCHORS, + }, + }, + amesim_pnl00r: { + presentation: "framed", + render: AmesimPnl00rSymbol, + layout: makeSymbolLayout("compact", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + amesim_pnl0001: { + presentation: "framed", + render: AmesimPnl0001Symbol, + layout: makeSymbolLayout("small", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + amesim_pnl0002: { + presentation: "framed", + render: AmesimPnl0002Symbol, + layout: makeSymbolLayout("small", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + amesim_pnl0003: { + presentation: "framed", + render: AmesimPnl0003Symbol, + layout: makeSymbolLayout("small", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + amesim_pn3node2: { + presentation: "framed", + render: AmesimPn3Node2Symbol, + layout: makeSymbolLayout("compact", { + left: [{ x: 3, y: 24 }], + right: [ + { x: 61, y: 12 }, + { x: 61, y: 36 }, + ], + }), + }, + amesim_p4node2: { + presentation: "framed", + render: AmesimP4Node2Symbol, + layout: makeSymbolLayout("small", { + left: [{ x: 3, y: 24 }], + right: [ + { x: 61, y: 8 }, + { x: 61, y: 24 }, + { x: 61, y: 40 }, + ], + }), + }, + amesim_step0: { + presentation: "framed", + render: AmesimStep0Symbol, + layout: { + ...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }), + legacyPortAnchors: { right: [{ x: 60, y: 24 }] }, + }, + }, + amesim_ud00: { + presentation: "framed", + render: AmesimUd00Symbol, + layout: { + ...makeSymbolLayout("standard", { right: [SIGNAL_OUTPUT_PORT_ANCHOR] }), + legacyPortAnchors: { right: [{ x: 60, y: 24 }] }, + }, + }, + amesim_f000: { + presentation: "framed", + render: AmesimF000Symbol, + layout: { + ...makeSymbolLayout("compact", { right: [F000_PORT_ANCHOR] }), + legacyPortAnchors: { right: [{ x: 62, y: 24 }] }, + }, + }, + amesim_forc: { + presentation: "framed", + render: AmesimForcSymbol, + layout: { + ...makeSymbolLayout("standard", { + left: [FORC_PORT_ANCHORS.left], + right: [FORC_PORT_ANCHORS.right], + }), + legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS, + }, + }, + amesim_mecmas21: { + presentation: "framed", + render: AmesimMecmas21Symbol, + layout: { + ...makeSymbolLayout("standard", { + left: [MECMAS21_PORT_ANCHORS.left], + right: [MECMAS21_PORT_ANCHORS.right], + }), + legacyPortAnchors: LEGACY_HORIZONTAL_PORT_ANCHORS, + }, + variantKey: (parameters) => + `${parameters?.useFriction ?? ""}:${parameters?.stoptype ?? ""}`, + }, + amesim_lstp00a: { + presentation: "framed", + render: AmesimLstp00aSymbol, + layout: makeSymbolLayout("standard", { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }), + }, + amesim_lmechn1: { + presentation: "framed", + render: AmesimLmechn1Symbol, + layout: makeSymbolLayout( + "custom", + { + left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })), + right: [{ x: 59, y: 24 }], + }, + { + symbolSize: { width: 128, height: 128 }, + nodeSize: { width: 148, height: 128 }, + }, + ), + }, + amesim_pnrp17: { + presentation: "framed", + render: AmesimPnrp17Symbol, + // PNRP17 的当前 112×112 画布作为 standard 档位基准。 + layout: makeSymbolLayout("standard", { + left: [ + { x: 25.5, y: 48 }, + { x: 2, y: 4.4 }, + { x: 2, y: 24.1 }, + ], + right: [ + { x: 62, y: 4.4 }, + { x: 62, y: 24.1 }, + ], + }), }, - amesim_pnl00r: { presentation: "framed", render: AmesimPnl00rSymbol }, - amesim_pnl0001: { presentation: "framed", render: AmesimPnl0001Symbol }, - amesim_pnl0002: { presentation: "framed", render: AmesimPnl0002Symbol }, - amesim_pnl0003: { presentation: "framed", render: AmesimPnl0003Symbol }, - amesim_pn3node2: { presentation: "framed", render: AmesimPn3Node2Symbol }, - amesim_p4node2: { presentation: "framed", render: AmesimP4Node2Symbol }, - amesim_step0: { presentation: "framed", render: AmesimStep0Symbol }, - amesim_ud00: { presentation: "framed", render: AmesimUd00Symbol }, - amesim_f000: { presentation: "framed", render: AmesimF000Symbol }, - amesim_forc: { presentation: "framed", render: AmesimForcSymbol }, - amesim_mecmas21: { presentation: "framed", render: AmesimMecmas21Symbol }, - amesim_lstp00a: { presentation: "framed", render: AmesimLstp00aSymbol }, - amesim_lmechn1: { presentation: "framed", render: AmesimLmechn1Symbol }, - amesim_pnrp17: { presentation: "framed", render: AmesimPnrp17Symbol }, amesim_ideal_air_medium: { presentation: "bare", render: AmesimIdealAirMediumSymbol, + layout: makePortlessSymbolLayout("standard"), }, amesim_helium_medium: { presentation: "bare", render: AmesimHeliumMediumSymbol, + layout: makePortlessSymbolLayout("standard"), }, } satisfies Record; type RegisteredComponentSymbol = keyof typeof symbolRegistry; -const symbolPortAnchors: Partial< - Record -> = { - cylinder: { - left: [{ x: 3, y: 24 }], - right: [{ x: 61, y: 24 }], - }, - tank: { left: [{ x: 2, y: 24 }] }, - pipe: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - orifice: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - tee: { - left: [{ x: 2, y: 24 }], - right: [ - { x: 62, y: 12 }, - { x: 62, y: 36 }, - ], - }, - generic: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - signal: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - junction: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnpl01: { left: [{ x: 2, y: 24 }] }, - amesim_pnch023: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnch012: { - left: [ - { x: 2, y: 16 }, - { x: 2, y: 32 }, - ], - right: [ - { x: 62, y: 16 }, - { x: 62, y: 32 }, - ], - }, - amesim_pnor001: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnvo001_fixed: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnvo001: { - left: [ - { x: 26, y: 46 }, - { x: 2, y: 24 }, - ], - right: [{ x: 62, y: 24 }], - }, - amesim_pnl00r: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnl0001: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnl0002: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pnl0003: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_pn3node2: { - left: [{ x: 3, y: 24 }], - right: [ - { x: 61, y: 12 }, - { x: 61, y: 36 }, - ], - }, - amesim_p4node2: { - left: [{ x: 3, y: 24 }], - right: [ - { x: 61, y: 8 }, - { x: 61, y: 24 }, - { x: 61, y: 40 }, - ], - }, - amesim_step0: { right: [{ x: 60, y: 24 }] }, - amesim_ud00: { right: [{ x: 60, y: 24 }] }, - amesim_f000: { right: [{ x: 62, y: 24 }] }, - amesim_forc: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_mecmas21: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_lstp00a: { - left: [{ x: 2, y: 24 }], - right: [{ x: 62, y: 24 }], - }, - amesim_lmechn1: { - left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })), - right: [{ x: 59, y: 24 }], - }, - amesim_pnrp17: { - left: [ - { x: 25.5, y: 48 }, - { x: 2, y: 4.4 }, - { x: 2, y: 24.1 }, - ], - right: [{ x: 62, y: 4.4 }, { x: 62, y: 24.1 }], - }, +const fallbackComponentSymbolLayout: ComponentSymbolLayout = { + ...symbolRegistry.generic.layout, + // 未注册图标只显示通用边框和名称,接口应落在边框边缘而不是 SVG 锚点。 + portAnchors: undefined, }; function isRegisteredComponentSymbol(symbol: string): symbol is RegisteredComponentSymbol { @@ -351,12 +635,27 @@ export function componentSymbolPresentation( : "framed"; } +export function componentSymbolLayout(symbol: string): ComponentSymbolLayout { + return isRegisteredComponentSymbol(symbol) + ? symbolRegistry[symbol].layout + : fallbackComponentSymbolLayout; +} + export function componentSymbolPortAnchors( symbol: string, ): ComponentSymbolPortAnchors | undefined { - return isRegisteredComponentSymbol(symbol) - ? symbolPortAnchors[symbol] - : undefined; + return componentSymbolLayout(symbol).portAnchors; +} + +export function componentSymbolVariantKey( + symbol: string, + parameters?: ComponentSymbolParameters, +): string { + if (!isRegisteredComponentSymbol(symbol)) { + return ""; + } + const definition: SymbolDefinition = symbolRegistry[symbol]; + return definition.variantKey?.(parameters) ?? ""; } export function ComponentSymbol({ @@ -368,8 +667,16 @@ export function ComponentSymbol({ const resolvedSymbol: RegisteredComponentSymbol = isRegisteredComponentSymbol(symbol) ? symbol : "generic"; - const definition = symbolRegistry[resolvedSymbol]; + const definition: SymbolDefinition = symbolRegistry[resolvedSymbol]; const SymbolRenderer = definition.render; + const { layout } = definition; + const rootRef = useRef(null); + const [paletteViewBox, setPaletteViewBox] = useState( + layout.viewBox, + ); + const paletteOccupancy = PALETTE_OCCUPANCY_BY_TIER[layout.tier]; + const variantKey = definition.variantKey?.(parameters) ?? ""; + const renderedViewBox = surface === "palette" ? paletteViewBox : layout.viewBox; const rootClassName = [ "component-symbol", `component-symbol-${surface}`, @@ -379,14 +686,59 @@ export function ComponentSymbol({ .filter(Boolean) .join(" "); + useLayoutEffect(() => { + if (surface !== "palette") { + return; + } + const svg = rootRef.current?.querySelector("svg"); + if (!svg) { + return; + } + let bounds: DOMRect; + try { + bounds = svg.getBBox(); + } catch { + return; + } + const fittedViewBox = fittedPaletteViewBox(bounds, paletteOccupancy); + if (!fittedViewBox) { + return; + } + setPaletteViewBox((currentViewBox) => + sameViewBox(currentViewBox, fittedViewBox) + ? currentViewBox + : fittedViewBox, + ); + }, [ + layout.tier, + layout.viewBox.height, + layout.viewBox.width, + layout.viewBox.x, + layout.viewBox.y, + paletteOccupancy, + resolvedSymbol, + surface, + variantKey, + ]); + return ( ); } diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index b4d93c4..90d6c4f 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -132,6 +132,7 @@ export type SimulationResultsSnapshot = { createdAt: string; project: { name: string; + presentationLayoutVersion?: 1 | null; nodes: ResultProjectNode[]; edges: ResultProjectEdge[]; simulation: ResultSimulationConfig; @@ -3910,6 +3911,9 @@ function isSimulationResultsSnapshotValue( if ( !isRecord(project) || typeof project.name !== "string" || + (project.presentationLayoutVersion !== undefined && + project.presentationLayoutVersion !== null && + project.presentationLayoutVersion !== 1) || !Array.isArray(project.nodes) || !project.nodes.every(isResultProjectNode) || !Array.isArray(project.edges) || diff --git a/frontend/src/componentSymbols/boundary.tsx b/frontend/src/componentSymbols/boundary.tsx index 4115000..4c28a5d 100644 --- a/frontend/src/componentSymbols/boundary.tsx +++ b/frontend/src/componentSymbols/boundary.tsx @@ -1,15 +1,42 @@ -import { SYMBOL_ACCENT, SYMBOL_STROKE, SymbolSvg } from "./primitives"; +import { SymbolSvg } from "./primitives"; + +const PNPL01_STROKE = "#8b134f"; +const PNPL01_CONTENT_SCALE = 0.25; +const PNPL01_CONTENT_CENTER = { x: 32, y: 24 } as const; +const PNPL01_CONTENT_TRANSFORM = `translate(${PNPL01_CONTENT_CENTER.x} ${PNPL01_CONTENT_CENTER.y}) scale(${PNPL01_CONTENT_SCALE}) translate(${-PNPL01_CONTENT_CENTER.x} ${-PNPL01_CONTENT_CENTER.y})`; +const PNPL01_CROSS_CENTER = { + x: + PNPL01_CONTENT_CENTER.x + + (47 - PNPL01_CONTENT_CENTER.x) * PNPL01_CONTENT_SCALE, + y: PNPL01_CONTENT_CENTER.y, +} as const; + +export const PNPL01_PORT_ANCHOR = { + x: 16, + y: PNPL01_CONTENT_CENTER.y, +} as const; export function AmesimPnpl01Symbol() { return ( - - + + + + + + ); } diff --git a/frontend/src/componentSymbols/mechanical.tsx b/frontend/src/componentSymbols/mechanical.tsx index e6bda4b..a98d34f 100644 --- a/frontend/src/componentSymbols/mechanical.tsx +++ b/frontend/src/componentSymbols/mechanical.tsx @@ -12,9 +12,63 @@ const MECHANICAL_LINE_WIDTH = 1.8; const MECMAS21_STROKE = "#00af00"; const MECMAS21_HATCH = "#66cf66"; const MECMAS21_FILL = "#ffffff"; +const MECMAS21_CONTENT_SCALE = 0.72; +const MECMAS21_CONTENT_CENTER = { x: 32, y: 24 } as const; +const MECMAS21_CONTENT_TRANSFORM = `translate(${MECMAS21_CONTENT_CENTER.x} ${MECMAS21_CONTENT_CENTER.y}) scale(${MECMAS21_CONTENT_SCALE}) translate(${-MECMAS21_CONTENT_CENTER.x} ${-MECMAS21_CONTENT_CENTER.y})`; +const MECMAS21_SELECTION_ENVELOPE = { + x: 9.608, + y: 2.328, + width: 44.784, + height: 43.344, +} as const; +const F000_CONTENT_SCALE = 0.25; +const F000_CONTENT_CENTER = { x: 32, y: 24 } as const; +const F000_CONTENT_TRANSFORM = `translate(${F000_CONTENT_CENTER.x} ${F000_CONTENT_CENTER.y}) scale(${F000_CONTENT_SCALE}) translate(${-F000_CONTENT_CENTER.x} ${-F000_CONTENT_CENTER.y})`; +const F000_FRAME_RIGHT_X = + F000_CONTENT_CENTER.x + + (52 - F000_CONTENT_CENTER.x) * F000_CONTENT_SCALE; +export const F000_PORT_ANCHOR = { + x: 48, + y: 24, +} as const; +const FORC_STROKE = "#00af00"; +const FORC_CONTENT_SCALE = 0.64; +const FORC_CONTENT_CENTER = { x: 32, y: 24 } as const; +const FORC_CONTENT_TRANSFORM = `translate(${FORC_CONTENT_CENTER.x} ${FORC_CONTENT_CENTER.y}) scale(${FORC_CONTENT_SCALE}) translate(${-FORC_CONTENT_CENTER.x} ${-FORC_CONTENT_CENTER.y})`; +export const FORC_PORT_ANCHORS = { + left: { + x: + FORC_CONTENT_CENTER.x + + (2 - FORC_CONTENT_CENTER.x) * FORC_CONTENT_SCALE, + y: FORC_CONTENT_CENTER.y, + }, + right: { + x: + FORC_CONTENT_CENTER.x + + (62 - FORC_CONTENT_CENTER.x) * FORC_CONTENT_SCALE, + y: FORC_CONTENT_CENTER.y, + }, +} as const; +export const MECMAS21_PORT_ANCHORS = { + left: { + x: + MECMAS21_CONTENT_CENTER.x + + (2 - MECMAS21_CONTENT_CENTER.x) * MECMAS21_CONTENT_SCALE, + y: 24, + }, + right: { + x: + MECMAS21_CONTENT_CENTER.x + + (62 - MECMAS21_CONTENT_CENTER.x) * MECMAS21_CONTENT_SCALE, + y: 24, + }, +} as const; const PNRP17_STROKE = "#8b134f"; const PNRP17_FILL = "#cf9db6"; const PNRP17_LIGHT_FILL = "#f5ebf0"; +// PNRP17 使用最终像素线宽;几何缩放不再改变参考图中的粗细层级。 +const PNRP17_PRIMARY_STROKE_WIDTH = 2; +const PNRP17_DETAIL_STROKE_WIDTH = 1.5; const mechanicalStroke = { fill: "none", @@ -34,16 +88,16 @@ function MechanicalSocket({ side }: { side: "left" | "right" }) { strokeLinejoin="miter" > ); @@ -52,18 +106,28 @@ function MechanicalSocket({ side }: { side: "left" | "right" }) { export function AmesimF000Symbol() { return ( - - - + + + + + + ); } @@ -71,37 +135,64 @@ export function AmesimF000Symbol() { export function AmesimForcSymbol() { return ( - - - + + + + + + + + F + - - - F - - ); } @@ -141,7 +232,7 @@ export function AmesimMecmas21Symbol({ @@ -149,23 +240,24 @@ export function AmesimMecmas21Symbol({ data-mecmas-friction={frictionEnabled ? "enabled" : "disabled"} data-mecmas-limits={limitsEnabled ? "finite" : "unlimited"} data-mecmas-variant={variant} + transform={MECMAS21_CONTENT_TRANSFORM} > {showGuides ? ( {limitsEnabled ? ( )} ) : null} {frictionEnabled ? ( M + {/* 固定选择包络:参数切换为仅中央滑块时,虚线框仍保持完整状态大小。 */} + ); } @@ -322,27 +424,27 @@ export function AmesimPnrp17Symbol() { fill={PNRP17_FILL} stroke={PNRP17_STROKE} strokeLinejoin="miter" - strokeWidth="1.2" + strokeWidth={PNRP17_PRIMARY_STROKE_WIDTH} /> {[15.3, 33].map((centerY) => ( + - - - - ); +function scaledOrificePoint(x: number, y: number) { + return { + x: + ORIFICE_CONTENT_CENTER.x + + (x - ORIFICE_CONTENT_CENTER.x) * ORIFICE_CONTENT_SCALE, + y: + ORIFICE_CONTENT_CENTER.y + + (y - ORIFICE_CONTENT_CENTER.y) * ORIFICE_CONTENT_SCALE, + }; } -/** Constant-coefficient pneumatic orifice (AMESim PNOR001). */ -export function AmesimPnor001Symbol() { - return ( - - - - ); -} +export const PNVO001_PORT_ANCHORS = { + physicalLeft: scaledOrificePoint(0, 24), + physicalRight: scaledOrificePoint(64, 24), + signal: scaledOrificePoint(24.25, 56), +} as const; -function Pnvo001Body({ - signalControlled = false, -}: { - signalControlled?: boolean; -}) { +type OrificeControl = "none" | "fixed" | "signal"; + +function Pnvo001Body({ control }: { control: OrificeControl }) { return ( - <> + - - {signalControlled ? ( + {control === "none" ? null : ( + <> + + + + )} + {control === "signal" ? ( ) : null} - - + + ); +} + +/** 定系数气动孔口(AMESim PNOR001)。 */ +export function AmesimPnor001Symbol() { + return ( + + + ); } @@ -97,7 +99,7 @@ export function AmesimPnvo001FixedSymbol() { className="component-symbol-amesim-pnvo001-fixed-svg" domain="pneumatic" > - + ); } @@ -109,7 +111,7 @@ export function AmesimPnvo001SignalSymbol() { className="component-symbol-amesim-pnvo001-signal-svg" domain="pneumatic" > - + ); } diff --git a/frontend/src/componentSymbols/primitives.tsx b/frontend/src/componentSymbols/primitives.tsx index 3de202c..fceb7d8 100644 --- a/frontend/src/componentSymbols/primitives.tsx +++ b/frontend/src/componentSymbols/primitives.tsx @@ -1,4 +1,6 @@ -import type { ReactNode } from "react"; +import { createContext, useContext, type ReactNode } from "react"; + +import type { ComponentSymbolViewBox } from "./types"; export type SymbolDomain = | "neutral" @@ -11,25 +13,48 @@ type SymbolSvgProps = { children: ReactNode; className?: string; domain?: SymbolDomain; - viewBox?: string; + strokeScaling?: "independent" | "geometry"; }; +type SymbolViewBoxProviderProps = { + children: ReactNode; + viewBox: ComponentSymbolViewBox; +}; + +const SymbolViewBoxContext = createContext("0 -8 64 64"); + export const SYMBOL_STROKE = "var(--component-symbol-stroke, currentColor)"; export const SYMBOL_FILL = "var(--component-symbol-fill, #ffffff)"; export const SYMBOL_ACCENT = "var(--component-symbol-accent, currentColor)"; export const SYMBOL_MUTED = "var(--component-symbol-muted, #64748b)"; +/** 由图标注册表统一提供 SVG 画布,避免渲染器与接口布局分别维护 viewBox。 */ +export function SymbolViewBoxProvider({ + children, + viewBox, +}: SymbolViewBoxProviderProps) { + return ( + + {children} + + ); +} + export function SymbolSvg({ children, className, domain = "neutral", - viewBox = "0 0 64 48", + strokeScaling = "independent", }: SymbolSvgProps) { + const viewBox = useContext(SymbolViewBoxContext); return (