@johnmorrisdotca/kazoku-ui 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-11-01
4
+
5
+ - Zoom about a point of your own: the `anchor` attribute (`x,y`), the `element.anchor` setter and the controller's `anchor` member and option take a point in CSS pixels from the view's middle at Fit. The + and - buttons, the 2x button and the + and - keys zoom about it, and it stays fixed on screen within a pixel; wheel, pinch and double tap still zoom about the pointer. With no anchor they zoom about the middle, as before. `zoomAt` and `zoomBy` with no point use it.
6
+ - Arrow Up the way a wheel goes: `key-tilt="wheel"` (option `keyTilt: "wheel"`) makes Arrow Up tip the way a wheel turned up does. The default, `drag`, still tips like a drag up.
7
+ - `stop-turn` (option `stopTurn`) stops the automatic turn on a press, the wheel or a key on the view, with a `pause` event. Off by default; buttons and sliders never stop it.
8
+ - `tilt-step` (option `tiltStep`) rounds the tilt of a drag to a multiple of this many degrees; `1` gives whole degrees. `dragTurn` takes it as a seventh argument. 0 by default.
9
+ - The demo switches them on in its address: `?anchor=pin`, `?key-tilt=wheel`, `?stop-turn`, `?tilt-step=1`.
10
+
3
11
  ## [0.2.1] - 2026-11-01
4
12
 
5
13
  - `ignore` now sees through shadow DOM: it tests the elements on the event's `composedPath()`, from the innermost up to the view, so a button inside a web component's open shadow root is left alone. Before, only the event's target (the host) was tested. A closed shadow root stays a limit: mark its host with `data-kazoku-ignore`.
package/README.md CHANGED
@@ -98,7 +98,7 @@ myPlayButton.onclick = () => view.toggle();
98
98
  myZoomIn.onclick = () => view.zoomBy(1.4);
