Routing problem for multi-pin port and anchors are debugged

This commit is contained in:
2026-05-31 22:16:44 +08:00
parent 9b4e8da796
commit ce7f6e95c4
36 changed files with 2470 additions and 676 deletions
+177 -39
View File
@@ -22,8 +22,11 @@
const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 };
// Base visual diameter and hit area used for port and anchor handles.
const PORT_NODE_SIZE = 30;
// Narrow anchor body width used in the canvas visual representation.
const ANCHOR_NODE_WIDTH = 8;
const PORT_LABEL_MIN_CHARS = 5;
const PORT_LABEL_CHAR_WIDTH = 7;
const PORT_LABEL_HORIZONTAL_PADDING = 12;
// Anchor body width used in the canvas visual representation.
const ANCHOR_NODE_WIDTH = 16;
// Default spacing between repeated anchor or port pins.
const DEFAULT_ELEMENT_PITCH = 10;
// Defines built-in port and anchor element metadata before per-node expansion.
@@ -477,7 +480,7 @@
const nextY = x * sin + y * cos;
x = nextX;
y = nextY;
angle += rotation;
angle -= rotation;
}
return {
@@ -489,16 +492,86 @@
};
// Create ordered React Flow handles for all ports on a single visual side.
const buildSideHandles = (ports, side) => {
const clampPercent = (value) => roundPercent(Math.min(100, Math.max(0, value)));
const createCoordinateMetrics = (ports, boxSize) => {
const width = positiveNumber(boxSize && boxSize.width);
const height = positiveNumber(boxSize && boxSize.height);
if (!width || !height) return null;
const values = { x: [], y: [] };
ports.forEach(port => {
const info = port && port.info;
const x = Number(info && info.x);
const y = Number(info && info.y);
if (Number.isFinite(x)) values.x.push(x);
if (Number.isFinite(y)) values.y.push(y);
});
return { width, height, values };
};
const coordinateAxisMode = (axisValues, size) => {
if (!axisValues || axisValues.length === 0 || !size) return 'fallback';
const min = Math.min(...axisValues);
const max = Math.max(...axisValues);
const epsilon = Math.max(0.001, size * 0.001);
if (Math.abs(max - min) <= epsilon) {
return Math.abs(max) <= epsilon ? 'centered' : 'fallback';
}
if (min < -epsilon && max <= size / 2 + epsilon && min >= -size / 2 - epsilon) {
return 'centered';
}
if (min >= -epsilon && max <= size + epsilon) {
return 'positive';
}
return 'fallback';
};
const coordinatePercent = (info, axis, metrics) => {
if (!metrics) return null;
const value = Number(info && info[axis]);
if (!Number.isFinite(value)) return null;
const size = axis === 'x' ? metrics.width : metrics.height;
const mode = coordinateAxisMode(metrics.values[axis], size);
if (mode === 'centered') {
return axis === 'x'
? clampPercent(50 + (value / size) * 100)
: clampPercent(50 - (value / size) * 100);
}
if (mode === 'positive') {
return axis === 'x'
? clampPercent((value / size) * 100)
: clampPercent(100 - (value / size) * 100);
}
return null;
};
const buildSideHandles = (ports, side, metrics) => {
const vertical = side === 'left' || side === 'right';
return ports.map((port, index) => {
const explicitPercent = Number(port.info && port.info.handlePercent);
const percent = Number.isFinite(explicitPercent) ? explicitPercent : fallbackPercent(index, ports.length);
const exactPercent = coordinatePercent(port.info, vertical ? 'y' : 'x', metrics);
const percent = Number.isFinite(explicitPercent)
? explicitPercent
: exactPercent !== null
? exactPercent
: fallbackPercent(index, ports.length);
const percentValue = `${percent}%`;
const style = vertical
? { top: percentValue, transform: side === 'left' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)' }
: { left: percentValue, transform: side === 'top' ? 'translate(-50%, -50%)' : 'translate(-50%, 50%)' };
? {
left: side === 'left' ? 0 : '100%',
right: 'auto',
top: percentValue,
bottom: 'auto',
transform: 'translate(-50%, -50%)'
}
: {
left: percentValue,
right: 'auto',
top: side === 'top' ? 0 : '100%',
bottom: 'auto',
transform: 'translate(-50%, -50%)'
};
return {
name: port.name,
@@ -511,13 +584,18 @@
// Group transformed ports into canvas handles with side and position styling.
const buildPortHandles = (ports, transform) => {
const options = transform || {};
const grouped = { left: [], right: [], top: [], bottom: [] };
const allPorts = [];
Object.entries(ports || {}).forEach(([name, info]) => {
if (name === 'a0' || name === 'b0') return;
const transformedInfo = transformPortInfo(info, transform);
const transformedInfo = transformPortInfo(info, options);
const side = portSideFromAngle(transformedInfo.a);
grouped[side].push({ name, info: transformedInfo });
const port = { name, info: transformedInfo };
grouped[side].push(port);
allPorts.push(port);
});
const metrics = createCoordinateMetrics(allPorts, options.boxSize);
Object.values(grouped).forEach(sidePorts => {
sidePorts.sort((a, b) => {
@@ -529,10 +607,10 @@
});
return [
...buildSideHandles(grouped.left, 'left'),
...buildSideHandles(grouped.right, 'right'),
...buildSideHandles(grouped.top, 'top'),
...buildSideHandles(grouped.bottom, 'bottom')
...buildSideHandles(grouped.left, 'left', metrics),
...buildSideHandles(grouped.right, 'right', metrics),
...buildSideHandles(grouped.top, 'top', metrics),
...buildSideHandles(grouped.bottom, 'bottom', metrics)
];
};
@@ -615,6 +693,35 @@
return name || (node && node.id) || 'port';
};
const pinRoleFromElementPortName = (elementType, portName) => {
const name = String(portName || '');
if (elementType === 'anchor') {
const anchorMatch = name.match(/^([ab])(\d+)$/);
return anchorMatch ? `${anchorMatch[1]}${anchorMatch[2]}` : name;
}
const portMatch = name.match(/^port_(\d+)$/);
return portMatch ? `io${portMatch[1]}` : 'io1';
};
const defaultElementPinName = (elementName, role) => `${elementName}_${role}`;
const getElementPinName = (node, portName) => {
const data = (node && node.data) || {};
const elementType = data.elementType === 'anchor' ? 'anchor' : 'port';
const elementName = getNodePortName(node);
const role = pinRoleFromElementPortName(elementType, portName);
return (data.pinNames && data.pinNames[role]) || defaultElementPinName(elementName, role);
};
const buildElementPinEntries = (node) => {
const data = (node && node.data) || {};
const elementType = data.elementType === 'anchor' ? 'anchor' : 'port';
return Object.keys(buildElementPorts(elementType, data)).map(portName => {
const role = pinRoleFromElementPortName(elementType, portName);
return { role, name: getElementPinName(node, portName) };
});
};
// Detect standalone port nodes that become top-level layout ports.
const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode');
// Detect built-in port or anchor nodes for element YAML export.
@@ -658,8 +765,13 @@
const portNumber = normalizePortNumber(data && data.portNumber);
const pitch = normalizePitch(data && data.pitch);
const handleClearance = Math.max(pitch, 14);
const portDisplayName = String((data && (data.portName || data.componentDisplayName || data.label)) || 'port');
const portWidth = Math.max(
PORT_NODE_SIZE,
PORT_LABEL_HORIZONTAL_PADDING + Math.max(PORT_LABEL_MIN_CHARS, portDisplayName.length) * PORT_LABEL_CHAR_WIDTH
);
return {
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : PORT_NODE_SIZE,
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : portWidth,
height: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance)
};
};
@@ -695,7 +807,7 @@
if (portNumber > 1) {
const entries = [];
Array.from({ length: portNumber }, (_, index) => {
const y = -PORT_NODE_SIZE / 2 + elementPortOffset(index, portNumber, pitch);
const y = elementPortOffset(index, portNumber, pitch);
entries.push([`a${index + 1}`, { x: 0, y, a: 180, width }]);
entries.push([`b${index + 1}`, { x: 0, y, a: 0, width }]);
});
@@ -771,40 +883,41 @@
};
};
// Flip an internal standalone Port angle into the outward-facing cell port
// angle used when this canvas is placed as a component elsewhere.
// Export standalone Port pins as the outward-facing pin angle.
const externalPortAngle = (angle) => normalizeAngle(Number(angle ?? 0) + 180);
// Convert standalone port nodes into page-level layout ports.
const buildPageComponentPorts = (port, nodes) => {
// Convert standalone port nodes into page-level layout pins.
const buildPageComponentPins = (port, nodes) => {
const portNodes = (nodes || []).filter(isPortElementNode);
if (portNodes.length > 0) {
return portNodes.reduce((ports, node) => {
return portNodes.reduce((pins, node) => {
const data = node.data || {};
const baseName = getNodePortName(node);
const elementPorts = buildElementPorts('port', data);
const entries = Object.entries(elementPorts);
entries.forEach(([portName, portInfo]) => {
const exportName = entries.length === 1
? baseName
: `${baseName}_${portName.replace(/^port_/, '')}`;
const exportName = getElementPinName(node, portName);
const point = getNodePortCanvasPoint(node, portName) || {
x: Number((node.position && node.position.x) || 0),
y: Number((node.position && node.position.y) || 0)
};
ports[exportName] = {
pins[exportName] = {
element: baseName,
pin: pinRoleFromElementPortName('port', portName),
x: Number(point.x || 0),
y: Number(point.y || 0),
a: externalPortAngle(portInfo.a ?? data.angle ?? data.a ?? 0),
width: Number(portInfo.width || data.width || 0.5)
};
});
return ports;
return pins;
}, {});
}
if (!port) return {};
return {
port: {
port_io1: {
element: 'port',
pin: 'io1',
x: Number(port.x || 0),
y: Number(port.y || 0),
a: externalPortAngle(port.a || 0),
@@ -813,27 +926,34 @@
};
};
// Serialize standalone canvas ports into a layout ports YAML section.
const buildCanvasPortsYaml = (nodes, fallbackPort) => {
const ports = buildPageComponentPorts(fallbackPort, nodes);
const entries = Object.entries(ports);
if (entries.length === 0) return 'ports: []';
// Backward-compatible helper name for callers that still use the old JS API.
const buildPageComponentPorts = buildPageComponentPins;
// Serialize standalone canvas pins into a layout pins YAML section.
const buildCanvasPinsYaml = (nodes, fallbackPort) => {
const pins = buildPageComponentPins(fallbackPort, nodes);
const entries = Object.entries(pins);
if (entries.length === 0) return 'pins: []';
const sourceNodes = new Map((nodes || []).filter(isPortElementNode).map(node => [getNodePortName(node), node]));
const lines = entries.map(([name, info]) => {
const data = (sourceNodes.get(name) && sourceNodes.get(name).data) || {};
const data = (sourceNodes.get(info.element) && sourceNodes.get(info.element).data) || {};
const description = data.description ? `\n description: ${toYamlScalar(data.description)}` : '';
return `- name: ${name}
${data.layer ? `layer: ${data.layer}` : 'layer: WG_CORE'}
element: ${info.element}
pin: ${info.pin}
x: ${Number(info.x || 0).toFixed(1)}
y: ${canvasToLayoutY(info.y).toFixed(1)}
angle: ${Number(info.a || 0).toFixed(1)}
width: ${Number(info.width || 0.5)}${description}`;
});
return `ports:\n${lines.join('\n')}`;
return `pins:\n${lines.join('\n')}`;
};
const buildCanvasPortsYaml = buildCanvasPinsYaml;
// Maintain legacy single-port YAML export behavior for older callers.
const buildPortsYaml = (port) => buildCanvasPortsYaml([], port);
const buildPortsYaml = (port) => buildCanvasPinsYaml([], port);
// Serialize built-in port and anchor nodes into layout element metadata.
const buildElementsYaml = (nodes) => {
@@ -845,16 +965,21 @@
const angle = data.elementType === 'port' ? data.angle : data.rotation;
const portNumber = normalizePortNumber(data.portNumber);
const pitch = normalizePitch(data.pitch);
const pinLines = buildElementPinEntries(node)
.map(pin => ` - name: ${pin.name}\n role: ${pin.role}`)
.join('\n');
return ` ${name}:
type: ${data.elementType}
x: ${Number((node.position && node.position.x) || 0).toFixed(1)}
y: ${canvasToLayoutY((node.position && node.position.y) || 0).toFixed(1)}
angle: ${Number(angle || 0).toFixed(1)}
port_number: ${portNumber}
pin_number: ${portNumber}
pitch: ${Number(pitch)}
layer: ${data.layer || 'WG_CORE'}
width: ${Number(data.width || 0.5)}
description: ${toYamlScalar(data.description || '')}`;
description: ${toYamlScalar(data.description || '')}
pins:
${pinLines}`;
});
return `elements:\n${lines.join('\n')}`;
};
@@ -872,8 +997,12 @@
const targetNode = nodeMap[edge.target];
const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source;
const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target;
const fromPort = edge.sourceHandle || 'unknown';
const toPort = edge.targetHandle || 'unknown';
const fromPort = sourceNode && sourceNode.data && sourceNode.data.elementType
? getElementPinName(sourceNode, edge.sourceHandle)
: edge.sourceHandle || 'unknown';
const toPort = targetNode && targetNode.data && targetNode.data.elementType
? getElementPinName(targetNode, edge.targetHandle)
: edge.targetHandle || 'unknown';
const route = createRouteSettings(manifest, edge.data && edge.data.route);
const storedPoints = Array.isArray(edge.data && edge.data.points) ? edge.data.points : [];
const points = storedPoints.length >= 2 ? getEdgeRoutePoints(edge, nodeMap) : [];
@@ -926,7 +1055,12 @@ ${linksYaml}`;
const ports = buildElementPorts('port', node.data);
const portInfo = ports && portName ? ports[portName] : ports.port;
if (!portInfo) return { x: roundMeasureValue(x), y: roundMeasureValue(y) };
const transformedInfo = transformPortInfo(portInfo, { rotation: 0 });
const data = node.data || {};
const transformedInfo = transformPortInfo(portInfo, {
rotation: data.angle ?? data.a ?? data.rotation ?? 0,
flip: Boolean(data.flip),
flop: Boolean(data.flop)
});
return {
x: roundMeasureValue(x + Number(transformedInfo.x || 0)),
y: roundMeasureValue(y - Number(transformedInfo.y || 0))
@@ -1127,12 +1261,16 @@ ${linksYaml}`;
getNodePortCanvasPoint,
buildPortHandles,
buildElementPorts,
buildElementPinEntries,
getElementPinName,
buildElementBoxSize,
buildBasicComponentPorts,
getBasicComponentMetadata,
buildInstanceYaml,
buildInstancesYaml,
buildPageComponentPorts,
buildPageComponentPins,
buildCanvasPinsYaml,
buildCanvasPortsYaml,
buildBundlesYaml,
buildPortsYaml,
+448 -136
View File
@@ -662,6 +662,18 @@ Organization : OptiHK Limited
transform: translateY(-1px);
}
.origin-select-btn {
border-color: rgba(45, 212, 191, 0.55);
color: var(--accent-green);
}
.origin-select-btn.active {
background: rgba(45, 212, 191, 0.16);
border-color: var(--accent-green);
color: var(--text-main);
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.2), 0 10px 20px rgba(45, 212, 191, 0.12);
}
body.light-mode .mini-btn {
background: var(--mini-button-bg);
border-color: rgba(30, 48, 69, 0.18);
@@ -697,7 +709,10 @@ Organization : OptiHK Limited
z-index: 10;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
max-width: calc(100% - 30px);
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
@@ -723,6 +738,71 @@ Organization : OptiHK Limited
color: #102033;
}
.coordinate-readout {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
min-width: 270px;
justify-content: center;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: rgba(13, 22, 38, 0.9);
color: var(--text-main);
box-shadow: 0 14px 28px var(--shadow);
backdrop-filter: blur(14px);
font: 600 0.62rem/1 'IBM Plex Mono', Consolas, Monaco, monospace;
pointer-events: none;
white-space: nowrap;
}
.coordinate-readout span {
color: var(--text-muted);
font-weight: 500;
}
body.light-mode .coordinate-readout {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(30, 48, 69, 0.16);
box-shadow: 0 14px 28px rgba(18, 32, 51, 0.12);
}
.origin-crosshair {
position: absolute;
z-index: 18;
width: 22px;
height: 22px;
transform: translate(-50%, -50%);
pointer-events: none;
}
.origin-crosshair::before,
.origin-crosshair::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.35);
}
.origin-crosshair::before {
width: 22px;
height: 1px;
transform: translate(-50%, -50%);
}
.origin-crosshair::after {
width: 1px;
height: 22px;
transform: translate(-50%, -50%);
}
.build-layout-btn {
position: absolute;
bottom: 20px;
@@ -1195,6 +1275,34 @@ Organization : OptiHK Limited
pointer-events: none;
}
.port-pin-label {
position: absolute;
z-index: 12;
pointer-events: none;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.34rem;
font-weight: 700;
line-height: 1;
color: var(--port-label-text);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.port-pin-label span {
display: block;
}
.anchor-node-shell {
position: relative;
font-family: 'IBM Plex Sans', sans-serif;
}
.anchor-visual-body {
position: relative;
box-sizing: border-box;
transform-origin: center center;
}
.build-progress {
position: absolute;
left: 50%;
@@ -1459,11 +1567,12 @@ Organization : OptiHK Limited
calculateCompositeBoxSize,
buildPortHandles,
buildElementPorts,
getElementPinName,
buildElementBoxSize,
getBasicComponentMetadata,
buildInstancesYaml,
buildPageComponentPorts,
buildCanvasPortsYaml,
buildCanvasPinsYaml,
buildElementsYaml,
buildBundlesYaml: buildRouteBundlesYaml,
normalizeAngle,
@@ -1599,9 +1708,9 @@ Organization : OptiHK Limited
}, [id, data.ports, data.componentName, data.boxSize]);
const baseHandleStyle = {
width: 8, height: 8,
width: 6, height: 6,
background: 'var(--bg-main)',
border: '2px solid var(--accent)',
border: '1px solid var(--accent)',
borderRadius: '50%',
};
const handlePositionMap = {
@@ -1610,27 +1719,32 @@ Organization : OptiHK Limited
top: Position.Top,
bottom: Position.Bottom
};
const portHandles = useMemo(
() => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[data.ports, data.rotation, data.flip, data.flop]
);
const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const portHandles = useMemo(
() => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop), boxSize: componentSize }),
[data.ports, data.rotation, data.flip, data.flop, componentSize]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
);
const isAnchorElement = data.elementType === 'anchor';
const isBasicCompactComponent = isBasicComponent(data.componentName) && ['waveguide', 'taper', '90 bend'].includes(data.componentName);
const visualSize = isAnchorElement ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : componentSize;
const componentVisualTransform = `rotate(${data.rotation || 0}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`;
const iconSize = createComponentSymbolMetrics(componentSize);
const portLabelStyle = (portHandle) => {
const base = { ...portHandle.style };
if (portHandle.position === 'left') {
return { ...base, right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' };
return { ...base, left: 'auto', right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' };
}
if (portHandle.position === 'right') {
return { ...base, left: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'left' };
return { ...base, left: 'calc(100% + 8px)', right: 'auto', transform: 'translateY(-50%)', textAlign: 'left' };
}
if (portHandle.position === 'top') {
return { ...base, bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
return { ...base, top: 'auto', bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
}
return { ...base, top: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
return { ...base, top: 'calc(100% + 8px)', bottom: 'auto', transform: 'translateX(-50%)', textAlign: 'center' };
};
return (
@@ -1651,7 +1765,7 @@ Organization : OptiHK Limited
height: visualSize.height,
minHeight: visualSize.height,
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
transform: `rotate(${data.rotation || 0}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`,
transform: componentVisualTransform,
boxShadow: selected ? '0 0 15px rgba(56, 189, 248, 0.2)' : '0 4px 6px rgba(0,0,0,0.3)',
...(isBasicCompactComponent ? {
padding: 0,
@@ -1684,22 +1798,34 @@ Organization : OptiHK Limited
)}
</div>
<div style={{
position: 'absolute', inset: 0,
width: componentSize.width,
height: visualSize.height,
pointerEvents: 'none'
}}>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10, pointerEvents: 'all' }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5, pointerEvents: 'all' }}
/>
</React.Fragment>
))}
</div>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10 }}
/>
<Handle
type="target"
position={handlePositionMap[portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5 }}
/>
<React.Fragment key={`label-${portHandle.name}`}>
<span className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}>
{portHandle.name}
</span>
@@ -1727,16 +1853,34 @@ Organization : OptiHK Limited
// Renders standalone exported port elements with repeated port handles.
const pinLabelFromPortName = (portName) => {
const name = String(portName || '');
const portMatch = name.match(/^port_(\d+)$/);
if (portMatch) return portMatch[1];
if (name === 'port') return '1';
return name;
};
const PortNode = ({ id, data, selected }) => {
const angle = data.angle ?? 0;
const canvasAngle = -Number(angle || 0);
const portDisplayName = data.portName || data.componentDisplayName || data.label || 'port';
const ports = buildElementPorts('port', data);
const elementSize = buildElementBoxSize(data);
const localHandlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: 0 }])
);
const localPortHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: 0, boxSize: elementSize }),
[localHandlePorts, elementSize]
);
const portHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: 0 }),
[localHandlePorts]
() => buildPortHandles(localHandlePorts, { rotation: canvasAngle }),
[localHandlePorts, canvasAngle]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
);
const handlePositionMap = {
left: Position.Left,
@@ -1746,27 +1890,61 @@ Organization : OptiHK Limited
};
const baseHandleStyle = {
background: 'var(--accent)',
width: 6,
height: 6
width: 5,
height: 5
};
const pinLabelStyle = (portHandle) => {
const base = {
left: portHandle.style?.left,
right: portHandle.style?.right,
top: portHandle.style?.top,
bottom: portHandle.style?.bottom
};
if (portHandle.position === 'left') return { ...base, transform: 'translate(calc(-100% - 5px), -50%)' };
if (portHandle.position === 'right') return { ...base, transform: 'translate(5px, -50%)' };
if (portHandle.position === 'top') return { ...base, transform: 'translate(-50%, calc(-100% - 5px))' };
return { ...base, transform: 'translate(-50%, 5px)' };
};
const pinLabelTextStyle = {
transform: `rotate(${-canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`
};
return (
<div style={{
width: elementSize.width, height: elementSize.height, borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
color: selected ? 'white' : 'var(--accent)',
fontSize: 8, fontWeight: 'bold',
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${angle}deg)`,
}}>
<span>P</span>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle type="source" position={handlePositionMap[portHandle.position]} id={portHandle.name} style={{ ...baseHandleStyle, ...portHandle.style }} />
<Handle type="target" position={handlePositionMap[portHandle.position]} id={portHandle.name} style={{ ...baseHandleStyle, ...portHandle.style }} />
</React.Fragment>
))}
<div style={{ width: elementSize.width, height: elementSize.height, position: 'relative' }}>
<div className="component-floating-label" title={portDisplayName}>
<strong>{portDisplayName}</strong>
<span>Port</span>
</div>
<div style={{
width: elementSize.width, height: elementSize.height, borderRadius: 7,
position: 'relative',
boxSizing: 'border-box',
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
color: selected ? 'white' : 'var(--accent)',
fontSize: 8, fontWeight: 'bold',
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`,
}}>
{localPortHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }}
/>
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
</div>
</React.Fragment>
))}
</div>
</div>
);
};
@@ -1776,17 +1954,18 @@ Organization : OptiHK Limited
const updateNodeInternals = useUpdateNodeInternals();
const anchorRotation = data.rotation || 0;
const anchorVisualRotation = -Number(anchorRotation || 0);
const anchorDisplayName = data.componentDisplayName || data.label || 'anchor';
const ports = buildElementPorts('anchor', data);
const elementSize = buildElementBoxSize(data);
const localAnchorHandlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: name.startsWith('a') || name.startsWith('left') ? 180 : 0 }])
);
const portHandles = useMemo(
() => buildPortHandles(localAnchorHandlePorts, { rotation: 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[localAnchorHandlePorts, data.flip, data.flop]
() => buildPortHandles(localAnchorHandlePorts, { rotation: 0, boxSize: elementSize, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[localAnchorHandlePorts, elementSize, data.flip, data.flop]
);
const anchorDirectionHandles = useMemo(
() => new Map(buildPortHandles(localAnchorHandlePorts, { rotation: Number(anchorRotation || 0), flip: Boolean(data.flip), flop: Boolean(data.flop) }).map(handle => [handle.name, handle.position])),
() => new Map(buildPortHandles(localAnchorHandlePorts, { rotation: -Number(anchorRotation || 0), flip: Boolean(data.flip), flop: Boolean(data.flop) }).map(handle => [handle.name, handle.position])),
[localAnchorHandlePorts, anchorRotation, data.flip, data.flop]
);
const handlePositionMap = {
@@ -1796,8 +1975,8 @@ Organization : OptiHK Limited
bottom: Position.Bottom
};
const baseHandleStyle = {
width: 6,
height: 6,
width: 5,
height: 5,
background: 'var(--accent)',
border: '1px solid var(--bg-main)',
borderRadius: '50%'
@@ -1806,18 +1985,10 @@ Organization : OptiHK Limited
const name = String(portName || '');
return name.startsWith('a') || name.startsWith('left') ? 'left' : 'right';
};
const anchorPortVisualTop = (portName) => {
const match = String(portName || '').match(/(\d+)$/);
const index = match ? Math.max(1, Number(match[1])) : 1;
const portCount = Math.max(1, Math.floor(Number(data.portNumber || 1)));
if (portCount <= 1) return elementSize.height / 2;
const travel = Math.max(0, elementSize.height - baseHandleStyle.height);
return baseHandleStyle.height / 2 + ((index - 1) / (portCount - 1)) * travel;
};
const anchorHandleVisualStyle = (portHandle, zIndex) => {
const visualSide = anchorPortVisualSide(portHandle.name);
const localLeft = visualSide === 'left' ? 0 : elementSize.width;
const localTop = anchorPortVisualTop(portHandle.name);
const localTop = portHandle.style?.top || '50%';
return {
...baseHandleStyle,
zIndex,
@@ -1828,47 +1999,66 @@ Organization : OptiHK Limited
transform: 'translate(-50%, -50%)'
};
};
const pinLabelStyle = (portHandle) => {
const visualSide = anchorPortVisualSide(portHandle.name);
const localLeft = visualSide === 'left' ? 0 : elementSize.width;
const localTop = portHandle.style?.top || '50%';
return {
left: localLeft,
top: localTop,
right: 'auto',
bottom: 'auto',
transform: visualSide === 'left' ? 'translate(calc(-100% - 5px), -50%)' : 'translate(5px, -50%)'
};
};
const pinLabelTextStyle = {
transform: `rotate(${Number(anchorRotation || 0)}deg)`
};
useEffect(() => {
updateNodeInternals(id);
}, [id, data.ports, data.rotation, data.flip, data.flop, updateNodeInternals]);
return (
<div style={{
position: 'relative',
width: elementSize.width,
height: elementSize.height,
borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: selected ? 'white' : 'var(--accent)',
fontSize: 10,
fontWeight: 800,
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${anchorVisualRotation}deg)`,
}}>
<span>A</span>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 10)}
/>
<Handle
type="target"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 5)}
/>
</React.Fragment>
))}
<div className="anchor-node-shell" style={{ width: elementSize.width, height: elementSize.height }}>
<div className="component-floating-label" title={anchorDisplayName}>
<strong>{anchorDisplayName}</strong>
<span>Anchor</span>
</div>
<div className="anchor-visual-body" style={{
width: elementSize.width,
height: elementSize.height,
borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
color: selected ? 'white' : 'var(--accent)',
fontSize: 10,
fontWeight: 800,
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${anchorVisualRotation}deg)`,
}}>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 10)}
/>
<Handle
type="target"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 5)}
/>
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
</div>
</React.Fragment>
))}
</div>
</div>
);
});
@@ -2919,7 +3109,7 @@ Organization : OptiHK Limited
onUpdateNode(selectedNode.id, {
data: {
basicArguments: nextArguments,
ports: metadata?.ports || {},
ports: metadata?.pins || metadata?.ports || {},
boxSize: metadata ? normalizeBoxSize(metadata) : selectedNode.data?.boxSize
}
});
@@ -3558,6 +3748,10 @@ Organization : OptiHK Limited
const [rulerStartPoint, setRulerStartPoint] = useState(null);
const [rulerEndPoint, setRulerEndPoint] = useState(null);
const [rulerPreviewPoint, setRulerPreviewPoint] = useState(null);
const [mouseCanvasPoint, setMouseCanvasPoint] = useState(null);
const [mouseScreenPoint, setMouseScreenPoint] = useState(null);
const [canvasOrigin, setCanvasOrigin] = useState({ x: 0, y: 0 });
const [originPickMode, setOriginPickMode] = useState(false);
const [projectTechnology, setProjectTechnology] = useState('');
const [technologyManifest, setTechnologyManifest] = useState(FALLBACK_TECHNOLOGY_MANIFEST);
const [currentLinkXsection, setCurrentLinkXsection] = useState('strip');
@@ -3565,6 +3759,7 @@ Organization : OptiHK Limited
const [clipboard, setClipboard] = useState({ nodes: [] });
const initializedRef = useRef(false);
const canvasViewportRef = useRef(null);
const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []);
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
@@ -3602,6 +3797,14 @@ Organization : OptiHK Limited
[rulerStartPoint, rulerActiveEndPoint]
);
const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null;
const displayMousePoint = useMemo(() => (
mouseCanvasPoint
? {
x: Number((mouseCanvasPoint.x - canvasOrigin.x).toFixed(3)),
y: Number((mouseCanvasPoint.y - canvasOrigin.y).toFixed(3))
}
: null
), [mouseCanvasPoint, canvasOrigin]);
// Normalizes free-route control points and removes adjacent duplicates before storage.
const compactRoutePoints = useCallback((points) => {
return (points || [])
@@ -3743,7 +3946,7 @@ Organization : OptiHK Limited
const getAnchorHandleRouteDirection = useCallback((node, handleId) => {
if (!node || !handleId || !(node.type === 'anchorNode' || node.data?.elementType === 'anchor')) return null;
const handles = buildPortHandles(buildElementPorts('anchor', node.data), {
rotation: Number(node.data?.rotation || 0),
rotation: -Number(node.data?.rotation || 0),
flip: Boolean(node.data?.flip),
flop: Boolean(node.data?.flop)
});
@@ -4414,10 +4617,7 @@ Organization : OptiHK Limited
if (!element || typeof element !== 'object') return;
const elementType = element.type === 'anchor' ? 'anchor' : (element.type === 'port' ? 'port' : '');
if (!elementType) return;
if (elementType === 'port' && elementName === 'port' && Array.isArray(doc.ports) && doc.ports.length > 0) {
return;
}
const portNumberValue = Math.floor(Number(element.port_number ?? element.portNumber ?? 1));
const portNumberValue = Math.floor(Number(element.pin_number ?? element.pinNumber ?? element.port_number ?? element.portNumber ?? 1));
const portNumber = Number.isFinite(portNumberValue) ? Math.max(1, portNumberValue) : 1;
const pitchValue = Number(element.pitch ?? DEFAULT_ELEMENT_PITCH);
const pitch = Number.isFinite(pitchValue) ? Math.max(0, pitchValue) : DEFAULT_ELEMENT_PITCH;
@@ -4436,6 +4636,7 @@ Organization : OptiHK Limited
pitch,
layer: element.layer || 'WG_CORE',
description: element.description || '',
pinNames: Object.fromEntries((element.pins || []).map(pin => [pin.role, pin.name]).filter(([role, name]) => role && name)),
boxSize: buildElementBoxSize({ elementType, portNumber, pitch })
};
const nodeId = `element-${elementName}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
@@ -4479,6 +4680,14 @@ Organization : OptiHK Limited
return nodes;
}, []);
const resolveLoadedPinHandle = useCallback((node, pinName) => {
if (!node || !node.data?.elementType) return pinName;
const elementType = node.data.elementType === 'anchor' ? 'anchor' : 'port';
const ports = buildElementPorts(elementType, node.data);
const matched = Object.keys(ports || {}).find(portName => getElementPinName(node, portName) === pinName);
return matched || pinName;
}, []);
useEffect(() => {
const input = document.getElementById('open-yaml-input');
if (!input) return;
@@ -4547,7 +4756,7 @@ Organization : OptiHK Limited
componentDisplayName: instName,
type: isProject ? 'composite' : undefined,
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents,
ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
ports: instIsBasic ? (basicMetadata?.pins || basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
@@ -4569,13 +4778,17 @@ Organization : OptiHK Limited
const sourceId = nodeNameMap[fromInst];
const targetId = nodeNameMap[toInst];
if (sourceId && targetId) {
const sourceNode = newNodes.find(node => node.id === sourceId);
const targetNode = newNodes.find(node => node.id === targetId);
const sourceHandle = resolveLoadedPinHandle(sourceNode, fromPort);
const targetHandle = resolveLoadedPinHandle(targetNode, toPort);
const view = routeStyleForSettings(route, false);
newEdges.push({
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
id: `edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
sourceHandle,
targetHandle,
type: view.type,
style: view.style,
data: { route, points: routePoints },
@@ -4591,8 +4804,8 @@ Organization : OptiHK Limited
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
const importedPort = Array.isArray(doc.ports) && doc.ports[0]
? { x: Number(doc.ports[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.ports[0].y) : Number(doc.ports[0].y || 0), a: Number(doc.ports[0].angle ?? doc.ports[0].a ?? 0), width: Number(doc.ports[0].width || 0.5) }
const importedPin = Array.isArray(doc.pins) && doc.pins[0]
? { x: Number(doc.pins[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.pins[0].y) : Number(doc.pins[0].y || 0), a: Number(doc.pins[0].angle ?? doc.pins[0].a ?? 0), width: Number(doc.pins[0].width || 0.5) }
: { x: 50, y: 150, a: 0, width: 0.5 };
const newPage = {
id: newPageId,
@@ -4603,8 +4816,8 @@ Organization : OptiHK Limited
{
id: 'page-port',
type: 'portNode',
position: { x: importedPort.x, y: importedPort.y },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: importedPort.a, width: importedPort.width || 0.5, layer: 'WG_CORE', description: '' },
position: { x: importedPin.x, y: importedPin.y },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: importedPin.a, width: importedPin.width || 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
@@ -4612,7 +4825,7 @@ Organization : OptiHK Limited
...newNodes,
],
edges: newEdges,
port: importedPort,
port: importedPin,
};
setPages(prev => [...prev, newPage]);
@@ -4663,7 +4876,7 @@ Organization : OptiHK Limited
input.addEventListener('change', handleFile);
return () => input.removeEventListener('change', handleFile);
}, [library, technologyManifest, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent]);
}, [library, technologyManifest, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
useEffect(() => {
setProjectCompositeMap(prev => {
@@ -4713,9 +4926,9 @@ Organization : OptiHK Limited
const pageFromYaml = (cellName, content, manifest, knownCompositeNames = new Set()) => {
const doc = jsyaml.load(content) || {};
const usesGdsYUp = doc.coordinate_system === 'gds_y_up';
const firstPort = Array.isArray(doc.ports) ? doc.ports[0] : null;
const pagePort = firstPort
? { x: Number(firstPort.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPort.y) : Number(firstPort.y || 0), a: Number(firstPort.angle ?? firstPort.a ?? 0), width: Number(firstPort.width || 0.5) }
const firstPin = Array.isArray(doc.pins) ? doc.pins[0] : null;
const pagePort = firstPin
? { x: Number(firstPin.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPin.y) : Number(firstPin.y || 0), a: Number(firstPin.angle ?? firstPin.a ?? 0), width: Number(firstPin.width || 0.5) }
: { x: 50, y: 150, a: 0, width: 0.5 };
const nodeNameMap = {};
const nodes = [
@@ -4760,7 +4973,7 @@ Organization : OptiHK Limited
componentDisplayName: instName,
type: instIsComposite ? 'composite' : undefined,
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents,
ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
ports: instIsBasic ? (basicMetadata?.pins || basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
@@ -4781,13 +4994,17 @@ Organization : OptiHK Limited
const sourceId = nodeNameMap[fromInst];
const targetId = nodeNameMap[toInst];
if (!sourceId || !targetId) return;
const sourceNode = nodes.find(node => node.id === sourceId);
const targetNode = nodes.find(node => node.id === targetId);
const sourceHandle = resolveLoadedPinHandle(sourceNode, fromPort);
const targetHandle = resolveLoadedPinHandle(targetNode, toPort);
const view = routeStyleForSettings(route, false);
edges.push({
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
id: `edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
sourceHandle,
targetHandle,
type: view.type,
style: view.style,
data: { route, points: routePoints },
@@ -4861,7 +5078,7 @@ Organization : OptiHK Limited
};
loadProject();
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent]);
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
useEffect(() => {
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
@@ -4949,7 +5166,7 @@ Organization : OptiHK Limited
position: clampPositionToCanvas(node.position, page.canvasSize || DEFAULT_CANVAS_SIZE, boxSize),
data: {
...node.data,
ports: metadata.ports || {},
ports: metadata.pins || metadata.ports || {},
boxSize,
foundry: metadata.foundry || '',
process: metadata.process || ''
@@ -5273,7 +5490,7 @@ Organization : OptiHK Limited
type: 'composite',
category: null,
rotation: 0,
ports: parsedData.ports || {},
ports: parsedData.pins || parsedData.ports || {},
boxSize: compositeBoxSize
}
};
@@ -5324,7 +5541,7 @@ Organization : OptiHK Limited
type: 'composite',
category: null,
rotation: 0,
ports: parsedData.ports || {},
ports: parsedData.pins || parsedData.ports || {},
boxSize: compositeBoxSize
}
};
@@ -5377,7 +5594,7 @@ Organization : OptiHK Limited
libraryCategory: 'basic',
category: 'basic',
rotation: 0,
ports: metadata?.ports || {},
ports: metadata?.pins || metadata?.ports || {},
boxSize: metadata ? normalizeBoxSize(metadata) : DEFAULT_COMPONENT_BOX_SIZE,
basicArguments
},
@@ -5585,13 +5802,16 @@ Organization : OptiHK Limited
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
} else {
setOriginPickMode(false);
}
return next;
});
}, []);
// Convert a pane click or pointer event into canvas ruler coordinates.
const eventToRulerPoint = useCallback((event) => {
// Convert a pane click or pointer event into canvas coordinates.
const eventToCanvasPoint = useCallback((event) => {
if (!reactFlowInstance || !event) return null;
const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
return {
x: Number(Math.min(activeCanvasSize.width, Math.max(0, rawPoint.x)).toFixed(3)),
@@ -5599,12 +5819,41 @@ Organization : OptiHK Limited
};
}, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]);
const updateMouseCanvasPoint = useCallback((event) => {
if (!activePage || activePage.type === 'layoutPreview') return null;
const nextPoint = eventToCanvasPoint(event);
if (!nextPoint) return null;
setMouseCanvasPoint(nextPoint);
const rect = canvasViewportRef.current?.getBoundingClientRect();
if (rect) {
setMouseScreenPoint({
x: event.clientX - rect.left,
y: event.clientY - rect.top
});
}
return nextPoint;
}, [activePage, eventToCanvasPoint]);
const toggleOriginPickMode = useCallback(() => {
setOriginPickMode(prev => {
const next = !prev;
if (next) {
setRulerMode(false);
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
}
return next;
});
}, []);
// Set ruler start/end points from canvas clicks.
const handleRulerPaneClick = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
event.preventDefault();
event.stopPropagation();
const nextPoint = eventToRulerPoint(event);
const nextPoint = eventToCanvasPoint(event);
if (!nextPoint) return;
if (!rulerStartPoint || rulerEndPoint) {
setRulerStartPoint(nextPoint);
setRulerEndPoint(null);
@@ -5618,14 +5867,47 @@ Organization : OptiHK Limited
if (measurement) {
addLog(`Ruler distance: ${measurement.label}`);
}
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, addLog]);
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToCanvasPoint, addLog]);
// Update the live ruler preview point while measuring.
const handleRulerMouseMove = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
if (!rulerStartPoint || rulerEndPoint) return;
setRulerPreviewPoint(eventToRulerPoint(event));
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint]);
const nextPoint = eventToCanvasPoint(event);
if (nextPoint) setRulerPreviewPoint(nextPoint);
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToCanvasPoint]);
const chooseCanvasOriginFromEvent = useCallback((event) => {
if (!originPickMode || !activePage || activePage.type === 'layoutPreview') return false;
const nextPoint = updateMouseCanvasPoint(event) || eventToCanvasPoint(event);
if (!nextPoint) return false;
event.preventDefault();
event.stopPropagation();
setCanvasOrigin(nextPoint);
setOriginPickMode(false);
addLog(`Canvas origin: (${nextPoint.x.toFixed(3)}, ${nextPoint.y.toFixed(3)}) um`);
return true;
}, [originPickMode, activePage, updateMouseCanvasPoint, eventToCanvasPoint, addLog]);
const handleCanvasMouseMove = useCallback((event) => {
updateMouseCanvasPoint(event);
handleRulerMouseMove(event);
}, [updateMouseCanvasPoint, handleRulerMouseMove]);
const handleCanvasPaneClick = useCallback((event) => {
if (chooseCanvasOriginFromEvent(event)) return;
handleRulerPaneClick(event);
}, [chooseCanvasOriginFromEvent, handleRulerPaneClick]);
const handleCanvasNodeClick = useCallback((event) => {
if (chooseCanvasOriginFromEvent(event)) return;
handleRulerPaneClick(event);
}, [chooseCanvasOriginFromEvent, handleRulerPaneClick]);
const handleCanvasMouseLeave = useCallback(() => {
setMouseCanvasPoint(null);
setMouseScreenPoint(null);
}, []);
// Select a route edge by id with optional additive selection.
const selectEdgeById = useCallback((edgeId, additive = false) => {
@@ -5873,7 +6155,7 @@ type: ${page.type === 'project' ? 'project' : 'composite'}
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
${buildCanvasPortsYaml(page.nodes)}
${buildCanvasPinsYaml(page.nodes)}
# 2. Instances (The sub-components dropped onto this canvas)
instances:`;
@@ -6098,8 +6380,11 @@ ${bundlesBlock}`;
))}
</div>
<div
ref={canvasViewportRef}
style={{ flex: 1, position: 'relative' }}
onMouseDownCapture={handleCanvasMouseDown}
onMouseMoveCapture={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
>
<div className="canvas-toolbar">
<span className="grid-snap-label">Snap to Grid</span>
@@ -6154,6 +6439,14 @@ ${bundlesBlock}`;
<button className="mini-btn" onClick={toggleRulerMode} aria-pressed={rulerMode ? 'true' : 'false'}>
{rulerMode ? 'Ruler On' : 'Ruler'}
</button>
<button
className={`mini-btn origin-select-btn ${originPickMode ? 'active' : ''}`}
onClick={toggleOriginPickMode}
aria-pressed={originPickMode ? 'true' : 'false'}
title="Select canvas origin"
>
{originPickMode ? 'Picking Origin' : 'Origin Select'}
</button>
</div>
{buildProgress.active && (
@@ -6176,6 +6469,25 @@ ${bundlesBlock}`;
</div>
)}
{activePage && activePage.type !== 'layoutPreview' && (
<div
className="coordinate-readout"
style={{ bottom: rulerMode ? 58 : 18 }}
title={`Origin (${canvasOrigin.x.toFixed(3)}, ${canvasOrigin.y.toFixed(3)}) um`}
>
X {displayMousePoint ? displayMousePoint.x.toFixed(3) : '--'} um
Y {displayMousePoint ? displayMousePoint.y.toFixed(3) : '--'} um
<span>O {canvasOrigin.x.toFixed(3)}, {canvasOrigin.y.toFixed(3)}</span>
</div>
)}
{originPickMode && mouseScreenPoint && activePage?.type !== 'layoutPreview' && (
<div
className="origin-crosshair"
style={{ left: mouseScreenPoint.x, top: mouseScreenPoint.y }}
/>
)}
{activePage && activePage.type !== 'layoutPreview' && (
<button
onClick={handleBuildLayout}
@@ -6196,12 +6508,12 @@ ${bundlesBlock}`;
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleBasicConnection}
onPaneClick={handleRulerPaneClick}
onPaneMouseMove={handleRulerMouseMove}
onPaneClick={handleCanvasPaneClick}
onPaneMouseMove={handleCanvasMouseMove}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeClick={handleRulerPaneClick}
onNodeMouseMove={handleRulerMouseMove}
onNodeClick={handleCanvasNodeClick}
onNodeMouseMove={handleCanvasMouseMove}
onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseDown={onNodeMouseDown}
onEdgeMouseDown={handleReactFlowEdgeMouseDown}