@johnmorrisdotca/kazoku-ui 0.15.2 → 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) {
@@ -12,7 +12,7 @@ export interface ViewChangeDetail extends ViewState {
12
12
  /**
13
13
  * 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
14
14
  * 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;
15
- * 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 at a pace of 1), `pace` (the auto-turn speed as a multiple of `speed`, 1 by default; the reader's choice of the buttons is written here and kept in the address as `pace`), `paces` (the speeds the buttons stand for, `0.5 1 2` by default),
15
+ * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop a turn by itself after this many seconds of turning: the one `autoplay` starts, and one the Play button or `play()` starts), `speed` (degrees a second at a pace of 1; a negative one starts the turn to the left), `direction` (`left` or `right`: which way Auto-turn goes; the reader's choice of the Left and Right buttons is written here and kept in the address as `direction`, and the angle goes on from where it was at the same speed), `pace` (the auto-turn speed as a multiple of `speed`, 1 by default; the reader's choice of the buttons is written here and kept in the address as `pace`), `paces` (the speeds the buttons stand for, `0.5 1 2` by default),
16
16
  * `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; the wheel itself zooms),
17
17
  * `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
18
18
  * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
@@ -32,7 +32,8 @@ export declare class ViewControlsElement extends HTMLElement {
32
32
  set view(element: HTMLElement | undefined);
33
33
  /** Your own labels over the language's, for example `{ front: "Home" }`. */
34
34
  set words(words: Partial<ViewWords>);
35
- play(): void;
35
+ /** Turn by itself. With `seconds`, stop after that many seconds of turning; else the `autoplay-for` attribute's, if there is one. */
36
+ play(seconds?: number): void;
36
37
  pause(): void;
37
38
  front(): void;
38
39
  /** Put the object back in the middle at Fit and the default angle, with a brief move (at once for a reader who prefers reduced motion, or with `false`). */
@@ -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, 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,10 +35,23 @@ 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
- <kazoku-speed-buttons class="pace adv" data-g="pace"></kazoku-speed-buttons>
48
+ <div class="pacing adv" data-g="pacing">
49
+ <kazoku-speed-buttons class="pace" data-g="pace"></kazoku-speed-buttons>
50
+ <div class="group dir" role="group" data-g="direction">
51
+ <button type="button" data-b="turn-left" aria-pressed="false" part="button">${ICON.left}<span data-w="turnLeft"></span></button>
52
+ <button type="button" data-b="turn-right" aria-pressed="true" part="button">${ICON.right}<span data-w="turnRight"></span></button>
53
+ </div>
54
+ </div>
42
55
  <button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
43
56
  <button type="button" class="centre" data-b="centre" part="button" hidden>${ICON.centre}<span class="text" data-w="centre"></span></button>
44
57
  <div class="group zoom" role="group" data-g="zoom">
@@ -64,7 +77,7 @@ const HTML = `
64
77
  /**
65
78
  * 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
66
79
  * 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;
67
- * 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 at a pace of 1), `pace` (the auto-turn speed as a multiple of `speed`, 1 by default; the reader's choice of the buttons is written here and kept in the address as `pace`), `paces` (the speeds the buttons stand for, `0.5 1 2` by default),
80
+ * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop a turn by itself after this many seconds of turning: the one `autoplay` starts, and one the Play button or `play()` starts), `speed` (degrees a second at a pace of 1; a negative one starts the turn to the left), `direction` (`left` or `right`: which way Auto-turn goes; the reader's choice of the Left and Right buttons is written here and kept in the address as `direction`, and the angle goes on from where it was at the same speed), `pace` (the auto-turn speed as a multiple of `speed`, 1 by default; the reader's choice of the buttons is written here and kept in the address as `pace`), `paces` (the speeds the buttons stand for, `0.5 1 2` by default),
68
81
  * `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; the wheel itself zooms),
69
82
  * `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
70
83
  * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
@@ -72,7 +85,7 @@ const HTML = `
72
85
  * or dark), `overlay` (sit over the bottom of a positioned parent).
73
86
  */
74
87
  export class ViewControlsElement extends HTMLElement {
75
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "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"];
76
89
  #root;
77
90
  #controller;
78
91
  #view;