99
99
  ```
100
100
 
101
- Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`. The controller also has `ignore` (changeable), `fitScale`, `startZoom` (the `zoom` the view opens at) and `setFitScale(n)`, `setStartZoom(zoom)`; `DEFAULT_IGNORE` is the default selector.
101
+ Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`, `anchor`, `keyTilt`, `stopTurn`, `tiltStep`. `anchor`, `keyTilt`, `stopTurn` and `tiltStep` are also members you can change at any time. The controller also has `ignore` (changeable), `fitScale`, `startZoom` (the `zoom` the view opens at) and `setFitScale(n)`, `setStartZoom(zoom)`; `DEFAULT_IGNORE` is the default selector.
102
102
 
103
103
  ### Buttons and links inside the view
104
104
 
@@ -106,6 +106,20 @@ The hands capture the pointer on the `for` element, so a press on a button or li
106
106
 
107
107
  `ignore` also sees through shadow DOM: it tests every element on the event's path from the innermost one up to the view, so a button inside a web component's open shadow root (a card's own Animate button, say) is left alone even though the event's target is the component. A closed shadow root hides its inside from the page, so only the component itself can be matched: mark the host with `data-kazoku-ignore`, or add its tag name to `ignore`.
108
108
 
109
+ ### Zooming about a point of your own
110
+
111
+ The zoom buttons and keys have no pointer, so they zoom about the view's middle. If your scene's middle is not the view's (a piece standing to one side, say), tell the controls where it is:
112
+
113
+ ```js
114
+ controls.anchor = { x: 60, y: -30 }; // pixels from the view's middle when it is at Fit
115
+ ```
116
+
117
+ Zoom is about that point of the picture, so it stays put on screen however far in the reader is and wherever the view has been moved to: `+` then `-` leaves it where it was within a pixel. Without the element, set `view.anchor` on the controller, or pass `anchor` to `createViewController`; `view.zoomBy(f)` and `view.zoomAt(z)` with no point use it.
118
+
119
+ ### Keys, the wheel and the turn
120
+
121
+ Arrow Up tips like a drag up (`tilt` gets larger). A page whose wheel and keys should agree sets `key-tilt="wheel"`: Arrow Up then tips the way a wheel turned up does (`tilt` gets smaller), and Arrow Down the other way. `stop-turn` stops the automatic turn when the reader takes hold of the view with a press, the wheel or a key (a `pause` event is sent first); the defaults keep it turning, and a button or slider never stops it. `tilt-step="1"` makes a drag give whole degrees of tilt.
122
+
109
123
  ### Opening at the scene's natural size
110
124
 
111
125
  Zoom 1 is Fit, whatever size that is on the page. A page that draws its scene at `fitScale` (for example 0.4 of natural size when it fits the view) and wants to open at natural size sets `zoom="1"` and tells the controls the scale:
@@ -146,6 +160,10 @@ An `init` event is sent once, a moment after the element connects, with the star
146
160
  | `zoom` | The starting zoom: `fit` (the default) or a number, a scale where 1 is the scene's natural size. It opens at that number divided by `fit-scale`; with `fit-scale` left at 1 it is the `zoom` of the event. |
147
161
  | `fit-scale` | The scale your page draws the scene at when it is at Fit (event `zoom` 1): what zoom 1 means. 1 by default. Or call `element.setFitScale(n)` once the scene is measured. |
148
162
  | `ignore` | A CSS selector for what inside the view a pointer press leaves alone, so its click goes through: `button, a, input, select, textarea, label, [data-kazoku-ignore]` by default. A bare `ignore=""` leaves nothing alone. |
163
+ | `anchor` | `x,y`: the point the + and - buttons, the 2x button and the + and - keys zoom about, in CSS pixels from the middle of the view when it is at Fit (zoom 1, no pan). It is a point on the picture, so it moves with it and stays fixed on screen while the buttons zoom. Left out, they zoom about the view's middle, as before. The wheel, a pinch and a double tap still zoom about the pointer. Or set `element.anchor = { x, y }` (`undefined` clears it). |
164
+ | `key-tilt` | Which way Arrow Up tips the view: `drag` (the default: like a drag up) or `wheel` (the way a wheel turned up tips it, the other way). Arrow Down goes the other way from Up. |
165
+ | `stop-turn` | A press on the view, the wheel over it or a key it handles stops the automatic turn. Off by default, so the turn carries on. Buttons and sliders never stop it. |
166
+ | `tilt-step` | Round the tilt of a drag to a multiple of this many degrees (`1` gives whole degrees). 0 by default: not rounded. |
149
167
  | `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
150
168
  | `speed` | Degrees a second (30). |
151
169
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
@@ -168,11 +186,11 @@ Only what a reader chose is kept: angle, tilt, zoom and the drag choice. The pan
168
186
 
169
187
  ## Size
170
188
 
171
- Gzipped, unbundled: the element and its definition about 5.0 KB, the controller 3.9 KB, the maths 1.4 KB, the address 1.2 KB, the labels 0.7 KB and the styles 1.6 KB (`pnpm test:package` prints them). Nothing is loaded from the network.
189
+ Gzipped, unbundled: the element and its definition about 5.0 KB, the controller 4.3 KB, the maths 1.4 KB, the address 1.2 KB, the labels 0.7 KB and the styles 1.6 KB (`pnpm test:package` prints them). Nothing is loaded from the network.
172
190
 
173
191
  ## Checks
174
192
 
175
- `pnpm check` (lint, types, 29 unit tests of the maths, the address, the controller and the package, the build and the packed package), and `pnpm test:demo` (the demo in Chromium and WebKit at a phone's width and a desk's: the controls in the first screen, a toggle during Auto-turn leaves the angle advancing, a button or link inside the view still clicks while a drag turns it, the opening zoom and fit scale, zoom about the pointer, pan when zoomed, touch, the address, the labels, reduced motion).
193
+ `pnpm check` (lint, types, 36 unit tests of the maths, the address, the controller and the package, the build and the packed package), and `pnpm test:demo` (the demo in Chromium and WebKit at a phone's width and a desk's: the controls in the first screen, a toggle during Auto-turn leaves the angle advancing, a button or link inside the view still clicks while a drag turns it, the opening zoom and fit scale, zoom about the pointer, zoom about an anchor (`?anchor=pin`), Arrow Up either way (`?key-tilt=wheel`), `?stop-turn`, `?tilt-step=1`, pan when zoomed, touch, the address, the labels, reduced motion).
176
194
 
