import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import type { Raster } from './raster'; import { DiagramModel, EdgeShape, FrameShape, LabelShape, NodeShape, effectiveOpacity, FLOW_SELECTOR } from './svgModel'; /** * The 3D scene. World units are Archify's SVG user units: x is the SVG x, * z the SVG y, and y points up. The floor is the diagram itself; nodes rise * from it as slabs whose top face is the node as Archify draws it. */ const NODE_HEIGHT = 16; /** Pipes run at mid-height of the blocks, so they plug into their sides. */ const PIPE_Y = NODE_HEIGHT / 2; /** Height offsets that let crossing pipes pass over each other. */ const PIPE_LANES = [0, 3.5, -3.5]; const FRAME_WALL = 12; const FLY_MS = 650; /** Glowing flows (focus pulse, traces) run in a slightly wider pipe over the relationship. */ const FLOW_SCALE = 1.4; /** Height tier per semantic kind, for the stacked view. */ const KIND_TIER: Record = { external: 3, frontend: 3, security: 2, backend: 1, cloud: 1, messagebus: 1, database: 0 }; export interface Layers { floor: Raster; nodes: Raster; labels: Raster; } export interface Palette { mask: string; dark: boolean; } interface NodeView { shape: NodeShape; group: THREE.Group; body: THREE.Mesh; materials: THREE.Material[]; lift: number; /** Extra height while Archify's node pulse plays. */ pulse: number; /** Opacity at build time; live dimming (intent trace) multiplies it. */ baseOpacity: number; dim: number; } interface EdgeView { shape: EdgeShape; tube: THREE.Mesh; material: THREE.MeshStandardMaterial; cap: THREE.Mesh; arrow?: THREE.Mesh; arrowLength: number; radius: number; bias: number; /** Opacity of the path's ancestors; the path's own opacity is read live. */ baseOpacity: number; /** Archify's dashes are sized for a thin stroke; pipes stretch them by this. */ dashScale: number; /** The route as built, for flows that run along it. */ path?: THREE.CurvePath; pipeLength: number; /** Last mirrored stroke-dasharray, stroke-dashoffset and opacity. */ mirrored: string; } /** A glowing flow Archify draws over a relationship (focus pulse, trace, lens…). */ interface FlowView { el: SVGPathElement; edge: EdgeView; mesh: THREE.Mesh; material: THREE.MeshBasicMaterial; } interface LabelView { shape: LabelShape; mesh: THREE.Mesh; edge?: EdgeView; } interface FrameView { shape: FrameShape; wall: THREE.Mesh; rim: THREE.Line; members: NodeShape[]; } export interface CameraState { position: [number, number, number]; target: [number, number, number]; } /** * True for a trackpad two-finger scroll, false for a mouse wheel. Trackpads * send pixel deltas, often sideways or fractional; wheels send coarse * vertical steps. */ function isTrackpadScroll(event: WheelEvent): boolean { if (event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) return false; if (event.deltaX !== 0) return true; return !Number.isInteger(event.deltaY) || Math.abs(event.deltaY) < 40; } const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; function cssColor(value: string, fallback = '#888'): THREE.Color { try { return new THREE.Color().setStyle(value && value !== 'none' ? value : fallback); } catch { return new THREE.Color(fallback); } } function canvasTexture(canvas: HTMLCanvasElement): THREE.CanvasTexture { const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = 8; texture.generateMipmaps = true; texture.minFilter = THREE.LinearMipmapLinearFilter; return texture; } /** A soft blurred rectangle, stretched under each node as a contact shadow. */ const shadowTexture = (() => { let texture: THREE.CanvasTexture | undefined; return () => { if (texture) return texture; const canvas = document.createElement('canvas'); canvas.width = canvas.height = 128; const ctx = canvas.getContext('2d')!; ctx.filter = 'blur(12px)'; ctx.fillStyle = '#000'; ctx.fillRect(28, 28, 72, 72); texture = new THREE.CanvasTexture(canvas); return texture; }; })(); /** Parses a computed stroke-dasharray ("10px, 8px", "none") into numbers. */ function parseDashes(value: string): number[] { if (!value || value === 'none') return []; return value.split(/[\s,]+/).map((v) => parseFloat(v)).filter((v) => v >= 0 && Number.isFinite(v)); } const dashCache = new Map(); /** Alpha stripes for a dash pattern; textures are shared per pattern. */ function dashFor(dasharray: number[]): { texture: THREE.Texture; period: number } | undefined { if (!dasharray.length || dasharray.every((d) => d === 0)) return undefined; const key = dasharray.map((d) => d.toFixed(4)).join(','); let entry = dashCache.get(key); if (!entry) { entry = dashTexture(dasharray); dashCache.set(key, entry); } // A clone shares the image but has its own repeat/offset. const texture = entry.texture.clone(); texture.needsUpdate = true; return { texture, period: entry.period }; } /** Alpha stripes for a dashed relationship, from its stroke-dasharray. */ function dashTexture(dasharray: number[]): { texture: THREE.CanvasTexture; period: number } { const pattern = dasharray.length % 2 ? [...dasharray, ...dasharray] : dasharray; const period = pattern.reduce((a, b) => a + b, 0); const canvas = document.createElement('canvas'); // Enough pixels per period for both 10px dashes and pathLength=1 patterns. const px = Math.min(4096, Math.max(8, 512 / Math.max(period, 1e-6))) ; canvas.width = Math.max(2, Math.min(4096, Math.round(period * px))); canvas.height = 1; const ctx = canvas.getContext('2d')!; let x = 0; pattern.forEach((len, i) => { ctx.fillStyle = i % 2 ? '#000' : '#fff'; ctx.fillRect(Math.round(x * px), 0, Math.ceil(len * px), 1); x += len; }); const texture = new THREE.CanvasTexture(canvas); texture.wrapS = THREE.RepeatWrapping; texture.magFilter = THREE.NearestFilter; return { texture, period }; } function disposeObject(root: THREE.Object3D): void { root.traverse((obj) => { const mesh = obj as THREE.Mesh; mesh.geometry?.dispose(); const material = mesh.material as THREE.Material | THREE.Material[] | undefined; for (const m of Array.isArray(material) ? material : material ? [material] : []) { for (const value of Object.values(m)) if (value instanceof THREE.Texture && value !== shadowTexture() && ![...dashCache.values()].some((d) => d.texture === value)) value.dispose(); m.dispose(); } }); } export class Stage { readonly element: HTMLDivElement; private readonly renderer: THREE.WebGLRenderer; private readonly scene = new THREE.Scene(); private readonly camera = new THREE.PerspectiveCamera(32, 1, 1, 40000); private readonly controls: OrbitControls; private world = new THREE.Group(); private model: DiagramModel | undefined; private nodes = new Map(); private edges: EdgeView[] = []; private flows = new Map(); /** Edge views by their SVG path data, to place Archify's flow overlays. */ private edgesByPath = new Map(); private glowColor = new THREE.Color('#34d399'); private pulsing = new Set(); /** Something changed that the next frame must draw. */ private dirty = true; private motionActive = false; private labels: LabelView[] = []; private frames: FrameView[] = []; private textures: THREE.Texture[] = []; private levels = new Map(); private levelGap = 60; private levelMix = 0; private levelTarget = 0; private hovered: string | undefined; private frameId = 0; private lastTime = 0; private flight: { from: THREE.Vector3; to: THREE.Vector3; fromTarget: THREE.Vector3; toTarget: THREE.Vector3; start: number } | undefined; private fitted = false; private readonly raycaster = new THREE.Raycaster(); private downAt: { x: number; y: number } | undefined; private panTimer: number | undefined; /** Called with a node id, or undefined for empty space. */ onPick: (id: string | undefined) => void = () => {}; /** Called when the pointer moves onto another node, or off all nodes. */ onHover: (id: string | undefined) => void = () => {}; /** Called when the camera settles after the reader moves it. */ onCameraChange: () => void = () => {}; constructor() { this.element = document.createElement('div'); this.element.className = 'archify-3d-stage'; this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); // Full retina density costs 4x the pixels for little gain on a moving scene. this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); this.renderer.setClearColor(0x000000, 0); this.element.appendChild(this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.09; this.controls.screenSpacePanning = false; this.controls.maxPolarAngle = Math.PI * 0.47; this.controls.zoomToCursor = true; this.controls.addEventListener('end', () => this.onCameraChange()); this.controls.addEventListener('change', () => (this.dirty = true)); this.scene.add(new THREE.HemisphereLight(0xffffff, 0x8090a0, 2.2)); const sun = new THREE.DirectionalLight(0xffffff, 1.4); sun.position.set(-0.4, 1, 0.6); this.scene.add(sun); this.scene.add(this.world); const canvas = this.renderer.domElement; // Keep Archify's 2D pan and zoom (which start on the container) out of // 3D input. pointermove/pointerup must still reach the document, where // OrbitControls tracks a drag. for (const type of ['pointerdown', 'wheel', 'dblclick']) { canvas.addEventListener(type, (event) => event.stopPropagation()); } canvas.addEventListener('pointerdown', (event) => (this.downAt = { x: event.clientX, y: event.clientY })); // Archify dismisses focus on document-level clicks outside the SVG. Catch // the canvas click at window capture, before those listeners, and hand // Archify a click on the node instead. window.addEventListener( 'click', (event) => { if (event.target !== canvas) return; event.stopPropagation(); const still = this.downAt && Math.hypot(event.clientX - this.downAt.x, event.clientY - this.downAt.y) <= 4; this.downAt = undefined; if (still) this.onPick(this.pick(event)); }, true, ); canvas.addEventListener('pointermove', (event) => { const id = this.pick(event); if (id !== this.hovered) { this.dirty = true; this.onHover(id); } this.hovered = id; canvas.style.cursor = id ? 'pointer' : 'grab'; }); canvas.addEventListener('pointerleave', () => { if (this.hovered) this.onHover(undefined); this.hovered = undefined; this.dirty = true; }); canvas.addEventListener('dblclick', (event) => { const id = this.pick(event); if (id) this.focusNode(id); else this.fit(); }); // Trackpad scrolling pans across the floor, in both directions, like // scrolling over the 2D diagram. Pinch (ctrl+wheel) and mouse wheels // fall through to OrbitControls, which zooms. this.element.addEventListener( 'wheel', (event) => { if (event.ctrlKey || !isTrackpadScroll(event)) return; event.preventDefault(); event.stopPropagation(); this.flight = undefined; this.panBy(event.deltaX, event.deltaY); }, { capture: true, passive: false }, ); new ResizeObserver(() => this.resize()).observe(this.element); } /** Moves camera and target along the floor by a scroll delta in pixels. */ private panBy(dx: number, dy: number): void { const distance = this.camera.position.distanceTo(this.controls.target); const perPixel = (2 * distance * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))) / Math.max(1, this.element.clientHeight); const forward = this.controls.target.clone().sub(this.camera.position).setY(0); if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1).applyQuaternion(this.camera.quaternion).setY(0); if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1); forward.normalize(); const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); // Scrolling down reveals what is below: move towards the reader. const move = right.multiplyScalar(dx * perPixel).addScaledVector(forward, -dy * perPixel); this.camera.position.add(move); this.controls.target.add(move); this.controls.update(); window.clearTimeout(this.panTimer); this.panTimer = window.setTimeout(() => this.onCameraChange(), 250); } // ---------------------------------------------------------------- build /** Rebuilds the scene from the current SVG state. The camera is kept. */ build(model: DiagramModel, layers: Layers, palette: Palette): void { disposeObject(this.world); this.scene.remove(this.world); for (const t of this.textures) t.dispose(); this.world = new THREE.Group(); this.scene.add(this.world); this.model = model; this.nodes.clear(); this.edges = []; this.edgesByPath.clear(); this.flows.clear(); this.pulsing.clear(); this.mirrored = false; const root = getComputedStyle(document.documentElement); this.glowColor = cssColor(root.getPropertyValue('--arrow-emphasis').trim(), '#34d399'); this.labels = []; this.frames = []; const floorTexture = canvasTexture(layers.floor.canvas); const nodeTexture = canvasTexture(layers.nodes.canvas); const labelTexture = canvasTexture(layers.labels.canvas); this.textures = [floorTexture, nodeTexture, labelTexture]; this.computeLevels(model); this.buildFloor(layers.floor, floorTexture); for (const shape of model.nodes) this.buildNode(shape, layers.nodes, nodeTexture, palette); model.edges.forEach((shape, index) => this.buildEdge(shape, index)); for (const shape of model.labels) this.buildLabel(shape, layers.labels, labelTexture); for (const shape of model.frames) this.buildFrame(shape, model); this.layout(); if (!this.fitted) { this.fit(false); this.fitted = true; } this.dirty = true; } private mirrored = false; private computeLevels(model: DiagramModel): void { const tiers = new Map(model.nodes.map((n) => [n.id, KIND_TIER[n.kind] ?? 1])); const used = [...new Set(tiers.values())].sort((a, b) => a - b); this.levels = new Map([...tiers].map(([id, tier]) => [id, used.indexOf(tier)])); const { w, h } = model.viewBox; this.levelGap = Math.max(48, Math.min(140, Math.max(w, h) * 0.08)); } private levelY(id: string): number { return (this.levels.get(id) ?? 0) * this.levelGap * this.levelMix; } private buildFloor(raster: Raster, texture: THREE.Texture): void { const { area } = raster; const floor = new THREE.Mesh( new THREE.PlaneGeometry(area.w, area.h).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({ map: texture }), ); floor.position.set(area.x + area.w / 2, 0, area.y + area.h / 2); floor.renderOrder = -1; this.world.add(floor); } private buildNode(shape: NodeShape, raster: Raster, texture: THREE.Texture, palette: Palette): void { const { x, y, w, h } = shape; const r = Math.min(shape.rx, w / 2, h / 2); const outline = new THREE.Shape(); outline.moveTo(x + r, y); outline.lineTo(x + w - r, y); outline.quadraticCurveTo(x + w, y, x + w, y + r); outline.lineTo(x + w, y + h - r); outline.quadraticCurveTo(x + w, y + h, x + w - r, y + h); outline.lineTo(x + r, y + h); outline.quadraticCurveTo(x, y + h, x, y + h - r); outline.lineTo(x, y + r); outline.quadraticCurveTo(x, y, x + r, y); const geometry = new THREE.ExtrudeGeometry(outline, { depth: NODE_HEIGHT, bevelEnabled: false, curveSegments: 6 }); // Map the caps onto the node layer: same SVG coordinates, same pixels. const pos = geometry.getAttribute('position'); const uv = geometry.getAttribute('uv'); const { area } = raster; for (let i = 0; i < pos.count; i++) { uv.setXY(i, (pos.getX(i) - area.x) / area.w, 1 - (pos.getY(i) - area.y) / area.h); } // Shape y (SVG y, downwards) becomes world z; the extrusion becomes height. geometry.rotateX(Math.PI / 2); geometry.translate(0, NODE_HEIGHT, 0); // Group 0 holds both caps; keep only the top one. The bottom cap carries // the same picture and shows through when a faded block turns see-through. const caps = geometry.groups[0]; const half = caps.count / 2; const bottomFirst = geometry.getAttribute('position').getY(caps.start) < NODE_HEIGHT / 2; caps.start += bottomFirst ? half : 0; caps.count = half; const body = this.model ? getComputedStyle(shape.el.querySelector(`rect.c-${CSS.escape(shape.kind)}`) ?? shape.el) : undefined; const stroke = cssColor(body?.stroke ?? '', '#64748b'); const side = stroke.clone().lerp(cssColor(palette.mask), palette.dark ? 0.45 : 0.25); const capMaterial = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide }); const sideMaterial = new THREE.MeshLambertMaterial({ color: side, transparent: true, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geometry, [capMaterial, sideMaterial]); mesh.userData.id = shape.id; // Contact shadow on the floor. const shadowMaterial = new THREE.MeshBasicMaterial({ map: shadowTexture(), transparent: true, opacity: palette.dark ? 0.55 : 0.22, depthWrite: false, }); const shadow = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.55, h * 1.9).rotateX(-Math.PI / 2), shadowMaterial); shadow.position.set(x + w / 2, 0.15, y + h / 2 + 6); shadow.renderOrder = 0; this.world.add(shadow); const group = new THREE.Group(); group.add(mesh); this.world.add(group); const opacity = effectiveOpacity(shape.el, this.model!.svg); for (const m of [capMaterial, sideMaterial]) m.opacity = opacity; shadowMaterial.opacity *= opacity; this.nodes.set(shape.id, { shape, group, body: mesh, materials: [capMaterial, sideMaterial, shadowMaterial], lift: 0, pulse: 0, baseOpacity: opacity, dim: 1 }); } private buildEdge(shape: EdgeShape, index: number): void { const style = getComputedStyle(shape.path); const width = parseFloat(style.strokeWidth) || 1.5; const color = cssColor(style.stroke, '#94a3b8'); const parent: Element | null = shape.path.parentElement; const baseOpacity = (parent && parent !== this.model!.svg ? effectiveOpacity(parent, this.model!.svg) : 1) * (Number(style.strokeOpacity) || 1); const radius = Math.min(4.5, Math.max(2.4, width * 2)); const material = new THREE.MeshStandardMaterial({ color, roughness: 0.32, metalness: 0.18, emissive: color, emissiveIntensity: 0.14, transparent: true, opacity: baseOpacity * Number(style.opacity || 1), }); const tube = new THREE.Mesh(new THREE.BufferGeometry(), material); const cap = new THREE.Mesh(new THREE.SphereGeometry(radius, 12, 8), material); this.world.add(tube, cap); // Arrowhead: a cone in the colour of Archify's marker, tip at the route end. let arrow: THREE.Mesh | undefined; const arrowLength = radius * 4.2; const marker = /url\(["']?#([^"')]+)["']?\)/.exec(shape.path.getAttribute('marker-end') ?? '')?.[1]; const polygon = marker ? this.model!.svg.querySelector(`marker#${CSS.escape(marker)} polygon, marker#${CSS.escape(marker)} path`) : null; if (polygon) { const fill = cssColor(getComputedStyle(polygon).fill, style.stroke); const geometry = new THREE.ConeGeometry(radius * 2.1, arrowLength, 16); geometry.rotateZ(-Math.PI / 2); geometry.translate(-arrowLength / 2, 0, 0); arrow = new THREE.Mesh( geometry, new THREE.MeshStandardMaterial({ color: fill, roughness: 0.32, metalness: 0.18, emissive: fill, emissiveIntensity: 0.18, transparent: true, opacity: material.opacity }), ); this.world.add(arrow); } const view: EdgeView = { shape, tube, material, cap, arrow, arrowLength: arrow ? arrowLength : 0, radius, bias: PIPE_LANES[index % PIPE_LANES.length], baseOpacity, // Archify's dash lengths suit a thin stroke; stretch them to the pipe's // thickness so a dashed pipe reads as separate pipe sections. dashScale: Math.max(1, (radius * 2) / (width * 2.2)), pipeLength: shape.length, mirrored: '', }; this.edges.push(view); const d = shape.path.getAttribute('d'); if (d) this.edgesByPath.set(d.trim(), view); this.mirrorEdge(view); } /** * Copies the live dash, offset and opacity of the 2D path onto its pipe. * Archify animates exactly these (trace draw-in, focus, Live); mirroring * them keeps the 3D motion in step with 2D, in every preset. */ private mirrorEdge(view: EdgeView): boolean { const style = getComputedStyle(view.shape.path); const key = `${style.strokeDasharray}|${style.strokeDashoffset}|${style.opacity}`; if (key === view.mirrored) return false; const dashKey = view.mirrored.split('|')[0]; view.mirrored = key; const material = view.material; if (style.strokeDasharray !== dashKey) { const dash = dashFor(parseDashes(style.strokeDasharray).map((d) => d * view.dashScale)); material.alphaMap?.dispose(); material.alphaMap = dash?.texture ?? null; material.alphaTest = dash ? 0.5 : 0; material.userData.period = dash?.period ?? 1; material.needsUpdate = true; } const alpha = material.alphaMap; if (alpha) { const period = material.userData.period as number; alpha.repeat.set(view.pipeLength / period, 1); alpha.offset.x = ((parseFloat(style.strokeDashoffset) || 0) * view.dashScale) / period; } const opacity = view.baseOpacity * Number(style.opacity || 1); material.opacity = opacity; // A relationship that is animating glows a little, as Signal Flow does in 2D. const animating = view.shape.path.getAnimations().some((anim) => anim.playState === 'running'); material.emissiveIntensity = animating ? 0.45 : 0.14; const arrowMaterial = view.arrow?.material as THREE.MeshStandardMaterial | undefined; if (arrowMaterial) arrowMaterial.opacity = opacity; return true; } private buildLabel(shape: LabelShape, raster: Raster, texture: THREE.Texture): void { const { area } = raster; const map = texture.clone(); map.repeat.set(shape.w / area.w, shape.h / area.h); map.offset.set((shape.x - area.x) / area.w, 1 - (shape.y - area.y + shape.h) / area.h); map.needsUpdate = true; this.textures.push(map); const mesh = new THREE.Mesh( new THREE.PlaneGeometry(shape.w, shape.h), new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, side: THREE.DoubleSide }), ); mesh.renderOrder = 2; this.world.add(mesh); this.labels.push({ shape, mesh, edge: this.edges.find((e) => e.shape.key === shape.key) }); } private buildFrame(shape: FrameShape, model: DiagramModel): void { if (shape.kind === 'lane') return; const style = getComputedStyle(shape.el); const color = cssColor(style.stroke, '#64748b'); const r = Math.min(shape.rx, shape.w / 2, shape.h / 2); const outline = new THREE.Shape(); const { x, y, w, h } = shape; outline.moveTo(x + r, y); outline.lineTo(x + w - r, y); outline.quadraticCurveTo(x + w, y, x + w, y + r); outline.lineTo(x + w, y + h - r); outline.quadraticCurveTo(x + w, y + h, x + w - r, y + h); outline.lineTo(x + r, y + h); outline.quadraticCurveTo(x, y + h, x, y + h - r); outline.lineTo(x, y + r); outline.quadraticCurveTo(x, y, x + r, y); const points = outline.getPoints(8); // A vertical strip along the outline; its height is set in layout(). const positions: number[] = []; for (let i = 0; i < points.length - 1; i++) { const a = points[i]; const b = points[i + 1]; positions.push(a.x, 0, a.y, b.x, 0, b.y, b.x, 1, b.y, a.x, 0, a.y, b.x, 1, b.y, a.x, 1, a.y); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); const wall = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.07, side: THREE.DoubleSide, depthWrite: false }), ); const rim = new THREE.Line( new THREE.BufferGeometry().setFromPoints(points.map((p) => new THREE.Vector3(p.x, 1, p.y))), new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.55 }), ); this.world.add(wall, rim); const members = model.nodes.filter((n) => n.x + n.w / 2 > x && n.x + n.w / 2 < x + w && n.y + n.h / 2 > y && n.y + n.h / 2 < y + h); this.frames.push({ shape, wall, rim, members }); } /** Positions that depend on the level mix and hover. */ private layout(): void { for (const view of this.nodes.values()) view.group.position.y = this.levelY(view.shape.id) + view.lift + view.pulse; for (const view of this.edges) { const { points, from, to } = view.shape; const y0 = this.levelY(from); const y1 = this.levelY(to); const n = points.length; const pts: THREE.Vector3[] = []; for (let i = 0; i < n; i++) { const s = i / (n - 1); const k = s * s * (3 - 2 * s); pts.push(new THREE.Vector3(points[i][0], PIPE_Y + view.bias + y0 + (y1 - y0) * k, points[i][1])); } const end = pts[n - 1]; // The pipe stops where the arrowhead's base begins. let cut = n - 1; while (cut > 1 && pts[cut].distanceTo(end) < view.arrowLength * 0.85) cut--; const path = new THREE.CurvePath(); for (let i = 1; i <= cut; i++) path.add(new THREE.LineCurve3(pts[i - 1], pts[i])); view.tube.geometry.dispose(); view.tube.geometry = new THREE.TubeGeometry(path, Math.max(2, cut), view.radius, 10, false); view.cap.position.copy(pts[0]); view.path = path; view.pipeLength = path.getLength(); view.mirrored = ''; this.mirrorEdge(view); if (view.arrow) { const t = end.clone().sub(pts[Math.max(0, n - 4)]).normalize(); const side = new THREE.Vector3().crossVectors(t, new THREE.Vector3(0, 1, 0)); if (side.lengthSq() < 1e-6) side.set(1, 0, 0); side.normalize(); const normal = new THREE.Vector3().crossVectors(side, t); view.arrow.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(t, normal, side)); view.arrow.position.copy(end); } } for (const view of this.labels) { const { shape } = view; const cx = shape.x + shape.w / 2; const cz = shape.y + shape.h / 2; // Labels lie on top of their pipe. let y = PIPE_Y + (view.edge ? view.edge.bias + view.edge.radius : 4) + 1.2; if (view.edge && this.levelMix > 0) { const { points, from, to } = view.edge.shape; let best = 0; let bestD = Infinity; points.forEach(([px, pz], i) => { const d = (px - cx) ** 2 + (pz - cz) ** 2; if (d < bestD) (bestD = d), (best = i); }); const s = best / Math.max(1, points.length - 1); const k = s * s * (3 - 2 * s); y += this.levelY(from) + (this.levelY(to) - this.levelY(from)) * k; } view.mesh.position.set(cx, y + (this.levelMix > 0.5 ? shape.h / 2 : 0), cz); view.mesh.userData.billboard = this.levelMix > 0.5; if (!view.mesh.userData.billboard) view.mesh.rotation.set(-Math.PI / 2, 0, 0); } for (const view of this.frames) { const top = view.members.length ? Math.max(...view.members.map((m) => this.levelY(m.id))) + NODE_HEIGHT + FRAME_WALL : FRAME_WALL; view.wall.scale.y = top; view.rim.position.y = top - 1; } for (const flow of this.flows.values()) this.removeFlow(flow); this.dirty = true; } // --------------------------------------------------------------- motion /** * Follows Archify's own animations, which keep running on the hidden 2D * SVG: the ambient trace (relationships drawing in, nodes pulsing) and the * flows drawn over relationships (focus pulse, intent trace, route probe, * lens, story). Returns true while anything moves. */ private syncMotion(): boolean { const svg = this.model?.svg; if (!svg) return false; const running = svg.getAnimations({ subtree: true }).filter((a) => a.playState === 'running'); const overlays = svg.querySelectorAll(FLOW_SELECTOR); if (!running.length && !overlays.length && !this.flows.size && !this.motionActive) return false; let changed = false; for (const view of this.edges) changed = this.mirrorEdge(view) || changed; for (const view of this.nodes.values()) changed = this.mirrorNode(view) || changed; // Node pulses: Archify animates glow and stroke on [data-animate="node"]. const pulsing = new Set(); for (const animation of running) { const name = (animation as CSSAnimation).animationName ?? ''; if (!/node-pulse/.test(name)) continue; const target = (animation.effect as KeyframeEffect | null)?.target as Element | null; const id = target?.closest('[data-node-id]')?.getAttribute('data-node-id'); const view = id ? this.nodes.get(id) : undefined; if (!view) continue; const p = animation.effect?.getComputedTiming().progress ?? 0; // Archify's envelope: rise to 18%, hold to 36%, fall back by 72%. const f = p < 0.18 ? p / 0.18 : p < 0.36 ? 1 : p < 0.72 ? 1 - (p - 0.36) / 0.36 : 0; this.setPulse(view, f); pulsing.add(view); } for (const view of this.pulsing) if (!pulsing.has(view)) this.setPulse(view, 0); this.pulsing = pulsing; // Flows over relationships. const seen = new Set(); overlays.forEach((el) => { const edge = this.edgesByPath.get((el.getAttribute('d') ?? '').trim()); if (!edge?.path) return; seen.add(el); let flow = this.flows.get(el); if (!flow) { flow = this.addFlow(el, edge); if (!flow) return; } this.mirrorFlow(flow); }); for (const flow of [...this.flows.values()]) if (!seen.has(flow.el)) this.removeFlow(flow); const active = running.length > 0 || this.flows.size > 0; const result = changed || active || pulsing.size > 0 || this.motionActive !== active; this.motionActive = active; return result; } /** * Live node dimming: Archify's intent trace fades unrelated nodes with * fill/stroke opacity, set from attributes that do not rebuild textures. */ private mirrorNode(view: NodeView): boolean { const dim = Math.max(0.18, Number(getComputedStyle(view.shape.el).fillOpacity || 1)); if (Math.abs(dim - view.dim) < 0.01) return false; view.dim = dim; const [cap, side, shadow] = view.materials; cap.opacity = side.opacity = view.baseOpacity * dim; shadow.opacity = (shadow.userData.base ??= shadow.opacity) * dim; return true; } private setPulse(view: NodeView, f: number): void { const side = view.materials[1] as THREE.MeshLambertMaterial; side.emissive.copy(this.glowColor); side.emissiveIntensity = f * 0.9; view.pulse = f * 3; view.group.position.y = this.levelY(view.shape.id) + view.lift + view.pulse; } private addFlow(el: SVGPathElement, edge: EdgeView): FlowView | undefined { if (!edge.path) return undefined; const material = new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }); const mesh = new THREE.Mesh(new THREE.TubeGeometry(edge.path, Math.max(2, Math.round(edge.pipeLength / 3)), edge.radius * FLOW_SCALE, 10, false), material); mesh.renderOrder = 3; this.world.add(mesh); const flow: FlowView = { el, edge, mesh, material }; this.flows.set(el, flow); return flow; } /** Flows use pathLength="1": their dashes are fractions of the route. */ private mirrorFlow(flow: FlowView): void { const style = getComputedStyle(flow.el); const material = flow.material; material.color = cssColor(style.stroke, '#34d399'); material.opacity = effectiveOpacity(flow.el, this.model!.svg) * Number(style.strokeOpacity || 1); const pattern = parseDashes(style.strokeDasharray); const key = pattern.join(','); if (material.userData.dashKey !== key) { material.alphaMap?.dispose(); const dash = dashFor(pattern); material.alphaMap = dash?.texture ?? null; material.alphaTest = dash ? 0.5 : 0; material.userData = { dashKey: key, period: dash?.period ?? 1 }; material.needsUpdate = true; } if (material.alphaMap) { const period = material.userData.period as number; material.alphaMap.repeat.set(1 / period, 1); material.alphaMap.offset.x = (parseFloat(style.strokeDashoffset) || 0) / period; } } private removeFlow(flow: FlowView): void { this.world.remove(flow.mesh); flow.mesh.geometry.dispose(); flow.material.alphaMap?.dispose(); flow.material.dispose(); this.flows.delete(flow.el); } // --------------------------------------------------------------- camera private bounds(): THREE.Box3 { const box = new THREE.Box3(); if (!this.model) return box; const { x, y, w, h } = this.model.viewBox; box.expandByPoint(new THREE.Vector3(x, 0, y)); box.expandByPoint(new THREE.Vector3(x + w, NODE_HEIGHT + this.levelGap * this.levelMix * 3, y + h)); return box; } private flyTo(position: THREE.Vector3, target: THREE.Vector3, animate = true): void { if (!animate || reducedMotion()) { this.flight = undefined; this.camera.position.copy(position); this.controls.target.copy(target); this.controls.update(); this.dirty = true; return; } this.flight = { from: this.camera.position.clone(), to: position, fromTarget: this.controls.target.clone(), toTarget: target, start: performance.now() }; } /** * Camera position looking at the box centre from `direction`, close enough * that every corner of the box projects inside the view (with a margin). */ private framing(box: THREE.Box3, direction: THREE.Vector3, margin = 0.9): { position: THREE.Vector3; target: THREE.Vector3 } { const target = box.getCenter(new THREE.Vector3()); const corners: THREE.Vector3[] = []; for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) corners.push(new THREE.Vector3(x, y, z)); const probe = this.camera.clone(); const fits = (distance: number) => { probe.position.copy(target).addScaledVector(direction, distance); probe.up.set(0, 1, 0); probe.lookAt(target); probe.updateMatrixWorld(); probe.updateProjectionMatrix(); return corners.every((c) => { const p = c.clone().project(probe); return Math.abs(p.x) <= margin && Math.abs(p.y) <= margin && p.z < 1; }); }; let lo = 1; let hi = box.getSize(new THREE.Vector3()).length() * 4 + 100; while (!fits(hi)) hi *= 2; for (let i = 0; i < 32; i++) { const mid = (lo + hi) / 2; if (fits(mid)) hi = mid; else lo = mid; } return { position: target.clone().addScaledVector(direction, hi), target }; } /** Frames the whole diagram from the front, raised like a reading desk. */ fit(animate = true): void { const { position, target } = this.framing(this.bounds(), new THREE.Vector3(-0.16, 1.25, 1).normalize()); this.flyTo(position, target, animate); } /** Straight down, like the 2D view. */ top(animate = true): void { const { position, target } = this.framing(this.bounds(), new THREE.Vector3(0, 1, 0.0001).normalize(), 0.95); this.flyTo(position, target, animate); } focusNode(id: string): void { const view = this.nodes.get(id); if (!view) return; const { x, y, w, h } = view.shape; const target = new THREE.Vector3(x + w / 2, this.levelY(id) + NODE_HEIGHT, y + h / 2); const offset = this.camera.position.clone().sub(this.controls.target).setLength(Math.max(w, h) * 4.5 + 120); this.flyTo(target.clone().add(offset), target); } zoom(factor: number): void { const offset = this.camera.position.clone().sub(this.controls.target).multiplyScalar(1 / factor); this.flyTo(this.controls.target.clone().add(offset), this.controls.target.clone()); } /** The camera, once the scene has been framed. */ cameraState(): CameraState | undefined { if (!this.fitted) return undefined; return { position: this.camera.position.toArray() as CameraState['position'], target: this.controls.target.toArray() as CameraState['target'] }; } restoreCamera(state: CameraState): void { this.flyTo(new THREE.Vector3(...state.position), new THREE.Vector3(...state.target), false); this.fitted = true; } get layered(): boolean { return this.levelTarget === 1; } setLayered(on: boolean, animate = true): void { this.levelTarget = on ? 1 : 0; this.dirty = true; if (!animate || reducedMotion()) { this.levelMix = this.levelTarget; this.layout(); } } // -------------------------------------------------------------- picking private pick(event: MouseEvent): string | undefined { const rect = this.renderer.domElement.getBoundingClientRect(); const pointer = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); this.raycaster.setFromCamera(pointer, this.camera); const hit = this.raycaster.intersectObjects([...this.nodes.values()].map((v) => v.body), false)[0]; return hit?.object.userData.id; } // ---------------------------------------------------------------- loop private resize(): void { const { clientWidth: w, clientHeight: h } = this.element; if (!w || !h) return; this.renderer.setSize(w, h); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.dirty = true; } start(): void { if (this.frameId) return; this.resize(); this.lastTime = performance.now(); const tick = (now: number) => { this.frameId = requestAnimationFrame(tick); this.frame(now); }; this.frameId = requestAnimationFrame(tick); } stop(): void { cancelAnimationFrame(this.frameId); this.frameId = 0; } /** * One animation frame. The scene is drawn only when something changed: * the camera, a transition, hover, or one of Archify's animations. An idle * diagram costs no GPU time. */ private frame(now: number): void { const dt = Math.min(0.1, (now - this.lastTime) / 1000); this.lastTime = now; let draw = this.dirty; this.dirty = false; if (this.levelMix !== this.levelTarget) { const step = dt / 0.7; this.levelMix = this.levelTarget > this.levelMix ? Math.min(1, this.levelMix + step) : Math.max(0, this.levelMix - step); this.layout(); draw = true; } for (const view of this.nodes.values()) { const goal = view.shape.id === this.hovered ? 4 : 0; if (Math.abs(view.lift - goal) > 0.01) { view.lift += (goal - view.lift) * Math.min(1, dt * 14); view.group.position.y = this.levelY(view.shape.id) + view.lift + view.pulse; draw = true; } } if (this.flight) { const t = Math.min(1, (now - this.flight.start) / FLY_MS); const k = 1 - Math.pow(1 - t, 3); this.camera.position.lerpVectors(this.flight.from, this.flight.to, k); this.controls.target.lerpVectors(this.flight.fromTarget, this.flight.toTarget, k); draw = true; if (t === 1) { this.flight = undefined; this.onCameraChange(); } } if (this.syncMotion()) draw = true; // update() returns true while the camera moves (including damping). if (this.controls.update()) draw = true; if (!draw) return; for (const view of this.labels) { if (view.mesh.userData.billboard) view.mesh.quaternion.copy(this.camera.quaternion); } this.renderer.render(this.scene, this.camera); } /** PNG of the current view. */ snapshot(): string { this.renderer.render(this.scene, this.camera); return this.renderer.domElement.toDataURL('image/png'); } dispose(): void { this.stop(); disposeObject(this.world); for (const t of this.textures) t.dispose(); this.controls.dispose(); this.renderer.dispose(); this.element.remove(); } }