More annotation added to the program
This commit is contained in:
@@ -5,19 +5,28 @@
|
||||
* Organization : OptiHK Limited
|
||||
*/
|
||||
(function (root, factory) {
|
||||
// Build the helper API once, then expose it to both browser and Node test environments.
|
||||
const helpers = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = helpers;
|
||||
}
|
||||
root.MxpicCanvasHelpers = helpers;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||
// Label used by the canvas to represent generated mxpic_forge components.
|
||||
const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge';
|
||||
// Serialized component type used when saving mxpic_forge-generated components.
|
||||
const FORGE_COMPONENT_TYPE = 'generate_with_forge';
|
||||
// Fallback visual size for PDK components without explicit metadata.
|
||||
const DEFAULT_COMPONENT_BOX_SIZE = { width: 132, height: 82 };
|
||||
// Default editable canvas dimensions in micrometers.
|
||||
const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 };
|
||||
// Base visual diameter and hit area used for port and anchor handles.
|
||||
const PORT_NODE_SIZE = 30;
|
||||
// Narrow anchor body width used in the canvas visual representation.
|
||||
const ANCHOR_NODE_WIDTH = 8;
|
||||
// Default spacing between repeated anchor or port pins.
|
||||
const DEFAULT_ELEMENT_PITCH = 10;
|
||||
// Defines built-in port and anchor element metadata before per-node expansion.
|
||||
const ELEMENT_COMPONENTS = {
|
||||
Port: {
|
||||
name: 'Port',
|
||||
@@ -35,6 +44,7 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
// Defines local primitive components that do not require PDK lookup.
|
||||
const BASIC_COMPONENTS = {
|
||||
waveguide: {
|
||||
name: 'waveguide',
|
||||
@@ -69,6 +79,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Default parameters sent when creating a component through mxpic_forge.
|
||||
const DEFAULT_FORGE_ARGUMENTS = {
|
||||
function_name: 'straight',
|
||||
component_name: '',
|
||||
@@ -87,6 +98,7 @@
|
||||
notes: ''
|
||||
};
|
||||
|
||||
// Fallback routing technology data used when the backend manifest is unavailable.
|
||||
const FALLBACK_TECHNOLOGY_MANIFEST = {
|
||||
routing_types: ['euler_bend', 'standard_bend'],
|
||||
defaults: {
|
||||
@@ -104,18 +116,22 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Merge user edits with default mxpic_forge arguments for saving and generation.
|
||||
const createForgeArguments = (overrides) => ({
|
||||
...DEFAULT_FORGE_ARGUMENTS,
|
||||
...(overrides || {})
|
||||
});
|
||||
|
||||
// Return a manifest object, falling back to bundled defaults when needed.
|
||||
const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST;
|
||||
|
||||
// Look up width, radius, and family defaults for a routing cross-section.
|
||||
const getXsectionInfo = (xsection, manifest) => {
|
||||
const technology = getTechnologyManifest(manifest);
|
||||
return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {};
|
||||
};
|
||||
|
||||
// Normalize route settings so every edge has xsection, family, width, radius, and bend type.
|
||||
const createRouteSettings = (manifest, overrides) => {
|
||||
const technology = getTechnologyManifest(manifest);
|
||||
const defaults = technology.defaults || FALLBACK_TECHNOLOGY_MANIFEST.defaults;
|
||||
@@ -132,6 +148,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Apply a single route-field edit while preserving route defaults and width override state.
|
||||
const updateRouteField = (route, key, value, manifest) => {
|
||||
const current = createRouteSettings(manifest, route);
|
||||
const numericFields = new Set(['width', 'radius']);
|
||||
@@ -143,6 +160,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Switch an edge cross-section and refresh dependent routing defaults.
|
||||
const updateRouteXsection = (route, xsection, manifest) => {
|
||||
const technology = getTechnologyManifest(manifest);
|
||||
const current = createRouteSettings(technology, route);
|
||||
@@ -159,6 +177,7 @@
|
||||
return next;
|
||||
};
|
||||
|
||||
// Convert route settings into React Flow edge styling for canvas display.
|
||||
const routeStyleForSettings = (route, selected) => {
|
||||
const settings = createRouteSettings(null, route);
|
||||
const palette = {
|
||||
@@ -180,14 +199,18 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Check whether a component name refers to the mxpic_forge generator placeholder.
|
||||
const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE;
|
||||
// Check whether a component is one of the built-in primitive canvas elements.
|
||||
const isBasicComponent = (componentName) => Boolean(BASIC_COMPONENTS[componentName]);
|
||||
|
||||
// Merge primitive component defaults with user-entered values.
|
||||
const createBasicSettings = (componentName, overrides) => ({
|
||||
...(BASIC_COMPONENTS[componentName] ? BASIC_COMPONENTS[componentName].settings : {}),
|
||||
...(overrides || {})
|
||||
});
|
||||
|
||||
// Normalize angles into the -180 to 180 degree range used by port logic.
|
||||
const normalizeAngle = (angle) => {
|
||||
const value = Number(angle);
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
@@ -196,6 +219,7 @@
|
||||
return Object.is(normalized, -0) ? 0 : normalized;
|
||||
};
|
||||
|
||||
// Map a port angle to the canvas side where its handle should appear.
|
||||
const portSideFromAngle = (angle) => {
|
||||
const normalized = normalizeAngle(angle);
|
||||
if (normalized === 0) return 'right';
|
||||
@@ -205,18 +229,22 @@
|
||||
return Math.abs(normalized) < 90 ? 'right' : 'left';
|
||||
};
|
||||
|
||||
// Round handle percentages so saved and rendered positions stay stable.
|
||||
const roundPercent = (value) => Number(value.toFixed(3));
|
||||
|
||||
// Generate even fallback spacing for handles when no exact position is available.
|
||||
const fallbackPercent = (index, count) => {
|
||||
if (count <= 1) return 50;
|
||||
return roundPercent(15 + (index / (count - 1)) * 70);
|
||||
};
|
||||
|
||||
// Accept only finite positive numeric values from metadata or user input.
|
||||
const positiveNumber = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
};
|
||||
|
||||
// Resolve component visual dimensions from metadata with a safe fallback.
|
||||
const normalizeBoxSize = (metadata, fallback) => {
|
||||
const fallbackSize = fallback || DEFAULT_COMPONENT_BOX_SIZE;
|
||||
const raw = metadata && (metadata.box_size || metadata.box_sz || metadata.boxSize);
|
||||
@@ -235,6 +263,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Select the physical component to place when a library category is dragged.
|
||||
const chooseCategoryComponent = (dragName, availableComponents, categoryName) => {
|
||||
const available = Array.isArray(availableComponents)
|
||||
? availableComponents.filter(Boolean)
|
||||
@@ -244,11 +273,13 @@
|
||||
return physicalComponent || dragName || available[0] || categoryName;
|
||||
};
|
||||
|
||||
// Resolve valid canvas dimensions from saved project metadata.
|
||||
const normalizeCanvasSize = (size) => ({
|
||||
width: positiveNumber(size && size.width) || DEFAULT_CANVAS_SIZE.width,
|
||||
height: positiveNumber(size && size.height) || DEFAULT_CANVAS_SIZE.height
|
||||
});
|
||||
|
||||
// Keep dragged or pasted nodes inside the active canvas bounds.
|
||||
const clampPositionToCanvas = (position, canvasSize, boxSize) => {
|
||||
const size = normalizeCanvasSize(canvasSize);
|
||||
const box = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] });
|
||||
@@ -260,6 +291,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Rotate or mirror a component-box corner for layout bounds calculation.
|
||||
const transformBoxCorner = (corner, transform) => {
|
||||
const options = transform || {};
|
||||
let x = Number(corner && corner.x) || 0;
|
||||
@@ -277,8 +309,10 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Round layout-bound values for stable preview and export metadata.
|
||||
const roundBoundsValue = (value) => Number(value.toFixed(6));
|
||||
|
||||
// Calculate the bounding rectangle containing all visible canvas nodes.
|
||||
const calculateLayoutBounds = (pageOrNodes) => {
|
||||
const page = Array.isArray(pageOrNodes) ? { nodes: pageOrNodes } : (pageOrNodes || {});
|
||||
const nodes = Array.isArray(page.nodes) ? page.nodes : [];
|
||||
@@ -326,8 +360,10 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Round ruler measurements for compact display.
|
||||
const roundMeasureValue = (value) => Number(value.toFixed(3));
|
||||
|
||||
// Convert pointer coordinates into valid ruler measurement points.
|
||||
const normalizeMeasurePoint = (point) => {
|
||||
const x = Number(point && point.x);
|
||||
const y = Number(point && point.y);
|
||||
@@ -335,6 +371,7 @@
|
||||
return { x: roundMeasureValue(x), y: roundMeasureValue(y) };
|
||||
};
|
||||
|
||||
// Build distance, delta, and midpoint values for the ruler overlay.
|
||||
const createRulerMeasurement = (startPoint, endPoint) => {
|
||||
const start = normalizeMeasurePoint(startPoint);
|
||||
const end = normalizeMeasurePoint(endPoint);
|
||||
@@ -357,6 +394,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Derive compact symbol dimensions for drawing component previews.
|
||||
const createComponentSymbolMetrics = (boxSize) => {
|
||||
const size = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] });
|
||||
const widthRatio = size.width >= 400 ? 0.95 : 0.9;
|
||||
@@ -366,6 +404,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Apply node rotation and mirror transforms to a component port definition.
|
||||
const transformPortInfo = (info, transform) => {
|
||||
const source = info || {};
|
||||
const options = transform || {};
|
||||
@@ -402,6 +441,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Create ordered React Flow handles for all ports on a single visual side.
|
||||
const buildSideHandles = (ports, side) => {
|
||||
const vertical = side === 'left' || side === 'right';
|
||||
|
||||
@@ -421,6 +461,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
// Group transformed ports into canvas handles with side and position styling.
|
||||
const buildPortHandles = (ports, transform) => {
|
||||
const grouped = { left: [], right: [], top: [], bottom: [] };
|
||||
Object.entries(ports || {}).forEach(([name, info]) => {
|
||||
@@ -447,6 +488,7 @@
|
||||
];
|
||||
};
|
||||
|
||||
// Serialize primitive JavaScript values into YAML-friendly scalar text.
|
||||
const toYamlScalar = (value) => {
|
||||
if (value === null || value === undefined) return '""';
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
@@ -458,9 +500,12 @@
|
||||
return JSON.stringify(String(value));
|
||||
};
|
||||
|
||||
// Convert canvas Y coordinates into layout Y coordinates.
|
||||
const canvasToLayoutY = (value) => -Number(value || 0);
|
||||
// Convert layout Y coordinates back into canvas Y coordinates.
|
||||
const layoutToCanvasY = (value) => -Number(value || 0);
|
||||
|
||||
// Serialize nested component settings into YAML blocks.
|
||||
const buildSettingsYaml = (settings, indent) => {
|
||||
const pad = ' '.repeat(indent);
|
||||
const entries = Object.entries(settings || {});
|
||||
@@ -468,6 +513,7 @@
|
||||
return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n');
|
||||
};
|
||||
|
||||
// Serialize one component instance into saved layout YAML.
|
||||
const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, flip, flop, forgeArguments, basicArguments }) => {
|
||||
const forge = isForgeComponent(componentName);
|
||||
const basic = isBasicComponent(componentName);
|
||||
@@ -489,6 +535,7 @@
|
||||
mirror: ${flip ? 'true' : 'false'}${settingsYaml}`;
|
||||
};
|
||||
|
||||
// Serialize all component nodes on a page into the instances YAML section.
|
||||
const buildInstancesYaml = ({ nodes, resolveComponentPath }) => {
|
||||
return (nodes || [])
|
||||
.filter(node => node.data && node.data.componentName && !node.data.elementType)
|
||||
@@ -514,26 +561,33 @@
|
||||
.join('\n\n');
|
||||
};
|
||||
|
||||
// Resolve the display/export name for a port-like node.
|
||||
const getNodePortName = (node) => {
|
||||
const name = node && node.data && (node.data.portName || node.data.componentDisplayName || node.data.label);
|
||||
return name || (node && node.id) || 'port';
|
||||
};
|
||||
|
||||
// Detect standalone port nodes that become top-level layout ports.
|
||||
const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode');
|
||||
// Detect built-in port or anchor nodes for element YAML export.
|
||||
const isElementNode = (node) => node && node.data && (node.data.elementType === 'port' || node.data.elementType === 'anchor');
|
||||
|
||||
// Clamp repeated port counts to a supported positive integer.
|
||||
const normalizePortNumber = (value) => {
|
||||
const number = Math.floor(Number(value));
|
||||
return Number.isFinite(number) ? Math.max(1, number) : 1;
|
||||
};
|
||||
|
||||
// Resolve repeated port spacing with the default pitch fallback.
|
||||
const normalizePitch = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.max(0, number) : DEFAULT_ELEMENT_PITCH;
|
||||
};
|
||||
|
||||
// Calculate the centered offset for one repeated port index.
|
||||
const elementPortOffset = (index, count, pitch) => ((count - 1) / 2 - index) * pitch;
|
||||
|
||||
// Grow port and anchor visual bodies so repeated port circles do not overlap.
|
||||
const buildElementBoxSize = (data) => {
|
||||
const portNumber = normalizePortNumber(data && data.portNumber);
|
||||
const pitch = normalizePitch(data && data.pitch);
|
||||
@@ -544,6 +598,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Expand port and anchor definitions into one or more named physical ports.
|
||||
const buildElementPorts = (elementType, data) => {
|
||||
const element = ELEMENT_COMPONENTS[elementType === 'anchor' ? 'Anchor' : 'Port'];
|
||||
if (!element) return {};
|
||||
@@ -583,6 +638,7 @@
|
||||
return JSON.parse(JSON.stringify(element.ports));
|
||||
};
|
||||
|
||||
// Generate port metadata for built-in primitive components.
|
||||
const buildBasicComponentPorts = (componentName, settings) => {
|
||||
const values = createBasicSettings(componentName, settings);
|
||||
const length = Number(values.length || 0);
|
||||
@@ -622,6 +678,7 @@
|
||||
return {};
|
||||
};
|
||||
|
||||
// Generate visual metadata and port definitions for primitive components.
|
||||
const getBasicComponentMetadata = (componentName, settings) => {
|
||||
if (!isBasicComponent(componentName)) return null;
|
||||
const values = createBasicSettings(componentName, settings);
|
||||
@@ -646,6 +703,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Convert standalone port nodes into page-level layout ports.
|
||||
const buildPageComponentPorts = (port, nodes) => {
|
||||
const portNodes = (nodes || []).filter(isPortElementNode);
|
||||
if (portNodes.length > 0) {
|
||||
@@ -683,6 +741,7 @@
|
||||
};
|
||||
};
|
||||
|
||||
// Serialize standalone canvas ports into a layout ports YAML section.
|
||||
const buildCanvasPortsYaml = (nodes, fallbackPort) => {
|
||||
const ports = buildPageComponentPorts(fallbackPort, nodes);
|
||||
const entries = Object.entries(ports);
|
||||
@@ -701,8 +760,10 @@
|
||||
return `ports:\n${lines.join('\n')}`;
|
||||
};
|
||||
|
||||
// Maintain legacy single-port YAML export behavior for older callers.
|
||||
const buildPortsYaml = (port) => buildCanvasPortsYaml([], port);
|
||||
|
||||
// Serialize built-in port and anchor nodes into layout element metadata.
|
||||
const buildElementsYaml = (nodes) => {
|
||||
const elementNodes = (nodes || []).filter(isElementNode);
|
||||
if (elementNodes.length === 0) return 'elements: {}';
|
||||
@@ -726,6 +787,7 @@
|
||||
return `elements:\n${lines.join('\n')}`;
|
||||
};
|
||||
|
||||
// Serialize canvas links into routed bundle YAML including route settings and bend points.
|
||||
const buildBundlesYaml = (page, manifest) => {
|
||||
const { nodes = [], edges = [] } = page || {};
|
||||
const nodeMap = {};
|
||||
@@ -774,6 +836,7 @@ bundles:
|
||||
${linksYaml}`;
|
||||
};
|
||||
|
||||
// Return the center point of a node when a more precise port point is unavailable.
|
||||
const getNodeCenter = (node) => {
|
||||
if (!node) return null;
|
||||
return {
|
||||
@@ -782,6 +845,7 @@ ${linksYaml}`;
|
||||
};
|
||||
};
|
||||
|
||||
// Resolve the exact canvas coordinate of a named node port or anchor pin.
|
||||
const getNodePortCanvasPoint = (node, portName) => {
|
||||
if (!node) return null;
|
||||
const x = Number((node.position && node.position.x) || 0);
|
||||
@@ -824,12 +888,14 @@ ${linksYaml}`;
|
||||
};
|
||||
};
|
||||
|
||||
// Convert handle percent strings into numeric ratios for endpoint lookup.
|
||||
const percentValue = (value, fallback = 50) => {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const number = Number(value.replace('%', ''));
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
};
|
||||
|
||||
// Resolve a route endpoint from an edge handle and its connected node.
|
||||
const getEdgeEndpointPoint = (edge, nodeMap, endpoint) => {
|
||||
const nodeId = endpoint === 'source' ? edge.source : edge.target;
|
||||
const handleId = endpoint === 'source' ? edge.sourceHandle : edge.targetHandle;
|
||||
@@ -871,6 +937,7 @@ ${linksYaml}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Return the editable route polyline points for crossing checks and YAML export.
|
||||
const getEdgeRoutePoints = (edge, nodeMap) => {
|
||||
const explicitPoints = edge && edge.data && Array.isArray(edge.data.points) ? edge.data.points : [];
|
||||
if (explicitPoints.length >= 2) {
|
||||
@@ -891,6 +958,7 @@ ${linksYaml}`;
|
||||
return [getNodeCenter(nodeMap[edge.source]), getNodeCenter(nodeMap[edge.target])].filter(Boolean);
|
||||
};
|
||||
|
||||
// Check two routed polylines for any segment crossing.
|
||||
const routeSegmentsIntersect = (pointsA, pointsB) => {
|
||||
for (let i = 0; i < pointsA.length - 1; i += 1) {
|
||||
for (let j = 0; j < pointsB.length - 1; j += 1) {
|
||||
@@ -902,12 +970,14 @@ ${linksYaml}`;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Classify point ordering for the line-segment intersection test.
|
||||
const orientation = (a, b, c) => {
|
||||
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
||||
if (Math.abs(value) < 1e-9) return 0;
|
||||
return value > 0 ? 1 : 2;
|
||||
};
|
||||
|
||||
// Detect whether two line segments cross each other.
|
||||
const segmentsIntersect = (p1, q1, p2, q2) => {
|
||||
if (!p1 || !q1 || !p2 || !q2) return false;
|
||||
const o1 = orientation(p1, q1, p2);
|
||||
@@ -917,6 +987,7 @@ ${linksYaml}`;
|
||||
return o1 !== o2 && o3 !== o4;
|
||||
};
|
||||
|
||||
// Normalize equivalent route xsection names before same-type crossing checks.
|
||||
const routeTypeKey = (route) => {
|
||||
const xsection = String((route && route.xsection) || '').trim().toLowerCase();
|
||||
if (xsection === 'metal1') return 'metal_1';
|
||||
@@ -925,6 +996,7 @@ ${linksYaml}`;
|
||||
return xsection;
|
||||
};
|
||||
|
||||
// Find an existing same-type route that would cross a candidate edge.
|
||||
const findSameTypeRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => {
|
||||
const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route);
|
||||
const candidateType = routeTypeKey(candidateRoute);
|
||||
@@ -942,8 +1014,10 @@ ${linksYaml}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Backward-compatible alias for same-type route crossing validation.
|
||||
const findSameFamilyRouteCrossing = findSameTypeRouteCrossing;
|
||||
|
||||
// Expose the helper functions consumed by canvas.html and the Node-based tests.
|
||||
return {
|
||||
FORGE_COMPONENT_LABEL,
|
||||
FORGE_COMPONENT_TYPE,
|
||||
|
||||
@@ -1477,6 +1477,7 @@ Organization : OptiHK Limited
|
||||
|
||||
|
||||
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;
|
||||
@@ -1506,6 +1507,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
|
||||
|
||||
// Displays a category icon with cached loading and graceful failure behavior.
|
||||
const IconImg = memo(({ category, containerStyle }) => {
|
||||
const [src, setSrc] = useState(() => {
|
||||
if (!category) return undefined;
|
||||
@@ -1569,6 +1571,7 @@ Organization : OptiHK Limited
|
||||
|
||||
|
||||
|
||||
// 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}`);
|
||||
@@ -1710,6 +1713,7 @@ Organization : OptiHK Limited
|
||||
|
||||
|
||||
|
||||
// Renders standalone exported port elements with repeated port handles.
|
||||
const PortNode = ({ id, data, selected }) => {
|
||||
const angle = data.angle ?? 0;
|
||||
const ports = buildElementPorts('port', data);
|
||||
@@ -1754,6 +1758,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -1855,10 +1860,12 @@ Organization : OptiHK Limited
|
||||
);
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -1877,12 +1884,14 @@ Organization : OptiHK Limited
|
||||
);
|
||||
});
|
||||
|
||||
// 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 };
|
||||
@@ -1890,6 +1899,7 @@ Organization : OptiHK Limited
|
||||
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;
|
||||
@@ -1898,6 +1908,7 @@ Organization : OptiHK Limited
|
||||
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;
|
||||
@@ -1971,6 +1982,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
});
|
||||
|
||||
// Displays generated layout SVG previews with zoom and pan controls.
|
||||
const LayoutSvgPreview = ({ page }) => {
|
||||
const [layoutScale, setLayoutScale] = useState(100);
|
||||
const previewBounds = useMemo(
|
||||
@@ -2039,6 +2051,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// Allows a canvas tab title to be renamed in place.
|
||||
const EditableCanvasTabName = ({ page, active, onRename }) => {
|
||||
const [value, setValue] = useState(page.name);
|
||||
|
||||
@@ -2077,6 +2090,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// Allows project-tree canvas names to be renamed from the navigation panel.
|
||||
const EditableTreeCanvasName = ({ pageId, name, canRename, onRename, onOpen }) => {
|
||||
const [value, setValue] = useState(name);
|
||||
|
||||
@@ -2119,8 +2133,10 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// 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))
|
||||
@@ -2130,6 +2146,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
};
|
||||
|
||||
// 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]));
|
||||
@@ -2159,6 +2176,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// Renders recursive component library nodes with drag behavior for leaves.
|
||||
const TreeNode = ({ name, children }) => {
|
||||
if (children && children.__type__ === 'component') {
|
||||
const componentName = children.__name__;
|
||||
@@ -2305,6 +2323,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -2428,6 +2447,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
@@ -2465,6 +2485,7 @@ Organization : OptiHK Limited
|
||||
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);
|
||||
@@ -2488,6 +2509,7 @@ Organization : OptiHK Limited
|
||||
};
|
||||
}, [resizingProjectPanel]);
|
||||
|
||||
// Toggle the expanded state of the project tree panel.
|
||||
const handleProjectToggle = () => {
|
||||
onProjectToggle();
|
||||
};
|
||||
@@ -2585,6 +2607,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// Renders editable properties for selected nodes, ports, anchors, and routes.
|
||||
const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => {
|
||||
const [componentData, setComponentData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -3342,6 +3365,7 @@ Organization : OptiHK Limited
|
||||
);
|
||||
};
|
||||
|
||||
// Provides a draggable divider for resizing side panels.
|
||||
const ResizeHandle = ({ onMouseDown }) => (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
@@ -3354,6 +3378,7 @@ Organization : OptiHK Limited
|
||||
/>
|
||||
);
|
||||
|
||||
// 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) {
|
||||
@@ -3372,6 +3397,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
return walk(lib, []) || [];
|
||||
}
|
||||
// Builds a compact project-tree structure from placed component instances.
|
||||
function buildCompInstanceTree(compNodes, library) {
|
||||
const tree = {};
|
||||
compNodes.forEach(node => {
|
||||
@@ -3388,6 +3414,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
|
||||
|
||||
// Builds a category tree of components used by the current canvas.
|
||||
function buildCompTree(compNodes, library) {
|
||||
const tree = {};
|
||||
compNodes.forEach(node => {
|
||||
@@ -3409,6 +3436,7 @@ Organization : OptiHK Limited
|
||||
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);
|
||||
@@ -3485,6 +3513,7 @@ Organization : OptiHK Limited
|
||||
[rulerStartPoint, rulerActiveEndPoint]
|
||||
);
|
||||
const rulerPreviewMeasurement = !rulerEndPoint && rulerPreviewPoint ? rulerMeasurement : null;
|
||||
// Normalizes free-route control points and removes adjacent duplicates before storage.
|
||||
const compactRoutePoints = useCallback((points) => {
|
||||
return (points || [])
|
||||
.map(point => ({
|
||||
@@ -3494,7 +3523,9 @@ Organization : OptiHK Limited
|
||||
.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 {
|
||||
@@ -3509,6 +3540,7 @@ Organization : OptiHK Limited
|
||||
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 = [{
|
||||
@@ -3551,6 +3583,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
return nodes;
|
||||
}, [activePage, rulerStartPoint, rulerEndPoint, rulerActiveEndPoint, rulerMeasurement]);
|
||||
// Builds temporary ruler edges between measurement endpoints.
|
||||
const rulerEdges = useMemo(() => {
|
||||
if (!rulerMeasurement) return [];
|
||||
return [{
|
||||
@@ -3568,6 +3601,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
}];
|
||||
}, [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 => {
|
||||
@@ -3601,6 +3635,7 @@ Organization : OptiHK Limited
|
||||
];
|
||||
});
|
||||
}, [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 [{
|
||||
@@ -3615,6 +3650,7 @@ Organization : OptiHK Limited
|
||||
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), {
|
||||
@@ -3624,6 +3660,7 @@ Organization : OptiHK Limited
|
||||
});
|
||||
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]));
|
||||
@@ -3674,6 +3711,7 @@ Organization : OptiHK Limited
|
||||
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 }]);
|
||||
}, []);
|
||||
@@ -3689,10 +3727,12 @@ Organization : OptiHK Limited
|
||||
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(() => {
|
||||
@@ -3700,14 +3740,17 @@ Organization : OptiHK Limited
|
||||
}, 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 => ({
|
||||
@@ -3717,6 +3760,7 @@ Organization : OptiHK Limited
|
||||
.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);
|
||||
@@ -3743,6 +3787,7 @@ Organization : OptiHK Limited
|
||||
|
||||
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)) return null;
|
||||
if (componentDataCacheRef.current.has(componentName)) {
|
||||
@@ -3755,6 +3800,7 @@ Organization : OptiHK Limited
|
||||
return data;
|
||||
}, [currentProjectName]);
|
||||
|
||||
// Send an auditable user action to the backend log endpoint.
|
||||
const recordUserAction = useCallback((action, payload = {}) => {
|
||||
fetch('/api/logs', {
|
||||
method: 'POST',
|
||||
@@ -3763,6 +3809,7 @@ Organization : OptiHK Limited
|
||||
}).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);
|
||||
@@ -3791,6 +3838,7 @@ Organization : OptiHK Limited
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Rebuild composite trees from all canvas pages after project load or cell edits.
|
||||
const syncAllCompositeTrees = useCallback((pagesToScan, libraryData) => {
|
||||
if (!libraryData) return;
|
||||
const nextTrees = {};
|
||||
@@ -3805,6 +3853,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 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__');
|
||||
@@ -3836,6 +3885,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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 => {
|
||||
@@ -3850,6 +3900,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [activePageId, technologyManifest]);
|
||||
|
||||
// Apply property-panel edits to a selected node.
|
||||
const handleUpdateNode = useCallback((nodeId, update) => {
|
||||
if (!activePageId) return;
|
||||
setPages(prev => prev.map(p => {
|
||||
@@ -3880,6 +3931,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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);
|
||||
@@ -3904,6 +3956,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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 => {
|
||||
@@ -3927,6 +3980,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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;
|
||||
@@ -3935,16 +3989,19 @@ Organization : OptiHK Limited
|
||||
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]);
|
||||
@@ -3976,6 +4033,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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);
|
||||
@@ -3984,6 +4042,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
}, [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);
|
||||
@@ -3997,6 +4056,7 @@ Organization : OptiHK Limited
|
||||
}
|
||||
}, [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 => {
|
||||
@@ -4031,6 +4091,7 @@ Organization : OptiHK Limited
|
||||
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);
|
||||
@@ -4115,6 +4176,7 @@ Organization : OptiHK Limited
|
||||
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;
|
||||
@@ -4123,6 +4185,7 @@ Organization : OptiHK Limited
|
||||
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;
|
||||
@@ -4131,6 +4194,7 @@ Organization : OptiHK Limited
|
||||
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;
|
||||
@@ -4142,16 +4206,19 @@ Organization : OptiHK Limited
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -4181,6 +4248,7 @@ Organization : OptiHK Limited
|
||||
return singularPrefix;
|
||||
}, []);
|
||||
|
||||
// Create the next available prefix-specific component display name.
|
||||
const generateComponentDisplayName = useCallback((prefixSource = 'element', options = {}) => {
|
||||
const prefix = normalizeComponentDisplayNamePrefix(prefixSource, options);
|
||||
reserveComponentDisplayNamesFromPages();
|
||||
@@ -4193,6 +4261,7 @@ Organization : OptiHK Limited
|
||||
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;
|
||||
@@ -4209,6 +4278,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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)}`);
|
||||
@@ -4220,6 +4290,7 @@ Organization : OptiHK Limited
|
||||
}, [currentProjectName]);
|
||||
useEffect(() => { fetchLibrary(); }, [fetchLibrary]);
|
||||
|
||||
// Flatten the library tree into component/category pairs.
|
||||
const collectComponentNames = useCallback((lib) => {
|
||||
const names = [];
|
||||
const walk = (obj) => {
|
||||
@@ -4234,6 +4305,7 @@ Organization : OptiHK Limited
|
||||
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);
|
||||
@@ -4246,6 +4318,7 @@ Organization : OptiHK Limited
|
||||
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]) => {
|
||||
@@ -4788,6 +4861,7 @@ Organization : OptiHK Limited
|
||||
|
||||
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);
|
||||
@@ -4810,6 +4884,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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');
|
||||
@@ -4832,6 +4907,7 @@ Organization : OptiHK Limited
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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)) {
|
||||
@@ -4868,6 +4944,7 @@ Organization : OptiHK Limited
|
||||
});
|
||||
}, [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;
|
||||
@@ -4924,6 +5001,7 @@ Organization : OptiHK Limited
|
||||
}).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;
|
||||
@@ -4963,6 +5041,7 @@ Organization : OptiHK Limited
|
||||
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);
|
||||
@@ -4976,6 +5055,7 @@ Organization : OptiHK Limited
|
||||
});
|
||||
}, [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;
|
||||
@@ -5021,10 +5101,12 @@ Organization : OptiHK Limited
|
||||
}).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;
|
||||
@@ -5050,11 +5132,13 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 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');
|
||||
@@ -5322,28 +5406,35 @@ Organization : OptiHK Limited
|
||||
});
|
||||
}, [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);
|
||||
@@ -5367,10 +5458,12 @@ Organization : OptiHK Limited
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// Toggle snap-to-grid movement in the editor.
|
||||
const toggleGridSnap = useCallback(() => {
|
||||
setGridSnap(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Toggle the measurement ruler and clear partial measurements.
|
||||
const toggleRulerMode = useCallback(() => {
|
||||
setRulerMode(prev => {
|
||||
const next = !prev;
|
||||
@@ -5383,6 +5476,7 @@ Organization : OptiHK Limited
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Convert a pane click or pointer event into canvas ruler coordinates.
|
||||
const eventToRulerPoint = useCallback((event) => {
|
||||
const rawPoint = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
return {
|
||||
@@ -5391,6 +5485,7 @@ Organization : OptiHK Limited
|
||||
};
|
||||
}, [reactFlowInstance, activeCanvasSize.width, activeCanvasSize.height]);
|
||||
|
||||
// Set ruler start/end points from canvas clicks.
|
||||
const handleRulerPaneClick = useCallback((event) => {
|
||||
if (!rulerMode || !reactFlowInstance || !activePage || activePage.type === 'layoutPreview') return;
|
||||
event.preventDefault();
|
||||
@@ -5411,12 +5506,14 @@ Organization : OptiHK Limited
|
||||
}
|
||||
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint, 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;
|
||||
setRulerPreviewPoint(eventToRulerPoint(event));
|
||||
}, [rulerMode, reactFlowInstance, activePage, rulerStartPoint, rulerEndPoint, eventToRulerPoint]);
|
||||
|
||||
// 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 => {
|
||||
@@ -5435,6 +5532,7 @@ Organization : OptiHK Limited
|
||||
}));
|
||||
}, [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;
|
||||
@@ -5469,6 +5567,7 @@ Organization : OptiHK Limited
|
||||
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]');
|
||||
@@ -5481,6 +5580,7 @@ Organization : OptiHK Limited
|
||||
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();
|
||||
@@ -5488,10 +5588,12 @@ Organization : OptiHK Limited
|
||||
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();
|
||||
@@ -5560,6 +5662,7 @@ Organization : OptiHK Limited
|
||||
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
|
||||
@@ -5610,10 +5713,12 @@ Organization : OptiHK Limited
|
||||
};
|
||||
}, [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]));
|
||||
@@ -5632,6 +5737,7 @@ Organization : OptiHK Limited
|
||||
return true;
|
||||
}, [technologyManifest, addLog]);
|
||||
|
||||
// Serialize a canvas page into the mxPIC YAML file format.
|
||||
const buildYamlForPage = useCallback((page) => {
|
||||
if (!page) return '';
|
||||
const header = `# =============================================
|
||||
@@ -5673,6 +5779,7 @@ ${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}`;
|
||||
@@ -5698,6 +5805,7 @@ ${bundlesBlock}`;
|
||||
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;
|
||||
@@ -5741,6 +5849,7 @@ ${bundlesBlock}`;
|
||||
}
|
||||
}, [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');
|
||||
@@ -5774,6 +5883,7 @@ ${bundlesBlock}`;
|
||||
}
|
||||
}, [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));
|
||||
@@ -5815,6 +5925,7 @@ ${bundlesBlock}`;
|
||||
}
|
||||
}, [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);
|
||||
|
||||
@@ -711,6 +711,7 @@ Organization : OptiHK Limited
|
||||
const logTerminal = document.getElementById('log-terminal');
|
||||
let technologies = [];
|
||||
|
||||
// Append a dashboard status message with a timestamp.
|
||||
function addLog(message) {
|
||||
const line = document.createElement('div');
|
||||
line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
@@ -718,6 +719,7 @@ Organization : OptiHK Limited
|
||||
logTerminal.scrollTop = logTerminal.scrollHeight;
|
||||
}
|
||||
|
||||
// Apply the selected dashboard theme class and persist it for later sessions.
|
||||
function applyTheme(mode) {
|
||||
document.body.classList.toggle('light-mode', mode === 'light');
|
||||
themeToggle.textContent = mode === 'light' ? 'Dark Mode' : 'Bright Mode';
|
||||
@@ -729,10 +731,12 @@ Organization : OptiHK Limited
|
||||
applyTheme(document.body.classList.contains('light-mode') ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
// Navigate from the dashboard into the canvas editor for a saved project.
|
||||
function openProject(name) {
|
||||
window.location.href = `/canvas?project=${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
// Load account profile details and available occupation choices.
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const response = await fetch('/api/profile');
|
||||
@@ -809,6 +813,7 @@ Organization : OptiHK Limited
|
||||
addLog('Password updated.');
|
||||
});
|
||||
|
||||
// Fetch saved projects and render the dashboard project list.
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
@@ -889,6 +894,7 @@ Organization : OptiHK Limited
|
||||
openProject(project.name);
|
||||
});
|
||||
|
||||
// Fetch available foundry/technology choices for new project creation.
|
||||
async function loadTechnologies() {
|
||||
const response = await fetch('/api/technologies');
|
||||
const data = await response.json();
|
||||
|
||||
@@ -416,6 +416,7 @@ Organization : OptiHK Limited
|
||||
<script>
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
|
||||
// Apply the selected login-page theme class and persist it for the next visit.
|
||||
function applyTheme(mode) {
|
||||
document.body.classList.toggle('light-mode', mode === 'light');
|
||||
themeToggle.textContent = mode === 'light' ? 'Dark Mode' : 'Bright Mode';
|
||||
|
||||
Reference in New Issue
Block a user