@johnmorrisdotca/kazoku-ui 0.7.2 → 0.9.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.
@@ -0,0 +1,34 @@
1
+ /** The GL object kinds counted. */
2
+ export declare const GPU_KINDS: readonly ["Buffer", "Texture", "VertexArray", "Program", "Framebuffer", "Renderbuffer"];
3
+ export type GpuKind = (typeof GPU_KINDS)[number];
4
+ export interface GlCounts {
5
+ /** WebGL contexts made. */
6
+ contexts: number;
7
+ /** Draw calls (drawArrays, drawElements and their instanced forms). */
8
+ draws: number;
9
+ /** `requestAnimationFrame` calls made, and the callbacks that ran. */
10
+ rafRequests: number;
11
+ rafCallbacks: number;
12
+ created: Record<GpuKind, number>;
13
+ deleted: Record<GpuKind, number>;
14
+ }
15
+ /** Install the counters in a page, before its own scripts run. A string, so `page.addInitScript(COUNT_SCRIPT)` takes it as it is. */
16
+ export declare const COUNT_SCRIPT: string;
17
+ /** What the page needs to be: Playwright's `Page` fits. */
18
+ export interface PageLike {
19
+ evaluate<R>(fn: () => R): Promise<R>;
20
+ }
21
+ /** The counters now, as a copy. */
22
+ export declare function readCounts(page: PageLike): Promise<GlCounts>;
23
+ /** GPU objects alive: made minus deleted, over every kind. Flat across a run of changes means nothing leaks. */
24
+ export declare function gpuObjects(counts: GlCounts): number;
25
+ export interface CanvasSize {
26
+ /** The most pixels any canvas on the page holds, and the longest side of any. */
27
+ pixels: number;
28
+ side: number;
29
+ count: number;
30
+ }
31
+ /** The biggest canvas on the page, in device pixels. */
32
+ export declare function canvasSize(page: PageLike): Promise<CanvasSize>;
33
+ /** True when a canvas is within the budget (the renderer's own: 6 million pixels, 4096 a side). */
34
+ export declare function withinPixelBudget(size: CanvasSize, maxPixels?: number, maxSide?: number): boolean;
@@ -0,0 +1,64 @@
1
+ // A test kit for a package's freeze checks, so every 3D package asserts the same things: how many WebGL contexts, how many GPU objects stay alive, how many
2
+ // draws and animation frames a page at rest costs, and how big its biggest canvas is. It names no test framework: hand it Playwright's `page` (or anything with
3
+ // `addInitScript` and `evaluate`). Nothing here touches the DOM until the script runs in a page, so it imports on a server.
4
+ //
5
+ // await page.addInitScript(COUNT_SCRIPT); // before page.goto
6
+ // await page.goto(url);
7
+ // const before = await readCounts(page);
8
+ // ...the changes under test...
9
+ // const after = await readCounts(page);
10
+ // expect(after.contexts).toBe(1);
11
+ // expect(after.draws - before.draws).toBe(0); // at rest
12
+ // expect(gpuObjects(after)).toBe(gpuObjects(before)); // nothing leaked
13
+ /** The GL object kinds counted. */
14
+ export const GPU_KINDS = ["Buffer", "Texture", "VertexArray", "Program", "Framebuffer", "Renderbuffer"];
15
+ /** Install the counters in a page, before its own scripts run. A string, so `page.addInitScript(COUNT_SCRIPT)` takes it as it is. */
16
+ export const COUNT_SCRIPT = `(() => {
17
+ const c = (window.__kazokuCount = { contexts: 0, draws: 0, rafRequests: 0, rafCallbacks: 0, created: {}, deleted: {} });
18
+ const kinds = ${JSON.stringify(GPU_KINDS)};
19
+ for (const k of kinds) { c.created[k] = 0; c.deleted[k] = 0; }
20
+ const seen = new WeakSet();
21
+ for (const proto of [window.HTMLCanvasElement && HTMLCanvasElement.prototype, window.OffscreenCanvas && OffscreenCanvas.prototype]) {
22
+ if (!proto) continue;
23
+ const getContext = proto.getContext;
24
+ proto.getContext = function (type, ...rest) {
25
+ const context = getContext.call(this, type, ...rest);
26
+ if (context && /^(experimental-)?webgl/.test(String(type)) && !seen.has(context)) { seen.add(context); c.contexts += 1; }
27
+ return context;
28
+ };
29
+ }
30
+ for (const proto of [window.WebGLRenderingContext && WebGLRenderingContext.prototype, window.WebGL2RenderingContext && WebGL2RenderingContext.prototype]) {
31
+ if (!proto) continue;
32
+ for (const k of kinds) {
33
+ const make = proto["create" + k];
34
+ const remove = proto["delete" + k];
35
+ if (make) proto["create" + k] = function (...a) { c.created[k] += 1; return make.apply(this, a); };
36
+ if (remove) proto["delete" + k] = function (o) { if (o) c.deleted[k] += 1; return remove.call(this, o); };
37
+ }
38
+ for (const name of ["drawArrays", "drawElements", "drawArraysInstanced", "drawElementsInstanced", "drawRangeElements"]) {
39
+ const draw = proto[name];
40
+ if (draw) proto[name] = function (...a) { c.draws += 1; return draw.apply(this, a); };
41
+ }
42
+ }
43
+ const raf = window.requestAnimationFrame.bind(window);
44
+ window.requestAnimationFrame = (callback) => { c.rafRequests += 1; return raf((t) => { c.rafCallbacks += 1; callback(t); }); };
45
+ })();`;
46
+ /** The counters now, as a copy. */
47
+ export function readCounts(page) {
48
+ return page.evaluate(() => JSON.parse(JSON.stringify(window.__kazokuCount)));
49
+ }
50
+ /** GPU objects alive: made minus deleted, over every kind. Flat across a run of changes means nothing leaks. */
51
+ export function gpuObjects(counts) {
52
+ return GPU_KINDS.reduce((sum, kind) => sum + counts.created[kind] - counts.deleted[kind], 0);
53
+ }
54
+ /** The biggest canvas on the page, in device pixels. */
55
+ export function canvasSize(page) {
56
+ return page.evaluate(() => {
57
+ const all = [...document.querySelectorAll("canvas")];
58
+ return { pixels: Math.max(0, ...all.map((c) => c.width * c.height)), side: Math.max(0, ...all.map((c) => Math.max(c.width, c.height))), count: all.length };
59
+ });
60
+ }
61
+ /** True when a canvas is within the budget (the renderer's own: 6 million pixels, 4096 a side). */
62
+ export function withinPixelBudget(size, maxPixels = 6_000_000, maxSide = 4096) {
63
+ return size.pixels <= maxPixels && size.side <= maxSide;
64
+ }
@@ -1,3 +1,4 @@
1
+ import { type Pace } from "./levels.ts";
1
2
  import { type Limits } from "./math.ts";
