Merged from jingwen_main for image icon revision

This commit is contained in:
=
2026-06-04 20:00:13 +08:00
30 changed files with 1514 additions and 98 deletions
+31 -14
View File
@@ -42,11 +42,14 @@
name: 'Anchor',
elementType: 'anchor',
ports: {
a1: { x: 0, y: -PORT_NODE_SIZE / 2, a: 180, width: 0.5 },
b1: { x: 0, y: -PORT_NODE_SIZE / 2, a: 0, width: 0.5 }
a1: { x: 0, y: 0, a: 180, width: 0.5 },
b1: { x: 0, y: 0, a: 0, width: 0.5 }
}
}
};
// Defines local primitive components that do not require PDK lookup.
const BASIC_COMPONENTS = {
waveguide: {
@@ -804,16 +807,13 @@
}
};
}
if (portNumber > 1) {
const entries = [];
Array.from({ length: portNumber }, (_, index) => {
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 }]);
});
return Object.fromEntries(entries);
}
return JSON.parse(JSON.stringify(element.ports));
const entries = [];
Array.from({ length: portNumber }, (_, index) => {
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 }]);
});
return Object.fromEntries(entries);
};
// Generate port metadata for built-in primitive components.
@@ -984,6 +984,20 @@ ${pinLines}`;
return `elements:\n${lines.join('\n')}`;
};
const finiteNumberOrNull = (value) => {
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
const getRouteEndpointWidth = (node, handleId) => {
if (!node || !node.data) return null;
const dataWidth = finiteNumberOrNull(node.data.width);
if (dataWidth !== null) return dataWidth;
const ports = node.data.ports || {};
const portWidth = ports[handleId] ? finiteNumberOrNull(ports[handleId].width) : null;
return portWidth;
};
// Serialize canvas links into routed bundle YAML including route settings and bend points.
const buildBundlesYaml = (page, manifest) => {
const { nodes = [], edges = [] } = page || {};
@@ -1004,6 +1018,9 @@ ${pinLines}`;
? getElementPinName(targetNode, edge.targetHandle)
: edge.targetHandle || 'unknown';
const route = createRouteSettings(manifest, edge.data && edge.data.route);
const routeWidth = getRouteEndpointWidth(sourceNode, edge.sourceHandle)
?? getRouteEndpointWidth(targetNode, edge.targetHandle)
?? route.width;
const storedPoints = Array.isArray(edge.data && edge.data.points) ? edge.data.points : [];
const points = storedPoints.length >= 2 ? getEdgeRoutePoints(edge, nodeMap) : [];
const pointsYaml = points.length > 0
@@ -1014,7 +1031,7 @@ ${pinLines}`;
return ` - id: ${toYamlScalar(edge.id)}
xsection: ${route.xsection}
family: ${route.family}
width: ${Number(route.width)}
width: ${Number(routeWidth)}
radius: ${Number(route.radius)}
routing_type: ${route.routing_type}${pointsYaml}`;
}
@@ -1022,7 +1039,7 @@ ${pinLines}`;
to: ${targetName}:${toPort}
xsection: ${route.xsection}
family: ${route.family}
width: ${Number(route.width)}
width: ${Number(routeWidth)}
radius: ${Number(route.radius)}
routing_type: ${route.routing_type}${pointsYaml}`;
});
+116 -83
View File
@@ -1622,7 +1622,7 @@ Organization : OptiHK Limited
// Displays a category icon with cached loading and graceful failure behavior.
const IconImg = memo(({ category, containerStyle }) => {
const IconImg = memo(({ category, containerStyle, objectFit: imgObjectFit }) => {
const [src, setSrc] = useState(() => {
if (!category) return undefined;
const cache = fetchIcon(category);
@@ -1671,7 +1671,7 @@ Organization : OptiHK Limited
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
objectFit: imgObjectFit || 'fill',
pointerEvents: 'none',
}}
onError={(e) => {
@@ -1764,42 +1764,46 @@ Organization : OptiHK Limited
width: componentSize.width,
height: visualSize.height,
minHeight: visualSize.height,
overflow: 'hidden',
...(visualSize.height < 50 && !isAnchorElement ? { padding: '2px 4px' } : {}),
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
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,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} : {}),
...(isAnchorElement ? {
width: PORT_NODE_SIZE,
minHeight: PORT_NODE_SIZE,
padding: 0,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} : {}),
}}
>
{isAnchorElement ? (
<span style={{ fontSize: 8, fontWeight: 800, color: selected ? 'var(--accent)' : 'var(--text-main)' }}>A</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', minHeight: '100%' }}>
{!data.hideIcon && data.category && (
<div style={{ width: iconSize.width, height: iconSize.height }}>
<IconImg category={data.category} />
transform: componentVisualTransform,
transformOrigin: 'center center',
...(isBasicCompactComponent ? {
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} : {}),
...(isAnchorElement ? {
width: PORT_NODE_SIZE,
minHeight: PORT_NODE_SIZE,
padding: 0,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} : {}),
}}
>
{isAnchorElement ? (
<span style={{ fontSize: 8, fontWeight: 800, color: selected ? 'var(--accent)' : 'var(--text-main)' }}>A</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', minHeight: '100%' }}>
{!data.hideIcon && data.category && (
<div style={{ maxWidth: iconSize.width, maxHeight: iconSize.height, width: '100%', aspectRatio: `${iconSize.width}/${iconSize.height}`, overflow: 'hidden' }}>
<IconImg category={data.category} objectFit="contain" />
</div>
)}
{!data.category && <div style={{ width: iconSize.width, height: iconSize.height, borderRadius: 4, border: '1px solid var(--border-strong)', background: 'rgba(148, 163, 184, 0.08)' }} />}
</div>
)}
{!data.category && <div style={{ width: iconSize.width, height: iconSize.height, borderRadius: 4, border: '1px solid var(--border-strong)', background: 'rgba(148, 163, 184, 0.08)' }} />}
</div>
)}
</div>
</div>
<div style={{
position: 'absolute', inset: 0,
position: 'absolute',
top: 0, left: 0,
width: componentSize.width,
height: visualSize.height,
pointerEvents: 'none'
@@ -1985,29 +1989,22 @@ Organization : OptiHK Limited
const name = String(portName || '');
return name.startsWith('a') || name.startsWith('left') ? 'left' : 'right';
};
const anchorHandleVisualStyle = (portHandle, zIndex) => {
const visualSide = anchorPortVisualSide(portHandle.name);
const localLeft = visualSide === 'left' ? 0 : elementSize.width;
const localTop = portHandle.style?.top || '50%';
return {
...baseHandleStyle,
zIndex,
left: localLeft,
top: localTop,
right: 'auto',
bottom: 'auto',
transform: 'translate(-50%, -50%)'
};
};
const anchorHandleVisualStyle = (portHandle, zIndex) => ({
...baseHandleStyle,
zIndex,
left: portHandle.style?.left,
top: portHandle.style?.top || '50%',
right: portHandle.style?.right || 'auto',
bottom: portHandle.style?.bottom || 'auto',
transform: portHandle.style?.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',
left: portHandle.style?.left,
top: portHandle.style?.top || '50%',
right: portHandle.style?.right || 'auto',
bottom: portHandle.style?.bottom || 'auto',
transform: visualSide === 'left' ? 'translate(calc(-100% - 5px), -50%)' : 'translate(5px, -50%)'
};
};
@@ -3359,6 +3356,7 @@ Organization : OptiHK Limited
const forge = isForgeComponent(componentName);
onUpdateNode(selectedNode.id, {
data: {
...selectedNode.data,
componentName,
label: componentName,
ports: forge ? {} : undefined,
@@ -3760,8 +3758,6 @@ Organization : OptiHK Limited
const initializedRef = useRef(false);
const canvasViewportRef = useRef(null);
const buildLayoutRequestRef = useRef(0);
const buildLayoutBusyRef = useRef(false);
const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []);
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
@@ -3807,6 +3803,14 @@ Organization : OptiHK Limited
}
: null
), [mouseCanvasPoint, canvasOrigin]);
const handleCanvasViewportMoveEnd = useCallback((event, viewport) => {
if (!activePageId || !viewport) return;
setPages(prev => prev.map(page => (
page.id === activePageId
? { ...page, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } }
: page
)));
}, [activePageId]);
// Normalizes free-route control points and removes adjacent duplicates before storage.
const compactRoutePoints = useCallback((points) => {
return (points || [])
@@ -5058,6 +5062,35 @@ Organization : OptiHK Limited
return boxSize ? { ...node, data: { ...node.data, boxSize } } : node;
})
}));
// Pre-fetch PDK component metadata so nodes render with correct boxSize immediately.
const allNodes = cellPages.flatMap(page => page.nodes);
const pdkNames = [...new Set(allNodes
.filter(n => n.data?.componentName && !n.data?.elementType
&& !isForgeComponent(n.data.componentName)
&& !isBasicComponent(n.data.componentName))
.map(n => n.data.componentName))];
if (pdkNames.length > 0) {
const metaResults = await Promise.all(
pdkNames.map(name => loadComponentMetadata(name).catch(() => null))
);
const metaMap = new Map(
pdkNames.filter((_, i) => metaResults[i]).map((name, i) => [name, metaResults[i]])
);
for (const page of cellPages) {
page.nodes = page.nodes.map(node => {
const metadata = metaMap.get(node.data?.componentName);
if (!metadata) return node;
const sz = normalizeBoxSize(metadata);
return {
...node,
position: clampPositionToCanvas(node.position, page.canvasSize || DEFAULT_CANVAS_SIZE, sz),
data: { ...node.data, boxSize: sz, ports: metadata.pins || metadata.ports || {}, foundry: metadata.foundry || '', process: metadata.process || '' }
};
});
}
}
const loadedProjectPage = cellPages.find(page => page.type === 'project' && page.name === currentProjectName);
const nonProjectPages = cellPages.filter(page => page !== loadedProjectPage);
const resolvedProjectPage = loadedProjectPage || projectPage;
@@ -5084,12 +5117,18 @@ Organization : OptiHK Limited
useEffect(() => {
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
reactFlowInstance.fitBounds(
{ x: 0, y: 0, width: activeCanvasSize.width, height: activeCanvasSize.height },
{ padding: 0.12, duration: 0 }
);
if (activePage.viewport) {
window.requestAnimationFrame(() => {
reactFlowInstance.setViewport(activePage.viewport, { duration: 0 });
});
} else {
reactFlowInstance.fitBounds(
{ x: 0, y: 0, width: activeCanvasSize.width, height: activeCanvasSize.height },
{ padding: 0.12, duration: 0 }
);
}
}
}, [activePage?.id, activeCanvasSize.width, activeCanvasSize.height, reactFlowInstance]);
}, [activePage?.id, activePage?.viewport, activeCanvasSize.width, activeCanvasSize.height, reactFlowInstance]);
useEffect(() => {
setRulerStartPoint(null);
@@ -5176,12 +5215,20 @@ Organization : OptiHK Limited
};
})
})));
// Force React Flow to re-measure nodes whose boxSize / ports have changed.
requestAnimationFrame(() => {
const updatedIds = results.filter(r => r.metadata).map(r => r.nodeId);
if (updatedIds.length > 0 && reactFlowInstance.updateNodeInternals) {
reactFlowInstance.updateNodeInternals(updatedIds);
}
});
});
return () => {
cancelled = true;
};
}, [pages, loadComponentMetadata]);
}, [pages, loadComponentMetadata, reactFlowInstance]);
const openTabs = useMemo(() => pages.filter(page => !page.isClosed), [pages]);
@@ -6210,64 +6257,49 @@ ${bundlesBlock}`;
// Save the active page, generate layout preview assets, and show the preview tab.
const handleBuildLayout = useCallback(async () => {
if (!activePage) return;
if (buildLayoutBusyRef.current) return;
if (buildLayoutBusy) return;
if (!validateRouteCrossings(activePage)) return;
const buildPage = activePage;
const buildRequestId = buildLayoutRequestRef.current + 1;
buildLayoutRequestRef.current = buildRequestId;
buildLayoutBusyRef.current = true;
setBuildLayoutBusy(true);
startBuildProgress('Building layout');
const yamlContent = buildYamlForPage(buildPage);
const layoutBounds = calculateLayoutBounds(buildPage);
const yamlContent = buildYamlForPage(activePage);
// send to backend
try {
const response = await fetch('/api/save-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
project: currentProjectName,
cell: buildPage.name,
cell: activePage.name,
content: yamlContent,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
if (buildRequestId !== buildLayoutRequestRef.current) return;
const errData = await response.json();
addLog(errData.error || 'Save failed, unknown error');
stopBuildProgress();
return;
}
const result = await response.json();
if (buildRequestId !== buildLayoutRequestRef.current) return;
addLog('Successfully saved: ' + result.path);
if (result.preview_error) {
addLog('Preview skipped: ' + result.preview_error);
}
if (result.svg_ready && result.svg_url) {
if (result.svg_url) {
completeBuildProgress('Layout ready');
openLayoutPreview(buildPage.name, result.svg_url, layoutBounds);
openLayoutPreview(activePage.name, result.svg_url, calculateLayoutBounds(activePage));
} else {
if (result.preview_status === 'generated') {
addLog('Layout SVG was not marked ready by the backend.');
}
completeBuildProgress('Layout saved');
}
} catch (err) {
if (buildRequestId !== buildLayoutRequestRef.current) return;
addLog('Save error: ' + err.message);
stopBuildProgress();
} finally {
if (buildRequestId === buildLayoutRequestRef.current) {
buildLayoutBusyRef.current = false;
setBuildLayoutBusy(false);
}
setBuildLayoutBusy(false);
}
}, [activePage, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
}, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
// Save YAML for every editable project/composite page without opening previews.
const handleSaveProjectLayouts = useCallback(async () => {
@@ -6551,6 +6583,7 @@ ${bundlesBlock}`;
minZoom={0.02}
maxZoom={4}
defaultViewport={{ x: 80, y: 80, zoom: 0.12 }}
onMoveEnd={handleCanvasViewportMoveEnd}
panOnDrag={false}
selectionOnDrag={true}
selectionMode={FULL_SELECTION_MODE}