@scenography/overlay-presets 0.2.0 → 0.3.0

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/dist/index.mjs CHANGED
@@ -132,6 +132,7 @@ function cardboardButton(options = {}) {
132
132
  ctx.gallery.setRender({ cardboard: enabled });
133
133
  button.classList.toggle("btn-active", enabled);
134
134
  button.setAttribute("aria-pressed", String(enabled));
135
+ ctx.gallery.emit("cardboard:change", { enabled });
135
136
  };
136
137
  button.addEventListener("click", onClick);
137
138
  return {
@@ -141,6 +142,161 @@ function cardboardButton(options = {}) {
141
142
  };
142
143
  }
143
144
  //#endregion
145
+ //#region src/components/reset-view/reset-view.ts
146
+ /**
147
+ * A "home" button that returns the camera to the scene's start position and
148
+ * orientation (`ctx.camera.reset()`) — for visitors who wander off and want to
149
+ * get back to the entrance. Defaults to a plain glyph; pass `lucideIcon("House")`.
150
+ */
151
+ function resetViewButton(options = {}) {
152
+ const icon = options.icon ?? "⌂";
153
+ return (ctx) => {
154
+ const button = document.createElement("button");
155
+ button.type = "button";
156
+ button.className = options.className ?? "btn btn-circle btn-sm";
157
+ setIcon(button, icon);
158
+ const onClick = () => ctx.gallery.ctx.camera.reset();
159
+ button.addEventListener("click", onClick);
160
+ const unbind = bindLocale(ctx, () => button.setAttribute("aria-label", resolveText(options.label ?? "Reset view")));
161
+ return {
162
+ element: button,
163
+ destroy: () => {
164
+ button.removeEventListener("click", onClick);
165
+ unbind();
166
+ }
167
+ };
168
+ };
169
+ }
170
+ //#endregion
171
+ //#region src/components/view-mode/view-mode.ts
172
+ /** Lucide icon per mode, shown beside its label in the picker. */
173
+ const MODE_ICON = {
174
+ touch: "Hand",
175
+ vr: "Orbit",
176
+ cardboard: "RectangleGoggles"
177
+ };
178
+ /**
179
+ * A camera-control mode picker: a `ScanEye` button opening a small modal with one
180
+ * entry per mode (Touch / VR / Cardboard). Picking one calls
181
+ * `ctx.viewControls.setMode` — which, inside this click gesture, flips the
182
+ * gyroscope and stereo split (so iOS grants motion here). The active mode is
183
+ * marked and kept in sync via the `controls:change` event, so the settings panel
184
+ * and this picker never disagree.
185
+ */
186
+ function viewModeButton(options) {
187
+ return (ctx) => {
188
+ const viewControls = ctx.gallery.ctx.viewControls;
189
+ const wrap = document.createElement("div");
190
+ const button = document.createElement("button");
191
+ button.type = "button";
192
+ button.className = "btn btn-circle btn-sm";
193
+ setIcon(button, lucideIcon("ScanEye"));
194
+ const dialog = document.createElement("dialog");
195
+ dialog.className = "modal";
196
+ const box = document.createElement("div");
197
+ box.className = "modal-box max-w-xs";
198
+ const close = document.createElement("button");
199
+ close.type = "button";
200
+ close.className = "btn btn-sm btn-circle btn-ghost absolute right-2 top-2";
201
+ close.textContent = "✕";
202
+ box.appendChild(close);
203
+ const heading = document.createElement("h3");
204
+ heading.className = "text-lg font-bold mb-4";
205
+ box.appendChild(heading);
206
+ const list = document.createElement("div");
207
+ list.className = "flex flex-col gap-2";
208
+ box.appendChild(list);
209
+ const cleanups = [];
210
+ const relabels = [];
211
+ const optionButtons = [];
212
+ for (const item of options.modes) {
213
+ const optBtn = document.createElement("button");
214
+ optBtn.type = "button";
215
+ optBtn.className = "btn btn-block justify-start gap-3";
216
+ const iconEl = document.createElement("span");
217
+ iconEl.style.display = "inline-flex";
218
+ setIcon(iconEl, lucideIcon(MODE_ICON[item.mode]));
219
+ const label = document.createElement("span");
220
+ optBtn.append(iconEl, label);
221
+ const onPick = () => {
222
+ viewControls.setMode(item.mode);
223
+ syncActive();
224
+ dialog.close();
225
+ };
226
+ optBtn.addEventListener("click", onPick);
227
+ cleanups.push(() => optBtn.removeEventListener("click", onPick));
228
+ list.appendChild(optBtn);
229
+ optionButtons.push({
230
+ el: optBtn,
231
+ mode: item.mode
232
+ });
233
+ relabels.push(() => label.textContent = resolveText(item.label));
234
+ }
235
+ const syncActive = () => {
236
+ const current = viewControls.getMode();
237
+ for (const { el, mode } of optionButtons) el.classList.toggle("btn-active", mode === current);
238
+ };
239
+ const backdrop = document.createElement("form");
240
+ backdrop.method = "dialog";
241
+ backdrop.className = "modal-backdrop";
242
+ const backdropBtn = document.createElement("button");
243
+ backdropBtn.textContent = "close";
244
+ backdrop.appendChild(backdropBtn);
245
+ dialog.append(box, backdrop);
246
+ wrap.append(button, dialog);
247
+ const offChange = ctx.gallery.on("controls:change", syncActive);
248
+ const unbind = bindLocale(ctx, () => {
249
+ const title = resolveText(options.title);
250
+ button.setAttribute("aria-label", title);
251
+ heading.textContent = title;
252
+ close.setAttribute("aria-label", resolveText(options.closeLabel ?? "Close"));
253
+ for (const relabel of relabels) relabel();
254
+ });
255
+ const onOpen = () => {
256
+ syncActive();
257
+ dialog.showModal();
258
+ };
259
+ const onClose = () => dialog.close();
260
+ button.addEventListener("click", onOpen);
261
+ close.addEventListener("click", onClose);
262
+ return {
263
+ element: wrap,
264
+ destroy: () => {
265
+ button.removeEventListener("click", onOpen);
266
+ close.removeEventListener("click", onClose);
267
+ for (const fn of cleanups) fn();
268
+ offChange();
269
+ unbind();
270
+ }
271
+ };
272
+ };
273
+ }
274
+ //#endregion
275
+ //#region src/components/view-mode/capabilities.ts
276
+ /**
277
+ * Sniff the device's control capabilities. Deliberately conservative: gyro is
278
+ * gated behind a touch (mobile) device too, since desktops expose the
279
+ * `DeviceOrientationEvent` interface but never fire it — pairing it with a coarse
280
+ * pointer avoids offering VR/Cardboard where they can't work.
281
+ */
282
+ function detectControlCapabilities() {
283
+ return {
284
+ touch: typeof matchMedia !== "undefined" ? matchMedia("(pointer: coarse)").matches : typeof navigator !== "undefined" && (navigator.maxTouchPoints ?? 0) > 0,
285
+ gyro: typeof window !== "undefined" && "DeviceOrientationEvent" in window
286
+ };
287
+ }
288
+ /**
289
+ * The modes worth offering on this device, in display order. Touch needs a touch
290
+ * screen; VR and Cardboard additionally need the gyro. Desktop (no touch) gets an
291
+ * empty list — it uses keyboard/mouse and shows no mode picker at all.
292
+ */
293
+ function availableModes(caps) {
294
+ const modes = [];
295
+ if (caps.touch) modes.push("touch");
296
+ if (caps.touch && caps.gyro) modes.push("vr", "cardboard");
297
+ return modes;
298
+ }
299
+ //#endregion
144
300
  //#region src/components/environment/environment.ts