@@ -91,6 +104,13 @@ export class ViewControlsElement extends HTMLElement {
91
104
  /** The zoom the slider shows now; NaN until it has shown one, and again when the marks are redrawn for new limits. */
92
105
  #zoomShown = Number.NaN;
93
106
  #pace = 1;
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. */
108
+ #direction = "right";
109
+ #start;
110
+ #axis = "angle";
111
+ #tiltWay = "right";
112
+ #rollWay = "right";
113
+ #writing = false;
94
114
  #labels = wordsFor("en");
95
115
  #said = "";
96
116
  #sayTimer;
@@ -118,8 +138,9 @@ export class ViewControlsElement extends HTMLElement {
118
138
  this.#words = words;
119
139
  this.#label();
120
140
  }
121
- play() {
122
- this.#ensure().play();
141
+ /** Turn by itself. With `seconds`, stop after that many seconds of turning; else the `autoplay-for` attribute's, if there is one. */
142
+ play(seconds) {
143
+ this.#ensure().play(seconds ?? this.#playLimit());
123
144
  }
124
145
  pause() {
125
146
  this.#ensure().pause();
@@ -162,16 +183,35 @@ export class ViewControlsElement extends HTMLElement {
162
183
  this.#arrange();
163
184
  this.#limits();
164
185
  const start = {};
165
- for (const key of ["yaw", "tilt"]) {
186
+ for (const key of ["yaw", "tilt", "roll"]) {
166
187
  const value = Number(this.getAttribute(key));
167
188
  if (this.hasAttribute(key) && Number.isFinite(value))
168
189
  start[key] = value;
169
190
  }
170
- const { pace, ...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) : {};
171
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
+ }
172
208
  c.turnable = !this.hasAttribute("flat");
173
209
  this.#level = parseLevel(this.#remembered()) ?? parseLevel(this.getAttribute("controls")) ?? "basic";
174
210
  this.#pace = this.#nearestPace(pace ?? parseSpeed(this.getAttribute("pace"), this.#paces()) ?? 1);
211
+ // Read once: put back on a page after a move, the element is still started by the page and not by the way the reader has since chosen (which is in the attribute now).
212
+ const first = this.#start === undefined;
213
+ this.#start ??= this.#startDirection();
214
+ this.#direction = direction ?? (first ? this.#start : this.#direction);
175
215
  this.#speed();
176
216
  c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
177
217
  for (const name of ["key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size"])
@@ -197,7 +237,7 @@ export class ViewControlsElement extends HTMLElement {
197
237
  return;
198
238
  this.#dispatch(c.state, { final: true, reason: "init" });
199
239
  if (this.hasAttribute("autoplay") && !globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
200
- c.play(Number(this.getAttribute("autoplay-for")));
240
+ c.play(this.#playLimit());
201
241
  });
202
242
  }
203
243
  disconnectedCallback() {
@@ -223,10 +263,15 @@ export class ViewControlsElement extends HTMLElement {
223
263
  this.#resolveView();
224
264
  else if (name === "lang")
225
265
  this.#label();
226
- else if (name === "yaw" || name === "tilt") {
266
+ else if (name === "yaw" || name === "tilt" || name === "roll") {
227
267
  if (value !== null && Number.isFinite(Number(value)))
228
268
  c.set({ [name]: Number(value) });
229
269
  }
270
+ else if (name === "axis") {
271
+ this.#axis = parseAxis(value) ?? "angle";
272
+ this.#speed();
273
+ this.#show();
274
+ }
230
275
  else if (name === "zoom")
231
276
  c.setStartZoom(this.#startZoom());
232
277
  else if (name === "fit-scale")
@@ -235,8 +280,33 @@ export class ViewControlsElement extends HTMLElement {
235
280
  c.ignore = value ?? DEFAULT_IGNORE;
236
281
  else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step" || name === "grab-radius" || name === "object-size")
237
282
  this.#option(name);
238
- else if (name === "speed")
283
+ else if (name === "speed") {
284
+ // The page's own start follows its speed's sign, unless the reader has chosen the other way.
285
+ const start = this.#startDirection();
286
+ if (this.#direction === (this.#start ?? start))
287
+ this.#direction = start;
288
+ this.#start = start;
289
+ this.#speed();
290
+ this.#show();
291
+ }
292
+ else if (name === "direction") {
293
+ if (this.#writing)
294
+ return;
295
+ this.#start = this.#direction = this.#startDirection();
239
296
  this.#speed();
297
+ this.#show();
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
+ }
240
310
  else if (name === "pace" || name === "paces") {
241
311
  this.#pace = this.#nearestPace(parseSpeed(this.getAttribute("pace"), this.#paces()) ?? this.#pace);
242
312
  this.#speed();
@@ -312,30 +382,38 @@ export class ViewControlsElement extends HTMLElement {
312
382
  const c = () => this.#ensure();
313
383
  const slider = (id) => {
314
384
  const input = q(`#${id}`);
315
- // 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.
316
386
  const hold = () => {
317
387
  this.#sliderHeld += 1;
318
- if (this.#sliderHeld === 1 && id === "yaw")
388
+ if (this.#sliderHeld === 1)
319
389
  c().holdTurn();
320
390
  };
321
391
  const free = () => {
322
392
  if (this.#sliderHeld === 0)
323
393
  return;
324
394
  this.#sliderHeld = 0;
325
- if (id === "yaw")
326
- c().releaseTurn();
395
+ c().releaseTurn();
327
396
  };
328
397
  input.addEventListener("pointerdown", () => hold());
329
398
  input.addEventListener("pointerup", free);
330
399
  input.addEventListener("pointercancel", free);
331
- input.addEventListener("input", () => c().set({ [id]: Number(input.value) }, false, "slider"));
332
- 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"));
333
402
  };
334
403
  slider("yaw");
335
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
+ }
336
409
  this.#zoomSlider(q("#zoom"), c);
337
410
  const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
338
- on("play", () => c().toggle());
411
+ on("play", () => {
412
+ if (c().state.turning)
413
+ c().pause();
414
+ else
415
+ c().play(this.#playLimit());
416
+ });
339
417
  on("front", () => c().front());
340
418
  on("out", () => c().zoomBy(1 / ZOOM_STEP));
341
419
  on("in", () => c().zoomBy(ZOOM_STEP));
@@ -357,6 +435,8 @@ export class ViewControlsElement extends HTMLElement {
357
435
  this.#choosePace(value);
358
436
  });
359
437
  on("pace-now", () => speeds.step(1));
438
+ on("turn-left", () => this.#chooseDirection("left"));
439
+ on("turn-right", () => this.#chooseDirection("right"));
360
440
  on("left", () => c().panBy(-1, 0));
361
441
  on("right", () => c().panBy(1, 0));
362
442
  on("up", () => c().panBy(0, -1));
@@ -462,9 +542,80 @@ export class ViewControlsElement extends HTMLElement {
462
542
  if (this.hasAttribute("address"))
463
543
  this.#save();
464
544
  }
465
- /** The turn's degrees a second: the `speed` attribute (30) times the pace. */
545
+ /** The way the page itself starts the turn: its `direction`, else left for a negative `speed` and right for the rest. */
546
+ #startDirection() {
547
+ return parseDirection(this.getAttribute("direction")) ?? (Number(this.getAttribute("speed")) < 0 ? "left" : "right");
548
+ }
549
+ /** The seconds `autoplay-for` limits a turn to, or `undefined` for none. */
550
+ #playLimit() {
551
+ const seconds = Number(this.getAttribute("autoplay-for"));
552
+ return seconds > 0 && Number.isFinite(seconds) ? seconds : undefined;
553
+ }
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. */
555
+ #chooseDirection(direction) {
556
+ const axes = this.#axis === "all" ? ["angle", "tilt", "roll"] : [this.#axis];
557
+ for (const axis of axes)
558
+ this.#putWay(axis, direction);
559
+ this.#speed();
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) {
583
+ this.#writing = true;
584
+ if (written === null)
585
+ this.removeAttribute("axis");
586
+ else
587
+ this.setAttribute("axis", written);
588
+ this.#writing = false;
589
+ }
590
+ this.#speed();
591
+ this.#show();
592
+ if (this.hasAttribute("address"))
593
+ this.#save();
594
+ }
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. */
466
612
  #speed() {
467
- this.#ensure().speed = (Number(this.getAttribute("speed")) || 30) * this.#pace;
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);
468
619
  }
469
620
  #limits() {
470
621
  const c = this.#ensure();
@@ -473,6 +624,7 @@ export class ViewControlsElement extends HTMLElement {
473
624
  return this.hasAttribute(name) && Number.isFinite(value) && value > 0 ? value : fallback;
474
625
  };
475
626
  c.limits.tiltMax = read("tilt-max", 30);
627
+ c.limits.rollMax = read("roll-max", 180);
476
628
  c.limits.zoomMin = Math.min(1, read("zoom-min", 1));
477
629
  c.limits.zoomMax = Math.max(c.limits.zoomMin, read("zoom-max", 5));
478
630
  const tilt = this.#root.querySelector("#tilt");
@@ -480,6 +632,11 @@ export class ViewControlsElement extends HTMLElement {
480
632
  tilt.min = String(-c.limits.tiltMax);
481
633
  tilt.max = String(c.limits.tiltMax);
482
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
+ }
483
640
  this.#drawMarks();
484
641
  }
485
642
  #resolveView() {
@@ -522,6 +679,8 @@ export class ViewControlsElement extends HTMLElement {
522
679
  set('[data-b="in"]', words.zoomIn);
523
680
  set('[data-g="zoom"]', words.zoom);
524
681
  set('[data-g="pan"]', words.pan);
682
+ set('[data-g="direction"]', words.direction);
683
+ set('[data-g="axes"]', words.axis);
525
684
  set('[data-g="drag"]', words.drag);
526
685
  const speeds = this.#root.querySelector("kazoku-speed-buttons");
527
686
  speeds?.setAttribute("lang", language);
@@ -540,17 +699,37 @@ export class ViewControlsElement extends HTMLElement {
540
699
  const q = (selector) => this.#root.querySelector(selector);
541
700
  const yaw = q("#yaw");
542
701
  const tilt = q("#tilt");
543
- yaw.value = String(Math.round(s.yaw));
702
+ const roll = q("#roll");
544
703
  tilt.value = String(Math.round(s.tilt));
545
- 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}°`;
546
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
+ }
547
727
  q('[data-b="play"]').setAttribute("aria-pressed", String(s.turning));
548
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.
549
729
  q(".centre").hidden = s.zoom === 1 && s.panX === 0 && s.panY === 0;
550
730
  q('[data-b="out"]').disabled = s.zoom <= c.limits.zoomMin + 1e-6;
551
731
  q('[data-b="in"]').disabled = s.zoom >= c.limits.zoomMax - 1e-6;
552
732
  const advanced = this.#level === "advanced";
553
- const words = this.#labels;
554
733
  const zoom = zoomText(s.zoom);
555
734
  // A frame of the automatic turn comes through here: what has not changed is not written.
556
735
  const write = (node, text) => {
@@ -586,6 +765,9 @@ export class ViewControlsElement extends HTMLElement {
586
765
  const pace = speedText(this.#pace);
587
766
  write(q('[data-o="pace"]'), pace);
588
767
  say(q('[data-b="pace-now"]'), `${words.speed}: ${pace}`);
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"));
589
771
  const more = q('[data-b="more"]');
590
772
  more.setAttribute("aria-expanded", String(advanced));
591
773
  this.dataset.level = this.#level;
@@ -596,9 +778,9 @@ export class ViewControlsElement extends HTMLElement {
596
778
  q('[data-b="turn"]').setAttribute("aria-pressed", String(s.drag === "turn"));
597
779
  // A flat view cannot turn: the angle, tilt, auto-turn and front are not shown, and a drag only moves.
598
780
  const flat = !c.turnable;
599
- for (const selector of [".angle", ".play"])
781
+ for (const selector of [".angle", ".tilt", ".roll", ".play", '[data-g="axes"]'])
600
782
  q(selector).hidden = flat;
601
- for (const selector of [".tilt", ".front", '[data-g="drag"]', '[data-g="pace"]'])
783
+ for (const selector of [".front", '[data-g="drag"]', '[data-g="pacing"]'])
602
784
  q(selector).hidden = flat || !advanced;
603
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.
604
786
  q(".pace-now").hidden = flat || advanced || !s.turning;
@@ -636,7 +818,14 @@ export class ViewControlsElement extends HTMLElement {
636
818
  #save() {
637
819
  this.#writer.write((search) => {
638
820
  const s = this.#ensure().state;
639
- return formatAddress(search, { ...s, pace: this.#pace }, 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);
640
829
  });
641
830
  }
642
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";
@@ -8,6 +8,12 @@ export type Pace = "slow" | "medium" | "fast";
8
8
  export declare const PACES: readonly Pace[];
9
9
  export declare const PACE_FACTOR: Record<Pace, number>;
10
10
  export declare const parsePace: (text: string | null | undefined) => Pace | undefined;
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
+ export type Direction = "left" | "right";
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;
11
17
  /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
12
18
  export declare const zoomText: (zoom: number) => string;
13
19
  /**
@@ -5,6 +5,8 @@ export const LEVEL_STORAGE_KEY = "kazoku-view-controls";
5
5
  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
+ 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);
8
10
  /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
9
11
  export const zoomText = (zoom) => `${Number(zoom < 1 ? zoom.toPrecision(2) : zoom.toFixed(1))}×`;
10
12
  export const LAYOUTS = ["classic", "horizontal", "vertical", "compact"];