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.
458 lines
20 KiB
TypeScript
458 lines
20 KiB
TypeScript
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 });
|