Files
mxpic_EDA/frontend/canvas.html
T
xsxx03-art 960066735c update
2026-06-04 15:17:02 +08:00

6603 lines
263 KiB
HTML

<!DOCTYPE html>
<!--
Description: Main MXPIC EDA canvas UI with React Flow editing, project pages, routing, layout build controls, and inspector panels.
Inside functions: fetchIcon, RotatableNode, PortNode, AnchorNode, RightPanel, findComponentPath, loadProject, handleBasicConnection, buildElementNodesFromYaml.
Developer : Qin Yue @ 2026
Organization : OptiHK Limited
-->
<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=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@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>
<script src="/canvas-helpers.js"></script>
<style>
:root {
--bg-main: #05080f;
--bg-card: #0b1320;
--text-main: #f5f7fa;
--text-muted: #9aa8ba;
--accent: #45d6c8;
--accent-hover: #7fded5;
--accent-green: #39d98a;
--accent-warm: #f59e0b;
--border: rgba(163, 186, 212, 0.16);
--border-strong: rgba(177, 207, 232, 0.32);
--input-bg: #070e19;
--panel-rail: #070d16;
--panel-header: #0f1724;
--panel-body: #0a121d;
--canvas-bg: #060b13;
--danger: #ef4444;
--folder-icon: #f2c14e;
--shadow: rgba(0, 0, 0, 0.36);
--surface-highlight: rgba(255, 255, 255, 0.045);
--focus-ring: rgba(69, 214, 200, 0.22);
--floating-label-bg: rgba(15, 23, 36, 0.96);
--floating-label-border: rgba(142, 169, 198, 0.34);
--floating-label-shadow: 0 10px 24px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(69, 214, 200, 0.08);
--port-label-bg: rgba(13, 21, 33, 0.96);
--port-label-text: #cbd6e3;
--mini-button-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)), #101a29;
--mini-button-text: #d5dfec;
}
body.light-mode {
--bg-main: #eef3f6;
--bg-card: #ffffff;
--text-main: #122033;
--text-muted: #637184;
--accent: #087f73;
--accent-hover: #0f9d8f;
--accent-green: #14834f;
--accent-warm: #c57a00;
--border: rgba(30, 48, 69, 0.14);
--border-strong: rgba(30, 48, 69, 0.28);
--input-bg: #f6f9fb;
--panel-rail: #dfe8ee;
--panel-header: #f8fafc;
--panel-body: #ffffff;
--canvas-bg: #eef4f7;
--folder-icon: #0f9d8f;
--shadow: rgba(18, 32, 51, 0.12);
--surface-highlight: rgba(255, 255, 255, 0.72);
--focus-ring: rgba(8, 127, 115, 0.18);
--floating-label-bg: rgba(255, 255, 255, 0.94);
--floating-label-border: rgba(30, 48, 69, 0.18);
--floating-label-shadow: 0 10px 22px rgba(18, 32, 51, 0.14), 0 0 0 1px rgba(8, 127, 115, 0.08);
--port-label-bg: rgba(255, 255, 255, 0.96);
--port-label-text: #334155;
--mini-button-bg: linear-gradient(180deg, #ffffff, #eef5f8);
--mini-button-text: #17263a;
}
.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: 'IBM Plex Sans', "Segoe UI", sans-serif;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.026) 1px, transparent 1px),
linear-gradient(0deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px),
linear-gradient(135deg, rgba(69, 214, 200, 0.08), transparent 38%),
var(--canvas-bg);
background-size: 40px 40px, 40px 40px, auto, auto;
color: var(--text-main);
overflow: hidden;
}
body.light-mode,
body.light-mode #root {
background:
linear-gradient(90deg, rgba(8, 127, 115, 0.045) 1px, transparent 1px),
linear-gradient(0deg, rgba(8, 127, 115, 0.035) 1px, transparent 1px),
linear-gradient(135deg, rgba(8, 127, 115, 0.08), transparent 42%),
var(--canvas-bg);
background-size: 40px 40px, 40px 40px, auto, auto;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-main);
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
details {
margin-left: 8px;
}
summary {
cursor: pointer;
padding: 4px 0;
color: var(--text-main);
}
summary.tree-folder {
font-weight: 500;
color: var(--accent);
line-height: 18px;
}
summary.tree-folder::marker {
color: var(--text-muted);
font-size: 0.74rem;
line-height: 1;
}
.component-leaf {
cursor: grab;
padding: 4px 6px;
margin-left: 15px;
margin-top: 2px;
word-break: break-all;
white-space: normal;
border-radius: 8px;
color: var(--text-muted);
transition: background 0.2s ease, color 0.2s ease;
}
.component-leaf:hover {
background: rgba(110, 231, 255, 0.08);
color: var(--text-main);
}
.component-card,
.category-card {
display: flex;
flex-direction: column;
align-items: center;
cursor: grab;
margin-left: 0;
margin-top: 4px;
margin-bottom: 4px;
padding: 10px;
border-radius: 8px;
background:
linear-gradient(180deg, var(--surface-highlight), transparent 74%),
var(--input-bg);
border: 1px solid var(--border);
transition: transform 0.16s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.component-grid,
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 8px;
padding: 8px 0 4px 18px;
align-items: stretch;
}
.component-card:hover,
.category-card:hover {
background: var(--bg-card);
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
}
.component-card-icon,
.category-card-icon {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
pointer-events: none;
}
.component-card-icon img,
.category-card-icon img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.component-card-name,
.category-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:
linear-gradient(180deg, var(--surface-highlight), transparent 78%),
var(--panel-body);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 18px 38px var(--shadow);
}
.left-block-header,
.right-block-header {
background: var(--panel-header);
padding: 8px 10px;
font-weight: 600;
font-family: 'IBM Plex Mono', Consolas, monospace;
font-size: 0.72em;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.left-block-body,
.right-block-body {
padding: 12px;
font-size: 0.85em;
min-height: 0;
background: var(--panel-body);
}
.placeholder-block {
border: 1px dashed var(--border-strong);
padding: 12px;
color: var(--text-muted);
text-align: center;
background: var(--input-bg);
border-radius: 8px;
}
.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: rgba(110, 231, 255, 0.08);
color: var(--text-main);
}
.build-gds-btn {
min-width: 106px;
height: 32px;
border: 1px solid rgba(69, 214, 200, 0.46);
border-radius: 8px;
padding: 0 11px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #03231f;
background: linear-gradient(180deg, #7ee7dc, #35c4b8);
box-shadow: 0 10px 24px rgba(24, 166, 153, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.55);
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
white-space: nowrap;
}
.build-gds-btn::before {
content: "";
width: 8px;
height: 8px;
border: 1.5px solid currentColor;
border-radius: 2px;
box-shadow: 5px 0 0 -2px currentColor, 0 5px 0 -2px currentColor;
opacity: 0.86;
}
.build-gds-btn:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.04);
box-shadow: 0 14px 30px rgba(24, 166, 153, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.68);
}
.build-gds-btn:focus-visible,
.mini-btn:focus-visible,
.toggle-btn:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.build-gds-btn:disabled {
cursor: wait;
opacity: 0.68;
filter: saturate(0.6);
transform: none;
}
body.light-mode .build-gds-btn {
color: #f3fffd;
background: linear-gradient(180deg, #0f9d8f, #087f73);
box-shadow: 0 10px 22px rgba(8, 127, 115, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
input[type="number"],
input[type="text"],
select {
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,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(110, 231, 255, 0.13);
}
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(--input-bg) !important;
}
.canvas-tabs {
display: flex;
align-items: center;
background: rgba(9, 17, 31, 0.96);
border-bottom: 1px solid var(--border);
padding: 6px 8px;
height: 42px;
gap: 6px;
overflow-x: auto;
white-space: nowrap;
box-sizing: border-box;
backdrop-filter: blur(12px);
}
.canvas-tab {
display: flex;
align-items: center;
min-height: 28px;
padding: 4px 12px;
border-radius: 8px;
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, border-color 0.2s;
}
.canvas-tab:hover {
color: var(--text-main);
border-color: var(--border-strong);
}
.canvas-tab.active {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: #04101f;
border-color: transparent;
font-weight: 700;
}
body.light-mode .canvas-tabs {
background: rgba(255, 255, 255, 0.9);
}
body.light-mode .component-card,
body.light-mode .placeholder-block {
background: #f4f8fb;
}
body.light-mode .component-card:hover,
body.light-mode .component-leaf:hover {
background: #eaf4ff;
}
body.light-mode .react-flow__controls button {
background-color: #ffffff !important;
fill: #0f172a !important;
}
.layout-preview {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #08111f;
}
.layout-preview-toolbar {
position: absolute;
top: 14px;
left: 14px;
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(13, 22, 38, 0.92);
box-shadow: 0 16px 34px var(--shadow);
backdrop-filter: blur(14px);
}
.layout-preview-toolbar label {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 0;
color: var(--text-main);
font-size: 0.78rem;
white-space: nowrap;
}
.layout-preview-toolbar input[type="range"] {
width: 160px;
accent-color: var(--accent);
}
.layout-preview-toolbar input[type="number"] {
width: 74px;
padding: 5px 7px;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.78rem;
}
.layout-preview-canvas {
width: 100%;
height: 100%;
overflow: auto;
padding: 58px 18px 18px;
box-sizing: border-box;
overscroll-behavior: contain;
}
.layout-preview-scroll-area {
min-width: 100%;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.layout-preview-stage {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.02);
}
.layout-preview-image {
width: 100%;
height: 100%;
display: block;
}
body.light-mode .layout-preview {
background: #eef4f8;
}
body.light-mode .layout-preview-toolbar {
background: rgba(255, 255, 255, 0.92);
}
.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;
}
.vertical-resize-handle {
height: 8px;
cursor: row-resize;
border-radius: 6px;
background: transparent;
flex: 0 0 auto;
position: relative;
}
.vertical-resize-handle::after {
content: "";
position: absolute;
left: 36%;
right: 36%;
top: 3px;
height: 2px;
background: rgba(110, 231, 255, 0.08);
border-radius: 2px;
}
.vertical-resize-handle:hover::after {
background: var(--accent);
}
.mini-btn {
background: var(--mini-button-bg);
border: 1px solid var(--border);
color: var(--mini-button-text);
border-radius: 8px;
cursor: pointer;
height: 32px;
padding: 0 12px;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: color 0.16s ease, border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.mini-btn:hover {
color: var(--text-main);
border-color: var(--accent);
transform: translateY(-1px);
}
.origin-select-btn {
border-color: rgba(45, 212, 191, 0.55);
color: var(--accent-green);
}
.origin-select-btn.active {
background: rgba(45, 212, 191, 0.16);
border-color: var(--accent-green);
color: var(--text-main);
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.2), 0 10px 20px rgba(45, 212, 191, 0.12);
}
body.light-mode .mini-btn {
background: var(--mini-button-bg);
border-color: rgba(30, 48, 69, 0.18);
color: var(--mini-button-text);
box-shadow: 0 6px 14px rgba(18, 32, 51, 0.08);
}
.site-nav-actions {
position: fixed;
top: 14px;
right: 16px;
z-index: 80;
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(13, 22, 38, 0.92);
box-shadow: 0 16px 34px var(--shadow);
backdrop-filter: blur(14px);
}
body.light-mode .site-nav-actions {
background: rgba(255, 255, 255, 0.94);
border-color: rgba(30, 48, 69, 0.16);
}
.canvas-toolbar {
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
max-width: calc(100% - 30px);
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(13, 22, 38, 0.9);
box-shadow: 0 16px 34px var(--shadow);
backdrop-filter: blur(14px);
}
body.light-mode .canvas-toolbar {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(30, 48, 69, 0.16);
}
.grid-snap-label {
font-size: 0.85em;
font-weight: 600;
color: var(--text-main);
user-select: none;
white-space: nowrap;
}
body.light-mode .grid-snap-label {
color: #102033;
}
.coordinate-readout {
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 12;
display: flex;
align-items: center;
gap: 10px;
min-width: 270px;
justify-content: center;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: rgba(13, 22, 38, 0.9);
color: var(--text-main);
box-shadow: 0 14px 28px var(--shadow);
backdrop-filter: blur(14px);
font: 600 0.62rem/1 'IBM Plex Mono', Consolas, Monaco, monospace;
pointer-events: none;
white-space: nowrap;
}
.coordinate-readout span {
color: var(--text-muted);
font-weight: 500;
}
body.light-mode .coordinate-readout {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(30, 48, 69, 0.16);
box-shadow: 0 14px 28px rgba(18, 32, 51, 0.12);
}
.origin-crosshair {
position: absolute;
z-index: 18;
width: 22px;
height: 22px;
transform: translate(-50%, -50%);
pointer-events: none;
}
.origin-crosshair::before,
.origin-crosshair::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.35);
}
.origin-crosshair::before {
width: 22px;
height: 1px;
transform: translate(-50%, -50%);
}
.origin-crosshair::after {
width: 1px;
height: 22px;
transform: translate(-50%, -50%);
}
.build-layout-btn {
position: absolute;
bottom: 20px;
right: 20px;
z-index: 10;
height: 44px;
border: 1px solid rgba(69, 214, 200, 0.46);
border-radius: 8px;
padding: 0 18px;
color: #05231f;
background: linear-gradient(180deg, #7ee7dc, #34c5b8);
box-shadow: 0 16px 34px rgba(24, 166, 153, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.58);
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
}
.build-layout-btn:hover {
transform: translateY(-1px);
filter: brightness(1.04);
box-shadow: 0 18px 38px rgba(24, 166, 153, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.68);
}
.build-layout-btn:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
body.light-mode .build-layout-btn {
color: #f3fffd;
background: linear-gradient(180deg, #0f9d8f, #087f73);
}
.tree-summary-row {
display: inline-flex;
align-items: center;
gap: 8px;
width: calc(100% - 18px);
vertical-align: middle;
}
.folder-icon {
font-size: 0;
width: 18px;
height: 14px;
flex: 0 0 auto;
position: relative;
border-radius: 3px;
background: linear-gradient(135deg, var(--folder-icon), var(--accent-warm));
box-shadow: inset 0 -5px 0 rgba(255, 255, 255, 0.16);
}
.folder-icon::before {
content: "";
position: absolute;
left: 2px;
top: -4px;
width: 9px;
height: 5px;
border-radius: 3px 3px 0 0;
background: var(--folder-icon);
}
.folder-icon.project-folder {
background: linear-gradient(135deg, #60a5fa, #2563eb);
}
.folder-icon.project-folder::before {
background: #93c5fd;
}
.folder-icon.canvas-folder {
background: linear-gradient(135deg, var(--accent-green), #0f766e);
}
.folder-icon.canvas-folder::before {
background: #6ee7b7;
}
.folder-icon.library-folder {
background: linear-gradient(135deg, var(--folder-icon), var(--accent-warm));
}
.tree-summary-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.component-card.compact-tree-card {
min-height: 18px;
padding: 2px 6px;
margin: 1px 0 1px 18px;
border-radius: 4px;
align-items: center;
background: transparent;
border-color: transparent;
box-shadow: none;
gap: 6px;
}
.component-card.compact-tree-card:hover {
background: rgba(110, 231, 255, 0.08);
border-color: var(--border);
box-shadow: none;
}
.component-card.compact-tree-card .component-card-name {
text-align: left;
line-height: 18px;
color: var(--text-muted);
}
.element-card-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
position: relative;
border: 1px solid var(--border);
background: rgba(6, 14, 24, 0.86);
}
.element-card-icon.port-icon {
border-radius: 50%;
}
.element-card-icon.port-icon::before {
content: '';
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-green);
top: 4px;
left: 4px;
box-shadow: 0 0 0 2px rgba(110, 231, 255, 0.18);
}
.element-card-icon.port-icon::after {
content: '';
position: absolute;
width: 5px;
height: 1px;
background: var(--accent-green);
top: 7px;
right: 1px;
}
.element-card-icon.anchor-icon {
border-radius: 4px;
}
.element-card-icon.anchor-icon::before,
.element-card-icon.anchor-icon::after {
content: '';
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-warm);
top: 5px;
}
.element-card-icon.anchor-icon::before {
left: 1px;
}
.element-card-icon.anchor-icon::after {
right: 1px;
}
.element-card-icon.basic-icon {
border-radius: 4px;
overflow: hidden;
background: rgba(15, 23, 42, 0.92);
}
.element-card-icon.basic-icon::before {
content: '';
position: absolute;
left: 2px;
right: 2px;
top: 7px;
height: 2px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.14);
}
.coordinate-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: end;
}
.coordinate-grid label {
display: grid;
gap: 4px;
min-width: 0;
}
.port-info-list {
display: grid;
gap: 6px;
margin: 0 0 15px 0;
padding: 0;
list-style: none;
color: var(--text-muted);
}
.port-info-list li {
line-height: 1.45;
letter-spacing: 0.2px;
}
.save-project-btn {
border: 1px solid rgba(45, 212, 191, 0.45);
background: linear-gradient(180deg, rgba(45, 212, 191, 0.16), rgba(13, 148, 136, 0.12));
color: var(--text-main);
border-radius: 5px;
padding: 5px 8px;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
}
.save-project-btn:disabled {
cursor: wait;
opacity: 0.66;
}
.category-card {
min-height: 94px;
}
.category-card-name {
color: var(--text-main);
font-weight: 600;
}
.category-card-count {
margin-top: 3px;
font-size: 0.62rem;
color: var(--text-muted);
pointer-events: none;
}
.tree-expander {
margin-left: auto;
color: var(--text-muted);
font-size: 0.72rem;
transition: transform 0.15s ease;
}
.tree-delete-btn {
width: 20px;
height: 20px;
border: 1px solid rgba(239, 68, 68, 0.45);
border-radius: 6px;
background: rgba(239, 68, 68, 0.08);
color: #fca5a5;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.78rem;
line-height: 1;
padding: 0;
flex: 0 0 auto;
}
.tree-delete-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: var(--danger);
color: #fecaca;
}
body.light-mode .tree-delete-btn {
color: #b91c1c;
}
details[open] > summary .tree-expander {
transform: rotate(90deg);
}
.workspace-name-input {
min-width: 90px;
max-width: 180px;
height: 24px;
padding: 2px 6px;
font-size: 0.85em;
background: transparent;
border: 1px solid transparent;
color: inherit;
}
.tree-name-input {
width: 100%;
min-width: 0;
height: 24px;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--text-main);
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
}
.tree-name-input:focus {
background: var(--input-bg);
border-color: var(--accent);
outline: none;
}
.workspace-name-input:focus {
background: var(--bg-card);
border-color: var(--accent);
color: var(--text-main);
}
.app-log-terminal {
height: 112px;
flex: 0 0 auto;
overflow: auto;
background: linear-gradient(180deg, #040812, #02050b);
color: #b7efe9;
border-top: 1px solid var(--border);
padding: 10px 14px;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.76rem;
line-height: 1.45;
box-sizing: border-box;
}
body.light-mode .app-log-terminal {
background: #f8fafc;
color: #155e58;
}
.route-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 9px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text-muted);
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.72rem;
margin-bottom: 12px;
}
.route-chip::before {
content: "";
width: 20px;
height: 3px;
border-radius: 999px;
background: currentColor;
}
.link-mode-tabs {
position: relative;
display: inline-block;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--input-bg);
}
.link-mode-summary {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 118px;
height: 30px;
padding: 0 9px;
list-style: none;
cursor: pointer;
user-select: none;
}
.link-mode-summary::-webkit-details-marker {
display: none;
}
.link-mode-label {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 600;
}
.link-mode-current {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 0.72rem;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
}
.link-mode-current::before,
.link-mode-btn::before {
content: "";
width: 16px;
height: 3px;
border-radius: 999px;
background: currentColor;
}
.link-mode-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 40;
min-width: 154px;
display: grid;
gap: 4px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-card);
box-shadow: 0 18px 40px var(--shadow);
}
.link-mode-btn {
display: flex;
align-items: center;
gap: 7px;
height: 28px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-main);
font-size: 0.72rem;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
cursor: pointer;
text-align: left;
}
.link-mode-btn.active {
background: rgba(14, 165, 233, 0.18);
border-color: currentColor;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
.port-name-label {
position: absolute;
z-index: 4;
max-width: 58px;
padding: 1px 4px;
border-radius: 4px;
background: var(--port-label-bg);
border: 1px solid var(--floating-label-border);
color: var(--port-label-text);
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.18);
font-size: 0.3rem;
line-height: 1.2;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.port-pin-label {
position: absolute;
z-index: 12;
pointer-events: none;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.34rem;
font-weight: 700;
line-height: 1;
color: var(--port-label-text);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.port-pin-label span {
display: block;
}
.anchor-node-shell {
position: relative;
font-family: 'IBM Plex Sans', sans-serif;
}
.anchor-visual-body {
position: relative;
box-sizing: border-box;
transform-origin: center center;
}
.build-progress {
position: absolute;
left: 50%;
top: 14px;
transform: translateX(-50%);
z-index: 20;
width: min(360px, calc(100% - 180px));
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: rgba(11, 19, 32, 0.94);
box-shadow: 0 18px 38px var(--shadow);
backdrop-filter: blur(14px);
}
.build-progress-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
font-size: 0.72rem;
color: var(--text-main);
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
}
.build-progress-track {
height: 7px;
border-radius: 999px;
overflow: hidden;
background: rgba(148, 163, 184, 0.18);
}
.build-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--accent-green));
transition: width 0.28s ease;
}
.component-node-shell {
position: relative;
min-width: 0;
max-width: none;
width: 132px;
min-height: 82px;
text-align: center;
font-family: 'IBM Plex Sans', sans-serif;
}
.component-visual-body {
min-height: 74px;
padding: 10px 15px;
border-radius: 6px;
background: var(--bg-card);
color: var(--text-main);
box-sizing: border-box;
transition: none;
transform-origin: center center;
}
.component-floating-label {
position: absolute;
left: 50%;
bottom: calc(100% + 7px);
transform: translateX(-50%);
z-index: 8;
min-width: 84px;
max-width: 160px;
padding: 3px 6px;
border-radius: 5px;
border: 1px solid var(--floating-label-border);
background: var(--floating-label-bg);
color: var(--text-main);
box-shadow: var(--floating-label-shadow);
pointer-events: none;
}
body.light-mode .component-floating-label {
background: var(--floating-label-bg);
border-color: var(--floating-label-border);
box-shadow: var(--floating-label-shadow);
}
.canvas-text-hidden .component-floating-label {
display: none;
}
body.light-mode .port-name-label {
background: var(--port-label-bg);
border-color: var(--floating-label-border);
color: var(--port-label-text);
box-shadow: 0 5px 12px rgba(18, 32, 51, 0.1);
}
.box-size-readout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin: 10px 0 14px;
padding: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text-main);
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
font-size: 0.68rem;
}
.box-size-readout span {
color: var(--text-muted);
}
.component-floating-label strong,
.component-floating-label span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.component-floating-label strong {
font-size: 0.4rem;
font-weight: 650;
}
.component-floating-label span {
margin-top: 1px;
color: var(--text-muted);
font-size: 0.32rem;
}
.canvas-size-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background:
linear-gradient(180deg, var(--surface-highlight), transparent 74%),
var(--input-bg);
}
.canvas-size-panel label {
display: block;
margin-bottom: 4px;
color: var(--text-muted);
font-size: 0.68rem;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
}
.canvas-size-title {
grid-column: 1 / -1;
color: var(--text-main);
font-size: 0.74rem;
font-weight: 700;
font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
}
.canvas-boundary-node {
width: 100%;
height: 100%;
box-sizing: border-box;
border: 4px solid rgba(69, 214, 200, 0.92);
background: rgba(69, 214, 200, 0.018);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14), 0 0 22px rgba(69, 214, 200, 0.18);
pointer-events: none;
}
.ruler-point-node {
width: 12px;
height: 12px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid #f8fafc;
background: var(--accent-green);
box-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18), 0 8px 18px rgba(0, 0, 0, 0.28);
pointer-events: none;
}
.ruler-measurement-node {
min-width: 148px;
padding: 5px 8px;
border-radius: 6px;
border: 1px solid rgba(45, 212, 191, 0.5);
background: rgba(9, 18, 28, 0.94);
color: #e2f7f3;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
font: 600 0.5rem/1.35 'IBM Plex Mono', Consolas, Monaco, monospace;
text-align: center;
transform: translate(-50%, -50%);
pointer-events: none;
white-space: nowrap;
}
body.light-mode .ruler-measurement-node {
background: rgba(255, 255, 255, 0.96);
color: #12323a;
box-shadow: 0 10px 24px rgba(18, 32, 51, 0.14);
}
.ruler-status {
position: absolute;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
z-index: 18;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(45, 212, 191, 0.38);
background: rgba(9, 18, 28, 0.9);
color: #c8f7f0;
font: 600 0.68rem/1 'IBM Plex Mono', Consolas, Monaco, monospace;
pointer-events: none;
}
body.light-mode .ruler-status {
background: rgba(255, 255, 255, 0.94);
color: #0f4c54;
}
</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,
SelectionMode,
useUpdateNodeInternals,
applyNodeChanges,
applyEdgeChanges,
} = window.ReactFlow;
const {
FORGE_COMPONENT_LABEL,
FORGE_COMPONENT_TYPE,
DEFAULT_COMPONENT_BOX_SIZE,
DEFAULT_CANVAS_SIZE,
PORT_NODE_SIZE,
DEFAULT_ELEMENT_PITCH,
ELEMENT_COMPONENTS,
BASIC_COMPONENTS,
createForgeArguments,
isForgeComponent,
isBasicComponent,
createBasicSettings,
normalizeBoxSize,
chooseCategoryComponent,
normalizeCanvasSize,
clampPositionToCanvas,
calculateLayoutBounds,
calculateCompositeBoxSize,
buildPortHandles,
buildElementPorts,
getElementPinName,
buildElementBoxSize,
getBasicComponentMetadata,
buildInstancesYaml,
buildPageComponentPorts,
buildCanvasPinsYaml,
buildElementsYaml,
buildBundlesYaml: buildRouteBundlesYaml,
normalizeAngle,
createRouteSettings,
updateRouteField,
updateRouteXsection,
routeStyleForSettings,
findSameTypeRouteCrossing,
createRulerMeasurement,
createComponentSymbolMetrics,
FALLBACK_TECHNOLOGY_MANIFEST,
layoutToCanvasY
} = window.MxpicCanvasHelpers;
const FULL_SELECTION_MODE = SelectionMode && SelectionMode.Full ? SelectionMode.Full : 'full';
const iconPromiseCache = {};
// Loads and caches category icons so repeated library renders do not refetch the same image.
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];
}
// Displays a category icon with cached loading and graceful failure behavior.
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={{
width: '100%',
height: '100%',
objectFit: 'fill',
pointerEvents: 'none',
}}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
</div>
);
}, (prevProps, nextProps) => prevProps.category === nextProps.category);
// Renders PDK and primitive component instances with transformed ports and selection styling.
const RotatableNode = memo(({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
const prevTransformRef = useRef(`${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`);
const updateNodeInternalsRef = useRef(updateNodeInternals);
useEffect(() => {
updateNodeInternalsRef.current = updateNodeInternals;
}, [updateNodeInternals]);
useEffect(() => {
const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`;
if (prevTransformRef.current !== transformKey) {
updateNodeInternalsRef.current(id);
prevTransformRef.current = transformKey;
}
}, [data.rotation, data.flip, data.flop, id]);
useEffect(() => {
updateNodeInternalsRef.current(id);
}, [id, data.ports, data.componentName, data.boxSize]);
const baseHandleStyle = {
width: 6, height: 6,
background: 'var(--bg-main)',
border: '1px solid var(--accent)',
borderRadius: '50%',
};
const handlePositionMap = {
left: Position.Left,
right: Position.Right,
top: Position.Top,
bottom: Position.Bottom
};
const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const portHandles = useMemo(
() => buildPortHandles(data.ports, { rotation: 0, flip: Boolean(data.flip), flop: Boolean(data.flop), boxSize: componentSize }),
[data.ports, data.rotation, data.flip, data.flop, componentSize]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
);
const isAnchorElement = data.elementType === 'anchor';
const isBasicCompactComponent = isBasicComponent(data.componentName) && ['waveguide', 'taper', '90 bend'].includes(data.componentName);
const visualSize = isAnchorElement ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : componentSize;
const componentVisualTransform = `rotate(${data.rotation || 0}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`;
const iconSize = createComponentSymbolMetrics(componentSize);
const portLabelStyle = (portHandle) => {
const base = { ...portHandle.style };
const unrotate = `rotate(${-(data.rotation || 0)}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`;
if (portHandle.position === 'left') {
return { ...base, left: 'auto', right: 'calc(100% + 8px)', transform: `translateY(-50%) ${unrotate}`, textAlign: 'right' };
}
if (portHandle.position === 'right') {
return { ...base, left: 'calc(100% + 8px)', right: 'auto', transform: `translateY(-50%) ${unrotate}`, textAlign: 'left' };
}
if (portHandle.position === 'top') {
return { ...base, top: 'auto', bottom: 'calc(100% + 8px)', transform: `translateX(-50%) ${unrotate}`, textAlign: 'center' };
}
return { ...base, top: 'calc(100% + 8px)', bottom: 'auto', transform: `translateX(-50%) ${unrotate}`, textAlign: 'center' };
};
return (
<div
className="component-node-shell"
style={{ width: visualSize.width, minWidth: visualSize.width, maxWidth: visualSize.width, minHeight: visualSize.height }}
>
<div className="component-floating-label" title={data.componentDisplayName}>
<strong>{data.componentDisplayName}</strong>
{data.componentName && data.componentName !== data.componentDisplayName && (
<span title={data.componentName}>{data.componentName}</span>
)}
</div>
<div style={{
position: 'relative',
width: componentSize.width,
transform: componentVisualTransform,
transformOrigin: 'center center',
}}>
<div
className="component-visual-body"
style={{
width: componentSize.width,
height: visualSize.height,
border: selected ? '2px solid var(--accent)' : '1px solid var(--border)',
boxShadow: selected ? '0 0 15px rgba(56, 189, 248, 0.2)' : '0 4px 6px rgba(0,0,0,0.3)',
...(isBasicCompactComponent ? {
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} : {}),
...(isAnchorElement ? {
width: PORT_NODE_SIZE,
minHeight: PORT_NODE_SIZE,
padding: 0,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
} : {}),
}}
>
{isAnchorElement ? (
<span style={{ fontSize: 8, fontWeight: 800, color: selected ? 'var(--accent)' : 'var(--text-main)' }}>A</span>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '8px', minHeight: '100%' }}>
{!data.hideIcon && data.category && (
<div style={{ width: iconSize.width, height: iconSize.height }}>
<IconImg category={data.category} />
</div>
)}
{!data.category && <div style={{ width: iconSize.width, height: iconSize.height, borderRadius: 4, border: '1px solid var(--border-strong)', background: 'rgba(148, 163, 184, 0.08)' }} />}
</div>
)}
</div>
<div style={{
position: 'absolute',
top: 0, left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
}}>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 10, pointerEvents: 'all' }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5, pointerEvents: 'all' }}
/>
</React.Fragment>
))}
</div>
{portHandles.map((portHandle) => (
<React.Fragment key={`label-${portHandle.name}`}>
<span className="port-name-label" style={portLabelStyle(portHandle)} title={portHandle.name}>
{portHandle.name}
</span>
</React.Fragment>
))}
</div>
</div>
);
}, (prevProps, nextProps) => {
return (
prevProps.id === nextProps.id &&
prevProps.selected === nextProps.selected &&
prevProps.data.componentDisplayName === nextProps.data.componentDisplayName &&
prevProps.data.componentName === nextProps.data.componentName &&
prevProps.data.category === nextProps.data.category &&
prevProps.data.rotation === nextProps.data.rotation &&
prevProps.data.flip === nextProps.data.flip &&
prevProps.data.flop === nextProps.data.flop &&
prevProps.data.boxSize === nextProps.data.boxSize &&
prevProps.data.hideIcon === nextProps.data.hideIcon &&
prevProps.data.ports === nextProps.data.ports
);
});
// Renders standalone exported port elements with repeated port handles.
const pinLabelFromPortName = (portName) => {
const name = String(portName || '');
const portMatch = name.match(/^port_(\d+)$/);
if (portMatch) return portMatch[1];
if (name === 'port') return '1';
return name;
};
const PortNode = ({ id, data, selected }) => {
const angle = data.angle ?? 0;
const canvasAngle = -Number(angle || 0);
const portDisplayName = data.portName || data.componentDisplayName || data.label || 'port';
const ports = buildElementPorts('port', data);
const elementSize = buildElementBoxSize(data);
const localHandlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: 0 }])
);
const localPortHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: 0, boxSize: elementSize }),
[localHandlePorts, elementSize]
);
const portHandles = useMemo(
() => buildPortHandles(localHandlePorts, { rotation: canvasAngle }),
[localHandlePorts, canvasAngle]
);
const portDirectionMap = useMemo(
() => new Map(portHandles.map(handle => [handle.name, handle.position])),
[portHandles]
);
const handlePositionMap = {
left: Position.Left,
right: Position.Right,
top: Position.Top,
bottom: Position.Bottom
};
const baseHandleStyle = {
background: 'var(--accent)',
width: 5,
height: 5
};
const pinLabelStyle = (portHandle) => {
const base = {
left: portHandle.style?.left,
right: portHandle.style?.right,
top: portHandle.style?.top,
bottom: portHandle.style?.bottom
};
if (portHandle.position === 'left') return { ...base, transform: 'translate(calc(-100% - 5px), -50%)' };
if (portHandle.position === 'right') return { ...base, transform: 'translate(5px, -50%)' };
if (portHandle.position === 'top') return { ...base, transform: 'translate(-50%, calc(-100% - 5px))' };
return { ...base, transform: 'translate(-50%, 5px)' };
};
const pinLabelTextStyle = {
transform: `rotate(${-canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`
};
return (
<div style={{ width: elementSize.width, height: elementSize.height, position: 'relative' }}>
<div className="component-floating-label" title={portDisplayName}>
<strong>{portDisplayName}</strong>
<span>Port</span>
</div>
<div style={{
width: elementSize.width, height: elementSize.height, borderRadius: 7,
position: 'relative',
boxSizing: 'border-box',
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
color: selected ? 'white' : 'var(--accent)',
fontSize: 8, fontWeight: 'bold',
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${canvasAngle}deg) scaleX(${data.flop ? -1 : 1}) scaleY(${data.flip ? -1 : 1})`,
}}>
{localPortHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }}
/>
<Handle
type="target"
position={handlePositionMap[portDirectionMap.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style }}
/>
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
</div>
</React.Fragment>
))}
</div>
</div>
);
};
// Renders anchor elements with split visual handles while keeping paired layout ports connected.
const AnchorNode = memo(({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
const anchorRotation = data.rotation || 0;
const anchorVisualRotation = -Number(anchorRotation || 0);
const anchorDisplayName = data.componentDisplayName || data.label || 'anchor';
const ports = buildElementPorts('anchor', data);
const elementSize = buildElementBoxSize(data);
const localAnchorHandlePorts = Object.fromEntries(
Object.entries(ports).map(([name, info]) => [name, { ...info, a: name.startsWith('a') || name.startsWith('left') ? 180 : 0 }])
);
const portHandles = useMemo(
() => buildPortHandles(localAnchorHandlePorts, { rotation: 0, boxSize: elementSize, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
[localAnchorHandlePorts, elementSize, data.flip, data.flop]
);
const anchorDirectionHandles = useMemo(
() => new Map(buildPortHandles(localAnchorHandlePorts, { rotation: -Number(anchorRotation || 0), flip: Boolean(data.flip), flop: Boolean(data.flop) }).map(handle => [handle.name, handle.position])),
[localAnchorHandlePorts, anchorRotation, data.flip, data.flop]
);
const handlePositionMap = {
left: Position.Left,
right: Position.Right,
top: Position.Top,
bottom: Position.Bottom
};
const baseHandleStyle = {
width: 5,
height: 5,
background: 'var(--accent)',
border: '1px solid var(--bg-main)',
borderRadius: '50%'
};
const anchorPortVisualSide = (portName) => {
const name = String(portName || '');
return name.startsWith('a') || name.startsWith('left') ? 'left' : 'right';
};
const anchorHandleVisualStyle = (portHandle, zIndex) => ({
...baseHandleStyle,
zIndex,
left: portHandle.style?.left,
top: portHandle.style?.top || '50%',
right: portHandle.style?.right || 'auto',
bottom: portHandle.style?.bottom || 'auto',
transform: portHandle.style?.transform || 'translate(-50%, -50%)'
});
const pinLabelStyle = (portHandle) => {
const visualSide = anchorPortVisualSide(portHandle.name);
return {
left: portHandle.style?.left,
top: portHandle.style?.top || '50%',
right: portHandle.style?.right || 'auto',
bottom: portHandle.style?.bottom || 'auto',
transform: visualSide === 'left' ? 'translate(calc(-100% - 5px), -50%)' : 'translate(5px, -50%)'
};
};
const pinLabelTextStyle = {
transform: `rotate(${Number(anchorRotation || 0)}deg)`
};
useEffect(() => {
updateNodeInternals(id);
}, [id, data.ports, data.rotation, data.flip, data.flop, updateNodeInternals]);
return (
<div className="anchor-node-shell" style={{ width: elementSize.width, height: elementSize.height }}>
<div className="component-floating-label" title={anchorDisplayName}>
<strong>{anchorDisplayName}</strong>
<span>Anchor</span>
</div>
<div className="anchor-visual-body" style={{
width: elementSize.width,
height: elementSize.height,
borderRadius: 999,
background: selected ? 'var(--accent)' : 'var(--bg-card)',
border: selected ? '2px solid white' : '2px solid var(--accent)',
color: selected ? 'white' : 'var(--accent)',
fontSize: 10,
fontWeight: 800,
boxShadow: selected ? '0 0 10px rgba(56,189,248,0.4)' : 'none',
transform: `rotate(${anchorVisualRotation}deg)`,
}}>
{portHandles.map((portHandle) => (
<React.Fragment key={portHandle.name}>
<Handle
type="source"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 10)}
/>
<Handle
type="target"
position={handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]}
id={portHandle.name}
title={portHandle.name}
style={anchorHandleVisualStyle(portHandle, 5)}
/>
<div className="port-pin-label" style={pinLabelStyle(portHandle)}>
<span style={pinLabelTextStyle}>{pinLabelFromPortName(portHandle.name)}</span>
</div>
</React.Fragment>
))}
</div>
</div>
);
});
// Draws the non-interactive canvas extent marker used by React Flow.
const CanvasBoundaryNode = memo(({ data }) => (
<div className="canvas-boundary-node" title={`${data.size.width} x ${data.size.height} um`} />
));
// Draws invisible connection handles for ruler measurement endpoints.
const RulerPointNode = memo(({ data }) => {
const hiddenHandleStyle = {
width: 1,
height: 1,
opacity: 0,
border: 0,
background: 'transparent',
pointerEvents: 'none'
};
return (
<>
<div className="ruler-point-node" title={data.label || 'Ruler point'} />
<Handle type="source" position={Position.Right} id="route" style={hiddenHandleStyle} isConnectable={false} />
<Handle type="target" position={Position.Left} id="route" style={hiddenHandleStyle} isConnectable={false} />
</>
);
});
// Displays the ruler measurement label at the measured midpoint.
const RulerMeasurementNode = memo(({ data }) => (
<div className="ruler-measurement-node" title={data.title || data.label}>
{data.label}
</div>
));
// Maps visual route directions to x/y vectors for edge geometry calculations.
const routeDirectionVector = (direction) => {
if (direction === 'left') return { x: -1, y: 0 };
if (direction === 'right') return { x: 1, y: 0 };
if (direction === 'top') return { x: 0, y: -1 };
if (direction === 'bottom') return { x: 0, y: 1 };
return null;
};
// Converts a route direction string into the matching React Flow handle position.
const directionToReactFlowPosition = (direction) => {
if (direction === 'left') return Position.Left;
if (direction === 'right') return Position.Right;
if (direction === 'top') return Position.Top;
if (direction === 'bottom') return Position.Bottom;
return undefined;
};
// Draws editable routed links, including parallel offsets and draggable bend control points.
const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => {
const offset = Number(data?.parallelOffset || 0);
const hasExplicitPoints = Array.isArray(data?.points) && data.points.length >= 2;
let rawPoints = hasExplicitPoints
? data.points.map(point => ({ x: Number(point.x), y: Number(point.y) })).filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
: [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }];
if (!data?.freeRoute && rawPoints.length >= 2) {
const sourcePoint = data?.sourceSnap?.point || { x: sourceX, y: sourceY };
const targetPoint = data?.targetSnap?.point || { x: targetX, y: targetY };
rawPoints = [
{ x: Number(sourcePoint.x), y: Number(sourcePoint.y) },
...rawPoints.slice(1, -1),
{ x: Number(targetPoint.x), y: Number(targetPoint.y) }
];
}
const sourceVector = routeDirectionVector(data?.sourceDirection);
const targetVector = routeDirectionVector(data?.targetDirection);
if (!hasExplicitPoints && (sourceVector || targetVector) && rawPoints.length >= 2) {
const stubLength = Math.min(48, Math.max(18, Math.hypot(targetX - sourceX, targetY - sourceY) / 4));
const directedPoints = [rawPoints[0]];
if (sourceVector) {
directedPoints.push({ x: rawPoints[0].x + sourceVector.x * stubLength, y: rawPoints[0].y + sourceVector.y * stubLength });
}
if (targetVector) {
directedPoints.push({ x: rawPoints[1].x + targetVector.x * stubLength, y: rawPoints[1].y + targetVector.y * stubLength });
}
directedPoints.push(rawPoints[1]);
rawPoints = directedPoints;
}
const firstPoint = rawPoints[0] || { x: sourceX, y: sourceY };
const lastPoint = rawPoints[rawPoints.length - 1] || { x: targetX, y: targetY };
const dx = lastPoint.x - firstPoint.x;
const dy = lastPoint.y - firstPoint.y;
const length = Math.hypot(dx, dy) || 1;
const normalX = -dy / length;
const normalY = dx / length;
const points = rawPoints.map(point => ({
x: point.x + normalX * offset,
y: point.y + normalY * offset
}));
const path = points.length >= 2
? `M ${points[0].x},${points[0].y} ${points.slice(1).map(point => `L ${point.x},${point.y}`).join(' ')}`
: `M ${sourceX},${sourceY} Q ${(sourceX + targetX) / 2 + normalX * offset},${(sourceY + targetY) / 2 + normalY * offset} ${targetX},${targetY}`;
const routeStyle = {
...(style || {}),
fill: 'none',
strokeWidth: selected ? Number(style?.strokeWidth || 2.4) + 1.2 : style?.strokeWidth
};
return (
<g className="react-flow__edge">
<path
d={path}
fill="none"
stroke="transparent"
strokeWidth={18}
className="react-flow__edge-interaction"
data-route-edge-id={id}
vectorEffect="non-scaling-stroke"
style={{ pointerEvents: data?.draft ? 'none' : 'stroke', cursor: data?.draft ? 'default' : 'pointer' }}
/>
<path
id={id}
d={path}
markerEnd={markerEnd}
className="react-flow__edge-path"
data-route-edge-id={id}
vectorEffect="non-scaling-stroke"
style={routeStyle}
/>
</g>
);
});
// Displays generated layout SVG previews with zoom and pan controls.
const LayoutSvgPreview = ({ page }) => {
const [layoutScale, setLayoutScale] = useState(100);
const previewBounds = useMemo(
() => page.layoutBounds || calculateLayoutBounds(page),
[page.layoutBounds, page.nodes, page.canvasSize]
);
const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100));
const stageWidth = Math.max(1, previewBounds.width) * normalizedScale / 100;
const stageHeight = Math.max(1, previewBounds.height) * normalizedScale / 100;
const updateScale = (value) => {
setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100)));
};
const handleWheel = (event) => {
event.preventDefault();
const direction = event.deltaY > 0 ? -1 : 1;
const step = event.shiftKey ? 5 : 15;
setLayoutScale(current => Math.min(800, Math.max(10, (Number(current) || 100) + direction * step)));
};
return (
<div className="layout-preview">
<div className="layout-preview-toolbar">
<label>
Scale
<input
type="range"
min="10"
max="800"
step="5"
value={normalizedScale}
onChange={(event) => updateScale(event.target.value)}
aria-label="Layout SVG preview scale"
/>
</label>
<label>
<input
type="number"
min="10"
max="800"
step="5"
value={normalizedScale}
onChange={(event) => updateScale(event.target.value)}
aria-label="Layout SVG preview scale percent"
/>
%
</label>
</div>
<div className="layout-preview-canvas" onWheel={handleWheel}>
<div className="layout-preview-scroll-area">
<div
className="layout-preview-stage"
style={{ width: stageWidth, height: stageHeight }}
>
<img
className="layout-preview-image"
src={page.svgUrl}
alt={`${page.name} layout preview`}
style={{ objectFit: 'contain' }}
/>
</div>
</div>
</div>
</div>
);
};
// Allows a canvas tab title to be renamed in place.
const EditableCanvasTabName = ({ page, active, onRename }) => {
const [value, setValue] = useState(page.name);
useEffect(() => {
setValue(page.name);
}, [page.name, page.id]);
if (!active || page.type === 'project') {
return <span>{page.name}</span>;
}
const commit = () => {
const nextName = value.trim();
if (nextName && nextName !== page.name) {
onRename(page.id, nextName);
} else {
setValue(page.name);
}
};
return (
<input
className="workspace-name-input"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setValue(page.name);
event.currentTarget.blur();
}
}}
/>
);
};
// Allows project-tree canvas names to be renamed from the navigation panel.
const EditableTreeCanvasName = ({ pageId, name, canRename, onRename, onOpen }) => {
const [value, setValue] = useState(name);
useEffect(() => {
setValue(name);
}, [name]);
if (!canRename) {
return (
<span className="tree-summary-name" onClick={onOpen}>
{name}
</span>
);
}
const commit = () => {
const nextName = value.trim();
if (nextName && nextName !== name) {
onRename(pageId, nextName);
} else {
setValue(name);
}
};
return (
<input
className="tree-name-input"
value={value}
onClick={(event) => event.stopPropagation()}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setValue(name);
event.currentTarget.blur();
}
}}
/>
);
};
// Checks whether a tree node represents a draggable component entry.
const isLibraryComponentLeaf = (node) => node && node.__type__ === 'component';
// Collects all component names under a library category for drag/drop selection.
const getCategoryComponents = (categoryNode) => {
return Object.entries(categoryNode || {})
.filter(([, childData]) => isLibraryComponentLeaf(childData))
.map(([childName, childData]) => ({
name: childData.__name__ || childName,
category: childData.__category__ || childData.__name__ || childName
}));
};
// Renders a top-level draggable category entry in the component library.
const CategoryCard = ({ name, components = [] }) => {
const componentNames = components.map(component => component.name).filter(Boolean);
const selectableComponents = Array.from(new Set([FORGE_COMPONENT_LABEL, ...componentNames]));
const handleDragStart = (event) => {
const dragData = JSON.stringify({
type: 'category',
category: name,
name: FORGE_COMPONENT_LABEL,
components: selectableComponents
});
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.setData('text/plain', dragData);
event.dataTransfer.effectAllowed = 'move';
};
return (
<div className="category-card" draggable onDragStart={handleDragStart}>
<div className="category-card-icon">
<IconImg category={name} />
</div>
<div className="category-card-name" title={name}>
{name}
</div>
<div className="category-card-count">{componentNames.length} components</div>
</div>
);
};
// Renders recursive component library nodes with drag behavior for leaves.
const TreeNode = ({ name, children }) => {
if (children && children.__type__ === 'component') {
const componentName = children.__name__;
const componentCategory = children.__category__ || 'default';
const isUserCell = children.__cell__ === true;
const isVirtualElement = children.__element__ === true;
const isBasicElement = children.__basic__ === true;
const elementIconClass = isBasicElement
? 'element-card-icon basic-icon'
: children.__elementType__ === 'anchor'
? 'element-card-icon anchor-icon'
: 'element-card-icon port-icon';
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 (isBasicElement) {
const dragData = JSON.stringify({
name: componentName,
type: 'basic',
componentName,
settings: createBasicSettings(componentName)
});
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.setData('text/plain', dragData);
event.dataTransfer.effectAllowed = 'move';
return;
}
if (isVirtualElement) {
const dragData = JSON.stringify({
name: componentName,
type: 'element',
elementType: children.__elementType__,
ports: children.__ports__ || {}
});
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.setData('text/plain', dragData);
event.dataTransfer.effectAllowed = 'move';
return;
}
const dragData = JSON.stringify(
isUserCell
? { name: componentName, type: 'composite', ports: children.__ports__ || {}, boxSize: children.__boxSize__ }
: { name: componentName, category: componentCategory }
);
console.log("DRAG START: Sending data ->", dragData);
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.setData('text/plain', dragData);
event.dataTransfer.effectAllowed = 'move';
dragStartPos.current = null;
dragReady.current = false;
};
const handleMouseUp = () => {
dragStartPos.current = null;
dragReady.current = false;
};
return (
<div
className={`component-card ${isUserCell ? 'compact-tree-card' : ''}`}
draggable
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragStart={handleDragStart}
>
{(isVirtualElement || isBasicElement) && (
<div
className={elementIconClass}
aria-hidden="true"
></div>
)}
{!isUserCell && !isVirtualElement && !isBasicElement && (
<div className="component-card-icon">
<IconImg category={componentCategory} />
</div>
)}
<div className="component-card-name" title={name}>
{name}
</div>
</div>
);
}
const entries = children ? Object.entries(children) : [];
const hasChildren = entries.length > 0;
const isComponentGrid = hasChildren && entries.every(([, childData]) => isLibraryComponentLeaf(childData));
const isElementComponentGrid = isComponentGrid && entries.every(([, childData]) => childData.__element__ === true);
const isDirectLeafGrid = isComponentGrid && entries.every(([, childData]) => (
childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true
));
const isCategoryGrid = hasChildren && entries.every(([, childData]) => {
const childEntries = Object.entries(childData || {});
return childEntries.length > 0 && childEntries.every(([, grandChild]) => isLibraryComponentLeaf(grandChild));
});
return (
<details>
<summary className="tree-folder">
<span className="tree-summary-row">
<span className="folder-icon library-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{hasChildren && (
isDirectLeafGrid ? (
<div className="category-grid">
{entries.map(([childName, childData]) => (
<TreeNode key={childName} name={childName} children={childData} />
))}
</div>
) : isCategoryGrid && !isElementComponentGrid ? (
<div className="category-grid">
{entries.map(([childName, childData]) => (
<CategoryCard key={childName} name={childName} components={getCategoryComponents(childData)} />
))}
</div>
) : isComponentGrid && !isElementComponentGrid ? (
<div className="category-grid">
<CategoryCard name={name} components={getCategoryComponents(children)} />
</div>
) : (
entries.map(([childName, childData]) => (
<TreeNode key={childName} name={childName} children={childData} />
))
)
)}
</details>
);
};
// Renders recursive project/cell/instance navigation with open, drag, rename, and delete actions.
const ProjectTreeNode = ({ name, children, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas }) => {
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' }}>
<span className="tree-summary-row">
<span className="folder-icon project-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{composites.map(comp => (
<ProjectTreeNode key={comp.pageId || comp.__name__} name={comp.__name__} children={comp} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
))}
</details>
);
}
if (children && children.__type__ === 'instance') {
const instanceName = children.__instance__ || name;
const pageName = children.__page__;
return (
<div
className="component-leaf"
style={{ marginLeft: 15 }}
onClick={() => onSelectInstance && onSelectInstance(pageName, instanceName)}
>
<span style={{ color: 'var(--accent)', marginRight: '4px' }}>[]</span>
{instanceName}
</div>
);
}
if (children && children.__type__ === 'composite') {
const compositeName = children.__name__ || name;
const cellName = children.__cellName__ || compositeName;
const tree = children.tree || {};
const handleDragStart = (event) => {
const dragData = JSON.stringify({ name: cellName, type: 'composite', ports: children.__ports__ || {}, boxSize: children.__boxSize__ });
event.dataTransfer.setData('application/reactflow', dragData);
event.dataTransfer.effectAllowed = 'move';
};
const handleOpen = (event) => {
if (event.target.closest('.tree-expander')) return;
if (event.target.closest('.tree-delete-btn')) return;
if (onOpenComposite) onOpenComposite(cellName);
};
const handleDeleteCanvas = (event) => {
event.preventDefault();
event.stopPropagation();
if (onDeleteCanvas) onDeleteCanvas(cellName);
};
return (
<details>
<summary className="tree-folder" draggable onDragStart={handleDragStart} onClick={handleOpen}>
<span className="tree-summary-row">
<span className="folder-icon canvas-folder"></span>
<EditableTreeCanvasName
pageId={children.pageId}
name={name}
canRename={children.pageId && children.__name__ === cellName}
onRename={onRenameCanvas}
onOpen={() => onOpenComposite && onOpenComposite(cellName)}
/>
<button className="tree-delete-btn" type="button" title={`Delete ${cellName}`} onClick={handleDeleteCanvas}>x</button>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{Object.keys(tree).length > 0 ? (
Object.entries(tree).map(([childName, childData]) => (
<CompositeComponentTree key={childName} name={childName} children={childData} canvasName={cellName} onSelectInstance={onSelectInstance} />
))
) : (
<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 className="tree-summary-row">
<span className="folder-icon library-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{Object.entries(children).map(([childName, childData]) => (
<ProjectTreeNode key={childName} name={childName} children={childData} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
))}
</details>
);
};
// Renders the nested contents of a composite cell inside the project tree.
const CompositeComponentTree = ({ name, children, canvasName, onSelectInstance }) => {
if (children && children.__type__ === 'component') {
const displayText = children.__instance__ || name;
return (
<div
className="component-leaf"
style={{ marginLeft: 15 }}
onClick={() => onSelectInstance && onSelectInstance(canvasName, displayText)}
>
<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 }}>
<span className="tree-summary-row">
<span className="folder-icon library-folder"></span>
<span className="tree-summary-name">{name}</span>
<span className="tree-expander">&gt;</span>
</span>
</summary>
{hasChildren &&
Object.entries(children).map(([childName, childData]) => (
<CompositeComponentTree key={childName} name={childName} children={childData} canvasName={canvasName} onSelectInstance={onSelectInstance} />
))
}
</details>
);
}
return null;
};
// Renders project actions, canvas sizing controls, and the component library navigation.
const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, onSaveProject, saveProjectBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey, canvasSize, onCanvasSizeChange }) => {
const [projectPanelHeight, setProjectPanelHeight] = useState(270);
const [resizingProjectPanel, setResizingProjectPanel] = useState(false);
const leftPanelRef = useRef(null);
const size = normalizeCanvasSize(canvasSize);
useEffect(() => {
if (!resizingProjectPanel) return;
const onMouseMove = (event) => {
if (!leftPanelRef.current) return;
const rect = leftPanelRef.current.getBoundingClientRect();
const nextHeight = event.clientY - rect.top - 12;
setProjectPanelHeight(Math.min(620, Math.max(150, nextHeight)));
};
const onMouseUp = () => setResizingProjectPanel(false);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [resizingProjectPanel]);
// Toggle the expanded state of the project tree panel.
const handleProjectToggle = () => {
onProjectToggle();
};
const handleLibraryToggle = () => {
onToggle();
};
return (
<aside style={{
width: width, background: 'var(--panel-rail)', borderRight: '1px solid var(--border)',
padding: 12, display: 'flex', flexDirection: 'column', height: '100%',
boxSizing: 'border-box', overflow: 'hidden', gap: 12
}} ref={leftPanelRef}>
<div style={{ display: 'flex', flexDirection: 'column', flex: '1 1 0', minHeight: 0, overflow: 'hidden' }}>
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, height: projectPanelHeight, flex: '0 0 auto', marginBottom: 0 }}>
<div className="left-block-header">
<span>Project Tree</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button className="save-project-btn" onClick={onSaveProject} disabled={saveProjectBusy} title="Save YAML for all canvases">
{saveProjectBusy ? 'Saving' : 'Save'}
</button>
<button className="build-gds-btn" onClick={onBuildGds} disabled={buildGdsBusy} title="Build project GDS">
{buildGdsBusy ? 'Building' : 'Build GDS'}
</button>
<button className="toggle-btn" onClick={handleProjectToggle} title={projectExpanded ? 'Collapse all' : 'Expand all'}>
{projectExpanded ? '-' : '+'}
</button>
</div>
</div>
<div className="left-block-body project-tree-scroll" style={{ flex: '1 1 0', minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }} key={projectTreeKey} ref={projectTreeRef}>
<div className="canvas-size-panel">
<div className="canvas-size-title">Canvas Size</div>
<div>
<label>Width um</label>
<input
type="number"
min="100"
step="100"
value={size.width}
onChange={(event) => onCanvasSizeChange && onCanvasSizeChange('width', event.target.value)}
/>
</div>
<div>
<label>Height um</label>
<input
type="number"
min="100"
step="100"
value={size.height}
onChange={(event) => onCanvasSizeChange && onCanvasSizeChange('height', event.target.value)}
/>
</div>
</div>
{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} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
);
} else {
return (
<ProjectTreeNode key={item.name} name={item.name} children={{ __type__: 'composite', __name__: item.name, tree: item.tree || {}, pageId: item.pageId, __ports__: item.__ports__ || {}, __boxSize__: item.__boxSize__ }} onOpenComposite={onOpenComposite} onOpenProject={onOpenProject} onSelectInstance={onSelectInstance} onRenameCanvas={onRenameCanvas} onDeleteCanvas={onDeleteCanvas} />
);
}
})
) : (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No project loaded</p>
)}
</div>
</div>
<div className="vertical-resize-handle" onMouseDown={(event) => { event.preventDefault(); setResizingProjectPanel(true); }} />
<div className="left-block" style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: '1 1 0', marginBottom: 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>
</aside>
);
};
// Renders editable properties for selected nodes, ports, anchors, and routes.
const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, compositeNames = [], width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => {
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('');
const [editingTransformField, setEditingTransformField] = useState(null);
const MIXED_VALUE = '--';
const selectedPositionNodes = useMemo(
() => (selectedNodes.length > 0 ? selectedNodes : (selectedNode ? [selectedNode] : [])).filter(node => node && node.position),
[selectedNodes, selectedNode]
);
const isMultiNodeSelection = selectedPositionNodes.length > 1;
const isPortRotationNode = useCallback((node) => (
node?.id === 'page-port' || node?.type === 'portNode' || node?.data?.elementType === 'port'
), []);
const getNodeRotationValue = useCallback((node) => (
isPortRotationNode(node) ? (node.data?.angle ?? 0) : (node.data?.rotation ?? 0)
), [isPortRotationNode]);
const getSharedNumericDisplay = useCallback((nodes, getValue) => {
if (!nodes.length) return '';
const firstValue = Number(getValue(nodes[0]));
if (!Number.isFinite(firstValue)) return MIXED_VALUE;
const sameValue = nodes.every(node => {
const nextValue = Number(getValue(node));
return Number.isFinite(nextValue) && Math.abs(nextValue - firstValue) < 0.0005;
});
return sameValue ? firstValue.toFixed(3) : MIXED_VALUE;
}, []);
const getSharedTextDisplay = useCallback((nodes, getValue) => {
if (!nodes.length) return '';
const firstValue = getValue(nodes[0]) || '';
return nodes.every(node => String(getValue(node) || '') === String(firstValue)) ? firstValue : MIXED_VALUE;
}, []);
const clearMixedInput = useCallback((event, setter) => {
if (event.currentTarget.value === MIXED_VALUE) {
setter('');
}
}, []);
const beginTransformInput = useCallback((event, field, setter) => {
setEditingTransformField(field);
clearMixedInput(event, setter);
}, [clearMixedInput]);
useEffect(() => {
const nodeId = selectedNode?.id;
if (!nodeId || isMultiNodeSelection) {
setComponentData(null);
setLoading(false);
return;
}
const compName = selectedNode?.data?.componentName;
const selectedIsComposite = selectedNode?.data?.type === 'composite' || compositeNames.includes(compName);
if (selectedNode?.data?.elementType || selectedIsComposite || isBasicComponent(compName)) {
setComponentData(null);
setLoading(false);
return;
}
if (!compName) {
setComponentData(null);
setLoading(false);
return;
}
if (isForgeComponent(compName)) {
setComponentData(null);
setLoading(false);
return;
}
if (componentData && componentData.name === compName && componentData.nodeId === nodeId) return;
setLoading(true);
fetch(`/api/component/${encodeURIComponent(compName)}?project=${encodeURIComponent(projectName || '')}`)
.then(r => {
if (!r.ok) throw new Error('Component metadata not found');
return r.json();
})
.then(data => {
setComponentData({ ...data, nodeId: nodeId, componentDisplayName: selectedNode.data.componentDisplayName || data.name });
onUpdateNode(nodeId, {
data: {
ports: data.ports || {},
boxSize: normalizeBoxSize(data),
foundry: data.foundry || '',
process: data.process || ''
}
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName, isMultiNodeSelection, compositeNames, projectName, onUpdateNode]);
useEffect(() => {
if (editingTransformField) return;
if (selectedPositionNodes.length > 0) {
setLocalX(getSharedNumericDisplay(selectedPositionNodes, node => node.position.x));
setLocalY(getSharedNumericDisplay(selectedPositionNodes, node => node.position.y));
setLocalRotation(getSharedNumericDisplay(selectedPositionNodes, getNodeRotationValue));
return;
}
setLocalX('');
setLocalY('');
setLocalRotation('');
}, [selectedPositionNodes, getSharedNumericDisplay, getNodeRotationValue, editingTransformField]);
const updatePosition = useCallback((id, axis, value) => {
const val = parseFloat(value);
if (isNaN(val)) return;
if (selectedPositionNodes.length > 1 && selectedPositionNodes.some(node => node.id === id)) {
selectedPositionNodes.forEach(node => {
onUpdateNode(node.id, { position: { [axis]: val } });
});
return;
}
onUpdateNode(id, { position: { [axis]: val } });
}, [onUpdateNode, selectedPositionNodes]);
const updateRotation = useCallback((id, value, isPortNode = false) => {
const val = parseFloat(value);
if (isNaN(val)) return;
const clamped = Math.min(180, Math.max(-180, val));
if (selectedPositionNodes.length > 1 && selectedPositionNodes.some(node => node.id === id)) {
selectedPositionNodes.forEach(node => {
const dataField = isPortRotationNode(node) ? { angle: clamped } : { rotation: clamped };
onUpdateNode(node.id, { data: dataField });
});
return;
}
const dataField = isPortNode || id === 'page-port' ? { angle: clamped } : { rotation: clamped };
onUpdateNode(id, { data: dataField });
}, [onUpdateNode, selectedPositionNodes, isPortRotationNode]);
const commitTransformInput = useCallback((event, field, setter) => {
const rawValue = event.currentTarget.value;
const val = parseFloat(rawValue);
if (!isNaN(val) && selectedNode) {
if (field === 'x') {
updatePosition(selectedNode.id, 'x', val);
} else if (field === 'y') {
updatePosition(selectedNode.id, 'y', val);
} else {
updateRotation(selectedNode.id, val, isPortRotationNode(selectedNode));
}
setter(val.toFixed(3));
} else if (field === 'x') {
setter(getSharedNumericDisplay(selectedPositionNodes, node => node.position.x));
} else if (field === 'y') {
setter(getSharedNumericDisplay(selectedPositionNodes, node => node.position.y));
} else {
setter(getSharedNumericDisplay(selectedPositionNodes, getNodeRotationValue));
}
setEditingTransformField(null);
}, [selectedNode, selectedPositionNodes, updatePosition, updateRotation, isPortRotationNode, getSharedNumericDisplay, getNodeRotationValue]);
const toggleComponentTransform = useCallback((key) => {
if (!selectedNode) return;
onUpdateNode(selectedNode.id, { data: { [key]: !Boolean(selectedNode.data?.[key]) } });
}, [onUpdateNode, selectedNode]);
const formatPort = (port) => {
if (!port) return '-';
const description = port.description ? ` ${port.description}` : '';
return `(${port.x ?? '?'}, ${port.y ?? '?'}, ${port.a ?? '?'})${description}`;
};
const currentComponentDisplayName = selectedNode?.data?.componentDisplayName || '';
const selectedComponentName = selectedNode?.data?.componentName || '';
const availableComponentsFromNode = Array.isArray(selectedNode?.data?.availableComponents)
? selectedNode.data.availableComponents.filter(Boolean)
: [];
const availableComponents = Array.from(new Set([...availableComponentsFromNode, selectedComponentName].filter(Boolean)));
const selectedIsVirtualElement = selectedNode?.data?.elementType === 'port' || selectedNode?.data?.elementType === 'anchor';
const canChooseComponent = !selectedIsVirtualElement && availableComponentsFromNode.length > 0;
const forgeSelected = isForgeComponent(selectedComponentName);
const basicSelected = isBasicComponent(selectedComponentName);
const basicMetadata = basicSelected ? getBasicComponentMetadata(selectedComponentName, selectedNode?.data?.basicArguments) : null;
const basicArguments = basicSelected ? createBasicSettings(selectedComponentName, selectedNode?.data?.basicArguments) : {};
const forgeArguments = createForgeArguments(selectedNode?.data?.forgeArguments);
const selectedIsPort = !isMultiNodeSelection && selectedNode && (selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port');
const selectedIsAnchor = !isMultiNodeSelection && selectedNode?.data?.elementType === 'anchor';
const selectedNodeBoxSize = !isMultiNodeSelection && selectedNode?.data?.componentName && !selectedNode?.data?.elementType
? normalizeBoxSize({ box_size: selectedNode.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
: null;
const xsections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {});
const sharedComponentName = isMultiNodeSelection
? getSharedTextDisplay(selectedPositionNodes, node => node.data?.componentName || node.data?.elementType || node.type || '')
: '';
const sharedDisplayName = isMultiNodeSelection
? getSharedTextDisplay(selectedPositionNodes, node => node.data?.componentDisplayName || node.data?.label || node.id)
: '';
const selectedRouteEdges = selectedEdges.length > 0 ? selectedEdges : (selectedEdge ? [selectedEdge] : []);
if (selectedRouteEdges.length > 0) {
const routes = selectedRouteEdges.map(edge => createRouteSettings(technologyManifest, edge.data?.route));
const selectedEdgeIds = selectedRouteEdges.map(edge => edge.id);
const firstRoute = routes[0];
const mixedValue = (key) => routes.every(route => String(route[key]) === String(firstRoute[key])) ? firstRoute[key] : '__mixed__';
const route = {
...firstRoute,
xsection: mixedValue('xsection'),
family: mixedValue('family'),
width: mixedValue('width'),
radius: mixedValue('radius'),
routing_type: mixedValue('routing_type')
};
const routingTypes = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).routing_types || ['euler_bend', 'standard_bend'];
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 }}>
<div className="right-block-header">Route Editor</div>
<div className="right-block-body">
<div className="route-chip" style={{ color: routeStyleForSettings(route, false).style.stroke }}>
{selectedRouteEdges.length} selected / {route.family === '__mixed__' ? '--' : route.family}
</div>
<label>XSection</label>
<select
value={route.xsection}
onChange={(event) => onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteXsection(currentRoute, event.target.value, technologyManifest))}
>
{route.xsection === '__mixed__' && <option value="__mixed__" disabled>--</option>}
{xsections.map(xsection => (
<option key={xsection} value={xsection}>{xsection}</option>
))}
</select>
<br /><br />
<label>Width</label>
<input
type="text"
step="0.01"
value={route.width === '__mixed__' ? '--' : route.width}
onChange={(event) => {
if (event.target.value === '--') return;
onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'width', event.target.value, technologyManifest));
}}
/>
<br /><br />
<label>Radius</label>
<input
type="text"
step="1"
value={route.radius === '__mixed__' ? '--' : route.radius}
onChange={(event) => {
if (event.target.value === '--') return;
onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'radius', event.target.value, technologyManifest));
}}
/>
<br /><br />
<label>Routing Type</label>
<select
value={route.routing_type}
onChange={(event) => onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'routing_type', event.target.value, technologyManifest))}
>
{route.routing_type === '__mixed__' && <option value="__mixed__" disabled>--</option>}
{routingTypes.map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
</div>
</aside>
);
}
const updateForgeArgument = (key, value, type) => {
if (!selectedNode) return;
let nextValue = value;
if (type === 'number') {
nextValue = value === '' ? '' : Number(value);
} else if (type === 'boolean') {
nextValue = Boolean(value);
}
onUpdateNode(selectedNode.id, {
data: {
forgeArguments: {
...forgeArguments,
[key]: nextValue
}
}
});
};
const updateBasicArgument = (key, value) => {
if (!selectedNode || !basicSelected) return;
const numericValue = Number(value);
const nextArguments = {
...basicArguments,
[key]: Number.isFinite(numericValue) && value !== '' ? numericValue : value
};
const metadata = getBasicComponentMetadata(selectedComponentName, nextArguments);
onUpdateNode(selectedNode.id, {
data: {
basicArguments: nextArguments,
ports: metadata?.pins || metadata?.ports || {},
boxSize: metadata ? normalizeBoxSize(metadata) : selectedNode.data?.boxSize
}
});
};
const updatePortField = (key, value, type = 'text') => {
if (!selectedNode) return;
let nextValue = type === 'number' ? Number(value || 0) : value;
if (key === 'portNumber') nextValue = Math.max(1, Math.floor(nextValue || 1));
if (key === 'pitch') nextValue = Math.max(0, Number(nextValue || 0));
const dataUpdate = { [key]: nextValue };
if (key === 'portName') {
dataUpdate.componentDisplayName = value || selectedNode.data?.componentDisplayName;
dataUpdate.label = value || selectedNode.data?.label;
}
if (key === 'portNumber' || key === 'pitch' || key === 'width') {
const nextData = { ...selectedNode.data, ...dataUpdate };
dataUpdate.ports = buildElementPorts(selectedNode.data?.elementType === 'anchor' ? 'anchor' : 'port', nextData);
dataUpdate.boxSize = buildElementBoxSize(nextData);
}
onUpdateNode(selectedNode.id, { data: dataUpdate });
};
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>
<div className="coordinate-grid">
<label>
<span>X</span>
<input
type="text"
step="1"
value={localX}
onChange={(e) => setLocalX(e.target.value)}
onFocus={(event) => beginTransformInput(event, 'x', setLocalX)}
onBlur={(event) => commitTransformInput(event, 'x', setLocalX)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</label>
<label>
<span>Y</span>
<input
type="text"
step="1"
value={localY}
onChange={(e) => setLocalY(e.target.value)}
onFocus={(event) => beginTransformInput(event, 'y', setLocalY)}
onBlur={(event) => commitTransformInput(event, 'y', setLocalY)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</label>
<label>
<span>Angle</span>
<input
type="text"
step="1"
value={localRotation}
onChange={(e) => setLocalRotation(e.target.value)}
onFocus={(event) => beginTransformInput(event, 'rotation', setLocalRotation)}
onBlur={(event) => commitTransformInput(event, 'rotation', setLocalRotation)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
</label>
</div>
{selectedPositionNodes.length > 1 && (
<div className="route-chip" style={{ marginTop: 10 }}>
{selectedPositionNodes.length} selected
</div>
)}
{!isMultiNodeSelection && selectedNode?.data?.componentName && !selectedNode?.data?.elementType && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 12 }}>
<button
type="button"
className="tree-action-btn"
onClick={() => toggleComponentTransform('flip')}
>
{selectedNode.data?.flip ? 'Unflip X' : 'Flip X'}
</button>
<button
type="button"
className="tree-action-btn"
onClick={() => toggleComponentTransform('flop')}
>
{selectedNode.data?.flop ? 'Unmirror Y' : 'Mirror Y'}
</button>
</div>
)}
</div>
) : (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', textAlign: 'center' }}>Select a node to inspect</p>
)}
</div>
</div>
{isMultiNodeSelection && (
<div className="right-block" style={{ flexShrink: 0 }}>
<div className="right-block-header">Component Information</div>
<div className="right-block-body">
<div style={{ display: 'grid', gap: 8, color: 'var(--text-muted)' }}>
<div><strong style={{ color: 'var(--text-main)' }}>Selection:</strong> {selectedPositionNodes.length} components</div>
<div><strong style={{ color: 'var(--text-main)' }}>Name:</strong> {sharedDisplayName || MIXED_VALUE}</div>
<div><strong style={{ color: 'var(--text-main)' }}>Component:</strong> {sharedComponentName || MIXED_VALUE}</div>
</div>
</div>
</div>
)}
{selectedIsPort && (
<div className="right-block" style={{ flexShrink: 0 }}>
<div className="right-block-header">Port</div>
<div className="right-block-body">
<label>Name</label>
<input
type="text"
value={selectedNode.data?.portName || selectedNode.data?.componentDisplayName || ''}
onChange={(event) => updatePortField('portName', event.target.value)}
/>
<br /><br />
<label>Description</label>
<input
type="text"
value={selectedNode.data?.description || ''}
onChange={(event) => updatePortField('description', event.target.value)}
/>
<br /><br />
<label>Layer</label>
<input
type="text"
value={selectedNode.data?.layer || 'WG_CORE'}
onChange={(event) => updatePortField('layer', event.target.value)}
/>
<br /><br />
<label>Width</label>
<input
type="number"
step="0.1"
value={selectedNode.data?.width ?? 0.5}
onChange={(event) => updatePortField('width', event.target.value, 'number')}
/>
<br /><br />
<label>Port Number</label>
<input
type="number"
min="1"
step="1"
value={selectedNode.data?.portNumber ?? 1}
onChange={(event) => updatePortField('portNumber', event.target.value, 'number')}
/>
<br /><br />
<label>Pitch</label>
<input
type="number"
min="0"
step="1"
value={selectedNode.data?.pitch ?? DEFAULT_ELEMENT_PITCH}
onChange={(event) => updatePortField('pitch', event.target.value, 'number')}
/>
</div>
</div>
)}
{selectedIsAnchor && (
<div className="right-block" style={{ flexShrink: 0 }}>
<div className="right-block-header">Anchor</div>
<div className="right-block-body">
<label>Name</label>
<input
type="text"
value={selectedNode.data?.componentDisplayName || ''}
onChange={(event) => onUpdateNode(selectedNode.id, { data: { componentDisplayName: event.target.value, label: event.target.value } })}
/>
<br /><br />
<label>Description</label>
<input
type="text"
value={selectedNode.data?.description || ''}
onChange={(event) => onUpdateNode(selectedNode.id, { data: { description: event.target.value } })}
/>
<br /><br />
<label>Port Number</label>
<input
type="number"
min="1"
step="1"
value={selectedNode.data?.portNumber ?? 1}
onChange={(event) => updatePortField('portNumber', event.target.value, 'number')}
/>
<br /><br />
<label>Pitch</label>
<input
type="number"
min="0"
step="1"
value={selectedNode.data?.pitch ?? DEFAULT_ELEMENT_PITCH}
onChange={(event) => updatePortField('pitch', event.target.value, 'number')}
/>
</div>
</div>
)}
{!isMultiNodeSelection && selectedNode?.data?.componentName && !selectedNode?.data?.elementType && (
<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' }}>
{canChooseComponent && (
<div style={{ marginBottom: '15px' }}>
<label>Component</label>
<select
value={selectedNode.data.componentName || availableComponents[0]}
onChange={(event) => {
const componentName = event.target.value;
const forge = isForgeComponent(componentName);
onUpdateNode(selectedNode.id, {
data: {
...selectedNode.data,
componentName,
label: componentName,
ports: forge ? {} : undefined,
boxSize: forge ? DEFAULT_COMPONENT_BOX_SIZE : undefined,
forgeArguments: forge ? createForgeArguments(selectedNode.data.forgeArguments) : selectedNode.data.forgeArguments
}
});
}}
>
{availableComponents.map(componentName => (
<option key={componentName} value={componentName}>{componentName}</option>
))}
</select>
</div>
)}
{selectedNodeBoxSize && (
<div>
<label>Box Size</label>
<div className="box-size-readout">
<div><span>W</span> {selectedNodeBoxSize.width.toFixed(3)} um</div>
<div><span>H</span> {selectedNodeBoxSize.height.toFixed(3)} um</div>
</div>
</div>
)}
{basicSelected && basicMetadata ? (
<>
<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 || selectedComponentName}</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Edit</span>
</div>
)}
</div>
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '8px' }}>Nazca Primitive:</p>
<div style={{ color: 'var(--text-muted)', marginBottom: '12px' }}>
{selectedComponentName} / {basicMetadata.process}
</div>
<div style={{ display: 'grid', gap: 10 }}>
{Object.entries(basicArguments).map(([key, value]) => (
<label key={key} style={{ display: 'grid', gap: 4 }}>
<span style={{ color: 'var(--text-muted)' }}>{key}</span>
{key === 'xsection' ? (
<select
value={value ?? ''}
onChange={(event) => updateBasicArgument(key, event.target.value)}
>
{value && !xsections.includes(value) && (
<option value={value}>{value}</option>
)}
{xsections.map(xsection => (
<option key={xsection} value={xsection}>{xsection}</option>
))}
</select>
) : (
<input
type={typeof value === 'number' ? 'number' : 'text'}
step="any"
value={value ?? ''}
onChange={(event) => updateBasicArgument(key, event.target.value)}
/>
)}
</label>
))}
</div>
<p style={{ color: 'var(--text-main)', fontWeight: '500', margin: '14px 0 4px' }}>Ports:</p>
<ul className="port-info-list">
{Object.entries(basicMetadata.ports || {}).map(([portName, portInfo]) => (
<li key={portName}>
<span style={{ color: 'var(--accent)' }}>{portName}</span> : {formatPort(portInfo)}
</li>
))}
</ul>
</>
) : forgeSelected ? (
<>
<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 || selectedComponentName}</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>Edit</span>
</div>
)}
</div>
<div style={{ color: 'var(--text-muted)', lineHeight: '1.6', marginBottom: '12px' }}>
<p style={{ margin: '0 0 8px 0', wordBreak: 'break-all' }}>
<strong style={{ color: 'var(--text-main)' }}>Cell:</strong> {FORGE_COMPONENT_TYPE}
</p>
<p style={{ margin: 0 }}>
<strong style={{ color: 'var(--text-main)' }}>Generator:</strong> mxpic_forge
</p>
</div>
<p style={{ color: 'var(--text-main)', fontWeight: '500', marginBottom: '8px' }}>Forge Arguments:</p>
<div style={{ display: 'grid', gap: 10 }}>
{Object.entries(forgeArguments).map(([key, value]) => {
const isBoolean = typeof value === 'boolean';
const isNumber = typeof value === 'number';
return (
<label key={key} style={{ display: 'grid', gap: 4 }}>
<span style={{ color: 'var(--text-muted)' }}>{key}</span>
{isBoolean ? (
<input
type="checkbox"
checked={Boolean(value)}
onChange={(event) => updateForgeArgument(key, event.target.checked, 'boolean')}
/>
) : (
<input
type={isNumber ? 'number' : 'text'}
step={isNumber ? 'any' : undefined}
value={value ?? ''}
onChange={(event) => updateForgeArgument(key, event.target.value, isNumber ? 'number' : 'text')}
/>
)}
</label>
);
})}
</div>
</>
) : 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)' }}>Edit</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 className="port-info-list">
{componentData.ports && Object.entries(componentData.ports).map(([portName, portInfo]) => (
<li key={portName}>
<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?project=${encodeURIComponent(projectName || '')}`}
alt="Component layout"
loading="lazy"
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', cursor: 'pointer' }}
onClick={() => setEnlarged(`/api/component/${encodeURIComponent(componentData.name)}/image?project=${encodeURIComponent(projectName || '')}`)}
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>
);
};
// Provides a draggable divider for resizing side panels.
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'}
/>
);
// Finds the library path for a component name so it can be serialized into YAML.
function findComponentPath(lib, compName) {
function walk(obj, currentPath) {
if (obj && obj.__type__ === 'component' && obj.__name__ === compName) {
if (obj.__path__) {
return obj.__path__.split('/').filter(Boolean);
}
return currentPath;
}
if (typeof obj === 'object') {
for (const [key, val] of Object.entries(obj)) {
const result = walk(val, [...currentPath, key]);
if (result) return result;
}
}
return null;
}
return walk(lib, []) || [];
}
// Builds a compact project-tree structure from placed component instances.
function buildCompInstanceTree(compNodes, library) {
const tree = {};
compNodes.forEach(node => {
const compName = node.data.componentName;
const instanceName = node.data.componentDisplayName || node.id;
if (!compName) return;
tree[instanceName] = {
__type__: 'component',
__name__: compName,
__instance__: instanceName
};
});
return tree;
}
// Builds a category tree of components used by the current canvas.
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;
}
// Coordinates editor state, project loading, naming, routing, save/load, and build actions.
function App() {
const currentProjectName = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get('project') || 'project_1';
}, []);
const [pages, setPages] = useState([]);
const [activePageId, setActivePageId] = useState(null);
const reactFlowInstance = useReactFlow();
const spaceRotateNodeIdRef = useRef(null);
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 [canvasTextVisible, setCanvasTextVisible] = useState(true);
const [themeMode, setThemeMode] = useState(() => localStorage.getItem('mxpic-theme') || 'dark');
const [logs, setLogs] = useState([{ time: new Date().toLocaleTimeString(), message: 'Editor ready.' }]);
const [buildLayoutBusy, setBuildLayoutBusy] = useState(false);
const [buildGdsBusy, setBuildGdsBusy] = useState(false);
const [saveProjectBusy, setSaveProjectBusy] = useState(false);
const [buildProgress, setBuildProgress] = useState({ active: false, label: '', value: 0 });
const [rulerMode, setRulerMode] = useState(false);
const [rulerStartPoint, setRulerStartPoint] = useState(null);
const [rulerEndPoint, setRulerEndPoint] = useState(null);
const [rulerPreviewPoint, setRulerPreviewPoint] = useState(null);
const [mouseCanvasPoint, setMouseCanvasPoint] = useState(null);
const [mouseScreenPoint, setMouseScreenPoint] = useState(null);
const [canvasOrigin, setCanvasOrigin] = useState({ x: 0, y: 0 });
const [originPickMode, setOriginPickMode] = useState(false);
const [projectTechnology, setProjectTechnology] = useState('');
const [technologyManifest, setTechnologyManifest] = useState(FALLBACK_TECHNOLOGY_MANIFEST);
const [currentLinkXsection, setCurrentLinkXsection] = useState('strip');
const [clipboard, setClipboard] = useState({ nodes: [] });
const initializedRef = useRef(false);
const canvasViewportRef = useRef(null);
const edgeTypes = useMemo(() => ({ parallelRoute: ParallelRouteEdge }), []);
const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]);
const compositePageNames = useMemo(() => pages.filter(page => page.type === 'composite').map(page => page.name), [pages]);
const compositePageNameSet = useMemo(() => new Set(compositePageNames), [compositePageNames]);
const currentNodes = activePage && Array.isArray(activePage.nodes) ? activePage.nodes : [];
const currentEdges = activePage && Array.isArray(activePage.edges) ? activePage.edges : [];
const activeCanvasSize = useMemo(() => normalizeCanvasSize(activePage?.canvasSize), [activePage?.canvasSize]);
const selectedEdges = useMemo(() => currentEdges.filter(edge => edge.selected), [currentEdges]);
const selectedEdge = selectedEdges[0] || null;
const selectedNodes = useMemo(() => currentNodes.filter(n => n.selected), [currentNodes]);
const selectedNode = selectedNodes[0] || null;
const linkXsectionChoices = useMemo(() => {
const manifestSections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {});
const preferred = ['strip', 'rib_low', 'metal_1', 'metal_2'];
const ordered = preferred.filter(xsection => manifestSections.includes(xsection));
manifestSections.forEach(xsection => {
if (!ordered.includes(xsection)) ordered.push(xsection);
});
return ordered.length ? ordered : preferred;
}, [technologyManifest]);
const currentLinkRoute = useMemo(
() => createRouteSettings(technologyManifest, { xsection: currentLinkXsection }),
[technologyManifest, currentLinkXsection]
);
useEffect(() => {
if (!linkXsectionChoices.includes(currentLinkXsection)) {
setCurrentLinkXsection(linkXsectionChoices[0] || 'strip');
}
}, [linkXsectionChoices, currentLinkXsection]);
const canvasNodeExtent = useMemo(() => [[0, 0], [activeCanvasSize.width, activeCanvasSize.height]], [activeCanvasSize.width, activeCanvasSize.height]);
const rulerActiveEndPoint = rulerEndPoint || rulerPreviewPoint;
const rulerMeasurement = useMemo(
() => createRulerMeasurement(rulerStartPoint, rulerActiveEndPoint),
[rulerStartPoint, rulerActiveEndPoint]
);
const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null;
const displayMousePoint = useMemo(() => (
mouseCanvasPoint
? {
x: Number((mouseCanvasPoint.x - canvasOrigin.x).toFixed(3)),
y: Number((mouseCanvasPoint.y - canvasOrigin.y).toFixed(3))
}
: null
), [mouseCanvasPoint, canvasOrigin]);
const handleCanvasViewportMoveEnd = useCallback((event, viewport) => {
if (!activePageId || !viewport) return;
setPages(prev => prev.map(page => (
page.id === activePageId
? { ...page, viewport: { x: viewport.x, y: viewport.y, zoom: viewport.zoom } }
: page
)));
}, [activePageId]);
// Normalizes free-route control points and removes adjacent duplicates before storage.
const compactRoutePoints = useCallback((points) => {
return (points || [])
.map(point => ({
x: Number(Number(point.x).toFixed(3)),
y: Number(Number(point.y).toFixed(3))
}))
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
.filter((point, index, list) => index === 0 || point.x !== list[index - 1].x || point.y !== list[index - 1].y);
}, []);
// Builds stable hidden endpoint node ids for free-route edges.
const routeEndpointNodeId = useCallback((edgeId, endpoint) => `__free_route_${edgeId}_${endpoint}__`, []);
// Creates a React Flow edge object for stored free-route polylines.
const makeFreeRouteEdge = useCallback((edgeId, points, route, selected = false) => {
const view = routeStyleForSettings(route, selected);
return {
id: edgeId,
source: routeEndpointNodeId(edgeId, 'start'),
target: routeEndpointNodeId(edgeId, 'end'),
sourceHandle: 'route',
targetHandle: 'route',
type: 'parallelRoute',
selectable: true,
style: view.style,
data: { route, points: compactRoutePoints(points), freeRoute: true },
};
}, [compactRoutePoints, routeEndpointNodeId]);
// Builds temporary ruler endpoint and label nodes while measuring distance.
const rulerNodes = useMemo(() => {
if (!activePage || activePage.type === 'layoutPreview' || !rulerStartPoint) return [];
const nodes = [{
id: '__ruler_start__',
type: 'rulerPointNode',
position: { x: rulerStartPoint.x - 6, y: rulerStartPoint.y - 6 },
data: { label: `Start (${rulerStartPoint.x.toFixed(3)}, ${rulerStartPoint.y.toFixed(3)})` },
draggable: false,
selectable: false,
deletable: false,
focusable: false
}];
if (rulerActiveEndPoint) {
const isPreviewPoint = !rulerEndPoint;
nodes.push({
id: isPreviewPoint ? '__ruler_preview__' : '__ruler_end__',
type: 'rulerPointNode',
position: { x: rulerActiveEndPoint.x - 6, y: rulerActiveEndPoint.y - 6 },
data: { label: `${isPreviewPoint ? 'Preview' : 'End'} (${rulerActiveEndPoint.x.toFixed(3)}, ${rulerActiveEndPoint.y.toFixed(3)})` },
draggable: false,
selectable: false,
deletable: false,
focusable: false
});
}
if (rulerMeasurement) {
nodes.push({
id: '__ruler_measurement__',
type: 'rulerMeasurementNode',
position: { x: rulerMeasurement.midpoint.x, y: rulerMeasurement.midpoint.y },
data: {
label: rulerMeasurement.label,
title: `Distance ${rulerMeasurement.distance.toFixed(3)} um; dx ${rulerMeasurement.dx.toFixed(3)}; dy ${rulerMeasurement.dy.toFixed(3)}`
},
draggable: false,
selectable: false,
deletable: false,
focusable: false
});
}
return nodes;
}, [activePage, rulerStartPoint, rulerEndPoint, rulerActiveEndPoint, rulerMeasurement]);
// Builds temporary ruler edges between measurement endpoints.
const rulerEdges = useMemo(() => {
if (!rulerMeasurement) return [];
return [{
id: '__ruler_edge__',
source: '__ruler_start__',
target: rulerPreviewMeasurement ? '__ruler_preview__' : '__ruler_end__',
type: 'straight',
selectable: false,
focusable: false,
data: { ruler: true },
style: {
stroke: '#2dd4bf',
strokeWidth: 2,
strokeDasharray: rulerPreviewMeasurement ? undefined : '8 6'
}
}];
}, [rulerMeasurement, rulerPreviewMeasurement]);
// Creates hidden nodes that let free-route edge endpoints participate in React Flow.
const freeRouteEndpointNodes = useMemo(() => {
if (!activePage || activePage.type === 'layoutPreview') return [];
return currentEdges.flatMap(edge => {
const points = edge.data?.freeRoute && Array.isArray(edge.data?.points) ? compactRoutePoints(edge.data.points) : [];
if (points.length < 2) return [];
const first = points[0];
const last = points[points.length - 1];
return [
{
id: routeEndpointNodeId(edge.id, 'start'),
type: 'rulerPointNode',
position: { x: first.x - 6, y: first.y - 6 },
data: { label: 'route start' },
draggable: false,
selectable: false,
deletable: false,
focusable: false,
style: { opacity: 0.001, pointerEvents: 'none' }
},
{
id: routeEndpointNodeId(edge.id, 'end'),
type: 'rulerPointNode',
position: { x: last.x - 6, y: last.y - 6 },
data: { label: 'route end' },
draggable: false,
selectable: false,
deletable: false,
focusable: false,
style: { opacity: 0.001, pointerEvents: 'none' }
}
];
});
}, [activePage, currentEdges, compactRoutePoints, routeEndpointNodeId]);
// Combines real nodes with boundary, ruler, and hidden route helper nodes for display.
const renderNodes = useMemo(() => {
if (!activePage || activePage.type === 'layoutPreview') return currentNodes;
return [{
id: '__canvas-boundary__',
type: 'canvasBoundaryNode',
position: { x: 0, y: 0 },
data: { size: activeCanvasSize },
draggable: false,
selectable: false,
deletable: false,
focusable: false,
style: { width: activeCanvasSize.width, height: activeCanvasSize.height, zIndex: -1, pointerEvents: 'none' }
}, ...currentNodes, ...freeRouteEndpointNodes, ...rulerNodes];
}, [activePage, currentNodes, activeCanvasSize, freeRouteEndpointNodes, rulerNodes]);
// Resolves rotated anchor handle direction so connected canvas links exit the correct side.
const getAnchorHandleRouteDirection = useCallback((node, handleId) => {
if (!node || !handleId || !(node.type === 'anchorNode' || node.data?.elementType === 'anchor')) return null;
const handles = buildPortHandles(buildElementPorts('anchor', node.data), {
rotation: -Number(node.data?.rotation || 0),
flip: Boolean(node.data?.flip),
flop: Boolean(node.data?.flop)
});
return handles.find(handle => handle.name === handleId)?.position || null;
}, []);
// Applies parallel offsets, anchor handle directions, and ruler overlays before rendering edges.
const renderEdges = useMemo(() => {
const groups = new Map();
const nodeMap = Object.fromEntries(currentNodes.map(node => [node.id, node]));
currentEdges.forEach(edge => {
const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`;
const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`;
const key = [sourceEndpoint, targetEndpoint].sort().join('<>');
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(edge.id);
});
const separatedEdges = currentEdges.map(edge => {
const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`;
const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`;
const key = [sourceEndpoint, targetEndpoint].sort().join('<>');
const group = groups.get(key) || [];
const sourceDirection = getAnchorHandleRouteDirection(nodeMap[edge.source], edge.sourceHandle);
const targetDirection = getAnchorHandleRouteDirection(nodeMap[edge.target], edge.targetHandle);
const usesAnchorDirection = Boolean(sourceDirection || targetDirection);
const hasRoutePoints = edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2;
const directionalEdge = usesAnchorDirection
? {
...edge,
sourcePosition: directionToReactFlowPosition(sourceDirection),
targetPosition: directionToReactFlowPosition(targetDirection)
}
: edge;
if (group.length <= 1 && !hasRoutePoints) return directionalEdge;
const index = group.indexOf(edge.id);
const offset = (index - (group.length - 1) / 2) * 18;
return {
...directionalEdge,
type: 'parallelRoute',
data: {
...(edge.data || {}),
parallelOffset: offset
}
};
});
return [...separatedEdges, ...rulerEdges];
}, [currentEdges, currentNodes, getAnchorHandleRouteDirection, rulerEdges]);
const [projectCompositeMap, setProjectCompositeMap] = useState({});
const [standaloneComposites, setStandaloneComposites] = useState([]);
const [compositeTrees, setCompositeTrees] = useState({});
useEffect(() => {
document.body.classList.toggle('light-mode', themeMode === 'light');
localStorage.setItem('mxpic-theme', themeMode);
}, [themeMode]);
// Append a short status message to the activity log.
const addLog = useCallback((message) => {
setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]);
}, []);
useEffect(() => {
if (!buildProgress.active || buildProgress.value >= 100) return;
const timer = window.setInterval(() => {
setBuildProgress(prev => {
if (!prev.active || prev.value >= 94) return prev;
return { ...prev, value: Math.min(94, prev.value + Math.max(1, Math.round((96 - prev.value) / 9))) };
});
}, 360);
return () => window.clearInterval(timer);
}, [buildProgress.active, buildProgress.value]);
// Start the build progress indicator for layout or GDS operations.
const startBuildProgress = useCallback((label) => {
setBuildProgress({ active: true, label, value: 8 });
}, []);
// Finish and auto-hide the build progress indicator.
const completeBuildProgress = useCallback((label) => {
setBuildProgress({ active: true, label, value: 100 });
window.setTimeout(() => {
setBuildProgress(prev => prev.value === 100 ? { active: false, label: '', value: 0 } : prev);
}, 900);
}, []);
// Clear the build progress indicator after a failure or cancellation.
const stopBuildProgress = useCallback(() => {
setBuildProgress({ active: false, label: '', value: 0 });
}, []);
// Normalize YAML boolean-like values when loading saved projects.
const toBooleanFlag = useCallback((value) => (
value === true || value === 1 || value === '1' || String(value).toLowerCase() === 'true'
), []);
// Normalize stored route points and convert layout Y coordinates when needed.
const normalizeRoutePoints = useCallback((points, usesGdsYUp = false) => (
(Array.isArray(points) ? points : [])
.map(point => ({
x: Number(point && point.x),
y: usesGdsYUp ? layoutToCanvasY(point && point.y) : Number(point && point.y)
}))
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
), []);
// Load routing defaults and cross-section data for the project technology.
const loadTechnologyManifest = useCallback(async (technologyId) => {
if (!technologyId || !technologyId.includes('/')) {
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
return FALLBACK_TECHNOLOGY_MANIFEST;
}
const [foundry, technology] = technologyId.split('/');
try {
const response = await fetch(`/api/technologies/${encodeURIComponent(foundry)}/${encodeURIComponent(technology)}/manifest`);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
addLog(data.error || 'Technology manifest not available; using fallback route defaults.');
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
return FALLBACK_TECHNOLOGY_MANIFEST;
}
const manifest = data.manifest || FALLBACK_TECHNOLOGY_MANIFEST;
setTechnologyManifest(manifest);
return manifest;
} catch (error) {
addLog('Technology manifest load failed: ' + error.message);
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
return FALLBACK_TECHNOLOGY_MANIFEST;
}
}, [addLog]);
const componentDataCacheRef = useRef(new Map());
// Fetch metadata for a component before creating a loaded or dropped node.
const loadComponentMetadata = useCallback(async (componentName) => {
if (!componentName || isForgeComponent(componentName) || compositePageNameSet.has(componentName)) return null;
if (componentDataCacheRef.current.has(componentName)) {
return componentDataCacheRef.current.get(componentName);
}
const response = await fetch(`/api/component/${encodeURIComponent(componentName)}?project=${encodeURIComponent(currentProjectName)}`);
if (!response.ok) return null;
const data = await response.json();
componentDataCacheRef.current.set(componentName, data);
return data;
}, [currentProjectName, compositePageNameSet]);
// Send an auditable user action to the backend log endpoint.
const recordUserAction = useCallback((action, payload = {}) => {
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, ...payload })
}).catch(() => {});
}, []);
// Keep project/composite ownership maps in step when cells are placed or removed.
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;
});
}, []);
// Rebuild composite trees from all canvas pages after project load or cell edits.
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
}));
}, []);
// Apply React Flow node changes while preserving canvas-only helper nodes.
const onNodesChange = useCallback((changes) => {
if (!activePageId) return;
const relevantChanges = changes.filter(change => change.id !== '__canvas-boundary__');
if (relevantChanges.length === 0) return;
const removedNodeIds = new Set(relevantChanges.filter(change => change.type === 'remove').map(change => change.id));
if (removedNodeIds.size > 0 && activePage) {
releaseComponentDisplayNames(activePage.nodes.filter(node => removedNodeIds.has(node.id)));
}
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
const newNodes = applyNodeChanges(relevantChanges, p.nodes).map(node => {
if (!node.position || node.id === 'page-port') return node;
const boxSize = node.type === 'rotatableNode'
? normalizeBoxSize({ box_size: node.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
: { width: 30, height: 30 };
return { ...node, position: clampPositionToCanvas(node.position, p.canvasSize || activeCanvasSize, boxSize) };
});
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;
const width = portNode.data?.width ?? p.port.width ?? 0.5;
if (x !== p.port.x || y !== p.port.y || angle !== p.port.a || width !== p.port.width) {
newPort = { x, y, a: angle, width };
}
}
return { ...p, nodes: newNodes, port: newPort };
}));
}, [activePageId, activePage, activeCanvasSize]);
// Apply React Flow edge changes while preserving route style and selection state.
const onEdgesChange = useCallback((changes) => {
if (!activePageId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
const styledEdges = applyEdgeChanges(changes, p.edges).map(edge => {
const route = createRouteSettings(technologyManifest, edge.data?.route);
const view = routeStyleForSettings(route, edge.selected);
const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2;
return { ...edge, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } };
});
return { ...p, edges: styledEdges };
}));
}, [activePageId, technologyManifest]);
// Apply property-panel edits to a selected node.
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) {
const nextData = { ...n.data, ...update.data };
const nextPosition = update.position != null ? { ...n.position, ...update.position } : n.position;
const boxSize = n.type === 'rotatableNode'
? normalizeBoxSize({ box_size: nextData.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
: { width: 30, height: 30 };
return {
...n,
position: update.position != null ? clampPositionToCanvas(nextPosition, p.canvasSize || activeCanvasSize, boxSize) : nextPosition,
data: nextData
};
}
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, width: portNode.data?.width ?? 0.5 };
}
}
return { ...p, nodes: newNodes, port: newPort };
}));
}, [activePageId, activeCanvasSize]);
// Update active canvas dimensions and clamp existing node positions inside the new bounds.
const handleCanvasSizeChange = useCallback((axis, value) => {
if (!activePageId) return;
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) return;
setPages(prev => prev.map(page => {
if (page.id !== activePageId) return page;
const nextCanvasSize = normalizeCanvasSize({
...(page.canvasSize || DEFAULT_CANVAS_SIZE),
[axis]: numericValue
});
return {
...page,
canvasSize: nextCanvasSize,
nodes: page.nodes.map(node => {
if (!node.position || node.id === 'page-port') return node;
const boxSize = node.type === 'rotatableNode'
? normalizeBoxSize({ box_size: node.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
: { width: 30, height: 30 };
return { ...node, position: clampPositionToCanvas(node.position, nextCanvasSize, boxSize) };
})
};
}));
}, [activePageId]);
// Rotate selected components, ports, and anchors in 90 degree steps from keyboard input.
const rotateComponentByNinety = useCallback((nodeId) => {
if (!activePageId || !nodeId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return {
...p,
nodes: p.nodes.map(node => {
if (node.id !== nodeId) return node;
if (node.type === 'portNode' || node.data?.elementType === 'port') {
return { ...node, data: { ...node.data, angle: normalizeAngle(Number(node.data?.angle || 0) + 90) } };
}
if (node.type === 'anchorNode' || node.data?.elementType === 'anchor') {
const rotation = normalizeAngle(Number(node.data?.rotation || 0) + 90);
return { ...node, data: { ...node.data, rotation } };
}
if (node.type !== 'rotatableNode') return node;
const rotation = normalizeAngle(Number(node.data?.rotation || 0) + 90);
return { ...node, data: { ...node.data, rotation } };
})
};
}));
}, [activePageId]);
// Resolve which selected or hovered node should rotate when Space is pressed.
const getSpaceRotationTarget = useCallback(() => {
if (spaceRotateNodeIdRef.current) return spaceRotateNodeIdRef.current;
const selectedSpaceNode = selectedNode;
if (!selectedSpaceNode) return null;
if (selectedSpaceNode.type !== 'rotatableNode' && selectedSpaceNode.type !== 'portNode' && selectedSpaceNode.type !== 'anchorNode') return null;
return selectedSpaceNode.id;
}, [selectedNode]);
// Remember the node under the pointer so Space rotation can target it.
const onNodeMouseDown = useCallback((event, node) => {
if (event.button !== 0) return;
if (node.type !== 'rotatableNode' && node.type !== 'portNode' && node.type !== 'anchorNode') return;
spaceRotateNodeIdRef.current = node.id;
}, []);
// Clear the temporary Space-rotation target when the mouse is released.
const clearSpaceRotateNode = useCallback(() => {
spaceRotateNodeIdRef.current = null;
}, []);
// Apply route setting edits and reject same-type route crossings.
const handleUpdateEdgeRoute = useCallback((edgeIds, routeUpdate) => {
if (!activePageId) return;
const targetEdgeIds = new Set(Array.isArray(edgeIds) ? edgeIds : [edgeIds]);
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
const nodeMap = Object.fromEntries(p.nodes.map(node => [node.id, node]));
let rejected = false;
const nextEdges = p.edges.map(edge => {
if (!targetEdgeIds.has(edge.id)) return edge;
const currentRoute = createRouteSettings(technologyManifest, edge.data?.route);
const route = createRouteSettings(
technologyManifest,
typeof routeUpdate === 'function' ? routeUpdate(currentRoute, edge) : routeUpdate
);
const view = routeStyleForSettings(route, edge.selected);
const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2;
const candidate = { ...edge, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } };
const conflict = findSameTypeRouteCrossing(candidate, p.edges, nodeMap, technologyManifest);
if (conflict) {
const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target;
addLog(`Route update rejected: ${route.xsection} route crosses ${source} to ${target}.`);
rejected = true;
return edge;
}
return candidate;
});
return rejected ? p : { ...p, edges: nextEdges };
}));
}, [activePageId, technologyManifest, addLog]);
// Copy selected nodes into the local editor clipboard.
const handleCopy = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected);
if (selectedNodes.length > 0) {
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
}
}, [activePage]);
// Copy and remove selected nodes while releasing their reserved display-name indexes.
const handleCut = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected);
if (selectedNodes.length > 0) {
setClipboard({ nodes: JSON.parse(JSON.stringify(selectedNodes)) });
releaseComponentDisplayNames(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]);
// Paste copied nodes with new display names and offset positions.
const handlePaste = useCallback(() => {
if (!activePage || clipboard.nodes.length === 0) return;
const newNodes = clipboard.nodes.map(node => {
const copyCategory = node.data?.libraryCategory && node.data.libraryCategory !== 'basic'
? node.data.libraryCategory
: (node.data?.category && node.data.category !== 'basic' ? node.data.category : '');
const copiedName = generateComponentDisplayName(
copyCategory || node.data?.componentName || node.data?.elementType,
{ singularize: Boolean(copyCategory), abbreviate: Boolean(copyCategory) }
);
const copiedData = {
...node.data,
componentDisplayName: copiedName
};
if (node.type === 'portNode' || node.data?.elementType === 'port') {
copiedData.portName = copiedName;
copiedData.label = copiedName;
copiedData.elementType = 'port';
}
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,
data: copiedData
};
});
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]);
// Delete selected nodes and attached edges while freeing their name indexes.
const handleDelete = useCallback(() => {
if (!activePage) return;
const selectedNodes = activePage.nodes.filter(n => n.selected);
const selectedNodeIds = new Set(selectedNodes.map(n => n.id));
if (selectedNodeIds.size > 0) {
releaseComponentDisplayNames(selectedNodes);
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));
recordUserAction('instance.delete', {
project: currentProjectName,
cell: activePage.name,
detail: {
instances: selectedNodes.map(node => node.data?.componentDisplayName || node.id)
}
});
}
}, [activePage, currentProjectName, recordUserAction]);
useEffect(() => {
const handleKeyDown = (e) => {
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
return;
}
const cmdOrCtrl = e.ctrlKey || e.metaKey;
const spaceRotationTarget = getSpaceRotationTarget();
if ((e.code === 'Space' || e.key === ' ') && spaceRotationTarget) {
e.preventDefault();
rotateComponentByNinety(spaceRotationTarget);
} else 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);
window.addEventListener('mouseup', clearSpaceRotateNode);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mouseup', clearSpaceRotateNode);
};
}, [handleCopy, handleCut, handlePaste, handleDelete, rotateComponentByNinety, getSpaceRotationTarget, clearSpaceRotateNode]);
const componentIndexesByPrefixRef = useRef({});
const COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS = {
directional_coupler: 'DC',
directional_couplers: 'DC',
multimode_interferometer: 'MMI',
multimode_interferometers: 'MMI',
photodetector: 'PD',
photodetectors: 'PD',
waveguide: 'WG',
waveguides: 'WG',
transition: 'TRX',
transitions: 'TRX',
transistion: 'TRX',
transistions: 'TRX',
Mach_Zender_Modulator: 'MZM',
Mach_Zender_Modulators: 'MZM',
Mach_Zender_modulator: 'MZM',
Mach_Zender_modulators: 'MZM',
mach_zender_modulator: 'MZM',
mach_zender_modulators: 'MZM',
bending: 'BD',
bendings: 'BD',
edge_coupler: 'EC',
edge_couplers: 'EC',
grating_coupler: 'GC',
grating_couplers: 'GC',
termination: 'TERM',
terminations: 'TERM'
};
// Split a generated display name into prefix and numeric index.
function parseComponentDisplayName(displayName) {
const match = String(displayName || '').match(/^(.+)_(\d+)$/);
if (!match) return null;
const index = Number(match[2]);
if (!Number.isInteger(index) || index < 1) return null;
return { prefix: match[1], index };
}
// Mark a generated name index as used for its prefix.
function reserveComponentDisplayName(displayName) {
const parsed = parseComponentDisplayName(displayName);
if (!parsed) return;
const usedIndexes = componentIndexesByPrefixRef.current[parsed.prefix] || new Set();
usedIndexes.add(parsed.index);
componentIndexesByPrefixRef.current[parsed.prefix] = usedIndexes;
}
// Release a generated name index so future components can reuse it.
function releaseComponentDisplayName(displayName) {
const parsed = parseComponentDisplayName(displayName);
if (!parsed) return;
const usedIndexes = componentIndexesByPrefixRef.current[parsed.prefix];
if (!usedIndexes) return;
usedIndexes.delete(parsed.index);
if (usedIndexes.size === 0) {
delete componentIndexesByPrefixRef.current[parsed.prefix];
}
}
// Release generated name indexes for a group of deleted or cut nodes.
function releaseComponentDisplayNames(nodes = []) {
nodes.forEach(node => releaseComponentDisplayName(node?.data?.componentDisplayName));
}
// Rebuild the used-name index table from all currently loaded pages.
function reserveComponentDisplayNamesFromPages() {
pages.forEach(page => {
(page.nodes || []).forEach(node => reserveComponentDisplayName(node?.data?.componentDisplayName));
});
}
// Convert a component category into the saved display-name prefix or abbreviation.
const normalizeComponentDisplayNamePrefix = useCallback((prefixSource, options = {}) => {
const cleanedPrefix = String(prefixSource || 'element')
.trim()
.replace(/[\\/]+/g, '_')
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
if (!cleanedPrefix) return 'element';
const abbreviation = options.abbreviate
? COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[cleanedPrefix] || COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[cleanedPrefix.toLowerCase()]
: '';
if (abbreviation) return abbreviation;
if (!options.singularize) return cleanedPrefix;
const parts = cleanedPrefix.split('_');
const lastIndex = parts.length - 1;
const last = parts[lastIndex];
if (last.length > 3 && last.endsWith('ies')) {
parts[lastIndex] = `${last.slice(0, -3)}y`;
} else if (last.length > 1 && last.endsWith('s')) {
parts[lastIndex] = last.slice(0, -1);
}
const singularPrefix = parts.join('_') || 'element';
if (options.abbreviate) {
return COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[singularPrefix] || COMPONENT_CATEGORY_PREFIX_ABBREVIATIONS[singularPrefix.toLowerCase()] || singularPrefix;
}
return singularPrefix;
}, []);
// Create the next available prefix-specific component display name.
const generateComponentDisplayName = useCallback((prefixSource = 'element', options = {}) => {
const prefix = normalizeComponentDisplayNamePrefix(prefixSource, options);
reserveComponentDisplayNamesFromPages();
const usedIndexes = componentIndexesByPrefixRef.current[prefix] || new Set();
let nextIndex = 1;
while (usedIndexes.has(nextIndex)) nextIndex += 1;
const name = `${prefix}_${nextIndex}`;
usedIndexes.add(nextIndex);
componentIndexesByPrefixRef.current[prefix] = usedIndexes;
return name;
}, [normalizeComponentDisplayNamePrefix, pages]);
// Rename a component node and update the name-index reservation table.
const renameComponent = useCallback((nodeId, newComponentDisplayName) => {
if (!activePageId) return;
const oldDisplayName = activePage?.nodes.find(node => node.id === nodeId)?.data?.componentDisplayName;
if (oldDisplayName !== newComponentDisplayName) {
releaseComponentDisplayName(oldDisplayName);
reserveComponentDisplayName(newComponentDisplayName);
}
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, activePage]);
// Load the current project-scoped PDK/component library from the backend.
const fetchLibrary = useCallback(async () => {
try {
const res = await fetch(`/api/library?project=${encodeURIComponent(currentProjectName)}`);
const data = await res.json();
setLibrary(data);
} catch (err) {
console.error('Failed to fetch library', err);
}
}, [currentProjectName]);
useEffect(() => { fetchLibrary(); }, [fetchLibrary]);
// Flatten the library tree into component/category pairs.
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;
}, []);
// Restore PDK-selection options for components loaded from saved YAML.
const getAvailableComponentsForLoadedComponent = useCallback((componentName) => {
if (!library || !componentName || isForgeComponent(componentName) || isBasicComponent(componentName)) return undefined;
const componentEntries = collectComponentNames(library);
const matchedComponent = componentEntries.find(component => component.name === componentName);
if (!matchedComponent) return undefined;
const sameCategoryComponents = componentEntries
.filter(component => component.category === matchedComponent.category)
.map(component => component.name)
.filter(Boolean);
return Array.from(new Set([FORGE_COMPONENT_LABEL, ...sameCategoryComponents, componentName]));
}, [library, collectComponentNames]);
// Recreate saved port and anchor nodes when a project YAML document is loaded.
const buildElementNodesFromYaml = useCallback((doc, usesGdsYUp, nodeNameMap = {}) => {
const nodes = [];
Object.entries(doc.elements || {}).forEach(([elementName, element]) => {
if (!element || typeof element !== 'object') return;
const elementType = element.type === 'anchor' ? 'anchor' : (element.type === 'port' ? 'port' : '');
if (!elementType) return;
const portNumberValue = Math.floor(Number(element.pin_number ?? element.pinNumber ?? element.port_number ?? element.portNumber ?? 1));
const portNumber = Number.isFinite(portNumberValue) ? Math.max(1, portNumberValue) : 1;
const pitchValue = Number(element.pitch ?? DEFAULT_ELEMENT_PITCH);
const pitch = Number.isFinite(pitchValue) ? Math.max(0, pitchValue) : DEFAULT_ELEMENT_PITCH;
const widthValue = Number(element.width ?? 0.5);
const width = Number.isFinite(widthValue) ? widthValue : 0.5;
const xValue = Number(element.x || 0);
const yValue = Number(element.y || 0);
const x = Number.isFinite(xValue) ? xValue : 0;
const y = Number.isFinite(yValue) ? yValue : 0;
const baseData = {
label: elementName,
componentDisplayName: elementName,
elementType,
width,
portNumber,
pitch,
layer: element.layer || 'WG_CORE',
description: element.description || '',
pinNames: Object.fromEntries((element.pins || []).map(pin => [pin.role, pin.name]).filter(([role, name]) => role && name)),
boxSize: buildElementBoxSize({ elementType, portNumber, pitch })
};
const nodeId = `element-${elementName}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
nodeNameMap[elementName] = nodeId;
if (elementType === 'port') {
const angle = Number(element.angle ?? element.a ?? 0);
nodes.push({
id: nodeId,
type: 'portNode',
position: {
x,
y: usesGdsYUp ? layoutToCanvasY(y) : y,
},
data: {
...baseData,
portName: elementName,
angle: Number.isFinite(angle) ? angle : 0,
ports: buildElementPorts('port', { angle: Number.isFinite(angle) ? angle : 0, width, portNumber, pitch })
},
});
return;
}
const rotation = Number(element.angle ?? element.rotation ?? 0);
nodes.push({
id: nodeId,
type: 'anchorNode',
position: {
x,
y: usesGdsYUp ? layoutToCanvasY(y) : y,
},
data: {
...baseData,
componentName: 'Anchor',
category: null,
rotation: Number.isFinite(rotation) ? rotation : 0,
hideIcon: true,
ports: buildElementPorts('anchor', { portNumber, pitch, width })
},
});
});
return nodes;
}, []);
const resolveLoadedPinHandle = useCallback((node, pinName) => {
if (!node || !node.data?.elementType) return pinName;
const elementType = node.data.elementType === 'anchor' ? 'anchor' : 'port';
const ports = buildElementPorts(elementType, node.data);
const matched = Object.keys(ports || {}).find(portName => getElementPinName(node, portName) === pinName);
return matched || pinName;
}, []);
useEffect(() => {
const input = document.getElementById('open-yaml-input');
if (!input) return;
const handleFile = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const doc = jsyaml.load(text);
const usesGdsYUp = doc.coordinate_system === 'gds_y_up';
if (!doc.instances && !doc.elements) {
alert('no instances or elements found');
return;
}
const newNodes = [];
const newEdges = [];
const nodeNameMap = {};
const isProject = doc.type === 'project';
if (!isProject) {
nodeNameMap.port = 'page-port';
}
for (const [instName, inst] of Object.entries(doc.instances || {})) {
const compPath = inst.component || '';
const compName = compPath.split('/').pop();
const instIsForge = isForgeComponent(compPath) || isForgeComponent(compName);
const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName);
const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName);
const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null;
const loadedAvailableComponents = getAvailableComponentsForLoadedComponent(displayCompName);
let category = '';
if (!isProject && displayCompName && library && !instIsForge) {
const walk = (obj) => {
if (obj?.__type__ === 'component' && obj.__name__ === displayCompName) {
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: usesGdsYUp ? layoutToCanvasY(inst.y) : (parseFloat(inst.y) || 0),
},
data: {
label: isProject ? instName : displayCompName,
componentName: isProject ? instName : displayCompName,
category: isProject ? '' : category,
rotation: parseFloat(inst.rotation) || 0,
flip: toBooleanFlag(inst.flip ?? inst.mirror),
flop: toBooleanFlag(inst.flop),
componentDisplayName: instName,
type: isProject ? 'composite' : undefined,
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents,
ports: instIsBasic ? (basicMetadata?.pins || basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
},
});
}
newNodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap));
if (!isProject) {
const links = doc.bundles?.output_bus?.links;
if (links) {
const linkArray = Array.isArray(links) ? links : [links];
linkArray.forEach(link => {
const route = createRouteSettings(technologyManifest, link);
const routePoints = normalizeRoutePoints(link.points, doc.coordinate_system === 'gds_y_up');
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) {
const sourceNode = newNodes.find(node => node.id === sourceId);
const targetNode = newNodes.find(node => node.id === targetId);
const sourceHandle = resolveLoadedPinHandle(sourceNode, fromPort);
const targetHandle = resolveLoadedPinHandle(targetNode, toPort);
const view = routeStyleForSettings(route, false);
newEdges.push({
id: `edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle}`,
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
type: view.type,
style: view.style,
data: { route, points: routePoints },
});
}
} else if (routePoints.length >= 2) {
const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
newEdges.push(makeFreeRouteEdge(edgeId, routePoints, route));
}
});
}
}
const newPageId = Date.now().toString() + Math.random().toString(36).substr(2, 5);
const newPageName = file.name.replace(/\.(yaml|yml)$/i, '');
const importedPin = Array.isArray(doc.pins) && doc.pins[0]
? { x: Number(doc.pins[0].x || 0), y: usesGdsYUp ? layoutToCanvasY(doc.pins[0].y) : Number(doc.pins[0].y || 0), a: Number(doc.pins[0].angle ?? doc.pins[0].a ?? 0), width: Number(doc.pins[0].width || 0.5) }
: { x: 50, y: 150, a: 0, width: 0.5 };
const newPage = {
id: newPageId,
name: newPageName,
type: isProject ? 'project' : 'composite',
canvasSize: normalizeCanvasSize(doc.canvas_size || doc.canvasSize),
nodes: isProject ? newNodes : [
{
id: 'page-port',
type: 'portNode',
position: { x: importedPin.x, y: importedPin.y },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: importedPin.a, width: importedPin.width || 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
},
...newNodes,
],
edges: newEdges,
port: importedPin,
};
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 (isForgeComponent(compPath) || isForgeComponent(compName)) continue;
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, technologyManifest, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
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) return;
initializedRef.current = true;
const makeProjectPage = () => ({
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
name: currentProjectName,
type: 'project',
nodes: [],
edges: [],
canvasSize: DEFAULT_CANVAS_SIZE,
port: { x: 0, y: 0, a: 0 }
});
const findCategory = (compName) => {
let category = '';
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);
return category;
};
const pageFromYaml = (cellName, content, manifest, knownCompositeNames = new Set()) => {
const doc = jsyaml.load(content) || {};
const usesGdsYUp = doc.coordinate_system === 'gds_y_up';
const firstPin = Array.isArray(doc.pins) ? doc.pins[0] : null;
const pagePort = firstPin
? { x: Number(firstPin.x || 0), y: usesGdsYUp ? layoutToCanvasY(firstPin.y) : Number(firstPin.y || 0), a: Number(firstPin.angle ?? firstPin.a ?? 0), width: Number(firstPin.width || 0.5) }
: { x: 50, y: 150, a: 0, width: 0.5 };
const nodeNameMap = {};
const nodes = [
{
id: 'page-port',
type: 'portNode',
position: { x: pagePort.x, y: pagePort.y },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: pagePort.a, width: pagePort.width || 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
}
];
const edges = [];
nodeNameMap.port = 'page-port';
Object.entries(doc.instances || {}).forEach(([instName, inst]) => {
const compPath = inst.component || '';
const compName = compPath.split('/').pop();
const instIsForge = isForgeComponent(compPath) || isForgeComponent(compName);
const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName);
const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName);
const instIsComposite = knownCompositeNames.has(compName);
const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null;
const loadedAvailableComponents = getAvailableComponentsForLoadedComponent(displayCompName);
const nodeId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
nodeNameMap[instName] = nodeId;
nodes.push({
id: nodeId,
type: 'rotatableNode',
position: {
x: parseFloat(inst.x) || 0,
y: usesGdsYUp ? layoutToCanvasY(inst.y) : (parseFloat(inst.y) || 0),
},
data: {
label: instIsComposite ? instName : displayCompName,
componentName: instIsComposite ? compName : displayCompName,
category: instIsComposite || instIsForge ? '' : findCategory(displayCompName),
rotation: parseFloat(inst.rotation) || 0,
flip: toBooleanFlag(inst.flip ?? inst.mirror),
flop: toBooleanFlag(inst.flop),
componentDisplayName: instName,
type: instIsComposite ? 'composite' : undefined,
availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents,
ports: instIsBasic ? (basicMetadata?.pins || basicMetadata?.ports || {}) : (instIsForge ? {} : undefined),
boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined,
forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined,
basicArguments: instIsBasic ? createBasicSettings(displayCompName, inst.settings) : undefined,
},
});
});
nodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap));
const links = doc.bundles?.output_bus?.links;
if (links) {
const linkArray = Array.isArray(links) ? links : [links];
linkArray.forEach(link => {
const route = createRouteSettings(manifest, link);
const routePoints = normalizeRoutePoints(link.points, usesGdsYUp);
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) return;
const sourceNode = nodes.find(node => node.id === sourceId);
const targetNode = nodes.find(node => node.id === targetId);
const sourceHandle = resolveLoadedPinHandle(sourceNode, fromPort);
const targetHandle = resolveLoadedPinHandle(targetNode, toPort);
const view = routeStyleForSettings(route, false);
edges.push({
id: `edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle}`,
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
type: view.type,
style: view.style,
data: { route, points: routePoints },
});
} else if (routePoints.length >= 2) {
const edgeId = link.id || `route-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
edges.push(makeFreeRouteEdge(edgeId, routePoints, route));
}
});
}
return {
id: `cell-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
name: doc.name || cellName,
type: doc.type === 'project' ? 'project' : 'composite',
canvasSize: normalizeCanvasSize(doc.canvas_size || doc.canvasSize),
nodes,
edges,
port: pagePort
};
};
const loadProject = async () => {
const projectPage = makeProjectPage();
try {
const response = await fetch(`/api/projects/${encodeURIComponent(currentProjectName)}`);
if (!response.ok) {
setProjectTechnology('');
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
setPages([projectPage]);
setActivePageId(projectPage.id);
setProjectCompositeMap({ [currentProjectName]: [] });
return;
}
const data = await response.json();
const technology = data.technology || '';
setProjectTechnology(technology);
const manifest = await loadTechnologyManifest(technology);
const knownCompositeNames = new Set((data.cells || []).map(cell => cell.name).filter(name => name !== currentProjectName));
const parsedCellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content, manifest, knownCompositeNames));
const compositeBoxSizes = new Map(parsedCellPages
.filter(page => page.type === 'composite')
.map(page => [page.name, calculateCompositeBoxSize(page)]));
const cellPages = parsedCellPages.map(page => ({
...page,
nodes: page.nodes.map(node => {
const boxSize = compositeBoxSizes.get(node.data?.componentName);
return boxSize ? { ...node, data: { ...node.data, boxSize } } : node;
})
}));
const loadedProjectPage = cellPages.find(page => page.type === 'project' && page.name === currentProjectName);
const nonProjectPages = cellPages.filter(page => page !== loadedProjectPage);
const resolvedProjectPage = loadedProjectPage || projectPage;
setPages([resolvedProjectPage, ...nonProjectPages]);
setActivePageId(resolvedProjectPage.id);
setProjectCompositeMap({ [currentProjectName]: nonProjectPages.map(page => page.name) });
setStandaloneComposites([]);
const nextTrees = {};
nonProjectPages.forEach(page => {
nextTrees[page.name] = buildCompInstanceTree(page.nodes.filter(n => n.id !== 'page-port' && n.data?.componentName), library);
});
setCompositeTrees(nextTrees);
} catch (error) {
setProjectTechnology('');
setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST);
setPages([projectPage]);
setActivePageId(projectPage.id);
setProjectCompositeMap({ [currentProjectName]: [] });
}
};
loadProject();
}, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent, resolveLoadedPinHandle]);
useEffect(() => {
if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) {
if (activePage.viewport) {
window.requestAnimationFrame(() => {
reactFlowInstance.setViewport(activePage.viewport, { duration: 0 });
});
} else {
reactFlowInstance.fitBounds(
{ x: 0, y: 0, width: activeCanvasSize.width, height: activeCanvasSize.height },
{ padding: 0.12, duration: 0 }
);
}
}
}, [activePage?.id, activePage?.viewport, activeCanvasSize.width, activeCanvasSize.height, reactFlowInstance]);
useEffect(() => {
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
}, [activePageId]);
useEffect(() => {
if (!library) return;
syncAllCompositeTrees(pages, library);
}, [pages, library, syncAllCompositeTrees]);
useEffect(() => {
const compositePages = new Map(pages.filter(page => page.type === 'composite').map(page => [page.name, page]));
const compositeUpdates = new Map();
pages.forEach(page => {
page.nodes.forEach(node => {
const compPage = compositePages.get(node.data?.componentName);
if (!compPage) return;
const nextPorts = buildPageComponentPorts(compPage.port, compPage.nodes);
const nextBoxSize = calculateCompositeBoxSize(compPage);
const portsChanged = JSON.stringify(node.data?.ports || {}) !== JSON.stringify(nextPorts);
const boxSizeChanged = JSON.stringify(node.data?.boxSize || {}) !== JSON.stringify(nextBoxSize);
if (portsChanged || boxSizeChanged) {
compositeUpdates.set(node.id, { ports: nextPorts, boxSize: nextBoxSize });
}
});
});
if (compositeUpdates.size === 0) return;
setPages(prev => prev.map(page => ({
...page,
nodes: page.nodes.map(node => (
compositeUpdates.has(node.id)
? { ...node, data: { ...node.data, ...compositeUpdates.get(node.id) } }
: node
))
})));
}, [pages]);
useEffect(() => {
const missingPortNodes = [];
pages.forEach(page => {
page.nodes.forEach(node => {
const componentName = node.data?.componentName;
if (node.data?.elementType || !componentName || isForgeComponent(componentName) || node.data?.type === 'composite' || compositePageNameSet.has(componentName)) return;
if (isBasicComponent(componentName)) {
if (node.data?.ports && node.data?.boxSize) return;
const metadata = getBasicComponentMetadata(componentName, node.data?.basicArguments);
if (metadata) {
missingPortNodes.push({ pageId: page.id, nodeId: node.id, componentName, metadata });
}
return;
}
if (node.data?.ports && node.data?.boxSize) return;
missingPortNodes.push({ pageId: page.id, nodeId: node.id, componentName });
});
});
if (missingPortNodes.length === 0) return;
let cancelled = false;
Promise.all(missingPortNodes.map(async item => ({
...item,
metadata: item.metadata || await loadComponentMetadata(item.componentName)
}))).then(results => {
if (cancelled) return;
const metadataByNode = new Map(results.filter(item => item.metadata).map(item => [item.nodeId, item.metadata]));
if (metadataByNode.size === 0) return;
setPages(prev => prev.map(page => ({
...page,
nodes: page.nodes.map(node => {
const metadata = metadataByNode.get(node.id);
if (!metadata) return node;
const boxSize = normalizeBoxSize(metadata);
return {
...node,
position: clampPositionToCanvas(node.position, page.canvasSize || DEFAULT_CANVAS_SIZE, boxSize),
data: {
...node.data,
ports: metadata.pins || metadata.ports || {},
boxSize,
foundry: metadata.foundry || '',
process: metadata.process || ''
}
};
})
})));
});
return () => {
cancelled = true;
};
}, [pages, loadComponentMetadata]);
const openTabs = useMemo(() => pages.filter(page => !page.isClosed), [pages]);
// Open a page and select a named instance from the project tree.
const selectInstanceInPage = useCallback((pageName, instanceName) => {
if (!pageName || !instanceName) return;
const targetPage = pages.find(p => p.name === pageName);
if (!targetPage) return;
setActivePageId(targetPage.id);
setPages(prev => prev.map(page => {
if (page.id !== targetPage.id) {
return {
...page,
nodes: page.nodes.map(node => ({ ...node, selected: false }))
};
}
return {
...page,
nodes: page.nodes.map(node => ({
...node,
selected: node.id !== 'page-port' && (node.data?.componentDisplayName === instanceName || node.id === instanceName)
}))
};
}));
}, [pages]);
// Open an existing project page by name.
const openProject = useCallback((name) => {
setPages(prev => {
const existing = prev.find(p => p.name === name && p.type === 'project');
if (existing) {
setActivePageId(existing.id);
return prev.map(p => p.id === existing.id ? { ...p, isClosed: false } : p);
}
const newProjectPage = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: name,
type: 'project',
nodes: [],
edges: [],
canvasSize: DEFAULT_CANVAS_SIZE,
port: { x: 0, y: 0, a: 0 }
};
setActivePageId(newProjectPage.id);
setProjectCompositeMap(prevMap => ({ ...prevMap, [name]: prevMap[name] || [] }));
return [...prev, newProjectPage];
});
}, []);
// Open a canvas tab and make it active.
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.map(p => p.id === existing.id ? { ...p, isClosed: false } : p);
}
const newComposite = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: name,
type: 'composite',
isClosed: false,
canvasSize: DEFAULT_CANVAS_SIZE,
nodes: [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 150 },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: 0, width: 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
}
],
edges: [],
port: { x: 50, y: 150, a: 0 }
};
setActivePageId(newComposite.id);
return [...prev, newComposite];
});
}, [projectCompositeMap, standaloneComposites]);
// Rename a canvas cell and synchronize backend files when needed.
const renameCanvas = useCallback((pageId, requestedName) => {
const normalizedName = requestedName.trim().replace(/[^A-Za-z0-9_.-]+/g, '_').replace(/^[._]+|[._]+$/g, '');
if (!normalizedName) return;
const pageToRename = pages.find(p => p.id === pageId);
if (!pageToRename || pageToRename.type === 'project' || pageToRename.name === normalizedName) return;
const nameTaken = pages.some(p => p.id !== pageId && p.name === normalizedName);
if (nameTaken) {
addLog(`Canvas rename failed: "${normalizedName}" already exists.`);
return;
}
const oldName = pageToRename.name;
setPages(prev => prev.map(p => {
const renamedPage = p.id === pageId ? { ...p, name: normalizedName } : p;
return {
...renamedPage,
nodes: renamedPage.nodes.map(node => {
if (node.data?.type === 'composite' && node.data.componentName === oldName) {
return {
...node,
data: {
...node.data,
componentName: normalizedName,
componentDisplayName: node.data.componentDisplayName === oldName ? normalizedName : node.data.componentDisplayName,
label: normalizedName
}
};
}
return node;
})
};
}));
setProjectCompositeMap(prev => {
const next = {};
Object.entries(prev).forEach(([project, cells]) => {
next[project] = cells.map(name => name === oldName ? normalizedName : name);
});
return next;
});
setStandaloneComposites(prev => prev.map(name => name === oldName ? normalizedName : name));
setCompositeTrees(prev => {
const next = { ...prev };
if (Object.prototype.hasOwnProperty.call(next, oldName)) {
next[normalizedName] = next[oldName];
delete next[oldName];
}
return next;
});
fetch(`/api/projects/${encodeURIComponent(currentProjectName)}/cells/${encodeURIComponent(oldName)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: normalizedName })
}).then(response => {
if (response.ok) addLog(`Renamed canvas "${oldName}" to "${normalizedName}".`);
}).catch(() => addLog(`Renamed canvas locally; saved file rename did not complete.`));
}, [pages, currentProjectName, addLog]);
// Create a new composite canvas with a unique cell name.
const createCell = useCallback(() => {
const existingNames = new Set(pages.filter(p => p.type === 'composite').map(p => p.name));
let index = existingNames.size + 1;
let cellName = `canvas_${index}`;
while (existingNames.has(cellName)) {
index += 1;
cellName = `canvas_${index}`;
}
const newCell = {
id: Date.now().toString() + Math.random().toString(36).substr(2, 5),
name: cellName,
type: 'composite',
isClosed: false,
canvasSize: DEFAULT_CANVAS_SIZE,
nodes: [
{
id: 'page-port',
type: 'portNode',
position: { x: 50, y: 150 },
data: { label: 'port', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: 0, width: 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
}
],
edges: [],
port: { x: 50, y: 150, a: 0 }
};
setPages(prev => [...prev, newCell]);
setActivePageId(newCell.id);
setProjectCompositeMap(prev => ({
...prev,
[currentProjectName]: [...(prev[currentProjectName] || []), cellName]
}));
recordUserAction('canvas.create', { project: currentProjectName, cell: cellName });
}, [pages, currentProjectName, recordUserAction]);
// Close a canvas tab without deleting its saved content.
const closePage = useCallback((pageId) => {
setPages(prev => {
const closed = prev.map(p => p.id === pageId ? { ...p, isClosed: true } : p);
if (activePageId === pageId) {
const idx = prev.findIndex(p => p.id === pageId);
const openPages = closed.filter(p => !p.isClosed);
const nextActive = openPages[Math.min(idx, openPages.length - 1)] || openPages[openPages.length - 1] || null;
setActivePageId(nextActive ? nextActive.id : null);
}
return closed;
});
}, [activePageId]);
// Delete a saved canvas cell and update project/composite references.
const deleteCanvas = useCallback((cellName) => {
if (!cellName) return;
if (!window.confirm(`Delete canvas "${cellName}" from this project?`)) return;
const pageToDelete = pages.find(p => p.type === 'composite' && p.name === cellName);
setPages(prev => {
const withoutCell = prev
.filter(p => !(p.type === 'composite' && p.name === cellName))
.map(p => {
if (p.type !== 'project') return p;
return {
...p,
nodes: p.nodes.filter(node => node.data?.componentName !== cellName),
edges: p.edges.filter(edge => {
const removedNodeIds = new Set(p.nodes.filter(node => node.data?.componentName === cellName).map(node => node.id));
return !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target);
})
};
});
if (activePageId === pageToDelete?.id) {
const nextActive = withoutCell.find(p => !p.isClosed) || withoutCell[0] || null;
setActivePageId(nextActive ? nextActive.id : null);
}
return withoutCell;
});
setProjectCompositeMap(prev => {
const next = {};
Object.entries(prev).forEach(([project, cells]) => {
next[project] = cells.filter(name => name !== cellName);
});
return next;
});
setStandaloneComposites(prev => prev.filter(name => name !== cellName));
setCompositeTrees(prev => {
const next = { ...prev };
delete next[cellName];
return next;
});
fetch(`/api/projects/${encodeURIComponent(currentProjectName)}/cells/${encodeURIComponent(cellName)}`, {
method: 'DELETE'
}).then(response => {
if (response.ok) addLog(`Deleted canvas "${cellName}".`);
else addLog(`Canvas "${cellName}" was removed locally, but file delete failed.`);
}).catch(() => addLog(`Canvas "${cellName}" was removed locally, but file delete failed.`));
}, [pages, activePageId, currentProjectName, addLog]);
// Switch the active editor tab.
const switchPage = useCallback((pageId) => {
setActivePageId(pageId);
}, []);
// Update legacy page-level port settings for a canvas.
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', componentDisplayName: 'port', portName: 'port', elementType: 'port', angle: newPort.a, width: newPort.width || 0.5, layer: 'WG_CORE', description: '' },
draggable: true,
selectable: true,
deletable: false,
});
}
return { ...p, port: newPort, nodes };
}));
}, []);
// Allow library and project-tree entries to be dropped onto the canvas.
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Create component, port, anchor, or composite nodes from dropped library entries.
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 compositeBoxSize = normalizeBoxSize({ box_size: parsedData.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const position = clampPositionToCanvas(
reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
activePage?.canvasSize || activeCanvasSize,
compositeBoxSize
);
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,
ports: parsedData.pins || parsedData.ports || {},
boxSize: compositeBoxSize
}
};
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 => {
const currentList = prev[projectName] || [];
const instanceName = newNode.data.componentDisplayName || parsedData.name;
if (currentList.includes(instanceName)) return prev;
return {
...prev,
[projectName]: [...currentList, instanceName]
};
});
}
recordUserAction('instance.create', {
project: currentProjectName,
cell: activePage?.name,
detail: { component: parsedData.name, instance: newNode.data.componentDisplayName, type: 'standaloneComposite' }
});
return;
}
if (parsedData.type === 'composite') {
if (!activePageId) return;
if (activePage?.type === 'composite' && parsedData.name === activePage.name) {
addLog(`Skipped self-reference: "${parsedData.name}" cannot be placed inside itself.`);
return;
}
const compositeBoxSize = normalizeBoxSize({ box_size: parsedData.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
const position = clampPositionToCanvas(
reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
activePage?.canvasSize || activeCanvasSize,
compositeBoxSize
);
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,
ports: parsedData.pins || parsedData.ports || {},
boxSize: compositeBoxSize
}
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
if (activePage?.type === 'project') {
setProjectCompositeMap(prev => {
const currentList = prev[activePage.name] || [];
const instanceName = newNode.data.componentDisplayName || parsedData.name;
if (currentList.includes(instanceName)) return prev;
return {
...prev,
[activePage.name]: [...currentList, instanceName]
};
});
}
recordUserAction('instance.create', {
project: currentProjectName,
cell: activePage?.name,
detail: { component: parsedData.name, instance: newNode.data.componentDisplayName, type: 'composite' }
});
return;
}
if (!activePageId) {
alert('Please open a composite page first.');
return;
}
const position = clampPositionToCanvas(
reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }),
activePage?.canvasSize || activeCanvasSize,
parsedData.type === 'element'
? buildElementBoxSize({ elementType: parsedData.elementType === 'anchor' ? 'anchor' : 'port', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH })
: DEFAULT_COMPONENT_BOX_SIZE
);
if (parsedData.type === 'basic') {
const componentName = parsedData.componentName || parsedData.name;
const basicArguments = createBasicSettings(componentName, parsedData.settings);
const metadata = getBasicComponentMetadata(componentName, basicArguments);
const componentDisplayName = generateComponentDisplayName(componentName);
const newNode = {
id: Date.now().toString(),
type: 'rotatableNode',
position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, normalizeBoxSize(metadata)),
data: {
label: componentName,
componentName,
componentDisplayName,
libraryCategory: 'basic',
category: 'basic',
rotation: 0,
ports: metadata?.pins || metadata?.ports || {},
boxSize: metadata ? normalizeBoxSize(metadata) : DEFAULT_COMPONENT_BOX_SIZE,
basicArguments
},
};
setPages(prev => prev.map(p => p.id === activePageId ? { ...p, nodes: p.nodes.concat(newNode) } : p));
recordUserAction('component.create', {
project: currentProjectName,
cell: activePage?.name,
detail: { component: componentName, instance: componentDisplayName, category: 'basic' }
});
return;
}
if (parsedData.type === 'element') {
const elementName = generateComponentDisplayName(parsedData.elementType === 'anchor' ? 'anchor' : 'port');
const isPort = parsedData.elementType === 'port';
const newNode = isPort
? {
id: Date.now().toString(),
type: 'portNode',
position,
data: {
label: elementName,
componentDisplayName: elementName,
portName: elementName,
elementType: 'port',
angle: 0,
width: 0.5,
portNumber: 1,
pitch: DEFAULT_ELEMENT_PITCH,
layer: 'WG_CORE',
description: '',
boxSize: buildElementBoxSize({ elementType: 'port', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH }),
ports: buildElementPorts('port', { angle: 0, width: 0.5, portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH })
},
}
: {
id: Date.now().toString(),
type: 'anchorNode',
position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, buildElementBoxSize({ elementType: 'anchor', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH })),
data: {
label: elementName,
componentName: 'Anchor',
componentDisplayName: elementName,
elementType: 'anchor',
category: null,
rotation: 0,
width: 0.5,
portNumber: 1,
pitch: DEFAULT_ELEMENT_PITCH,
layer: 'WG_CORE',
description: '',
hideIcon: true,
boxSize: buildElementBoxSize({ elementType: 'anchor', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH }),
ports: buildElementPorts('anchor', { portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH, width: 0.5 })
},
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
recordUserAction('element.create', {
project: currentProjectName,
cell: activePage?.name,
detail: { element: parsedData.elementType, name: elementName }
});
return;
}
if (parsedData.type === 'category') {
const availableComponents = Array.isArray(parsedData.components)
? parsedData.components
.map(component => typeof component === 'string' ? component : component?.name)
.filter(Boolean)
: [];
const selectedComponent = chooseCategoryComponent(parsedData.name, availableComponents, parsedData.category);
if (!selectedComponent) {
addLog('Skipped category placement: no components were found in this library category.');
return;
}
const selectedIsForge = isForgeComponent(selectedComponent);
const componentDisplayName = generateComponentDisplayName(parsedData.category || selectedComponent, {
singularize: Boolean(parsedData.category),
abbreviate: Boolean(parsedData.category)
});
const newNode = {
id: Date.now().toString(),
type: 'rotatableNode',
position,
data: {
label: selectedComponent,
componentName: selectedComponent,
availableComponents: availableComponents.length > 0 ? availableComponents : [selectedComponent],
libraryCategory: parsedData.category || 'default',
category: parsedData.category || 'default',
rotation: 0,
componentDisplayName: componentDisplayName,
ports: selectedIsForge ? {} : undefined,
forgeArguments: selectedIsForge ? createForgeArguments() : undefined
},
};
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return { ...p, nodes: p.nodes.concat(newNode) };
}));
recordUserAction('instance.create', {
project: currentProjectName,
cell: activePage?.name,
detail: { component: selectedComponent, instance: componentDisplayName, category: parsedData.category }
});
return;
}
const componentDisplayName = generateComponentDisplayName(parsedData.category || parsedData.name, {
singularize: Boolean(parsedData.category),
abbreviate: Boolean(parsedData.category)
});
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) };
}));
recordUserAction('instance.create', {
project: currentProjectName,
cell: activePage?.name,
detail: { component: parsedData.name, instance: componentDisplayName, category: parsedData.category }
});
}, [activePageId, activePage, activeCanvasSize, openPage, reactFlowInstance, generateComponentDisplayName, syncCompositePlacement, recordUserAction, currentProjectName, toBooleanFlag]);
// Expand all library tree nodes.
const expandAll = useCallback(() => {
if (treeContainerRef.current) {
treeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
}
}, []);
// Collapse all library tree nodes.
const collapseAll = useCallback(() => setTreeKey(k => k + 1), []);
// Toggle the expanded state of the component library panel.
const handleToggle = useCallback(() => {
if (expanded) { collapseAll(); setExpanded(false); }
else { expandAll(); setExpanded(true); }
}, [expanded, expandAll, collapseAll]);
// Expand all project tree nodes.
const expandProjectAll = useCallback(() => {
if (projectTreeContainerRef.current) {
projectTreeContainerRef.current.querySelectorAll('details').forEach(d => d.open = true);
}
}, []);
// Collapse all project tree nodes.
const collapseProjectAll = useCallback(() => setProjectTreeKey(k => k + 1), []);
// Toggle the expanded state of the project tree panel.
const handleProjectToggle = useCallback(() => {
if (projectExpanded) { collapseProjectAll(); setProjectExpanded(false); }
else { expandProjectAll(); setProjectExpanded(true); }
}, [projectExpanded, expandProjectAll, collapseProjectAll]);
// Begin side-panel resize tracking.
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]);
// Toggle snap-to-grid movement in the editor.
const toggleGridSnap = useCallback(() => {
setGridSnap(prev => !prev);
}, []);
// Toggle the instance name/PDK labels shown above canvas components.
const toggleCanvasText = useCallback(() => {
setCanvasTextVisible(prev => !prev);
}, []);
// Toggle the measurement ruler and clear partial measurements.
const toggleRulerMode = useCallback(() => {
setRulerMode(prev => {
const next = !prev;
if (!next) {
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
} else {
setOriginPickMode(false);
}
return next;
});
}, []);
// Convert a pane click or pointer event into canvas coordinates.
const eventToCanvasPoint = useCallback((event) => {
if (!reactFlowInstance || !event) return null;
const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
return {
x: Number(Math.min(activeCanvasSize.width, Math.max(0, rawPoint.x)).toFixed(3)),
y: Number(Math.min(activeCanvasSize.height, Math.max(0, rawPoint.y)).toFixed(3))
};
}, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]);
const updateMouseCanvasPoint = useCallback((event) => {
if (!activePage || activePage.type === 'layoutPreview') return null;
const nextPoint = eventToCanvasPoint(event);
if (!nextPoint) return null;
setMouseCanvasPoint(nextPoint);
const rect = canvasViewportRef.current?.getBoundingClientRect();
if (rect) {
setMouseScreenPoint({
x: event.clientX - rect.left,
y: event.clientY - rect.top
});
}
return nextPoint;
}, [activePage, eventToCanvasPoint]);
const toggleOriginPickMode = useCallback(() => {
setOriginPickMode(prev => {
const next = !prev;
if (next) {
setRulerMode(false);
setRulerStartPoint(null);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
}
return next;
});
}, []);
// Set ruler start/end points from canvas clicks.
const handleRulerPaneClick = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
event.preventDefault();
event.stopPropagation();
const nextPoint = eventToCanvasPoint(event);
if (!nextPoint) return;
if (!rulerStartPoint || rulerEndPoint) {
setRulerStartPoint(nextPoint);
setRulerEndPoint(null);
setRulerPreviewPoint(null);
addLog(`Ruler start: (${nextPoint.x.toFixed(3)}, ${nextPoint.y.toFixed(3)}) um`);
return;
}
const measurement = createRulerMeasurement(rulerStartPoint, nextPoint);
setRulerEndPoint(nextPoint);
setRulerPreviewPoint(null);
if (measurement) {
addLog(`Ruler distance: ${measurement.label}`);
}
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToCanvasPoint, addLog]);
// Update the live ruler preview point while measuring.
const handleRulerMouseMove = useCallback((event) => {
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
if (!rulerStartPoint || rulerEndPoint) return;
const nextPoint = eventToCanvasPoint(event);
if (nextPoint) setRulerPreviewPoint(nextPoint);
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToCanvasPoint]);
const chooseCanvasOriginFromEvent = useCallback((event) => {
if (!originPickMode || !activePage || activePage.type === 'layoutPreview') return false;
const nextPoint = updateMouseCanvasPoint(event) || eventToCanvasPoint(event);
if (!nextPoint) return false;
event.preventDefault();
event.stopPropagation();
setCanvasOrigin(nextPoint);
setOriginPickMode(false);
addLog(`Canvas origin: (${nextPoint.x.toFixed(3)}, ${nextPoint.y.toFixed(3)}) um`);
return true;
}, [originPickMode, activePage, updateMouseCanvasPoint, eventToCanvasPoint, addLog]);
const handleCanvasMouseMove = useCallback((event) => {
updateMouseCanvasPoint(event);
handleRulerMouseMove(event);
}, [updateMouseCanvasPoint, handleRulerMouseMove]);
const handleCanvasPaneClick = useCallback((event) => {
if (chooseCanvasOriginFromEvent(event)) return;
handleRulerPaneClick(event);
}, [chooseCanvasOriginFromEvent, handleRulerPaneClick]);
const handleCanvasNodeClick = useCallback((event) => {
if (chooseCanvasOriginFromEvent(event)) return;
handleRulerPaneClick(event);
}, [chooseCanvasOriginFromEvent, handleRulerPaneClick]);
const handleCanvasMouseLeave = useCallback(() => {
setMouseCanvasPoint(null);
setMouseScreenPoint(null);
}, []);
// Select a route edge by id with optional additive selection.
const selectEdgeById = useCallback((edgeId, additive = false) => {
if (!activePageId || !edgeId) return;
setPages(prev => prev.map(p => {
if (p.id !== activePageId) return p;
return {
...p,
nodes: additive ? p.nodes : p.nodes.map(node => ({ ...node, selected: false })),
edges: p.edges.map(edge => {
const route = createRouteSettings(technologyManifest, edge.data?.route);
const selected = edge.id === edgeId ? (additive ? !edge.selected : true) : (additive ? edge.selected : false);
const view = routeStyleForSettings(route, selected);
const hasRoutePoints = Array.isArray(edge.data?.points) && edge.data.points.length >= 2;
return { ...edge, selected, type: hasRoutePoints ? 'parallelRoute' : view.type, style: view.style, data: { ...edge.data, route } };
})
};
}));
}, [activePageId, technologyManifest]);
// Create a new routed connection and reject same-type crossings.
const handleBasicConnection = useCallback((connection) => {
if (!activePageId || !activePage || activePage.type === 'layoutPreview' || rulerMode) return;
if (!connection?.source || !connection?.target || !connection?.sourceHandle || !connection?.targetHandle) return;
if (connection.source === connection.target && connection.sourceHandle === connection.targetHandle) return;
const route = currentLinkRoute;
const view = routeStyleForSettings(route, false);
const edgeId = `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`;
const candidate = {
id: edgeId,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
type: view.type,
selectable: true,
style: view.style,
data: { route }
};
const nodeMap = Object.fromEntries(activePage.nodes.map(node => [node.id, node]));
const conflict = findSameTypeRouteCrossing(candidate, activePage.edges, nodeMap, technologyManifest);
if (conflict) {
const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target;
addLog(`Connection rejected: ${route.xsection} route crosses ${source} to ${target}.`);
return;
}
setPages(prev => prev.map(p => (
p.id === activePageId
? { ...p, edges: addEdge(candidate, p.edges) }
: p
)));
addLog(`Connected ${connection.sourceHandle} to ${connection.targetHandle}.`);
}, [activePageId, activePage, rulerMode, currentLinkRoute, technologyManifest, addLog]);
// Select custom route edges from their SVG hit target.
const handleRouteEdgeMouseDown = useCallback((event) => {
if (rulerMode) return false;
const target = event.target?.closest?.('[data-route-edge-id]');
if (!target) return false;
const edgeId = target.getAttribute('data-route-edge-id');
if (!edgeId) return false;
event.preventDefault();
event.stopPropagation();
selectEdgeById(edgeId, event.shiftKey);
return true;
}, [rulerMode, selectEdgeById]);
// Select standard React Flow edges while ignoring helper/ruler edges.
const handleReactFlowEdgeMouseDown = useCallback((event, edge) => {
if (rulerMode || !edge || edge.data?.draft || edge.data?.ruler) return;
event.preventDefault();
event.stopPropagation();
selectEdgeById(edge.id, event.shiftKey);
}, [rulerMode, selectEdgeById]);
// Forward canvas mouse-down events to route-edge selection logic.
const handleCanvasMouseDown = useCallback((event) => {
handleRouteEdgeMouseDown(event);
}, [handleRouteEdgeMouseDown]);
// Build the left-panel project tree from project pages, composites, and instances.
const projectTreeItems = useMemo(() => {
const items = [];
const projectPagesByName = new Map();
pages
.filter(p => p.type === 'project')
.forEach(project => {
if (!projectPagesByName.has(project.name) || project.id === activePageId) {
projectPagesByName.set(project.name, project);
}
});
const projectPages = Array.from(projectPagesByName.values());
projectPages.forEach(project => {
const projectNodeItems = project.nodes
.filter(node => node.id !== 'page-port' && node.data?.componentName)
.map(node => {
const instanceName = node.data.componentDisplayName || node.id;
const componentName = node.data.componentName;
const compPage = pages.find(p => p.name === componentName && p.type === 'composite');
if (compPage) {
return {
__type__: 'composite',
__name__: instanceName,
__cellName__: componentName,
tree: compositeTrees[componentName] || {},
pageId: compPage.id,
__ports__: buildPageComponentPorts(compPage.port, compPage.nodes),
__boxSize__: calculateCompositeBoxSize(compPage)
};
}
return {
__type__: 'instance',
__name__: instanceName,
__instance__: instanceName,
__page__: project.name
};
});
const unplacedCells = (projectCompositeMap[project.name] || [])
.filter(name => !projectNodeItems.some(item => item.__name__ === name || item.__cellName__ === name))
.map(name => {
const compPage = pages.find(p => p.name === name && p.type === 'composite');
return {
__type__: 'composite',
__name__: name,
__cellName__: name,
tree: compositeTrees[name] || {},
pageId: compPage ? compPage.id : name,
__ports__: compPage ? buildPageComponentPorts(compPage.port, compPage.nodes) : {},
__boxSize__: compPage ? calculateCompositeBoxSize(compPage) : DEFAULT_COMPONENT_BOX_SIZE
};
});
items.push({
type: 'project',
name: project.name,
composites: [...projectNodeItems, ...unplacedCells]
});
});
standaloneComposites.forEach(name => {
const compPage = pages.find(p => p.name === name && p.type === 'composite');
items.push({
type: 'standaloneComposite',
name: name,
tree: compositeTrees[name] || {},
pageId: compPage?.id || name,
__ports__: buildPageComponentPorts(compPage?.port, compPage?.nodes),
__boxSize__: compPage ? calculateCompositeBoxSize(compPage) : DEFAULT_COMPONENT_BOX_SIZE
});
});
return items;
}, [pages, library, projectCompositeMap, standaloneComposites, compositeTrees, activePageId]);
// Merge saved composite cells, built-in elements, primitives, and PDK entries for dragging.
const libraryWithCells = useMemo(() => {
const cellEntries = {};
pages
.filter(page => page.type === 'composite')
.forEach(page => {
cellEntries[page.name] = {
__type__: 'component',
__name__: page.name,
__category__: 'composite',
__cell__: true,
__ports__: buildPageComponentPorts(page.port, page.nodes),
__boxSize__: calculateCompositeBoxSize(page)
};
});
const basicEntries = {
Port: {
__type__: 'component',
__name__: 'Port',
__category__: 'element',
__element__: true,
__elementType__: 'port',
__ports__: ELEMENT_COMPONENTS.Port.ports
},
Anchor: {
__type__: 'component',
__name__: 'Anchor',
__category__: 'element',
__element__: true,
__elementType__: 'anchor',
__ports__: ELEMENT_COMPONENTS.Anchor.ports
},
...Object.fromEntries(Object.entries(BASIC_COMPONENTS)
.filter(([, definition]) => !definition.hidden)
.map(([name, definition]) => ([
name,
{
__type__: 'component',
__name__: name,
__category__: 'basic',
__basic__: true,
__ports__: getBasicComponentMetadata(name, definition.settings)?.ports || {}
}
])))
};
return {
Cells: cellEntries,
Basic: basicEntries,
PDK: library || {}
};
}, [pages, library]);
// Serialize current page edges into bundle YAML with route metadata.
const buildBundlesYaml = useCallback((page) => {
return buildRouteBundlesYaml(page, technologyManifest);
}, [technologyManifest]);
// Block layout or GDS builds when same-type route crossings are present.
const validateRouteCrossings = useCallback((page) => {
if (!page || !Array.isArray(page.edges)) return true;
const nodeMap = Object.fromEntries((page.nodes || []).map(node => [node.id, node]));
for (const edge of page.edges) {
const conflict = findSameTypeRouteCrossing(edge, page.edges, nodeMap, technologyManifest);
if (conflict) {
const route = createRouteSettings(technologyManifest, edge.data?.route);
const source = nodeMap[edge.source]?.data?.componentDisplayName || edge.source;
const target = nodeMap[edge.target]?.data?.componentDisplayName || edge.target;
const conflictSource = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source;
const conflictTarget = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target;
addLog(`Build blocked: ${route.xsection} route ${source} to ${target} crosses ${conflictSource} to ${conflictTarget}.`);
return false;
}
}
return true;
}, [technologyManifest, addLog]);
// Serialize a canvas page into the mxPIC YAML file format.
const buildYamlForPage = useCallback((page) => {
if (!page) return '';
const header = `# =============================================
# mxPIC Cell/Project Definition File
# =============================================
schema_version: "2.0.0"
kind: cell
coordinate_system: gds_y_up
canvas_size:
width: ${Number(page.canvasSize?.width || DEFAULT_CANVAS_SIZE.width)}
height: ${Number(page.canvasSize?.height || DEFAULT_CANVAS_SIZE.height)}
project: ${currentProjectName}
name: ${page.name}
type: ${page.type === 'project' ? 'project' : 'composite'}
version: "1.0.0"
# 1. External Ports (How this cell connects to the outside world)
${buildCanvasPinsYaml(page.nodes)}
# 2. Instances (The sub-components dropped onto this canvas)
instances:`;
const resolveComponentPath = (compName) => {
if (!library || !compName || isForgeComponent(compName) || isBasicComponent(compName)) return compName;
const pathArr = findComponentPath(library, compName);
return pathArr.length > 0 ? pathArr.join('/') : compName;
};
const instancesBlock = buildInstancesYaml({
nodes: page.nodes,
resolveComponentPath
});
const elementsBlock = buildElementsYaml(page.nodes);
const bundlesBlock = buildBundlesYaml(page);
return `${header}
${instancesBlock}
${elementsBlock}
${bundlesBlock}`;
}, [currentProjectName, library, buildBundlesYaml]);
// Open or refresh a tab showing the generated SVG layout preview.
const openLayoutPreview = useCallback((cellName, svgUrl, layoutBounds) => {
if (!cellName || !svgUrl) return;
const layoutTabId = `layout-${currentProjectName}-${cellName}`;
setPages(prev => {
const existing = prev.find(page => page.id === layoutTabId);
if (existing) {
return prev.map(page => page.id === layoutTabId
? { ...page, name: `${cellName}:layout`, type: 'layoutPreview', svgUrl, layoutBounds, nodes: [], edges: [], isClosed: false }
: page
);
}
return prev.concat({
id: layoutTabId,
name: `${cellName}:layout`,
type: 'layoutPreview',
svgUrl,
layoutBounds,
nodes: [],
edges: [],
isClosed: false
});
});
setActivePageId(layoutTabId);
}, [currentProjectName]);
// Save the active page, generate layout preview assets, and show the preview tab.
const handleBuildLayout = useCallback(async () => {
if (!activePage) return;
if (buildLayoutBusy) return;
if (!validateRouteCrossings(activePage)) return;
setBuildLayoutBusy(true);
startBuildProgress('Building layout');
const yamlContent = buildYamlForPage(activePage);
// send to backend
try {
const response = await fetch('/api/save-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: currentProjectName,
cell: activePage.name,
content: yamlContent,
}),
});
if (!response.ok) {
const errData = await response.json();
addLog(errData.error || 'Save failed, unknown error');
stopBuildProgress();
return;
}
const result = await response.json();
addLog('Successfully saved: ' + result.path);
if (result.preview_error) {
addLog('Preview skipped: ' + result.preview_error);
}
if (result.svg_url) {
completeBuildProgress('Layout ready');
openLayoutPreview(activePage.name, result.svg_url, calculateLayoutBounds(activePage));
} else {
completeBuildProgress('Layout saved');
}
} catch (err) {
addLog('Save error: ' + err.message);
stopBuildProgress();
} finally {
setBuildLayoutBusy(false);
}
}, [activePage, buildLayoutBusy, buildYamlForPage, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
// Save YAML for every editable project/composite page without opening previews.
const handleSaveProjectLayouts = useCallback(async () => {
if (saveProjectBusy) return;
const savePages = pages.filter(page => page.type !== 'layoutPreview');
if (savePages.length === 0) {
addLog('No canvas YAML to save.');
return;
}
setSaveProjectBusy(true);
try {
for (const page of savePages) {
const response = await fetch('/api/save-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: currentProjectName,
cell: page.name,
content: buildYamlForPage(page),
preview: false,
}),
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || `Save failed for ${page.name}`);
}
}
addLog(`Saved YAML for ${savePages.length} canvas${savePages.length === 1 ? '' : 'es'}.`);
} catch (err) {
addLog('Project save error: ' + err.message);
} finally {
setSaveProjectBusy(false);
}
}, [saveProjectBusy, pages, currentProjectName, buildYamlForPage, addLog]);
// Build project GDS output through the backend and open the download when ready.
const handleBuildGds = useCallback(async () => {
if (buildGdsBusy) return;
const invalidPage = pages.find(page => page.type !== 'layoutPreview' && !validateRouteCrossings(page));
if (invalidPage) return;
setBuildGdsBusy(true);
startBuildProgress('Building GDS');
try {
const response = await fetch('/api/build-gds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: currentProjectName }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
const detail = result.error ? `: ${result.error}` : ` (HTTP ${response.status})`;
addLog(`Build GDS failed${detail}`);
stopBuildProgress();
return;
}
const warningText = result.warnings && result.warnings.length > 0
? ` (${result.warnings.length} warnings)`
: '';
if (result.download_url) {
const link = document.createElement('a');
link.href = result.download_url;
link.download = result.filename || `${currentProjectName}.gds`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
}
addLog(`GDS built with ${result.engine}: ${result.filename || result.path}${warningText}`);
completeBuildProgress('GDS ready');
} catch (err) {
addLog(`Build GDS network error: ${err.message}. Check that the Flask server is running from the same host and Python environment.`);
stopBuildProgress();
} finally {
setBuildGdsBusy(false);
}
}, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings, startBuildProgress, completeBuildProgress, stopBuildProgress]);
// Open composite cells when their placed instances are double-clicked.
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' }}>
<div className="site-nav-actions">
<button className="mini-btn" onClick={() => { window.location.href = '/dashboard'; }}>
Dashboard
</button>
<button className="mini-btn" onClick={() => { window.location.href = '/logout'; }}>
Logout
</button>
</div>
<LeftPanel
projectTreeItems={projectTreeItems}
library={libraryWithCells} treeKey={treeKey} expanded={expanded}
onToggle={handleToggle} treeRef={treeContainerRef} width={leftWidth}
onOpenComposite={openPage}
onOpenProject={openProject}
onSelectInstance={selectInstanceInPage}
onRenameCanvas={renameCanvas}
onDeleteCanvas={deleteCanvas}
onBuildGds={handleBuildGds}
buildGdsBusy={buildGdsBusy}
onSaveProject={handleSaveProjectLayouts}
saveProjectBusy={saveProjectBusy}
projectExpanded={projectExpanded}
onProjectToggle={handleProjectToggle}
projectTreeRef={projectTreeContainerRef}
projectTreeKey={projectTreeKey}
canvasSize={activeCanvasSize}
onCanvasSizeChange={handleCanvasSizeChange}
/>
<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>
<div className="canvas-tab" style={{ cursor: 'pointer' }} onClick={createCell}>
+ Cell
</div>
{openTabs.map(page => (
<div key={page.id} className={`canvas-tab ${page.id === activePageId ? 'active' : ''}`} onClick={() => switchPage(page.id)}>
<EditableCanvasTabName page={page} active={page.id === activePageId} onRename={renameCanvas} />
<button onClick={(e) => { e.stopPropagation(); closePage(page.id); }}>x</button>
</div>
))}
</div>
<div
ref={canvasViewportRef}
style={{ flex: 1, position: 'relative' }}
onMouseDownCapture={handleCanvasMouseDown}
onMouseMoveCapture={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
>
<div className="canvas-toolbar">
<span className="grid-snap-label">Snap to Grid</span>
<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>
<button className="mini-btn" onClick={toggleCanvasText} aria-pressed={canvasTextVisible ? 'true' : 'false'}>
{canvasTextVisible ? 'Text On' : 'Text Off'}
</button>
<details className="link-mode-tabs" title="Route type used for new links">
<summary className="link-mode-summary">
<span className="link-mode-label">Link</span>
<span className="link-mode-current" style={{ color: routeStyleForSettings(currentLinkRoute, false).style.stroke }}>
{currentLinkXsection}
</span>
</summary>
<div className="link-mode-menu">
{linkXsectionChoices.map(xsection => {
const route = createRouteSettings(technologyManifest, { xsection });
const color = routeStyleForSettings(route, false).style.stroke;
return (
<button
key={xsection}
type="button"
className={`link-mode-btn ${currentLinkXsection === xsection ? 'active' : ''}`}
style={{ color }}
onClick={(event) => {
setCurrentLinkXsection(xsection);
event.currentTarget.closest('details')?.removeAttribute('open');
}}
>
{xsection}
</button>
);
})}
</div>
</details>
<button className="mini-btn" onClick={() => setThemeMode(themeMode === 'light' ? 'dark' : 'light')}>
{themeMode === 'light' ? 'Dark Mode' : 'Bright Mode'}
</button>
<button className="mini-btn" onClick={toggleRulerMode} aria-pressed={rulerMode ? 'true' : 'false'}>
{rulerMode ? 'Ruler On' : 'Ruler'}
</button>
<button
className={`mini-btn origin-select-btn ${originPickMode ? 'active' : ''}`}
onClick={toggleOriginPickMode}
aria-pressed={originPickMode ? 'true' : 'false'}
title="Select canvas origin"
>
{originPickMode ? 'Picking Origin' : 'Origin Select'}
</button>
</div>
{buildProgress.active && (
<div className="build-progress">
<div className="build-progress-row">
<span>{buildProgress.label}</span>
<span>{Math.round(buildProgress.value)}%</span>
</div>
<div className="build-progress-track">
<div className="build-progress-fill" style={{ width: `${buildProgress.value}%` }} />
</div>
</div>
)}
{rulerMode && activePage?.type !== 'layoutPreview' && (
<div className="ruler-status">
{rulerMeasurement
? rulerMeasurement.label
: (rulerStartPoint && !rulerEndPoint ? 'Move mouse, click second point' : 'Click the first point')}
</div>
)}
{activePage && activePage.type !== 'layoutPreview' && (
<div
className="coordinate-readout"
style={{ bottom: rulerMode ? 58 : 18 }}
title={`Origin (${canvasOrigin.x.toFixed(3)}, ${canvasOrigin.y.toFixed(3)}) um`}
>
X {displayMousePoint ? displayMousePoint.x.toFixed(3) : '--'} um
Y {displayMousePoint ? displayMousePoint.y.toFixed(3) : '--'} um
<span>O {canvasOrigin.x.toFixed(3)}, {canvasOrigin.y.toFixed(3)}</span>
</div>
)}
{originPickMode && mouseScreenPoint && activePage?.type !== 'layoutPreview' && (
<div
className="origin-crosshair"
style={{ left: mouseScreenPoint.x, top: mouseScreenPoint.y }}
/>
)}
{activePage && activePage.type !== 'layoutPreview' && (
<button
onClick={handleBuildLayout}
className="build-layout-btn"
disabled={buildLayoutBusy}
>
{buildLayoutBusy ? 'Building...' : 'Build Layout'}
</button>
)}
{activePage && activePage.type === 'layoutPreview' ? (
<LayoutSvgPreview page={activePage} />
) : (
<ReactFlow
className={canvasTextVisible ? '' : 'canvas-text-hidden'}
nodes={renderNodes}
edges={renderEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleBasicConnection}
onPaneClick={handleCanvasPaneClick}
onPaneMouseMove={handleCanvasMouseMove}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeClick={handleCanvasNodeClick}
onNodeMouseMove={handleCanvasMouseMove}
onNodeDoubleClick={onNodeDoubleClick}
onNodeMouseDown={onNodeMouseDown}
onEdgeMouseDown={handleReactFlowEdgeMouseDown}
onNodeMouseUp={clearSpaceRotateNode}
nodeTypes={{ rotatableNode: RotatableNode, portNode: PortNode, anchorNode: AnchorNode, canvasBoundaryNode: CanvasBoundaryNode, rulerPointNode: RulerPointNode, rulerMeasurementNode: RulerMeasurementNode }}
edgeTypes={edgeTypes}
nodeExtent={canvasNodeExtent}
snapToGrid={gridSnap}
snapGrid={[10, 10]}
nodesDraggable={true}
nodesConnectable={true}
elementsSelectable={true}
connectionMode="loose"
connectionRadius={50}
minZoom={0.02}
maxZoom={4}
defaultViewport={{ x: 80, y: 80, zoom: 0.12 }}
onMoveEnd={handleCanvasViewportMoveEnd}
panOnDrag={false}
selectionOnDrag={true}
selectionMode={FULL_SELECTION_MODE}
multiSelectionKeyCode="Shift"
deleteKeyCode={['Backspace', 'Delete']}
>
<Controls style={{ bottom: 15, left: 15 }} />
<Background color="#334155" gap={10} size={1} />
</ReactFlow>
)}
</div>
<div className="app-log-terminal">
{logs.map((entry, index) => (
<div key={`${entry.time}-${index}`}>[{entry.time}] {entry.message}</div>
))}
</div>
</div>
<ResizeHandle onMouseDown={handleResizeStart('right')} />
<RightPanel
selectedNode={selectedNode}
selectedNodes={selectedNodes}
selectedEdge={selectedEdge}
selectedEdges={selectedEdges}
technologyManifest={technologyManifest}
projectName={currentProjectName}
compositeNames={compositePageNames}
width={rightWidth}
onRenameComponent={renameComponent}
onUpdateNode={handleUpdateNode}
onUpdateEdgeRoute={handleUpdateEdgeRoute}
/>
</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 %}