The toolbar button next to Export is now a two-state switch: both labels are visible and the filled half is the current view, so a diagram that opens in 2D shows "2D" and one click flips it to 3D. The dropdown is gone; nothing is greyed out and no menu stays open after a choice. The 3D-only controls moved to Archify's bottom-right dock beside PATH and LENS: STACK (levels by kind, on or off), TOP (from above), and the zoom stepper's middle button reads RESET while 3D is on, instead of a 2D zoom level that meant nothing there.
461 lines
19 KiB
TypeScript
461 lines
19 KiB
TypeScript
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<string, unknown>;
|
||
setState(patch: Record<string, unknown>): 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<void> | 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<HTMLButtonElement, boolean>();
|
||
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 =
|
||
'<span class="archify-3d-seg" data-seg="2d" aria-hidden="true">2D</span>' +
|
||
'<span class="archify-3d-seg" data-seg="3d" aria-hidden="true">3D</span>';
|
||
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<HTMLElement>('.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<HTMLButtonElement>('[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 = '<span class="export-item-copy"><strong>3D view</strong><small class="hint">PNG of the current 3D view</small></span>';
|
||
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<string, () => 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<HTMLElement>('.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<HTMLButtonElement>('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<void> {
|
||
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<void> {
|
||
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<HTMLElement>('.diagram-container');
|
||
const svg = container?.querySelector<SVGSVGElement>(':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 });
|