Files
lit.ruv.wtf/website/scripts/main.js

775 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { WorkspaceNavigator } from "./WorkspaceNavigator.js";
import { NodeRenderer } from "./NodeRenderer.js";
import { ConnectionRenderer } from "./ConnectionRenderer.js";
import { GraphNode } from "./GraphNode.js";
import { GraphConnection } from "./GraphConnection.js";
import { GraphComment } from "./GraphComment.js";
import { DebugPanel } from "./DebugPanel.js";
import { PropertiesPanel } from "./PropertiesPanel.js";
import { ItemDragger } from "./ItemDragger.js";
import { PinConnectionManager } from "./PinConnectionManager.js";
import { GraphExecutor } from "./GraphExecutor.js";
import { PrintBubble } from "./PrintBubble.js";
import { SpatialAudio } from "./SpatialAudio.js";
import { getTypeColor } from "./getTypeColor.js";
import { NodeContextMenu } from "./NodeContextMenu.js";
import { NodeRegistry } from "./nodes/NodeRegistry.js";
const canvas = document.getElementById("workspaceCanvas");
const worldLayer = document.getElementById("worldLayer");
const nodeLayer = document.getElementById("nodeLayer");
const connLayerSvg = document.getElementById("connectionLayer");
const worldBoxLayer = document.getElementById("worldBoxLayer");
const viewboxLayer = document.getElementById("viewboxLayer");
const jsonEditorModal = document.getElementById("jsonEditorModal");
const jsonEditorTextarea = document.getElementById("jsonEditorTextarea");
const resetBtn = document.getElementById("resetViewBtn");
const draggableToggle = document.getElementById("draggableToggle");
const debugPanelEl = document.getElementById("debugPanel");
const propertiesPanelEl = document.getElementById("propertiesPanel");
/** @type {HTMLTemplateElement} */
const nodeTemplate = document.getElementById("nodeTemplate");
/** @type {HTMLTemplateElement} */
const pinTemplate = document.getElementById("pinTemplate");
const nav = new WorkspaceNavigator(canvas, worldLayer);
const audio = new SpatialAudio(() => nav.getViewInfo().viewbox);
const nodeRend = new NodeRenderer(nodeLayer, nodeTemplate, pinTemplate, (nodeId, pinId, direction) => {
const targetId = nodeRend.getConnectedNodeId(nodeId, pinId, direction);
if (!targetId) return;
const rect = nodeRend.getNodeWorldRect(targetId);
if (!rect) return;
const focusOptions = nodeRend.getNodes().find(n => n.id === targetId)?.focusOptions ?? {};
nav.animateFocusOnRect(rect, focusOptions);
history.pushState({ nodeId: targetId }, "", `#${targetId}`);
}, (nodeId, pinId) => executor.execute(nodeId, pinId), () => connRend.render());
/** @type {Map<string, PrintBubble>} */
const printBubbles = new Map();
const executor = new GraphExecutor(nodeRend, (nodeId, value) => {
printBubbles.get(nodeId)?.push(value);
// Play spatial audio at node position
const node = nodeRend.getNodes().find(n => n.id === nodeId);
if (node) {
const rect = nodeRend.getNodeWorldRect(nodeId);
if (rect) {
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
audio.play('pop', centerX, centerY, rect.width, rect.height);
}
}
}, async (connId, kind) => {
await connRend.activatePath(connId, kind === "exec" ? 200 : 150);
});
const connRend = new ConnectionRenderer(connLayerSvg, canvas, nodeRend, nav);
const pinConns = new PinConnectionManager(canvas, connLayerSvg, nodeLayer, nodeRend, nav, connRend);
// Properties Panel - Initialize early so it can be referenced
const propertiesPanel = new PropertiesPanel(propertiesPanelEl, () => {
connRend.render();
renderWorldBoxes();
renderViewbox();
});
const dragger = new ItemDragger(canvas, nav, nodeRend,
() => {
const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null;
debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode);
propertiesPanel.loadNode(activeNode);
},
() => { connRend.render(); }
);
// Suppress navigator pan while a node drag is actively in progress (enables edge-pan on mobile + mouse)
nav.setDragInProgressChecker(() => dragger.isDragging);
// Drop the active drag immediately when a pinch-to-zoom starts so the two gestures don't conflict
nav.setOnPinchStart(() => dragger.cancelDrag());
// Node context menu for adding nodes
const nodeContextMenu = new NodeContextMenu(canvas, (type, worldX, worldY) => {
// Generate unique node ID
const existingIds = nodeRend.getNodes().map(n => n.id);
let counter = 1;
let newId = type;
while (existingIds.includes(newId)) {
counter++;
newId = `${type}_${counter}`;
}
// Get default pins from node definition
const pins = NodeRegistry.BlueprintPure_GetDefaultPins(type);
// Create and add new node
const newNode = new GraphNode({
id: newId,
type: type,
title: type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
position: { x: Math.round(worldX), y: Math.round(worldY) },
inputs: pins.inputs.map(p => ({ ...p })),
outputs: pins.outputs.map(p => ({ ...p })),
userSpawned: true
});
nodeRend.addNode(newNode);
dragger.registerNode(newId);
connRend.render();
// Attach PrintBubble if it's a print node
if (type === "print") {
const el = nodeRend.getNodeElements().get(newId);
if (el) printBubbles.set(newId, new PrintBubble(el));
}
});
const debugPanel = new DebugPanel(debugPanelEl,
(enabled) => { dragger.enabled = enabled; pinConns.enabled = enabled; },
() => {
const liveGraph = {
settings: graphData.settings,
images: graphData.images,
nodes: nodeRend.getNodes().map(n => ({
id: n.id,
type: n.type,
title: n.title,
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
...(n.width !== undefined ? { width: n.width } : {}),
...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}),
...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}),
...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}),
...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}),
inputs: n.inputs,
outputs: n.outputs,
})),
connections: nodeRend.getConnections().map(c => ({
id: c.id,
from: c.from,
to: c.to,
kind: c.kind,
})),
};
navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2));
}, (nodeId, updates) => {
const node = nodeRend.getNodes().find(n => n.id === nodeId);
if (!node) return;
let needsRerender = false;
if (updates.title !== undefined) node.title = updates.title;
if (updates.position) {
nodeRend.setNodePosition(nodeId, updates.position.x, updates.position.y);
}
if (updates.width !== undefined) {
node.width = updates.width;
const el = nodeRend.getNodeElement(nodeId);
if (el) el.style.width = updates.width != null ? `${updates.width}px` : '';
}
if (updates.inputs !== undefined) {
node.inputs = updates.inputs;
needsRerender = true;
}
if (updates.outputs !== undefined) {
node.outputs = updates.outputs;
needsRerender = true;
}
const nodeEl = nodeRend.getNodeElement(nodeId);
if (nodeEl && updates.title !== undefined) {
const titleEl = nodeEl.querySelector('.node-title');
if (titleEl) titleEl.textContent = updates.title;
}
// Re-render node if pins changed
if (needsRerender && nodeEl) {
const parent = nodeEl.parentElement;
nodeEl.remove();
// Re-add using internal render method
const fragment = nodeTemplate.content.cloneNode(true);
const article = fragment.querySelector('.blueprint-node');
const title = article.querySelector('.node-title');
const inputs = article.querySelector('.node-inputs');
const outputs = article.querySelector('.node-outputs');
article.dataset.nodeId = node.id;
article.dataset.nodeType = node.type;
title.textContent = node.title;
article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`;
if (node.width != null) article.style.width = `${node.width}px`;
// Re-render pins using pinTemplate
inputs.innerHTML = '';
outputs.innerHTML = '';
node.inputs.forEach(pin => {
const pinFrag = pinTemplate.content.cloneNode(true);
const container = pinFrag.firstElementChild;
const label = container.querySelector('.pin-label');
const handle = container.querySelector('.pin-handle');
container.dataset.pinId = pin.id;
container.dataset.type = pin.kind;
container.dataset.direction = 'input';
container.classList.add('is-disconnected');
container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind));
const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out');
if (isStandardExec && !pin.name) {
label.textContent = '';
label.classList.add('is-hidden');
} else {
label.textContent = pin.name;
}
inputs.appendChild(container);
});
node.outputs.forEach(pin => {
const pinFrag = pinTemplate.content.cloneNode(true);
const container = pinFrag.firstElementChild;
const label = container.querySelector('.pin-label');
const handle = container.querySelector('.pin-handle');
container.dataset.pinId = pin.id;
container.dataset.type = pin.kind;
container.dataset.direction = 'output';
container.classList.add('is-disconnected');
container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind));
const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out');
if (isStandardExec && !pin.name) {
label.textContent = '';
label.classList.add('is-hidden');
} else {
label.textContent = pin.name;
}
outputs.appendChild(container);
});
parent?.appendChild(article);
// Update internal node elements map
const nodeElements = nodeRend.getNodeElements();
nodeElements.set(nodeId, article);
// Update debug panel with refreshed node
const activeNode = nodeRend.getNodes().find(n => n.id === dragger.activeId);
debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode);
}
connRend.render();
},
(enabled) => {
// WorldBox toggle
if (worldBoxLayer) {
worldBoxLayer.style.display = enabled ? 'block' : 'none';
renderWorldBoxes();
}
},
() => {
// Copy entire graph as JSON
const liveGraph = {
settings: graphData.settings,
images: graphData.images,
comments: graphData.comments,
nodes: nodeRend.getNodes().map(n => ({
id: n.id,
type: n.type,
title: n.title,
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
...(n.width !== undefined ? { width: n.width } : {}),
...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}),
...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}),
...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}),
...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}),
inputs: n.inputs,
outputs: n.outputs,
})),
connections: nodeRend.getConnections().map(c => ({
id: c.id,
from: c.from,
to: c.to,
kind: c.kind,
})),
};
navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2));
},
(enabled) => {
// Viewbox toggle
if (viewboxLayer) {
viewboxLayer.style.display = enabled ? 'block' : 'none';
renderViewbox();
}
},
() => {
// Show Properties Panel
propertiesPanel.expand();
}
);
// Connection SVG is screen-space — must re-render on every pan/zoom so paths track node positions.
let hasTransformed = false;
let trackTransform = false;
nav.setOnAfterTransform(() => {
connRend.render();
renderWorldBoxes();
renderViewbox();
const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null;
debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode);
if (trackTransform && !hasTransformed) {
hasTransformed = true;
canvas.classList.add('has-transformed');
}
});
// ─── WorldBox Visualizer ──────────────────────────────────────────────────────
/**
* Renders worldbox overlays for all nodes with focusOptions
*/
function renderWorldBoxes() {
if (!worldBoxLayer || worldBoxLayer.style.display === 'none') return;
// Clear existing
worldBoxLayer.innerHTML = '';
const viewInfo = nav.getViewInfo();
const zoom = viewInfo.zoom;
const offset = nav.getEffectiveOffset();
const canvasRect = canvas.getBoundingClientRect();
nodeRend.getNodes().forEach(node => {
if (!node.focusOptions?.minWorldBox && !node.focusOptions?.responsiveWorldBox) return;
const rect = nodeRend.getNodeWorldRect(node.id);
if (!rect) return;
const nodeCenterX = rect.x + rect.width / 2;
const nodeCenterY = rect.y + rect.height / 2;
// Determine which breakpoint would be active at current viewport width
const vw = window.innerWidth;
let activeBreakpointIndex = -1; // -1 = base worldBox is active
if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) {
const sorted = [...node.focusOptions.responsiveWorldBox]
.map((bp, i) => ({ bp, i }))
.sort((a, b) => (a.bp.minViewportWidth ?? 0) - (b.bp.minViewportWidth ?? 0));
for (const { bp, i } of sorted) {
if (vw < (bp.minViewportWidth ?? Infinity)) {
activeBreakpointIndex = i;
break;
}
}
}
// Render base worldbox if it exists
if (node.focusOptions.minWorldBox) {
const worldBox = node.focusOptions.minWorldBox;
const anchorX = node.focusOptions.anchorX ?? 0.5;
const anchorY = node.focusOptions.anchorY ?? 0.5;
const isActive = activeBreakpointIndex === -1;
// The viewport region is offset from the node center based on anchor.
// anchor=0.5 means node is centered in viewport (no offset).
// The box shifts so the node sits at the anchor position within it.
const boxX = nodeCenterX - worldBox.width * anchorX;
const boxY = nodeCenterY - worldBox.height * anchorY;
// Convert to screen space
const screenX = (boxX + offset.x) * zoom;
const screenY = (boxY + offset.y) * zoom;
const screenW = worldBox.width * zoom;
const screenH = worldBox.height * zoom;
const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect');
rectEl.setAttribute('x', String(screenX));
rectEl.setAttribute('y', String(screenY));
rectEl.setAttribute('width', String(screenW));
rectEl.setAttribute('height', String(screenH));
worldBoxLayer.appendChild(rectEl);
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label');
textEl.setAttribute('x', String(screenX + 6));
textEl.setAttribute('y', String(screenY + 14));
textEl.textContent = `${node.id} (${worldBox.width}×${worldBox.height})`;
worldBoxLayer.appendChild(textEl);
// Draw crosshair at the node center (the focus point)
const anchorScreenX = (nodeCenterX + offset.x) * zoom;
const anchorScreenY = (nodeCenterY + offset.y) * zoom;
const crossSize = 8;
const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
hLine.setAttribute('x1', String(anchorScreenX - crossSize));
hLine.setAttribute('y1', String(anchorScreenY));
hLine.setAttribute('x2', String(anchorScreenX + crossSize));
hLine.setAttribute('y2', String(anchorScreenY));
worldBoxLayer.appendChild(hLine);
const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
vLine.setAttribute('x1', String(anchorScreenX));
vLine.setAttribute('y1', String(anchorScreenY - crossSize));
vLine.setAttribute('x2', String(anchorScreenX));
vLine.setAttribute('y2', String(anchorScreenY + crossSize));
worldBoxLayer.appendChild(vLine);
}
// Render responsive worldboxes
if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) {
node.focusOptions.responsiveWorldBox.forEach((responsive, index) => {
const worldBox = responsive.minWorldBox;
if (!worldBox) return;
const anchorX = responsive.anchorX ?? 0.5;
const anchorY = responsive.anchorY ?? 0.5;
const isActive = activeBreakpointIndex === index;
// The viewport region is offset from the node center based on anchor.
const boxX = nodeCenterX - worldBox.width * anchorX;
const boxY = nodeCenterY - worldBox.height * anchorY;
// Convert to screen space
const screenX = (boxX + offset.x) * zoom;
const screenY = (boxY + offset.y) * zoom;
const screenW = worldBox.width * zoom;
const screenH = worldBox.height * zoom;
const opacity = isActive ? '1' : String(0.4 + (index * 0.15));
const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect');
rectEl.setAttribute('x', String(screenX));
rectEl.setAttribute('y', String(screenY));
rectEl.setAttribute('width', String(screenW));
rectEl.setAttribute('height', String(screenH));
rectEl.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(rectEl);
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label');
textEl.setAttribute('x', String(screenX + 6));
textEl.setAttribute('y', String(screenY + 14 + (index * 16)));
textEl.textContent = `@${responsive.minViewportWidth}px: ${worldBox.width}×${worldBox.height}`;
textEl.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(textEl);
// Draw crosshair at the node center (the focus point)
const anchorScreenX = (nodeCenterX + offset.x) * zoom;
const anchorScreenY = (nodeCenterY + offset.y) * zoom;
const crossSize = 8;
const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
hLine.setAttribute('x1', String(anchorScreenX - crossSize));
hLine.setAttribute('y1', String(anchorScreenY));
hLine.setAttribute('x2', String(anchorScreenX + crossSize));
hLine.setAttribute('y2', String(anchorScreenY));
hLine.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(hLine);
const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor');
vLine.setAttribute('x1', String(anchorScreenX));
vLine.setAttribute('y1', String(anchorScreenY - crossSize));
vLine.setAttribute('x2', String(anchorScreenX));
vLine.setAttribute('y2', String(anchorScreenY + crossSize));
vLine.setAttribute('opacity', opacity);
worldBoxLayer.appendChild(vLine);
});
}
});
}
// ─── Viewbox Visualizer ───────────────────────────────────────────────────────
/**
* Renders the current viewbox overlay showing visible world area
*/
function renderViewbox() {
if (!viewboxLayer || viewboxLayer.style.display === 'none') return;
// Clear existing
viewboxLayer.innerHTML = '';
const viewInfo = nav.getViewInfo();
const zoom = viewInfo.zoom;
const offset = nav.getEffectiveOffset();
const viewbox = viewInfo.viewbox;
// Convert viewbox (world coords) to screen space
const screenX = (viewbox.x + offset.x) * zoom;
const screenY = (viewbox.y + offset.y) * zoom;
const screenW = viewbox.width * zoom;
const screenH = viewbox.height * zoom;
// Create rectangle
const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectEl.setAttribute('class', 'viewbox-rect');
rectEl.setAttribute('x', String(screenX));
rectEl.setAttribute('y', String(screenY));
rectEl.setAttribute('width', String(screenW));
rectEl.setAttribute('height', String(screenH));
viewboxLayer.appendChild(rectEl);
// Add label
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textEl.setAttribute('class', 'viewbox-label');
textEl.setAttribute('x', String(screenX + 10));
textEl.setAttribute('y', String(screenY + 20));
textEl.textContent = `Viewbox (${Math.round(viewbox.width)}×${Math.round(viewbox.height)})`;
viewboxLayer.appendChild(textEl);
}
// ─── Load graph from JSON ──────────────────────────────────────────────────
const response = await fetch("data/graph.json");
let graphData = await response.json();
// Load spatial audio
await audio.load('pop', 'data/pop.mp3');
await audio.load('denied', 'data/denied.mp3');
graphData.comments?.forEach(c => nodeRend.addComment(new GraphComment(c)));
graphData.nodes.forEach(n => nodeRend.addNode(new GraphNode(n)));
graphData.connections.forEach(c => nodeRend.addConnection(new GraphConnection(c.from, c.to, c.kind, c.id)));
// Load graph settings into properties panel
propertiesPanel.loadGraphSettings(graphData);
// Attach a PrintBubble to every print-type node
graphData.nodes.forEach(n => {
if (n.type !== "print") return;
const el = nodeRend.getNodeElements().get(n.id);
if (el) printBubbles.set(n.id, new PrintBubble(el));
});
for (const img of graphData.images ?? []) {
if (img.inline) {
// Fetch and inline SVG for direct style control
try {
const response = await fetch(img.src);
const svgText = await response.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
const svgEl = svgDoc.documentElement;
svgEl.classList.add('world-image');
// Remove percentage dimensions, set explicit width
svgEl.removeAttribute('height');
svgEl.setAttribute('width', String(img.width));
// Preserve viewBox for aspect ratio
if (!svgEl.hasAttribute('viewBox')) {
const w = svgEl.getAttribute('width');
const h = svgEl.getAttribute('height');
if (w && h) svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
}
svgEl.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`;
svgEl.style.display = 'block';
// Apply cssStyle to all paths/shapes in SVG
const shapes = svgEl.querySelectorAll('path, rect, circle, ellipse, polygon, polyline');
if (img.cssStyle) {
const styleProps = img.cssStyle.split(';').filter(s => s.trim());
styleProps.forEach(prop => {
const [key, val] = prop.split(':').map(s => s.trim());
if (!key || !val) return;
if (key === 'fill' || key === 'stroke' || key === 'stroke-width' || key === 'opacity') {
shapes.forEach(el => {
// Skip elements with fill="none" (background/artboard rects)
if (el.getAttribute('fill') === 'none') return;
el.style[key] = val;
});
} else {
svgEl.style[key] = val;
}
});
} else {
// Default fill to white if no style specified
shapes.forEach(el => {
if (el.getAttribute('fill') === 'none') return; // Skip background
if (!el.hasAttribute('fill') && !el.style.fill) {
el.style.fill = '#ffffff';
}
});
}
worldLayer.prepend(svgEl);
dragger.registerImage(img.src, svgEl, { ...img.position });
console.log('Inlined SVG:', img.src, svgEl);
} catch (err) {
console.error(`Failed to inline SVG: ${img.src}`, err);
}
} else {
// Standard img element
const el = document.createElement("img");
el.className = "world-image";
el.src = img.src;
el.width = img.width;
el.alt = "";
el.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`;
if (img.cssStyle) {
el.style.cssText += `;${img.cssStyle}`;
}
el.addEventListener("dragstart", (e) => e.preventDefault());
worldLayer.prepend(el);
dragger.registerImage(img.src, el, { ...img.position });
}
}
graphData.nodes.forEach(n => dragger.registerNode(n.id));
// ─── Initial focus ─────────────────────────────────────────────────────────
const settings = graphData.settings ?? {};
const introDurationMs = settings.introDurationMs ?? 700;
const initialViewbox = settings.initialViewbox ?? { x: 0, y: 0, width: 1280, height: 720 };
const initialDraggable = settings.draggable ?? false;
dragger.enabled = initialDraggable;
pinConns.enabled = initialDraggable;
debugPanel.setDraggable(initialDraggable);
// Snap to a tiny zoom first so the intro animation has somewhere to travel from
const introRect = {
x: initialViewbox.x + initialViewbox.width / 2 - 50,
y: initialViewbox.y + initialViewbox.height / 2 - 50,
width: 100,
height: 100,
};
const anchorId = location.hash.slice(1);
const anchorNode = anchorId ? nodeRend.getNodes().find(n => n.id === anchorId) : null;
const anchorRect = anchorNode ? nodeRend.getNodeWorldRect(anchorNode.id) : null;
// Seed the current history entry so popstate can restore it when navigating back
history.replaceState({ nodeId: anchorId || null }, "");
nav.focusOnRect(introRect);
// Initial connection render — one rAF so layout is measured after nodes are in the DOM.
requestAnimationFrame(() => connRend.render());
if (anchorNode) {
// Wait for all markdown to finish loading so info nodes have their real height,
// then one rAF to let the browser measure layout before animating.
nodeRend.contentReady().then(() => requestAnimationFrame(() => {
const rect = nodeRend.getNodeWorldRect(anchorNode.id);
if (rect) {
nav.animateFocusOnRect(rect, { ...(anchorNode.focusOptions ?? {}), durationMs: introDurationMs });
} else {
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
}
setTimeout(() => { trackTransform = true; }, introDurationMs + 100);
}));
} else {
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
setTimeout(() => { trackTransform = true; }, introDurationMs + 100);
}
resetBtn.addEventListener("click", () => {
history.pushState({ nodeId: null }, "", location.pathname + location.search);
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
});
window.addEventListener("popstate", (e) => {
const nodeId = (e.state?.nodeId ?? location.hash.slice(1)) || null;
if (nodeId) {
const node = nodeRend.getNodes().find(n => n.id === nodeId);
if (node) {
nodeRend.contentReady().then(() => requestAnimationFrame(() => {
const rect = nodeRend.getNodeWorldRect(node.id);
if (rect) nav.animateFocusOnRect(rect, { ...(node.focusOptions ?? {}), durationMs: introDurationMs });
}));
return;
}
}
nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs });
});
draggableToggle.addEventListener("click", () => {
const isActive = draggableToggle.classList.toggle("active");
dragger.enabled = isActive;
pinConns.enabled = isActive;
});
// Clear selection when clicking on canvas background
canvas.addEventListener("click", (e) => {
// Only clear if clicking directly on canvas (not on nodes or other elements)
if (e.target === canvas) {
dragger.clearSelection();
propertiesPanel.loadNode(null);
}
});
// Delete key to remove selected node
document.addEventListener("keydown", (e) => {
// Skip if user is typing in an input
if (e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target.isContentEditable) {
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
const activeId = dragger.getActiveId();
if (activeId) {
const node = nodeRend.getNodes().find(n => n.id === activeId);
if (node?.userSpawned) {
e.preventDefault();
dragger.unregisterNode(activeId);
nodeRend.removeNode(activeId);
printBubbles.delete(activeId);
propertiesPanel.loadNode(null);
connRend.render();
} else if (node) {
// Node can't be deleted - shake it red and play denied sound
e.preventDefault();
const nodeEl = nodeRend.getNodeElements().get(activeId);
if (nodeEl) {
nodeEl.classList.add('node-shake-red');
nodeEl.addEventListener('animationend', () => {
nodeEl.classList.remove('node-shake-red');
}, { once: true });
}
// Play denied sound at node position
const rect = nodeRend.getNodeWorldRect(activeId);
if (rect) {
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
audio.play('denied', centerX, centerY, rect.width, rect.height);
}
}
}
}
});
// Right-click context menu for adding nodes
canvas.addEventListener("contextmenu", (e) => {
e.preventDefault();
// Don't show menu if we just finished a pan drag
if (nav.didPanRecently(150)) {
return;
}
// Convert client coords to world coords
const rect = canvas.getBoundingClientRect();
const zoom = Math.max(0.01, nav.zoomLevel || 1);
const offset = nav.getEffectiveOffset();
const worldX = (e.clientX - rect.left) / zoom - offset.x;
const worldY = (e.clientY - rect.top) / zoom - offset.y;
nodeContextMenu.show(e.clientX, e.clientY, worldX, worldY);
});
// Initialize dragging as disabled
dragger.enabled = false;
pinConns.enabled = false;