145
301
  /**
146
302
  * An environment panel: a button (e.g. a sun/cloud icon) opening a daisyUI modal
@@ -462,6 +618,286 @@ function infoPanel(options) {
462
618
  };
463
619
  }
464
620
  //#endregion
621
+ //#region src/components/help/diagrams.ts
622
+ const CLOSE = `</svg>`;
623
+ const DIAGRAMS = { cardboard: "<svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"currentColor\" role=\"img\" aria-hidden=\"true\"><path d=\"M9.26402604,18 C8.79719433,18.6226198 8.05922695,19 7.26393202,19 L4.5,19 C3.11928813,19 2,17.8807119 2,16.5 L2,9.5 C2,8.27576856 2.8799598,7.25706275 4.04188098,7.04188098 C4.25706275,5.8799598 5.27576856,5 6.5,5 L17.5,5 C18.7242314,5 19.7429373,5.8799598 19.958119,7.04188098 C21.1200402,7.25706275 22,8.27576856 22,9.5 L22,16.5 C22,17.8807119 20.8807119,19 19.5,19 L16.736068,19 C15.940773,19 15.2028057,18.6226198 14.735974,18 L9.26402604,18 Z M9.82576985,17 L14.1742301,17 C13.7685119,16.3807773 13.0746523,16 12.322949,16 L11.677051,16 C10.9253477,16 10.2314881,16.3807773 9.82576985,17 Z M5.08535285,7 L18.9146471,7 C18.7087289,6.41740381 18.1531094,6 17.5,6 L6.5,6 C5.84689059,6 5.29127106,6.41740381 5.08535285,7 Z M8.8027864,16.7763932 C9.34713196,15.6877021 10.4598573,15 11.677051,15 L12.322949,15 C13.5401427,15 14.652868,15.6877021 15.1972136,16.7763932 L15.3944272,17.1708204 C15.6485152,17.6789964 16.1679099,18 16.736068,18 L19.5,18 C20.3284271,18 21,17.3284271 21,16.5 L21,9.5 C21,8.67157288 20.3284271,8 19.5,8 L4.5,8 C3.67157288,8 3,8.67157288 3,9.5 L3,16.5 C3,17.3284271 3.67157288,18 4.5,18 L7.26393202,18 C7.83209011,18 8.35148479,17.6789964 8.60557281,17.1708204 L8.8027864,16.7763932 Z M7.5,15 C6.11928813,15 5,13.8807119 5,12.5 C5,11.1192881 6.11928813,10 7.5,10 C8.88071187,10 10,11.1192881 10,12.5 C10,13.8807119 8.88071187,15 7.5,15 Z M7.5,14 C8.32842712,14 9,13.3284271 9,12.5 C9,11.6715729 8.32842712,11 7.5,11 C6.67157288,11 6,11.6715729 6,12.5 C6,13.3284271 6.67157288,14 7.5,14 Z M16.5,15 C15.1192881,15 14,13.8807119 14,12.5 C14,11.1192881 15.1192881,10 16.5,10 C17.8807119,10 19,11.1192881 19,12.5 C19,13.8807119 17.8807119,15 16.5,15 Z M16.5,14 C17.3284271,14 18,13.3284271 18,12.5 C18,11.6715729 17.3284271,11 16.5,11 C15.6715729,11 15,11.6715729 15,12.5 C15,13.3284271 15.6715729,14 16.5,14 Z\"/>" + CLOSE };
624
+ /** Build the diagram figure node for a simple tab kind (a div wrapping the SVG). */
625
+ function diagramNode(kind) {
626
+ const figure = document.createElement("div");
627
+ figure.className = "text-base-content/70 mx-auto mb-3 h-28 w-full max-w-xs";
628
+ figure.innerHTML = DIAGRAMS[kind];
629
+ return figure;
630
+ }
631
+ const GP_LEFT_STICK = "<path d=\"M182.4,251.5c-28.9,0-52.5,23.5-52.5,52.5s23.5,52.5,52.5,52.5c28.9,0,52.5-23.5,52.5-52.5S211.3,251.5,182.4,251.5z M182.4,331.9c-15.4,0-28-12.5-28-28s12.5-28,28-28c15.4,0,28,12.5,28,28S197.8,331.9,182.4,331.9z\"/>";
632
+ const GP_RIGHT_STICK = "<path d=\"M382.1,303.9c0-28.9-23.5-52.5-52.5-52.5c-28.9,0-52.5,23.5-52.5,52.5s23.5,52.5,52.5,52.5C358.6,356.4,382.1,332.8,382.1,303.9z M329.6,331.9c-15.4,0-28-12.5-28-28s12.5-28,28-28c15.4,0,28,12.5,28,28S345.1,331.9,329.6,331.9z\"/>";
633
+ const GP_DPAD = "<path d=\"M159.7,132.6c-6.8,0-12.3,5.5-12.3,12.3V167h-22.1c-6.8,0-12.3,5.5-12.3,12.3s5.5,12.3,12.3,12.3h22.1v22.1c0,6.8,5.5,12.3,12.3,12.3s12.3-5.5,12.3-12.3v-22.1h22.1c6.8,0,12.3-5.5,12.3-12.3s-5.5-12.3-12.3-12.3H172v-22.1C172,138.1,166.5,132.6,159.7,132.6z\"/>";
634
+ const GP_BTN_TOP = `<circle cx="353.4" cy="147.8" r="16.6"/>`;
635
+ const GP_BTN_LEFT = `<circle cx="319.5" cy="181.8" r="16.6"/>`;
636
+ const GP_BTN_RIGHT = `<circle cx="387.4" cy="181.8" r="16.6"/>`;
637
+ const GP_BTN_BOTTOM = `<circle cx="353.4" cy="215.7" r="16.6"/>`;
638
+ const lit = (part) => `<g class="text-primary" fill="currentColor">${part}</g>`;
639
+ /** The gamepad icon with only the parts used by `highlight` lit in the accent. */
640
+ function gamepadSvg(highlight) {
641
+ const on = (part, when) => highlight === when ? lit(part) : part;
642
+ return "<svg viewBox=\"0 0 512 512\" width=\"100%\" height=\"100%\" fill=\"currentColor\" role=\"img\" aria-hidden=\"true\"><path d=\"M467.8,173.6c-8.4-50-49.7-87-100.6-90.1l-2.1-0.4c-0.7-0.1-1.4-0.2-2.1-0.2H151.5c-53.3,0-98.4,38.1-107.2,90.4L12,346.4c-1,4.7-1.5,9.4-1.5,14.1c0,37.8,30.7,68.5,68.5,68.5c22.8,0,44.1-11.3,56.6-29.9l15.6-21.6c4-5.5,2.7-13.1-2.8-17.1c-5.5-4-13.1-2.7-17.1,2.8l-15.8,21.9c-8.2,12.2-21.9,19.5-36.5,19.5c-24.3,0-44-19.8-44-44c0-3,0.3-6.1,1-9.4l32.3-173.5c6.8-40.7,41.8-70.2,83.1-70.2H362l1.7,0.3c0.5,0.1,0.9,0.1,1.4,0.2c39,2.1,72,31.5,78.5,69.9L476,351.4c0.6,3,0.9,6.1,0.9,9.1c0,24.3-19.8,44-44,44c-14.7,0-28.3-7.3-36.8-19.8l-16.8-23.2c-4-5.5-11.6-6.7-17.1-2.8c-5.5,4-6.7,11.6-2.8,17.1l16.5,22.9c12.8,18.9,34,30.2,56.8,30.2c37.8,0,68.5-30.7,68.5-68.5c0-4.7-0.5-9.5-1.4-13.9L467.8,173.6z\"/>" + on(GP_LEFT_STICK, "move") + on(GP_RIGHT_STICK, "look") + on(GP_DPAD, "move") + GP_BTN_TOP + GP_BTN_LEFT + GP_BTN_RIGHT + on(GP_BTN_BOTTOM, "select") + CLOSE;
643
+ }
644
+ const PHONE_ROTATE = "<svg viewBox=\"-8.04 0 36.945037 32.134997\" width=\"100%\" height=\"100%\" role=\"img\" aria-hidden=\"true\"><g fill=\"currentColor\" transform=\"translate(-614.78856,-194.333)\"><path d=\"m 630.278,196.333 a 0.855,0.855 0 0 1 0.854,0.854 v 26.427 a 0.855,0.855 0 0 1 -0.854,0.854 h -10.345 a 0.855,0.855 0 0 1 -0.854,-0.854 v -26.427 a 0.855,0.855 0 0 1 0.854,-0.854 h 10.345 m 0,-2 h -10.345 a 2.854,2.854 0 0 0 -2.854,2.854 v 26.427 a 2.854,2.854 0 0 0 2.854,2.854 h 10.345 a 2.854,2.854 0 0 0 2.854,-2.854 v -26.427 a 2.854,2.854 0 0 0 -2.854,-2.854 z\"/><path d=\"m 625.106,220.375 a 1.144,1.144 0 1 0 1.143,1.143 1.144,1.144 0 0 0 -1.143,-1.143 z\"/></g><path fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.1371\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"m 21.517396,7.653044 c 0.34046,0.09237 0.678922,0.2068044 1.0138,0.3440646 4.368435,1.7905494 6.458225,6.7833844 4.667677,11.1518194 -1.013391,2.472391 -3.052499,4.214862 -5.423632,4.937107 M -0.65235214,23.84002 C -0.99282974,23.74769 -1.3312767,23.633223 -1.6661557,23.495962 -6.0346112,21.705405 -8.1243897,16.712597 -6.3338324,12.344142 -5.3655121,9.981708 -3.4606611,8.2856913 -1.2244333,7.5094329 M -2.1519003,10.593762 -0.93612324,7.6276059 -1.2244333,7.5094329 M 24.686186,25.279184 21.77524,24.086036 m 0,0 -0.05514,-0.02261 1.215708,-2.966184 M -3.9022805,6.41183 -1.2244349,7.5094331\"/></svg>";
645
+ const thumbDot = (cx, cy) => `<g class="text-primary" fill="currentColor"><circle cx="${cx}" cy="${cy}" r="3"/></g>`;
646
+ const grabPhone = (light) => "<svg viewBox=\"-8.04 0 49.591388 22.890837\" width=\"100%\" height=\"100%\" role=\"img\" aria-hidden=\"true\"><g fill=\"currentColor\" transform=\"rotate(-90,219.77734,413.35469)\"><path d=\"m 633.13203,197.18696 a 2.854,2.854 0 0 0 -2.85402,-2.85394 h -8.64185 l 1.67971,1.99997 h 6.96214 a 0.855,0.855 0 0 1 0.85398,0.85397 V 223.614 a 0.855,0.855 0 0 1 -0.85398,0.85398 h -7.1771 l -1.67979,2.00004 h 8.85689 a 2.854,2.854 0 0 0 2.85402,-2.85402 z m -14.05302,1.1933 -1.82611,-2.17421 a 2.854,2.854 0 0 0 -0.17393,0.98091 V 223.614 a 2.854,2.854 0 0 0 0.11352,0.79686 l 1.88652,-2.24615 z\"/></g><path fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.73127\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"m -7.1743772,20.405591 6.27449192,1.61962 c 0,0 2.28297208,-1.746 3.01734048,-3.303819 0.6250483,-1.325918 1.2501011,-3.541343 1.2501011,-3.541343 0,0 4.9303549,-3.331849 4.990109,-4.64706 C 8.4038939,9.51548 7.4523448,8.44669 6.489279,8.115078 5.4553799,7.759076 -0.61569418,12.732235 -0.61569418,12.732235 L 1.7109732,10.62309 1.7633602,4.158494 c 0,0 -2.9599019,-0.1661 -5.3375374,2.108198 -1.407702,1.34652 -3.6002,14.138899 -3.6002,14.138899 z\"/><path fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.73127\" stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"m 40.685765,20.076393 -6.274492,1.61962 c 0,0 -2.282972,-1.746 -3.01734,-3.303819 -0.625049,-1.325918 -1.250101,-3.541343 -1.250101,-3.541343 0,0 -4.930355,-3.331849 -4.990109,-4.64706 -0.04623,-1.017509 0.90532,-2.086299 1.868386,-2.417911 1.033899,-0.356002 7.104973,4.617157 7.104973,4.617157 l -2.326667,-2.109145 -0.05239,-6.464596 c 0,0 2.959902,-0.1661 5.337537,2.108198 1.407702,1.34652 3.6002,14.138899 3.6002,14.138899 z\"/>" + (light === "move" ? thumbDot(8, 8) : "") + (light === "look" ? thumbDot(26, 8) : "") + `</svg>`;
647
+ const PHONE = {
648
+ rotate: PHONE_ROTATE,
649
+ hold: grabPhone(),
650
+ move: grabPhone("move"),
651
+ look: grabPhone("look")
652
+ };
653
+ const MOVE_STICK = "<svg viewBox=\"0 0 100 100\" width=\"100%\" height=\"100%\" role=\"img\" aria-hidden=\"true\"><circle cx=\"50\" cy=\"50\" r=\"30\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"6\"/><g class=\"text-primary\" fill=\"currentColor\"><circle cx=\"50\" cy=\"50\" r=\"12\"/></g><g fill=\"currentColor\"><path d=\"M50 12 l-7 10 h14 z\"/><path d=\"M50 88 l-7 -10 h14 z\"/><path d=\"M12 50 l10 -7 v14 z\"/><path d=\"M88 50 l-10 -7 v14 z\"/></g>" + CLOSE;
654
+ const KEY = 152;
655
+ const KEY_HALF = KEY / 2;
656
+ const KEY_UP = [262, 82];
657
+ const KEY_LEFT = [82, 259];
658
+ const KEY_DOWN = [262, 259];
659
+ const KEY_RIGHT = [442, 259];
660
+ const keyRect = ([cx, cy]) => `<rect x="${cx - KEY_HALF}" y="${cy - KEY_HALF}" width="${KEY}" height="${KEY}" rx="24" fill="none" stroke="currentColor" stroke-width="11"/>`;
661
+ const keyLetter = ([cx, cy], ch) => `<text x="${cx}" y="${cy}" font-family="system-ui, sans-serif" font-weight="800" font-size="96" text-anchor="middle" dominant-baseline="central" fill="currentColor" stroke="none">${ch}</text>`;
662
+ const keyArrow = ([cx, cy], dir) => {
663
+ const L = 42;
664
+ const H = 20;
665
+ const line = (d) => `<path d="${d}" fill="none" stroke="currentColor" stroke-width="11" stroke-linecap="round" stroke-linejoin="round"/>`;
666
+ switch (dir) {
667
+ case "up": return line(`M${cx} ${cy + L} V${cy - L}`) + line(`M${cx - H} ${cy - L + H} L${cx} ${cy - L} L${cx + H} ${cy - L + H}`);
668
+ case "down": return line(`M${cx} ${cy - L} V${cy + L}`) + line(`M${cx - H} ${cy + L - H} L${cx} ${cy + L} L${cx + H} ${cy + L - H}`);
669
+ case "left": return line(`M${cx + L} ${cy} H${cx - L}`) + line(`M${cx - L + H} ${cy - H} L${cx - L} ${cy} L${cx - L + H} ${cy + H}`);
670
+ default: return line(`M${cx - L} ${cy} H${cx + L}`) + line(`M${cx + L - H} ${cy - H} L${cx + L} ${cy} L${cx + L - H} ${cy + H}`);
671
+ }
672
+ };
673
+ const AWSD_LAYER = keyRect(KEY_UP) + keyLetter(KEY_UP, "W") + keyRect(KEY_LEFT) + keyLetter(KEY_LEFT, "A") + keyRect(KEY_DOWN) + keyLetter(KEY_DOWN, "S") + keyRect(KEY_RIGHT) + keyLetter(KEY_RIGHT, "D");
674
+ const KEYS = `<svg viewBox="0 0 524 741" width="100%" height="100%" role="img" aria-hidden="true"><g transform="translate(0 400)">${keyRect(KEY_UP) + keyArrow(KEY_UP, "up") + keyRect(KEY_LEFT) + keyArrow(KEY_LEFT, "left") + keyRect(KEY_DOWN) + keyArrow(KEY_DOWN, "down") + keyRect(KEY_RIGHT) + keyArrow(KEY_RIGHT, "right")}</g><g>${AWSD_LAYER}</g>` + CLOSE;
675
+ const MOUSE_BODY = "M8.5 0c-3.033 0-5.5 2.467-5.5 5.5v6c0 3.033 2.467 5.5 5.5 5.5s5.5-2.467 5.5-5.5v-6c0-3.033-2.467-5.5-5.5-5.5zM13 5.5v0.5h-4v-4.949c2.244 0.252 4 2.139 4 4.449zM8 1.051v4.949h-4v-0.5c0-2.31 1.756-4.197 4-4.449zM8.5 16c-2.481 0-4.5-2.019-4.5-4.5v-4.5h9v4.5c0 2.481-2.019 4.5-4.5 4.5z";
676
+ const MOUSE_LEFT_BTN = `M8 1.051v4.949h-4v-0.5c0-2.31 1.756-4.197 4-4.449z`;
677
+ const MOUSE_RIGHT_BTN = `M13 5.5v0.5h-4v-4.949c2.244 0.252 4 2.139 4 4.449z`;
678
+ const mouse = (light) => `<svg viewBox="0 0 17 17" width="100%" height="100%" role="img" aria-hidden="true">` + (light === "left" ? `<g class="text-primary" fill="currentColor"><path d="${MOUSE_LEFT_BTN}"/></g>` : "") + (light === "right" ? `<g class="text-primary" fill="currentColor"><path d="${MOUSE_RIGHT_BTN}"/></g>` : "") + `<path fill="currentColor" fill-rule="evenodd" d="${MOUSE_BODY}"/>` + CLOSE;
679
+ function figureFrom(svg) {
680
+ const figure = document.createElement("div");
681
+ figure.className = "text-base-content/70 h-16 w-16 shrink-0";
682
+ figure.innerHTML = svg;
683
+ return figure;
684
+ }
685
+ /**
686
+ * Icon factories for a tab whose pane is split into per-control sections, in the
687
+ * order its lines document them; `null` for single-diagram tabs. Each factory
688
+ * makes a fresh node (a DOM node can only live in one place).
689
+ */
690
+ function sectionFigures(kind) {
691
+ switch (kind) {
692
+ case "touch": return [
693
+ "rotate",
694
+ "hold",
695
+ "move",
696
+ "look"
697
+ ].map((step) => () => figureFrom(PHONE[step]));
698
+ case "vr": return [() => figureFrom(PHONE.rotate), () => figureFrom(MOVE_STICK)];
699
+ case "keyboard-mouse": return [
700
+ () => figureFrom(KEYS),
701
+ () => figureFrom(mouse("left")),
702
+ () => figureFrom(mouse("right"))
703
+ ];
704
+ case "gamepad": return [
705
+ "move",
706
+ "look",
707
+ "select"
708
+ ].map((h) => () => figureFrom(gamepadSvg(h)));
709
+ default: return null;
710
+ }
711
+ }
712
+ //#endregion
713
+ //#region src/components/help/modality.ts
714
+ /** Map a modality to the help tab that documents it. */
715
+ function modalityTab(modality) {
716
+ return modality;
717
+ }
718
+ function createModalityTracker() {
719
+ let last = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches ? "touch" : "keyboard-mouse";
720
+ const onTouch = () => {
721
+ last = "touch";
722
+ };
723
+ const onPointer = (e) => {
724
+ if (e.pointerType === "touch") return;
725
+ last = "keyboard-mouse";
726
+ };
727
+ const onKey = () => {
728
+ last = "keyboard-mouse";
729
+ };
730
+ const onPad = () => {
731
+ last = "gamepad";
732
+ };
733
+ window.addEventListener("touchstart", onTouch, { passive: true });
734
+ window.addEventListener("pointerdown", onPointer, { passive: true });
735
+ window.addEventListener("keydown", onKey);
736
+ window.addEventListener("gamepadconnected", onPad);
737
+ return {
738
+ get: () => last,
739
+ destroy: () => {
740
+ window.removeEventListener("touchstart", onTouch);
741
+ window.removeEventListener("pointerdown", onPointer);
742
+ window.removeEventListener("keydown", onKey);
743
+ window.removeEventListener("gamepadconnected", onPad);
744
+ }
745
+ };
746
+ }
747
+ //#endregion
748
+ //#region src/components/help/help.ts
749
+ /**
750
+ * Build a help tab's pane body and return the text nodes to relocalize. Most tabs
751
+ * are one diagram above a bullet list. Touch and gamepad are split into sections:
752
+ * one icon per control (with just that part lit) next to its instruction, so the
753
+ * visitor sees which part does what; any extra lines (e.g. "connect a controller")
754
+ * stay as notes below.
755
+ */
756
+ function buildPane(pane, tab) {
757
+ const figures = sectionFigures(tab.kind);
758
+ if (figures) return buildSectionedPane(pane, tab.lines, figures);
759
+ pane.appendChild(diagramNode(tab.kind));
760
+ const list = document.createElement(tab.ordered ? "ol" : "ul");
761
+ list.className = `${tab.ordered ? "list-decimal" : "list-disc"} list-outside pl-5 space-y-1.5 text-sm leading-relaxed`;
762
+ const lineEls = tab.lines.map((line) => {
763
+ const el = document.createElement("li");
764
+ list.appendChild(el);
765
+ return {
766
+ el,
767
+ line
768
+ };
769
+ });
770
+ pane.appendChild(list);
771
+ return lineEls;
772
+ }
773
+ function buildSectionedPane(pane, lines, figures) {
774
+ const lineEls = [];
775
+ figures.forEach((makeFigure, i) => {
776
+ const line = lines[i];
777
+ if (!line) return;
778
+ const row = document.createElement("div");
779
+ row.className = "flex items-center gap-4 mb-3";
780
+ row.appendChild(makeFigure());
781
+ const p = document.createElement("p");
782
+ p.className = "text-sm leading-relaxed";
783
+ row.appendChild(p);
784
+ pane.appendChild(row);
785
+ lineEls.push({
786
+ el: p,
787
+ line
788
+ });
789
+ });
790
+ const notes = lines.slice(figures.length);
791
+ if (notes.length > 0) {
792
+ const list = document.createElement("ul");
793
+ list.className = "list-disc list-outside pl-5 space-y-1.5 text-sm leading-relaxed text-base-content/70";
794
+ for (const line of notes) {
795
+ const el = document.createElement("li");
796
+ list.appendChild(el);
797
+ lineEls.push({
798
+ el,
799
+ line
800
+ });
801
+ }
802
+ pane.appendChild(list);
803
+ }
804
+ return lineEls;
805
+ }
806
+ let instanceCount = 0;
807
+ /**
808
+ * A visual controls-help panel: an ⓘ button opening a centered daisyUI modal
809
+ * with one tab per input mode, each showing an SVG diagram and a few localized
810
+ * lines. On open it pre-selects the tab for the mode you're using. Diagrams are
811
+ * built in (standard schemes); the text is app-supplied `Text`, relocalized live.
812
+ */
813
+ function helpPanel(options) {
814
+ const autoSelect = options.autoSelect ?? true;
815
+ const groupName = `sg-help-tabs-${instanceCount += 1}`;
816
+ return (ctx) => {
817
+ const wrap = document.createElement("div");
818
+ const button = document.createElement("button");
819
+ button.type = "button";
820
+ button.className = "btn btn-circle btn-sm";
821
+ setIcon(button, lucideIcon("Info"));
822
+ const dialog = document.createElement("dialog");
823
+ dialog.className = "modal";
824
+ const box = document.createElement("div");
825
+ box.className = "modal-box max-w-md";
826
+ const close = document.createElement("button");
827
+ close.type = "button";
828
+ close.className = "btn btn-sm btn-circle btn-ghost absolute right-2 top-2";
829
+ close.textContent = "✕";
830
+ box.appendChild(close);
831
+ const heading = document.createElement("h3");
832
+ heading.className = "text-lg font-bold mb-4";
833
+ box.appendChild(heading);
834
+ const tablist = document.createElement("div");
835
+ tablist.setAttribute("role", "tablist");
836
+ tablist.className = "tabs tabs-border";
837
+ box.appendChild(tablist);
838
+ const relabels = [];
839
+ const tabInputs = /* @__PURE__ */ new Map();
840
+ let firstInput = null;
841
+ for (const tab of options.tabs) {
842
+ const input = document.createElement("input");
843
+ input.type = "radio";
844
+ input.name = groupName;
845
+ input.role = "tab";
846
+ input.className = "tab";
847
+ if (!firstInput) {
848
+ input.checked = true;
849
+ firstInput = input;
850
+ }
851
+ const pane = document.createElement("div");
852
+ pane.className = "tab-content pt-4";
853
+ const lineEls = buildPane(pane, tab);
854
+ tablist.append(input, pane);
855
+ tabInputs.set(tab.kind, input);
856
+ relabels.push(() => {
857
+ input.setAttribute("aria-label", resolveText(tab.title));
858
+ for (const { el, line } of lineEls) el.textContent = resolveText(line);
859
+ });
860
+ }
861
+ const backdrop = document.createElement("form");
862
+ backdrop.method = "dialog";
863
+ backdrop.className = "modal-backdrop";
864
+ const backdropBtn = document.createElement("button");
865
+ backdropBtn.textContent = "close";
866
+ backdrop.appendChild(backdropBtn);
867
+ dialog.append(box, backdrop);
868
+ wrap.append(button, dialog);
869
+ const tracker = createModalityTracker();
870
+ const unbind = bindLocale(ctx, () => {
871
+ const title = resolveText(options.title);
872
+ button.setAttribute("aria-label", title);
873
+ heading.textContent = title;
874
+ close.setAttribute("aria-label", resolveText(options.closeLabel ?? "Close"));
875
+ for (const relabel of relabels) relabel();
876
+ });
877
+ const onOpen = () => {
878
+ if (autoSelect) {
879
+ const mode = ctx.gallery.ctx.viewControls.getMode();
880
+ const kind = mode === "cardboard" ? "cardboard" : mode === "vr" ? "vr" : modalityTab(tracker.get());
881
+ const target = tabInputs.get(kind) ?? firstInput;
882
+ if (target) target.checked = true;
883
+ }
884
+ dialog.showModal();
885
+ };
886
+ const onClose = () => dialog.close();
887
+ button.addEventListener("click", onOpen);
888
+ close.addEventListener("click", onClose);
889
+ return {
890
+ element: wrap,
891
+ destroy: () => {
892
+ button.removeEventListener("click", onOpen);
893
+ close.removeEventListener("click", onClose);
894
+ tracker.destroy();
895
+ unbind();
896
+ }
897
+ };
898
+ };
899
+ }
900
+ //#endregion
465
901
  //#region src/components/artwork-info/artwork-info.ts
