Add a 3D view of every diagram; organise source by job; 0.6.0
3D view: a "3D" menu beside Export switches the diagram to a three.js scene built from the live Archify SVG. Nodes become blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Clicks and hover go to the SVG nodes, so focus, the Semantic Passport and the intent trace work as in 2D; Archify's own animations (trace, node pulse, passport/path/lens flows) are mirrored live. The menu also offers flat or stacked levels, reset view and a top view; Export gains "3D view" (PNG). Trackpad scrolling pans, pinch and the mouse wheel zoom, and the zoom buttons work beyond the 2D limits. The scene draws only when something moves. New setting archify.defaultView. Source: src/ is split by job (commands, views, webview, archify, sources, language, util, viewer3d); behaviour unchanged.
This commit is contained in:
1 parent
20786f4b5a
commit
97b3b5f685
40 files changed
+2553
-353
No files matched your search
@@ -0,0 +1,558 @@
|
||||
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 3D menu 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;
|
||||
|
||||
type MenuValue = '2d' | '3d' | 'flat' | 'stacked' | 'reset' | 'top';
|
||||
|
||||
interface MenuEntry {
|
||||
value: MenuValue;
|
||||
title: string;
|
||||
hint: string;
|
||||
/** Radio group; actions have none. */
|
||||
group?: 'view' | 'levels';
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const MENU: { heading: string; entries: MenuEntry[] }[] = [
|
||||
{
|
||||
heading: 'View',
|
||||
entries: [
|
||||
{ value: '2d', group: 'view', title: '2D', hint: 'The diagram as Archify draws it' },
|
||||
{ value: '3d', group: 'view', title: '3D', hint: 'Blocks and pipes you can turn' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Levels',
|
||||
entries: [
|
||||
{ value: 'flat', group: 'levels', title: 'Flat', hint: 'Every node on the floor' },
|
||||
{ value: 'stacked', group: 'levels', title: 'Stacked', hint: 'Clients on top, data at the bottom' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Camera',
|
||||
entries: [
|
||||
{ value: 'reset', title: 'Reset view', hint: 'Show the whole diagram', shortcut: '0' },
|
||||
{ value: 'top', title: 'From above', hint: 'Look straight down, like 2D' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
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 button: HTMLButtonElement;
|
||||
private readonly menu: HTMLDivElement;
|
||||
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) ?? {};
|
||||
({ button: this.button, menu: this.menu } = this.buildMenu());
|
||||
this.addExportItem();
|
||||
this.interceptViewerControls();
|
||||
if (this.saved.active ?? host.defaultView === '3d') this.setActive(true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- chrome
|
||||
|
||||
/**
|
||||
* The 3D menu: a toolbar button with a dropdown, built from Archify's own
|
||||
* style-menu classes so it looks and behaves like the menus beside it.
|
||||
*/
|
||||
private buildMenu(): { button: HTMLButtonElement; menu: HTMLDivElement } {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'preset-wrap archify-3d-wrap';
|
||||
const button = document.createElement('button');
|
||||
button.id = 'btn-3d';
|
||||
button.type = 'button';
|
||||
button.title = '3D view: switch 2D / 3D, levels and camera';
|
||||
button.setAttribute('aria-label', '3D view options');
|
||||
button.setAttribute('aria-haspopup', 'menu');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.setAttribute('aria-controls', 'archify-3d-menu');
|
||||
// Same make-up as Archify's Export button: a label and a chevron.
|
||||
button.innerHTML = '3D <span class="toolbar-icon toolbar-chevron" aria-hidden="true"></span>';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'archify-3d-menu';
|
||||
menu.className = 'preset-menu archify-3d-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', 'View options');
|
||||
for (const section of MENU) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'preset-menu-heading';
|
||||
heading.setAttribute('role', 'presentation');
|
||||
heading.innerHTML = `<span>${section.heading}</span>`;
|
||||
menu.appendChild(heading);
|
||||
for (const entry of section.entries) {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'preset-option archify-3d-option';
|
||||
option.tabIndex = -1;
|
||||
option.dataset.archify3d = entry.value;
|
||||
option.setAttribute('role', entry.group ? 'menuitemradio' : 'menuitem');
|
||||
if (entry.group) option.setAttribute('aria-checked', 'false');
|
||||
option.innerHTML =
|
||||
`<span class="archify-3d-swatch" aria-hidden="true"><span class="archify-3d-swatch-icon archify-3d-i-${entry.value}"></span></span>` +
|
||||
`<span class="preset-option-copy"><strong>${entry.title}</strong><small>${entry.hint}</small></span>` +
|
||||
(entry.group
|
||||
? '<span class="preset-option-check" aria-hidden="true"></span>'
|
||||
: `<kbd class="archify-3d-kbd" aria-hidden="true">${entry.shortcut ?? ''}</kbd>`);
|
||||
option.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (option.getAttribute('aria-disabled') === 'true') return;
|
||||
this.choose(entry.value);
|
||||
if (!entry.group) this.closeMenu();
|
||||
else this.syncButtons();
|
||||
});
|
||||
menu.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (menu.classList.contains('open')) this.closeMenu();
|
||||
else this.openMenu();
|
||||
});
|
||||
menu.addEventListener('keydown', (event) => this.menuKey(event));
|
||||
document.addEventListener('click', (event) => {
|
||||
if (menu.classList.contains('open') && !wrap.contains(event.target as Node)) this.closeMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && menu.classList.contains('open')) {
|
||||
this.closeMenu();
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
|
||||
wrap.append(button, menu);
|
||||
// Beside Export, outside the tools capsule, as its twin.
|
||||
const exportWrap = document.querySelector('.toolbar .export-wrap');
|
||||
if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(wrap, exportWrap);
|
||||
else document.querySelector('.toolbar')?.appendChild(wrap);
|
||||
return { button, menu };
|
||||
}
|
||||
|
||||
private options(): HTMLButtonElement[] {
|
||||
return [...this.menu.querySelectorAll<HTMLButtonElement>('.archify-3d-option')].filter((o) => o.getAttribute('aria-disabled') !== 'true');
|
||||
}
|
||||
|
||||
private openMenu(): void {
|
||||
// Only one toolbar menu at a time: close Archify's own.
|
||||
document.querySelectorAll('.toolbar .preset-menu.open, .toolbar .export-menu.open').forEach((m) => {
|
||||
if (m !== this.menu) (m.previousElementSibling as HTMLElement | null)?.click();
|
||||
});
|
||||
this.syncButtons();
|
||||
this.menu.classList.add('open');
|
||||
this.button.setAttribute('aria-expanded', 'true');
|
||||
const checked = this.options().find((o) => o.getAttribute('aria-checked') === 'true');
|
||||
(checked ?? this.options()[0])?.focus();
|
||||
}
|
||||
|
||||
private closeMenu(): void {
|
||||
this.menu.classList.remove('open');
|
||||
this.button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
private menuKey(event: KeyboardEvent): void {
|
||||
const options = this.options();
|
||||
const index = options.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const move = (to: number) => {
|
||||
event.preventDefault();
|
||||
options[(to + options.length) % options.length]?.focus();
|
||||
};
|
||||
if (event.key === 'ArrowDown') move(index + 1);
|
||||
else if (event.key === 'ArrowUp') move(index - 1);
|
||||
else if (event.key === 'Home') move(0);
|
||||
else if (event.key === 'End') move(options.length - 1);
|
||||
else if (event.key === 'Tab') this.closeMenu();
|
||||
}
|
||||
|
||||
private choose(value: MenuValue): void {
|
||||
switch (value) {
|
||||
case '2d':
|
||||
case '3d':
|
||||
if ((value === '3d') !== this.active) this.setActive(value === '3d');
|
||||
return;
|
||||
case 'flat':
|
||||
case 'stacked':
|
||||
this.stage?.setLayered(value === 'stacked');
|
||||
this.save();
|
||||
return;
|
||||
case 'reset':
|
||||
this.stage?.fit();
|
||||
return;
|
||||
case 'top':
|
||||
this.stage?.top();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 layered = !!this.stage?.layered;
|
||||
this.button.toggleAttribute('data-active', this.active);
|
||||
for (const option of this.menu.querySelectorAll<HTMLButtonElement>('.archify-3d-option')) {
|
||||
const value = option.dataset.archify3d as MenuValue;
|
||||
if (value === '2d' || value === '3d') option.setAttribute('aria-checked', String((value === '3d') === this.active));
|
||||
if (value === 'flat' || value === 'stacked') option.setAttribute('aria-checked', String((value === 'stacked') === layered));
|
||||
// Levels and camera only apply to the 3D view.
|
||||
if (value !== '2d' && value !== '3d') option.setAttribute('aria-disabled', String(!this.active));
|
||||
}
|
||||
if (this.exportItem) this.exportItem.hidden = !this.active;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 });
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Box, Role, roleOf } from './svgModel';
|
||||
|
||||
/**
|
||||
* Turns parts of the live Archify SVG into images. Each element's computed
|
||||
* style is written inline into a standalone copy, so the image carries the
|
||||
* current theme, preset, focus and dimming exactly as the 2D view shows them.
|
||||
*/
|
||||
|
||||
/** Presentation properties copied from computed style. */
|
||||
const PROPS = [
|
||||
'fill', 'fill-opacity', 'fill-rule',
|
||||
'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset',
|
||||
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
|
||||
// visibility is left out: the 3D view hides the 2D SVG with it.
|
||||
'opacity', 'display', 'filter', 'paint-order',
|
||||
'font-family', 'font-size', 'font-weight', 'font-style', 'letter-spacing', 'text-anchor',
|
||||
'dominant-baseline', 'text-transform',
|
||||
];
|
||||
|
||||
let fontCss: string | undefined;
|
||||
|
||||
/** The page's @font-face rules (Archify embeds JetBrains Mono as data URIs). */
|
||||
function fontFaces(): string {
|
||||
if (fontCss !== undefined) return fontCss;
|
||||
const rules: string[] = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
let list: CSSRuleList;
|
||||
try {
|
||||
list = sheet.cssRules;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const rule of [...list]) if (rule instanceof CSSFontFaceRule) rules.push(rule.cssText);
|
||||
}
|
||||
fontCss = rules.join('\n');
|
||||
return fontCss;
|
||||
}
|
||||
|
||||
export interface LayerOptions {
|
||||
/** Roles whose elements are drawn; everything else is hidden. */
|
||||
keep: Role[];
|
||||
/** Draw kept nodes fully opaque; the 3D view applies their dimming itself. */
|
||||
opaqueNodes?: boolean;
|
||||
}
|
||||
|
||||
/** Copies computed styles from the live tree onto a clone with the same shape. */
|
||||
function inlineStyles(live: Element, copy: Element, options: LayerOptions, inherited: Role | undefined, root: boolean): void {
|
||||
const role = inherited ?? roleOf(live);
|
||||
const isDefs = live.tagName === 'defs' || !!live.closest('defs');
|
||||
const style = getComputedStyle(live);
|
||||
const parts: string[] = [];
|
||||
for (const prop of PROPS) {
|
||||
const value = style.getPropertyValue(prop);
|
||||
if (value) parts.push(`${prop}:${value}`);
|
||||
}
|
||||
let hidden = false;
|
||||
if (root) {
|
||||
// The root carries viewer state (camera transform, our fade); keep only
|
||||
// what its children inherit.
|
||||
copy.setAttribute('style', parts.filter((p) => /^(font|letter|text)/.test(p)).join(';'));
|
||||
} else {
|
||||
if (!isDefs) {
|
||||
if (role) hidden = !options.keep.includes(role);
|
||||
else if (live.children.length === 0) hidden = !options.keep.includes('floor');
|
||||
}
|
||||
if (options.opaqueNodes && role === 'node' && !inherited) parts.push('opacity:1');
|
||||
if (hidden) parts.push('display:none');
|
||||
copy.setAttribute('style', parts.join(';'));
|
||||
}
|
||||
copy.removeAttribute('class');
|
||||
if (hidden) return;
|
||||
const liveKids = live.children;
|
||||
const copyKids = copy.children;
|
||||
for (let i = 0; i < liveKids.length && i < copyKids.length; i++) {
|
||||
inlineStyles(liveKids[i], copyKids[i], options, role, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone SVG markup for one layer of the diagram, sized at `scale`
|
||||
* pixels per user unit so the browser rasterizes it sharply.
|
||||
*/
|
||||
export function layerMarkup(svg: SVGSVGElement, viewBox: Box, options: LayerOptions, scale: number): string {
|
||||
const copy = svg.cloneNode(true) as SVGSVGElement;
|
||||
inlineStyles(svg, copy, options, undefined, true);
|
||||
// Viewer-only children (focus overlays, hit areas) carry no data roles but
|
||||
// are not part of the authored drawing.
|
||||
copy.querySelectorAll('[data-viewer-only], .no-export').forEach((el) => el.remove());
|
||||
copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
copy.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`);
|
||||
copy.setAttribute('width', String(Math.round(viewBox.w * scale)));
|
||||
copy.setAttribute('height', String(Math.round(viewBox.h * scale)));
|
||||
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
||||
style.textContent = fontFaces();
|
||||
copy.insertBefore(style, copy.firstChild);
|
||||
return new XMLSerializer().serializeToString(copy);
|
||||
}
|
||||
|
||||
export interface Raster {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** Pixels per SVG user unit. */
|
||||
scale: number;
|
||||
/** SVG area the canvas covers (the view box plus a margin). */
|
||||
area: Box;
|
||||
}
|
||||
|
||||
/** Rasterizes SVG markup over `area` (in user units) at `scale` pixels per unit. */
|
||||
export async function rasterize(markup: string, viewBox: Box, area: Box, scale: number, paint?: (ctx: CanvasRenderingContext2D) => void): Promise<Raster> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(area.w * scale));
|
||||
canvas.height = Math.max(1, Math.round(area.h * scale));
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-area.x, -area.y);
|
||||
paint?.(ctx);
|
||||
const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml' }));
|
||||
try {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
ctx.drawImage(image, viewBox.x, viewBox.y, viewBox.w, viewBox.h);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
return { canvas, scale, area };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
/** Styles for the 3D mode; they reuse Archify's tokens so every preset fits. */
|
||||
export const STYLE = `
|
||||
.diagram-container { position: relative; }
|
||||
/* The 2D SVG stays in the page (its styles and animations drive the 3D
|
||||
view) but is not painted while 3D is on. */
|
||||
html.archify-3d-on .diagram-container > svg { visibility: hidden !important; pointer-events: none !important; }
|
||||
html.archify-3d-on .diagram-container { min-height: max(420px, 62vh); }
|
||||
.archify-3d-stage { position: absolute; inset: 0; z-index: 1; border-radius: inherit; overflow: hidden; }
|
||||
.archify-3d-stage canvas { display: block; touch-action: none; cursor: grab; }
|
||||
.archify-3d-stage canvas:active { cursor: grabbing; }
|
||||
html.archify-3d-on .diagram-container > :not(svg):not(.archify-3d-stage) { z-index: 2; }
|
||||
|
||||
/* The 3D button is Export's twin: same pill, same type, same menu. */
|
||||
.toolbar #btn-3d { padding-right: 0.95rem; padding-left: 1rem; color: var(--bg); font-weight: 550; }
|
||||
.toolbar #btn-3d::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||
.toolbar #btn-3d:hover,
|
||||
.toolbar #btn-3d[aria-expanded="true"] { color: var(--bg); }
|
||||
.toolbar #btn-3d:hover::before,
|
||||
.toolbar #btn-3d[aria-expanded="true"]::before { background: color-mix(in srgb, var(--text) 86%, var(--bg)); }
|
||||
/* While the 3D view is on, the pill wears a cyan ring. */
|
||||
.toolbar #btn-3d[data-active]::before { box-shadow: 0 0 0 2px var(--frontend-stroke), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
||||
.toolbar .archify-3d-wrap .archify-3d-menu { right: 0; }
|
||||
.toolbar .export-menu button[data-archify3d-export]::before { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
|
||||
/* Menu rows: Archify's .preset-option layout, with an icon swatch. */
|
||||
.toolbar .archify-3d-menu { width: 18.5rem; }
|
||||
.toolbar .archify-3d-menu .preset-menu-heading { padding-top: 0.6rem; }
|
||||
.toolbar .archify-3d-menu .preset-menu-heading:first-child { padding-top: 0.45rem; }
|
||||
.archify-3d-swatch { display: grid; place-items: center; width: 2.375rem; height: 1.75rem; border-radius: 0.4375rem; color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 14%, transparent);
|
||||
transition: box-shadow 0.14s ease, color 0.14s ease; }
|
||||
.toolbar .archify-3d-option[aria-checked="true"] .archify-3d-swatch { color: var(--frontend-stroke); box-shadow: 0 0 0 2px var(--toolbar-menu-bg), 0 0 0 3.5px var(--frontend-stroke); }
|
||||
.archify-3d-swatch-icon { width: 1.05rem; height: 1.05rem; background: currentColor; -webkit-mask: center / contain no-repeat; mask: center / contain no-repeat; }
|
||||
.archify-3d-i-2d { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='14' height='12' rx='1.5'/%3E%3Cpath d='M6 8h3M6 11h5M12.5 8h1.5'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='14' height='12' rx='1.5'/%3E%3Cpath d='M6 8h3M6 11h5M12.5 8h1.5'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.archify-3d-i-3d { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5l7 4v7l-7 4-7-4v-7z'/%3E%3Cpath d='M3 6.5l7 4 7-4M10 10.5v7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.archify-3d-i-flat { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 6 17.5 9.8 10 13.6 2.5 9.8z'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 6 17.5 9.8 10 13.6 2.5 9.8z'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.archify-3d-i-stacked { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5 17.5 6.3 10 10 2.5 6.3z'/%3E%3Cpath d='m2.5 10 7.5 3.8 7.5-3.8M2.5 13.7 10 17.5l7.5-3.8'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.archify-3d-i-reset { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 10a6.5 6.5 0 1 0 2-4.7'/%3E%3Cpath d='M3.5 3.5v3.5H7'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 10a6.5 6.5 0 1 0 2-4.7'/%3E%3Cpath d='M3.5 3.5v3.5H7'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.archify-3d-i-top { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.5v9M6.5 8l3.5 3.5L13.5 8'/%3E%3Cpath d='M3 14.5h14M5 17.5h10'/%3E%3C/g%3E%3C/svg%3E"); }
|
||||
.toolbar .archify-3d-option[aria-disabled="true"] { opacity: 0.42; cursor: default; }
|
||||
.toolbar .archify-3d-option[aria-disabled="true"]:hover { background: transparent; }
|
||||
.archify-3d-kbd { justify-self: end; min-width: 1rem; color: var(--text-faint); font: inherit; font-size: 0.6875rem; text-align: center; }
|
||||
.archify-3d-kbd:empty { visibility: hidden; }
|
||||
`;
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Reads the semantic parts of a rendered Archify SVG: nodes, routed
|
||||
* relationships, their labels and structural frames. Archify marks them
|
||||
* with the same data attributes in all five diagram types, so the 3D view
|
||||
* needs no per-type code.
|
||||
*/
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface NodeShape extends Box {
|
||||
id: string;
|
||||
kind: string;
|
||||
rx: number;
|
||||
el: SVGGElement;
|
||||
}
|
||||
|
||||
export interface EdgeShape {
|
||||
key: string;
|
||||
from: string;
|
||||
to: string;
|
||||
path: SVGPathElement;
|
||||
/** Points along the route, every SAMPLE_STEP user units. */
|
||||
points: [number, number][];
|
||||
length: number;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
export interface LabelShape extends Box {
|
||||
key: string;
|
||||
el: SVGGElement;
|
||||
}
|
||||
|
||||
export interface FrameShape extends Box {
|
||||
kind: string;
|
||||
rx: number;
|
||||
el: SVGRectElement;
|
||||
}
|
||||
|
||||
export interface DiagramModel {
|
||||
svg: SVGSVGElement;
|
||||
viewBox: Box;
|
||||
nodes: NodeShape[];
|
||||
edges: EdgeShape[];
|
||||
labels: LabelShape[];
|
||||
frames: FrameShape[];
|
||||
}
|
||||
|
||||
const SAMPLE_STEP = 3;
|
||||
|
||||
/** Role of an element for the 3D view; descendants share their ancestor's role. */
|
||||
export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay';
|
||||
|
||||
/**
|
||||
* Archify's temporary viewer layers (focus pulse, intent trace, route probe,
|
||||
* journey, lens, story) are marked data-…-overlay. They are animated in 3D
|
||||
* from the live SVG and never baked into the textures.
|
||||
*/
|
||||
function isOverlay(el: Element): boolean {
|
||||
for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */
|
||||
export const FLOW_SELECTOR = [
|
||||
'relationship-pulse',
|
||||
'intent-trace',
|
||||
'route-probe',
|
||||
'route-journey',
|
||||
'semantic-lens',
|
||||
'story',
|
||||
'story-carrier',
|
||||
'chapter-handoff',
|
||||
].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', ');
|
||||
|
||||
export function roleOf(el: Element): Role | undefined {
|
||||
if (isOverlay(el)) return 'overlay';
|
||||
if (el.hasAttribute('data-node-id')) return 'node';
|
||||
if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge';
|
||||
if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge';
|
||||
if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge';
|
||||
if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function num(el: Element, name: string): number {
|
||||
return Number(el.getAttribute(name) ?? 0) || 0;
|
||||
}
|
||||
|
||||
function rectBox(rect: SVGRectElement): Box & { rx: number } {
|
||||
return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') };
|
||||
}
|
||||
|
||||
function nodeShape(el: SVGGElement): NodeShape | undefined {
|
||||
const id = el.getAttribute('data-node-id');
|
||||
if (!id) return undefined;
|
||||
const kind = el.getAttribute('data-node-kind') ?? 'backend';
|
||||
// The node body is the semantic rect (c-<kind>); c-mask sits under it.
|
||||
const rect =
|
||||
el.querySelector<SVGRectElement>(`:scope > rect.c-${CSS.escape(kind)}`) ??
|
||||
el.querySelector<SVGRectElement>(':scope > rect:not(.c-mask)') ??
|
||||
el.querySelector<SVGRectElement>(':scope > rect');
|
||||
if (rect) return { id, kind, el, ...rectBox(rect) };
|
||||
const bbox = el.getBBox();
|
||||
return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 };
|
||||
}
|
||||
|
||||
function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } {
|
||||
const length = path.getTotalLength();
|
||||
const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP));
|
||||
const points: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const p = path.getPointAtLength((length * i) / steps);
|
||||
points.push([p.x, p.y]);
|
||||
}
|
||||
return { points, length };
|
||||
}
|
||||
|
||||
export function readModel(svg: SVGSVGElement): DiagramModel {
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
|
||||
|
||||
const nodes = [...svg.querySelectorAll<SVGGElement>('g[data-node-id]')]
|
||||
.map(nodeShape)
|
||||
.filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0);
|
||||
|
||||
const edges: EdgeShape[] = [];
|
||||
svg.querySelectorAll<SVGPathElement>('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => {
|
||||
const from = path.getAttribute('data-edge-from') ?? '';
|
||||
const to = path.getAttribute('data-edge-to') ?? '';
|
||||
const group = path.parentElement;
|
||||
const { points, length } = samplePath(path);
|
||||
if (length <= 0) return;
|
||||
edges.push({
|
||||
key: path.getAttribute('data-edge-key') ?? `e${index}`,
|
||||
from,
|
||||
to,
|
||||
path,
|
||||
points,
|
||||
length,
|
||||
animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'),
|
||||
});
|
||||
});
|
||||
|
||||
const labels: LabelShape[] = [];
|
||||
svg.querySelectorAll<SVGGElement>('g[data-edge-key]').forEach((el) => {
|
||||
if (roleOf(el) !== 'label') return;
|
||||
const rect = el.querySelector<SVGRectElement>(':scope > rect');
|
||||
const box = rect ? rectBox(rect) : (() => {
|
||||
const b = el.getBBox();
|
||||
return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 };
|
||||
})();
|
||||
if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box });
|
||||
});
|
||||
|
||||
const frames = [...svg.querySelectorAll<SVGRectElement>('rect[data-graph-role="structural-frame"]')].map((el) => ({
|
||||
kind: el.getAttribute('data-composition-frame-kind') ?? 'region',
|
||||
el,
|
||||
...rectBox(el),
|
||||
}));
|
||||
|
||||
return { svg, viewBox, nodes, edges, labels, frames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opacity of an element including its ancestors below the SVG root. The
|
||||
* root is left out: the 3D view hides the 2D SVG by fading the root.
|
||||
*/
|
||||
export function effectiveOpacity(el: Element, root: Element): number {
|
||||
let value = 1;
|
||||
for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) {
|
||||
const style = getComputedStyle(cur);
|
||||
if (style.display === 'none') return 0;
|
||||
value *= Number(style.opacity || 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in new issue
Block a user