diff --git a/README.md b/README.md index c0b15cc..dd27da4 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,4 @@ # mxpic_EDA The EDA coding for the layout for optihk # requirements -flask - -# Frontend - backend GDS bridge -Each canvas in the fronend should create a same name **.yaml** file. -This **.yaml** should contain the following parts. - -- basic description : name, type, version -- ports : the output connection ports for this canvas object -- instances : the instance objects from **PDK** or other **canvas**, together with their **x, y, a, mirror** informations. -- bundles : 1-1 or N-N conenctions that used for auto routing. "***This***" refers to the canvas ports. - -``` -[ Project Canvas ] - │ (Contains instances of Component A and Component B) - ▼ -[ Component A Canvas ] - │ (Contains instances of DC_2x2 and Ubend) - ▼ -[ PDK Primitives ] ──> (Loads raw .gds + physical port data .yml) -``` - -``` yaml -# ========================================== -# mxPIC Cell/Project Definition File -# ========================================== -name: MMI_Splitter_Module # Name of this cell/component/project -type: composite # "primitive" (PDK base) or "composite" (hierarchical) -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: - mmi_inst1: - component: MMI_2x2 # References another PDK cell or composite YAML - x: 20.0 # Placement origin X - y: 0.0 # Placement origin Y - rotation: 0.0 # Rotation angle in degrees - mirror: false # True/False for layout mirroring - settings: # Local parameter overrides if parametric - length: 25.5 - - ubend_top: - component: Ubend - x: 60.0 - y: 5.0 - rotation: 0.0 - mirror: false - - ubend_bottom: - component: Ubend - x: 60.0 - y: -5.0 - rotation: 0.0 - mirror: true # Flipped for symmetrical routing - -# 3. Bundles (Grouped links for multi-bus/parallel routing) -bundles: - output_bus: - routing_type: euler_bend # Metadata for the backend router - links: - - from: ubend_top:out0 - to: this:out0 - - from: ubend_bottom:out0 - to: this:out1 -``` +flask \ No newline at end of file diff --git a/backend/PDK_libs/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604/EC_SiN400_1310_1p0dB_L635_A0_QY_202604.png b/backend/PDK_libs/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604/EC_SiN400_1310_1p0dB_L635_A0_QY_202604.png new file mode 100644 index 0000000..5208d8c Binary files /dev/null and b/backend/PDK_libs/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604/EC_SiN400_1310_1p0dB_L635_A0_QY_202604.png differ diff --git a/backend/PDK_libs/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604/EC_SiN400_1310_1p0dB_L635_A0_QY_202604.yml b/backend/PDK_libs/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604/EC_SiN400_1310_1p0dB_L635_A0_QY_202604.yml new file mode 100644 index 0000000..d701aa5 --- /dev/null +++ b/backend/PDK_libs/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604/EC_SiN400_1310_1p0dB_L635_A0_QY_202604.yml @@ -0,0 +1,35 @@ +name: EC_SiN400_1310_1p0dB_L635_A0_QY_202604 +foundry: Silterra +process: EMO1_2ML_Cu_RDL +year: '2026' +type: primitive +dependency: None +maturity: development +tapeout_history: +- run: Silterra_EMO1_2ML_Cu_RDL_2026_Q2 + status: Pending testing +center_wavelength: 1310 +version: 1.0 +designer: Qin Yue +update_notes: New SiN edge couplers with high efficiency +ports: + a1: + x: -642.6 + y: 0.0 + a: 180.0 + width: 0.7 + b0: + x: 0.0 + y: 0.0 + a: 0.0 + width: None + a0: + x: 0.0 + y: 0.0 + a: 180.0 + width: 0.0 +time: 20260505-170136 +box_size: +- 646.0 +- 75.0 +file_size: 1.36 KB diff --git a/backend/directories.yaml b/backend/directories.yaml new file mode 100644 index 0000000..7311315 --- /dev/null +++ b/backend/directories.yaml @@ -0,0 +1,17 @@ +level : 4 + +root : + - PDK_libs + - primitives + - directional_couplers + - edge_couplers + - crossings + - multimode_interferometers + - photodectors + - compotites + - MZIs + - electronics + - resistors + - capacitors + - others + - logos diff --git a/backend/generated_layouts/comp_1.yaml b/backend/generated_layouts/comp_1.yaml new file mode 100644 index 0000000..a16bc9c --- /dev/null +++ b/backend/generated_layouts/comp_1.yaml @@ -0,0 +1,62 @@ +# ============================================= +# mxPIC Cell/Project Definition File +# ============================================= +name: comp_1 +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: + component_1: + component: EMO1_2ML_CU_Al_RDL/composite/Mach_Zender_modulators/MZM_800G_L3000_GSSG_TRAIL_TypeX5_QY_v1_20260303 + x: 100.0 + y: 100.0 + rotation: 0.0 + mirror: false + settings: + length: + + component_2: + component: EMO1_2ML_CU_Al_RDL/electronics/inductors/INDC_200pH_SiNPP_QY_202604 + x: 400.0 + y: 100.0 + rotation: 0.0 + mirror: false + settings: + length: + + component_3: + component: EMO1_2ML_CU_Al_RDL/electronics/pads/Spec_PADs_ABCD_292_P125_250_W80_80_QY_20260324 + x: 700.0 + y: 100.0 + rotation: 0.0 + mirror: false + settings: + length: + +# 3. Bundles (Grouped links for multi-bus/parallel routing) +bundles: + output_bus: + routing_type: euler_bend + links: diff --git a/backend/mxpic_data.db b/backend/mxpic_data.db new file mode 100644 index 0000000..c2a697c Binary files /dev/null and b/backend/mxpic_data.db differ diff --git a/backend/server_new.py b/backend/server_new.py index 4190432..bf34a56 100644 --- a/backend/server_new.py +++ b/backend/server_new.py @@ -1,103 +1,3 @@ -# import os -# import yaml -# from collections import OrderedDict -# from flask import Flask, jsonify, send_from_directory, request, redirect, url_for, session, render_template -# from werkzeug.security import check_password_hash -# import database # Imports the database.py you created earlier - -# # --- Path Configurations --- -# BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -# FRONTEND_DIR = os.path.join(BASE_DIR, '..', 'frontend') - -# # Use os.path.join exclusively for cross-platform safety -# YML_PATH = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra', 'directories.yaml') -# COMPS_ROOT = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra') - -# # Initialize Flask, pointing to the frontend folder for HTML/CSS/JS -# app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR) -# app.secret_key = 'super_secret_mxpic_key' # Required for session management -# app.json.sort_keys = False # Keep dictionary order - -# # Ensure database tables exist when the server boots -# database.init_db() - -# # --- YAML & PDK Parsing Helper Functions (Unchanged) --- -# def countSpaces(line): -# """Count leading spaces (tab=4).""" -# expanded = line.expandtabs(4) -# return len(expanded) - len(expanded.lstrip(' ')) - -# def buildTree(filepath): -# """Build nested tree from indented yaml.""" -# if not os.path.exists(filepath): -# return OrderedDict() - -# with open(filepath, 'r', encoding='utf-8') as f: -# lines = f.readlines() - -# rootIdx = None -# for i, line in enumerate(lines): -# if line.strip().startswith('root') and ':' in line.strip(): -# rootIdx = i -# break -# if rootIdx is None: -# return OrderedDict() - -# entries = [] -# for line in lines[rootIdx + 1:]: -# stripped = line.strip() -# if not stripped or stripped.startswith('#'): -# continue -# if stripped.startswith('- '): -# spaceNum = countSpaces(line) -# # FIX 1: Strip trailing colons off the string so 'composites:' becomes 'composites' -# name = stripped[2:].strip().rstrip(':') -# if name: -# entries.append((spaceNum, name)) - -# if not entries: -# return OrderedDict() - -# minIndent = min(indent for indent, _ in entries) -# nest = OrderedDict() -# levelStack = [(minIndent - 1, nest)] - -# for spaceNum, name in entries: -# while levelStack and levelStack[-1][0] >= spaceNum: -# levelStack.pop() -# parent = levelStack[-1][1] -# child = OrderedDict() -# parent[name] = child -# levelStack.append((spaceNum, child)) - -# return nest - -# def addCompsToTree(compMap): -# """ -# Build a completely fresh tree from scratch and insert component nodes. -# No previous tree object or inspection required. -# """ -# # Initialize a clean, empty root tree -# fresh_tree = OrderedDict() - -# for pathSeg, compItem in compMap.items(): -# compName = compItem['folder'] -# curNode = fresh_tree - -# # Sequentially build the nested path segments dynamically -# for seg in pathSeg: -# if seg not in curNode: -# curNode[seg] = OrderedDict() -# curNode = curNode[seg] - -# # Place the component metadata dictionary into its leaf node -# curNode[compName] = OrderedDict({ -# "__type__": "component", -# "__name__": compName, -# "__yml__": compItem['yml'] -# }) - -# return fresh_tree import os import yaml @@ -105,6 +5,7 @@ from collections import OrderedDict from flask import Flask, jsonify, send_from_directory, request, redirect, url_for, session, render_template from werkzeug.security import check_password_hash import database +from flask import Response # --- Path Configurations --- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -115,6 +16,10 @@ COMPS_ROOT = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra') # Define where your new icons folder is located (adjust if it's placed elsewhere) ICONS_DIR = os.path.join(BASE_DIR, 'icons') +#build layout save path +SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'generated_layouts') + + app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR) app.secret_key = 'super_secret_mxpic_key' app.json.sort_keys = False @@ -175,18 +80,22 @@ def addCompsToTree(compMap): @app.route('/api/icon/') def getIcon(category): """Serve the icon corresponding to the component category.""" - # Look for an image matching the category name (e.g., edge_coupler.png) for ext in ('.png', '.svg', '.jpg'): icon_path = os.path.join(ICONS_DIR, f"{category}{ext}") if os.path.exists(icon_path): return send_from_directory(ICONS_DIR, f"{category}{ext}") - # Optional: Return a default fallback icon if the specific one is missing fallback = os.path.join(ICONS_DIR, "default.png") if os.path.exists(fallback): return send_from_directory(ICONS_DIR, "default.png") - - return jsonify({"error": "Icon not found"}), 404 + + # return png if not found + transparent_png = ( + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01' + b'\x00\x00\x05\x00\x01\r\n\xf4\xc0\x00\x00\x00\x00IEND\xaeB`\x82' + ) + return Response(transparent_png, mimetype='image/png') # ... [Keep existing API routes below] ... @@ -249,6 +158,33 @@ def logout(): session.clear() return redirect(url_for('home')) + + + +@app.route('/api/save-layout', methods=['POST']) +def save_layout(): + try: + data = request.get_json() + filename = data.get('filename', 'layout.yaml') + content = data.get('content', '') + + os.makedirs(SAVE_DIR, exist_ok=True) + + save_path = os.path.join(SAVE_DIR, filename) + + with open(save_path, 'w', encoding='utf-8') as f: + f.write(content) + + return jsonify({ + "message": "successfully saved", + "path": save_path + }), 200 + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + + # --- API ROUTES (Library & Components) --- @app.route('/api/library') def getLib(): @@ -284,4 +220,8 @@ def getCompImg(component_name): if __name__ == '__main__': print("Starting mxpic EDA Server on http://127.0.0.1:3000") - app.run(host='127.0.0.1', port=3000, debug=True) \ No newline at end of file + app.run(host='127.0.0.1', port=3000, debug=True) + + + + \ No newline at end of file diff --git a/frontend/canvas.html b/frontend/canvas.html index c0bba2c..e8be100 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -6,14 +6,16 @@ mxPIC Core - Canvas + + - - + + + @@ -194,15 +361,119 @@ Handle, Position, useUpdateNodeInternals, + applyNodeChanges, + applyEdgeChanges, } = window.ReactFlow; - // --- NODE DESIGN (Dark CAD Style) --- - // --- NODE DESIGN (Dark CAD Style) --- - const RotatableNode = ({ id, data, selected }) => { - const updateNodeInternals = useUpdateNodeInternals(); + + 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(() => { - updateNodeInternals(id); - }, [data.rotation, updateNodeInternals, id]); + 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 ( +
+ {src !== undefined && src !== null && ( + {category} { + e.currentTarget.style.display = 'none'; + }} + /> + )} +
+ ); + }, (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, @@ -217,42 +488,29 @@ return (
- - {/* Updated Icon and Title Layout */}
- {data.category && ( - {data.category} { - e.currentTarget.style.display = 'none'; - }} - /> + {!data.hideIcon && data.category && ( +
+ +
)} - - {/* Shrunk the text and added ellipsis for long names */} +
); - }; + }, (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 ( +
+ C + +
+ ); + }; + + const TreeNode = ({ name, children }) => { if (children && children.__type__ === 'component') { const componentName = children.__name__; - // Fallback to 'default' if category is somehow missing - const componentCategory = children.__category__ || 'default'; - + 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); // <--- DEBUG LOG + 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 ( -
- {name} +
+
+ +
+
+ {name} +
); } - // ... keep the rest of TreeNode unchanged - // ... keep the rest of TreeNode unchanged const hasChildren = children && Object.keys(children).length > 0; return (
📂 {name} - + {hasChildren && Object.entries(children).map(([childName, childData]) => ( @@ -314,61 +638,281 @@ ); }; - const LeftPanel = ({ library, treeKey, expanded, onToggle, treeRef, width }) => ( -
+ ); + } -
-
Routing modes
-
-
    -
  • Single mode wires
  • -
  • Multi-mode wires
  • -
  • DC electrical wires
  • -
  • RF electrical wires
  • -
