@johnmorrisdotca/kazoku-ui 0.1.0 → 0.2.1

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1] - 2026-11-01
4
+
5
+ - `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`.
6
+
7
+ ## [0.2.0] - 2026-11-01
8
+
9
+ - A button, link or form control inside the view still works: a pointer press on a child that matches the new `ignore` selector is not captured, so the click goes through and a drag from the empty view still turns it. Attribute `ignore` and `createViewController` option `ignore`; the default is `button, a, input, select, textarea, label, [data-kazoku-ignore]` (`DEFAULT_IGNORE`); `ignore=""` leaves nothing alone.
10
+ - Open at a zoom set against the page's fit scale: `zoom` is `fit` (the default, unchanged) or a number where 1 is the scene's natural size, and `fit-scale` / `setFitScale(n)` / the `fitScale` option tell the controls what zoom 1 means. The event's `zoom` stays 1 at Fit. New controller members `fitScale`, `startZoom`, `setStartZoom`; a new `fit-scale` reason in the event. `formatAddress` takes a fifth argument, `startZoom`, so the opening zoom keeps the address plain.
11
+
3
12
  ## [0.1.0] - 2026-11-01
4
13
 
5
14
  The first release, and its first part: view controls, at `@johnmorrisdotca/kazoku-ui/view-controls`.
package/README.md CHANGED
@@ -98,6 +98,27 @@ 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.
102
+
103
+ ### Buttons and links inside the view
104
+
105
+ The hands capture the pointer on the `for` element, so a press on a button or link placed inside it would be taken for a drag and its click lost. A press on anything that matches `ignore` is left alone (no capture, no drag, no double-tap), and the click goes through. Mark any other child with `data-kazoku-ignore`. Dragging from the empty part of the view turns it as before.
106
+
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
+
109
+ ### Opening at the scene's natural size
110
+
111
+ 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:
112
+
113
+ ```js
114
+ const controls = document.querySelector("kazoku-view-controls");
115
+ controls.setAttribute("zoom", "1"); // natural size
116
+ controls.setFitScale(measure()); // the scale the scene is drawn at when it fits, for example 0.4
117
+ // the event's zoom is now 2.5; draw at event.detail.zoom * controls.fitScale
118
+ ```
119
+
120
+ `setFitScale` moves the view to the opening zoom for the new scale, unless the reader has already zoomed. Fit and the Fit key still go to zoom 1, and the address leaves a zoom out when it is the opening one. `zoom="fit"`, or no `zoom`, changes nothing from before.
121
+
101
122
  ## The event API
102
123
 
103
124
  `kazoku-viewchange` is dispatched on the element (it bubbles and crosses shadow roots). `event.detail`, and the first argument of `onChange`, is:
@@ -106,12 +127,12 @@ myZoomIn.onclick = () => view.zoomBy(1.4);
106
127
  |---|---|
107
128
  | `yaw` | Degrees, -180 up to 180: the turn about the vertical. Dragging right makes it larger. |
108
129
  | `tilt` | Degrees, held to plus or minus `tilt-max` (30): the top tips away as it gets larger (dragging up). |
109
- | `zoom` | 1 to 5 (`zoom-min`, `zoom-max`). |
130
+ | `zoom` | 1 to 5 (`zoom-min`, `zoom-max`): 1 is Fit. The scene is drawn at `zoom * fitScale` (see below). |
110
131
  | `panX`, `panY` | CSS pixels the zoomed picture is moved from the middle of the view; 0 at 1x. |
111
132
  | `turning` | True while Auto-turn is running. |
112
133
  | `drag` | `"move"` or `"turn"`: what a plain drag does while zoomed in. |
113
134
  | `final` | False for the steps of a drag, a pinch, a slider and the automatic turn; true when something settled. Do the costly drawing on `final`, the cheap transform on every event. |
114
- | `reason` | What changed it: `init`, `auto`, `drag`, `pinch`, `wheel`, `key`, `slider`, `zoom`, `pan`, `front`, `fit`, `play`, `pause` or `set`. |
135
+ | `reason` | What changed it: `init`, `auto`, `drag`, `pinch`, `wheel`, `key`, `slider`, `zoom`, `pan`, `front`, `fit`, `fit-scale`, `play`, `pause` or `set`. |
115
136
 