466
902
  const AVAILABILITY_COLOR = {
467
903
  available: "#22c55e",
@@ -480,7 +916,7 @@ function ensureStyles() {
480
916
  if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
481
917
  const style = document.createElement("style");
482
918
  style.id = STYLE_ID;
483
- style.textContent = `.sg-artwork-panel{position:absolute;left:0;top:0;height:100%;width:${PANEL_FRACTION * 100}%;box-sizing:border-box;background:rgba(0,0,0,0.85);color:#fff;padding:2.5rem;z-index:10;overflow-y:auto;display:none;flex-direction:column;gap:0.75rem;pointer-events:auto;font-family:system-ui,sans-serif;}.sg-artwork-panel--open{display:flex;}.sg-artwork-panel__close{align-self:flex-end;background:none;border:none;color:#fff;font-size:1.5rem;line-height:1;cursor:pointer;opacity:0.7;padding:0;}.sg-artwork-panel__close:hover{opacity:1;}.sg-artwork-panel__title{font-size:1.75rem;font-weight:700;margin:0;}.sg-artwork-panel__meta{display:flex;flex-direction:column;gap:0.15rem;font-size:0.9rem;opacity:0.8;}.sg-artwork-panel__status{display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;font-weight:600;}.sg-artwork-panel__dot{width:0.7rem;height:0.7rem;border-radius:50%;flex:none;}.sg-artwork-panel__desc{font-size:1rem;line-height:1.6;margin-top:0.5rem;white-space:pre-line;}.sg-artwork-panel__link{align-self:flex-start;margin-top:0.5rem;display:inline-flex;align-items:center;gap:0.4rem;color:#fff;text-decoration:underline;font-size:0.95rem;}.sg-artwork-panel__link:hover{opacity:0.8;}`;
919
+ style.textContent = `.sg-artwork-panel{position:absolute;left:0;top:0;height:100%;width:${PANEL_FRACTION * 100}%;box-sizing:border-box;background:rgba(0,0,0,0.85);color:#fff;padding:2.5rem;z-index:10;overflow-y:auto;display:none;flex-direction:column;gap:0.75rem;pointer-events:auto;font-family:system-ui,sans-serif;}.sg-artwork-panel--open{display:flex;}.sg-artwork-panel__close{align-self:flex-end;background:none;border:none;color:#fff;font-size:1.5rem;line-height:1;cursor:pointer;opacity:0.7;padding:0;}.sg-artwork-panel__close:hover{opacity:1;}.sg-artwork-panel__title{font-size:1.75rem;font-weight:700;margin:0;}.sg-artwork-panel__meta{display:flex;flex-direction:column;gap:0.15rem;font-size:0.9rem;opacity:0.8;}.sg-artwork-panel__status{display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;font-weight:600;}.sg-artwork-panel__dot{width:0.7rem;height:0.7rem;border-radius:50%;flex:none;}.sg-artwork-panel__desc{font-size:1rem;line-height:1.6;margin-top:0.5rem;white-space:pre-line;}.sg-artwork-panel__link{align-self:flex-start;margin-top:0.5rem;display:inline-flex;align-items:center;gap:0.4rem;color:#fff;text-decoration:underline;font-size:0.95rem;}.sg-artwork-panel__link:hover{opacity:0.8;}.sg-artwork-panel__nav{display:flex;gap:0.75rem;margin-top:auto;padding-top:1.5rem;}.sg-artwork-panel__navbtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:0.4rem;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.25);color:#fff;border-radius:0.5rem;padding:0.6rem 0.8rem;font-size:0.95rem;cursor:pointer;font-family:inherit;}.sg-artwork-panel__navbtn:hover{background:rgba(255,255,255,0.2);}`;
484
920
  document.head.appendChild(style);
485
921
  }
486
922
  const join = (parts, sep) => parts.filter((p) => Boolean(p)).join(sep);
@@ -488,15 +924,19 @@ const join = (parts, sep) => parts.filter((p) => Boolean(p)).join(sep);
488
924
  * The artwork info panel. The engine emits `artwork:select { id }` when you click
489
925
  * a piece's cartela (or the piece itself); this opens a left-hand panel (40%
490
926
  * width, full height, black at 85% alpha) with the full caption and asks the