2
3
  export type DragMode = "move" | "turn";
3
4
  export interface AddressKeys {
@@ -5,6 +6,8 @@ export interface AddressKeys {
5
6
  tilt: string;
6
7
  zoom: string;
7
8
  drag: string;
9
+ /** The auto-turn speed (`slow` or `fast`; Medium is the start and leaves the address). */
10
+ speed?: string;
8
11
  }
9
12
  export declare const DEFAULT_KEYS: AddressKeys;
10
13
  export interface AddressChoices {
@@ -12,6 +15,7 @@ export interface AddressChoices {
12
15
  tilt: number;
13
16
  zoom: number;
14
17
  drag: DragMode;
18
+ pace?: Pace;
15
19
  }
16
20
  /** Read the choices out of a query string (`?yaw=40`, with or without the `?`). Anything missing or not a number is left out of the result. */
17
21
  export declare function parseAddress(search: string, keys?: AddressKeys, limits?: Limits): Partial<AddressChoices>;
@@ -1,7 +1,8 @@
1
1
  // The choices kept in the page's address: pure string functions, so a server or a test can use them. Only what a person chose is kept
2
2
  // (angle, tilt, zoom, what a drag does); the pan is in pixels of one screen and the turning of a moment, so neither is.
3
+ import { parsePace } from "./levels.js";
3
4
  import { clamp, DEFAULT_LIMITS, wrapYaw } from "./math.js";
4
- export const DEFAULT_KEYS = { yaw: "yaw", tilt: "tilt", zoom: "zoom", drag: "drag" };
5
+ export const DEFAULT_KEYS = { yaw: "yaw", tilt: "tilt", zoom: "zoom", drag: "drag", speed: "speed" };
5
6
  const rounded = (value, places) => Number(value.toFixed(places));
6
7
  /** Read the choices out of a query string (`?yaw=40`, with or without the `?`). Anything missing or not a number is left out of the result. */
7
8
  export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMITS) {
@@ -26,6 +27,9 @@ export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMIT
26
27
  const drag = params.get(keys.drag);
27
28
  if (drag === "turn" || drag === "move")
28
29
  out.drag = drag;
30
+ const pace = parsePace(params.get(keys.speed ?? "speed"));
31
+ if (pace !== undefined)
32
+ out.pace = pace;
29
33
  return out;
30
34
  }
31
35
  /**
@@ -45,6 +49,10 @@ export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = f
45
49
  set(keys.tilt, Math.round(choices.tilt) === 0 ? undefined : String(rounded(choices.tilt, 0)));
46
50
  set(keys.zoom, Math.abs(choices.zoom - startZoom) < 0.005 ? undefined : String(rounded(choices.zoom, 2)));
47
51
  set(keys.drag, choices.drag === "turn" ? "turn" : undefined);
52
+ // The speed key is touched only when the caller has a pace, and a value that is not a pace word (a page's own `speed`) is left alone.
53
+ const speedKey = keys.speed ?? "speed";
54
+ if (choices.pace !== undefined && (choices.pace !== "medium" || parsePace(params.get(speedKey)) !== undefined))
55
+ set(speedKey, choices.pace === "medium" ? undefined : choices.pace);
48
56
  const text = params.toString();
49
57
  return text === "" ? "" : `?${text}`;
50
58
  }
@@ -13,6 +13,7 @@ export interface ViewChangeDetail extends ViewState {
13
13
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
14
14
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
15
15
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
16
+ * `controls` (`basic`, the default, or `advanced`: which controls are shown; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
16
17
  * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
17
18
  * or dark), `overlay` (sit over the bottom of a positioned parent).
18
19
  */
@@ -2,9 +2,11 @@
2
2
  // to the element named by `for`, and tells the page what the view is now with a `kazoku-viewchange` event; the page applies it to its canvas, WebGL, SVG or CSS 3D.
3
3
  import { createAddressWriter } from "../address-state/address.js";
4
4
  import { adoptStyles } from "../shared/adopt.js";
5
+ import { localStore, remember } from "../shared/store.js";
5
6
  import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
6
7
  import { createViewController, DEFAULT_IGNORE } from "./controller.js";
7
8
  import { EVENT, TAG } from "./index.js";
9
+ import { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, zoomText } from "./levels.js";
8
10
  import { ZOOM_STEP, ZOOMED } from "./math.js";
9
11
  import { STYLES } from "./styles.js";
10
12
  import { languageOf, wordsFor } from "./words.js";
@@ -19,20 +21,27 @@ const ICON = {
19
21
  right: icon("M9 5l9 7-9 7z"),
20
22
  up: icon("M5 15l7-9 7 9z"),
21
23
  down: icon("M5 9l7 9 7-9z"),
24
+ more: icon("M3 7h18v2H3zM3 15h18v2H3zM8 4a3 3 0 1 0 0 6 3 3 0 1 0 0-6zM16 12a3 3 0 1 0 0 6 3 3 0 1 0 0-6z"),
22
25
  };
23
26
  const HTML = `
24
27
  <div class="bar" part="bar" role="group">
25
28
  <div class="field angle" part="field angle"><label data-w="angle" for="yaw"></label><input id="yaw" type="range" min="-180" max="180" step="1" value="0" part="slider" /><output for="yaw" data-o="yaw">0°</output></div>
26
- <div class="field tilt" part="field tilt"><label data-w="tilt" for="tilt"></label><input id="tilt" type="range" min="-30" max="30" step="1" value="0" part="slider" /><output for="tilt" data-o="tilt">0°</output></div>
29
+ <div class="field tilt adv" part="field tilt"><label data-w="tilt" for="tilt"></label><input id="tilt" type="range" min="-30" max="30" step="1" value="0" part="slider" /><output for="tilt" data-o="tilt">0°</output></div>
27
30
  <button type="button" class="play" data-b="play" aria-pressed="false" part="button">${ICON.play}${ICON.pause}<span class="text" data-w="autoTurn"></span></button>
28
- <button type="button" class="front" data-b="front" part="button"><span data-w="front"></span></button>
31
+ <div class="group pace adv" role="group" data-g="pace"><span class="lbl" data-w="speed" aria-hidden="true"></span>
32
+ <button type="button" data-b="slow" aria-pressed="false" part="button"><span data-w="slow"></span></button>
33
+ <button type="button" data-b="medium" aria-pressed="true" part="button"><span data-w="medium"></span></button>
34
+ <button type="button" data-b="fast" aria-pressed="false" part="button"><span data-w="fast"></span></button>
35
+ </div>
36
+ <button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
29
37
  <div class="group zoom" role="group" data-g="zoom">
30
38
  <button type="button" data-b="out" part="button">${ICON.minus}</button>
31
- <button type="button" class="two" data-b="two" part="button">2×</button>
39
+ <button type="button" class="level" data-b="level" part="button"><span data-o="zoom">1×</span></button>
32
40
  <button type="button" data-b="in" part="button">${ICON.plus}</button>
41
+ <button type="button" class="two adv" data-b="two" part="button">2×</button>
33
42
  <button type="button" data-b="fit" part="button"><span data-w="fit"></span></button>
34
43
  </div>
35
- <div class="group pan" role="group" data-g="pan" hidden>
44
+ <div class="group pan adv" role="group" data-g="pan" hidden>
36
45
  <button type="button" data-b="left" part="button">${ICON.left}</button>
37
46
  <button type="button" data-b="up" part="button">${ICON.up}</button>
38
47
  <button type="button" data-b="down" part="button">${ICON.down}</button>
@@ -42,17 +51,20 @@ const HTML = `
42
51
  <button type="button" data-b="turn" aria-pressed="false" part="button"><span data-w="dragTurn"></span></button>
43
52
  </div>
44
53
  </div>
54
+ <button type="button" class="more" data-b="more" aria-expanded="false" part="button">${ICON.more}<span class="text" data-w="more"></span></button>
55
+ <span class="vh" role="status" aria-live="polite" data-live></span>
45
56
  </div>`;
46
57
  /**
47
58
  * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt` (the starting view), `zoom` (`fit`, the default, or a scale where 1
48
59
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
49
60
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
50
61
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
62
+ * `controls` (`basic`, the default, or `advanced`: which controls are shown; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
51
63
  * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
52
64
  * or dark), `overlay` (sit over the bottom of a positioned parent).
53
65
  */
54
66
  export class ViewControlsElement extends HTMLElement {
55
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "speed", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
67
+ static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "speed", "controls", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
56
68
  #root;
57
69
  #controller;
58
70
  #view;
@@ -65,6 +77,11 @@ export class ViewControlsElement extends HTMLElement {
65
77
  #seen;
66
78
  #onScreen = true;
67
79
  #suspended = false;
80
+ #level = "basic";
81
+ #pace = "medium";
82
+ #labels = wordsFor("en");
83
+ #said = "";
84
+ #sayTimer;
68
85
  constructor() {
69
86
  super();
70
87
  this.#root = this.attachShadow({ mode: "open" });
@@ -130,10 +147,12 @@ export class ViewControlsElement extends HTMLElement {
130
147
  if (this.hasAttribute(key) && Number.isFinite(value))
131
148
  start[key] = value;
132
149
  }
133
- if (this.hasAttribute("address"))
134
- Object.assign(start, parseAddress(globalThis.location?.search ?? "", this.#keys, c.limits));
150
+ const { pace, ...chosen } = this.hasAttribute("address") ? parseAddress(globalThis.location?.search ?? "", this.#keys, c.limits) : {};
151
+ Object.assign(start, chosen);
135
152
  c.turnable = !this.hasAttribute("flat");
136
- c.speed = Number(this.getAttribute("speed")) || 30;
153
+ this.#level = parseLevel(this.#remembered()) ?? parseLevel(this.getAttribute("controls")) ?? "basic";
154
+ this.#pace = pace ?? "medium";
155
+ this.#speed();
137
156
  c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
138
157
  for (const name of ["key-tilt", "stop-turn", "tilt-step"])
139
158
  this.#option(name);
@@ -174,6 +193,8 @@ export class ViewControlsElement extends HTMLElement {
174
193
  this.#view = undefined;
175
194
  this.#ready = false;
176
195
  this.#writer.cancel();
196
+ clearTimeout(this.#sayTimer);
197
+ this.#said = "";
177
198
  }
178
199
  attributeChangedCallback(name, _old, value) {
179
200
  if (!this.#ready)
@@ -196,7 +217,11 @@ export class ViewControlsElement extends HTMLElement {
196
217
  else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step")
197
218
  this.#option(name);
198
219
  else if (name === "speed")
199
- c.speed = Number(value) || 30;
220
+ this.#speed();
221
+ else if (name === "controls") {
222
+ this.#level = parseLevel(value) ?? "basic";
223
+ this.#show();
224
+ }
200
225
  else if (name === "flat") {
201
226
  c.turnable = value === null;
202
227
  this.#show();
@@ -265,6 +290,21 @@ export class ViewControlsElement extends HTMLElement {
265
290
  on("in", () => c.zoomBy(ZOOM_STEP));
266
291
  on("two", () => c.zoomAt(2));
267
292
  on("fit", () => c.fit());
293
+ on("level", () => c.fit());
294
+ on("more", () => {
295
+ this.#level = this.#level === "basic" ? "advanced" : "basic";
296
+ remember(this.#storageKey(), this.#level);
297
+ this.#show();
298
+ });
299
+ for (const pace of PACES) {
300
+ on(pace, () => {
301
+ this.#pace = pace;
302
+ this.#speed();
303
+ this.#show();
304
+ if (this.hasAttribute("address"))
305
+ this.#save();
306
+ });
307
+ }
268
308
  on("left", () => c.panBy(-1, 0));
269
309
  on("right", () => c.panBy(1, 0));
270
310
  on("up", () => c.panBy(0, -1));
@@ -272,6 +312,21 @@ export class ViewControlsElement extends HTMLElement {
272
312
  on("move", () => c.setDrag("move"));
273
313
  on("turn", () => c.setDrag("turn"));
274
314
  }
315
+ #storageKey() {
316
+ return this.getAttribute("storage") || LEVEL_STORAGE_KEY;
317
+ }
318
+ #remembered() {
319
+ try {
320
+ return localStore()?.getItem(this.#storageKey()) ?? null;
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }
326
+ /** The turn's degrees a second: the `speed` attribute (30) times the pace. */
327
+ #speed() {
328
+ this.#ensure().speed = (Number(this.getAttribute("speed")) || 30) * PACE_FACTOR[this.#pace];
329
+ }
275
330
  #limits() {
276
331
  const c = this.#ensure();
277
332
  const read = (name, fallback) => {
@@ -339,6 +394,7 @@ export class ViewControlsElement extends HTMLElement {
339
394
  #label() {
340
395
  const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
341
396
  const words = wordsFor(language, this.#words);
397
+ this.#labels = words;
342
398
  for (const node of this.#root.querySelectorAll("[data-w]"))
343
399
  node.textContent = words[node.dataset.w];
344
400
  const set = (selector, label) => this.#root.querySelector(selector)?.setAttribute("aria-label", label);
@@ -350,11 +406,14 @@ export class ViewControlsElement extends HTMLElement {
350
406
  set('[data-g="zoom"]', words.zoom);
351
407
  set('[data-g="pan"]', words.pan);
352
408
  set('[data-g="drag"]', words.drag);
409
+ set('[data-g="pace"]', words.speed);
353
410
  set('[data-b="left"]', words.panLeft);
354
411
  set('[data-b="right"]', words.panRight);
355
412
  set('[data-b="up"]', words.panUp);
356
413
  set('[data-b="down"]', words.panDown);
357
414
  this.#root.querySelector(".bar")?.setAttribute("lang", language);
415
+ if (this.#ready)
416
+ this.#show();
358
417
  }
359
418
  #show() {
360
419
  const c = this.#ensure();
@@ -369,15 +428,45 @@ export class ViewControlsElement extends HTMLElement {
369
428
  q('[data-b="play"]').setAttribute("aria-pressed", String(s.turning));
370
429
  q('[data-b="out"]').disabled = s.zoom <= c.limits.zoomMin + 1e-6;
371
430
  q('[data-b="in"]').disabled = s.zoom >= c.limits.zoomMax - 1e-6;
372
- q('[data-g="pan"]').hidden = s.zoom <= ZOOMED;
431
+ const advanced = this.#level === "advanced";
432
+ const words = this.#labels;
433
+ const zoom = zoomText(s.zoom);
434
+ q('[data-o="zoom"]').textContent = zoom;
435
+ q('[data-b="level"]').setAttribute("aria-label", `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
436
+ this.#sayZoom(`${words.zoomLevel} ${zoom}`);
437
+ for (const pace of PACES)
438
+ q(`[data-b="${pace}"]`).setAttribute("aria-pressed", String(this.#pace === pace));
439
+ const more = q('[data-b="more"]');
440
+ more.setAttribute("aria-expanded", String(advanced));
441
+ this.dataset.level = this.#level;
442
+ for (const node of this.#root.querySelectorAll(".adv"))
443
+ node.hidden = !advanced;
444
+ q('[data-g="pan"]').hidden = !advanced || s.zoom <= ZOOMED;
373
445
  q('[data-b="move"]').setAttribute("aria-pressed", String(s.drag === "move"));
374
446
  q('[data-b="turn"]').setAttribute("aria-pressed", String(s.drag === "turn"));
375
447
  // A flat view cannot turn: the angle, tilt, auto-turn and front are not shown, and a drag only moves.
376
448
  const flat = !c.turnable;
377
- for (const selector of [".angle", ".tilt", ".play", ".front", '[data-g="drag"]'])
449
+ for (const selector of [".angle", ".play"])
378
450
  q(selector).hidden = flat;
451
+ for (const selector of [".tilt", ".front", '[data-g="drag"]', '[data-g="pace"]'])
452
+ q(selector).hidden = flat || !advanced;
379
453
  this.toggleAttribute("data-turning", s.turning);
380
454
  }
455
+ /** Tells a screen reader the zoom once it has settled for a moment (a pinch or a wheel makes many steps). */
456
+ #sayZoom(text) {
457
+ if (text === this.#said)
458
+ return;
459
+ const first = this.#said === "";
460
+ this.#said = text;
461
+ if (first)
462
+ return;
463
+ clearTimeout(this.#sayTimer);
464
+ this.#sayTimer = setTimeout(() => {
465
+ const live = this.#root.querySelector('[data-live]');
466
+ if (live !== null)
467
+ live.textContent = text;
468
+ }, 400);
469
+ }
381
470
  #changed(state, info) {
382
471
  if (!this.#ready)
383
472
  return;
@@ -393,7 +482,7 @@ export class ViewControlsElement extends HTMLElement {
393
482
  #save() {
394
483
  this.#writer.write((search) => {
395
484
  const s = this.#ensure().state;
396
- return formatAddress(search, s, this.#keys, s.turning, this.#ensure().startZoom);
485
+ return formatAddress(search, { ...s, pace: this.#pace }, this.#keys, s.turning, this.#ensure().startZoom);
397
486
  });
398
487
  }
399
488
  }
@@ -4,3 +4,4 @@ export { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACRO
4
4
  export { languageOf, WORDS, wordsFor, type Language, type ViewWords } from "./words.ts";
5
5
  export declare const EVENT = "kazoku-viewchange";
6
6
  export declare const TAG = "kazoku-view-controls";
7
+ export { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, parsePace, zoomText, type Level, type Pace } from "./levels.ts";
@@ -5,3 +5,4 @@ export { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACRO
5
5
  export { languageOf, WORDS, wordsFor } from "./words.js";
6
6
  export const EVENT = "kazoku-viewchange";
7
7
  export const TAG = "kazoku-view-controls";
8
+ export { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, parsePace, zoomText } from "./levels.js";
@@ -0,0 +1,12 @@
1
+ /** `basic` is the default: turn, zoom, Fit and Auto-turn. `advanced` adds tilt, speed, Front, 2 times and the pan pad. */
2
+ export type Level = "basic" | "advanced";
3
+ export declare const parseLevel: (text: string | null | undefined) => Level | undefined;
4
+ /** The localStorage key the level is remembered under, unless `storage` names another. */
5
+ export declare const LEVEL_STORAGE_KEY = "kazoku-view-controls";
6
+ /** Auto-turn speed: Medium is the element's `speed`, Slow half of it and Fast twice it. */
7
+ export type Pace = "slow" | "medium" | "fast";
8
+ export declare const PACES: readonly Pace[];
9
+ export declare const PACE_FACTOR: Record<Pace, number>;
10
+ export declare const parsePace: (text: string | null | undefined) => Pace | undefined;
11
+ /** The zoom as the readout shows it: `1×`, `2.4×`. */
12
+ export declare const zoomText: (zoom: number) => string;
@@ -0,0 +1,9 @@
1
+ // The controls' two levels and the auto-turn pace: pure, so a server or a test can use them.
2
+ export const parseLevel = (text) => (text === "basic" || text === "advanced" ? text : undefined);
3
+ /** The localStorage key the level is remembered under, unless `storage` names another. */
4
+ export const LEVEL_STORAGE_KEY = "kazoku-view-controls";
5
+ export const PACES = ["slow", "medium", "fast"];
6
+ export const PACE_FACTOR = { slow: 0.5, medium: 1, fast: 2 };
7
+ export const parsePace = (text) => PACES.find((pace) => pace === text);
8
+ /** The zoom as the readout shows it: `1×`, `2.4×`. */
9
+ export const zoomText = (zoom) => `${Number(zoom.toFixed(1))}×`;
@@ -1 +1 @@
1
- export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n:host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }\n:host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }\n[hidden] { display: none !important; }\n.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }\n.field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }\n.field.tilt { flex-basis: 150px; }\n.field label { flex: none; }\n.field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\ninput[type=\"range\"] { -webkit-appearance: none; appearance: none; flex: 1 1 auto; width: 100%; min-width: 60px; height: 48px; margin: 0; padding: 0; background: transparent; cursor: pointer; }\ninput[type=\"range\"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -11px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\nbutton[aria-pressed=\"true\"] .i-play, button[aria-pressed=\"false\"] .i-pause { display: none; }\n.zoom button.two { min-width: 56px; }\n.drag { margin-inline-start: 8px; }\n/* A compact bar on a phone: three rows (angle, tilt and zoom, the pad when zoomed), labels read only by a screen reader. */\n@media (max-width: 640px) {\n .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px auto; grid-template-areas: \"angle play front\" \"tilt zoom zoom\" \"pan pan pan\"; gap: 0 8px; padding: 2px 8px; }\n .field.angle { grid-area: angle; }\n .field.tilt { grid-area: tilt; }\n .field label, .field output { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .play { grid-area: play; padding: 0; }\n .play .text { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .front { grid-area: front; padding: 0 12px; }\n .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n .zoom button { padding: 0 8px; }\n .pan { grid-area: pan; justify-content: center; }\n .drag { margin-inline-start: 4px; }\n}\n";
1
+ export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n:host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }\n:host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }\n[hidden] { display: none !important; }\n.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }\n.field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }\n.field.tilt { flex-basis: 150px; }\n.field label { flex: none; }\n.field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\ninput[type=\"range\"] { -webkit-appearance: none; appearance: none; flex: 1 1 auto; width: 100%; min-width: 60px; height: 48px; margin: 0; padding: 0; background: transparent; cursor: pointer; }\ninput[type=\"range\"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -11px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton[aria-expanded=\"true\"] { border-color: var(--_ink); box-shadow: inset 0 0 0 1px var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\nbutton[aria-pressed=\"true\"] .i-play, button[aria-pressed=\"false\"] .i-pause { display: none; }\n.zoom button.two { min-width: 56px; }\n.zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }\n.pace .lbl { margin-inline-end: 4px; }\n.drag { margin-inline-start: 8px; }\n.more { margin-inline-start: auto; }\n/* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */\n@media (max-width: 640px) {\n .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: \"angle play more\" \"tilt tilt front\" \"pace pace pace\" \"zoom zoom zoom\" \"pan pan pan\"; gap: 0 8px; padding: 2px 8px; }\n .field.angle { grid-area: angle; }\n .field.tilt { grid-area: tilt; }\n .field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .play, .more { padding: 0; }\n .play { grid-area: play; }\n .more { grid-area: more; }\n .front { grid-area: front; padding: 0 12px; }\n .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n .pace button { padding: 0 12px; }\n .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n .zoom button { padding: 0 8px; }\n .zoom button.level { padding: 0 6px; }\n .pan { grid-area: pan; justify-content: center; }\n .drag { margin-inline-start: 4px; }\n}\n";
@@ -48,22 +48,30 @@ button { -webkit-appearance: none; appearance: none; box-sizing: border-box; dis
48
48
  button:hover:not(:disabled) { border-color: var(--_ink); }
49
49
  button:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }
50
50
  button[aria-pressed="true"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }
51
+ button[aria-expanded="true"] { border-color: var(--_ink); box-shadow: inset 0 0 0 1px var(--_ink); }
51
52
  button:disabled { opacity: 0.4; cursor: default; }
52
53
  svg { width: 18px; height: 18px; fill: currentColor; flex: none; }
53
54
  button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { display: none; }
54
55
  .zoom button.two { min-width: 56px; }
56
+ .zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }
57
+ .pace .lbl { margin-inline-end: 4px; }
55
58
  .drag { margin-inline-start: 8px; }
56
- /* A compact bar on a phone: three rows (angle, tilt and zoom, the pad when zoomed), labels read only by a screen reader. */
59
+ .more { margin-inline-start: auto; }
60
+ /* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */
57
61
  @media (max-width: 640px) {
58
- .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px auto; grid-template-areas: "angle play front" "tilt zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
62
+ .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: "angle play more" "tilt tilt front" "pace pace pace" "zoom zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
59
63
  .field.angle { grid-area: angle; }
60
64
  .field.tilt { grid-area: tilt; }
61
- .field label, .field output { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
62
- .play { grid-area: play; padding: 0; }
63
- .play .text { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
65
+ .field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
66
+ .play, .more { padding: 0; }
67
+ .play { grid-area: play; }
68
+ .more { grid-area: more; }
64
69
  .front { grid-area: front; padding: 0 12px; }
70
+ .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }
71
+ .pace button { padding: 0 12px; }
65
72
  .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
66
73
  .zoom button { padding: 0 8px; }
74
+ .zoom button.level { padding: 0 6px; }
67
75
  .pan { grid-area: pan; justify-content: center; }
68
76
  .drag { margin-inline-start: 4px; }
69
77
  }
@@ -17,6 +17,13 @@ export interface ViewWords {
17
17
  drag: string;
18
18
  dragMove: string;
19
19
  dragTurn: string;
20
+ zoomLevel: string;
21
+ zoomReset: string;
22
+ more: string;
23
+ speed: string;
24
+ slow: string;
25
+ medium: string;
26
+ fast: string;
20
27
  }
21
28
  export type Language = "en" | "ja";
22
29
  export declare const WORDS: Record<Language, ViewWords>;
@@ -19,6 +19,13 @@ export const WORDS = {
19
19
  drag: "What a drag does",
20
20
  dragMove: "Move",
21
21
  dragTurn: "Turn",
22
+ zoomLevel: "Zoom level",
23
+ zoomReset: "back to Fit",
24
+ more: "More",
25
+ speed: "Speed",
26
+ slow: "Slow",
27
+ medium: "Medium",
28
+ fast: "Fast",
22
29
  },
23
30
  ja: {
24
31
  group: "表示の操作",
@@ -39,6 +46,13 @@ export const WORDS = {
39
46
  drag: "ドラッグの動作",
40
47
  dragMove: "移動",
41
48
  dragTurn: "回転",
49
+ zoomLevel: "ズーム倍率",
50
+ zoomReset: "全体に戻す",
51
+ more: "詳細",
52
+ speed: "速さ",
53
+ slow: "ゆっくり",
54
+ medium: "ふつう",
55
+ fast: "はやい",
42
56
  },
43
57
  };
44
58
  /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kazoku-ui",
3
- "version": "0.7.2",
4
- "description": "Kazoku UI 家族, the family's shared interface parts as their own versioned package: view controls for any 3D or zoomable view (angle, tilt, auto-turn, zoom about the pointer, pan, touch, address state) a light, dark and auto theme switch, a language picker, choices kept in the address and a save-as-image button, each a framework-free custom element and a plain controller. Zero dependencies.",
3
+ "version": "0.9.0",
4
+ "description": "Kazoku UI 家族, the family's shared interface parts as their own versioned package: view controls for any 3D or zoomable view (angle, tilt, auto-turn, zoom about the pointer, pan, touch, address state) a light, dark and auto theme switch, a language picker, choices kept in the address and a save-as-image button, each a framework-free custom element and a plain controller. Zero dependencies; the shared 3D renderer takes three.js as an optional peer.",
5
5
  "keywords": [
6
6
  "view-controls",
7
7
  "3d",
@@ -26,7 +26,11 @@
26
26
  "save-image",
27
27
  "png",
28
28
  "svg",
29
- "download"
29
+ "download",
30
+ "webgl",
31
+ "three",
32
+ "three.js",
33
+ "renderer"
30
34
  ],
31
35
  "homepage": "https://johnmorrisdotca.github.io/kazoku-ui/",
32
36
  "repository": {
@@ -118,6 +122,16 @@
118
122
  "types": "./dist/save-image/define.d.ts",
119
123
  "import": "./dist/save-image/define.js",
120
124
  "default": "./dist/save-image/define.js"
125
+ },
126
+ "./renderer": {
127
+ "types": "./dist/renderer/index.d.ts",
128
+ "import": "./dist/renderer/index.js",
129
+ "default": "./dist/renderer/index.js"
130
+ },
131
+ "./testing": {
132
+ "types": "./dist/testing/index.d.ts",
133
+ "import": "./dist/testing/index.js",
134
+ "default": "./dist/testing/index.js"
121
135
  }
122
136
  },
123
137
  "files": [
@@ -129,6 +143,14 @@
129
143
  "engines": {
130
144
  "node": ">=22"
131
145
  },
146
+ "peerDependencies": {
147
+ "three": ">=0.160.0"
148
+ },
149
+ "peerDependenciesMeta": {
150
+ "three": {
151
+ "optional": true
152
+ }
153
+ },
132
154
  "scripts": {
133
155
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
134
156
  "lint": "eslint .",
@@ -147,6 +169,7 @@
147
169
  "@playwright/test": "^1.63.0",
148
170
  "@types/node": "^24.0.0",
149
171
  "eslint": "^9.0.0",
172
+ "three": "0.186.1",
150
173
  "typescript": "^5.9.0",
151
174
  "typescript-eslint": "^8.0.0"
152
175
  },