116
137
  An `init` event is sent once, a moment after the element connects, with the starting view (from the attributes and the address). `element.state` is the current view at any time.
117
138
 
@@ -121,7 +142,10 @@ An `init` event is sent once, a moment after the element connects, with the star
121
142
  |---|---|
122
143
  | `for` | The id of the view the hands bind to. Or set `element.view = someElement`. |
123
144
  | `lang` | `en` or `ja`. Otherwise the nearest `lang` above it, else English. |
124
- | `yaw`, `tilt`, `zoom` | The starting view. |
145
+ | `yaw`, `tilt` | The starting angle and tilt. |
146
+ | `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
+ | `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
+ | `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. |
125
149
  | `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
126
150
  | `speed` | Degrees a second (30). |
127
151
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
@@ -131,12 +155,12 @@ An `init` event is sent once, a moment after the element connects, with the star
131
155
  | `theme` | `light` or `dark`; otherwise the page's colour scheme. |
132
156
  | `overlay` | Sit over the bottom of a positioned parent. |
133
157
 
134
- Methods: `play()`, `pause()`, `front()`, `fit()`, `set({ yaw, tilt, zoom, panX, panY, drag })`, and `controller` (the `createViewController` object behind it). `element.words = { front: "Home" }` replaces labels.
158
+ Methods: `play()`, `pause()`, `front()`, `fit()`, `set({ yaw, tilt, zoom, panX, panY, drag })`, `setFitScale(n)`, and `controller` (the `createViewController` object behind it). `element.words = { front: "Home" }` replaces labels.
135
159
  Colours are custom properties: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-line`, `--kazoku-accent`; the parts are `bar`, `field`, `slider` and `button`.
136
160
 
137
161
  ### The address
138
162
 
139
- Only what a reader chose is kept: angle, tilt, zoom and the drag choice. The pan is in pixels of one screen and the turning is of a moment, so neither is; while the view turns by itself the angle is left out. A value at its start leaves the address, and other parameters are kept. The address is written at most four times a second (Safari stops a page that changes its address more than 100 times in 30 seconds). Without the element: `parseAddress(search, keys?)` and `formatAddress(search, choices, keys?, leaveYaw?)`.
163
+ Only what a reader chose is kept: angle, tilt, zoom and the drag choice. The pan is in pixels of one screen and the turning is of a moment, so neither is; while the view turns by itself the angle is left out. A value at its start (Fit, or the `zoom` the page opens at) leaves the address, and other parameters are kept. The address is written at most four times a second (Safari stops a page that changes its address more than 100 times in 30 seconds). Without the element: `parseAddress(search, keys?)` and `formatAddress(search, choices, keys?, leaveYaw?, startZoom?)`.
140
164
 
141
165
  ### Why the tag is only defined when it is free
142
166
 
@@ -144,11 +168,11 @@ Only what a reader chose is kept: angle, tilt, zoom and the drag choice. The pan
144
168
 
145
169
  ## Size
146
170
 
147
- Gzipped, unbundled: the element and its definition about 4.8 KB, the controller 3.5 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.
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.
148
172
 
149
173
  ## Checks
150
174
 
151
- `pnpm check` (lint, types, 25 unit tests of the maths, the address and the controller, 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, zoom about the pointer, pan when zoomed, touch, the address, the labels, reduced motion).
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).
152
176
 
153
177
  ## Not yet
154
178
 
@@ -17,8 +17,8 @@ export interface AddressChoices {
17
17
  export declare function parseAddress(search: string, keys?: AddressKeys, limits?: Limits): Partial<AddressChoices>;
18
18
  /**
19
19
  * The query string (with its `?`, or empty) that keeps `choices` and every other parameter of `search`. A choice at its starting value is left out, so
20
- * the plain address stays plain. `leaveYaw` keeps the angle out while the view turns by itself (it would change every frame).
20
+ * the plain address stays plain. `leaveYaw` keeps the angle out while the view turns by itself (it would change every frame). `startZoom` is the zoom the view opens at (1, Fit, unless the page starts elsewhere).
21
21
  */
22
- export declare function formatAddress(search: string, choices: AddressChoices, keys?: AddressKeys, leaveYaw?: boolean): string;
22
+ export declare function formatAddress(search: string, choices: AddressChoices, keys?: AddressKeys, leaveYaw?: boolean, startZoom?: number): string;
23
23
  /** Read `address-keys`: `yaw:a,tilt:b` renames the keys it names and leaves the others. */
24
24
  export declare function parseKeys(text: string | null): AddressKeys;
@@ -30,9 +30,9 @@ export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMIT
30
30
  }
31
31
  /**
32
32
  * The query string (with its `?`, or empty) that keeps `choices` and every other parameter of `search`. A choice at its starting value is left out, so
33
- * the plain address stays plain. `leaveYaw` keeps the angle out while the view turns by itself (it would change every frame).
33
+ * the plain address stays plain. `leaveYaw` keeps the angle out while the view turns by itself (it would change every frame). `startZoom` is the zoom the view opens at (1, Fit, unless the page starts elsewhere).
34
34
  */
35
- export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = false) {
35
+ export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = false, startZoom = 1) {
36
36
  const params = new URLSearchParams(search);
37
37
  const set = (key, value) => {
38
38
  if (value === undefined)
@@ -43,7 +43,7 @@ export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = f
43
43
  if (!leaveYaw)
44
44
  set(keys.yaw, Math.round(choices.yaw) === 0 ? undefined : String(rounded(wrapYaw(choices.yaw), 0)));
45
45
  set(keys.tilt, Math.round(choices.tilt) === 0 ? undefined : String(rounded(choices.tilt, 0)));
46
- set(keys.zoom, Math.abs(choices.zoom - 1) < 0.005 ? undefined : String(rounded(choices.zoom, 2)));
46
+ set(keys.zoom, Math.abs(choices.zoom - startZoom) < 0.005 ? undefined : String(rounded(choices.zoom, 2)));
47
47
  set(keys.drag, choices.drag === "turn" ? "turn" : undefined);
48
48
  const text = params.toString();
49
49
  return text === "" ? "" : `?${text}`;
@@ -12,6 +12,8 @@ export interface ChangeInfo {
12
12
  /** What changed it: "set", "auto", "drag", "pinch", "wheel", "key", "zoom", "pan", "front", "fit", "play", "pause", "tap". */
13
13
  reason: string;
14
14
  }
15
+ /** What `bind` leaves alone by default: the page's own controls inside the view. */
16
+ export declare const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
15
17
  export interface Clock {
16
18
  request(callback: (now: number) => void): number;
17
19
  cancel(id: number): void;
@@ -23,6 +25,12 @@ export interface ViewControllerOptions {
23
25
  /** Degrees a second when it turns by itself. */
24
26
  speed?: number;
25
27
  limits?: Partial<Limits>;
28
+ /** The starting zoom: "fit" (the default) or a scale where 1 is the scene's natural size; it opens at that scale divided by `fitScale`. With `fitScale` left at 1 a number is a zoom as `ViewState.zoom` has it. `initial.zoom` is used only when this is left out. */
29
+ zoom?: "fit" | number;
30
+ /** The page's scale when the view is at Fit (`state.zoom` 1): what zoom 1 means. The scene is drawn at `state.zoom * fitScale`. 1 by default. */
31
+ fitScale?: number;
32
+ /** 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
+ ignore?: string;
26
34
  /** The view's size in CSS pixels, for holding the pan; `bind` gives it from the element when this is left out. */
27
35
  size?: () => Size | undefined;
28
36
  onChange?: (state: ViewState, info: ChangeInfo) => void;
@@ -34,6 +42,16 @@ export interface ViewController {
34
42
  turnable: boolean;
35
43
  speed: number;
36
44
  readonly limits: Limits;
45
+ /** What a pointer press leaves alone inside the view (see `ViewControllerOptions.ignore`). Can be changed at any time. */
46
+ ignore: string;
47
+ /** What zoom 1 means to the page (see `ViewControllerOptions.fitScale`). */
48
+ readonly fitScale: number;
49
+ /** 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. */
50
+ readonly startZoom: number;
51
+ /** Tell the controller what zoom 1 means. If the reader has not zoomed since the start, the view moves to the starting zoom for the new scale. */
52
+ setFitScale(scale: number): void;
53
+ /** Start at "fit" or at a scale relative to natural size, and go there now. */
54
+ setStartZoom(zoom: "fit" | number): void;
37
55
  set(next: Partial<ViewState>, final?: boolean, reason?: string): void;
38
56
  play(): void;
39
57
  pause(): void;
@@ -1,4 +1,7 @@
1
1
  import { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
2
+ /** What `bind` leaves alone by default: the page's own controls inside the view. */
3
+ export const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
4
+ const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined);
2
5
  const browserClock = () => ({ request: (callback) => globalThis.requestAnimationFrame(callback), cancel: (id) => globalThis.cancelAnimationFrame(id) });
3
6
  export function createViewController(options = {}) {
4
7
  const limits = { ...DEFAULT_LIMITS, ...options.limits };
@@ -8,7 +11,14 @@ export function createViewController(options = {}) {
8
11
  let frame = 0;
9
12
  let last = 0;
10
13
  let holds = 0;
14
+ let fitScale = positive(options.fitScale) ?? 1;
15
+ let start = positive(options.zoom) ?? "fit";
16
+ let atStart = true;
17
+ const startState = () => (start === "fit" ? 1 : start / fitScale);
11
18
  const state = { yaw: 0, tilt: 0, zoom: 1, panX: 0, panY: 0, turning: false, drag: "move", ...options.initial };
19
+ if (start !== "fit")
20
+ state.zoom = clamp(startState(), limits.zoomMin, limits.zoomMax);
21
+ atStart = state.zoom === clamp(startState(), limits.zoomMin, limits.zoomMax);
12
22
  const size = () => {
13
23
  const own = sizeOf?.();
14
24
  if (own !== undefined)
@@ -29,6 +39,8 @@ export function createViewController(options = {}) {
29
39
  const held = clampPan({ ...next, zoom: clamp(next.zoom, limits.zoomMin, limits.zoomMax), tilt: clamp(next.tilt, -limits.tiltMax, limits.tiltMax) }, size());
30
40
  state.yaw = controller.turnable ? wrapYaw(held.yaw) : state.yaw;
31
41
  state.tilt = controller.turnable ? held.tilt : state.tilt;
42
+ if (held.zoom !== state.zoom)
43
+ atStart = false;
32
44
  state.zoom = held.zoom;
33
45
  state.panX = held.panX;
34
46
  state.panY = held.panY;
@@ -66,6 +78,32 @@ export function createViewController(options = {}) {
66
78
  turnable: options.turnable ?? true,
67
79
  speed: options.speed ?? 30,
68
80
  limits,
81
+ ignore: options.ignore ?? DEFAULT_IGNORE,
82
+ get fitScale() {
83
+ return fitScale;
84
+ },
85
+ get startZoom() {
86
+ return clamp(startState(), limits.zoomMin, limits.zoomMax);
87
+ },
88
+ setFitScale(scale) {
89
+ const next = positive(scale);
90
+ if (next === undefined || next === fitScale)
91
+ return;
92
+ fitScale = next;
93
+ if (!atStart)
94
+ return;
95
+ const before = state.zoom;
96
+ take({ ...camera(), zoom: startState() });
97
+ atStart = true;
98
+ if (state.zoom !== before)
99
+ emit(true, "fit-scale");
100
+ },
101
+ setStartZoom(zoom) {
102
+ start = positive(zoom) ?? "fit";
103
+ take({ ...camera(), zoom: startState() });
104
+ atStart = true;
105
+ emit(true, "set");
106
+ },
69
107
  set(next, final = true, reason = "set") {
70
108
  const { turning, drag, ...cam } = next;
71
109
  take({ ...camera(), ...cam });
@@ -161,9 +199,33 @@ export function createViewController(options = {}) {
161
199
  holding = false;
162
200
  };
163
201
  const canTurn = () => controller.turnable;
202
+ const ignored = (e) => {
203
+ if (controller.ignore === "")
204
+ return false;
205
+ const path = typeof e.composedPath === "function" ? e.composedPath() : [];
206
+ try {
207
+ if (path.length === 0) {
208
+ const target = e.target;
209
+ const hit = typeof target?.closest === "function" ? target.closest(controller.ignore) : null;
210
+ return hit !== null && hit !== view && view.contains(hit);
211
+ }
212
+ for (const node of path) {
213
+ if (node === view)
214
+ return false;
215
+ if (typeof node.matches === "function" && node.matches(controller.ignore))
216
+ return true;
217
+ }
218
+ }
219
+ catch {
220
+ return false;
221
+ }
222
+ return false;
223
+ };
164
224
  const onDown = (e) => {
165
225
  if (e.pointerType === "mouse" && e.button > 2)
166
226
  return;
227
+ if (ignored(e))
228
+ return;
167
229
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
168
230
  try {
169
231
  view.setPointerCapture(e.pointerId);
@@ -8,9 +8,11 @@ export interface ViewChangeDetail extends ViewState {
8
8
  reason: string;
9
9
  }
10
10
  /**
11
- * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt`, `zoom` (the starting view), `autoplay` (turn by itself from the
12
- * start, unless the reader prefers reduced motion), `speed` (degrees a second), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the
13
- * choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light or dark), `overlay` (sit over the bottom of a positioned parent).
11
+ * 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
+ * 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
+ * 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
+ * or dark), `overlay` (sit over the bottom of a positioned parent).
14
16
  */
