mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
update to node site
This commit is contained in:
672
website/scripts/main.js
Normal file
672
website/scripts/main.js
Normal file
@@ -0,0 +1,672 @@
|
||||
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";
|
||||
|
||||
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());
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize dragging as disabled
|
||||
dragger.enabled = false;
|
||||
pinConns.enabled = false;
|
||||
Reference in New Issue
Block a user