@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/LICENSE +21 -0
- package/README.md +75 -2
- package/assets/layout_fonts/Roboto-400-italic.ttf +0 -0
- package/assets/layout_fonts/Roboto-400-normal.ttf +0 -0
- package/assets/layout_fonts/Roboto-700-italic.ttf +0 -0
- package/assets/layout_fonts/Roboto-700-normal.ttf +0 -0
- package/assets/layout_fonts/Roboto-OFL.txt +93 -0
- package/assets/layout_fonts/Roboto-Slab-LICENSE.txt +202 -0
- package/assets/layout_fonts/Roboto-Slab.ttf +0 -0
- package/assets/layout_fonts/Roboto.ttf +0 -0
- package/assets/layout_fonts/fixtures.json +1 -0
- package/assets/layout_fonts/manifest.json +51 -0
- package/demo/demo.js +155 -0
- package/demo/index.html +12 -0
- package/demo/seed.js +1 -0
- package/dist/demo.js +937 -0
- package/dist/layout-v2.schema.json +4997 -0
- package/dist/manifest.json +64 -0
- package/dist/venue_layout_kernel.js +8266 -0
- package/package.json +46 -3
- package/src/assets.js +52 -0
- package/src/index.d.ts +18 -0
- package/src/index.js +474 -0
- package/src/kernel.d.ts +2 -0
package/dist/demo.js
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// src/index.js
|
|
3
|
+
var defaultMessages = Object.freeze({
|
|
4
|
+
viewerLabel: "Venue layout",
|
|
5
|
+
places: "Places",
|
|
6
|
+
place: "Place",
|
|
7
|
+
standing: "Standing",
|
|
8
|
+
availableCount: "{count} available",
|
|
9
|
+
addOne: "Add one",
|
|
10
|
+
removeOne: "Remove one",
|
|
11
|
+
confirmQuantity: "Confirm quantity",
|
|
12
|
+
selectedQuantity: "Selected quantity {count}",
|
|
13
|
+
quantityRejected: "Quantity rejected",
|
|
14
|
+
placeUnavailable: "Place unavailable",
|
|
15
|
+
selectedPlaces: "Selected {count} places",
|
|
16
|
+
releasedPlaces: "Released {count} places",
|
|
17
|
+
selectionRejected: "Selection rejected",
|
|
18
|
+
backgroundUnavailable: "Background resource unavailable",
|
|
19
|
+
placesTogether: "{count} places together",
|
|
20
|
+
pending: "pending",
|
|
21
|
+
available: "available",
|
|
22
|
+
occupied: "occupied",
|
|
23
|
+
reserved: "reserved",
|
|
24
|
+
blocked: "blocked",
|
|
25
|
+
unavailable: "unavailable",
|
|
26
|
+
disabled: "disabled",
|
|
27
|
+
sold: "sold"
|
|
28
|
+
});
|
|
29
|
+
function message(options, key, values = {}) {
|
|
30
|
+
const template = options.messages?.[key] ?? defaultMessages[key] ?? key;
|
|
31
|
+
return String(template).replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`));
|
|
32
|
+
}
|
|
33
|
+
var SVG = "http://www.w3.org/2000/svg";
|
|
34
|
+
var viewerSerial = 0;
|
|
35
|
+
var color = (value) => /^#[0-9a-f]{8}$/i.test(value || "") ? value : "#00000000";
|
|
36
|
+
var element = (tag, attributes = {}) => {
|
|
37
|
+
const node = document.createElementNS(SVG, tag);
|
|
38
|
+
for (const [key, value] of Object.entries(attributes)) if (value != null) node.setAttribute(key, String(value));
|
|
39
|
+
return node;
|
|
40
|
+
};
|
|
41
|
+
var pathData = (path) => path.map((command) => command.join(" ")).join(" ");
|
|
42
|
+
function visibleWorldBounds(svg, matrix) {
|
|
43
|
+
const rect = svg.getBoundingClientRect();
|
|
44
|
+
if (!matrix || rect.width <= 0 || rect.height <= 0) return null;
|
|
45
|
+
let inverse;
|
|
46
|
+
try {
|
|
47
|
+
inverse = matrix.inverse();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const points = [[rect.left, rect.top], [rect.right, rect.top], [rect.left, rect.bottom], [rect.right, rect.bottom]].map(([x2, y2]) => new DOMPoint(x2, y2).matrixTransform(inverse));
|
|
52
|
+
if (points.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) return null;
|
|
53
|
+
const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
|
|
54
|
+
const x = Math.min(...xs), y = Math.min(...ys);
|
|
55
|
+
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
56
|
+
}
|
|
57
|
+
function mount(container, options = {}) {
|
|
58
|
+
if (!container || !globalThis.VenueLayoutKernel) throw new Error("Container and VenueLayoutKernel are required");
|
|
59
|
+
const root = document.createElement("div");
|
|
60
|
+
root.className = "venue-layout-viewer";
|
|
61
|
+
root.style.position = "relative";
|
|
62
|
+
const previousTouchAction = container.style.touchAction;
|
|
63
|
+
container.style.touchAction = "none";
|
|
64
|
+
const svg = element("svg", { role: "img", "aria-label": options.accessibilityLabel || message(options, "viewerLabel"), width: "100%", height: "100%" });
|
|
65
|
+
svg.style.touchAction = "none";
|
|
66
|
+
svg.style.display = "block";
|
|
67
|
+
svg.style.minHeight = "240px";
|
|
68
|
+
const list = document.createElement("div");
|
|
69
|
+
list.setAttribute("role", "list");
|
|
70
|
+
list.setAttribute("aria-label", message(options, "places"));
|
|
71
|
+
const live = document.createElement("div");
|
|
72
|
+
live.setAttribute("role", "status");
|
|
73
|
+
live.setAttribute("aria-live", "polite");
|
|
74
|
+
for (const assistive of [list, live]) Object.assign(assistive.style, {
|
|
75
|
+
position: "absolute",
|
|
76
|
+
top: "0",
|
|
77
|
+
left: "0",
|
|
78
|
+
width: "1px",
|
|
79
|
+
height: "1px",
|
|
80
|
+
overflow: "hidden",
|
|
81
|
+
clipPath: "inset(50%)",
|
|
82
|
+
whiteSpace: "nowrap",
|
|
83
|
+
padding: "0",
|
|
84
|
+
margin: "-1px",
|
|
85
|
+
border: "0",
|
|
86
|
+
boxSizing: "border-box"
|
|
87
|
+
});
|
|
88
|
+
const detail = document.createElement("div");
|
|
89
|
+
root.append(svg, list, detail, live);
|
|
90
|
+
container.append(root);
|
|
91
|
+
const handle = { serial: ++viewerSerial, container, root, svg, list, detail, live, previousTouchAction, options: {}, pending: /* @__PURE__ */ new Set(), selected: /* @__PURE__ */ new Set(), quantities: /* @__PURE__ */ new Map(), disposed: false, generation: 0, update(next) {
|
|
92
|
+
return update(handle, next);
|
|
93
|
+
}, dispose() {
|
|
94
|
+
dispose(handle);
|
|
95
|
+
}, zoomBy(factor) {
|
|
96
|
+
if (handle.disposed) throw new Error("Viewer is disposed");
|
|
97
|
+
if (!Number.isFinite(factor) || factor <= 0) throw new Error("Zoom factor must be positive");
|
|
98
|
+
handle.hideTooltip?.();
|
|
99
|
+
const c = handle.camera, ratio = zoomFactor(handle, c, 1 / factor);
|
|
100
|
+
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 };
|
|
101
|
+
render(handle, []);
|
|
102
|
+
}, fitToScreen() {
|
|
103
|
+
if (handle.disposed) throw new Error("Viewer is disposed");
|
|
104
|
+
handle.hideTooltip?.();
|
|
105
|
+
handle.camera = null;
|
|
106
|
+
render(handle, []);
|
|
107
|
+
} };
|
|
108
|
+
handle.click = async (event) => {
|
|
109
|
+
if (!svg.contains(event.target)) {
|
|
110
|
+
if (event.target === root || event.target === container) handle.suppressClick = false;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const inverse = svg.getScreenCTM()?.inverse();
|
|
114
|
+
if (!inverse) return;
|
|
115
|
+
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(inverse);
|
|
116
|
+
const id = globalThis.VenueLayoutKernel.hitTest(handle.json, point.x, point.y);
|
|
117
|
+
if (handle.suppressClick) {
|
|
118
|
+
handle.suppressClick = false;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (id) await requestSelection(handle, id);
|
|
122
|
+
};
|
|
123
|
+
container.addEventListener("click", handle.click);
|
|
124
|
+
installCamera(handle);
|
|
125
|
+
installTooltip(handle, options);
|
|
126
|
+
try {
|
|
127
|
+
update(handle, options);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
dispose(handle);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
133
|
+
handle.resizeObserver = new ResizeObserver(() => {
|
|
134
|
+
if (!handle.disposed) render(handle, []);
|
|
135
|
+
});
|
|
136
|
+
handle.resizeObserver.observe(svg);
|
|
137
|
+
}
|
|
138
|
+
return handle;
|
|
139
|
+
}
|
|
140
|
+
async function requestSelection(handle, id) {
|
|
141
|
+
const { options } = handle;
|
|
142
|
+
const node = options.document.nodes.find((n) => n.id === id);
|
|
143
|
+
if (!node) return;
|
|
144
|
+
if (node.kind === "capacityArea") {
|
|
145
|
+
if (handle.pending.has(id)) return;
|
|
146
|
+
const state = options.capacityPresentation?.[id] || {};
|
|
147
|
+
const selected2 = handle.quantities.get(id) ?? 0;
|
|
148
|
+
const maximum = Math.max(0, Number(state.available ?? state.capacity ?? 0));
|
|
149
|
+
const unavailable2 = maximum === 0 || ["occupied", "unavailable", "disabled", "blocked", "sold", "reserved"].includes(state.status) || state.selectable === false;
|
|
150
|
+
if (unavailable2 && selected2 === 0) {
|
|
151
|
+
handle.detail.replaceChildren();
|
|
152
|
+
handle.live.textContent = state.tooltip || message(options, "placeUnavailable");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const generation2 = handle.generation, callback = options.onQuantityRequested;
|
|
156
|
+
handle.detail.replaceChildren();
|
|
157
|
+
const label = document.createElement("label");
|
|
158
|
+
label.textContent = `${node.label || message(options, "standing")} - ${message(options, "availableCount", { count: state.available ?? state.capacity ?? 0 })}`;
|
|
159
|
+
const quantity = document.createElement("input");
|
|
160
|
+
quantity.type = "number";
|
|
161
|
+
quantity.min = "0";
|
|
162
|
+
quantity.max = String(state.available ?? state.capacity ?? 0);
|
|
163
|
+
quantity.value = String(Math.min(maximum, handle.quantities.get(id) ?? 1));
|
|
164
|
+
const stepper = document.createElement("span");
|
|
165
|
+
Object.assign(stepper.style, { display: "inline-flex", alignItems: "center", gap: "8px" });
|
|
166
|
+
const minus = document.createElement("button"), plus = document.createElement("button");
|
|
167
|
+
minus.type = plus.type = "button";
|
|
168
|
+
minus.textContent = "\u2212";
|
|
169
|
+
plus.textContent = "+";
|
|
170
|
+
minus.setAttribute("aria-label", message(options, "removeOne"));
|
|
171
|
+
plus.setAttribute("aria-label", message(options, "addOne"));
|
|
172
|
+
for (const button of [minus, plus]) Object.assign(button.style, { minWidth: "48px", minHeight: "48px" });
|
|
173
|
+
const updateSteps = () => {
|
|
174
|
+
minus.disabled = Number(quantity.value) <= 0;
|
|
175
|
+
plus.disabled = Number(quantity.value) >= maximum || unavailable2;
|
|
176
|
+
};
|
|
177
|
+
minus.onclick = () => {
|
|
178
|
+
quantity.stepDown();
|
|
179
|
+
updateSteps();
|
|
180
|
+
};
|
|
181
|
+
plus.onclick = () => {
|
|
182
|
+
quantity.stepUp();
|
|
183
|
+
updateSteps();
|
|
184
|
+
};
|
|
185
|
+
quantity.oninput = updateSteps;
|
|
186
|
+
updateSteps();
|
|
187
|
+
const confirm = document.createElement("button");
|
|
188
|
+
confirm.textContent = message(options, "confirmQuantity");
|
|
189
|
+
confirm.onclick = async () => {
|
|
190
|
+
if (handle.disposed || generation2 !== handle.generation || handle.pending.has(id)) return;
|
|
191
|
+
const current = handle.options.capacityPresentation?.[id] || {};
|
|
192
|
+
const value = Number(quantity.value), available = Number(current.available ?? current.capacity ?? 0);
|
|
193
|
+
const unavailable3 = ["occupied", "unavailable", "disabled", "blocked", "sold", "reserved"].includes(current.status) || current.selectable === false;
|
|
194
|
+
if (!Number.isInteger(value) || value < 0 || value > available || value > 0 && unavailable3 || !callback) return;
|
|
195
|
+
confirm.disabled = true;
|
|
196
|
+
handle.pending.add(id);
|
|
197
|
+
render(handle, [id]);
|
|
198
|
+
try {
|
|
199
|
+
const result = await callback(id, value);
|
|
200
|
+
if (handle.disposed || generation2 !== handle.generation) return;
|
|
201
|
+
if (result?.accepted) handle.quantities.set(id, value);
|
|
202
|
+
handle.live.textContent = result?.accepted ? message(options, "selectedQuantity", { count: value }) : result?.message || message(options, "quantityRejected");
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (!handle.disposed && generation2 === handle.generation) handle.live.textContent = String(error.message || error);
|
|
205
|
+
} finally {
|
|
206
|
+
if (!handle.disposed && generation2 === handle.generation) {
|
|
207
|
+
handle.pending.delete(id);
|
|
208
|
+
confirm.disabled = false;
|
|
209
|
+
render(handle, [id]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
stepper.append(minus, quantity, plus);
|
|
214
|
+
label.append(stepper);
|
|
215
|
+
handle.detail.append(label, confirm);
|
|
216
|
+
quantity.focus();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (node.kind !== "seat" || !options.onSelectionRequested) return;
|
|
220
|
+
const unavailable = /* @__PURE__ */ new Set(["occupied", "unavailable", "disabled", "blocked", "sold", "reserved"]);
|
|
221
|
+
const unit = (options.document.selectionUnits || []).find((u) => u.nodeIds.includes(id));
|
|
222
|
+
const ids = unit?.nodeIds || [id];
|
|
223
|
+
if (ids.some((id2) => unavailable.has(options.seatPresentation?.[id2]?.status) || options.seatPresentation?.[id2]?.selectable === false)) {
|
|
224
|
+
handle.live.textContent = message(options, "placeUnavailable");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (ids.some((id2) => handle.pending.has(id2))) return;
|
|
228
|
+
const selecting = !ids.every((id2) => handle.selected.has(id2));
|
|
229
|
+
const previous = new Set(handle.selected);
|
|
230
|
+
ids.forEach((id2) => {
|
|
231
|
+
handle.pending.add(id2);
|
|
232
|
+
selecting ? handle.selected.add(id2) : handle.selected.delete(id2);
|
|
233
|
+
});
|
|
234
|
+
const generation = handle.generation;
|
|
235
|
+
render(handle, ids);
|
|
236
|
+
try {
|
|
237
|
+
const result = await options.onSelectionRequested(new Set(ids), selecting);
|
|
238
|
+
if (handle.disposed || generation !== handle.generation) return;
|
|
239
|
+
if (!result?.accepted) ids.forEach((id2) => previous.has(id2) ? handle.selected.add(id2) : handle.selected.delete(id2));
|
|
240
|
+
handle.live.textContent = result?.accepted ? message(options, selecting ? "selectedPlaces" : "releasedPlaces", { count: ids.length }) : result?.message || message(options, "selectionRejected");
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!handle.disposed && generation === handle.generation) {
|
|
243
|
+
ids.forEach((id2) => previous.has(id2) ? handle.selected.add(id2) : handle.selected.delete(id2));
|
|
244
|
+
handle.live.textContent = String(error.message || error);
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
if (!handle.disposed && generation === handle.generation) {
|
|
248
|
+
ids.forEach((id2) => handle.pending.delete(id2));
|
|
249
|
+
render(handle, ids);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function update(handle, options = {}) {
|
|
254
|
+
handle.hideTooltip?.();
|
|
255
|
+
if (handle.disposed) throw new Error("Viewer is disposed");
|
|
256
|
+
const resourcesChanged = options.resources && options.resources !== handle.options.resources;
|
|
257
|
+
if (options.document && options.document !== handle.options.document) {
|
|
258
|
+
const issues = JSON.parse(globalThis.VenueLayoutKernel.validate(JSON.stringify(options.document)));
|
|
259
|
+
if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.reason}`).join("; "));
|
|
260
|
+
}
|
|
261
|
+
if (resourcesChanged && globalThis.VenueLayoutKernel.validateResources) {
|
|
262
|
+
const issues = JSON.parse(globalThis.VenueLayoutKernel.validateResources(JSON.stringify(options.resources)));
|
|
263
|
+
if (issues.length) throw new Error(issues.map((i) => `${i.path}: ${i.reason}`).join("; "));
|
|
264
|
+
}
|
|
265
|
+
const replacingDocument = options.document && options.document !== handle.options.document;
|
|
266
|
+
handle.options = { ...handle.options, ...options };
|
|
267
|
+
if (!handle.options.document) throw new Error("A document is required");
|
|
268
|
+
if (replacingDocument) {
|
|
269
|
+
handle.generation++;
|
|
270
|
+
handle.pending.clear();
|
|
271
|
+
handle.selected.clear();
|
|
272
|
+
handle.quantities.clear();
|
|
273
|
+
handle.detail.replaceChildren();
|
|
274
|
+
handle.scene = null;
|
|
275
|
+
handle.sceneItems = null;
|
|
276
|
+
handle.elements = null;
|
|
277
|
+
handle.listDocument = null;
|
|
278
|
+
}
|
|
279
|
+
if (options.seatPresentation) for (const [id, state] of Object.entries(options.seatPresentation)) {
|
|
280
|
+
if (!handle.pending.has(id) && typeof state.selected === "boolean") state.selected ? handle.selected.add(id) : handle.selected.delete(id);
|
|
281
|
+
}
|
|
282
|
+
if (options.capacityPresentation) for (const [id, state] of Object.entries(options.capacityPresentation)) {
|
|
283
|
+
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);
|
|
284
|
+
}
|
|
285
|
+
if (replacingDocument || !handle.json) handle.json = JSON.stringify(handle.options.document);
|
|
286
|
+
if (resourcesChanged || !handle.resourcesJson) {
|
|
287
|
+
handle.resourcesJson = JSON.stringify(handle.options.resources || {});
|
|
288
|
+
handle.sceneItems = null;
|
|
289
|
+
handle.elements = null;
|
|
290
|
+
}
|
|
291
|
+
render(handle);
|
|
292
|
+
return handle;
|
|
293
|
+
}
|
|
294
|
+
function render(handle, dirtyIds) {
|
|
295
|
+
const { options, svg } = handle;
|
|
296
|
+
const presentation2 = { ...options.seatPresentation, ...options.capacityPresentation };
|
|
297
|
+
for (const id of /* @__PURE__ */ new Set([...Object.keys(options.seatPresentation || {}), ...handle.selected])) presentation2[id] = { ...presentation2[id], selected: handle.selected.has(id) };
|
|
298
|
+
for (const id of handle.pending) presentation2[id] = { ...presentation2[id], pending: true };
|
|
299
|
+
let scene;
|
|
300
|
+
if (handle.sceneItems && globalThis.VenueLayoutKernel.compileSceneNodes) {
|
|
301
|
+
const changed = dirtyIds || [.../* @__PURE__ */ new Set([...Object.keys(presentation2), ...Object.keys(handle.previousPresentation || {})])].filter((id) => JSON.stringify(presentation2[id]) !== JSON.stringify(handle.previousPresentation?.[id]));
|
|
302
|
+
if (changed.length === 0) {
|
|
303
|
+
scene = handle.scene;
|
|
304
|
+
} else {
|
|
305
|
+
scene = JSON.parse(globalThis.VenueLayoutKernel.compileSceneNodes(handle.json, JSON.stringify(changed), handle.resourcesJson, JSON.stringify(presentation2)));
|
|
306
|
+
const changedSet = new Set(changed);
|
|
307
|
+
for (const [id, item] of handle.sceneItems) if (changedSet.has(item.nodeId || item.id)) handle.sceneItems.delete(id);
|
|
308
|
+
for (const item of scene.items) handle.sceneItems.set(item.id, item);
|
|
309
|
+
scene = { ...scene, items: [...handle.sceneItems.values()].sort((a, b) => a.paintOrder[0] - b.paintOrder[0] || a.paintOrder[1] - b.paintOrder[1]) };
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
scene = JSON.parse(globalThis.VenueLayoutKernel.compileScene(handle.json, handle.resourcesJson, JSON.stringify(presentation2)));
|
|
313
|
+
handle.sceneItems = new Map(scene.items.map((item) => [item.id, item]));
|
|
314
|
+
}
|
|
315
|
+
handle.previousPresentation = presentation2;
|
|
316
|
+
handle.scene = scene;
|
|
317
|
+
if (scene.issues.length) {
|
|
318
|
+
handle.live.textContent = scene.issues.map((i) => i.reason).join(", ");
|
|
319
|
+
options.onIssues?.(scene.issues);
|
|
320
|
+
}
|
|
321
|
+
const c = scene.canvas;
|
|
322
|
+
if (!handle.camera) handle.camera = { x: c.x || 0, y: c.y || 0, width: c.width, height: c.height };
|
|
323
|
+
handle.camera = boundCamera(handle, handle.camera);
|
|
324
|
+
svg.setAttribute("viewBox", `${handle.camera.x} ${handle.camera.y} ${handle.camera.width} ${handle.camera.height}`);
|
|
325
|
+
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
326
|
+
updateCanvasFrame(handle, c);
|
|
327
|
+
const limits = cameraMetrics(handle, handle.camera);
|
|
328
|
+
options.onCameraChanged?.({ canZoomIn: handle.camera.width > limits.minWidth + 1e-3, canZoomOut: handle.camera.width < limits.maxWidth - 1e-3 });
|
|
329
|
+
if (!handle.elements) {
|
|
330
|
+
svg.replaceChildren(element("rect", { x: c.x || 0, y: c.y || 0, width: c.width, height: c.height, fill: color(c.fill) }));
|
|
331
|
+
handle.elements = /* @__PURE__ */ new Map();
|
|
332
|
+
}
|
|
333
|
+
const containers = [svg], previous = /* @__PURE__ */ new Map([[svg, svg.firstElementChild]]);
|
|
334
|
+
const placeNode = (node) => {
|
|
335
|
+
const parent = containers.at(-1), before = previous.get(parent) || null;
|
|
336
|
+
if (node.parentNode !== parent || node.previousSibling !== before) parent.insertBefore(node, before ? before.nextSibling : parent.firstChild);
|
|
337
|
+
previous.set(parent, node);
|
|
338
|
+
};
|
|
339
|
+
const wanted = /* @__PURE__ */ new Set(), screenMatrix = svg.getScreenCTM(), zoom = screenMatrix ? Math.hypot(screenMatrix.a, screenMatrix.b) : 1, viewport = visibleWorldBounds(svg, screenMatrix);
|
|
340
|
+
for (const item of scene.items) {
|
|
341
|
+
if (zoom < (item.minZoom || 0)) continue;
|
|
342
|
+
const b = item.cullingBounds || item.bounds, v = viewport;
|
|
343
|
+
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;
|
|
344
|
+
if (item.kind === "vectorRestore") {
|
|
345
|
+
containers.pop();
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
wanted.add(item.id);
|
|
349
|
+
const old = handle.elements.get(item.id);
|
|
350
|
+
if (item.kind === "vectorClip" || item.kind === "vectorLayer") {
|
|
351
|
+
let group2 = old?.item === item ? old.node : null;
|
|
352
|
+
if (!group2) {
|
|
353
|
+
group2 = element("g", { "data-node-id": item.nodeId });
|
|
354
|
+
if (item.kind === "vectorLayer") group2.setAttribute("opacity", item.opacity);
|
|
355
|
+
else {
|
|
356
|
+
const id = `venue-${handle.serial}-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}-clip`, defs = element("defs"), clip = element("clipPath", { id, clipPathUnits: "userSpaceOnUse" });
|
|
357
|
+
clip.append(element("path", { d: pathData(item.path), "clip-rule": item.fillRule, transform: `matrix(${item.matrix.join(" ")})` }));
|
|
358
|
+
defs.append(clip);
|
|
359
|
+
group2.append(defs);
|
|
360
|
+
group2.setAttribute("clip-path", `url(#${id})`);
|
|
361
|
+
}
|
|
362
|
+
if (old) old.node.replaceWith(group2);
|
|
363
|
+
handle.elements.set(item.id, { item, node: group2 });
|
|
364
|
+
}
|
|
365
|
+
placeNode(group2);
|
|
366
|
+
containers.push(group2);
|
|
367
|
+
previous.set(group2, item.kind === "vectorClip" ? group2.firstElementChild : null);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (old?.item === item) {
|
|
371
|
+
placeNode(old.node);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const group = element("g", { transform: `matrix(${item.matrix.join(" ")})`, opacity: item.kind === "image" ? item.opacity ?? 1 : null, "data-node-id": item.nodeId || item.id });
|
|
375
|
+
if (item.kind === "image") {
|
|
376
|
+
const asset = options.resources?.assets?.[item.assetRef];
|
|
377
|
+
const href = asset?.url;
|
|
378
|
+
if (href && /^(data:image\/(png|jpeg|webp);base64,|blob:|https?:|file:|\.\.?\/)/i.test(href)) {
|
|
379
|
+
const source = item.sourceRect, destination = item.destinationRect;
|
|
380
|
+
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" });
|
|
381
|
+
nested.append(element("image", { href, width: item.width, height: item.height }));
|
|
382
|
+
const local = element("g", { transform: group.getAttribute("transform") });
|
|
383
|
+
local.append(nested);
|
|
384
|
+
group.removeAttribute("transform");
|
|
385
|
+
if (item.clip) {
|
|
386
|
+
const clipId = `venue-${handle.serial}-background-clip`, defs = element("defs"), clip = element("clipPath", { id: clipId, clipPathUnits: "userSpaceOnUse" });
|
|
387
|
+
clip.append(element("rect", item.clip));
|
|
388
|
+
defs.append(clip);
|
|
389
|
+
group.append(defs);
|
|
390
|
+
group.setAttribute("clip-path", `url(#${clipId})`);
|
|
391
|
+
}
|
|
392
|
+
group.append(local);
|
|
393
|
+
} else {
|
|
394
|
+
handle.live.textContent = message(options, "backgroundUnavailable");
|
|
395
|
+
options.onIssues?.([{ reason: "asset_unavailable", path: "background", assetRef: item.assetRef }]);
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
const paint = (kind) => {
|
|
399
|
+
const gradient = item[kind + "Gradient"];
|
|
400
|
+
if (!gradient) return color(item[kind]);
|
|
401
|
+
const id = `venue-${handle.serial}-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}-${kind}`, defs = element("defs");
|
|
402
|
+
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 });
|
|
403
|
+
gradient.colors.forEach((c2, i) => g.append(element("stop", { offset: gradient.stops[i], "stop-color": color(c2) })));
|
|
404
|
+
defs.append(g);
|
|
405
|
+
group.append(defs);
|
|
406
|
+
return `url(#${id})`;
|
|
407
|
+
};
|
|
408
|
+
if (item.localClip) {
|
|
409
|
+
const id = `venue-${handle.serial}-${item.id.replace(/[^a-zA-Z0-9_-]/g, "-")}-text-clip`, defs = element("defs"), clip = element("clipPath", { id, clipPathUnits: "userSpaceOnUse" });
|
|
410
|
+
clip.append(element("rect", item.localClip));
|
|
411
|
+
defs.append(clip);
|
|
412
|
+
group.append(defs);
|
|
413
|
+
group.setAttribute("clip-path", `url(#${id})`);
|
|
414
|
+
}
|
|
415
|
+
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" }));
|
|
416
|
+
}
|
|
417
|
+
const tooltip = options.seatPresentation?.[item.nodeId || item.id]?.tooltip ?? options.capacityPresentation?.[item.nodeId || item.id]?.tooltip ?? item.label;
|
|
418
|
+
if (tooltip) {
|
|
419
|
+
const title = element("title");
|
|
420
|
+
title.textContent = tooltip;
|
|
421
|
+
group.append(title);
|
|
422
|
+
}
|
|
423
|
+
if (old) old.node.replaceWith(group);
|
|
424
|
+
placeNode(group);
|
|
425
|
+
handle.elements.set(item.id, { item, node: group });
|
|
426
|
+
}
|
|
427
|
+
for (const [id, entry] of handle.elements) if (!wanted.has(id)) {
|
|
428
|
+
entry.node.remove();
|
|
429
|
+
handle.elements.delete(id);
|
|
430
|
+
}
|
|
431
|
+
if (dirtyIds?.length !== 0) renderList(handle);
|
|
432
|
+
}
|
|
433
|
+
function installTooltip(handle, options) {
|
|
434
|
+
const tooltip = document.createElement("aside");
|
|
435
|
+
tooltip.className = "venue-layout-tooltip";
|
|
436
|
+
tooltip.id = `venue-layout-tooltip-${handle.serial}`;
|
|
437
|
+
tooltip.setAttribute("role", "tooltip");
|
|
438
|
+
tooltip.hidden = true;
|
|
439
|
+
Object.assign(tooltip.style, {
|
|
440
|
+
position: "fixed",
|
|
441
|
+
zIndex: "2147483647",
|
|
442
|
+
pointerEvents: "none",
|
|
443
|
+
maxWidth: "min(280px, calc(100vw - 16px))",
|
|
444
|
+
boxSizing: "border-box",
|
|
445
|
+
padding: "8px 10px",
|
|
446
|
+
borderRadius: "6px",
|
|
447
|
+
background: "#202124",
|
|
448
|
+
color: "#fff",
|
|
449
|
+
font: "13px/1.4 system-ui, sans-serif",
|
|
450
|
+
whiteSpace: "pre-line",
|
|
451
|
+
overflowWrap: "anywhere",
|
|
452
|
+
boxShadow: "0 2px 8px #0004",
|
|
453
|
+
margin: "0"
|
|
454
|
+
});
|
|
455
|
+
Object.assign(tooltip.style, options.tooltipStyle || {});
|
|
456
|
+
handle.root.append(tooltip);
|
|
457
|
+
handle.tooltip = tooltip;
|
|
458
|
+
handle.hideTooltip = () => {
|
|
459
|
+
tooltip.hidden = true;
|
|
460
|
+
tooltip.textContent = "";
|
|
461
|
+
};
|
|
462
|
+
handle.hover = (event) => {
|
|
463
|
+
if (handle.disposed || event.buttons || event.pointerType === "touch") {
|
|
464
|
+
handle.hideTooltip();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const inverse = handle.svg.getScreenCTM()?.inverse();
|
|
468
|
+
if (!inverse) {
|
|
469
|
+
handle.hideTooltip();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(inverse);
|
|
473
|
+
const id = globalThis.VenueLayoutKernel.hitTest(handle.json, point.x, point.y);
|
|
474
|
+
const node = handle.options.document.nodes.find((node2) => node2.id === id);
|
|
475
|
+
const content = handle.options.seatPresentation?.[id]?.tooltip ?? handle.options.capacityPresentation?.[id]?.tooltip ?? node?.label;
|
|
476
|
+
if (!content) {
|
|
477
|
+
handle.hideTooltip();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
tooltip.textContent = String(content);
|
|
481
|
+
tooltip.hidden = false;
|
|
482
|
+
const bounds = tooltip.getBoundingClientRect();
|
|
483
|
+
const width = globalThis.innerWidth || document.documentElement.clientWidth;
|
|
484
|
+
const height = globalThis.innerHeight || document.documentElement.clientHeight;
|
|
485
|
+
const offset = handle.options.tooltipOffset ?? 15;
|
|
486
|
+
tooltip.style.left = `${Math.max(8, event.clientX + offset + bounds.width > width ? event.clientX - bounds.width - offset : event.clientX + offset)}px`;
|
|
487
|
+
tooltip.style.top = `${Math.max(8, event.clientY + offset + bounds.height > height ? event.clientY - bounds.height - offset : event.clientY + offset)}px`;
|
|
488
|
+
};
|
|
489
|
+
handle.tooltipKey = (event) => {
|
|
490
|
+
if (event.key === "Escape") handle.hideTooltip();
|
|
491
|
+
};
|
|
492
|
+
handle.svg.addEventListener("pointermove", handle.hover);
|
|
493
|
+
for (const name of ["pointerleave", "pointerdown", "pointercancel", "wheel"]) {
|
|
494
|
+
handle.svg.addEventListener(name, handle.hideTooltip);
|
|
495
|
+
}
|
|
496
|
+
handle.tooltipWindow = document.defaultView || globalThis;
|
|
497
|
+
handle.tooltipWindow.addEventListener?.("keydown", handle.tooltipKey);
|
|
498
|
+
handle.tooltipWindow.addEventListener?.("scroll", handle.hideTooltip, true);
|
|
499
|
+
handle.tooltipWindow.addEventListener?.("resize", handle.hideTooltip);
|
|
500
|
+
}
|
|
501
|
+
function updateCanvasFrame(handle, canvas) {
|
|
502
|
+
const requested = Number(handle.options.canvasCornerRadius) || 0;
|
|
503
|
+
const svg = handle.svg, matrix = svg.getScreenCTM();
|
|
504
|
+
if (requested <= 0 || !matrix) {
|
|
505
|
+
svg.style.clipPath = "";
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const rect = svg.getBoundingClientRect();
|
|
509
|
+
const a = new DOMPoint(canvas.x || 0, canvas.y || 0).matrixTransform(matrix);
|
|
510
|
+
const b = new DOMPoint((canvas.x || 0) + canvas.width, (canvas.y || 0) + canvas.height).matrixTransform(matrix);
|
|
511
|
+
const radius = Math.min(requested, Math.abs(b.x - a.x) / 2, Math.abs(b.y - a.y) / 2);
|
|
512
|
+
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)`;
|
|
513
|
+
}
|
|
514
|
+
function cameraMetrics(handle, camera) {
|
|
515
|
+
const canvas = handle.options.document.canvas, rect = handle.svg.getBoundingClientRect();
|
|
516
|
+
const width = rect.width || canvas.width, height = rect.height || canvas.height;
|
|
517
|
+
const fit = Math.min(width / canvas.width, height / canvas.height);
|
|
518
|
+
const aspect = camera.width / camera.height, fitWidth = Math.min(width, height * aspect) / fit;
|
|
519
|
+
return { canvas, width, height, minWidth: fitWidth / (handle.options.maxZoom || 20), maxWidth: fitWidth / (handle.options.minZoom || 0.9) };
|
|
520
|
+
}
|
|
521
|
+
function zoomFactor(handle, camera, factor) {
|
|
522
|
+
const limits = cameraMetrics(handle, camera);
|
|
523
|
+
return Math.min(limits.maxWidth, Math.max(limits.minWidth, camera.width * factor)) / camera.width;
|
|
524
|
+
}
|
|
525
|
+
function boundCamera(handle, camera) {
|
|
526
|
+
const factor = zoomFactor(handle, camera, 1);
|
|
527
|
+
camera = {
|
|
528
|
+
...camera,
|
|
529
|
+
x: camera.x + camera.width * (1 - factor) / 2,
|
|
530
|
+
y: camera.y + camera.height * (1 - factor) / 2,
|
|
531
|
+
width: camera.width * factor,
|
|
532
|
+
height: camera.height * factor
|
|
533
|
+
};
|
|
534
|
+
const { canvas, width, height } = cameraMetrics(handle, camera);
|
|
535
|
+
const scale = Math.min(width / camera.width, height / camera.height);
|
|
536
|
+
const viewportWidth = width / scale, viewportHeight = height / scale;
|
|
537
|
+
const shiftX = (viewportWidth - camera.width) / 2, shiftY = (viewportHeight - camera.height) / 2;
|
|
538
|
+
const clampAxis = (position, start, extent, viewport) => {
|
|
539
|
+
const visible = viewport / 2;
|
|
540
|
+
return Math.min(start + extent - visible, Math.max(start + visible - viewport, position));
|
|
541
|
+
};
|
|
542
|
+
return {
|
|
543
|
+
...camera,
|
|
544
|
+
x: clampAxis(camera.x - shiftX, canvas.x || 0, canvas.width, viewportWidth) + shiftX,
|
|
545
|
+
y: clampAxis(camera.y - shiftY, canvas.y || 0, canvas.height, viewportHeight) + shiftY
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function installCamera(handle) {
|
|
549
|
+
const svg = handle.svg, surface = handle.container;
|
|
550
|
+
const onSurface = (event) => event.target === surface || event.target === handle.root || svg.contains(event.target);
|
|
551
|
+
handle.wheel = (event) => {
|
|
552
|
+
if (!handle.camera || !onSurface(event)) return;
|
|
553
|
+
handle.hideTooltip?.();
|
|
554
|
+
event.preventDefault();
|
|
555
|
+
const matrix = svg.getScreenCTM(), p = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse()), c = handle.camera;
|
|
556
|
+
if (event.shiftKey) {
|
|
557
|
+
const delta = new DOMPoint(event.deltaX || event.deltaY, event.deltaX ? event.deltaY : 0).matrixTransform(matrix.inverse());
|
|
558
|
+
const origin = new DOMPoint(0, 0).matrixTransform(matrix.inverse());
|
|
559
|
+
handle.camera = boundCamera(handle, { ...c, x: c.x + delta.x - origin.x, y: c.y + delta.y - origin.y });
|
|
560
|
+
render(handle, []);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const factor = zoomFactor(handle, c, Math.exp(event.deltaY / 200));
|
|
564
|
+
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 };
|
|
565
|
+
render(handle, []);
|
|
566
|
+
};
|
|
567
|
+
let start, pinch;
|
|
568
|
+
const pointers = /* @__PURE__ */ new Map();
|
|
569
|
+
handle.pointerdown = (event) => {
|
|
570
|
+
if (!onSurface(event) || event.button !== 0 && event.button !== 1) return;
|
|
571
|
+
handle.hideTooltip?.();
|
|
572
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
573
|
+
if (pointers.size === 2) {
|
|
574
|
+
const [a, b] = [...pointers.values()];
|
|
575
|
+
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() };
|
|
576
|
+
}
|
|
577
|
+
start = { x: event.clientX, y: event.clientY, camera: { ...handle.camera }, inverse: svg.getScreenCTM()?.inverse() };
|
|
578
|
+
svg.setPointerCapture(event.pointerId);
|
|
579
|
+
};
|
|
580
|
+
handle.pointermove = (event) => {
|
|
581
|
+
if (!pointers.has(event.pointerId)) return;
|
|
582
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
583
|
+
if (pointers.size === 2 && pinch?.inverse) {
|
|
584
|
+
const [a2, b2] = [...pointers.values()], factor = zoomFactor(handle, pinch.camera, pinch.distance / Math.max(1, Math.hypot(a2.x - b2.x, a2.y - b2.y))), origin = new DOMPoint(pinch.center.x, pinch.center.y).matrixTransform(pinch.inverse), current = new DOMPoint((a2.x + b2.x) / 2, (a2.y + b2.y) / 2).matrixTransform(pinch.inverse), c = pinch.camera;
|
|
585
|
+
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 };
|
|
586
|
+
handle.suppressClick = true;
|
|
587
|
+
render(handle, []);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (!start?.inverse) return;
|
|
591
|
+
const dx = event.clientX - start.x, dy = event.clientY - start.y;
|
|
592
|
+
if (Math.hypot(dx, dy) < 4) return;
|
|
593
|
+
const a = new DOMPoint(start.x, start.y).matrixTransform(start.inverse), b = new DOMPoint(event.clientX, event.clientY).matrixTransform(start.inverse);
|
|
594
|
+
handle.camera = { ...start.camera, x: start.camera.x + a.x - b.x, y: start.camera.y + a.y - b.y };
|
|
595
|
+
handle.suppressClick = true;
|
|
596
|
+
render(handle, []);
|
|
597
|
+
};
|
|
598
|
+
handle.pointerup = (event) => {
|
|
599
|
+
if (!pointers.delete(event.pointerId)) return;
|
|
600
|
+
start = null;
|
|
601
|
+
pinch = null;
|
|
602
|
+
};
|
|
603
|
+
surface.addEventListener("wheel", handle.wheel, { passive: false });
|
|
604
|
+
surface.addEventListener("pointerdown", handle.pointerdown);
|
|
605
|
+
surface.addEventListener("pointermove", handle.pointermove);
|
|
606
|
+
surface.addEventListener("pointerup", handle.pointerup);
|
|
607
|
+
surface.addEventListener("pointercancel", handle.pointerup);
|
|
608
|
+
}
|
|
609
|
+
function renderList(handle) {
|
|
610
|
+
const allNodes = handle.listDocument === handle.options.document ? handle.listNodes : handle.options.document.nodes.filter((n) => n.kind === "seat" || n.kind === "capacityArea");
|
|
611
|
+
handle.listDocument = handle.options.document;
|
|
612
|
+
handle.listNodes = allNodes;
|
|
613
|
+
const focusIndex = Math.max(0, allNodes.findIndex((n) => n.id === (document.activeElement?.dataset?.nodeId || handle.focused)));
|
|
614
|
+
const offset = Math.max(0, focusIndex - 25);
|
|
615
|
+
const nodes = allNodes.slice(offset, offset + 100);
|
|
616
|
+
const previousFocus = document.activeElement?.dataset?.nodeId;
|
|
617
|
+
handle.list.replaceChildren();
|
|
618
|
+
const focused = previousFocus || handle.focused || nodes[0]?.id;
|
|
619
|
+
nodes.forEach((node, index) => {
|
|
620
|
+
const button = document.createElement("button");
|
|
621
|
+
button.dataset.nodeId = node.id;
|
|
622
|
+
button.setAttribute("aria-posinset", String(offset + index + 1));
|
|
623
|
+
button.setAttribute("aria-setsize", String(allNodes.length));
|
|
624
|
+
const state = handle.options.seatPresentation?.[node.id] || handle.options.capacityPresentation?.[node.id] || {};
|
|
625
|
+
const unit = (handle.options.document.selectionUnits || []).find((u) => u.nodeIds.includes(node.id));
|
|
626
|
+
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")}`;
|
|
627
|
+
button.tabIndex = node.id === focused ? 0 : -1;
|
|
628
|
+
button.setAttribute("aria-pressed", String(handle.selected.has(node.id)));
|
|
629
|
+
if (node.kind === "capacityArea") {
|
|
630
|
+
const unavailable = Number(state.available ?? state.capacity ?? 0) <= 0 || state.selectable === false || ["occupied", "unavailable", "disabled", "blocked", "sold", "reserved"].includes(state.status);
|
|
631
|
+
button.setAttribute("aria-disabled", String(unavailable));
|
|
632
|
+
if (state.tooltip) button.title = state.tooltip;
|
|
633
|
+
}
|
|
634
|
+
button.onclick = () => requestSelection(handle, node.id);
|
|
635
|
+
button.onfocus = () => {
|
|
636
|
+
handle.focused = node.id;
|
|
637
|
+
};
|
|
638
|
+
button.onkeydown = (event) => {
|
|
639
|
+
const delta = ["ArrowRight", "ArrowDown"].includes(event.key) ? 1 : ["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 0;
|
|
640
|
+
if (delta) {
|
|
641
|
+
event.preventDefault();
|
|
642
|
+
const direction = { ArrowRight: "right", ArrowLeft: "left", ArrowUp: "up", ArrowDown: "down" }[event.key];
|
|
643
|
+
const nearest = globalThis.VenueLayoutKernel.nearestNode?.(handle.json, node.id, direction);
|
|
644
|
+
const target = allNodes.find((n) => n.id === nearest) || node;
|
|
645
|
+
handle.focused = target.id;
|
|
646
|
+
button.blur();
|
|
647
|
+
renderList(handle);
|
|
648
|
+
handle.list.querySelector(`[data-node-id="${CSS.escape(target.id)}"]`)?.focus();
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
handle.list.append(button);
|
|
652
|
+
});
|
|
653
|
+
handle.list.style.maxHeight = "120px";
|
|
654
|
+
handle.list.style.overflow = "auto";
|
|
655
|
+
if (previousFocus) handle.list.querySelector(`[data-node-id="${CSS.escape(previousFocus)}"]`)?.focus({ preventScroll: true });
|
|
656
|
+
}
|
|
657
|
+
function dispose(handle) {
|
|
658
|
+
if (handle.disposed) return;
|
|
659
|
+
handle.disposed = true;
|
|
660
|
+
handle.generation++;
|
|
661
|
+
handle.container.removeEventListener("click", handle.click);
|
|
662
|
+
if (handle.container.style.touchAction === "none") handle.container.style.touchAction = handle.previousTouchAction;
|
|
663
|
+
handle.svg.removeEventListener("pointermove", handle.hover);
|
|
664
|
+
for (const name of ["pointerleave", "pointerdown", "pointercancel", "wheel"]) handle.svg.removeEventListener(name, handle.hideTooltip);
|
|
665
|
+
handle.tooltipWindow?.removeEventListener?.("keydown", handle.tooltipKey);
|
|
666
|
+
handle.tooltipWindow?.removeEventListener?.("scroll", handle.hideTooltip, true);
|
|
667
|
+
handle.tooltipWindow?.removeEventListener?.("resize", handle.hideTooltip);
|
|
668
|
+
handle.hideTooltip?.();
|
|
669
|
+
for (const name of ["wheel", "pointerdown", "pointermove", "pointerup", "pointercancel"]) handle.container.removeEventListener(name, handle[name === "pointercancel" ? "pointerup" : name]);
|
|
670
|
+
handle.resizeObserver?.disconnect();
|
|
671
|
+
handle.root.remove();
|
|
672
|
+
handle.pending.clear();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// demo/demo.js
|
|
676
|
+
var view;
|
|
677
|
+
var documentData;
|
|
678
|
+
var resources = {};
|
|
679
|
+
var prices = {};
|
|
680
|
+
var selected = /* @__PURE__ */ new Set();
|
|
681
|
+
var quantities = {};
|
|
682
|
+
var occupied = false;
|
|
683
|
+
var loadRequest = 0;
|
|
684
|
+
var activeGeneration = 0;
|
|
685
|
+
var el = (id) => document.getElementById(id);
|
|
686
|
+
var previewToken = new URLSearchParams(location.search).get("editor");
|
|
687
|
+
var fromEditor = !!previewToken && /^[a-f0-9-]{36}$/.test(previewToken);
|
|
688
|
+
if (fromEditor) {
|
|
689
|
+
document.documentElement.lang = "cs";
|
|
690
|
+
document.querySelector("h1").textContent = "Vyberte si m\xEDsta";
|
|
691
|
+
document.title = "V\xFDb\u011Br m\xEDst - webov\xFD n\xE1hled";
|
|
692
|
+
el("sample-label").textContent = "N\xE1hled va\u0161eho rozpracovan\xE9ho s\xE1lu";
|
|
693
|
+
el("back-to-editor").hidden = false;
|
|
694
|
+
el("back-to-editor").href = `/flutter/?fromPreview=${encodeURIComponent(previewToken)}`;
|
|
695
|
+
el("import-tools").hidden = true;
|
|
696
|
+
el("legend").hidden = false;
|
|
697
|
+
el("reset").textContent = "Zru\u0161it v\xFDb\u011Br";
|
|
698
|
+
el("status").textContent = "P\u0159ipravuji webov\xFD n\xE1hled\u2026";
|
|
699
|
+
el("preview-note").textContent = "Uk\xE1zkov\xFD v\xFDb\u011Br - nevytv\xE1\u0159\xED rezervaci ani objedn\xE1vku";
|
|
700
|
+
}
|
|
701
|
+
var status = (message2) => el("status").textContent = message2;
|
|
702
|
+
var maxBytes = 16 * 1024 * 1024;
|
|
703
|
+
async function digest(data) {
|
|
704
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", data))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
705
|
+
}
|
|
706
|
+
async function decode(text) {
|
|
707
|
+
if (new TextEncoder().encode(text).length > maxBytes) throw Error("Import exceeds 16 MiB");
|
|
708
|
+
const value = JSON.parse(text);
|
|
709
|
+
if (value.schemaVersion === 2) return { layout: value, resources: resources2 };
|
|
710
|
+
if (Object.keys(value).some((k) => !["format", "version", "protocol", "layout", "host", "resources"].includes(k))) throw Error("Unknown portable bundle field");
|
|
711
|
+
if (value.format !== "venue-layout-demo" || value.version !== 2 || value.protocol !== 1) throw Error("Unsupported file version");
|
|
712
|
+
const loaded = {};
|
|
713
|
+
for (const item of value.resources) {
|
|
714
|
+
if (Object.keys(item).some((k) => !["path", "size", "sha256", "data"].includes(k)) || item.path !== "resources/fixtures.json") throw Error("Unknown portable resource");
|
|
715
|
+
if (!/^resources\/[a-zA-Z0-9_-]+\.json$/.test(item.path) || loaded[item.path]) throw Error("Invalid or duplicate resource path");
|
|
716
|
+
const bytes = Uint8Array.from(atob(item.data), (c) => c.charCodeAt(0));
|
|
717
|
+
if (bytes.length > maxBytes || bytes.length !== item.size || await digest(bytes) !== item.sha256) throw Error("Resource integrity failed");
|
|
718
|
+
loaded[item.path] = JSON.parse(new TextDecoder().decode(bytes));
|
|
719
|
+
}
|
|
720
|
+
if (!loaded["resources/fixtures.json"]) throw Error("Missing bundled resources");
|
|
721
|
+
const resources2 = loaded["resources/fixtures.json"];
|
|
722
|
+
if (Object.keys(resources2).some((k) => !["protocol", "fontFamilies", "fontFaces", "textLayouts", "numberSymbols", "assets"].includes(k))) throw Error("Unknown portable resource field");
|
|
723
|
+
if ("assets" in resources2 && (!resources2.assets || typeof resources2.assets !== "object" || Array.isArray(resources2.assets))) throw Error("Embedded assets must be a map");
|
|
724
|
+
for (const [id, asset] of Object.entries(resources2.assets ?? {})) {
|
|
725
|
+
if (!id || !asset || typeof asset !== "object" || Array.isArray(asset) || Object.keys(asset).some((k) => !["hash", "url", "byteLength", "mimeType", "width", "height"].includes(k))) throw Error("Invalid embedded asset fields");
|
|
726
|
+
const prefix = `data:${asset.mimeType};base64,`;
|
|
727
|
+
if (!["image/png", "image/jpeg", "image/webp"].includes(asset.mimeType) || typeof asset.url !== "string" || !asset.url.startsWith(prefix)) throw Error("Embedded image required");
|
|
728
|
+
const bytes = Uint8Array.from(atob(asset.url.slice(prefix.length)), (c) => c.charCodeAt(0));
|
|
729
|
+
if (bytes.length > 800 * 1024 || bytes.length !== asset.byteLength || await digest(bytes) !== asset.hash) throw Error("Embedded asset integrity failed");
|
|
730
|
+
}
|
|
731
|
+
return { layout: value.layout, resources: resources2, host: value.host || {} };
|
|
732
|
+
}
|
|
733
|
+
function price(n) {
|
|
734
|
+
return prices[n.id] ?? prices[n.parentId] ?? 250;
|
|
735
|
+
}
|
|
736
|
+
function capacityStates() {
|
|
737
|
+
return Object.fromEntries(documentData.nodes.filter((n) => n.kind === "capacityArea").map((n) => [n.id, { capacity: n.capacity ?? 20, available: n.capacity ?? 20, selectedQuantity: quantities[n.id] ?? 0 }]));
|
|
738
|
+
}
|
|
739
|
+
function presentation() {
|
|
740
|
+
return Object.fromEntries(documentData.nodes.filter((n) => n.kind === "seat").map((n) => [n.id, { status: selected.has(n.id) ? "selected" : occupied ? "occupied" : "available", selected: selected.has(n.id), label: n.label || "Seat", accessibilityLabel: `${n.label || "Seat"} - ${price(n)} CZK`, price: price(n) }]));
|
|
741
|
+
}
|
|
742
|
+
async function response() {
|
|
743
|
+
const scenario = el("scenario").value;
|
|
744
|
+
await new Promise((r) => setTimeout(r, scenario === "timeout" ? 800 : 100));
|
|
745
|
+
if (scenario !== "success") {
|
|
746
|
+
status(`Demo ${scenario}: selection preserved. Retry with success.`);
|
|
747
|
+
return { accepted: false, message: `Demo ${scenario}` };
|
|
748
|
+
}
|
|
749
|
+
return { accepted: true };
|
|
750
|
+
}
|
|
751
|
+
function units(ids) {
|
|
752
|
+
const result = new Set(ids);
|
|
753
|
+
for (const unit of documentData.selectionUnits ?? []) {
|
|
754
|
+
if (unit.nodeIds.some((id) => result.has(id))) for (const id of unit.nodeIds) result.add(id);
|
|
755
|
+
}
|
|
756
|
+
return result;
|
|
757
|
+
}
|
|
758
|
+
function update2() {
|
|
759
|
+
const standing = Object.values(quantities).reduce((a, b) => a + b, 0), total = documentData.nodes.filter((n) => selected.has(n.id)).reduce((sum, n) => sum + price(n), 0);
|
|
760
|
+
el("selected").textContent = fromEditor ? `Sezen\xED: ${selected.size} \xB7 St\xE1n\xED: ${standing} \xB7 Cena sezen\xED: ${total.toLocaleString("cs-CZ")} K\u010D` : `${selected.size} seats selected; ${standing} standing selected - sample seat total ${total} CZK`;
|
|
761
|
+
view?.update({ document: documentData, resources, seatPresentation: presentation(), capacityPresentation: capacityStates() });
|
|
762
|
+
}
|
|
763
|
+
function capacities() {
|
|
764
|
+
el("capacity").replaceChildren();
|
|
765
|
+
for (const area of documentData.nodes.filter((n) => n.kind === "capacityArea")) {
|
|
766
|
+
const label = document.createElement("label");
|
|
767
|
+
label.textContent = `${area.label || "Standing"} - total capacity ${area.capacity}: `;
|
|
768
|
+
const input = document.createElement("input");
|
|
769
|
+
input.type = "number";
|
|
770
|
+
input.min = "0";
|
|
771
|
+
input.max = String(area.capacity);
|
|
772
|
+
input.value = String(quantities[area.id] ?? 0);
|
|
773
|
+
input.setAttribute("aria-label", `${area.label || "Standing"} selected quantity`);
|
|
774
|
+
input.onchange = async () => {
|
|
775
|
+
const count = Number(input.value);
|
|
776
|
+
if (!Number.isInteger(count) || count < 0 || count > area.capacity) {
|
|
777
|
+
input.value = String(quantities[area.id] ?? 0);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const result = await response();
|
|
781
|
+
if (result.accepted) quantities[area.id] = count;
|
|
782
|
+
else input.value = String(quantities[area.id] ?? 0);
|
|
783
|
+
update2();
|
|
784
|
+
};
|
|
785
|
+
label.append(input);
|
|
786
|
+
el("capacity").append(label);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
function validatePrices(values, layout) {
|
|
790
|
+
if (values == null) return;
|
|
791
|
+
if (typeof values !== "object" || Array.isArray(values)) throw Error("Invalid demo prices");
|
|
792
|
+
const ids = new Set(layout.nodes.filter((n) => ["seat", "table", "group", "furniture", "capacityArea"].includes(n.kind)).map((n) => n.id));
|
|
793
|
+
for (const [id, value] of Object.entries(values)) if (!ids.has(id) || !Number.isSafeInteger(value) || value < 0 || value > 1e8) throw Error("Invalid or orphaned sample price binding");
|
|
794
|
+
}
|
|
795
|
+
async function load(text) {
|
|
796
|
+
const request = ++loadRequest, parsed = await decode(text);
|
|
797
|
+
const issues = JSON.parse(VenueLayoutKernel.validate(JSON.stringify(parsed.layout)));
|
|
798
|
+
if (issues.length) throw Error(issues[0].reason);
|
|
799
|
+
const resourceIssues = JSON.parse(VenueLayoutKernel.validateResources(JSON.stringify(parsed.resources)));
|
|
800
|
+
if (resourceIssues.length) throw Error("Invalid font resource: " + resourceIssues[0].reason);
|
|
801
|
+
const host = parsed.host || {}, allowed = /* @__PURE__ */ new Set(["prices", "template", "revision", "label", "savedBaseline", "savedPrices"]);
|
|
802
|
+
if (Object.keys(host).some((k) => !allowed.has(k))) throw Error("Unknown demo metadata");
|
|
803
|
+
validatePrices(host.prices, parsed.layout);
|
|
804
|
+
if (host.savedBaseline) {
|
|
805
|
+
const errors = JSON.parse(VenueLayoutKernel.validate(JSON.stringify(host.savedBaseline)));
|
|
806
|
+
if (errors.length) throw Error("Invalid saved baseline");
|
|
807
|
+
validatePrices(host.savedPrices, host.savedBaseline);
|
|
808
|
+
}
|
|
809
|
+
for (const asset of Object.values(parsed.resources.assets ?? {})) {
|
|
810
|
+
const bytes = Uint8Array.from(atob(asset.url.split(",")[1]), (c) => c.charCodeAt(0));
|
|
811
|
+
const image = await createImageBitmap(new Blob([bytes], { type: asset.mimeType }));
|
|
812
|
+
const valid = image.width === asset.width && image.height === asset.height;
|
|
813
|
+
image.close();
|
|
814
|
+
if (!valid) throw Error("Embedded image dimensions differ");
|
|
815
|
+
}
|
|
816
|
+
if (parsed.layout.background && !parsed.resources.assets?.[parsed.layout.background.assetRef]) throw Error("Missing embedded background");
|
|
817
|
+
const scene = JSON.parse(VenueLayoutKernel.compileScene(JSON.stringify(parsed.layout), JSON.stringify(parsed.resources), "{}"));
|
|
818
|
+
if (scene.issues.length) throw Error("Missing or invalid prepared resource: " + scene.issues[0].reason);
|
|
819
|
+
if (request !== loadRequest) return;
|
|
820
|
+
const nextPrices = host.prices || {}, nextPresentation = Object.fromEntries(parsed.layout.nodes.filter((n) => n.kind === "seat").map((n) => [n.id, { label: n.label || "Seat", status: "available" }]));
|
|
821
|
+
const staging = document.createElement("div");
|
|
822
|
+
let next;
|
|
823
|
+
try {
|
|
824
|
+
next = mount(staging, {
|
|
825
|
+
document: parsed.layout,
|
|
826
|
+
resources: parsed.resources,
|
|
827
|
+
seatPresentation: nextPresentation,
|
|
828
|
+
onQuantityRequested: async (id, count) => {
|
|
829
|
+
const result = await response();
|
|
830
|
+
if (activeGeneration !== request) return { accepted: false, message: "Document changed" };
|
|
831
|
+
if (result.accepted) {
|
|
832
|
+
quantities[id] = count;
|
|
833
|
+
update2();
|
|
834
|
+
}
|
|
835
|
+
return result;
|
|
836
|
+
},
|
|
837
|
+
onSelectionRequested: async (ids, selecting) => {
|
|
838
|
+
const result = await response();
|
|
839
|
+
if (activeGeneration !== request) return { accepted: false, message: "Document changed" };
|
|
840
|
+
if (result.accepted) {
|
|
841
|
+
for (const id of units(ids)) {
|
|
842
|
+
if (selecting) selected.add(id);
|
|
843
|
+
else selected.delete(id);
|
|
844
|
+
}
|
|
845
|
+
update2();
|
|
846
|
+
}
|
|
847
|
+
return result;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
} catch (error) {
|
|
851
|
+
next?.dispose();
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
if (fromEditor) {
|
|
855
|
+
const list = staging.querySelector('[role="list"]');
|
|
856
|
+
if (list) {
|
|
857
|
+
const details = document.createElement("details"), summary = document.createElement("summary");
|
|
858
|
+
summary.textContent = "P\u0159\xEDstupn\xFD seznam m\xEDst";
|
|
859
|
+
list.before(details);
|
|
860
|
+
details.append(summary, list);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
view?.dispose();
|
|
864
|
+
el("picker").replaceChildren(...staging.childNodes);
|
|
865
|
+
view = next;
|
|
866
|
+
activeGeneration = request;
|
|
867
|
+
documentData = parsed.layout;
|
|
868
|
+
resources = parsed.resources;
|
|
869
|
+
prices = nextPrices;
|
|
870
|
+
selected = /* @__PURE__ */ new Set();
|
|
871
|
+
quantities = {};
|
|
872
|
+
capacities();
|
|
873
|
+
update2();
|
|
874
|
+
status(fromEditor ? "Vyberte m\xEDsto kliknut\xEDm. Pl\xE1nek m\u016F\u017Eete p\u0159ibli\u017Eovat a posouvat." : "Same exported geometry loaded. Select seats or standing quantities.");
|
|
875
|
+
}
|
|
876
|
+
el("import").onchange = async () => {
|
|
877
|
+
try {
|
|
878
|
+
const file = el("import").files[0];
|
|
879
|
+
if (file) await load(await file.text());
|
|
880
|
+
} catch (error) {
|
|
881
|
+
status(`Import rejected; current plan preserved: ${error.message}`);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
el("reset").onclick = () => {
|
|
885
|
+
selected.clear();
|
|
886
|
+
quantities = {};
|
|
887
|
+
capacities();
|
|
888
|
+
update2();
|
|
889
|
+
};
|
|
890
|
+
el("patch").onclick = () => {
|
|
891
|
+
occupied = !occupied;
|
|
892
|
+
update2();
|
|
893
|
+
status("Presentation patch applied; geometry unchanged.");
|
|
894
|
+
};
|
|
895
|
+
window.venueDemo = { load, get document() {
|
|
896
|
+
return documentData;
|
|
897
|
+
}, get selected() {
|
|
898
|
+
return [...selected];
|
|
899
|
+
} };
|
|
900
|
+
(async () => {
|
|
901
|
+
if (fromEditor) {
|
|
902
|
+
try {
|
|
903
|
+
const text = await new Promise((resolve, reject) => {
|
|
904
|
+
const request = indexedDB.open("venue-layout-preview", 1);
|
|
905
|
+
request.onupgradeneeded = () => request.result.createObjectStore("drafts");
|
|
906
|
+
request.onerror = () => reject(Error("\xDAlo\u017Ei\u0161t\u011B n\xE1hledu nen\xED dostupn\xE9."));
|
|
907
|
+
request.onblocked = () => reject(Error("\xDAlo\u017Ei\u0161t\u011B n\xE1hledu je zablokovan\xE9."));
|
|
908
|
+
request.onsuccess = () => {
|
|
909
|
+
const database = request.result;
|
|
910
|
+
const transaction = database.transaction("drafts");
|
|
911
|
+
const read = transaction.objectStore("drafts").get(previewToken);
|
|
912
|
+
read.onsuccess = () => resolve(read.result);
|
|
913
|
+
read.onerror = () => reject(Error("Pl\xE1nek se nepoda\u0159ilo na\u010D\xEDst."));
|
|
914
|
+
transaction.oncomplete = () => database.close();
|
|
915
|
+
transaction.onabort = () => {
|
|
916
|
+
database.close();
|
|
917
|
+
reject(Error("Na\u010Dten\xED pl\xE1nku bylo p\u0159eru\u0161eno."));
|
|
918
|
+
};
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
if (typeof text !== "string") throw Error("Chyb\xED pl\xE1nek z editoru. Vra\u0165te se a otev\u0159ete n\xE1hled znovu.");
|
|
922
|
+
await load(text);
|
|
923
|
+
} catch (error) {
|
|
924
|
+
status(`N\xE1hled se nepoda\u0159ilo otev\u0159\xEDt: ${error.message}`);
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (globalThis.venueDemoSeed) {
|
|
929
|
+
const { layout, resources: resources2 } = globalThis.venueDemoSeed;
|
|
930
|
+
const bytes = new TextEncoder().encode(JSON.stringify(resources2));
|
|
931
|
+
let binary = "";
|
|
932
|
+
for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
933
|
+
const data = btoa(binary);
|
|
934
|
+
load(JSON.stringify({ format: "venue-layout-demo", version: 2, protocol: 1, layout, host: {}, resources: [{ path: "resources/fixtures.json", size: bytes.length, sha256: await digest(bytes), data }] })).catch((error) => status(`Seed failed: ${error.message}`));
|
|
935
|
+
}
|
|
936
|
+
})();
|
|
937
|
+
})();
|