15
17
  export declare class ViewControlsElement extends HTMLElement {
16
18
  #private;
@@ -30,6 +32,9 @@ export declare class ViewControlsElement extends HTMLElement {
30
32
  front(): void;
31
33
  fit(): void;
32
34
  set(next: Partial<ViewState>): void;
35
+ /** What zoom 1 means to the page: the scene is drawn at `state.zoom * fitScale`. */
36
+ get fitScale(): number;
37
+ setFitScale(scale: number): void;
33
38
  connectedCallback(): void;
34
39
  disconnectedCallback(): void;
35
40
  attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
@@ -1,7 +1,7 @@
1
1
  // <kazoku-view-controls>: the view controls as a framework-free custom element. It never draws the scene. It binds the pointer, pinch, wheel and keys
2
2
  // to the element named by `for`, and tells the page what the view is now with a `kazoku-viewchange` event; the page applies it to its canvas, WebGL, SVG or CSS 3D.
3
3
  import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
4
- import { createViewController } from "./controller.js";
4
+ import { createViewController, DEFAULT_IGNORE } from "./controller.js";
5
5
  import { EVENT, TAG } from "./index.js";
6
6
  import { ZOOM_STEP, ZOOMED } from "./math.js";
7
7
  import { STYLES } from "./styles.js";
@@ -42,12 +42,14 @@ const HTML = `
42
42
  </div>
43
43
  </div>`;
44
44
  /**
45
- * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt`, `zoom` (the starting view), `autoplay` (turn by itself from the
46
- * start, unless the reader prefers reduced motion), `speed` (degrees a second), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the
47
- * choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light or dark), `overlay` (sit over the bottom of a positioned parent).
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
+ * 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
+ * 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
49
+ * or dark), `overlay` (sit over the bottom of a positioned parent).
48
50
  */
49
51
  export class ViewControlsElement extends HTMLElement {
50
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "speed", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
52
+ static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "speed", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
51
53
  #root;
52
54
  #controller;
53
55
  #view;
@@ -96,6 +98,13 @@ export class ViewControlsElement extends HTMLElement {
96
98
  set(next) {
97
99
  this.#ensure().set(next);
98
100
  }
101
+ /** What zoom 1 means to the page: the scene is drawn at `state.zoom * fitScale`. */
102
+ get fitScale() {
103
+ return this.#ensure().fitScale;
104
+ }
105
+ setFitScale(scale) {
106
+ this.#ensure().setFitScale(scale);
107
+ }
99
108
  connectedCallback() {
100
109
  const c = this.#ensure();
101
110
  if (this.#root.querySelector(".bar") === null)
@@ -103,7 +112,7 @@ export class ViewControlsElement extends HTMLElement {
103
112
  this.#keys = parseKeys(this.getAttribute("address-keys"));
104
113
  this.#limits();
105
114
  const start = {};
106
- for (const key of ["yaw", "tilt", "zoom"]) {
115
+ for (const key of ["yaw", "tilt"]) {
107
116
  const value = Number(this.getAttribute(key));
108
117
  if (this.hasAttribute(key) && Number.isFinite(value))
109
118
  start[key] = value;
@@ -112,6 +121,11 @@ export class ViewControlsElement extends HTMLElement {
112
121
  Object.assign(start, parseAddress(globalThis.location?.search ?? "", this.#keys, c.limits));
113
122
  c.turnable = !this.hasAttribute("flat");
114
123
  c.speed = Number(this.getAttribute("speed")) || 30;
124
+ c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
125
+ const fitScale = Number(this.getAttribute("fit-scale"));
126
+ if (this.hasAttribute("fit-scale"))
127
+ c.setFitScale(fitScale);
128
+ c.setStartZoom(this.#startZoom());
115
129
  c.set(start, true, "init");
116
130
  this.#label();
117
131
  this.#resolveView();
@@ -147,10 +161,16 @@ export class ViewControlsElement extends HTMLElement {
147
161
  this.#resolveView();
148
162
  else if (name === "lang")
149
163
  this.#label();
150
- else if (name === "yaw" || name === "tilt" || name === "zoom") {
164
+ else if (name === "yaw" || name === "tilt") {
151
165
  if (value !== null && Number.isFinite(Number(value)))
152
166
  c.set({ [name]: Number(value) });
153
167
  }
168
+ else if (name === "zoom")
169
+ c.setStartZoom(this.#startZoom());
170
+ else if (name === "fit-scale")
171
+ c.setFitScale(Number(value));
172
+ else if (name === "ignore")
173
+ c.ignore = value ?? DEFAULT_IGNORE;
154
174
  else if (name === "speed")
155
175
  c.speed = Number(value) || 30;
156
176
  else if (name === "flat") {
@@ -162,6 +182,10 @@ export class ViewControlsElement extends HTMLElement {
162
182
  else
163
183
  this.#limits();
164
184
  }
185
+ #startZoom() {
186
+ const value = Number(this.getAttribute("zoom"));
187
+ return this.hasAttribute("zoom") && Number.isFinite(value) && value > 0 ? value : "fit";
188
+ }
165
189
  #ensure() {
166
190
  if (this.#controller === undefined) {
167
191
  this.#controller = createViewController({ onChange: (state, info) => this.#changed(state, info) });
@@ -305,7 +329,7 @@ export class ViewControlsElement extends HTMLElement {
305
329
  this.#saveTimer = 0;
306
330
  try {
307
331
  const s = this.#ensure().state;
308
- const next = formatAddress(globalThis.location.search, s, this.#keys, s.turning);
332
+ const next = formatAddress(globalThis.location.search, s, this.#keys, s.turning, this.#ensure().startZoom);
309
333
  if (next !== globalThis.location.search)
310
334
  globalThis.history.replaceState(globalThis.history.state, "", `${globalThis.location.pathname}${next}${globalThis.location.hash}`);
311
335
  }
@@ -1,5 +1,5 @@
1
1
  export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys, type AddressChoices, type AddressKeys, type DragMode } from "./address.ts";
2
- export { createViewController, type ChangeInfo, type Clock, type ViewController, type ViewControllerOptions, type ViewState } from "./controller.ts";
2
+ export { createViewController, DEFAULT_IGNORE, type ChangeInfo, type Clock, type ViewController, type ViewControllerOptions, type ViewState } from "./controller.ts";
3
3
  export { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout, type Camera, type Limits, type PinchStart, type Point, type Size } from "./math.ts";
4
4
  export { languageOf, WORDS, wordsFor, type Language, type ViewWords } from "./words.ts";
5
5
  export declare const EVENT = "kazoku-viewchange";
@@ -1,6 +1,6 @@
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
- export { createViewController } from "./controller.js";
3
+ export { createViewController, DEFAULT_IGNORE } from "./controller.js";
4
4
  export { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, 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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kazoku-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",