@festapp/venue-layout 0.0.0-stage → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@festapp/venue-layout",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.6",
4
+ "description": "Venue Layout: a portable SVG venue viewer and seat picker powered by a shared Dart geometry kernel",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.js"
11
+ },
12
+ "./kernel": {
13
+ "types": "./src/kernel.d.ts",
14
+ "default": "./dist/venue_layout_kernel.js"
15
+ },
16
+ "./document-schema": "./dist/layout-v2.schema.json"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "dist/venue_layout_kernel.js",
21
+ "dist/demo.js",
22
+ "dist/manifest.json",
23
+ "dist/layout-v2.schema.json",
24
+ "demo",
25
+ "README.md",
26
+ "LICENSE",
27
+ "assets"
28
+ ],
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/festappnet/venue_seat_picker"
33
+ },
34
+ "devDependencies": {
35
+ "playwright": "^1.55.0",
36
+ "esbuild": "^0.25.0"
37
+ },
38
+ "homepage": "https://festappnet.github.io/venue_seat_picker/",
39
+ "keywords": [
40
+ "venue-layout",
41
+ "seating-chart",
42
+ "seat-picker",
43
+ "svg"
44
+ ],
45
+ "scripts": {
46
+ "build": "../tool/build_web.sh",
47
+ "test": "node --test test/*.test.mjs"
48
+ }
6
49
  }
