Files
mxpic_EDA/frontend/canvas.html
T
2026-05-26 11:55:56 +08:00

2346 lines
83 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
{% raw %}
<head>
<meta charset="UTF-8">
<title>mxPIC Core - Canvas</title>
<link rel="preconnect" href="https://unpkg.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/reactflow@11/dist/umd/index.js" crossorigin></script>
<link rel="stylesheet" href="https://unpkg.com/reactflow@11/dist/style.css" />
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4/dist/js-yaml.min.js"></script>
<style>
:root {
--bg-main: #0f172a;
--bg-card: #1e293b;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent: #38bdf8;
--accent-hover: #0284c7;
--border: #334155;
--input-bg: #0b1120;
}
.left-block {
min-height: 0;
display: flex;
flex-direction: column;
}
.left-block-body {
min-height: 0;
flex: 1 1 0;
}
.project-tree-scroll {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.project-tree-scroll details {
display: block;
}
.project-tree-scroll summary {
display: block;
list-style: none;
}
.project-tree-scroll summary::-webkit-details-marker {
display: none;
}
body,
html,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-family: 'Inter', sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
overflow: hidden;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-main);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
details {
margin-left: 8px;
}
summary {
cursor: pointer;
padding: 4px 0;
color: var(--text-main);
}
.tree-folder summary {
font-weight: 500;
color: var(--accent);
}
.component-leaf {
cursor: grab;
padding: 4px 6px;
margin-left: 15px;
margin-top: 2px;
word-break: break-all;
white-space: normal;
border-radius: 4px;
color: var(--text-muted);
transition: background 0.2s ease, color 0.2s ease;
}
.component-leaf:hover {
background: var(--border);
color: var(--text-main);
}
.component-card {
display: flex;
flex-direction: column;
align-items: center;
cursor: grab;
margin-left: 15px;
margin-top: 4px;
margin-bottom: 4px;
padding: 8px;
border-radius: 6px;
background: var(--input-bg);
border: 1px solid var(--border);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
width: 120px;
box-sizing: border-box;
}
.component-card:hover {
background: var(--bg-card);
border-color: var(--accent);
box-shadow: 0 0 8px rgba(56, 189, 248, 0.15);
}
.component-card-icon {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
pointer-events: none;
}
.component-card-icon img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.component-card-name {
font-size: 0.7rem;
color: var(--text-muted);
text-align: center;
word-break: break-all;
line-height: 1.2;
pointer-events: none;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.left-block,
.right-block {
background: var(--bg-main);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
}
.left-block-header,
.right-block-header {
background: var(--bg-card);
padding: 2px 6px;
font-weight: 600;
font-size: 0.85em;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.left-block-body,
.right-block-body {
padding: 12px;
font-size: 0.85em;
min-height: 0;
}
.placeholder-block {
border: 1px dashed var(--border);
padding: 12px;
color: var(--text-muted);
text-align: center;
background: var(--bg-main);
}
.toggle-btn {
background: none;
border: none;
font-size: 1.5em;
color: var(--text-muted);
cursor: pointer;
padding: 6px 12px;
border-radius: 4px;
transition: background 0.2s ease, color 0.2s ease;
width: 36px;
height: 36px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.toggle-btn:hover {
background: var(--border);
color: var(--text-main);
}
input[type="number"],
input[type="text"] {
background-color: var(--input-bg);
border: 1px solid var(--border);
color: var(--text-main);
font-family: inherit;
font-size: 0.9em;
padding: 6px 10px;
border-radius: 4px;
outline: none;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input[type="number"]:focus,
input[type="text"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
}
label {
font-weight: 500;
color: var(--text-muted);
margin-bottom: 4px;
display: block;
}
.react-flow__controls button {
background-color: var(--bg-card) !important;
border-bottom: 1px solid var(--border) !important;
fill: var(--text-main) !important;
}
.react-flow__controls button:hover {
background-color: var(--border) !important;
}
.canvas-tabs {
display: flex;
align-items: center;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 0 8px;
height: 36px;
gap: 4px;
overflow-x: auto;
white-space: nowrap;
}
.canvas-tab {
display: flex;
align-items: center;
padding: 4px 12px;
border-radius: 4px;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
font-size: 0.85em;
transition: background 0.2s, color 0.2s;
}
.canvas-tab.active {
background: #ffffff;
color: #1e293b;
border-color: #ffffff;
}
.canvas-tab button {
background: none;
border: none;
color: inherit;
margin-left: 6px;
cursor: pointer;
font-size: 1.2em;
line-height: 1;
padding: 0 2px;
}
.left-block {
min-height: 0;
display: flex;
flex-direction: column;
}
.left-block-body {
min-height: 0;
}
.project-tree-scroll {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.project-tree-scroll details {
display: block;
}
.project-tree-scroll summary {
display: block;
list-style: none;
}
.project-tree-scroll summary::-webkit-details-marker {
display: none;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback, useMemo, memo } = React;
const {
ReactFlow,
ReactFlowProvider,
useNodesState,
useEdgesState,
Controls,
Background,
useReactFlow,
addEdge,
Handle,
Position,
useUpdateNodeInternals,
applyNodeChanges,
applyEdgeChanges,
} = window.ReactFlow;
const iconPromiseCache = {};
function fetchIcon(category) {
if (!iconPromiseCache[category]) {
let resolveFn;
const promise = new Promise((resolve) => {
resolveFn = resolve;
});
iconPromiseCache[category] = {
promise,
result: undefined,
resolved: false,
};
const url = `/api/icon/${category}`;
const img = new Image();
img.onload = () => {
iconPromiseCache[category].result = url;
iconPromiseCache[category].resolved = true;
resolveFn(url);
};
img.onerror = () => {
iconPromiseCache[category].result = null;
iconPromiseCache[category].resolved = true;
resolveFn(null);
};
img.src = url;
}
return iconPromiseCache[category];
}
const IconImg = memo(({ category, containerStyle }) => {
const [src, setSrc] = useState(() => {
if (!category) return undefined;
const cache = fetchIcon(category);
return cache.resolved ? cache.result : undefined;
});
useEffect(() => {
if (!category) {
setSrc(undefined);
return;
}
const cache = fetchIcon(category);
if (cache.resolved) {
if (src !== cache.result) {
setSrc(cache.result);
}
return;
}
let cancelled = false;
cache.promise.then((result) => {
if (!cancelled) setSrc(result);
});
return () => {
cancelled = true;
};
}, [category, src]);
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...containerStyle,
}}
>
{src !== undefined && src !== null && (
<img
src={src}
alt={category}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
</div>
);
}, (prevProps, nextProps) => prevProps.category === nextProps.category);
const RotatableNode = memo(({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
const prevRotationRef = useRef(data.rotation);
const updateNodeInternalsRef = useRef(updateNodeInternals);
useEffect(() => {
updateNodeInternalsRef.current = updateNodeInternals;
}, [updateNodeInternals]);
useEffect(() => {
if (prevRotationRef.current !== data.rotation) {
updateNodeInternalsRef.current(id);
prevRotationRef.current = data.rotation;
}
}, [data.rotation, id]);
const baseHandleStyle = {
width: 10, height: 10,
background: 'var(--bg-main)',
border: '2px solid var(--accent)',
borderRadius: '50%',
};
const leftTopPort = { ...baseHandleStyle, top: '24%', transform: 'translate(-50%, -50%)' };
const leftBottomPort = { ...baseHandleStyle, top: '76%', transform: 'translate(-50%, -50%)' };
const rightTopPort = { ...baseHandleStyle, top: '24%', transform: 'translate(50%, -50%)' };
const rightBottomPort = { ...baseHandleStyle, top: '76%', transform: 'translate(50%, -50%)' };
return (
<div style={{
padding: '10px 15px',
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
borderRadius: 6,
background: 'var(--bg-card)',
color: 'var(--text-main)',
minWidth: 100,
maxWidth: 140,
textAlign: 'center',
position: 'relative', transform: `rotate(${data.rotation || 0}deg)`,
transition: 'none',
boxSizing: 'border-box',
boxShadow: selected ? '0 0 15px rgba(56, 189, 248, 0.2)' : '0 4px 6px rgba(0,0,0,0.3)',
fontFamily: "'Inter', sans-serif",
}}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
{!data.hideIcon && data.category && (
<div style={{ width: 128, height: 64 }}>
<IconImg category={data.category} />
</div>
)}
<div style={{
fontSize: '0.5rem',
color: 'var(--text-muted)',
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}} title={data.componentDisplayName}>
{data.componentDisplayName}
</div>
</div>
<Handle type="source" position={Position.Left} id="port-lt-source" style={{ ...leftTopPort, zIndex: 10 }} />
<Handle type="target" position={Position.Left} id="port-lt-target" style={{ ...leftTopPort, zIndex: 5 }} />
<Handle type="source" position={Position.Left} id="port-lb-source" style={{ ...leftBottomPort, zIndex: 10 }} />
<Handle type="target" position={Position.Left} id="port-lb-target" style={{ ...leftBottomPort, zIndex: 5 }} />
<Handle type="source" position={Position.Right} id="port-rt-source" style={{ ...rightTopPort, zIndex: 10 }} />
<Handle type="target" position={Position.Right} id="port-rt-target" style={{ ...rightTopPort, zIndex: 5 }} />
<Handle type="source" position={Position.Right} id="port-rb-source" style={{ ...rightBottomPort, zIndex: 10 }} />
<Handle type="target" position={Position.Right} id="port-rb-target" style={{ ...rightBottomPort, zIndex: 5 }} />
</div>
);
}, (prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&
prevProps.selected === nextProps.selected &&
prevProps.data.componentDisplayName === nextProps.data.componentDisplayName &&
prevProps.data.category === nextProps.data.category &&
prevProps.data.rotation === nextProps.data.rotation &&
prevProps.data.hideIcon === nextProps.data.hideIcon
);
});
const PortNode = ({ id, data, selected }) => {
const angle = data.angle ?? 0;
return (
<div style={{
width: 30, height: 30, borderRadius: '50%',
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: 10, fontWeight: 'bold',
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${angle}deg)`,
}}>
<span>C</span>
<Handle type="source" position={Position.Right} id="port-source" style={{ background: 'var(--accent)', width: 8, height: 8 }} />
</div>
);
};
const TreeNode = ({ name, children }) => {
if (children && children.__type__ === 'component') {
const componentName = children.__name__;
const componentCategory = children.__category__ || 'default';
const dragStartPos = useRef(null);
const dragReady = useRef(false);
const handleMouseDown = (event) => {
dragStartPos.current = { x: event.clientX, y: event.clientY };
dragReady.current = false;
};
const handleMouseMove = (event) => {
if (!dragStartPos.current) return;
const dx = event.clientX - dragStartPos.current.x;
const dy = event.clientY - dragStartPos.current.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
dragReady.current = true;
}
};
const handleDragStart = (event) => {
if (!dragReady.current) {
event.preventDefault();
return false;
}
const dragData = JSON.stringify({ name: componentName, category: componentCategory });
console.log("🚀 DRAG START: Sending data ->", dragData);
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.effectAllowed = 'move';
dragStartPos.current = null;
dragReady.current = false;
};
const handleMouseUp = () => {
dragStartPos.current = null;
dragReady.current = false;
};
return (
<div
className="component-card"
draggable
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragStart={handleDragStart}
>
<div className="component-card-icon">
<IconImg category={componentCategory} />
</div>
<div className="component-card-name" title={name}>
{name}
</div>
</div>
);
}
const hasChildren = children && Object.keys(children).length > 0;
return (
<details>
<summary className="tree-folder">
<span style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>📂 {name}</span>
</summary>
{hasChildren &&
Object.entries(children).map(([childName, childData]) => (
<TreeNode key={childName} name={childName} children={childData} />
))
}
</details>
);
};
const ProjectTreeNode = ({ name, children, onOpenComposite, onOpenProject }) => {
if (children && children.__type__ === 'project') {
const projectName = children.__name__ || name;
const composites = children.composites || [];
const handleDoubleClick = () => {
if (onOpenProject) onOpenProject(projectName);
};
return (
<details>
<summary className="tree-folder" onDoubleClick={handleDoubleClick} style={{ cursor: 'pointer' }}>
📁 {name}
</summary>
{composites.map(comp => (
<ProjectTreeNode key={comp.pageId || comp.__name__} name={comp.__name__} children={comp} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
))}
</details>
);
}
if (children && children.__type__ === 'composite') {
const compositeName = children.__name__ || name;
const tree = children.tree || {};
const handleDragStart = (event) => {
const dragData = JSON.stringify({ name: compositeName, type: 'composite' });
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.effectAllowed = 'move';
};
const handleDoubleClick = () => {
if (onOpenComposite) onOpenComposite(compositeName);
};
return (
<details>
<summary className="tree-folder" draggable onDragStart={handleDragStart} onDoubleClick={handleDoubleClick}>
{name}
</summary>
{Object.keys(tree).length > 0 ? (
Object.entries(tree).map(([childName, childData]) => (
<CompositeComponentTree key={childName} name={childName} children={childData} />
))
) : (
<div style={{ marginLeft: 15, color: 'var(--text-muted)', fontStyle: 'italic' }}>No components</div>
)}
</details>
);
}
if (children && children.__type__ === 'technology') {
return (
<div style={{ padding: '4px 6px', marginLeft: 15, color: 'var(--text-muted)', fontStyle: 'italic' }}>
{name}: {children.description || '(empty)'}
</div>
);
}
if (children && children.__type__ === 'block') {
return (
<div style={{ padding: '4px 6px', marginLeft: 15, color: 'var(--text-muted)', fontStyle: 'italic' }}>
{name}: {children.description || '(empty)'}
</div>
);
}
const hasChildren = children && typeof children === 'object' && Object.keys(children).length > 0 && !children.__type__;
if (!hasChildren) {
return (
<div style={{ padding: '4px 6px', marginLeft: 15, color: 'var(--text-muted)' }}>
{name}
</div>
);
}
return (
<details>
<summary className="tree-folder">
<span style={{ wordBreak: 'break-all', whiteSpace: 'normal' }}>📁 {name}</span>
</summary>
{Object.entries(children).map(([childName, childData]) => (
<ProjectTreeNode key={childName} name={childName} children={childData} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
))}
</details>
);
};
const CompositeComponentTree = ({ name, children }) => {
if (children && children.__type__ === 'component') {
const instances = children.instances || [];
const displayText = instances.length > 0
? instances.join(', ')
: (children.__name__ || name);
return (
<div className="component-leaf" style={{ marginLeft: 15 }}>
<span style={{ color: 'var(--accent)', marginRight: '4px' }}></span>
{displayText}
</div>
);
}
if (children && typeof children === 'object' && !children.__type__) {
const hasChildren = Object.keys(children).length > 0;
return (
<details>
<summary className="tree-folder" style={{ marginLeft: 8 }}>
📂 {name}
</summary>
{hasChildren &&
Object.entries(children).map(([childName, childData]) => (
<CompositeComponentTree key={childName} name={childName} children={childData} />
))
}
</details>
);
}
return null;
};
const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, activePage, onPortChange, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => {
const [portX, setPortX] = useState('');
const [portY, setPortY] = useState('');
const [portA, setPortA] = useState('');
const [activeBlock, setActiveBlock] = useState('project');
useEffect(() => {
if (activePage) {
setPortX(activePage.port.x.toString());
setPortY(activePage.port.y.toString());
setPortA(activePage.port.a.toString());
} else {
setPortX('');
setPortY('');
setPortA('');
}
}, [activePage?.id, activePage?.port.x, activePage?.port.y, activePage?.port.a]);
const handleSubmitX = () => {
if (!activePage) return;
const val = parseFloat(portX);
if (!isNaN(val)) {
onPortChange(activePage.id, { ...activePage.port, x: val });
} else {
setPortX(activePage.port.x.toString());
}
};
const handleSubmitY = () => {
if (!activePage) return;
const val = parseFloat(portY);
if (!isNaN(val)) {
onPortChange(activePage.id, { ...activePage.port, y: val });
} else {
setPortY(activePage.port.y.toString());
}
};
const handleSubmitA = () => {
if (!activePage) return;
const val = parseFloat(portA);
if (!isNaN(val)) {
onPortChange(activePage.id, { ...activePage.port, a: val });
} else {
setPortA(activePage.port.a.toString());
}
};
const handleProjectToggle = () => {
if (!projectExpanded) {
setActiveBlock('project');
}
onProjectToggle();
};
const handleLibraryToggle = () => {
if (!expanded) {
setActiveBlock('library');
}
onToggle();
};
return (
<aside style={{
width: width, background: 'var(--bg-card)', borderRight: '1px solid var(--border)',
padding: 12, display: 'flex', flexDirection: 'column', height: '100%',
boxSizing: 'border-box', overflow: 'hidden', gap: 12
}}>
<div style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0', minHeight: 0, overflowY: 'auto', gap: 12 }}>
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: activeBlock === 'project' ? '3 1 0' : '1 1 0' }}>
<div className="left-block-header">
<span>Project Tree</span>
<button className="toggle-btn" onClick={handleProjectToggle} title={projectExpanded ? 'Collapse all' : 'Expand all'}>
{projectExpanded ? '▾' : '▸'}
</button>
</div>
<div className="left-block-body project-tree-scroll" style={{ flex: '1 1 0', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} key={projectTreeKey} ref={projectTreeRef}>
{projectTreeItems && projectTreeItems.length > 0 ? (
projectTreeItems.map(item => {
if (item.type === 'project') {
return (
<ProjectTreeNode key={item.name} name={item.name} children={{ __type__: 'project', __name__: item.name, composites: item.composites }} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
);
} else {
return (
<ProjectTreeNode key={item.name} name={item.name} children={{ __type__: 'composite', __name__: item.name, tree: item.tree || {}, pageId: item.pageId }} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} />
);
}
})
) : (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No project loaded</p>
)}
</div>
</div>
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: activeBlock === 'library' ? '3 1 0' : '1 1 0' }}>
<div className="left-block-header">
<span>PDK Libraries</span>
<button className="toggle-btn" onClick={handleLibraryToggle} title={expanded ? 'Collapse all' : 'Expand all'}>
{expanded ? '▾' : '▸'}
</button>
</div>
<div className="left-block-body" style={{ flex: '1 1 0', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} key={treeKey} ref={treeRef}>
{library && Object.keys(library).length > 0 ? (
Object.entries(library).map(([key, value]) => (
<TreeNode key={key} name={key} children={value} />
))
) : (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>Loading library...</p>
)}
</div>
</div>
</div>
<div className="left-block" style={{ flexShrink: 0, minHeight: 180 }}>
<div className="left-block-header">Canva</div>
<div className="left-block-body" style={{ color: 'var(--text-muted)' }}>
{activePage ? (
<div>
<label>X Coordinate</label>
<input
type="number"
step="1"
value={portX}
onChange={(e) => setPortX(e.target.value)}
onBlur={handleSubmitX}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitX(); }}
/>
<br />
<label>Y Coordinate</label>
<input
type="number"
step="1"
value={portY}
onChange={(e) => setPortY(e.target.value)}
onBlur={handleSubmitY}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitY(); }}
/>
<br />
<label>Angle (deg)</label>
<input
type="number"
step="1"
value={portA}
onChange={(e) => setPortA(e.target.value)}
onBlur={handleSubmitA}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitA(); }}
/>
</div>
) : (
<p style={{ fontStyle: 'italic' }}>No canvas open</p>
)}
</div>
</div>
</aside>
);
};
const RightPanel = ({ selectedNode, width, onRenameComponent, onUpdateNode }) => {
const [componentData, setComponentData] = useState(null);
const [loading, setLoading] = useState(false);
const [enlarged, setEnlarged] = useState(null);
const [editingComponentName, setEditingComponentName] = useState(false);
const [tempComponentName, setTempComponentName] = useState('');
const [localX, setLocalX] = useState('');
const [localY, setLocalY] = useState('');
const [localRotation, setLocalRotation] = useState('');
useEffect(() => {
const nodeId = selectedNode?.id;
if (!nodeId) {
setComponentData(null);
setLoading(false);
return;
}
const compName = selectedNode?.data?.componentName;
if (!compName) {
setComponentData(null);
setLoading(false);
return;
}
if (componentData && componentData.name === compName && componentData.nodeId === nodeId) return;
setLoading(true);
fetch(`/api/component/${encodeURIComponent(compName)}`)
.then(r => r.json())
.then(data => {
setComponentData({ ...data, nodeId: nodeId, componentDisplayName: selectedNode.data.componentDisplayName || data.name });
setLoading(false);
})
.catch(() => setLoading(false));
}, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName]);
useEffect(() => {
if (selectedNode) {
setLocalX(selectedNode.position.x.toFixed(3));
setLocalY(selectedNode.position.y.toFixed(3));
const rot = selectedNode.id === 'page-port'
? (selectedNode.data?.angle ?? 0)
: (selectedNode.data?.rotation ?? 0);
setLocalRotation(rot.toFixed(3));
}
}, [selectedNode?.position.x, selectedNode?.position.y, selectedNode?.data?.rotation, selectedNode?.data?.angle, selectedNode?.id]);
const updatePosition = useCallback((id, axis, value) => {
const val = parseFloat(value);
if (isNaN(val)) return;
onUpdateNode(id, { position: { [axis]: val } });
}, [onUpdateNode]);
const updateRotation = useCallback((id, value) => {
const val = parseFloat(value);
if (isNaN(val)) return;
const clamped = Math.min(180, Math.max(-180, val));
const dataField = id === 'page-port' ? { angle: clamped } : { rotation: clamped };
onUpdateNode(id, { data: dataField });
}, [onUpdateNode]);
const formatPort = (port) => {
if (!port) return '—';
return `x:${port.x ?? '?'}, y:${port.y ?? '?'}, a:${port.a ?? '?'}, w:${port.width ?? '?'}`;
};
const currentComponentDisplayName = selectedNode?.data?.componentDisplayName || '';
const handleStartEditName = () => {
setTempComponentName(currentComponentDisplayName);
setEditingComponentName(true);
};
const handleSaveName = () => {
const newName = tempComponentName.trim();
if (newName && selectedNode) {
onRenameComponent(selectedNode.id, newName);
}
setEditingComponentName(false);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
handleSaveName();
} else if (e.key === 'Escape') {
setEditingComponentName(false);
}
};
return (
<aside style={{
width: width, background: 'var(--bg-card)', borderLeft: '1px solid var(--border)',
padding: 12, display: 'flex', flexDirection: 'column', height: '100%',
boxSizing: 'border-box', overflowY: 'auto'
}}>
<div className="right-block" style={{ flexShrink: 0, minHeight: 200 }}>
<div className="right-block-header">Transforms</div>
<div className="right-block-body">
{selectedNode ? (
<div>
<label>X Coordinate</label>
<input
type="number"
step="1"
value={localX}
onChange={(e) => setLocalX(e.target.value)}
onBlur={() => {
const val = parseFloat(localX);
if (!isNaN(val) && selectedNode) {
updatePosition(selectedNode.id, 'x', val);
setLocalX(val.toFixed(3));
} else if (selectedNode) {
setLocalX(selectedNode.position.x.toFixed(3));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
<br /><br />
<label>Y Coordinate</label>
<input
type="number"
step="1"
value={localY}
onChange={(e) => setLocalY(e.target.value)}
onBlur={() => {
const val = parseFloat(localY);
if (!isNaN(val) && selectedNode) {
updatePosition(selectedNode.id, 'y', val);
setLocalY(val.toFixed(3));
} else if (selectedNode) {
setLocalY(selectedNode.position.y.toFixed(3));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
<br /><br />
<label>Angle (deg)</label>
<input
type="number"
step="1"
value={localRotation}
onChange={(e) => setLocalRotation(e.target.value)}
onBlur={() => {
const val = parseFloat(localRotation);
if (!isNaN(val) && selectedNode) {
updateRotation(selectedNode.id, val);
setLocalRotation(val.toFixed(3));
} else if (selectedNode) {
const rot = selectedNode.id === 'page-port'
? (selectedNode.data?.angle ?? 0)
: (selectedNode.data?.rotation ?? 0);
setLocalRotation(rot.toFixed(3));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</div>
) : (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', textAlign: 'center' }}>Select a node to inspect</p>
)}
</div>
</div>
{selectedNode?.data?.componentName && (
<div className="right-block" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="right-block-header">Parameters</div>
<div className="right-block-body" style={{ flex: 1, overflowY: 'auto' }}>
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading data...</p>
) : componentData ? (
<>
<div style={{ marginBottom: '15px' }}>
<label>Instance Name</label>
{editingComponentName ? (
<input
type="text"
value={tempComponentName}
onChange={(e) => setTempComponentName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={handleKeyDown}
autoFocus
/>
) : (
<div
style={{
cursor: 'pointer',
padding: '6px 8px',
backgroundColor: 'var(--input-bg)',
border: '1px solid var(--border)',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
wordBreak: 'break-all',
color: 'var(--accent)'
}}
onClick={handleStartEditName}
title="Click to edit"
>
<span>{currentComponentDisplayName || componentData.name}</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}></span>
</div>
)}
</div>
<div style={{ color: 'var(--text-muted)', lineHeight: '1.6' }}>
<p style={{ margin: '0 0 8px 0', wordBreak: 'break-all' }}>
<strong style={{ color: 'var(--text-main)' }}>Cell:</strong> {componentData.name}
</p>
<p style={{ margin: '0 0 8px 0' }}>
<strong style={{ color: 'var(--text-main)' }}>Foundry:</strong> {componentData.foundry}<br />
<strong style={{ color: 'var(--text-main)' }}>Process:</strong> {componentData.process}
</p>
</div>
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '4px' }}>Ports:</p>
<ul style={{ paddingLeft: 15, margin: '0 0 15px 0', color: 'var(--text-muted)' }}>
{componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => (
<li key={portName} style={{ letterSpacing: '0.5px' }}>
<span style={{ color: 'var(--accent)' }}>{portName}</span>: {formatPort(portInfo)}
</li>
))}
</ul>
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '4px' }}>Preview:</p>
<div style={{
border: '1px solid var(--border)', width: '100%', height: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--input-bg)', borderRadius: '4px',
overflow: 'hidden',
}}>
<img
src={`/api/component/${encodeURIComponent(componentData.name)}/image`}
alt="Component layout"
loading="lazy"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', cursor: 'pointer' }}
onClick={() => setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image`)}
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement.innerHTML = '<span style="color:var(--text-muted)">No preview</span>';
}}
/>
</div>
</>
) : (
<p style={{ color: 'var(--text-muted)' }}>No data available</p>
)}
</div>
</div>
)}
<div className="right-block" style={{ marginTop: 'auto', flexShrink: 0 }}>
<div className="right-block-header">Inverse Design</div>
<div className="right-block-body placeholder-block">Requires AI Upgrade</div>
</div>
{enlarged && (
<div
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(15, 23, 42, 0.9)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'zoom-out',
backdropFilter: 'blur(4px)'
}}
onClick={() => setEnlarged(null)}
>
<img
src={enlarged}
alt="Enlarged layout"
style={{ maxWidth: '90%', maxHeight: '90%', objectFit: 'contain', border: '1px solid var(--border)', background: 'var(--bg-main)' }}
/>
</div>
)}
</aside>
);
};
const ResizeHandle = ({ onMouseDown }) => (
<div
onMouseDown={onMouseDown}
style={{
width: 6, cursor: 'col-resize', background: 'transparent',
transition: 'background 0.2s', zIndex: 5, flexShrink: 0,
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
/>
);
function findComponentPath(lib, compName) {
const path = [];
function walk(obj, currentPath) {
if (obj && obj.__type__ === 'component' && obj.__name__ === compName) {
path.push(...currentPath);
return true;
}
if (typeof obj === 'object') {
for (const [key, val] of Object.entries(obj)) {
if (walk(val, [...currentPath, key])) return true;
}
}
return false;
}
walk(lib, []);
return path;
}
function buildCompInstanceTree(compNodes, library) {
const tree = {};
compNodes.forEach(node => {
const compName = node.data.componentName;
const instanceName = node.data.componentDisplayName || node.id;
if (!compName) return;
const fullPath = findComponentPath(library, compName);
if (fullPath.length === 0) return;
let current = tree;
for (let i = 0; i < fullPath.length - 1; i++) {
const seg = fullPath[i];
if (!current[seg]) current[seg] = {};
current = current[seg];
}
const lastDir = fullPath[fullPath.length - 1];
if (!current[lastDir]) {
current[lastDir] = {
__type__: 'component',
__name__: compName,
instances: []
};
}
if (!current[lastDir].instances.includes(instanceName)) {
current[lastDir].instances.push(instanceName);
}
});
return tree;
}
function buildCompTree(compNodes, library) {
const tree = {};
compNodes.forEach(node => {
const compName = node.data.componentName;
if (!compName) return;
const fullPath = findComponentPath(library, compName);
if (fullPath.length === 0) return;
let current = tree;
for (let i = 0; i < fullPath.length - 1; i++) {
const seg = fullPath[i];
if (!current[seg]) current[seg] = {};
current = current[seg];
}
const leafName = fullPath[fullPath.length - 1];
if (!current[leafName]) {
current[leafName] = { __type__: 'component', __name__: compName };
}
});
return tree;
}
function App() {
const [pages, setPages] = useState([]);
const [activePageId, setActivePageId] = useState(null);
const reactFlowInstance = useReactFlow();
const [library, setLibrary] = useState(null);
const [treeKey, setTreeKey] = useState(0);
const [expanded, setExpanded] = useState(false);
const treeContainerRef = useRef(null);
const [projectTreeKey, setProjectTreeKey] = useState(0);
const [projectExpanded, setProjectExpanded] = useState(false);
const projectTreeContainerRef = useRef(null);
const [leftWidth, setLeftWidth] = useState(430);
const [rightWidth, setRightWidth] = useState(260);
const [dragging, setDragging] = useState(null);
const [gridSnap, setGridSnap] = useState(false);
const [clipboard, setClipboard] = useState({ nodes: [] });
const initializedRef = useRef(false);
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
const currentNodes = activePage ? activePage.nodes : [];
const currentEdges = activePage ? activePage.edges : [];
const [projectCompositeMap, setProjectCompositeMap] = useState({});
const [standaloneComposites, setStandaloneComposites] = useState([]);
const [compositeTrees, setCompositeTrees] = useState({});
const syncCompositePlacement = useCallback((projectName, compositeName, mode = 'add') => {
setStandaloneComposites(prev => {
if (mode === 'add') return prev.filter(name => name !== compositeName);
if (mode === 'remove' && !prev.includes(compositeName)) return [...prev, compositeName];
return prev;
});
setProjectCompositeMap(prev => {
const currentList = prev[projectName] || [];
if (mode === 'add') {
if (currentList.includes(compositeName)) return prev;
return {
...prev,
[projectName]: [...currentList, compositeName]
};
}
if (mode === 'remove') {
return {
...prev,
[projectName]: currentList.filter(name => name !== compositeName)
};
}
return prev;
});
}, []);
const syncAllCompositeTrees = useCallback((pagesToScan, libraryData) => {
if (!libraryData) return;
const nextTrees = {};
pagesToScan.forEach(page => {
if (page.type !== 'composite') return;
const compNodes = page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName);
nextTrees[page.name] = buildCompInstanceTree(compNodes, libraryData);
});
setCompositeTrees(prev => ({
...prev,
...nextTrees
}));
}, []);
const onNodesChange = useCallback((changes) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
const newNodes = applyNodeChanges(changes, p.nodes);
const portNode = newNodes.find(n => n.id === 'page-port');
let newPort = p.port;
if (portNode) {
const { x, y } = portNode.position;
const angle = portNode.data?.angle ?? 0;
if (x !== p.port.x || y !== p.port.y || angle !== p.port.a) {
newPort = { x, y, a: angle };
}
}
return { ...p, nodes: newNodes, port: newPort };
}));
}, [activePageId]);
const onEdgesChange = useCallback((changes) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, edges: applyEdgeChanges(changes, p.edges) };
}));
}, [activePageId]);
const handleUpdateNode = useCallback((nodeId, update) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
const newNodes = p.nodes.map(n => {
if (n.id === nodeId) {
return {
...n,
position: update.position != null ? { ...n.position, ...update.position } : n.position,
data: { ...n.data, ...update.data }
};
}
return n;
});
let newPort = p.port;
if (nodeId === 'page-port') {
const portNode = newNodes.find(n => n.id === 'page-port');
if (portNode) {
newPort = { x: portNode.position.x, y: portNode.position.y, a: portNode.data?.angle ?? 0 };
}
}
return { ...p, nodes: newNodes, port: newPort };
}));
}, [activePageId]);
const handleCopy = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected && n.id !== 'page-port');
if (selectedNodes.length > 0) {
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
}
}, [activePage]);
const handleCut = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected && n.id !== 'page-port');
if (selectedNodes.length > 0) {
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
const selectedNodeIds = new Set(selectedNodes.map(n => n.id));
const newNodes = activePage.nodes.filter(n => !selectedNodeIds.has(n.id));
const newEdges = activePage.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
setPages(prev => prev.map(p => p.id === activePage.id ? { ...p, nodes: newNodes, edges: newEdges } : p));
}
}, [activePage, setPages]);
const handlePaste = useCallback(() => {
if (!activePage || clipboard.nodes.length === 0) return;
const newNodes = clipboard.nodes.map(node => ({
...node,
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
position: { x: node.position.x + 20, y: node.position.y + 20 },
selected: true,
data: { ...node.data, componentDisplayName: generateComponentDisplayName() }
}));
setPages(prev => prev.map(p => {
if (p.id !== activePage.id) return p;
return { ...p, nodes: p.nodes.map(n => ({ ...n, selected: false })).concat(newNodes) };
}));
setClipboard({ nodes: newNodes });
}, [activePage, clipboard, generateComponentDisplayName]);
const handleDelete = useCallback(() => {
if (!activePage) return;
const selectedNodeIds = new Set(activePage.nodes.filter(n => n.selected && n.id !== 'page-port').map(n => n.id));
if (selectedNodeIds.size > 0) {
const newNodes = activePage.nodes.filter(n => !selectedNodeIds.has(n.id));
const newEdges = activePage.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
setPages(prev => prev.map(p => p.id === activePage.id ? { ...p, nodes: newNodes, edges: newEdges } : p));
}
}, [activePage]);
useEffect(() => {
const handleKeyDown = (e) => {
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
return;
}
const cmdOrCtrl = e.ctrlKey || e.metaKey;
if (cmdOrCtrl && e.key.toLowerCase() === 'c') {
e.preventDefault();
handleCopy();
} else if (cmdOrCtrl && e.key.toLowerCase() === 'x') {
e.preventDefault();
handleCut();
} else if (cmdOrCtrl && e.key.toLowerCase() === 'v') {
e.preventDefault();
handlePaste();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
handleDelete();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleCopy, handleCut, handlePaste, handleDelete]);
const componentCounterRef = useRef(1);
const generateComponentDisplayName = useCallback(() => {
const name = `component_${componentCounterRef.current}`;
componentCounterRef.current += 1;
return name;
}, []);
const renameComponent = useCallback((nodeId, newComponentDisplayName) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return {
...p,
nodes: p.nodes.map(n => n.id === nodeId ? { ...n, data: { ...n.data, componentDisplayName: newComponentDisplayName } } : n)
};
}));
}, [activePageId]);
const fetchLibrary = useCallback(async () => {
try {
const res = await fetch('/api/library');
const data = await res.json();
setLibrary(data);
} catch (err) {
console.error('Failed to fetch library', err);
}
}, []);
useEffect(() => { fetchLibrary(); }, [fetchLibrary]);
const collectComponentNames = useCallback((lib) => {
const names = [];
const walk = (obj) => {
if (obj && obj.__type__ === 'component' && obj.__name__) {
names.push({ name: obj.__name__, category: obj.__category__ || 'default' });
}
if (typeof obj === 'object') {
Object.values(obj).forEach(walk);
}
};
walk(lib);
return names;
}, []);
useEffect(() => {
const input = document.getElementById('open-yaml-input');
if (!input) return;
const handleFile = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const doc = jsyaml.load(text);
if (!doc.instances) {
alert('no instances found');
return;
}
const newNodes = [];
const newEdges = [];
const nodeNameMap = {};
const isProject = doc.type === 'project';
for (const [instName, inst] of Object.entries(doc.instances)) {
const compPath = inst.component || '';
const compName = compPath.split('/').pop();
let category = '';
if (!isProject && compName && library) {
const walk = (obj) => {
if (obj?.__type__ === 'component' && obj.__name__ === compName) {
category = obj.__category__ || '';
return true;
}
if (typeof obj === 'object') {
for (const v of Object.values(obj)) if (walk(v)) return true;
}
return false;
};
walk(library);
}
const nodeId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
nodeNameMap[instName] = nodeId;
newNodes.push({
id: nodeId,
type: 'rotatableNode',
position: {
x: parseFloat(inst.x) || 0,
y: parseFloat(inst.y) || 0,
},
data: {
label: isProject ? instName : compName,
componentName: isProject ? instName : compName,
category: isProject ? '' : category,
rotation: parseFloat(inst.rotation) || 0,
componentDisplayName: instName,
type: isProject ? 'composite' : undefined,
},
});
}
if (!isProject) {
const links = doc.bundles?.output_bus?.links;
if (links) {
const linkArray = Array.isArray(links) ? links : [links];
linkArray.forEach(link => {
if (link.from && link.to) {
const [fromInst, fromPort] = link.from.split(':');
const [toInst, toPort] = link.to.split(':');
const sourceId = nodeNameMap[fromInst];
const targetId = nodeNameMap[toInst];
if (sourceId && targetId) {
newEdges.push({
id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`,
source: sourceId,
target: targetId,
sourceHandle: fromPort,
targetHandle: toPort,
type: 'smoothstep',
style: { stroke: 'var(--accent)', strokeWidth: 2 },
});
}
}
});
}
}
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
const newPage = {
id: newPageId,
name: newPageName,
type: isProject ? 'project' : 'composite',
nodes: isProject ? newNodes : [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 150 },
data: { label: 'Port', angle: 0 },
draggable: true,
selectable: true,
deletable: false,
},
...newNodes,
],
edges: newEdges,
port: { x: 50, y: 150, a: 0 },
};
setPages(prev => [...prev, newPage]);
setActivePageId(newPageId);
if (isProject) {
setProjectCompositeMap(prev => ({
...prev,
[newPageName]: [...(prev[newPageName] || []), ...Object.keys(doc.instances)]
}));
} else {
setStandaloneComposites(prev => {
if (!prev.includes(newPageName)) return [...prev, newPageName];
return prev;
});
if (library) {
const compTree = {};
for (const inst of Object.values(doc.instances)) {
const compPath = inst.component || '';
const compName = compPath.split('/').pop();
if (!compName) continue;
const fullPath = findComponentPath(library, compName);
if (fullPath.length === 0) continue;
const emoIndex = fullPath.indexOf('EMO1_2ML_CU_Al_RDL');
const segments = emoIndex >= 0 ? fullPath.slice(emoIndex + 1) : fullPath.slice(1);
if (segments.length === 0) continue;
let current = compTree;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
if (!current[seg]) current[seg] = {};
current = current[seg];
}
const leaf = segments[segments.length - 1];
if (!current[leaf]) {
current[leaf] = { __type__: 'component', __name__: compName };
}
}
setCompositeTrees(prev => ({ ...prev, [newPageName]: compTree }));
}
}
} catch (err) {
alert('yaml parse error: ' + err.message);
}
e.target.value = '';
};
input.addEventListener('change', handleFile);
return () => input.removeEventListener('change', handleFile);
}, [library]);
useEffect(() => {
setProjectCompositeMap(prev => {
const projectNames = pages.filter(p => p.type === 'project').map(p => p.name);
const filtered = {};
for (const name of projectNames) {
if (prev[name]) filtered[name] = prev[name];
}
return filtered;
});
setStandaloneComposites(prev => {
const compositeNames = pages.filter(p => p.type === 'composite').map(p => p.name);
return prev.filter(name => compositeNames.includes(name));
});
}, [pages]);
useEffect(() => {
if (library && !initializedRef.current) {
initializedRef.current = true;
const compList = collectComponentNames(library);
const projectId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const projectPage = {
id: projectId,
name: 'MainProject',
type: 'project',
nodes: [],
edges: [],
port: { x: 0, y: 0, a: 0 }
};
let counter = 1;
const fixedComps1 = compList.slice(0, 3);
const compNodes1 = fixedComps1.map((comp, i) => {
const name = `component_${counter++}`;
return {
id: `node-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 5)}`,
type: 'rotatableNode',
position: { x: 100 + i * 300, y: 100 },
data: {
label: comp.name,
componentName: comp.name,
category: comp.category,
rotation: 0,
componentDisplayName: name
}
};
});
const comp1Id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const comp1Page = {
id: comp1Id,
name: 'comp_1',
type: 'composite',
nodes: [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 150 },
data: { label: 'Port', angle: 0 },
draggable: true,
selectable: true,
deletable: false,
},
...compNodes1
],
edges: [],
port: { x: 50, y: 150, a: 0 }
};
const fixedComps2 = compList.slice(0, 3);
const compNodes2 = fixedComps2.map((comp, i) => {
const name = `component_${counter++}`;
return {
id: `node-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 10)}`,
type: 'rotatableNode',
position: { x: 100 + i * 300, y: 200 },
data: {
label: comp.name,
componentName: comp.name,
category: comp.category,
rotation: 0,
componentDisplayName: name
}
};
});
componentCounterRef.current = counter;
const comp2Id = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const comp2Page = {
id: comp2Id,
name: 'comp_2',
type: 'composite',
nodes: [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 250 },
data: { label: 'Port', angle: 0 },
draggable: true,
selectable: true,
deletable: false,
},
...compNodes2
],
edges: [],
port: { x: 50, y: 250, a: 0 }
};
setPages([projectPage, comp1Page, comp2Page]);
setActivePageId(projectId);
setProjectCompositeMap({ MainProject: ['comp_1', 'comp_2'] });
setCompositeTrees({
comp_1: buildCompInstanceTree(comp1Page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library),
comp_2: buildCompInstanceTree(comp2Page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library)
});
}
}, [library, collectComponentNames]);
useEffect(() => {
if (activePage && reactFlowInstance) {
reactFlowInstance.setViewport({ x: 0, y: 0, zoom: 1 });
}
}, [activePage?.id]);
useEffect(() => {
if (!library) return;
syncAllCompositeTrees(pages, library);
}, [pages, library, syncAllCompositeTrees]);
const selectedNode = useMemo(() => currentNodes.find(n => n.selected), [currentNodes]);
const openProject = useCallback((name) => {
setPages(prev => {
const existing = prev.find(p => p.name === name && p.type === 'project');
if (existing) {
setActivePageId(existing.id);
return prev;
}
const newProjectPage = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: name,
type: 'project',
nodes: [],
edges: [],
port: { x: 0, y: 0, a: 0 }
};
setActivePageId(newProjectPage.id);
setProjectCompositeMap(prevMap => ({ ...prevMap, [name]: prevMap[name] || [] }));
return [...prev, newProjectPage];
});
}, []);
const openPage = useCallback((name) => {
const belongsToProject = Object.values(projectCompositeMap).some(comps => comps.includes(name));
if (!belongsToProject && !standaloneComposites.includes(name)) {
setStandaloneComposites(prev => [...prev, name]);
}
setPages(prev => {
const existing = prev.find(p => p.name === name && p.type === 'composite');
if (existing) {
setActivePageId(existing.id);
return prev;
}
const newComposite = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: name,
type: 'composite',
nodes: [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 150 },
data: { label: 'Port', angle: 0 },
draggable: true,
selectable: true,
deletable: false,
}
],
edges: [],
port: { x: 50, y: 150, a: 0 }
};
setActivePageId(newComposite.id);
return [...prev, newComposite];
});
}, [projectCompositeMap, standaloneComposites]);
const closePage = useCallback((pageId) => {
setPages(prev => {
const pageToClose = prev.find(p => p.id === pageId);
const filtered = prev.filter(p => p.id !== pageId);
if (activePageId === pageId) {
const idx = prev.findIndex(p => p.id === pageId);
const nextActive = filtered[Math.min(idx, filtered.length - 1)] || null;
setActivePageId(nextActive ? nextActive.id : null);
}
return filtered;
});
}, [activePageId]);
const switchPage = useCallback((pageId) => {
setActivePageId(pageId);
}, []);
const handlePortChange = useCallback((pageId, newPort) => {
setPages(prev => prev.map(p => {
if (p.id !== pageId) return p;
const portNodeId = 'page-port';
const nodes = p.nodes.map(n => {
if (n.id === portNodeId) {
return { ...n, position: { x: newPort.x, y: newPort.y }, data: { ...n.data, angle: newPort.a } };
}
return n;
});
if (!nodes.some(n => n.id === portNodeId)) {
nodes.push({
id: portNodeId,
type: 'portNode',
position: { x: newPort.x, y: newPort.y },
data: { label: 'Port', angle: newPort.a },
draggable: true,
selectable: true,
deletable: false,
});
}
return { ...p, port: newPort, nodes };
}));
}, []);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback((event) => {
event.preventDefault();
const rawData = event.dataTransfer.getData('application/reactflow');
console.log("📥 DROP EVENT: Received raw data ->", rawData);
if (!rawData) return;
let parsedData;
try {
parsedData = JSON.parse(rawData);
} catch (error) {
parsedData = { name: rawData, category: 'default' };
}
if (parsedData.type === 'standaloneComposite') {
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const newNode = {
id: Date.now().toString(),
type: 'rotatableNode',
position,
data: {
label: parsedData.name,
componentName: parsedData.name,
componentDisplayName: parsedData.name,
type: 'composite',
category: null,
rotation: 0
}
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
if (activePage?.type === 'project') {
const projectName = activePage.name;
setStandaloneComposites(prev => prev.filter(name => name !== parsedData.name));
setProjectCompositeMap(prev => ({
...prev,
[projectName]: [...(prev[projectName] || []), parsedData.name]
}));
}
return;
}
if (parsedData.type === 'composite') {
if (!activePageId) return;
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const newNode = {
id: Date.now().toString(),
type: 'rotatableNode',
position,
data: {
label: parsedData.name,
componentName: parsedData.name,
componentDisplayName: parsedData.name,
type: 'composite',
category: null,
rotation: 0
}
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
if (activePage?.type === 'project') {
syncCompositePlacement(activePage.name, parsedData.name, 'add');
}
return;
}
if (!activePageId) {
alert('Please open a composite page first.');
return;
}
const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const componentDisplayName = generateComponentDisplayName();
const newNode = {
id: Date.now().toString(),
type: 'rotatableNode',
position,
data: {
label: parsedData.name,
componentName: parsedData.name,
category: parsedData.category,
rotation: 0,
componentDisplayName: componentDisplayName
},
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
}, [activePageId, activePage, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement]);
const onConnect = useCallback((connection) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, edges: addEdge({ ...connection, type: 'smoothstep', style: { stroke: 'var(--accent)', strokeWidth: 2 } }, p.edges) };
}));
}, [activePageId]);
const expandAll = useCallback(() => {
if (treeContainerRef.current) {
treeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
}
}, []);
const collapseAll = useCallback(() => setTreeKey(k => k + 1), []);
const handleToggle = useCallback(() => {
if (expanded) { collapseAll(); setExpanded(false); }
else { expandAll(); setExpanded(true); }
}, [expanded, expandAll, collapseAll]);
const expandProjectAll = useCallback(() => {
if (projectTreeContainerRef.current) {
projectTreeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
}
}, []);
const collapseProjectAll = useCallback(() => setProjectTreeKey(k => k + 1), []);
const handleProjectToggle = useCallback(() => {
if (projectExpanded) { collapseProjectAll(); setProjectExpanded(false); }
else { expandProjectAll(); setProjectExpanded(true); }
}, [projectExpanded, expandProjectAll, collapseProjectAll]);
const handleResizeStart = useCallback((side) => (e) => {
e.preventDefault();
setDragging(side);
}, []);
useEffect(() => {
if (!dragging) return;
const onMouseMove = (e) => {
if (dragging === 'left') {
setLeftWidth(Math.min(500, Math.max(150, e.clientX)));
} else if (dragging === 'right') {
const newWidth = window.innerWidth - e.clientX;
setRightWidth(Math.min(500, Math.max(150, newWidth)));
}
};
const onMouseUp = () => setDragging(null);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [dragging]);
const toggleGridSnap = useCallback(() => {
setGridSnap(prev => !prev);
}, []);
const projectTreeItems = useMemo(() => {
const items = [];
const projectPages = pages.filter(p => p.type === 'project');
projectPages.forEach(project => {
const composites = (projectCompositeMap[project.name] || []).map(name => {
const compPage = pages.find(p => p.name === name && p.type === 'composite');
return {
__type__: 'composite',
__name__: name,
tree: compositeTrees[name] || {},
pageId: compPage ? compPage.id : name
};
});
items.push({
type: 'project',
name: project.name,
composites: composites
});
});
standaloneComposites.forEach(name => {
items.push({
type: 'standaloneComposite',
name: name,
tree: compositeTrees[name] || {},
pageId: pages.find(p => p.name === name && p.type === 'composite')?.id || name
});
});
return items;
}, [pages, library, projectCompositeMap, standaloneComposites, compositeTrees]);
const buildBundlesYaml = (page) => {
const { nodes, edges } = page;
const nodeMap = {};
nodes.forEach(n => { nodeMap[n.id] = n; });
let linksYaml = '';
if (edges.length > 0) {
const linkLines = edges.map(edge => {
const sourceNode = nodeMap[edge.source];
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';
return ` - from: ${sourceName}:${fromPort}\n to: ${targetName}:${toPort}`;
});
linksYaml = linkLines.join('\n');
}
return `# 3. Bundles (Grouped links for multi-bus/parallel routing)
bundles:
output_bus:
routing_type: euler_bend
links:
${linksYaml}`;
};
const handleBuildLayout = useCallback(async () => {
if (!activePage) return;
const header = `# =============================================
# mxPIC Cell/Project Definition File
# =============================================
name: ${activePage.name}
type: ${'composite'}
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
ports:
- name: in0
layer: WG_CORE
x: 0.0
y: 0.0
angle: 180.0
width: 0.5
- name: out0
layer: WG_CORE
x: 100.0
y: 10.0
angle: 0.0
width: 0.5
- name: out1
layer: WG_CORE
x: 100.0
y: -10.0
angle: 0.0
width: 0.5
# 2. Instances (The sub-components dropped onto this canvas)
instances:`;
let instancesBlock = '';
if (activePage.type === 'project') {
const compositeNodes = activePage.nodes.filter(n => n.type === 'rotatableNode' && n.data?.type === 'composite');
instancesBlock = compositeNodes.map(n => {
const instanceName = n.data.componentDisplayName || n.data.componentName;
return ` ${instanceName}:
component:
x: ${n.position.x.toFixed(1)}
y: ${n.position.y.toFixed(1)}
rotation: ${(n.data.rotation || 0).toFixed(1)}
mirror: false
settings:
length:`;
}).join('\n\n');
} else {
const nodes = activePage.nodes.filter(n => n.id !== 'page-port');
instancesBlock = nodes.map(n => {
const data = n.data;
const instanceName = data.componentDisplayName || n.id;
const compName = data.componentName || '';
let componentPath = compName;
if (library && compName) {
const pathArr = findComponentPath(library, compName);
if (pathArr.length > 0) {
componentPath = pathArr.join('/');
}
}
const x = n.position.x.toFixed(1);
const y = n.position.y.toFixed(1);
const rotation = (data.rotation || 0).toFixed(1);
return ` ${instanceName}:
component: ${componentPath}
x: ${x}
y: ${y}
rotation: ${rotation}
mirror: false
settings:
length: ${compName.includes('MMI') ? '25.5' : ''}`;
}).join('\n\n');
}
const bundlesBlock = buildBundlesYaml(activePage);
const yamlContent = `${header}
${instancesBlock}
${bundlesBlock}`;
// send to backend
try {
const response = await fetch('/api/save-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: `${activePage.name}.yaml`, // file name
content: yamlContent,
}),
});
if (!response.ok) {
const errData = await response.json();
alert(errData.error || 'Save failed, unknown error');
return;
}
const result = await response.json();
alert('successfully saved : ' + result.path);
} catch (err) {
alert('save error: ' + err.message);
}
}, [activePage, library, buildBundlesYaml, findComponentPath]);
const onNodeDoubleClick = useCallback((event, node) => {
if (node.data?.type === 'composite') {
openPage(node.data.componentName);
}
}, [openPage]);
return (
<div style={{ display: 'flex', width: '100%', height: '100%', userSelect: dragging ? 'none' : 'auto' }}>
<LeftPanel
projectTreeItems={projectTreeItems}
library={library} treeKey={treeKey} expanded={expanded}
onToggle={handleToggle} treeRef={treeContainerRef} width={leftWidth}
onOpenComposite={openPage}
onOpenProject={openProject}
activePage={activePage}
onPortChange={handlePortChange}
projectExpanded={projectExpanded}
onProjectToggle={handleProjectToggle}
projectTreeRef={projectTreeContainerRef}
projectTreeKey={projectTreeKey}
/>
<ResizeHandle onMouseDown={handleResizeStart('left')} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="canvas-tabs">
<div className="canvas-tab" style={{ borderRight: '1px solid var(--border)', marginRight: 4, cursor: 'pointer' }} onClick={() => document.getElementById('open-yaml-input').click()}>
📂 Open Project
</div>
{pages.map(page => (
<div key={page.id} className={`canvas-tab ${page.id === activePageId ? 'active' : ''}`} onClick={() => switchPage(page.id)}>
<span>{page.name}</span>
<button onClick={(e) => { e.stopPropagation(); closePage(page.id); }}>×</button>
</div>
))}
</div>
<div style={{ flex: 1, position: 'relative' }}>
<div style={{
position: 'absolute', top: 15, right: 15, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 10,
background: 'var(--bg-card)', padding: '6px 12px', borderRadius: '8px',
border: '1px solid var(--border)', boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
}}>
<span style={{ fontSize: '0.85em', fontWeight: '500', color: 'var(--text-main)', userSelect: 'none' }}>Snap to Grid</span>
<div onClick={toggleGridSnap} style={{
width: 40, height: 20, borderRadius: 10,
background: gridSnap ? 'var(--accent)' : 'var(--input-bg)',
border: '1px solid ' + (gridSnap ? 'var(--accent)' : 'var(--border)'),
cursor: 'pointer', display: 'flex', alignItems: 'center',
padding: '0 2px', transition: 'background 0.3s, border-color 0.3s',
}}>
<div style={{
width: 16, height: 16, borderRadius: '50%',
background: '#fff',
transform: gridSnap ? 'translateX(20px)' : 'translateX(0)',
transition: 'transform 0.2s',
}} />
</div>
</div>
{activePage && (
<button
onClick={handleBuildLayout}
style={{
position: 'absolute',
bottom: 20,
right: 20,
zIndex: 10,
background: 'var(--accent)',
color: 'var(--bg-main)',
border: 'none',
padding: '12px 20px',
borderRadius: 6,
fontWeight: '600',
cursor: 'pointer',
fontSize: '0.85em',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
>
Build Layout
</button>
)}
<ReactFlow
nodes={currentNodes}
edges={currentEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onDragOver={onDragOver}
onDrop={onDrop}
onConnect={onConnect}
onNodeDoubleClick={onNodeDoubleClick}
nodeTypes={{ rotatableNode: RotatableNode, portNode: PortNode }}
snapToGrid={gridSnap}
snapGrid={[10, 10]}
nodesDraggable={true}
nodesConnectable={true}
elementsSelectable={true}
connectionRadius={50}
>
<Controls style={{ bottom: 15, left: 15 }} />
<Background color="#334155" gap={20} size={1} />
</ReactFlow>
</div>
</div>
<ResizeHandle onMouseDown={handleResizeStart('right')} />
<RightPanel
selectedNode={selectedNode}
width={rightWidth}
onRenameComponent={renameComponent}
onUpdateNode={handleUpdateNode}
/>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<ReactFlowProvider>
<App />
<input type="file" id="open-yaml-input" accept=".yaml,.yml" style={{ display: 'none' }} />
</ReactFlowProvider>
);
</script>
</body>
</html>
{% endraw %}