@johnmorrisdotca/kazoku-ui 0.16.0 → 0.17.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.
@@ -1,5 +1,5 @@
1
1
  import { turnOnlyInSight } from "./sight.js";
2
- import { between, clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, WHEEL_GESTURE_MS, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
2
+ import { between, clamp, clampPan, DEFAULT_LIMITS, dragTurn, eulerFromQuat, PAN_STEP, pendulumTilt, pinchTo, quatFromEuler, quatIdentity, spinLocal, tumbleRates, WHEEL_GESTURE_MS, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
3
3
  /** What `bind` leaves alone by default: the page's own controls inside the view. */
4
4
  export const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
5
5
  /** How long a move to the middle takes. */
@@ -21,11 +21,15 @@ export function createViewController(options = {}) {
21
21
  let destroyed = false;
22
22
  let centring = 0;
23
23
  let stepping = false;
24
+ let tiltPhase = 0;
24
25
  let fitScale = positive(options.fitScale) ?? 1;
25
26
  let start = positive(options.zoom) ?? "fit";
26
27
  let atStart = true;
27
28
  const startState = () => (start === "fit" ? 1 : start / fitScale);
28
- const state = { yaw: 0, tilt: 0, zoom: 1, panX: 0, panY: 0, turning: false, drag: "move", ...options.initial };
29
+ const state = { yaw: 0, tilt: 0, roll: 0, orientation: quatIdentity(), zoom: 1, panX: 0, panY: 0, turning: false, drag: "move", ...options.initial };
30
+ if (state.orientation === undefined)
31
+ state.orientation = quatIdentity();
32
+ state.roll ??= 0;
29
33
  if (start !== "fit")
30
34
  state.zoom = clamp(startState(), limits.zoomMin, limits.zoomMax);
31
35
  atStart = state.zoom === clamp(startState(), limits.zoomMin, limits.zoomMax);
@@ -51,17 +55,25 @@ export function createViewController(options = {}) {
51
55
  };
52
56
  const camera = () => ({ yaw: state.yaw, tilt: state.tilt, zoom: state.zoom, panX: state.panX, panY: state.panY });
53
57
  /** Take a camera in, held to the limits and, once the view's size is known, to the room the zoom gives. */
58
+ const pose = () => {
59
+ state.orientation = quatFromEuler(state.yaw, state.tilt, state.roll ?? 0);
60
+ };
54
61
  const take = (next) => {
55
62
  if (!stepping)
56
63
  stopCentre();
57
- const held = clampPan({ ...next, zoom: clamp(next.zoom, limits.zoomMin, limits.zoomMax), tilt: clamp(next.tilt, -limits.tiltMax, limits.tiltMax) }, size(), controller.objectSize);
64
+ const rollMax = limits.rollMax;
65
+ const roll = clamp(wrapYaw(next.roll ?? state.roll ?? 0), -rollMax, rollMax);
66
+ const held = clampPan({ ...next, roll, zoom: clamp(next.zoom, limits.zoomMin, limits.zoomMax), tilt: clamp(next.tilt, -limits.tiltMax, limits.tiltMax) }, size(), controller.objectSize);
58
67
  state.yaw = controller.turnable ? wrapYaw(held.yaw) : state.yaw;
59
68
  state.tilt = controller.turnable ? held.tilt : state.tilt;
69
+ state.roll = controller.turnable ? roll : state.roll;
60
70
  if (held.zoom !== state.zoom)
61
71
  atStart = false;
62
72
  state.zoom = held.zoom;
63
73
  state.panX = held.panX;
64
74
  state.panY = held.panY;
75
+ pose();
76
+ tiltPhase = Math.asin(clamp(state.tilt / (limits.tiltMax || 1), -1, 1));
65
77
  };
66
78
  const centre = () => {
67
79
  if (bound === undefined)
@@ -86,20 +98,71 @@ export function createViewController(options = {}) {
86
98
  last = 0;
87
99
  return;
88
100
  }
89
- const dt = last === 0 ? 0 : Math.min(0.1, (now - last) / 1000);
101
+ const gap = last === 0 ? 0 : (now - last) / 1000;
102
+ // The picture moves at most a tenth of a second in one frame, so a late frame does not jump. The limit counts the time that really passed, up to a second, so a turn that stops itself does so when that time has passed.
103
+ const dt = Math.min(0.1, gap);
90
104
  last = now;
91
- if (dt === 0)
105
+ if (gap === 0)
92
106
  return;
93
- state.yaw = wrapYaw(state.yaw + controller.speed * dt);
94
- turned += dt;
107
+ const rate = controller.speed;
108
+ if (controller.turnAxis === "tilt")
109
+ stepTilt(dt, rate);
110
+ else if (controller.turnAxis === "roll")
111
+ stepRoll(dt, rate);
112
+ else if (controller.turnAxis === "all")
113
+ stepAll(dt);
114
+ else {
115
+ state.yaw = wrapYaw(state.yaw + rate * dt);
116
+ pose();
117
+ }
118
+ turned += Math.min(gap, 1);
95
119
  emit(false, "auto");
96
120
  if (limit > 0 && turned >= limit)
97
121
  controller.pause();
98
122
  };
123
+ /** Tilt within the limit swings back. At 90 degrees or more it rolls over on the quaternion, so the up axis never flips. */
124
+ const stepTilt = (dt, rate) => {
125
+ if (limits.tiltMax >= 90) {
126
+ state.orientation = spinLocal(state.orientation, 0, rate * dt, 0);
127
+ const euler = eulerFromQuat(state.orientation);
128
+ state.yaw = euler.yaw;
129
+ state.tilt = euler.tilt;
130
+ state.roll = euler.roll;
131
+ return;
132
+ }
133
+ tiltPhase += (rate * dt * Math.PI) / 180;
134
+ state.tilt = pendulumTilt(tiltPhase, limits.tiltMax);
135
+ pose();
136
+ };
137
+ const stepRoll = (dt, rate) => {
138
+ const max = limits.rollMax;
139
+ state.roll = max >= 180 ? wrapYaw((state.roll ?? 0) + rate * dt) : clamp((state.roll ?? 0) + rate * dt, -max, max);
140
+ pose();
141
+ };
142
+ const stepAll = (dt) => {
143
+ const signs = { yaw: controller.speed, tilt: controller.tiltSign, roll: controller.rollSign };
144
+ const rates = tumbleRates(turned, Math.abs(controller.speed), signs);
145
+ if (limits.tiltMax >= 90) {
146
+ state.orientation = spinLocal(state.orientation, rates.yaw * dt, rates.tilt * dt, rates.roll * dt);
147
+ const euler = eulerFromQuat(state.orientation);
148
+ state.yaw = euler.yaw;
149
+ state.tilt = euler.tilt;
150
+ state.roll = euler.roll;
151
+ return;
152
+ }
153
+ state.yaw = wrapYaw(state.yaw + rates.yaw * dt);
154
+ state.roll = wrapYaw((state.roll ?? 0) + rates.roll * dt);
155
+ tiltPhase += (rates.tilt * dt * Math.PI) / 180;
156
+ state.tilt = pendulumTilt(tiltPhase, limits.tiltMax);
157
+ pose();
158
+ };
99
159
  const controller = {
100
160
  state,
101
161
  turnable: options.turnable ?? true,
102
162
  speed: options.speed ?? 30,
163
+ turnAxis: options.turnAxis ?? "angle",
164
+ tiltSign: 1,
165
+ rollSign: 1,
103
166
  limits,
104
167
  ignore: options.ignore ?? DEFAULT_IGNORE,
105
168
  anchor: options.anchor,
@@ -193,7 +256,7 @@ export function createViewController(options = {}) {
193
256
  last = 0;
194
257
  },
195
258
  front() {
196
- take({ ...camera(), yaw: 0, tilt: 0 });
259
+ take({ ...camera(), yaw: 0, tilt: 0, roll: 0 });
197
260
  emit(true, "front");
198
261
  },
199
262
  fit() {
@@ -203,7 +266,7 @@ export function createViewController(options = {}) {
203
266
  centre(animate = true) {
204
267
  stopCentre();
205
268
  const from = camera();
206
- const to = { yaw: 0, tilt: 0, zoom: 1, panX: 0, panY: 0 };
269
+ const to = { yaw: 0, tilt: 0, roll: 0, zoom: 1, panX: 0, panY: 0 };
207
270
  const reduced = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
208
271
  const same = from.yaw === 0 && from.tilt === 0 && from.zoom === 1 && from.panX === 0 && from.panY === 0;
209
272
  if (!animate || reduced || same || destroyed) {
@@ -6,7 +6,7 @@ import { localStore, remember } from "../shared/store.js";
6
6
  import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
7
7
  import { createViewController, DEFAULT_IGNORE } from "./controller.js";
8
8
  import { EVENT, TAG } from "./index.js";
9
- import { LEVEL_STORAGE_KEY, parseDirection, parseLayout, parseLevel, zoomText } from "./levels.js";
9
+ import { LEVEL_STORAGE_KEY, parseAxis, parseDirection, parseLayout, parseLevel, zoomText } from "./levels.js";
10
10
  import { ZOOM_STEP, ZOOMED } from "./math.js";
11
11
  import { turnOnlyInSight } from "./sight.js";
12
12
  import "../speed-buttons/define.js";
@@ -35,7 +35,14 @@ const THUMB = 28;
35
35
  const HTML = `
36
36
  <div class="bar" part="bar" role="group">
37
37
  <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>
38
- <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>
38
+ <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>
39
+ <div class="field roll" part="field roll"><label data-w="roll" for="roll"></label><input id="roll" type="range" min="-180" max="180" step="1" value="0" part="slider" /><output for="roll" data-o="roll">0°</output></div>
40
+ <div class="group axes" role="group" data-g="axes">
41
+ <button type="button" data-b="axis-angle" aria-pressed="true" part="button"><span data-w="angle"></span></button>
42
+ <button type="button" data-b="axis-tilt" aria-pressed="false" part="button"><span data-w="tilt"></span></button>
43
+ <button type="button" data-b="axis-roll" aria-pressed="false" part="button"><span data-w="roll"></span></button>
44
+ <button type="button" data-b="axis-all" aria-pressed="false" part="button"><span data-w="tumble"></span></button>
45
+ </div>
39
46
  <button type="button" class="pace-now" data-b="pace-now" part="button" hidden><span class="lbl" data-w="speed"></span><span data-o="pace"></span></button>
40
47
  <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>
41
48
  <div class="pacing adv" data-g="pacing">
@@ -78,7 +85,7 @@ const HTML = `
78
85
  * or dark), `overlay` (sit over the bottom of a positioned parent).
79
86
  */
80
87
  export class ViewControlsElement extends HTMLElement {
81
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "direction", "pace", "paces", "controls", "layout", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
88
+ static observedAttributes = ["for", "lang", "yaw", "tilt", "roll", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "direction", "tilt-direction", "roll-direction", "axis", "pace", "paces", "controls", "layout", "flat", "tilt-max", "roll-max", "zoom-min", "zoom-max", "address-keys"];
82
89
  #root;
83
90
  #controller;
84
91
  #view;
@@ -100,6 +107,9 @@ export class ViewControlsElement extends HTMLElement {
100
107
  /** Which way the turn goes now, and the way the page itself starts it (its `direction`, else the sign of its `speed`): the address keeps a way only when it is not the start. */
101
108
  #direction = "right";
102
109
  #start;
110
+ #axis = "angle";
111
+ #tiltWay = "right";
112
+ #rollWay = "right";
103
113
  #writing = false;
104
114
  #labels = wordsFor("en");
105
115
  #said = "";
@@ -173,13 +183,28 @@ export class ViewControlsElement extends HTMLElement {
173
183
  this.#arrange();
174
184
  this.#limits();
175
185
  const start = {};
176
- for (const key of ["yaw", "tilt"]) {
186
+ for (const key of ["yaw", "tilt", "roll"]) {
177
187
  const value = Number(this.getAttribute(key));
178
188
  if (this.hasAttribute(key) && Number.isFinite(value))
179
189
  start[key] = value;
180
190
  }
181
- const { pace, direction, ...chosen } = this.hasAttribute("address") ? parseAddress(globalThis.location?.search ?? "", this.#keys, c.limits) : {};
191
+ const { pace, direction, axis, tiltDirection, rollDirection, ...chosen } = this.hasAttribute("address") ? parseAddress(globalThis.location?.search ?? "", this.#keys, c.limits) : {};
182
192
  Object.assign(start, chosen);
193
+ this.#axis = axis ?? parseAxis(this.getAttribute("axis")) ?? this.#axis;
194
+ if (tiltDirection)
195
+ this.#tiltWay = tiltDirection;
196
+ else {
197
+ const tiltWay = parseDirection(this.getAttribute("tilt-direction"));
198
+ if (tiltWay !== undefined)
199
+ this.#tiltWay = tiltWay;
200
+ }
201
+ if (rollDirection)
202
+ this.#rollWay = rollDirection;
203
+ else {
204
+ const rollWay = parseDirection(this.getAttribute("roll-direction"));
205
+ if (rollWay !== undefined)
206
+ this.#rollWay = rollWay;
207
+ }
183
208
  c.turnable = !this.hasAttribute("flat");
184
209
  this.#level = parseLevel(this.#remembered()) ?? parseLevel(this.getAttribute("controls")) ?? "basic";
185
210
  this.#pace = this.#nearestPace(pace ?? parseSpeed(this.getAttribute("pace"), this.#paces()) ?? 1);
@@ -238,10 +263,15 @@ export class ViewControlsElement extends HTMLElement {
238
263
  this.#resolveView();
239
264
  else if (name === "lang")
240
265
  this.#label();
241
- else if (name === "yaw" || name === "tilt") {
266
+ else if (name === "yaw" || name === "tilt" || name === "roll") {
242
267
  if (value !== null && Number.isFinite(Number(value)))
243
268
  c.set({ [name]: Number(value) });
244
269
  }
270
+ else if (name === "axis") {
271
+ this.#axis = parseAxis(value) ?? "angle";
272
+ this.#speed();
273
+ this.#show();
274
+ }
245
275
  else if (name === "zoom")
246
276
  c.setStartZoom(this.#startZoom());
247
277
  else if (name === "fit-scale")
@@ -266,6 +296,17 @@ export class ViewControlsElement extends HTMLElement {
266
296
  this.#speed();
267
297
  this.#show();
268
298
  }
299
+ else if (name === "tilt-direction" || name === "roll-direction") {
300
+ if (this.#writing)
301
+ return;
302
+ const way = parseDirection(value) ?? "right";
303
+ if (name === "tilt-direction")
304
+ this.#tiltWay = way;
305
+ else
306
+ this.#rollWay = way;
307
+ this.#speed();
308
+ this.#show();
309
+ }
269
310
  else if (name === "pace" || name === "paces") {
270
311
  this.#pace = this.#nearestPace(parseSpeed(this.getAttribute("pace"), this.#paces()) ?? this.#pace);
271
312
  this.#speed();
@@ -341,27 +382,30 @@ export class ViewControlsElement extends HTMLElement {
341
382
  const c = () => this.#ensure();
342
383
  const slider = (id) => {
343
384
  const input = q(`#${id}`);
344
- // A hand on the angle slider holds the automatic turn still while it is down; the turn then goes on from where the hand left it.
385
+ // A hand on a turn slider holds the automatic turn still while it is down; the turn then goes on from where the hand left it.
345
386
  const hold = () => {
346
387
  this.#sliderHeld += 1;
347
- if (this.#sliderHeld === 1 && id === "yaw")
388
+ if (this.#sliderHeld === 1)
348
389
  c().holdTurn();
349
390
  };
350
391
  const free = () => {
351
392
  if (this.#sliderHeld === 0)
352
393
  return;
353
394
  this.#sliderHeld = 0;
354
- if (id === "yaw")
355
- c().releaseTurn();
395
+ c().releaseTurn();
356
396
  };
357
397
  input.addEventListener("pointerdown", () => hold());
358
398
  input.addEventListener("pointerup", free);
359
399
  input.addEventListener("pointercancel", free);
360
- input.addEventListener("input", () => c().set({ [id]: Number(input.value) }, false, "slider"));
361
- input.addEventListener("change", () => c().set({ [id]: Number(input.value) }, true, "slider"));
400
+ input.addEventListener("input", () => c().set({ [id === "yaw" ? this.#hand() : id]: Number(input.value) }, false, "slider"));
401
+ input.addEventListener("change", () => c().set({ [id === "yaw" ? this.#hand() : id]: Number(input.value) }, true, "slider"));
362
402
  };
363
403
  slider("yaw");
364
404
  slider("tilt");
405
+ slider("roll");
406
+ for (const axis of ["angle", "tilt", "roll", "all"]) {
407
+ q(`[data-b="axis-${axis}"]`).addEventListener("click", () => this.#chooseAxis(axis));
408
+ }
365
409
  this.#zoomSlider(q("#zoom"), c);
366
410
  const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
367
411
  on("play", () => {
@@ -507,22 +551,71 @@ export class ViewControlsElement extends HTMLElement {
507
551
  const seconds = Number(this.getAttribute("autoplay-for"));
508
552
  return seconds > 0 && Number.isFinite(seconds) ? seconds : undefined;
509
553
  }
510
- /** A way was pressed. It is written to the `direction` attribute and the address; the angle goes on from where it was, at the same speed. */
554
+ /** A way was pressed. It sets the active axis's direction (all three when All is on). The angle goes on from where it was. */
511
555
  #chooseDirection(direction) {
512
- this.#direction = direction;
556
+ const axes = this.#axis === "all" ? ["angle", "tilt", "roll"] : [this.#axis];
557
+ for (const axis of axes)
558
+ this.#putWay(axis, direction);
513
559
  this.#speed();
514
- if (this.getAttribute("direction") !== direction) {
560
+ this.#show();
561
+ if (this.hasAttribute("address"))
562
+ this.#save();
563
+ }
564
+ #putWay(axis, direction) {
565
+ const name = axis === "tilt" ? "tilt-direction" : axis === "roll" ? "roll-direction" : "direction";
566
+ if (axis === "tilt")
567
+ this.#tiltWay = direction;
568
+ else if (axis === "roll")
569
+ this.#rollWay = direction;
570
+ else
571
+ this.#direction = direction;
572
+ if (this.getAttribute(name) !== direction) {
573
+ this.#writing = true;
574
+ this.setAttribute(name, direction);
575
+ this.#writing = false;
576
+ }
577
+ }
578
+ /** Which axis Auto-turn drives, and which slider a reader last named. Angle is the start, so it leaves the attribute. */
579
+ #chooseAxis(axis) {
580
+ this.#axis = axis;
581
+ const written = axis === "angle" ? null : axis;
582
+ if (this.getAttribute("axis") !== written) {
515
583
  this.#writing = true;
516
- this.setAttribute("direction", direction);
584
+ if (written === null)
585
+ this.removeAttribute("axis");
586
+ else
587
+ this.setAttribute("axis", written);
517
588
  this.#writing = false;
518
589
  }
590
+ this.#speed();
519
591
  this.#show();
520
592
  if (this.hasAttribute("address"))
521
593
  this.#save();
522
594
  }
523
- /** The turn's degrees a second: the size of the `speed` attribute (30) times the pace, going left when it is negative. */
595
+ /** The slider shows this axis. All is a tumble, so the slider stays on the angle. */
596
+ #hand() {
597
+ if (this.#axis === "tilt")
598
+ return "tilt";
599
+ if (this.#axis === "roll")
600
+ return "roll";
601
+ return "yaw";
602
+ }
603
+ /** The way Left and Right show for the axis that is on. All shows the angle's way. */
604
+ #activeWay() {
605
+ if (this.#axis === "tilt")
606
+ return this.#tiltWay;
607
+ if (this.#axis === "roll")
608
+ return this.#rollWay;
609
+ return this.#direction;
610
+ }
611
+ /** The turn's degrees a second: the size of the `speed` attribute (30) times the pace, going left when the active axis says so. */
524
612
  #speed() {
525
- this.#ensure().speed = Math.abs(Number(this.getAttribute("speed")) || 30) * this.#pace * (this.#direction === "left" ? -1 : 1);
613
+ const c = this.#ensure();
614
+ const size = Math.abs(Number(this.getAttribute("speed")) || 30) * this.#pace;
615
+ c.turnAxis = this.#axis;
616
+ c.tiltSign = this.#tiltWay === "left" ? -1 : 1;
617
+ c.rollSign = this.#rollWay === "left" ? -1 : 1;
618
+ c.speed = size * (this.#activeWay() === "left" ? -1 : 1);
526
619
  }
527
620
  #limits() {
528
621
  const c = this.#ensure();
@@ -531,6 +624,7 @@ export class ViewControlsElement extends HTMLElement {
531
624
  return this.hasAttribute(name) && Number.isFinite(value) && value > 0 ? value : fallback;
532
625
  };
533
626
  c.limits.tiltMax = read("tilt-max", 30);
627
+ c.limits.rollMax = read("roll-max", 180);
534
628
  c.limits.zoomMin = Math.min(1, read("zoom-min", 1));
535
629
  c.limits.zoomMax = Math.max(c.limits.zoomMin, read("zoom-max", 5));
536
630
  const tilt = this.#root.querySelector("#tilt");
@@ -538,6 +632,11 @@ export class ViewControlsElement extends HTMLElement {
538
632
  tilt.min = String(-c.limits.tiltMax);
539
633
  tilt.max = String(c.limits.tiltMax);
540
634
  }
635
+ const roll = this.#root.querySelector("#roll");
636
+ if (roll !== null) {
637
+ roll.min = String(-c.limits.rollMax);
638
+ roll.max = String(c.limits.rollMax);
639
+ }
541
640
  this.#drawMarks();
542
641
  }
543
642
  #resolveView() {
@@ -581,6 +680,7 @@ export class ViewControlsElement extends HTMLElement {
581
680
  set('[data-g="zoom"]', words.zoom);
582
681
  set('[data-g="pan"]', words.pan);
583
682
  set('[data-g="direction"]', words.direction);
683
+ set('[data-g="axes"]', words.axis);
584
684
  set('[data-g="drag"]', words.drag);
585
685
  const speeds = this.#root.querySelector("kazoku-speed-buttons");
586
686
  speeds?.setAttribute("lang", language);
@@ -599,17 +699,37 @@ export class ViewControlsElement extends HTMLElement {
599
699
  const q = (selector) => this.#root.querySelector(selector);
600
700
  const yaw = q("#yaw");
601
701
  const tilt = q("#tilt");
602
- yaw.value = String(Math.round(s.yaw));
702
+ const roll = q("#roll");
603
703
  tilt.value = String(Math.round(s.tilt));
604
- q('[data-o="yaw"]').value = `${Math.round(s.yaw)}°`;
704
+ roll.value = String(Math.round(s.roll ?? 0));
705
+ // The one slider shows the axis the chips have chosen. All keeps the angle, which is where a tumble is read from.
706
+ const hand = this.#hand();
707
+ const span = hand === "tilt" ? c.limits.tiltMax : hand === "roll" ? c.limits.rollMax : 180;
708
+ const value = hand === "tilt" ? s.tilt : hand === "roll" ? (s.roll ?? 0) : s.yaw;
709
+ const text = String(Math.round(value));
710
+ if (yaw.max !== String(span)) {
711
+ yaw.max = String(span);
712
+ yaw.min = String(-span);
713
+ }
714
+ if (yaw.value !== text)
715
+ yaw.value = text;
716
+ const words = this.#labels;
717
+ const name = hand === "tilt" ? words.tilt : hand === "roll" ? words.roll : words.angle;
718
+ const label = q('[data-w="angle"]');
719
+ if (label.textContent !== name)
720
+ label.textContent = name;
721
+ q('[data-o="yaw"]').value = `${text}°`;
605
722
  q('[data-o="tilt"]').value = `${Math.round(s.tilt)}°`;
723
+ q('[data-o="roll"]').value = `${Math.round(s.roll ?? 0)}°`;
724
+ for (const axis of ["angle", "tilt", "roll", "all"]) {
725
+ q(`[data-b="axis-${axis}"]`).setAttribute("aria-pressed", String(this.#axis === axis));
726
+ }
606
727
  q('[data-b="play"]').setAttribute("aria-pressed", String(s.turning));
607
728
  // Centre comes in once the view has been zoomed or moved, which is when the object can be lost; it puts it back in the middle at Fit and the default angle.
608
729
  q(".centre").hidden = s.zoom === 1 && s.panX === 0 && s.panY === 0;
609
730
  q('[data-b="out"]').disabled = s.zoom <= c.limits.zoomMin + 1e-6;
610
731
  q('[data-b="in"]').disabled = s.zoom >= c.limits.zoomMax - 1e-6;
611
732
  const advanced = this.#level === "advanced";
612
- const words = this.#labels;
613
733
  const zoom = zoomText(s.zoom);
614
734
  // A frame of the automatic turn comes through here: what has not changed is not written.
615
735
  const write = (node, text) => {
@@ -645,8 +765,9 @@ export class ViewControlsElement extends HTMLElement {
645
765
  const pace = speedText(this.#pace);
646
766
  write(q('[data-o="pace"]'), pace);
647
767
  say(q('[data-b="pace-now"]'), `${words.speed}: ${pace}`);
648
- q('[data-b="turn-left"]').setAttribute("aria-pressed", String(this.#direction === "left"));
649
- q('[data-b="turn-right"]').setAttribute("aria-pressed", String(this.#direction === "right"));
768
+ const way = this.#activeWay();
769
+ q('[data-b="turn-left"]').setAttribute("aria-pressed", String(way === "left"));
770
+ q('[data-b="turn-right"]').setAttribute("aria-pressed", String(way === "right"));
650
771
  const more = q('[data-b="more"]');
651
772
  more.setAttribute("aria-expanded", String(advanced));
652
773
  this.dataset.level = this.#level;
@@ -657,9 +778,9 @@ export class ViewControlsElement extends HTMLElement {
657
778
  q('[data-b="turn"]').setAttribute("aria-pressed", String(s.drag === "turn"));
658
779
  // A flat view cannot turn: the angle, tilt, auto-turn and front are not shown, and a drag only moves.
659
780
  const flat = !c.turnable;
660
- for (const selector of [".angle", ".play"])
781
+ for (const selector of [".angle", ".tilt", ".roll", ".play", '[data-g="axes"]'])
661
782
  q(selector).hidden = flat;
662
- for (const selector of [".tilt", ".front", '[data-g="drag"]', '[data-g="pacing"]'])
783
+ for (const selector of [".front", '[data-g="drag"]', '[data-g="pacing"]'])
663
784
  q(selector).hidden = flat || !advanced;
664
785
  // The speed is always in sight while the view turns: one small button in basic, which steps through the three; advanced always has the three.
665
786
  q(".pace-now").hidden = flat || advanced || !s.turning;
@@ -697,7 +818,14 @@ export class ViewControlsElement extends HTMLElement {
697
818
  #save() {
698
819
  this.#writer.write((search) => {
699
820
  const s = this.#ensure().state;
700
- return formatAddress(search, { ...s, pace: this.#pace, direction: this.#direction === (this.#start ?? this.#direction) ? null : this.#direction }, this.#keys, s.turning, this.#ensure().startZoom);
821
+ return formatAddress(search, {
822
+ ...s,
823
+ pace: this.#pace,
824
+ direction: this.#direction === (this.#start ?? this.#direction) ? null : this.#direction,
825
+ axis: this.#axis,
826
+ tiltDirection: this.#tiltWay === "right" ? null : this.#tiltWay,
827
+ rollDirection: this.#rollWay === "right" ? null : this.#rollWay,
828
+ }, this.#keys, s.turning, this.#ensure().startZoom);
701
829
  });
702
830
  }
703
831
  }
@@ -1,8 +1,8 @@
1
1
  export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys, type AddressChoices, type AddressKeys, type DragMode } from "./address.ts";
2
2
  export { createViewController, DEFAULT_IGNORE, type ChangeInfo, type Clock, type ViewController, type ViewControllerOptions, type ViewState } from "./controller.ts";
3
- export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, WHEEL_GESTURE_MS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout, type Camera, type Limits, type PinchStart, type Point, type Size } from "./math.ts";
3
+ export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, eulerFromQuat, PAN_STEP, pendulumTilt, pinchTo, quatFromEuler, quatIdentity, rateSign, spinLocal, TILT_ACROSS, tumbleRates, TURN_ACROSS, upVector, WHEEL_GESTURE_MS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout, type Camera, type Limits, type PinchStart, type Point, type Quat, type Size, type TumbleSigns, type Vec3 } from "./math.ts";
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 { LAYOUTS, LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLayout, parseLevel, parsePace, zoomText, type Layout, type Level, type Pace } from "./levels.ts";
7
+ export { LAYOUTS, LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseAxis, parseLayout, parseLevel, parsePace, zoomText, type Layout, type Level, type Pace, type TurnAxis } from "./levels.ts";
8
8
  export { COMMON_ZOOMS, MARK_GAP, SLIDER_STEP, SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark, type ZoomMark } from "./zoomScale.ts";
@@ -1,9 +1,9 @@
1
1
  // The view controls without the element: the controller, the arithmetic, the address and the labels. Nothing here touches the DOM until `bind` is called, so it imports on a server.
2
2
  export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
3
3
  export { createViewController, DEFAULT_IGNORE } from "./controller.js";
4
- export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, WHEEL_GESTURE_MS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout } from "./math.js";
4
+ export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, eulerFromQuat, PAN_STEP, pendulumTilt, pinchTo, quatFromEuler, quatIdentity, rateSign, spinLocal, TILT_ACROSS, tumbleRates, TURN_ACROSS, upVector, WHEEL_GESTURE_MS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout } from "./math.js";
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 { LAYOUTS, LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLayout, parseLevel, parsePace, zoomText } from "./levels.js";
8
+ export { LAYOUTS, LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseAxis, parseLayout, parseLevel, parsePace, zoomText } from "./levels.js";
9
9
  export { COMMON_ZOOMS, MARK_GAP, SLIDER_STEP, SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark } from "./zoomScale.js";
@@ -11,6 +11,9 @@ export declare const parsePace: (text: string | null | undefined) => Pace | unde
11
11
  /** Which way Auto-turn goes. By the drag rule `yaw` goes up as the hand goes right, so `right` is a positive speed and `left` a negative one; the page's drawing decides what that looks like. */
12
12
  export type Direction = "left" | "right";
13
13
  export declare const parseDirection: (text: string | null | undefined) => Direction | undefined;
14
+ /** Which axis Auto-turn drives. `angle` is yaw, the default. `all` is a tumble on yaw, tilt and roll at once. */
15
+ export type TurnAxis = "angle" | "tilt" | "roll" | "all";
16
+ export declare const parseAxis: (text: string | null | undefined) => TurnAxis | undefined;
14
17
  /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
15
18
  export declare const zoomText: (zoom: number) => string;
16
19
  /**
@@ -6,6 +6,7 @@ export const PACES = ["slow", "medium", "fast"];
6
6
  export const PACE_FACTOR = { slow: 0.5, medium: 1, fast: 2 };
7
7
  export const parsePace = (text) => PACES.find((pace) => pace === text);
8
8
  export const parseDirection = (text) => (text === "left" || text === "right" ? text : undefined);
9
+ export const parseAxis = (text) => (text === "angle" || text === "tilt" || text === "roll" || text === "all" ? text : undefined);
9
10
  /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
10
11
  export const zoomText = (zoom) => `${Number(zoom < 1 ? zoom.toPrecision(2) : zoom.toFixed(1))}×`;
11
12
  export const LAYOUTS = ["classic", "horizontal", "vertical", "compact"];
@@ -10,12 +10,16 @@ export interface Size {
10
10
  export interface Camera {
11
11
  yaw: number;
12
12
  tilt: number;
13
+ /** Degrees about the view's forward axis. Left out, it is 0. */
14
+ roll?: number;
13
15
  zoom: number;
14
16
  panX: number;
15
17
  panY: number;
16
18
  }
17
19
  export interface Limits {
18
20
  tiltMax: number;
21
+ /** How far a hand may roll, in degrees. 180 (the default) is a full turn. */
22
+ rollMax: number;
19
23
  zoomMin: number;
20
24
  zoomMax: number;
21
25
  }
@@ -64,3 +68,49 @@ export declare function between(from: Camera, to: Camera, fraction: number): Cam
64
68
  export declare const wheelZoomFactor: (deltaY: number, deltaMode: number, kind?: "pinch" | "wheel") => number;
65
69
  /** Wheel steps closer together than this, in milliseconds, belong to one turn of the wheel or one swipe (with its glide). */
66
70
  export declare const WHEEL_GESTURE_MS = 300;
71
+ /** A rotation. `w` is the real part. The identity is `{ x: 0, y: 0, z: 0, w: 1 }`. */
72
+ export interface Quat {
73
+ x: number;
74
+ y: number;
75
+ z: number;
76
+ w: number;
77
+ }
78
+ export interface Vec3 {
79
+ x: number;
80
+ y: number;
81
+ z: number;
82
+ }
83
+ export declare const quatIdentity: () => Quat;
84
+ /** Which way a signed rate points: -1 or 1. Zero and anything that is not a number count as 1. */
85
+ export declare const rateSign: (value: number) => number;
86
+ export declare const normalizeQuat: (q: Quat) => Quat;
87
+ /**
88
+ * Yaw about Y, then tilt about X, then roll about Z, in degrees. Past a right angle the numbers fold, so a tumble that rolls over keeps its quaternion and only shows these for the sliders.
89
+ */
90
+ export declare function quatFromEuler(yaw: number, tilt: number, roll: number): Quat;
91
+ /** The yaw, tilt and roll a quaternion shows on the sliders. Tilt stays within a right angle; the quaternion itself does not flip. */
92
+ export declare function eulerFromQuat(q: Quat): {
93
+ yaw: number;
94
+ tilt: number;
95
+ roll: number;
96
+ };
97
+ /**
98
+ * Turn `q` by `yaw`, `tilt` and `roll` degrees about its own axes (Y, X, then Z). Small steps stay continuous through the poles: the up vector never jumps.
99
+ */
100
+ export declare function spinLocal(q: Quat, yaw: number, tilt: number, roll: number): Quat;
101
+ /** The view's up axis after `q`. */
102
+ export declare function upVector(q: Quat): Vec3;
103
+ /**
104
+ * Tilt swinging inside `amplitude` degrees. `phase` is radians of the swing. At the ends it comes back, so a limited tilt never rolls over.
105
+ */
106
+ export declare const pendulumTilt: (phase: number, amplitude: number) => number;
107
+ export interface TumbleSigns {
108
+ yaw: number;
109
+ tilt: number;
110
+ roll: number;
111
+ }
112
+ /**
113
+ * Degrees a second for a tumble. The three rates differ, and each drifts on its own incommensurate wave, so the motion does not repeat.
114
+ * `speed` is the size of the angle's rate. A sign below 0 reverses that axis.
115
+ */
116
+ export declare function tumbleRates(seconds: number, speed: number, signs: TumbleSigns): TumbleSigns;