package/src/assets.js ADDED
@@ -0,0 +1,52 @@
1
+ /** Resolve portable raster resources before mounting the synchronous viewer.
2
+ * The host supplies URLs; no backend or asset catalog is owned by this package.
3
+ */
4
+ export async function loadLayoutAssets(resources = {}, options = {}) {
5
+ const {fetch: fetcher = globalThis.fetch, signal, maxBytes = 20_000_000} = options;
6
+ const assets = {};
7
+ for (const [ref, asset] of Object.entries(resources.assets || {})) {
8
+ signal?.throwIfAborted();
9
+ if (!asset || !/^[0-9a-f]{64}$/.test(asset.hash) ||
10
+ !['image/png', 'image/jpeg', 'image/webp'].includes(asset.mimeType) ||
11
+ !Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) ||
12
+ asset.width <= 0 || asset.height <= 0 || asset.width * asset.height > 40_000_000 ||
13
+ !Number.isSafeInteger(asset.byteLength) || asset.byteLength < 1 || asset.byteLength > maxBytes ||
14
+ (ref.startsWith('image:') && ref !== `image:${asset.hash}`)) {
15
+ throw new Error('invalid_asset_manifest');
16
+ }
17
+ let bytes;
18
+ {
19
+ const url = new URL(asset.url, globalThis.location?.href);
20
+ if (!['http:', 'https:', 'data:'].includes(url.protocol)) throw new Error('invalid_asset_url');
21
+ const response = await fetcher(url, {signal, credentials: 'omit', cache: 'force-cache'});
22
+ if (!response.ok) throw new Error('asset_load_failed');
23
+ if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('asset_size_limit');
24
+ const chunks = []; let length = 0;
25
+ const reader = response.body.getReader();
26
+ try {
27
+ for (;;) {
28
+ const {value, done} = await reader.read();
29
+ if (done) break;
30
+ length += value.byteLength;
31
+ if (length > maxBytes || length > asset.byteLength) throw new Error('asset_size_limit');
32
+ chunks.push(value);
33
+ }
34
+ } finally { await reader.cancel(); reader.releaseLock(); }
35
+ bytes = new Uint8Array(length); let offset = 0;
36
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
37
+ }
38
+ if (bytes.byteLength !== asset.byteLength) throw new Error('asset_length_mismatch');
39
+ const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
40
+ b => b.toString(16).padStart(2, '0')).join('');
41
+ if (hash !== asset.hash) throw new Error('asset_hash_mismatch');
42
+ const bitmap = await createImageBitmap(new Blob([bytes], {type: asset.mimeType}));
43
+ try {
44
+ if (bitmap.width !== asset.width || bitmap.height !== asset.height) throw new Error('asset_dimensions_mismatch');
45
+ } finally { bitmap.close(); }
46
+ signal?.throwIfAborted();
47
+ let binary = '';
48
+ for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
49
+ assets[ref] = {hash:asset.hash,url:`data:${asset.mimeType};base64,${btoa(binary)}`,width:asset.width,height:asset.height,mimeType:asset.mimeType,byteLength:asset.byteLength};
50
+ }
51
+ return Object.keys(assets).length ? {...resources, assets} : resources;
52
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /** Loads the unmodified compiled Dart asset once, with shared in-flight requests. */
2
+ export declare function loadKernel(url:URL|string):Promise<void>;
3
+ export interface LayoutNode { id:string; kind:'group'|'table'|'seat'|'furniture'|'capacityArea'|'shape'|'text'|'image'|'vector'; parentId?:string; transform:{x:number;y:number;rotation:number}; width:number;height:number;z?:number;locked?:boolean;label?:string;arrowHeadLength?:number;[property:string]:unknown }
4
+ export interface VenueLayoutDocument {schemaVersion:2;symbolSetVersion:1;canvas:{x:number;y:number;width:number;height:number;fill:string};nodes:LayoutNode[];styles?:Record<string,unknown>;seatingDefaults?:Record<string,number>;textPolicy?:{language?:string;direction?:'auto'|'ltr'|'rtl'};sections?:Record<string,unknown>[];selectionUnits?:{id:string;kind:'atomic';nodeIds:string[];label?:string}[];attachments?:Record<string,unknown>[];presets?:Record<string,unknown>[];background?:Record<string,unknown>|null}
5
+ export interface SelectionResult {accepted:boolean;message?:string}
6
+ /** Text templates use {count}; status keys match presentation statuses. */
7
+ export type LayoutMessages = Record<'viewerLabel' | 'places' | 'place' | 'standing' | 'availableCount' | 'confirmQuantity' | 'addOne' | 'removeOne' | 'selectedQuantity' | 'quantityRejected' | 'placeUnavailable' | 'selectedPlaces' | 'releasedPlaces' | 'selectionRejected' | 'backgroundUnavailable' | 'placesTogether' | 'pending' | 'available' | 'occupied' | 'reserved' | 'blocked' | 'unavailable' | 'disabled' | 'sold', string>;
8
+ export interface SeatPresentation {status?:string;selected?:boolean;selectable?:boolean;fill?:string;stateMarker?:'auto'|'none';label?:string;tooltip?:string;[property:string]:unknown}
9
+ export interface LayoutOptions {/** Optional rounded canvas frame in CSS pixels; does not change document geometry. */canvasCornerRadius?:number;tooltipStyle?:Partial<CSSStyleDeclaration>;tooltipOffset?:number;minZoom?:number;maxZoom?:number;onCameraChanged?:(state:{canZoomIn:boolean;canZoomOut:boolean})=>void;messages?:Partial<LayoutMessages>;document:VenueLayoutDocument;resources?:Record<string,unknown>;seatPresentation?:Record<string,SeatPresentation>;capacityPresentation?:Record<string,unknown>;onSelectionRequested?:(ids:Set<string>,selecting:boolean)=>Promise<SelectionResult>;onQuantityRequested?:(id:string,quantity:number)=>Promise<SelectionResult>;accessibilityLabel?:string;onIssues?:(issues:Record<string,unknown>[])=>void}
10
+ export interface LayoutHandle {zoomBy(factor:number):void;fitToScreen():void;update(options:Partial<LayoutOptions>):LayoutHandle;dispose():void}
11
+ export declare function mount(container:HTMLElement,options:LayoutOptions):LayoutHandle;
12
+ export declare function update(handle:LayoutHandle,options:Partial<LayoutOptions>):LayoutHandle;
13
+ export declare function dispose(handle:LayoutHandle):void;
14
+ export interface VenueLayoutKernel {readonly protocol:1;readonly documentVersion:2;readonly resourceProtocol:2;decode(json:string):string;validate(json:string):string;validateResources(resourcesJson:string):string;nearestNode(json:string,id:string,direction:"left"|"right"|"up"|"down"):string|null;compileScene(json:string,resourcesJson:string,presentationJson:string):string;compileSceneNodes(json:string,idsJson:string,resourcesJson:string,presentationJson:string):string;hitTest(json:string,x:number,y:number):string|null;interpolate(fromJson:string,toJson:string,t:number):string}
15
+ declare global {var VenueLayoutKernel:VenueLayoutKernel}
16
+
17
+ /** Verifies processed image assets before exposing immutable render data. */
18
+ export declare function loadLayoutAssets(resources:Record<string,unknown>, options?:{fetch?:typeof fetch;signal?:AbortSignal;maxBytes?:number}):Promise<Record<string,unknown>>;
package/src/index.js ADDED
@@ -0,0 +1,474 @@
1
+ export {loadLayoutAssets} from './assets.js';
2
+ const defaultMessages = Object.freeze({
3
+ viewerLabel: 'Venue layout', places: 'Places', place: 'Place', standing: 'Standing',
4
+ availableCount: '{count} available', addOne: 'Add one', removeOne: 'Remove one', confirmQuantity: 'Confirm quantity', selectedQuantity: 'Selected quantity {count}',
5
+ quantityRejected: 'Quantity rejected', placeUnavailable: 'Place unavailable',
6
+ selectedPlaces: 'Selected {count} places', releasedPlaces: 'Released {count} places',
7
+ selectionRejected: 'Selection rejected', backgroundUnavailable: 'Background resource unavailable',
8
+ placesTogether: '{count} places together', pending: 'pending', available: 'available',
9
+ occupied: 'occupied', reserved: 'reserved', blocked: 'blocked', unavailable: 'unavailable', disabled: 'disabled', sold: 'sold',
10
+ });
11
+ function message(options, key, values = {}) {
12
+ const template = options.messages?.[key] ?? defaultMessages[key] ?? key;
13
+ return String(template).replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`));
14
+ }
15
+
16
+ const kernelLoads = new Map();
17
+ const kernelReady = () => globalThis.VenueLayoutKernel?.protocol === 1 &&
18
+ globalThis.VenueLayoutKernel.documentVersion === 2 &&
19
+ globalThis.VenueLayoutKernel.resourceProtocol === 2 &&
20
+ typeof globalThis.VenueLayoutKernel.validate === 'function' &&
21
+ typeof globalThis.VenueLayoutKernel.compileScene === 'function';
22
+
23
+ /** Load the Dart asset unchanged. Re-bundling it renames runtime type identities. */
24
+ export function loadKernel(url) {
25
+ if (kernelReady()) return Promise.resolve();
26
+ if (globalThis.VenueLayoutKernel) return Promise.reject(new Error('Unsupported venue layout kernel protocol'));
27
+ if (typeof document === 'undefined') return Promise.reject(new Error('Kernel script loading requires a browser document'));
28
+ let href;
29
+ try {
30
+ const parsed = new URL(url, document.baseURI);
31
+ if (!['http:', 'https:', 'file:'].includes(parsed.protocol)) throw new Error('Unsupported kernel asset URL');
32
+ href = parsed.href;
33
+ } catch (error) { return Promise.reject(error); }
34
+ if (kernelLoads.has(href)) return kernelLoads.get(href);
35
+ const script = document.createElement('script');
36
+ script.src = href;
37
+ script.async = true;
38
+ const loading = new Promise((resolve, reject) => {
39
+ script.onload = () => kernelReady() ? resolve() : reject(new Error('Invalid venue layout kernel protocol or exports'));
40
+ script.onerror = () => reject(new Error('Venue layout kernel asset could not be loaded'));
41
+ }).finally(() => {
42
+ script.onload = script.onerror = null;
43
+ script.remove();
44
+ kernelLoads.delete(href);
45
+ });
46
+ kernelLoads.set(href, loading);
47
+ document.head.appendChild(script);
48
+ return loading;
49
+ }
50
+
51
+ const SVG = 'http://www.w3.org/2000/svg';
52
+ let viewerSerial = 0;
53
+ const color = value => /^#[0-9a-f]{8}$/i.test(value || '') ? value : '#00000000';
54
+ const element = (tag, attributes = {}) => {
55
+ const node = document.createElementNS(SVG, tag);
56
+ for (const [key, value] of Object.entries(attributes)) if (value != null) node.setAttribute(key, String(value));
57
+ return node;
58
+ };
59
+ const pathData = path => path.map(command => command.join(' ')).join(' ');
60
+ // `meet` may expose world coordinates outside the nominal viewBox. Culling
61
+ // must follow the browser's actual viewport rather than treating viewBox as a clip.
62
+ function visibleWorldBounds(svg, matrix) {
63
+ const rect = svg.getBoundingClientRect();
64
+ if (!matrix || rect.width <= 0 || rect.height <= 0) return null;
65
+ let inverse;
66
+ try {inverse = matrix.inverse();} catch {return null;}
67
+ const points = [[rect.left, rect.top], [rect.right, rect.top], [rect.left, rect.bottom], [rect.right, rect.bottom]]
68
+ .map(([x, y]) => new DOMPoint(x, y).matrixTransform(inverse));
69
+ if (points.some(p => !Number.isFinite(p.x) || !Number.isFinite(p.y))) return null;
70
+ const xs = points.map(p => p.x), ys = points.map(p => p.y);
71
+ const x = Math.min(...xs), y = Math.min(...ys);
72
+ return {x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y};
73
+ }
74
+ /** Mount the shared Dart scene. Geometry, glyphs and hit testing belong to the kernel. */
75
+ export function mount(container, options = {}) {
76
+ if (!container || !globalThis.VenueLayoutKernel) throw new Error('Container and VenueLayoutKernel are required');
77
+ const root = document.createElement('div');
78
+ root.className = 'venue-layout-viewer';
79
+ root.style.position = 'relative';
80
+ const previousTouchAction = container.style.touchAction;
81
+ container.style.touchAction = 'none';
82
+ const svg = element('svg', {role: 'img', 'aria-label': options.accessibilityLabel || message(options, 'viewerLabel'), width: '100%', height: '100%'});
83
+ svg.style.touchAction = 'none';
84
+ svg.style.display = 'block';
85
+ svg.style.minHeight = '240px';
86
+ const list = document.createElement('div');
87
+ list.setAttribute('role', 'list');
88
+ list.setAttribute('aria-label', message(options, 'places'));
89
+ const live = document.createElement('div');
90
+ live.setAttribute('role', 'status'); live.setAttribute('aria-live', 'polite');
91
+ // Preserve keyboard/screen-reader controls without a second visual seat UI.
92
+ for (const assistive of [list, live]) Object.assign(assistive.style, {
93
+ position: 'absolute', top: '0', left: '0', width: '1px', height: '1px',
94
+ overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap',
95
+ padding: '0', margin: '-1px', border: '0', boxSizing: 'border-box',
96
+ });
97
+ const detail = document.createElement('div');
98
+ root.append(svg, list, detail, live); container.append(root);
99
+ const handle = {serial: ++viewerSerial, container, root, svg, list, detail, live, previousTouchAction, options: {}, pending: new Set(), selected: new Set(), quantities: new Map(), disposed: false, generation: 0, update(next) {return update(handle, next);}, dispose() {dispose(handle);}, zoomBy(factor) {
100
+ if (handle.disposed) throw new Error('Viewer is disposed');
101
+ if (!Number.isFinite(factor) || factor <= 0) throw new Error('Zoom factor must be positive');
102
+ handle.hideTooltip?.();
103
+ const c = handle.camera, ratio = zoomFactor(handle, c, 1 / factor);
104
+ handle.camera = {...c, x: c.x + c.width * (1 - ratio) / 2, y: c.y + c.height * (1 - ratio) / 2, width: c.width * ratio, height: c.height * ratio};
105
+ render(handle, []);
106
+ }, fitToScreen() {
107
+ if (handle.disposed) throw new Error('Viewer is disposed');
108
+ handle.hideTooltip?.(); handle.camera = null; render(handle, []);
109
+ }};
110
+ handle.click = async event => {
111
+ if (!svg.contains(event.target)) {
112
+ if (event.target === root || event.target === container) handle.suppressClick = false;
113
+ return;
114
+ }
115
+ const inverse = svg.getScreenCTM()?.inverse();
116
+ if (!inverse) return;
117
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(inverse);
118
+ const id = globalThis.VenueLayoutKernel.hitTest(handle.json, point.x, point.y);
119
+ if (handle.suppressClick) {handle.suppressClick = false; return;}
120
+ if (id) await requestSelection(handle, id);
121
+ };
122
+ container.addEventListener('click', handle.click);
123
+ installCamera(handle);
124
+ installTooltip(handle, options);
125
+ try {update(handle, options);} catch(error) {dispose(handle);throw error;}
126
+ if (typeof ResizeObserver !== 'undefined') {
127
+ handle.resizeObserver = new ResizeObserver(() => {if (!handle.disposed) render(handle, []);});
128
+ handle.resizeObserver.observe(svg);
129
+ }
130
+ return handle;
131
+ }
132
+ async function requestSelection(handle, id) {
133
+ const {options} = handle;
134
+ const node = options.document.nodes.find(n => n.id === id);
135
+ if (!node) return;
136
+ if (node.kind === 'capacityArea') {
137
+ if(handle.pending.has(id))return;
138
+ const state = options.capacityPresentation?.[id] || {};
139
+ const selected = handle.quantities.get(id) ?? 0;
140
+ const maximum = Math.max(0, Number(state.available ?? state.capacity ?? 0));
141
+ const unavailable = maximum === 0 || ['occupied','unavailable','disabled','blocked','sold','reserved'].includes(state.status) || state.selectable === false;
142
+ if (unavailable && selected === 0) {
143
+ handle.detail.replaceChildren();
144
+ handle.live.textContent = state.tooltip || message(options, 'placeUnavailable');
145
+ return;
146
+ }
147
+ const generation=handle.generation, callback=options.onQuantityRequested;
148
+ handle.detail.replaceChildren();
149
+ const label = document.createElement('label'); label.textContent = `${node.label || message(options, 'standing')} - ${message(options, 'availableCount', {count: state.available ?? state.capacity ?? 0})}`;
150
+ const quantity = document.createElement('input'); quantity.type = 'number'; quantity.min = '0'; quantity.max = String(state.available ?? state.capacity ?? 0); quantity.value = String(Math.min(maximum, handle.quantities.get(id) ?? 1));
151
+ const stepper = document.createElement('span');
152
+ Object.assign(stepper.style, {display:'inline-flex', alignItems:'center', gap:'8px'});
153
+ const minus = document.createElement('button'), plus = document.createElement('button');
154
+ minus.type = plus.type = 'button';
155
+ minus.textContent = '−'; plus.textContent = '+';
156
+ minus.setAttribute('aria-label', message(options, 'removeOne'));
157
+ plus.setAttribute('aria-label', message(options, 'addOne'));
158
+ for (const button of [minus, plus]) Object.assign(button.style, {minWidth:'48px', minHeight:'48px'});
159
+ const updateSteps = () => {minus.disabled = Number(quantity.value) <= 0; plus.disabled = Number(quantity.value) >= maximum || unavailable;};
160
+ minus.onclick = () => {quantity.stepDown(); updateSteps();};
161
+ plus.onclick = () => {quantity.stepUp(); updateSteps();};
162
+ quantity.oninput = updateSteps; updateSteps();
163
+ const confirm = document.createElement('button'); confirm.textContent = message(options, 'confirmQuantity');
164
+ confirm.onclick = async () => {
165
+ if(handle.disposed||generation!==handle.generation||handle.pending.has(id))return;
166
+ const current=handle.options.capacityPresentation?.[id]||{};
167
+ const value = Number(quantity.value), available=Number(current.available??current.capacity??0);
168
+ const unavailable=['occupied','unavailable','disabled','blocked','sold','reserved'].includes(current.status)||current.selectable===false;
169
+ if (!Number.isInteger(value) || value < 0 || value > available || (value>0&&unavailable) || !callback) return;
170
+ confirm.disabled = true;handle.pending.add(id);render(handle,[id]);
171
+ try {
172
+ const result = await callback(id, value);
173
+ if (handle.disposed||generation!==handle.generation)return;
174
+ if(result?.accepted)handle.quantities.set(id,value);
175
+ handle.live.textContent = result?.accepted ? message(options, 'selectedQuantity', {count: value}) : result?.message || message(options, 'quantityRejected');
176
+ } catch (error) {if (!handle.disposed&&generation===handle.generation) handle.live.textContent = String(error.message || error);}
177
+ finally {if(!handle.disposed&&generation===handle.generation){handle.pending.delete(id);confirm.disabled=false;render(handle,[id]);}}
178
+ };
179
+ stepper.append(minus, quantity, plus); label.append(stepper); handle.detail.append(label, confirm); quantity.focus(); return;
180
+ }
181
+ if (node.kind !== 'seat' || !options.onSelectionRequested) return;
182
+ const unavailable = new Set(['occupied','unavailable','disabled','blocked','sold','reserved']);
183
+ const unit = (options.document.selectionUnits || []).find(u => u.nodeIds.includes(id));
184
+ const ids = unit?.nodeIds || [id];
185
+ if (ids.some(id => unavailable.has(options.seatPresentation?.[id]?.status) || options.seatPresentation?.[id]?.selectable === false)) {handle.live.textContent = message(options, 'placeUnavailable'); return;}
186
+ if (ids.some(id => handle.pending.has(id))) return;
187
+ const selecting = !ids.every(id => handle.selected.has(id));
188
+ const previous = new Set(handle.selected);
189
+ ids.forEach(id => {handle.pending.add(id); selecting ? handle.selected.add(id) : handle.selected.delete(id);});
190
+ const generation = handle.generation;
191
+ render(handle, ids);
192
+ try {
193
+ const result = await options.onSelectionRequested(new Set(ids), selecting);
194
+ if (handle.disposed || generation !== handle.generation) return;
195
+ if (!result?.accepted) ids.forEach(id => previous.has(id) ? handle.selected.add(id) : handle.selected.delete(id));
196
+ handle.live.textContent = result?.accepted ? message(options, selecting ? 'selectedPlaces' : 'releasedPlaces', {count: ids.length}) : result?.message || message(options, 'selectionRejected');
197
+ } catch (error) {if (!handle.disposed && generation === handle.generation) {ids.forEach(id => previous.has(id) ? handle.selected.add(id) : handle.selected.delete(id)); handle.live.textContent = String(error.message || error);}}
198
+ finally {if (!handle.disposed && generation === handle.generation) {ids.forEach(id => handle.pending.delete(id)); render(handle, ids);}}
199
+ }
200
+ export function update(handle, options = {}) {
201
+ handle.hideTooltip?.();
202
+ if (handle.disposed) throw new Error('Viewer is disposed');
203
+ const resourcesChanged = options.resources && options.resources !== handle.options.resources;
204
+ if (options.document && options.document !== handle.options.document) {const issues=JSON.parse(globalThis.VenueLayoutKernel.validate(JSON.stringify(options.document)));if(issues.length)throw new Error(issues.map(i=>`${i.path}: ${i.reason}`).join('; '));}
205
+ if (resourcesChanged && globalThis.VenueLayoutKernel.validateResources) {const issues=JSON.parse(globalThis.VenueLayoutKernel.validateResources(JSON.stringify(options.resources)));if(issues.length)throw new Error(issues.map(i=>`${i.path}: ${i.reason}`).join('; '));}
206
+ const replacingDocument = options.document && options.document !== handle.options.document;
207
+ handle.options = {...handle.options, ...options};
208
+ if (!handle.options.document) throw new Error('A document is required');
209
+ if (replacingDocument) {handle.generation++; handle.pending.clear(); handle.selected.clear(); handle.quantities.clear(); handle.detail.replaceChildren(); handle.scene = null; handle.sceneItems = null; handle.elements = null; handle.listDocument = null;}
210
+ if (options.seatPresentation) for (const [id,state] of Object.entries(options.seatPresentation)) {
211
+ if (!handle.pending.has(id) && typeof state.selected==='boolean') state.selected ? handle.selected.add(id) : handle.selected.delete(id);
212
+ }
213
+ if(options.capacityPresentation)for(const [id,state] of Object.entries(options.capacityPresentation)) {
214
+ if(!handle.pending.has(id)&&Number.isInteger(state.quantity)&&state.quantity>=0&&(state.quantity>0||handle.quantities.has(id)))handle.quantities.set(id,state.quantity);
215
+ }
216
+ if (replacingDocument || !handle.json) handle.json = JSON.stringify(handle.options.document);
217
+ if (resourcesChanged || !handle.resourcesJson) {handle.resourcesJson = JSON.stringify(handle.options.resources || {}); handle.sceneItems = null; handle.elements = null;}
218
+ render(handle);
219
+ return handle;
220
+ }
221
+ function render(handle, dirtyIds) {
222
+ const {options, svg} = handle;
223
+ const presentation = {...options.seatPresentation, ...options.capacityPresentation};
224
+ for (const id of new Set([...Object.keys(options.seatPresentation || {}),...handle.selected])) presentation[id] = {...presentation[id], selected: handle.selected.has(id)};
225
+ for (const id of handle.pending) presentation[id] = {...presentation[id], pending: true};
226
+ let scene;
227
+ if (handle.sceneItems && globalThis.VenueLayoutKernel.compileSceneNodes) {
228
+ const changed = dirtyIds || [...new Set([...Object.keys(presentation), ...Object.keys(handle.previousPresentation || {})])].filter(id => JSON.stringify(presentation[id]) !== JSON.stringify(handle.previousPresentation?.[id]));
229
+ if (changed.length === 0) {
230
+ scene = handle.scene;
231
+ } else {
232
+ scene = JSON.parse(globalThis.VenueLayoutKernel.compileSceneNodes(handle.json, JSON.stringify(changed), handle.resourcesJson, JSON.stringify(presentation)));
233
+ const changedSet = new Set(changed);
234
+ for (const [id, item] of handle.sceneItems) if (changedSet.has(item.nodeId || item.id)) handle.sceneItems.delete(id);
235
+ for (const item of scene.items) handle.sceneItems.set(item.id, item);
236
+ scene = {...scene, items: [...handle.sceneItems.values()].sort((a, b) => a.paintOrder[0] - b.paintOrder[0] || a.paintOrder[1] - b.paintOrder[1])};
237
+ }
238
+ } else {
239
+ scene = JSON.parse(globalThis.VenueLayoutKernel.compileScene(handle.json, handle.resourcesJson, JSON.stringify(presentation)));
240
+ handle.sceneItems = new Map(scene.items.map(item => [item.id, item]));
241
+ }
242
+ handle.previousPresentation = presentation; handle.scene = scene;
243
+ if (scene.issues.length) {handle.live.textContent = scene.issues.map(i => i.reason).join(', '); options.onIssues?.(scene.issues);}
244
+ const c = scene.canvas;
245
+ if (!handle.camera) handle.camera = {x: c.x || 0, y: c.y || 0, width: c.width, height: c.height};
246
+ handle.camera = boundCamera(handle, handle.camera);
247
+ svg.setAttribute('viewBox', `${handle.camera.x} ${handle.camera.y} ${handle.camera.width} ${handle.camera.height}`);
248
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
249
+ updateCanvasFrame(handle, c);
250
+ const limits = cameraMetrics(handle, handle.camera);
251
+ options.onCameraChanged?.({canZoomIn: handle.camera.width > limits.minWidth + .001, canZoomOut: handle.camera.width < limits.maxWidth - .001});
252
+ if (!handle.elements) {svg.replaceChildren(element('rect', {x: c.x || 0, y: c.y || 0, width: c.width, height: c.height, fill: color(c.fill)})); handle.elements = new Map();}
253
+ const containers=[svg],previous=new Map([[svg,svg.firstElementChild]]);
254
+ const placeNode = node => {const parent=containers.at(-1),before=previous.get(parent)||null;if(node.parentNode!==parent||node.previousSibling!==before)parent.insertBefore(node,before?before.nextSibling:parent.firstChild);previous.set(parent,node);};
255
+ const wanted = new Set(), screenMatrix=svg.getScreenCTM(), zoom=screenMatrix?Math.hypot(screenMatrix.a,screenMatrix.b):1, viewport=visibleWorldBounds(svg, screenMatrix);
256
+ for (const item of scene.items) {
257
+ if (zoom < (item.minZoom || 0)) continue;
258
+ const b = item.cullingBounds || item.bounds, v = viewport;
259
+ if (b && v && (b.x + b.width < v.x || b.y + b.height < v.y || b.x > v.x + v.width || b.y > v.y + v.height)) continue;
260
+ if(item.kind==='vectorRestore'){containers.pop();continue;}
261
+ wanted.add(item.id);
262
+ const old = handle.elements.get(item.id);
263
+ if(item.kind==='vectorClip'||item.kind==='vectorLayer'){
264
+ let group=old?.item===item?old.node:null;
265
+ if(!group){
266
+ group=element('g',{'data-node-id':item.nodeId});
267
+ if(item.kind==='vectorLayer')group.setAttribute('opacity',item.opacity);
268
+ else{
269
+ const id=`venue-${handle.serial}-${item.id.replace(/[^a-zA-Z0-9_-]/g,'-')}-clip`,defs=element('defs'),clip=element('clipPath',{id,clipPathUnits:'userSpaceOnUse'});
270
+ clip.append(element('path',{d:pathData(item.path),'clip-rule':item.fillRule,transform:`matrix(${item.matrix.join(' ')})`}));defs.append(clip);group.append(defs);group.setAttribute('clip-path',`url(#${id})`);
271
+ }
272
+ if(old)old.node.replaceWith(group);handle.elements.set(item.id,{item,node:group});
273
+ }
274
+ placeNode(group);containers.push(group);previous.set(group,item.kind==='vectorClip'?group.firstElementChild:null);continue;
275
+ }
276
+ if (old?.item === item) {placeNode(old.node); continue;}
277
+ const group = element('g', {transform: `matrix(${item.matrix.join(' ')})`, opacity: item.kind === 'image' ? item.opacity ?? 1 : null, 'data-node-id': item.nodeId || item.id});
278
+ if (item.kind === 'image') {
279
+ const asset = options.resources?.assets?.[item.assetRef];
280
+ const href = asset?.url;
281
+ if (href && /^(data:image\/(png|jpeg|webp);base64,|blob:|https?:|file:|\.\.?\/)/i.test(href)) {
282
+ const source = item.sourceRect, destination = item.destinationRect;
283
+ const nested = element('svg', {x: destination.x, y: destination.y, width: destination.width, height: destination.height, viewBox: `${source.x} ${source.y} ${source.width} ${source.height}`, preserveAspectRatio: 'none', overflow: 'hidden'});
284
+ nested.append(element('image', {href, width: item.width, height: item.height}));
285
+ const local = element('g', {transform: group.getAttribute('transform')}); local.append(nested); group.removeAttribute('transform');
286
+ if (item.clip) {const clipId = `venue-${handle.serial}-background-clip`, defs = element('defs'), clip = element('clipPath', {id: clipId, clipPathUnits:'userSpaceOnUse'}); clip.append(element('rect', item.clip)); defs.append(clip); group.append(defs); group.setAttribute('clip-path', `url(#${clipId})`);}
287
+ group.append(local);
288
+ } else {handle.live.textContent = message(options, 'backgroundUnavailable'); options.onIssues?.([{reason: 'asset_unavailable', path: 'background', assetRef: item.assetRef}]);}
289
+ } else {
290
+ const paint=(kind)=>{
291
+ const gradient=item[kind+'Gradient'];if(!gradient)return color(item[kind]);
292
+ const id=`venue-${handle.serial}-${item.id.replace(/[^a-zA-Z0-9_-]/g,'-')}-${kind}`,defs=element('defs');
293
+ const g=gradient.type==='linear'?element('linearGradient',{id,gradientUnits:'userSpaceOnUse',x1:gradient.from[0],y1:gradient.from[1],x2:gradient.to[0],y2:gradient.to[1],spreadMethod:gradient.spread}):element('radialGradient',{id,gradientUnits:'userSpaceOnUse',cx:gradient.center[0],cy:gradient.center[1],r:gradient.radius,fx:gradient.focal?.[0]??gradient.center[0],fy:gradient.focal?.[1]??gradient.center[1],gradientTransform:`matrix(${gradient.matrix.join(' ')})`,spreadMethod:gradient.spread});
294
+ gradient.colors.forEach((c,i)=>g.append(element('stop',{offset:gradient.stops[i],'stop-color':color(c)})));defs.append(g);group.append(defs);return `url(#${id})`;
295
+ };
296
+ if(item.localClip){const id=`venue-${handle.serial}-${item.id.replace(/[^a-zA-Z0-9_-]/g,'-')}-text-clip`,defs=element('defs'),clip=element('clipPath',{id,clipPathUnits:'userSpaceOnUse'});clip.append(element('rect',item.localClip));defs.append(clip);group.append(defs);group.setAttribute('clip-path',`url(#${id})`);}
297
+ group.append(element('path', {d: pathData(item.path || []), fill: paint('fill'), stroke: paint('stroke'), 'fill-rule':item.fillRule||'nonzero', 'stroke-miterlimit':item.miterLimit||4, 'fill-opacity': item.opacity ?? 1, 'stroke-opacity': item.opacity ?? 1, 'stroke-width': item.strokeWidth || 0, 'vector-effect': item.kind === 'floor' ? 'non-scaling-stroke' : null, 'stroke-dasharray': item.dash?.join?.(' '), 'stroke-linecap': item.kind==='state'?'round':item.strokeCap||'butt', 'stroke-linejoin': item.kind==='state'?'round':item.strokeJoin||'miter'}));
298
+ }
299
+ const tooltip = options.seatPresentation?.[item.nodeId || item.id]?.tooltip ?? options.capacityPresentation?.[item.nodeId || item.id]?.tooltip ?? item.label;
300
+ if (tooltip) {const title = element('title'); title.textContent = tooltip; group.append(title);}
301
+ if (old) old.node.replaceWith(group);
302
+ placeNode(group);
303
+ handle.elements.set(item.id, {item, node: group});
304
+ }
305
+ for (const [id, entry] of handle.elements) if (!wanted.has(id)) {entry.node.remove(); handle.elements.delete(id);}
306
+ if (dirtyIds?.length !== 0) renderList(handle);
307
+ }
308
+ function installTooltip(handle, options) {
309
+ const tooltip = document.createElement('aside');
310
+ tooltip.className = 'venue-layout-tooltip';
311
+ tooltip.id = `venue-layout-tooltip-${handle.serial}`;
312
+ tooltip.setAttribute('role', 'tooltip');
313
+ tooltip.hidden = true;
314
+ Object.assign(tooltip.style, {
315
+ position: 'fixed', zIndex: '2147483647', pointerEvents: 'none',
316
+ maxWidth: 'min(280px, calc(100vw - 16px))', boxSizing: 'border-box',
317
+ padding: '8px 10px', borderRadius: '6px', background: '#202124',
318
+ color: '#fff', font: '13px/1.4 system-ui, sans-serif', whiteSpace: 'pre-line',
319
+ overflowWrap: 'anywhere', boxShadow: '0 2px 8px #0004', margin: '0',
320
+ });
321
+ Object.assign(tooltip.style, options.tooltipStyle || {});
322
+ // Stay inside the viewer's dialog/top layer; never inject HTML from hosts.
323
+ handle.root.append(tooltip);
324
+ handle.tooltip = tooltip;
325
+ handle.hideTooltip = () => {tooltip.hidden = true; tooltip.textContent = '';};
326
+ handle.hover = event => {
327
+ if (handle.disposed || event.buttons || event.pointerType === 'touch') {
328
+ handle.hideTooltip(); return;
329
+ }
330
+ const inverse = handle.svg.getScreenCTM()?.inverse();
331
+ if (!inverse) {handle.hideTooltip(); return;}
332
+ const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(inverse);
333
+ const id = globalThis.VenueLayoutKernel.hitTest(handle.json, point.x, point.y);
334
+ const node = handle.options.document.nodes.find(node => node.id === id);
335
+ const content = handle.options.seatPresentation?.[id]?.tooltip ?? handle.options.capacityPresentation?.[id]?.tooltip ?? node?.label;
336
+ if (!content) {handle.hideTooltip(); return;}
337
+ tooltip.textContent = String(content);
338
+ tooltip.hidden = false;
339
+ const bounds = tooltip.getBoundingClientRect();
340
+ const width = globalThis.innerWidth || document.documentElement.clientWidth;
341
+ const height = globalThis.innerHeight || document.documentElement.clientHeight;
342
+ const offset = handle.options.tooltipOffset ?? 15;
343
+ tooltip.style.left = `${Math.max(8, event.clientX + offset + bounds.width > width ? event.clientX - bounds.width - offset : event.clientX + offset)}px`;
344
+ tooltip.style.top = `${Math.max(8, event.clientY + offset + bounds.height > height
345
+ ? event.clientY - bounds.height - offset : event.clientY + offset)}px`;
346
+ };
347
+ handle.tooltipKey = event => {if (event.key === 'Escape') handle.hideTooltip();};
348
+ handle.svg.addEventListener('pointermove', handle.hover);
349
+ for (const name of ['pointerleave', 'pointerdown', 'pointercancel', 'wheel']) {
350
+ handle.svg.addEventListener(name, handle.hideTooltip);
351
+ }
352
+ handle.tooltipWindow = document.defaultView || globalThis;
353
+ handle.tooltipWindow.addEventListener?.('keydown', handle.tooltipKey);
354
+ handle.tooltipWindow.addEventListener?.('scroll', handle.hideTooltip, true);
355
+ handle.tooltipWindow.addEventListener?.('resize', handle.hideTooltip);
356
+ }
357
+ // Presentation-only canvas framing in CSS pixels, independent of document
358
+ // geometry and zoom. Clipping includes imported artwork that covers the fill.
359
+ function updateCanvasFrame(handle, canvas) {
360
+ const requested = Number(handle.options.canvasCornerRadius) || 0;
361
+ const svg = handle.svg, matrix = svg.getScreenCTM();
362
+ if (requested <= 0 || !matrix) {svg.style.clipPath = ''; return;}
363
+ const rect = svg.getBoundingClientRect();
364
+ const a = new DOMPoint(canvas.x || 0, canvas.y || 0).matrixTransform(matrix);
365
+ const b = new DOMPoint((canvas.x || 0) + canvas.width, (canvas.y || 0) + canvas.height).matrixTransform(matrix);
366
+ const radius = Math.min(requested, Math.abs(b.x-a.x)/2, Math.abs(b.y-a.y)/2);
367
+ svg.style.clipPath = `inset(${a.y-rect.top}px ${rect.right-b.x}px ${rect.bottom-b.y}px ${a.x-rect.left}px round ${radius}px)`;
368
+ }
369
+ function cameraMetrics(handle, camera) {
370
+ const canvas = handle.options.document.canvas, rect = handle.svg.getBoundingClientRect();
371
+ const width = rect.width || canvas.width, height = rect.height || canvas.height;
372
+ const fit = Math.min(width / canvas.width, height / canvas.height);
373
+ const aspect = camera.width / camera.height, fitWidth = Math.min(width, height * aspect) / fit;
374
+ return {canvas, width, height, minWidth: fitWidth / (handle.options.maxZoom || 20), maxWidth: fitWidth / (handle.options.minZoom || .9)};
375
+ }
376
+ function zoomFactor(handle, camera, factor) {
377
+ const limits = cameraMetrics(handle, camera);
378
+ return Math.min(limits.maxWidth, Math.max(limits.minWidth, camera.width * factor)) / camera.width;
379
+ }
380
+ function boundCamera(handle, camera) {
381
+ const factor = zoomFactor(handle, camera, 1);
382
+ camera = {...camera, x: camera.x + camera.width * (1 - factor) / 2,
383
+ y: camera.y + camera.height * (1 - factor) / 2, width: camera.width * factor, height: camera.height * factor};
384
+ const {canvas, width, height} = cameraMetrics(handle, camera);
385
+ const scale = Math.min(width / camera.width, height / camera.height);
386
+ const viewportWidth = width / scale, viewportHeight = height / scale;
387
+ const shiftX = (viewportWidth - camera.width) / 2, shiftY = (viewportHeight - camera.height) / 2;
388
+ const clampAxis = (position, start, extent, viewport) => {
389
+ // Match Flutter: empty space cannot extend beyond the viewport midpoint.
390
+ const visible = viewport / 2;
391
+ return Math.min(start + extent - visible, Math.max(start + visible - viewport, position));
392
+ };
393
+ return {...camera, x: clampAxis(camera.x - shiftX, canvas.x || 0, canvas.width, viewportWidth) + shiftX,
394
+ y: clampAxis(camera.y - shiftY, canvas.y || 0, canvas.height, viewportHeight) + shiftY};
395
+ }
396
+ function installCamera(handle) {
397
+ const svg = handle.svg, surface = handle.container;
398
+ // Canvas framing clips SVG hit testing as well as paint. The surrounding
399
+ // surface remains the gesture plane, while HTML controls keep their own UI.
400
+ const onSurface = event => event.target === surface || event.target === handle.root || svg.contains(event.target);
401
+ handle.wheel = event => {
402
+ if (!handle.camera || !onSurface(event)) return;
403
+ handle.hideTooltip?.();
404
+ event.preventDefault();
405
+ const matrix = svg.getScreenCTM(), p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse()), c = handle.camera;
406
+ if (event.shiftKey) {
407
+ const delta = new DOMPoint(event.deltaX || event.deltaY, event.deltaX ? event.deltaY : 0).matrixTransform(matrix.inverse());
408
+ const origin = new DOMPoint(0, 0).matrixTransform(matrix.inverse());
409
+ handle.camera = boundCamera(handle, {...c, x: c.x + delta.x - origin.x, y: c.y + delta.y - origin.y});
410
+ render(handle, []); return;
411
+ }
412
+ // Flutter uses exp(-deltaY / 200); SVG viewBox dimensions scale inversely.
413
+ const factor = zoomFactor(handle, c, Math.exp(event.deltaY / 200));
414
+ handle.camera = {x: p.x + (c.x - p.x) * factor, y: p.y + (c.y - p.y) * factor, width: c.width * factor, height: c.height * factor};
415
+ render(handle, []);
416
+ };
417
+ let start, pinch; const pointers = new Map();
418
+ handle.pointerdown = event => {if (!onSurface(event) || (event.button !== 0 && event.button !== 1)) return; handle.hideTooltip?.(); pointers.set(event.pointerId, {x:event.clientX,y:event.clientY}); if (pointers.size === 2) {const [a,b] = [...pointers.values()]; pinch = {distance:Math.max(1,Math.hypot(a.x-b.x,a.y-b.y)),center:{x:(a.x+b.x)/2,y:(a.y+b.y)/2},camera:{...handle.camera},inverse:svg.getScreenCTM()?.inverse()};} start = {x: event.clientX, y: event.clientY, camera: {...handle.camera}, inverse: svg.getScreenCTM()?.inverse()}; svg.setPointerCapture(event.pointerId);};
419
+ handle.pointermove = event => {if (!pointers.has(event.pointerId)) return; pointers.set(event.pointerId,{x:event.clientX,y:event.clientY}); if (pointers.size === 2 && pinch?.inverse) {const [a,b] = [...pointers.values()], factor=zoomFactor(handle,pinch.camera,pinch.distance/Math.max(1,Math.hypot(a.x-b.x,a.y-b.y))), origin=new DOMPoint(pinch.center.x,pinch.center.y).matrixTransform(pinch.inverse), current=new DOMPoint((a.x+b.x)/2,(a.y+b.y)/2).matrixTransform(pinch.inverse), c=pinch.camera; handle.camera={x:origin.x+(c.x-origin.x)*factor+(origin.x-current.x),y:origin.y+(c.y-origin.y)*factor+(origin.y-current.y),width:c.width*factor,height:c.height*factor};handle.suppressClick=true;render(handle,[]);return;} if (!start?.inverse) return; const dx = event.clientX - start.x, dy = event.clientY - start.y; if (Math.hypot(dx, dy) < 4) return; const a = new DOMPoint(start.x, start.y).matrixTransform(start.inverse), b = new DOMPoint(event.clientX, event.clientY).matrixTransform(start.inverse); handle.camera = {...start.camera, x: start.camera.x + a.x - b.x, y: start.camera.y + a.y - b.y}; handle.suppressClick = true; render(handle, []);};
420
+ handle.pointerup = event => {if (!pointers.delete(event.pointerId)) return;start = null;pinch = null;};
421
+ surface.addEventListener('wheel', handle.wheel, {passive: false}); surface.addEventListener('pointerdown', handle.pointerdown); surface.addEventListener('pointermove', handle.pointermove); surface.addEventListener('pointerup', handle.pointerup); surface.addEventListener('pointercancel', handle.pointerup);
422
+ }
423
+ function renderList(handle) {
424
+ const allNodes = handle.listDocument === handle.options.document ? handle.listNodes : handle.options.document.nodes.filter(n => n.kind === 'seat' || n.kind === 'capacityArea');
425
+ handle.listDocument = handle.options.document; handle.listNodes = allNodes;
426
+ const focusIndex = Math.max(0, allNodes.findIndex(n => n.id === (document.activeElement?.dataset?.nodeId || handle.focused)));
427
+ const offset = Math.max(0, focusIndex - 25);
428
+ const nodes = allNodes.slice(offset, offset + 100);
429
+ const previousFocus = document.activeElement?.dataset?.nodeId;
430
+ handle.list.replaceChildren();
431
+ // Roving focus keeps a single tab stop, including large documents.
432
+ const focused = previousFocus || handle.focused || nodes[0]?.id;
433
+ nodes.forEach((node, index) => {
434
+ const button = document.createElement('button');
435
+ button.dataset.nodeId = node.id;
436
+ button.setAttribute('aria-posinset', String(offset + index + 1)); button.setAttribute('aria-setsize', String(allNodes.length));
437
+ const state = handle.options.seatPresentation?.[node.id] || handle.options.capacityPresentation?.[node.id] || {};
438
+ const unit = (handle.options.document.selectionUnits || []).find(u => u.nodeIds.includes(node.id));
439
+ button.textContent = `${state.accessibilityLabel || state.label || node.label || message(handle.options, 'place')}${unit ? ` - ${message(handle.options, 'placesTogether', {count: unit.nodeIds.length})}` : ''} - ${message(handle.options, handle.pending.has(node.id) ? 'pending' : state.status || 'available')}`;
440
+ button.tabIndex = node.id === focused ? 0 : -1;
441
+ button.setAttribute('aria-pressed', String(handle.selected.has(node.id)));
442
+ if (node.kind === 'capacityArea') {
443
+ const unavailable = Number(state.available ?? state.capacity ?? 0) <= 0 || state.selectable === false || ['occupied','unavailable','disabled','blocked','sold','reserved'].includes(state.status);
444
+ button.setAttribute('aria-disabled', String(unavailable));
445
+ if (state.tooltip) button.title = state.tooltip;
446
+ }
447
+ button.onclick = () => requestSelection(handle, node.id);
448
+ button.onfocus = () => {handle.focused = node.id;};
449
+ button.onkeydown = event => {
450
+ const delta = ['ArrowRight', 'ArrowDown'].includes(event.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 : 0;
451
+ if (delta) {event.preventDefault(); const direction = {ArrowRight: 'right', ArrowLeft: 'left', ArrowUp: 'up', ArrowDown: 'down'}[event.key]; const nearest = globalThis.VenueLayoutKernel.nearestNode?.(handle.json, node.id, direction); const target = allNodes.find(n => n.id === nearest) || node; handle.focused = target.id; button.blur(); renderList(handle); handle.list.querySelector(`[data-node-id="${CSS.escape(target.id)}"]`)?.focus();}
452
+ };
453
+ handle.list.append(button);
454
+ });
455
+ // The accessible list is visually collapsible and usable without the drawing.
456
+ handle.list.style.maxHeight = '120px'; handle.list.style.overflow = 'auto';
457
+ if (previousFocus) handle.list.querySelector(`[data-node-id="${CSS.escape(previousFocus)}"]`)?.focus({preventScroll: true});
458
+ }
459
+ export function dispose(handle) {
460
+ if (handle.disposed) return;
461
+ handle.disposed = true; handle.generation++;
462
+ handle.container.removeEventListener('click', handle.click);
463
+ if (handle.container.style.touchAction === 'none') handle.container.style.touchAction = handle.previousTouchAction;
464
+ handle.svg.removeEventListener('pointermove', handle.hover);
465
+ for (const name of ['pointerleave', 'pointerdown', 'pointercancel', 'wheel']) handle.svg.removeEventListener(name, handle.hideTooltip);
466
+ handle.tooltipWindow?.removeEventListener?.('keydown', handle.tooltipKey);
467
+ handle.tooltipWindow?.removeEventListener?.('scroll', handle.hideTooltip, true);
468
+ handle.tooltipWindow?.removeEventListener?.('resize', handle.hideTooltip);
469
+ handle.hideTooltip?.();
470
+ for (const name of ['wheel','pointerdown','pointermove','pointerup','pointercancel']) handle.container.removeEventListener(name, handle[name === 'pointercancel' ? 'pointerup' : name]);
471
+ handle.resizeObserver?.disconnect();
472
+ handle.root.remove(); handle.pending.clear();
473
+ }
474
+ export const protocol = 1;
@@ -0,0 +1,2 @@
1
+ // Side-effect import registers globalThis.VenueLayoutKernel before mount.
2
+ export {};