491
- * camera to frame the piece in the free right 60%. Closing with ✕ re-centers the
492
- * piece full-width; if the user walks/turns away the engine emits
493
- * `artwork:released` and the panel just closes (no camera move).
927
+ * camera to frame the piece in the free right 60%. Prev/next buttons at the
928
+ * bottom cycle through the pieces (wrapping) without leaving the panel. Closing
929
+ * with ✕ re-centers the piece full-width, pulling back to `minCloseDistance` when
930
+ * the framing would otherwise be too close; if the user walks/turns away the
931
+ * engine emits `artwork:released` and the panel just closes (no camera move).
494
932
  *
495
933
  * Content is data (`artworks`), unlocalized for now. The panel is mounted on the
496
934
  * surface (escaping the overlay grid); the slot element is an inert placeholder.
497
935
  */
498
936
  function artworkInfo(options) {
499
- const byId = new Map(options.artworks.map((a) => [a.id, a]));
937
+ const list = options.artworks;
938
+ const byId = new Map(list.map((a) => [a.id, a]));
939
+ const indexOf = new Map(list.map((a, i) => [a.id, i]));
500
940
  return (ctx) => {
501
941
  ensureStyles();
502
942
  const camera = ctx.gallery.ctx.camera;
@@ -524,7 +964,23 @@ function artworkInfo(options) {
524
964
  link.target = "_blank";
525
965
  link.rel = "noopener noreferrer";
526
966
  link.textContent = `${options.linkText ?? "View artwork"} ↗`;
527
- panel.append(close, title, meta, status, desc, link);
967
+ const nav = document.createElement("div");
968
+ nav.className = "sg-artwork-panel__nav";
969
+ const prevBtn = document.createElement("button");
970
+ prevBtn.type = "button";
971
+ prevBtn.className = "sg-artwork-panel__navbtn";
972
+ const prevText = options.prevLabel ?? "Previous";
973
+ prevBtn.textContent = `‹ ${prevText}`;
974
+ prevBtn.setAttribute("aria-label", prevText);
975
+ const nextBtn = document.createElement("button");
976
+ nextBtn.type = "button";
977
+ nextBtn.className = "sg-artwork-panel__navbtn";
978
+ const nextText = options.nextLabel ?? "Next";
979
+ nextBtn.textContent = `${nextText} ›`;
980
+ nextBtn.setAttribute("aria-label", nextText);
981
+ nav.append(prevBtn, nextBtn);
982
+ if (list.length <= 1) nav.style.display = "none";
983
+ panel.append(close, title, meta, status, desc, link, nav);
528
984
  ctx.surface.appendChild(panel);
529
985
  let currentId = null;
530
986
  const render = (artwork) => {
@@ -553,18 +1009,29 @@ function artworkInfo(options) {
553
1009
  panel.classList.remove("sg-artwork-panel--open");
554
1010
  currentId = null;
555
1011
  };
556
- const onSelect = (payload) => {
557
- const id = payload?.id;
558
- const artwork = id ? byId.get(id) : void 0;
559
- if (!artwork || !id) return;
1012
+ const show = (id) => {
1013
+ const artwork = byId.get(id);
1014
+ if (!artwork) return;
560
1015
  currentId = id;
561
1016
  render(artwork);
1017
+ panel.scrollTop = 0;
562
1018
  panel.classList.add("sg-artwork-panel--open");
563
1019
  camera.focusArtwork(id, {
564
1020
  centerX: CENTER_X,
565
1021
  widthFraction: FREE_FRACTION
566
1022
  });
567
1023
  };
1024
+ const step = (delta) => {
1025
+ if (currentId === null || list.length <= 1) return;
1026
+ const i = indexOf.get(currentId);
1027
+ if (i === void 0) return;
1028
+ const next = list[(i + delta + list.length) % list.length];
1029
+ if (next) show(next.id);
1030
+ };
1031
+ const onSelect = (payload) => {
1032
+ const id = payload?.id;
1033
+ if (id) show(id);
1034
+ };
568
1035
  const onReleased = (payload) => {
569
1036
  const id = payload?.id;
570
1037
  if (id && id === currentId) hide();
@@ -572,11 +1039,15 @@ function artworkInfo(options) {
572
1039
  const onClose = () => {
573
1040
  const id = currentId;
574
1041
  hide();
575
- if (id) camera.centerArtwork(id);
1042
+ if (id) camera.centerArtwork(id, { minDistance: options.minCloseDistance ?? 0 });
576
1043
  };
1044
+ const onPrev = () => step(-1);
1045
+ const onNext = () => step(1);
577
1046
  const offSelect = ctx.gallery.on("artwork:select", onSelect);
578
1047
  const offReleased = ctx.gallery.on("artwork:released", onReleased);
579
1048
  close.addEventListener("click", onClose);
1049
+ prevBtn.addEventListener("click", onPrev);
1050
+ nextBtn.addEventListener("click", onNext);
580
1051
  const placeholder = document.createElement("div");
581
1052
  placeholder.style.display = "none";
582
1053
  return {
@@ -585,6 +1056,8 @@ function artworkInfo(options) {
585
1056
  offSelect();
586
1057
  offReleased();
587
1058
  close.removeEventListener("click", onClose);
1059
+ prevBtn.removeEventListener("click", onPrev);
1060
+ nextBtn.removeEventListener("click", onNext);
588
1061
  panel.remove();
589
1062
  }
590
1063
  };
@@ -684,39 +1157,83 @@ function settingsPanel(options) {
684
1157
  field.append(label, select);
685
1158
  addTab(lang.tab, lang.label, field);
686
1159
  }
687
- if (options.handedness) {
688
- const hand = options.handedness;
1160
+ if (options.handedness || options.controls) {
689
1161
  const pane = document.createElement("div");
690
- const legend = document.createElement("div");
691
- legend.className = "label-text mb-1 block";
692
- pane.appendChild(legend);
693
- const onChange = (e) => hand.onChange(e.target.value);
694
- const texts = [];
695
- for (const opt of hand.options) {
696
- const row = document.createElement("label");
697
- row.className = "label cursor-pointer justify-start gap-3 py-1";
698
- const input = document.createElement("input");
699
- input.type = "radio";
700
- input.name = "sg-settings-handedness";
701
- input.value = opt.value;
702
- input.className = "radio radio-sm";
703
- input.checked = opt.value === hand.value;
704
- input.addEventListener("change", onChange);
705
- cleanups.push(() => input.removeEventListener("change", onChange));
706
- const text = document.createElement("span");
707
- text.className = "label-text text-sm";
708
- row.append(input, text);
709
- pane.appendChild(row);
710
- texts.push({
711
- el: text,
712
- label: opt.label
1162
+ if (options.handedness) {
1163
+ const hand = options.handedness;
1164
+ const legend = document.createElement("div");
1165
+ legend.className = "label-text mb-1 block";
1166
+ pane.appendChild(legend);
1167
+ const onChange = (e) => hand.onChange(e.target.value);
1168
+ const texts = [];
1169
+ for (const opt of hand.options) {
1170
+ const row = document.createElement("label");
1171
+ row.className = "label cursor-pointer justify-start gap-3 py-1";
1172
+ const input = document.createElement("input");
1173
+ input.type = "radio";
1174
+ input.name = "sg-settings-handedness";
1175
+ input.value = opt.value;
1176
+ input.className = "radio radio-sm";
1177
+ input.checked = opt.value === hand.value;
1178
+ input.addEventListener("change", onChange);
1179
+ cleanups.push(() => input.removeEventListener("change", onChange));
1180
+ const text = document.createElement("span");
1181
+ text.className = "label-text text-sm";
1182
+ row.append(input, text);
1183
+ pane.appendChild(row);
1184
+ texts.push({
1185
+ el: text,
1186
+ label: opt.label
1187
+ });
1188
+ }
1189
+ relabels.push(() => {
1190
+ legend.textContent = resolveText(hand.label);
1191
+ for (const { el, label } of texts) el.textContent = resolveText(label);
713
1192
  });
714
1193
  }
715
- relabels.push(() => {
716
- legend.textContent = resolveText(hand.label);
717
- for (const { el, label } of texts) el.textContent = resolveText(label);
718
- });
719
- addTab(hand.tab, hand.label, pane);
1194
+ if (options.controls) {
1195
+ const cfg = options.controls;
1196
+ const viewControls = ctx.gallery.ctx.viewControls;
1197
+ const legend = document.createElement("div");
1198
+ legend.className = `label-text mb-1 block${options.handedness ? " mt-3" : ""}`;
1199
+ pane.appendChild(legend);
1200
+ const list = document.createElement("div");
1201
+ list.className = "flex flex-col gap-1";
1202
+ pane.appendChild(list);
1203
+ const rows = [];
1204
+ for (const item of cfg.items) {
1205
+ const row = document.createElement("label");
1206
+ row.className = "label cursor-pointer justify-start gap-3 py-1";
1207
+ const input = document.createElement("input");
1208
+ input.type = "checkbox";
1209
+ input.className = "toggle toggle-sm";
1210
+ input.checked = viewControls.get()[item.key];
1211
+ const onChange = () => viewControls.setFlag(item.key, input.checked);
1212
+ input.addEventListener("change", onChange);
1213
+ cleanups.push(() => input.removeEventListener("change", onChange));
1214
+ const text = document.createElement("span");
1215
+ text.className = "label-text text-sm";
1216
+ row.append(input, text);
1217
+ list.appendChild(row);
1218
+ rows.push({
1219
+ input,
1220
+ key: item.key,
1221
+ el: text,
1222
+ label: item.label
1223
+ });
1224
+ }
1225
+ const sync = () => {
1226
+ const flags = viewControls.get();
1227
+ for (const { input, key } of rows) input.checked = flags[key];
1228
+ };
1229
+ cleanups.push(ctx.gallery.on("controls:change", sync));
1230
+ relabels.push(() => {
1231
+ legend.textContent = resolveText(cfg.label);
1232
+ for (const { el, label } of rows) el.textContent = resolveText(label);
1233
+ });
1234
+ }
1235
+ const section = options.handedness ?? options.controls;
1236
+ addTab(section.tab, section.label, pane);
720
1237
  }
721
1238
  if (options.graphics) {
722
1239
  const gfx = options.graphics;
@@ -869,14 +1386,30 @@ function defaultPreset(overlay, options = {}) {
869
1386
  enter: lucideIcon("Maximize"),
870
1387
  exit: lucideIcon("Minimize")
871
1388
  } }));
1389
+ if (options.resetView) overlay.add("top-right", resetViewButton({
1390
+ icon: lucideIcon("House"),
1391
+ ...typeof options.resetView === "object" ? options.resetView : {}
1392
+ }));
1393
+ if (options.viewMode) {
1394
+ const supported = availableModes(detectControlCapabilities());
1395
+ const modes = options.viewMode.modes.filter((m) => supported.includes(m.mode));
1396
+ if (modes.length >= 2) overlay.add("top-right", viewModeButton({
1397
+ ...options.viewMode,
1398
+ modes
1399
+ }));
1400
+ }
872
1401
  if (options.settings) overlay.add("top-right", settingsPanel(options.settings));
873
1402
  if (options.environment) overlay.add("top-right", environmentPanel({
874
1403
  icon: lucideIcon("CloudSun"),
875
1404
  ...options.environment
876
1405
  }));
877
- overlay.add("top-right", cardboardButton({ icon: lucideIcon("RectangleGoggles") }));
1406
+ if (options.cardboard) overlay.add("top-right", cardboardButton({
1407
+ icon: lucideIcon("RectangleGoggles"),
1408
+ ...typeof options.cardboard === "object" ? options.cardboard : {}
1409
+ }));
878
1410
  if (options.artworks) overlay.add("middle-left", artworkInfo(options.artworks));
879
- if (options.info) overlay.add("bottom-right", infoPanel(options.info));
1411
+ if (options.help) overlay.add("bottom-right", helpPanel(options.help));
1412
+ else if (options.info) overlay.add("bottom-right", infoPanel(options.info));
880
1413
  if (options.credits) overlay.add("bottom-right", creditsPanel({
881
1414
  items: options.credits,
882
1415
  title: options.creditsTitle,
@@ -885,6 +1418,6 @@ function defaultPreset(overlay, options = {}) {
885
1418
  if (options.watermark !== false) overlay.add("bottom-left", watermark(options.watermark ?? {}));
886
1419
  }
887
1420
  //#endregion
888
- export { actionButton, artworkInfo, cardboardButton, creditsPanel, defaultPreset, environmentPanel, fullscreenButton, infoPanel, lucideIcon, muteButton, settingsPanel, watermark };
1421
+ export { actionButton, artworkInfo, availableModes, cardboardButton, creditsPanel, defaultPreset, detectControlCapabilities, environmentPanel, fullscreenButton, helpPanel, infoPanel, lucideIcon, muteButton, resetViewButton, settingsPanel, viewModeButton, watermark };
889
1422
 
890
1423
  //# sourceMappingURL=index.mjs.map