+ if (children && children.__type__ === 'technology') { + return ( +
+ {name}: {children.description || '(empty)'}
-
- -
-
Session
-
-
Name: XXXXXX
-
ID: 12345678
- + ); + } + if (children && children.__type__ === 'block') { + return ( +
+ {name}: {children.description || '(empty)'}
-
- - ); + ); + } - const RightPanel = memo(({ selectedNode, width, onRenameComponent }) => { + const hasChildren = children && typeof children === 'object' && Object.keys(children).length > 0 && !children.__type__; + if (!hasChildren) { + return ( +
+ {name} +
+ ); + } + return ( +
+ + 📁 {name} + + {Object.entries(children).map(([childName, childData]) => ( + + ))} +
+ ); + }; + + const CompositeComponentTree = ({ name, children }) => { + if (children && children.__type__ === 'component') { + const instances = children.instances || []; + const displayText = instances.length > 0 + ? instances.join(', ') + : (children.__name__ || name); + + return ( +
+ + {displayText} +
+ ); + } + if (children && typeof children === 'object' && !children.__type__) { + const hasChildren = Object.keys(children).length > 0; + return ( +
+ + 📂 {name} + + {hasChildren && + Object.entries(children).map(([childName, childData]) => ( + + )) + } +
+ ); + } + 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 ( + + ); + }; + + const RightPanel = ({ selectedNode, width, onRenameComponent, onUpdateNode }) => { 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(''); @@ -404,29 +948,32 @@ if (selectedNode) { setLocalX(selectedNode.position.x.toFixed(3)); setLocalY(selectedNode.position.y.toFixed(3)); - setLocalRotation(((selectedNode.data?.rotation || 0)).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?.id]); + }, [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; - setNodes(nds => nds.map(n => n.id === id ? { ...n, position: { ...n.position, [axis]: val } } : n)); - }, [setNodes]); + 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)); - setNodes(nds => nds.map(n => n.id === id ? { ...n, data: { ...n.data, rotation: clamped } } : n)); - }, [setNodes]); + 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 currentRotation = selectedNode?.data?.rotation ?? 0; const currentComponentDisplayName = selectedNode?.data?.componentDisplayName || ''; const handleStartEditName = () => { @@ -456,7 +1003,7 @@ padding: 12, display: 'flex', flexDirection: 'column', height: '100%', boxSizing: 'border-box', overflowY: 'auto' }}> -
+
Transforms
{selectedNode ? ( @@ -513,7 +1060,10 @@ updateRotation(selectedNode.id, val); setLocalRotation(val.toFixed(3)); } else if (selectedNode) { - setLocalRotation(((selectedNode.data?.rotation || 0)).toFixed(3)); + const rot = selectedNode.id === 'page-port' + ? (selectedNode.data?.angle ?? 0) + : (selectedNode.data?.rotation ?? 0); + setLocalRotation(rot.toFixed(3)); } }} onKeyDown={(e) => { @@ -528,11 +1078,11 @@
{selectedNode?.data?.componentName && ( -
+
Parameters
-
+
{loading ? ( -

Loading data...

+

Loading data...

) : componentData ? ( <>
@@ -540,7 +1090,7 @@ {editingComponentName ? ( setTempComponentName(e.target.value)} onBlur={handleSaveName} onKeyDown={handleKeyDown} @@ -564,31 +1114,31 @@ title="Click to edit" > {currentComponentDisplayName || componentData.name} - +
)}
-
-

- Cell: {componentData.name} -

-

- Foundry: {componentData.foundry}
- Process: {componentData.process} -

+
+

+ Cell: {componentData.name} +

+

+ Foundry: {componentData.foundry}
+ Process: {componentData.process} +

-

Ports:

+

Ports:

    {componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => (
  • - {portName}: {formatPort(portInfo)} + {portName}: {formatPort(portInfo)}
  • ))}
-

Preview:

+

Preview:

setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image`)} onError={(e) => { @@ -614,7 +1165,7 @@
)} -
+
Inverse Design
Requires AI Upgrade
@@ -639,18 +1190,7 @@ )} ); - }, (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 }) => (
); + 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 [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [pages, setPages] = useState([]); + const [activePageId, setActivePageId] = useState(null); const reactFlowInstance = useReactFlow(); const [library, setLibrary] = useState(null); @@ -674,89 +1285,174 @@ const [expanded, setExpanded] = useState(false); const treeContainerRef = useRef(null); - const [leftWidth, setLeftWidth] = useState(260); + 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); - // --- CLIPBOARD & ACTIONS --- 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(() => { - const currentNodes = reactFlowInstance.getNodes(); - const selectedNodes = currentNodes.filter(n => n.selected); + if (!activePage) return; + const selectedNodes = activePage.nodes.filter(n => n.selected && n.id !== 'page-port'); if (selectedNodes.length > 0) { - // Deep clone the selected nodes to prevent reference issues setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) }); } - }, [reactFlowInstance]); + }, [activePage]); const handleCut = useCallback(() => { - const currentNodes = reactFlowInstance.getNodes(); - const selectedNodes = currentNodes.filter(n => n.selected); - + 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)); - - // Remove nodes and any edges connected to them - setNodes(nds => nds.filter(n => !selectedNodeIds.has(n.id))); - setEdges(eds => eds.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))); + 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)); } - }, [reactFlowInstance, setNodes, setEdges]); + }, [activePage, setPages]); const handlePaste = useCallback(() => { - if (clipboard.nodes.length > 0) { - const newNodes = clipboard.nodes.map(node => { - // Give the new node a unique ID and a slight positional offset - // so it doesn't overlap perfectly with the original - return { - ...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, // Automatically select the newly pasted node - data: { - ...node.data, - // Ensure the pasted node gets a fresh display name (e.g., component_5) - componentDisplayName: generateComponentDisplayName() - } - }; - }); - - // Deselect existing nodes and add the new ones - setNodes(nds => nds.map(n => ({...n, selected: false})).concat(newNodes)); - - // Update the clipboard with the new offset nodes so - // rapid pasting cascades them nicely - setClipboard({ nodes: newNodes }); - } - }, [clipboard, setNodes, generateComponentDisplayName]); + 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(() => { - const currentNodes = reactFlowInstance.getNodes(); - const selectedNodes = currentNodes.filter(n => n.selected); - const selectedNodeIds = new Set(selectedNodes.map(n => n.id)); - + 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) { - setNodes(nds => nds.filter(n => !selectedNodeIds.has(n.id))); - setEdges(eds => eds.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))); + 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)); } - }, [reactFlowInstance, setNodes, setEdges]); + }, [activePage]); - // --- KEYBOARD SHORTCUTS --- useEffect(() => { const handleKeyDown = (e) => { - // Prevent actions if the user is typing in an input or textarea if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') { return; } - const cmdOrCtrl = e.ctrlKey || e.metaKey; // Supports Windows/Linux (Ctrl) and Mac (Cmd) + const cmdOrCtrl = e.ctrlKey || e.metaKey; if (cmdOrCtrl && e.key.toLowerCase() === 'c') { e.preventDefault(); @@ -768,8 +1464,6 @@ e.preventDefault(); handlePaste(); } else if (e.key === 'Delete' || e.key === 'Backspace') { - // ReactFlow handles this natively if the canvas is focused, - // but this ensures it fires even if focus is outside the canvas wrapper. handleDelete(); } }; @@ -787,19 +1481,15 @@ }, []); const renameComponent = useCallback((nodeId, newComponentDisplayName) => { - setNodes(nds => nds.map(n => { - if (n.id === nodeId) { - return { - ...n, - data: { - ...n.data, - componentDisplayName: newComponentDisplayName - } - }; - } - return n; + 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) + }; })); - }, [setNodes]); + }, [activePageId]); const fetchLibrary = useCallback(async () => { try { @@ -812,7 +1502,405 @@ }, []); useEffect(() => { fetchLibrary(); }, [fetchLibrary]); - const selectedNode = useMemo(() => nodes.find(n => n.selected), [nodes]); + 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(); @@ -822,29 +1910,74 @@ const onDrop = useCallback((event) => { event.preventDefault(); const rawData = event.dataTransfer.getData('application/reactflow'); - console.log("📥 DROP EVENT: Received raw data ->", rawData); // <--- DEBUG LOG - - if (!rawData) { - console.error("Drop failed: No data received!"); - return; - } - + console.log("📥 DROP EVENT: Received raw data ->", rawData); + if (!rawData) return; let parsedData; try { parsedData = JSON.parse(rawData); - console.log("✅ PARSED JSON SUCCESS:", parsedData); // <--- DEBUG LOG } catch (error) { - console.warn("⚠️ JSON Parse Failed. Falling back to string format.", rawData); parsedData = { name: rawData, category: 'default' }; } - - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - + 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', @@ -852,19 +1985,24 @@ data: { label: parsedData.name, componentName: parsedData.name, - category: parsedData.category, + category: parsedData.category, rotation: 0, componentDisplayName: componentDisplayName }, }; - - console.log("✨ ADDING NEW NODE TO CANVAS:", newNode); // <--- DEBUG LOG - setNodes((nds) => nds.concat(newNode)); - }, [setNodes, reactFlowInstance, generateComponentDisplayName]); + 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) => { - setEdges((eds) => addEdge({ ...connection, type: 'smoothstep', style: { stroke: 'var(--accent)', strokeWidth: 2 } }, eds)); - }, [setEdges]); + 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) { @@ -877,6 +2015,17 @@ 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); @@ -904,66 +2053,270 @@ 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 (
-
- - {/* Grid Snap Toggle Switch */} -
- Snap to Grid -
+
+
document.getElementById('open-yaml-input').click()}> + 📂 Open Project +
+ {pages.map(page => ( +
switchPage(page.id)}> + {page.name} + +
+ ))} +
+
+
+ Snap to Grid +
-
+ }}> +
+
-
- - - {/* Dark mode background for the canvas */} - - + {activePage && ( + + )} + + + + + +
@@ -971,6 +2324,7 @@ selectedNode={selectedNode} width={rightWidth} onRenameComponent={renameComponent} + onUpdateNode={handleUpdateNode} />
); @@ -978,8 +2332,10 @@ const root = ReactDOM.createRoot(document.getElementById('root')); root.render( + + ); diff --git a/frontend/canvas_edit.html b/frontend/canvas_edit.html index 7431059..039070d 100644 --- a/frontend/canvas_edit.html +++ b/frontend/canvas_edit.html @@ -126,10 +126,10 @@ .toggle-btn { background: none; border: none; - font-size: 1.2em; + font-size: 1.5em; color: var(--text-muted); cursor: pointer; - padding: 2px 6px; + padding: 4px 10px; border-radius: 4px; transition: background 0.2s ease, color 0.2s ease; } diff --git a/frontend/canvas_legacy.html b/frontend/canvas_legacy.html index 7fcaebd..2d33d63 100644 --- a/frontend/canvas_legacy.html +++ b/frontend/canvas_legacy.html @@ -5,7 +5,7 @@ - Canvas with PDK Library – Component Name & Rotation + Canvas diff --git a/frontend/dashboard.html b/frontend/dashboard.html index eb30616..37ad02d 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -1,12 +1,13 @@ + Dashboard - + + @@ -174,7 +177,7 @@

Welcome back, {{ username }}!

- +
Your Recent Layouts
  • @@ -186,19 +189,26 @@ Ring_Modulator_Test.gds
- +
-
- -
- Powered by mxpic core -
+ +
+ +
+ + + + \ No newline at end of file