177
195
  ## Not yet
178
196
 
@@ -14,6 +14,8 @@ export interface ChangeInfo {
14
14
  }
15
15
  /** What `bind` leaves alone by default: the page's own controls inside the view. */
16
16
  export declare const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
17
+ /** Which way Arrow Up tips the view: like a drag up (`"drag"`, the default) or the way the wheel turned up does (`"wheel"`, the other way). */
18
+ export type KeyTilt = "drag" | "wheel";
17
19
  export interface Clock {
18
20
  request(callback: (now: number) => void): number;
19
21
  cancel(id: number): void;
@@ -31,6 +33,14 @@ export interface ViewControllerOptions {
31
33
  fitScale?: number;
32
34
  /** A CSS selector for what inside the view `bind` leaves alone: a press on a match is not captured, so its click goes through. An empty string leaves nothing alone. */
33
35
  ignore?: string;
36
+ /** The point the zoom buttons and keys zoom about, in content coordinates: CSS pixels from the view's middle when it is at Fit (zoom 1, no pan). It moves with the picture, so the point stays where it is on screen while the view zooms. Left out, they zoom about the view's middle. */
37
+ anchor?: Point;
38
+ /** Which way Arrow Up tips the view: `"drag"` (the default, as a drag up) or `"wheel"` (as a wheel turned up). */
39
+ keyTilt?: KeyTilt;
40
+ /** True to stop the automatic turn when the reader presses the view, uses the wheel on it or presses a key it handles. False (the default) lets it carry on. Buttons and sliders never stop it. */
41
+ stopTurn?: boolean;
42
+ /** Round the tilt of a drag to a multiple of this many degrees; 0 (the default) leaves it unrounded, 1 gives whole degrees. */
43
+ tiltStep?: number;
34
44
  /** The view's size in CSS pixels, for holding the pan; `bind` gives it from the element when this is left out. */
35
45
  size?: () => Size | undefined;
36
46
  onChange?: (state: ViewState, info: ChangeInfo) => void;
@@ -44,6 +54,11 @@ export interface ViewController {
44
54
  readonly limits: Limits;
45
55
  /** What a pointer press leaves alone inside the view (see `ViewControllerOptions.ignore`). Can be changed at any time. */
46
56
  ignore: string;
57
+ /** The point the buttons and keys zoom about (see `ViewControllerOptions.anchor`); `undefined` for the view's middle. Can be changed at any time. */
58
+ anchor: Point | undefined;
59
+ keyTilt: KeyTilt;
60
+ stopTurn: boolean;
61
+ tiltStep: number;
47
62
  /** What zoom 1 means to the page (see `ViewControllerOptions.fitScale`). */
48
63
  readonly fitScale: number;
49
64
  /** The `state.zoom` the view opens at: 1 for "fit", else the starting scale divided by `fitScale`. The address leaves a zoom at this value out. */
@@ -61,7 +76,7 @@ export interface ViewController {
61
76
  releaseTurn(): void;
62
77
  front(): void;
63
78
  fit(): void;
64
- /** Zoom to `next` about the point `at` (client pixels; the view's middle when left out). */
79
+ /** Zoom to `next` about the point `at` (client pixels); left out, about the `anchor`, else the view's middle. */
65
80
  zoomAt(next: number, at?: Point, final?: boolean): void;
66
81
  zoomBy(factor: number, at?: Point): void;
67
82
  /** Move the view by whole fractions of its size: `panBy(1, 0)` is a quarter of its width to the right. */
@@ -1,6 +1,7 @@
1
1
  import { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
2
2
  /** What `bind` leaves alone by default: the page's own controls inside the view. */
3
3
  export const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
4
+ const HANDLED_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "_", "0", "Home"];
4
5
  const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined);
5
6
  const browserClock = () => ({ request: (callback) => globalThis.requestAnimationFrame(callback), cancel: (id) => globalThis.cancelAnimationFrame(id) });
6
7
  export function createViewController(options = {}) {
@@ -51,10 +52,12 @@ export function createViewController(options = {}) {
51
52
  const r = bound.getBoundingClientRect();
52
53
  return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
53
54
  };
54
- /** Client pixels to pixels from the view's middle. */
55
+ /** Client pixels to pixels from the view's middle; with none, the anchor's place on screen now, else the middle. */
55
56
  const fromMiddle = (at) => {
56
- if (at === undefined)
57
- return { x: 0, y: 0 };
57
+ if (at === undefined) {
58
+ const a = controller.anchor;
59
+ return a === undefined ? { x: 0, y: 0 } : { x: state.panX + state.zoom * a.x, y: state.panY + state.zoom * a.y };
60
+ }
58
61
  const c = centre();
59
62
  return { x: at.x - c.x, y: at.y - c.y };
60
63
  };
@@ -79,6 +82,10 @@ export function createViewController(options = {}) {
79
82
  speed: options.speed ?? 30,
80
83
  limits,
81
84
  ignore: options.ignore ?? DEFAULT_IGNORE,
85
+ anchor: options.anchor,
86
+ keyTilt: options.keyTilt ?? "drag",
87
+ stopTurn: options.stopTurn ?? false,
88
+ tiltStep: options.tiltStep ?? 0,
82
89
  get fitScale() {
83
90
  return fitScale;
84
91
  },
@@ -199,6 +206,10 @@ export function createViewController(options = {}) {
199
206
  holding = false;
200
207
  };
201
208
  const canTurn = () => controller.turnable;
209
+ const grab = () => {
210
+ if (controller.stopTurn)
211
+ controller.pause();
212
+ };
202
213
  const ignored = (e) => {
203
214
  if (controller.ignore === "")
204
215
  return false;
@@ -226,6 +237,7 @@ export function createViewController(options = {}) {
226
237
  return;
227
238
  if (ignored(e))
228
239
  return;
240
+ grab();
229
241
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
230
242
  try {
231
243
  view.setPointerCapture(e.pointerId);
@@ -273,7 +285,7 @@ export function createViewController(options = {}) {
273
285
  return;
274
286
  const r = view.getBoundingClientRect();
275
287
  // Up and down tips it for a mouse; on a finger the browser keeps the vertical drag to scroll the page, and the tilt slider tips it.
276
- take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits) });
288
+ take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits, controller.tiltStep) });
277
289
  emit(false, "drag");
278
290
  }
279
291
  };
@@ -310,6 +322,7 @@ export function createViewController(options = {}) {
310
322
  if (e.ctrlKey || e.metaKey) {
311
323
  // A trackpad's pinch arrives as Ctrl and the wheel; Command and the wheel does the same for a mouse.
312
324
  e.preventDefault();
325
+ grab();
313
326
  controller.zoomAt(state.zoom * wheelZoomFactor(e.deltaY, e.deltaMode), { x: e.clientX, y: e.clientY });
314
327
  return;
315
328
  }
@@ -321,6 +334,7 @@ export function createViewController(options = {}) {
321
334
  e.preventDefault();
322
335
  if (!canTurn())
323
336
  return;
337
+ grab();
324
338
  if (horizontal || e.shiftKey)
325
339
  controller.set({ yaw: state.yaw + (e.deltaX || e.deltaY) * 0.3 }, true, "wheel");
326
340
  else
@@ -329,6 +343,10 @@ export function createViewController(options = {}) {
329
343
  const onKey = (e) => {
330
344
  if (e.ctrlKey || e.metaKey || e.altKey)
331
345
  return;
346
+ if (!HANDLED_KEYS.includes(e.key))
347
+ return;
348
+ grab();
349
+ const up = controller.keyTilt === "wheel" ? -5 : 5;
332
350
  const big = e.shiftKey ? 45 : 15;
333
351
  const turn = (next) => {
334
352
  if (canTurn())
@@ -342,10 +360,10 @@ export function createViewController(options = {}) {
342
360
  turn({ yaw: state.yaw + big });
343
361
  break;
344
362
  case "ArrowUp":
345
- turn({ tilt: state.tilt + 5 });
363
+ turn({ tilt: state.tilt + up });
346
364
  break;
347
365
  case "ArrowDown":
348
- turn({ tilt: state.tilt - 5 });
366
+ turn({ tilt: state.tilt - up });
349
367
  break;
350
368
  case "+":
351
369
  case "=":
@@ -1,5 +1,6 @@
1
1
  import { type ViewController, type ViewState } from "./controller.ts";
2
2
  import { EVENT, TAG } from "./index.ts";
3
+ import { type Point } from "./math.ts";
3
4
  import { type ViewWords } from "./words.ts";
4
5
  export { EVENT, TAG };
5
6
  /** What the `kazoku-viewchange` event carries in `detail`. */
@@ -11,7 +12,8 @@ export interface ViewChangeDetail extends ViewState {
11
12
  * 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
12
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;
13
14
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `speed` (degrees a second),
14
- * `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
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
+ * `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
15
17
  * or dark), `overlay` (sit over the bottom of a positioned parent).
16
18
  */
17
19
  export declare class ViewControlsElement extends HTMLElement {
@@ -32,6 +34,9 @@ export declare class ViewControlsElement extends HTMLElement {
32
34
  front(): void;
33
35
  fit(): void;
34
36
  set(next: Partial<ViewState>): void;
37
+ /** The point the zoom buttons and keys zoom about: `{ x, y }` in CSS pixels from the view's middle when it is at Fit, moving with the picture. `undefined` zooms about the view's middle. The `anchor` attribute (`"x,y"`) sets the same. */
38
+ get anchor(): Point | undefined;
39
+ set anchor(point: Point | undefined | null);
35
40
  /** What zoom 1 means to the page: the scene is drawn at `state.zoom * fitScale`. */
36
41
  get fitScale(): number;
37
42
  setFitScale(scale: number): void;
@@ -45,11 +45,12 @@ const HTML = `
45
45
  * 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
46
46
  * 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;
47
47
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `speed` (degrees a second),
48
- * `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
48
+ * `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),
49
+ * `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
49
50
  * or dark), `overlay` (sit over the bottom of a positioned parent).
50
51
  */
51
52
  export class ViewControlsElement extends HTMLElement {
52
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "speed", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
53
+ 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"];
53
54
  #root;
54
55
  #controller;
55
56
  #view;
@@ -98,6 +99,13 @@ export class ViewControlsElement extends HTMLElement {
98
99
  set(next) {
99
100
  this.#ensure().set(next);
100
101
  }
102
+ /** The point the zoom buttons and keys zoom about: `{ x, y }` in CSS pixels from the view's middle when it is at Fit, moving with the picture. `undefined` zooms about the view's middle. The `anchor` attribute (`"x,y"`) sets the same. */
103
+ get anchor() {
104
+ return this.#ensure().anchor;
105
+ }
106
+ set anchor(point) {
107
+ this.#ensure().anchor = point ?? undefined;
108
+ }
101
109
  /** What zoom 1 means to the page: the scene is drawn at `state.zoom * fitScale`. */
102
110
  get fitScale() {
103
111
  return this.#ensure().fitScale;
@@ -122,6 +130,10 @@ export class ViewControlsElement extends HTMLElement {
122
130
  c.turnable = !this.hasAttribute("flat");
123
131
  c.speed = Number(this.getAttribute("speed")) || 30;
124
132
  c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
133
+ for (const name of ["key-tilt", "stop-turn", "tilt-step"])
134
+ this.#option(name);
135
+ if (this.hasAttribute("anchor"))
136
+ this.#option("anchor");
125
137
  const fitScale = Number(this.getAttribute("fit-scale"));
126
138
  if (this.hasAttribute("fit-scale"))
127
139
  c.setFitScale(fitScale);
@@ -171,6 +183,8 @@ export class ViewControlsElement extends HTMLElement {
171
183
  c.setFitScale(Number(value));
172
184
  else if (name === "ignore")
173
185
  c.ignore = value ?? DEFAULT_IGNORE;
186
+ else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step")
187
+ this.#option(name);
174
188
  else if (name === "speed")
175
189
  c.speed = Number(value) || 30;
176
190
  else if (name === "flat") {
@@ -182,6 +196,20 @@ export class ViewControlsElement extends HTMLElement {
182
196
  else
183
197
  this.#limits();
184
198
  }
199
+ #option(name) {
200
+ const c = this.#ensure();
201
+ const value = this.getAttribute(name);
202
+ if (name === "anchor") {
203
+ const [x, y] = (value ?? "").trim().split(/[\s,]+/).map(Number);
204
+ c.anchor = x !== undefined && y !== undefined && Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
205
+ }
206
+ else if (name === "key-tilt")
207
+ c.keyTilt = value === "wheel" ? "wheel" : "drag";
208
+ else if (name === "stop-turn")
209
+ c.stopTurn = value !== null;
210
+ else if (name === "tilt-step")
211
+ c.tiltStep = Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : 0;
212
+ }
185
213
  #startZoom() {
186
214
  const value = Number(this.getAttribute("zoom"));
187
215
  return this.hasAttribute("zoom") && Number.isFinite(value) && value > 0 ? value : "fit";
@@ -44,7 +44,7 @@ export interface PinchStart {
44
44
  }
45
45
  /** The camera for two fingers now at `a` and `b`: zoom by the change of distance about where the pinch began, and the middle of the fingers also drags the view. `centre` is the view's middle in client pixels. */
46
46
  export declare function pinchTo(start: PinchStart, a: Point, b: Point, centre: Point, limits?: Limits): Camera;
47
- /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). */
48
- export declare function dragTurn(start: Camera, dx: number, dy: number, size: Size, tips: boolean, limits?: Limits): Pick<Camera, "yaw" | "tilt">;
47
+ /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it. */
48
+ export declare function dragTurn(start: Camera, dx: number, dy: number, size: Size, tips: boolean, limits?: Limits, step?: number): Pick<Camera, "yaw" | "tilt">;
49
49
  /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
50
50
  export declare const wheelZoomFactor: (deltaY: number, deltaMode: number) => number;
@@ -38,10 +38,11 @@ export function pinchTo(start, a, b, centre, limits = DEFAULT_LIMITS) {
38
38
  return zoomed;
39
39
  return { ...zoomed, panX: zoomed.panX + (middle.x - start.middle.x), panY: zoomed.panY + (middle.y - start.middle.y) };
40
40
  }
41
- /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). */
42
- export function dragTurn(start, dx, dy, size, tips, limits = DEFAULT_LIMITS) {
41
+ /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it. */
42
+ export function dragTurn(start, dx, dy, size, tips, limits = DEFAULT_LIMITS, step = 0) {
43
43
  const yaw = wrapYaw(start.yaw + (dx / (size.width || 360)) * TURN_ACROSS);
44
- const tilt = tips ? clamp(start.tilt - (dy / (size.height || 360)) * TILT_ACROSS, -limits.tiltMax, limits.tiltMax) : start.tilt;
44
+ const raw = start.tilt - (dy / (size.height || 360)) * TILT_ACROSS;
45
+ const tilt = tips ? clamp(step > 0 ? Math.round(raw / step) * step : raw, -limits.tiltMax, limits.tiltMax) : start.tilt;
45
46
  return { yaw, tilt };
46
47
  }
47
48
  /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kazoku-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Kazoku UI 家族, the family's shared interface parts as their own versioned package. First part: view controls for any 3D or zoomable view (angle, tilt, auto-turn, zoom about the pointer, pan, touch, address state) as a framework-free custom element and a plain controller. Zero dependencies.",
5
5
  "keywords": [
6
6
  "view-controls",