Files
archify-vscode-ext/src/viewer3d/index.ts
T
root-at-skic 27d4794ff8 0.9.2: Source view, schema checks without the YAML extension
Every Archify page can show its file's YAML in place. Diagrams get
Diagram | Graph | Source (Graph only inside a model), main.archify
Model | Graph | Source, registry and decision pages View | Source, and the
Graph page a Source segment. 2D | 3D moves left of that switch and hides
while Source shows.

The YAML opens in a CodeMirror editor inside the page (dist/source.js,
loaded on first use), themed with the page's colours. Edits go to the VS
Code document as WorkspaceEdits; outside changes come back to the page.
Undo and redo are the editor's own, since VS Code sends ⌘Z in a focused
webview to the webview; save and find stay VS Code's. Problems come from
the host only (what the Problems panel holds for the file) and are
counted on the Source segment; completion and hover come from the schema,
folded into one draft-07 document per diagram type.

The host now checks .archify files against the extension's schema with
ajv on the YAML as written, before archify validate, and checks
.archify-registry files against the registry schema for the first time.
Registry and decision pages become text editor providers. While Source
shows, new renders wait and appear when the picture comes back.

Show Source switches the page to Source; Open in Text Editor (the editor
title button) opens the text beside it, as Show Source did. The decision
schema is no longer given to the YAML extension. Fixed: YAML errors in
registry and decision files were reported on line 1; "Webview is
disposed" when a diagram closed mid-render; the model heading under the
toolbar in narrow editors.
2026-10-27 23:46:05 +02:00

458 lines
20 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';
import type { ArchifyHost } from '../webview/host';
/**
* 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.
*/
type Host = ArchifyHost;
interface SavedView {
active?: boolean;
layered?: boolean;
camera?: CameraState;
}
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();
// The Source view hides the picture; the stage stops drawing meanwhile.
window.addEventListener('archify-source', (event) => {
if (!this.active || !this.stage) return;
if ((event as CustomEvent<{ on: boolean }>).detail.on) this.stage.stop();
else this.stage.start();
});
}
// -------------------------------------------------------------- chrome
/**
* The 2D | 3D switch in the toolbar, left of Diagram | Graph | Source (it
* says how the picture is drawn, that one what is shown). 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 anchor = document.querySelector('.toolbar #archify-mode-switch') ?? document.querySelector('.toolbar .export-wrap');
if (anchor?.parentElement) anchor.parentElement.insertBefore(button, anchor);
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 });