import { layerMarkup, rasterize } from './raster'; import { CameraState, Layers, Stage } from './stage'; import { DiagramModel, readModel } from './svgModel'; import { STYLE } from './style'; /** * 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's * toolbar and, when on, replaces the 2D canvas with a three.js stage built * from the live SVG. Archify keeps owning the diagram: clicks in 3D go to the SVG * nodes, so focus, Semantic Passport, reach and lens work as in 2D; the * stage redraws whenever Archify changes the SVG, theme or preset, and * follows Archify's animations as they play. */ interface Host { defaultView: '2d' | '3d'; post(message: unknown): void; getState(): Record; setState(patch: Record): void; } interface SavedView { active?: boolean; layered?: boolean; camera?: CameraState; } declare global { interface Window { __archifyHost?: Host; } } const REDRAW_MS = 220; const MARGIN = 80; /** Texture pixels per SVG unit; more costs memory and upload time for little gain. */ const TEXTURE_SCALE = 2; class Viewer3D { private stage: Stage | undefined; private model: DiagramModel | undefined; private active = false; private redrawTimer: number | undefined; private drawing: Promise | undefined; private pendingRedraw = false; private readonly observer = new MutationObserver((records) => this.onMutation(records)); /** Keeps Archify's zoom buttons usable in 3D (see holdZoomButtons). */ private readonly zoomObserver = new MutationObserver(() => this.enableZoomButtons()); private zoomDisabled = new Map(); private readonly toggle: HTMLButtonElement; private readonly stackButton: HTMLButtonElement; private readonly topButton: HTMLButtonElement; private readonly resetLabel: HTMLSpanElement; private ambientReplayed = false; private exportItem: HTMLButtonElement | undefined; private saved: SavedView; constructor( private readonly host: Host, private readonly container: HTMLElement, private readonly svg: SVGSVGElement, ) { this.saved = (host.getState().view3d as SavedView | undefined) ?? {}; this.toggle = this.buildSwitch(); ({ stack: this.stackButton, top: this.topButton, reset: this.resetLabel } = this.buildNavControls()); this.addExportItem(); this.interceptViewerControls(); if (this.saved.active ?? host.defaultView === '3d') this.setActive(true); else this.syncButtons(); } // -------------------------------------------------------------- chrome /** * The 2D | 3D switch: Export's twin in the toolbar. Both states are always * visible, the filled half is the current one, a click flips it. */ private buildSwitch(): HTMLButtonElement { const button = document.createElement('button'); button.id = 'btn-3d'; button.type = 'button'; button.setAttribute('role', 'switch'); button.setAttribute('aria-checked', 'false'); button.innerHTML = '' + ''; button.addEventListener('click', (event) => { event.stopPropagation(); this.setActive(!this.active); }); const exportWrap = document.querySelector('.toolbar .export-wrap'); if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(button, exportWrap); else document.querySelector('.toolbar')?.appendChild(button); return button; } /** * 3D-only controls in Archify's bottom-right dock, where its own view * controls live: Stacked (toggle) and From above, before the zoom stepper; * the stepper's "100%" (a 2D zoom level) reads "Reset" while 3D is on. */ private buildNavControls(): { stack: HTMLButtonElement; top: HTMLButtonElement; reset: HTMLSpanElement } { const nav = this.container.querySelector('.diagram-nav'); const make = (id: string, label: string, title: string, pressed: boolean, action: () => void) => { const button = document.createElement('button'); button.id = id; button.type = 'button'; button.title = title; button.setAttribute('aria-label', title); if (pressed) button.setAttribute('aria-pressed', 'false'); button.textContent = label; button.hidden = true; button.addEventListener('click', (event) => { event.stopPropagation(); action(); }); return button; }; const stack = make('btn-3d-stack', 'STACK', 'Stacked: clients on top, data at the bottom', true, () => { this.stage?.setLayered(!this.stage.layered); this.syncButtons(); this.save(); }); const top = make('btn-3d-top', 'TOP', 'From above: look straight down, like 2D', false, () => this.stage?.top()); const reset = document.createElement('span'); reset.className = 'archify-3d-reset'; reset.textContent = 'RESET'; const zoomOut = nav?.querySelector('[data-view="out"]') ?? null; nav?.insertBefore(stack, zoomOut); nav?.insertBefore(top, zoomOut); const resetButton = nav?.querySelector('[data-view="reset"]'); if (resetButton) { resetButton.appendChild(reset); resetButton.dataset.archify3dTitle = resetButton.title; } return { stack, top, reset }; } private addExportItem(): void { const png = document.querySelector('.export-menu button[data-format="png"]'); if (!png?.parentElement) return; const item = document.createElement('button'); item.type = 'button'; item.setAttribute('role', 'menuitem'); item.tabIndex = -1; item.hidden = true; item.dataset.archify3dExport = ''; item.innerHTML = '3D viewPNG of the current 3D view'; item.addEventListener('click', (event) => { event.stopPropagation(); document.getElementById('btn-export')?.click(); this.exportPng(); }); png.parentElement.insertBefore(item, png); this.exportItem = item; } /** Archify's zoom buttons and keys drive the 3D camera while 3D is on. */ private interceptViewerControls(): void { this.container.addEventListener( 'click', (event) => { if (!this.active || !this.stage) return; const button = (event.target as Element | null)?.closest?.('[data-view]'); const view = button?.getAttribute('data-view'); if (!view) return; event.stopPropagation(); event.preventDefault(); if (view === 'in') this.stage.zoom(1.25); else if (view === 'out') this.stage.zoom(0.8); else if (view === 'reset') this.stage.fit(); }, true, ); window.addEventListener( 'keydown', (event) => { if (!this.active || !this.stage || event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target as HTMLElement | null; if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return; const actions: Record void> = { '0': () => this.stage!.fit(), '+': () => this.stage!.zoom(1.25), '=': () => this.stage!.zoom(1.25), '-': () => this.stage!.zoom(0.8), }; const action = actions[event.key]; if (!action) return; event.stopPropagation(); event.preventDefault(); action(); }, true, ); } private syncButtons(): void { const on = this.active; this.toggle.setAttribute('aria-checked', String(on)); this.toggle.setAttribute('aria-label', on ? '3D view on; switch to 2D' : '2D view; switch to 3D'); this.toggle.title = on ? 'Switch to 2D' : 'Switch to 3D'; for (const seg of this.toggle.querySelectorAll('.archify-3d-seg')) seg.classList.toggle('on', (seg.dataset.seg === '3d') === on); this.stackButton.hidden = !on; this.stackButton.setAttribute('aria-pressed', String(!!this.stage?.layered)); this.topButton.hidden = !on; const resetButton = this.resetLabel.parentElement as HTMLButtonElement | null; if (resetButton) resetButton.title = on ? 'Reset view: show the whole diagram (0)' : resetButton.dataset.archify3dTitle ?? resetButton.title; if (this.exportItem) this.exportItem.hidden = !on; } // ---------------------------------------------------------------- mode setActive(on: boolean): void { this.active = on; document.documentElement.classList.toggle('archify-3d-on', on); if (on) { if (!this.stage) this.createStage(); this.container.appendChild(this.stage!.element); this.stage!.start(); this.observer.observe(this.svg, { subtree: true, attributes: true, childList: true, characterData: true }); this.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-preset'] }); this.holdZoomButtons(true); void this.redraw().then(() => this.replayAmbient()); } else { this.holdZoomButtons(false); this.observer.disconnect(); this.stage?.stop(); this.stage?.element.remove(); } this.syncButtons(); this.save(); } private zoomButtons(): HTMLButtonElement[] { return [...this.container.querySelectorAll('button[data-view="in"], button[data-view="out"]')]; } /** * Archify disables its zoom buttons at the 2D limits (100% and 300%). The * 3D camera has its own range, so the buttons stay enabled while 3D is on, * and get Archify's state back when 3D is turned off. */ private holdZoomButtons(on: boolean): void { if (on) { this.zoomDisabled = new Map(this.zoomButtons().map((b) => [b, b.disabled])); for (const button of this.zoomButtons()) this.zoomObserver.observe(button, { attributes: true, attributeFilter: ['disabled'] }); this.enableZoomButtons(); } else { this.zoomObserver.disconnect(); for (const [button, disabled] of this.zoomDisabled) button.disabled = disabled; } } private enableZoomButtons(): void { for (const button of this.zoomButtons()) if (button.disabled) button.disabled = false; } private createStage(): void { const stage = new Stage(); stage.onPick = (id) => this.pick(id); stage.onHover = (id) => this.hover(id); stage.onCameraChange = () => this.save(); if (this.saved.layered) stage.setLayered(true, false); if (this.saved.camera) stage.restoreCamera(this.saved.camera); this.stage = stage; } /** * Archify plays its ambient trace once, when the page opens. Opening 3D * later replays it once, so the 3D view starts the way 2D did. Readers who * paused motion (Live off) or prefer reduced motion get no replay. */ private replayAmbient(): void { const html = document.documentElement; if (this.ambientReplayed || !this.active) return; // The load-time pass may still be playing (usually under the not-yet-built // 3D view): wait for it to settle, then replay it in 3D. if (html.getAttribute('data-ambient-motion') === 'running') { const wait = new MutationObserver(() => { if (html.getAttribute('data-ambient-motion') === 'running') return; wait.disconnect(); this.replayAmbient(); }); wait.observe(html, { attributes: true, attributeFilter: ['data-ambient-motion'] }); return; } this.ambientReplayed = true; if (html.getAttribute('data-ambient-motion') !== 'settled' || html.getAttribute('data-motion') === 'still') return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; if (!this.svg.querySelector('[data-animate]')) return; // Restart Archify's CSS trace: the "settled" state must be applied // (style flushed) before "running" again, or the animations do not restart. html.setAttribute('data-ambient-motion', 'settled'); void getComputedStyle(this.svg.querySelector('[data-animate]')!).animationName; html.setAttribute('data-ambient-motion', 'running'); const settle = () => html.setAttribute('data-ambient-motion', 'settled'); const done = () => { if (this.svg.getAnimations({ subtree: true }).some((anim) => anim.playState === 'running')) return; this.svg.removeEventListener('animationend', done, true); this.svg.removeEventListener('animationcancel', done, true); settle(); }; this.svg.addEventListener('animationend', done, true); this.svg.addEventListener('animationcancel', done, true); // Safety net if an animation never reports its end. window.setTimeout(settle, 12000); } private save(): void { this.saved = { active: this.active, layered: this.stage?.layered ?? this.saved.layered, camera: this.stage?.cameraState() ?? this.saved.camera }; this.host.setState({ view3d: this.saved }); } // ------------------------------------------------------------- picking /** Hands a 3D click to Archify, as if the reader had clicked the 2D node. */ private pick(id: string | undefined): void { const target = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) : this.svg; target?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); } private hovered: Element | undefined; /** * Hands 3D hover to Archify as pointer events on the SVG node, so its * intent trace (one-hop flows, fading the rest) plays as in 2D. */ private hover(id: string | undefined): void { const next = id ? this.svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) ?? undefined : undefined; if (next === this.hovered) return; const init = { bubbles: true, cancelable: true, pointerType: 'mouse', isPrimary: true, view: window }; this.hovered?.dispatchEvent(new PointerEvent('pointerout', init)); next?.dispatchEvent(new PointerEvent('pointerover', init)); this.hovered = next; } // -------------------------------------------------------------- redraw private onMutation(records: MutationRecord[]): void { if (!records.some((r) => this.changesDrawing(r))) return; window.clearTimeout(this.redrawTimer); // Let Archify's 140–200ms state transitions finish before sampling styles. this.redrawTimer = window.setTimeout(() => void this.redraw(), REDRAW_MS); } /** * Whether a mutation changes what the textures show. Archify's camera * (the SVG root's style), its temporary overlays and the ambient-motion * flag only change motion, which the stage follows live. */ private changesDrawing(record: MutationRecord): boolean { const target = record.target as Element; if (target === this.svg && record.attributeName === 'style') return false; if (target === document.documentElement) return record.attributeName === 'data-theme' || record.attributeName === 'data-preset'; // Intent trace only fades and draws flows; the stage mirrors both live. if (record.attributeName?.startsWith('data-intent-trace')) return false; const inOverlay = (node: Node) => node instanceof Element && !!node.closest('[data-relationship-pulse-overlay], [data-intent-trace-overlay], [data-route-probe-overlay], [data-route-journey-overlay], [data-semantic-lens-overlay], [data-story-overlay], [data-story-carrier-overlay], [data-chapter-handoff-overlay], [data-relationship-hit-overlay]'); if (record.type === 'childList') { const nodes = [...record.addedNodes, ...record.removedNodes].filter((n) => n.nodeType === Node.ELEMENT_NODE); if (nodes.length && nodes.every((n) => inOverlay(n) || [...(n as Element).getAttributeNames()].some((a) => a.endsWith('-overlay')))) return false; return nodes.length > 0 || !inOverlay(target); } return !inOverlay(target); } private async redraw(): Promise { if (this.drawing) { this.pendingRedraw = true; return this.drawing; } this.drawing = this.draw().finally(() => { this.drawing = undefined; if (this.pendingRedraw) { this.pendingRedraw = false; void this.redraw(); } }); return this.drawing; } private async draw(): Promise { if (!this.active || !this.stage) return; await document.fonts?.ready; const model = readModel(this.svg); this.model = model; const { viewBox } = model; const area = { x: viewBox.x - MARGIN, y: viewBox.y - MARGIN, w: viewBox.w + MARGIN * 2, h: viewBox.h + MARGIN * 2 }; const scale = Math.min(TEXTURE_SCALE, 8192 / Math.max(area.w, area.h)); const root = getComputedStyle(document.documentElement); const containerStyle = getComputedStyle(this.container); const background = opaque(containerStyle.backgroundColor) ?? (root.getPropertyValue('--bg').trim() || '#020617'); const dot = root.getPropertyValue('--canvas-dot').trim(); const paintFloor = (ctx: CanvasRenderingContext2D) => { ctx.fillStyle = background; ctx.fillRect(area.x, area.y, area.w, area.h); if (dot) { ctx.fillStyle = dot; for (let x = Math.ceil(area.x / 20) * 20; x < area.x + area.w; x += 20) { for (let y = Math.ceil(area.y / 20) * 20; y < area.y + area.h; y += 20) { ctx.beginPath(); ctx.arc(x, y, 0.9, 0, Math.PI * 2); ctx.fill(); } } } }; const [floor, nodes, labels] = await Promise.all([ rasterize(layerMarkup(this.svg, viewBox, { keep: ['floor'] }, scale), viewBox, area, scale, paintFloor), rasterize(layerMarkup(this.svg, viewBox, { keep: ['node'], opaqueNodes: true }, scale), viewBox, area, scale), rasterize(layerMarkup(this.svg, viewBox, { keep: ['label'] }, scale), viewBox, area, scale), ]); if (!this.active || this.model !== model) return; const layers: Layers = { floor, nodes, labels }; const mask = root.getPropertyValue('--mask').trim() || '#0f172a'; this.stage.build(model, layers, { mask, dark: document.documentElement.getAttribute('data-theme') !== 'light' }); } // -------------------------------------------------------------- export private exportPng(): void { if (!this.stage) return; const shot = new Image(); shot.onload = () => { const canvas = document.createElement('canvas'); canvas.width = shot.width; canvas.height = shot.height; const ctx = canvas.getContext('2d')!; ctx.fillStyle = opaque(getComputedStyle(this.container).backgroundColor) ?? getComputedStyle(document.documentElement).getPropertyValue('--bg'); ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(shot, 0, 0); const data = canvas.toDataURL('image/png'); const title = (document.querySelector('.header h1')?.textContent ?? 'archify').trim().replace(/[^\w.-]+/g, '-').toLowerCase(); this.host.post({ type: 'save', filename: `${title || 'archify'}-3d.png`, base64: data.slice(data.indexOf(',') + 1) }); }; shot.src = this.stage.snapshot(); } } /** A CSS colour if it is not fully transparent. */ function opaque(value: string): string | undefined { if (!value || value === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(value)) return undefined; return value; } function start(): void { const host = window.__archifyHost; const container = document.querySelector('.diagram-container'); const svg = container?.querySelector(':scope > svg'); if (!host || !container || !svg) return; const style = document.createElement('style'); style.textContent = STYLE; document.head.appendChild(style); new Viewer3D(host, container, svg); } if (document.readyState === 'complete') start(); else window.addEventListener('load', start, { once: true });