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.
This commit is contained in:
2026-10-12 17:35:38 +03:00
parent fb7d7706aa
commit c71d2e40b9
7 changed files with 112 additions and 208 deletions
+71 -169
View File
@@ -4,9 +4,9 @@ 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
* 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.
@@ -36,41 +36,6 @@ 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;
@@ -83,8 +48,10 @@ class Viewer3D {
/** 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 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;
@@ -95,145 +62,79 @@ class Viewer3D {
private readonly svg: SVGSVGElement,
) {
this.saved = (host.getState().view3d as SavedView | undefined) ?? {};
({ button: this.button, menu: this.menu } = this.buildMenu());
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 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.
* 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 buildMenu(): { button: HTMLButtonElement; menu: HTMLDivElement } {
const wrap = document.createElement('div');
wrap.className = 'preset-wrap archify-3d-wrap';
private buildSwitch(): HTMLButtonElement {
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.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();
if (menu.classList.contains('open')) this.closeMenu();
else this.openMenu();
this.setActive(!this.active);
});
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 };
if (exportWrap?.parentElement) exportWrap.parentElement.insertBefore(button, exportWrap);
else document.querySelector('.toolbar')?.appendChild(button);
return button;
}
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();
/**
* 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;
};
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;
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 {
@@ -295,16 +196,17 @@ class Viewer3D {
}
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;
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