Files
archify-vscode-ext/src/viewer3d/index.ts
T
root-at-skic c71d2e40b9 Replace the 3D menu with a 2D | 3D switch; 3D controls in the dock; 0.6.1
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.
2026-10-12 17:35:38 +03:00

461 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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 });