Files
2026-05-16 22:19:07 +08:00

751 lines
27 KiB
HTML
Raw Permalink 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>Canvas with PDK Library Component Name & Rotation</title>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.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>
<style>
body,
html,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
details {
margin-left: 8px;
}
summary {
cursor: pointer;
}
.left-block {
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 8px;
}
.left-block-header {
background: #e0e0e0;
padding: 6px 10px;
font-weight: bold;
font-size: 0.9em;
border-bottom: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: space-between;
}
.left-block-body {
padding: 8px 10px;
font-size: 0.85em;
}
.tree-folder summary {
font-weight: bold;
}
.right-block {
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 8px;
}
.right-block-header {
background: #e0e0e0;
padding: 6px 10px;
font-weight: bold;
font-size: 0.9em;
border-bottom: 1px solid #ccc;
}
.right-block-body {
padding: 8px 10px;
font-size: 0.85em;
}
.placeholder-block {
border: 1px dashed #bbb;
padding: 8px;
color: #888;
font-size: 0.85em;
background: #f9f9f9;
}
.toggle-btn {
background: none;
border: none;
font-size: 1.8em;
cursor: pointer;
padding: 2px 4px;
line-height: 1;
border-radius: 3px;
}
.toggle-btn:hover {
background: #d0d0d0;
}
.component-leaf {
cursor: grab;
padding: 2px 4px;
margin-left: 20px;
word-break: break-all;
white-space: normal;
}
.component-leaf:hover {
background: #e6f7ff;
}
</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,
} = window.ReactFlow;
const RotatableNode = ({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
updateNodeInternals(id);
}, [data.rotation, updateNodeInternals, id]);
const baseHandleStyle = {
width: 14, height: 14,
background: '#555',
border: 'none',
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 20px', border: '1px solid #333', borderRadius: 6,
background: '#fff', minWidth: 100, textAlign: 'center',
position: 'relative', transform: `rotate(${data.rotation || 0}deg)`,
transition: selected ? 'none' : 'transform 0.1s ease',
boxSizing: 'border-box',
}}>
<div>{data.componentDisplayName}</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>
);
};
const TreeNode = ({ name, children }) => {
if (children && children.__type__ === 'component') {
const componentName = children.__name__;
const handleDragStart = (event) => {
event.dataTransfer.setData('application/reactflow', componentName);
event.dataTransfer.effectAllowed = 'move';
};
return (
<div className="component-leaf" draggable onDragStart={handleDragStart}>
🔷 {name}
</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 LeftPanel = ({ library, treeKey, expanded, onToggle, treeRef, width }) => (
<aside style={{
width: width, background: '#f4f4f4', borderRight: '1px solid #ccc',
padding: 10, display: 'flex', flexDirection: 'column', height: '100%',
boxSizing: 'border-box', overflowY: 'auto'
}}>
<div className="left-block">
<div className="left-block-header">
<span>PDK Libraries</span>
<button className="toggle-btn" onClick={onToggle} title={expanded ? 'Collapse all' : 'Expand all'}>
{expanded ? '▾' : '▸'}
</button>
</div>
<div className="left-block-body" style={{ maxHeight: '45vh', overflowY: 'auto' }} 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: '#999' }}>Loading library...</p>
)}
</div>
</div>
<div className="left-block">
<div className="left-block-header">Routing selections</div>
<div className="left-block-body">
<ul style={{ paddingLeft: 20, margin: 0 }}>
<li>Single mode wires</li>
<li>Multi-mode wires</li>
<li>DC electrical wires</li>
<li>RF electrical wires</li>
</ul>
</div>
</div>
<div className="left-block" style={{ marginTop: 'auto' }}>
<div className="left-block-header">User info</div>
<div className="left-block-body">
<div>Name: XXXXXX</div>
<div>ID: 12345678</div>
<button disabled>Log out</button>
</div>
</div>
</aside>
);
const RightPanel = memo(({ selectedNode, width, onRenameComponent }) => {
const [componentData, setComponentData] = useState(null);
const [loading, setLoading] = useState(false);
const [enlarged, setEnlarged] = useState(null);
const { setNodes } = useReactFlow();
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));
setLocalRotation(((selectedNode.data?.rotation || 0)).toFixed(3));
}
}, [selectedNode?.position.x, selectedNode?.position.y, selectedNode?.data?.rotation, selectedNode?.id]);
const updatePosition = useCallback((id, axis, value) => {
const val = parseFloat(value);
if (isNaN(val)) return;
setNodes(nds => nds.map(n => n.id === id ? { ...n, position: { ...n.position, [axis]: val } } : n));
}, [setNodes]);
const updateRotation = useCallback((id, value) => {
const val = parseFloat(value);
if (isNaN(val)) return;
const clamped = Math.min(180, Math.max(-180, val));
setNodes(nds => nds.map(n => n.id === id ? { ...n, data: { ...n.data, rotation: clamped } } : n));
}, [setNodes]);
const formatPort = (port) => {
if (!port) return '—';
return `x:${port.x ?? '?'}, y:${port.y ?? '?'}, a:${port.a ?? '?'}, w:${port.width ?? '?'}`;
};
const currentRotation = selectedNode?.data?.rotation ?? 0;
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: '#fafafa', borderLeft: '1px solid #ccc',
padding: 10, display: 'flex', flexDirection: 'column', height: '100%',
boxSizing: 'border-box', overflowY: 'auto'
}}>
<div className="right-block">
<div className="right-block-header">Properties</div>
<div className="right-block-body">
{selectedNode ? (
<div>
<label>X:</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();
}
}}
style={{ width: '100%' }}
/>
<br /><br />
<label>Y:</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();
}
}}
style={{ width: '100%' }}
/>
<br /><br />
<label>A (deg):</label>
<div style={{ marginTop: 4 }}>
<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) {
setLocalRotation(((selectedNode.data?.rotation || 0)).toFixed(3));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
style={{ width: '100%', marginTop: 4 }}
/>
</div>
</div>
) : (
<p style={{ color: '#999' }}>Click a node to edit</p>
)}
</div>
</div>
{selectedNode?.data?.componentName && (
<div className="right-block">
<div className="right-block-header">Item details</div>
<div className="right-block-body">
{loading ? (
<p>Loading...</p>
) : componentData ? (
<>
<div style={{ marginBottom: '10px' }}>
<strong style={{ display: 'block', marginBottom: '4px' }}>Component name:</strong>
{editingComponentName ? (
<input
type="text"
value={tempComponentName}
onChange={(e) => setTempComponentName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={handleKeyDown}
autoFocus
style={{ width: '100%', padding: '4px', fontSize: '0.85em', boxSizing: 'border-box' }}
/>
) : (
<div
style={{
cursor: 'pointer',
padding: '4px 6px',
backgroundColor: '#f0f0f0',
borderRadius: '3px',
display: 'inline-block',
wordBreak: 'break-all'
}}
onClick={handleStartEditName}
title="Click to edit"
>
{currentComponentDisplayName || componentData.name}
</div>
)}
</div>
<p style={{ wordBreak: 'break-all', whiteSpace: 'normal', overflowWrap: 'break-word', marginTop: '8px' }}>
<strong>PDK name:</strong><br />{componentData.name}
</p>
<p><strong>Description:</strong><br />
Foundry: {componentData.foundry}<br />
Process: {componentData.process}<br />
Year: {componentData.year}<br />
Designer: {componentData.designer}
</p>
<p><strong>PDK:</strong> (string)</p>
<p><strong>Ports:</strong></p>
<ul style={{ paddingLeft: 20, margin: 0 }}>
{componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => (
<li key={portName} style={{ letterSpacing: '0.5px' }}>{portName}: {formatPort(portInfo)}</li>
))}
</ul>
<p><strong>Image:</strong></p>
<div style={{
border: '1px dashed #ccc', width: '100%', height: 80,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#999', background: '#fcfcfc', marginTop: 4,
overflow: 'hidden',
}}>
<img
src={`/api/component/${encodeURIComponent(componentData.name)}/image`}
alt="Component layout"
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 = 'No image available';
}}
/>
</div>
</>
) : (
<p style={{ color: '#999' }}>No data</p>
)}
</div>
</div>
)}
<div className="right-block" style={{ marginTop: 'auto' }}>
<div className="right-block-header">Function block to be explored</div>
<div className="right-block-body placeholder-block">Reserved for future functionality</div>
</div>
{enlarged && (
<div
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'zoom-out',
}}
onClick={() => setEnlarged(null)}
>
<img
src={enlarged}
alt="Enlarged layout"
style={{ maxWidth: '90%', maxHeight: '90%', objectFit: 'contain' }}
/>
</div>
)}
</aside>
);
}, (prevProps, nextProps) => {
const prev = prevProps.selectedNode;
const next = nextProps.selectedNode;
if (prev?.id !== next?.id) return false;
if (prev?.position?.x !== next?.position?.x) return false;
if (prev?.position?.y !== next?.position?.y) return false;
if (prev?.data?.rotation !== next?.data?.rotation) return false;
if (prev?.data?.componentName !== next?.data?.componentName) return false;
if (prev?.data?.componentDisplayName !== next?.data?.componentDisplayName) return false;
if (prevProps.width !== nextProps.width) return false;
return true;
});
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 = '#ccc'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
/>
);
function App() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const reactFlowInstance = useReactFlow();
const [library, setLibrary] = useState(null);
const [treeKey, setTreeKey] = useState(0);
const [expanded, setExpanded] = useState(false);
const treeContainerRef = useRef(null);
const [leftWidth, setLeftWidth] = useState(240);
const [rightWidth, setRightWidth] = useState(220);
const [dragging, setDragging] = useState(null);
const [gridSnap, setGridSnap] = useState(false);
const componentCounterRef = useRef(1);
const generateComponentDisplayName = useCallback(() => {
const name = `component_${componentCounterRef.current}`;
componentCounterRef.current += 1;
return name;
}, []);
const renameComponent = useCallback((nodeId, newComponentDisplayName) => {
setNodes(nds => nds.map(n => {
if (n.id === nodeId) {
return {
...n,
data: {
...n.data,
componentDisplayName: newComponentDisplayName
}
};
}
return n;
}));
}, [setNodes]);
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 selectedNode = useMemo(() => nodes.find(n => n.selected), [nodes]);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback((event) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow');
if (!type) 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: type,
componentName: type,
rotation: 0,
componentDisplayName: componentDisplayName
},
};
setNodes((nds) => nds.concat(newNode));
}, [setNodes, reactFlowInstance, generateComponentDisplayName]);
const onConnect = useCallback((connection) => {
setEdges((eds) => addEdge({ ...connection, type: 'straight' }, eds));
}, [setEdges]);
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 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);
}, []);
return (
<div style={{ display: 'flex', width: '100%', height: '100%', userSelect: dragging ? 'none' : 'auto' }}>
<LeftPanel
library={library} treeKey={treeKey} expanded={expanded}
onToggle={handleToggle} treeRef={treeContainerRef} width={leftWidth}
/>
<ResizeHandle onMouseDown={handleResizeStart('left')} />
<div style={{ flex: 1, position: 'relative' }}>
<div style={{
position: 'absolute', top: 10, right: 10, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span style={{
fontSize: '0.85em', fontWeight: 'bold', fontFamily: "'Sofia Pro', sans-serif",
color: '#333', userSelect: 'none'
}}>Grid lock</span>
<div
onClick={toggleGridSnap}
style={{
width: 48, height: 24, borderRadius: 12,
background: gridSnap ? '#28a745' : '#ccc',
cursor: 'pointer', display: 'flex', alignItems: 'center',
padding: '0 2px', transition: 'background 0.3s',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.2)',
}}
>
<div style={{
width: 20, height: 20, borderRadius: '50%',
background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
transform: gridSnap ? 'translateX(24px)' : 'translateX(0)',
transition: 'transform 0.2s',
}} />
</div>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onDragOver={onDragOver}
onDrop={onDrop}
onConnect={onConnect}
nodeTypes={{ rotatableNode: RotatableNode }}
fitView
snapToGrid={gridSnap}
snapGrid={[10, 10]}
nodesDraggable={true}
nodesConnectable={true}
elementsSelectable={true}
connectionRadius={50}
>
<Controls />
<Background />
</ReactFlow>
</div>
<ResizeHandle onMouseDown={handleResizeStart('right')} />
<RightPanel
selectedNode={selectedNode}
width={rightWidth}
onRenameComponent={renameComponent}
/>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<ReactFlowProvider>
<App />
</ReactFlowProvider>
);
</script>
</body>
</html>
{% endraw %}