@johnmorrisdotca/kazoku-ui 0.11.0 → 0.13.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
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.0] - 2026-11-03
6
+
7
+ The wheel over the view zooms it, in every family package that uses the view controls.
8
+
9
+ - **The wheel zooms** (KAZOKU-UI-45). The wheel over the view now zooms it about the pointer, like a map, about 1.2 times a notch; before, the plain wheel tipped a focused view and only Ctrl or Command with the wheel zoomed. A trackpad's pinch (Ctrl and the wheel) still zooms. Shift and the wheel, or a sideways swipe, turns the view. Tipping is a drag, the arrow keys or the tilt slider. With the pointer anywhere else the wheel scrolls the page; a wheel that starts while the zoom is already at its end that way (turned down at Fit, or up at the most) scrolls the page as well, so it is never trapped, while the steps of a turn that reaches the end part-way through stay with the view, so the page does not jump. A flat view leaves Shift and the wheel and a sideways swipe to the page. `wheelZoomFactor` takes a third argument, `"pinch"` (the default, as before) or `"wheel"`, and `WHEEL_GESTURE_MS` (300) is exported. `key-tilt="wheel"` still swaps the up and down keys.
10
+ - The README opens with the package's thumbnail, and the demo carries it as its link preview (og:image) and publishes the card, a dark card and an icon on Pages at `thumbnail.png`, `thumbnail-dark.png` and `icon.png`.
11
+
12
+ ### Documentation
13
+
14
+ - **Fourteen guides, each with a Japanese page, and a generated API reference.** `docs/guides/` has getting started, view controls, layouts and the zoom slider, the view controller and its arithmetic, the theme switch, the language picker, address state, save image, the shared renderer, frameworks and CSP, accessibility and labels, architecture, troubleshooting and upgrading; each has its Japanese page of the same name in `docs/guides/ja/`. `docs/API.md` is written from the built declarations (`pnpm docs:api`). The README is rewritten into sections around the same material, and no longer says the package is unpublished.
15
+ - **Every example runs, and the documentation is checked.** `pnpm test:readme` runs each `ts`, `js` and `html` block of the README and the guides (Node for the scripts, headless Chromium in software for the HTML); `pnpm docs:check` fails when `docs/API.md` is out of date, a guide has no Japanese page or a different set of code blocks, a link or picture is missing, or an export is named nowhere. Both run in `.github/workflows/docs.yml`.
16
+ - **Twelve more pictures** in `docs/images/` (the layouts on a phone, the header's switches, address state, save image, the shared renderer and the builder, light and dark), made by `pnpm screenshots`. The hero and the layouts pictures were taken again, because the demo's links now include the builder.
17
+ - New demo page: `demo/builder.html`, to choose a part, set its options, see it live and copy the code, in English and in Japanese, with `e2e/builder.demo.mjs`.
18
+
19
+ ## [0.12.0] - 2026-11-03
20
+
21
+ The view controls in four layouts, chosen by one attribute, and the zoom as a slider with marks. A page that names no layout looks exactly as before.
22
+
23
+ - **`layout="horizontal|vertical|compact"`** on `<kazoku-view-controls>` (KAZOKU-UI-29). `horizontal` is a tidy bar under the view: the turn on one row, the zoom slider on the next, one row when the bar is 900 px or wider. `vertical` is a column beside the view (`--kazoku-column-width`, 16rem), Auto-turn and More at the top so More stays put. `compact` is one short row (Auto-turn, −, the readout, +, More) that never takes a second row until More is pressed, and then shows everything else under it. `classic` is the default and the bar of 0.10, unchanged. The level (`controls`, More) still says what is shown, the same way in every layout. The host carries `data-layout`, and `data-flat` on a flat view.
24
+ - **The zoom slider** in the horizontal, vertical and compact layouts: `zoom-min` to `zoom-max` on a logarithmic scale, marked and labelled at the ends and at the common zooms between them that have room (1×, 2×, 3×, 5× for the default range). The readout stays beside it and both always show the true zoom. − and + still step. A drag or press within 8 px of a mark goes to it, never further. Keys: arrows step by 1.1× and stop on a mark they would pass, Page Up and Down go from mark to mark, Home and End to the ends. A screen reader hears the value as the readout says it. Part names `zoom-slider`.
25
+ - **The scale without the element**: `zoomMarks`, `zoomPosition`, `zoomAtPosition`, `zoomStep`, `zoomToMark`, `zoomSnap`, `COMMON_ZOOMS`, `MARK_GAP`, `SLIDER_STEP`, `SNAP_PX`, and `parseLayout`, `LAYOUTS` and the type `Layout`, from `@johnmorrisdotca/kazoku-ui/view-controls`.
26
+ - **The layouts demo** (`demo/layouts.html`, on GitHub Pages as `layouts.html`): every layout live, side by side, each with its code and a Copy button, and a row of zoom ranges that changes every slider's marks and the code; one layout at a time, picked from a row, below 1100 px. The main demo takes `?layout=`, `?zoom-min=` and `?zoom-max=`. The README shows the layouts in a picture (`docs/images/layouts-*.webp`, made by `pnpm screenshots`) and a table of what each shows.
27
+ - Tests: `test/zoom-scale.test.ts` (the scale, the marks for eight ranges, the keys' steps, the snap, the layout words) and `e2e/layouts.demo.mjs` (each layout's controls, rows and sizes at a phone's width and a desk's, no row added by turning, compact one row on a phone, More staying put in the vertical column, the marks against the limits and the track, the slider by keys, mouse and touch, the slider and readout true after every way of zooming, the slider's name in both languages, and the layouts page).
28
+ - **Centre in every layout**: the Centre button of 0.11.0 sits beside the zoom: in the two-row horizontal bar an icon in More's column, so Auto-turn and More never move; its own column on the one-row bar; its own row in the vertical column; with Fit behind More in compact, which stays one row. `e2e/layouts.demo.mjs` checks each.
29
+
5
30
  ## [0.11.0] - 2026-11-03
6
31
 
7
32
  - **Exact grab** (KAZOKU-UI-28, from chikyu-earth's MODULES-200). `grab-radius` on `<kazoku-view-controls>` (and `grabRadius` on `createViewController`, `degreesPerPixel` and a `grabRadius` argument to `dragTurn`): the on-screen radius of a globe or turnable piece in CSS pixels at Fit, or `fit` for half the smaller side of the view. A drag then turns the view by the angle that keeps the touched point under the pointer, on both axes, and the radius grows with the zoom. Left out, nothing changes: the fixed angle across the view (180 degrees across the width, 60 down the height) that moved a globe's ground about 1.5 pixels for each pixel of drag sideways and half a pixel up and down.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <p align="center"><a href="https://johnmorrisdotca.github.io/kazoku-ui/"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/thumbnail-dark.png"><img src="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/thumbnail.png" alt="Kazoku UI: a card with a blue circle and a black triangle, turned and tipped in 3D, over the view controls that turn it." width="1200"></picture></a></p>
2
+
1
3
  # Kazoku UI 家族
2
4
 
3
5
  <picture>
@@ -9,13 +11,136 @@ The family's shared interface parts, as their own versioned package: one part pe
9
11
  The first part is the **view controls** for any 3D or zoomable view. They never draw the scene: they tell your page what the reader chose, and your canvas, WebGL,
10
12
  SVG or CSS 3D applies it.
11
13
 
12
- [Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Shared 3D renderer](docs/renderer.md) · [The drag rule](docs/drag-rule.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
14
+ [Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Layouts demo](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Shared 3D renderer](docs/renderer.md) · [The drag rule](docs/drag-rule.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md) · [Builder](https://johnmorrisdotca.github.io/kazoku-ui/builder.html) · [API reference](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/API.md) · [Guides](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides)
15
+
16
+ ## In 30 seconds
13
17
 
14
18
  ```sh
15
19
  npm install @johnmorrisdotca/kazoku-ui
16
20
  ```
17
21
 
18
- Parts (each its own subpath; later ones: picture-tile pickers, Help, GitHub and npm pills, the family's backgrounds):
22
+ ```html
23
+ <div id="scene" tabindex="0" style="height: 200px">Your scene</div>
24
+ <kazoku-view-controls for="scene" address></kazoku-view-controls>
25
+ <script type="module">
26
+ import "@johnmorrisdotca/kazoku-ui/view-controls/define";
27
+
28
+ document.querySelector("kazoku-view-controls").addEventListener("kazoku-viewchange", (event) => {
29
+ const { yaw, tilt, zoom, panX, panY } = event.detail;
30
+ document.getElementById("scene").style.transform = `translate(${panX}px, ${panY}px) scale(${zoom}) rotateX(${tilt}deg) rotateY(${yaw}deg)`;
31
+ });
32
+ </script>
33
+ ```
34
+
35
+ And the pure functions, with no page at all:
36
+
37
+ ```js
38
+ import { formatAddress, parseAddress, zoomMarks, zoomText } from "@johnmorrisdotca/kazoku-ui/view-controls";
39
+
40
+ console.log(formatAddress("?x=1", { yaw: 40, tilt: 10, zoom: 2, drag: "turn" })); // ?x=1&yaw=40&tilt=10&zoom=2&drag=turn
41
+ console.log(JSON.stringify(parseAddress("?yaw=40&zoom=2"))); // {"yaw":40,"zoom":2}
42
+ console.log(zoomMarks(1, 5).map((mark) => mark.zoom).join(" ")); // 1 2 3 5
43
+ console.log(zoomText(2.44)); // 2.4×
44
+ ```
45
+
46
+ ## Who it is for
47
+
48
+ - A page or a package that shows a 3D or zoomable view (a card, a globe, a rocket, a map) and wants the reader to turn, tip, zoom and move it, without writing the controls again.
49
+ - A site with a language choice and a Light, Dark or Auto switch that should look and behave the same across every page and every package of a family.
50
+ - A page that wants Save PNG and Save SVG for a canvas, an SVG or a piece of the page.
51
+ - A page of many 3D views that must stay inside one WebGL context and a pixel budget.
52
+ - A framework app: the parts are plain custom elements with plain modules beside them.
53
+
54
+ ## Features
55
+
56
+ - **No runtime dependencies, one part to a subpath.** A page loads only what it uses.
57
+ - **View controls** in four layouts, with a zoom slider, a readout, Auto-turn, Centre, keys, touch and the address.
58
+ - **Theme switch** (Light, Dark, Auto) and **language picker** (each language in its own name) that write `data-theme` and `lang` and tell the page.
59
+ - **Save image** for a canvas, an SVG or any element, as PNG or SVG.
60
+ - **Address state**, with no element: choices kept in the link with `replaceState`.
61
+ - **A shared 3D renderer** and its **freeze checks**: one WebGL context, shared assets, stills, a pixel budget.
62
+ - **Labels in English and Japanese**, light and dark, 48 px touch targets, reduced motion respected.
63
+ - **A strict Content-Security-Policy is fine:** styles are adopted as constructable stylesheets.
64
+
65
+ ## Install
66
+
67
+ ```sh
68
+ npm install @johnmorrisdotca/kazoku-ui
69
+ ```
70
+
71
+ ```sh
72
+ pnpm add @johnmorrisdotca/kazoku-ui
73
+ ```
74
+
75
+ Node 22 or later builds and tests it; the parts run in any current browser. Each part is its own subpath, and the `define` file of an element registers its tag, once, and only if the tag is free.
76
+
77
+ ## Use it in your project
78
+
79
+ In a bundler or a framework, import the `define` file of each element you use once, in code that runs in the browser, and import the pure modules wherever you like:
80
+
81
+ ```ts no-run
82
+ import "@johnmorrisdotca/kazoku-ui/view-controls/define";
83
+ import "@johnmorrisdotca/kazoku-ui/theme-switch/define";
84
+ import { createAddressState } from "@johnmorrisdotca/kazoku-ui/address-state";
85
+ import type { ViewChangeDetail } from "@johnmorrisdotca/kazoku-ui/view-controls/element";
86
+
87
+ export const state = createAddressState({ defaults: { size: "m" } });
88
+ export const draw = (detail: ViewChangeDetail) => console.log(detail.yaw, detail.zoom);
89
+ ```
90
+
91
+ ## Use it anywhere
92
+
93
+ On a page with no bundler, an import map tells the browser where each subpath lives, and the `dist` folder is served as it is:
94
+
95
+ ```html
96
+ <script type="importmap">
97
+ { "imports": {
98
+ "@johnmorrisdotca/kazoku-ui/theme-switch/define": "/node_modules/@johnmorrisdotca/kazoku-ui/dist/theme-switch/define.js"
99
+ } }
100
+ </script>
101
+ <kazoku-theme-switch address storage></kazoku-theme-switch>
102
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/theme-switch/define";</script>
103
+ ```
104
+
105
+ ## Examples
106
+
107
+ A theme switch and a language picker side by side, with the page answering their events:
108
+
109
+ ```html
110
+ <kazoku-theme-switch address storage></kazoku-theme-switch>
111
+ <kazoku-language-picker languages="en ja" address storage></kazoku-language-picker>
112
+ <script type="module">
113
+ import "@johnmorrisdotca/kazoku-ui/theme-switch/define";
114
+ import "@johnmorrisdotca/kazoku-ui/language-picker/define";
115
+
116
+ document.addEventListener("kazoku-themechange", ({ detail }) => console.log(detail.choice, detail.scheme));
117
+ document.addEventListener("kazoku-languagechange", ({ detail }) => console.log(detail.lang));
118
+ </script>
119
+ ```
120
+
121
+ Save a canvas as a PNG:
122
+
123
+ ```html
124
+ <canvas id="art" width="360" height="200"></canvas>
125
+ <kazoku-save-image for="art" name="my-picture"></kazoku-save-image>
126
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/save-image/define";</script>
127
+ ```
128
+
129
+ The same idea in code, with the address and the theme as plain functions:
130
+
131
+ ```js
132
+ import { applyParams } from "@johnmorrisdotca/kazoku-ui/address-state";
133
+ import { resolveTheme } from "@johnmorrisdotca/kazoku-ui/theme-switch";
134
+ import { filenameFor } from "@johnmorrisdotca/kazoku-ui/save-image";
135
+
136
+ console.log(applyParams("?a=1&size=m", { size: "l", a: null }, { size: "m" })); // ?size=l
137
+ console.log(resolveTheme("auto", true)); // dark
138
+ console.log(filenameFor("My Card/x", "png")); // My Card-x.png
139
+ ```
140
+
141
+ ## Parts
142
+
143
+ Each its own subpath; later ones: picture-tile pickers, Help, GitHub and npm pills, the family's backgrounds.
19
144
 
20
145
  | Subpath | What it is |
21
146
  |---|---|
@@ -40,13 +165,52 @@ Parts (each its own subpath; later ones: picture-tile pickers, Help, GitHub and
40
165
  - **Two levels, so the bar stays simple.** The default, `controls="basic"`, shows the angle slider, Auto-turn, zoom out and in with a **zoom readout** (`2.4×`; tap it to go back to Fit), Fit, and a small More button; while the view turns, one small **speed** button beside Auto-turn (it shows the word Speed over `Medium`, and a press steps to the next speed). More (or `controls="advanced"`) adds the tilt slider, all three speeds (Slow, Medium, Fast), Front, and, once zoomed in, the four-arrow pan pad and the Move or Turn choice for what a drag does. The reader's choice with More is remembered in `localStorage` and beats the attribute.
41
166
  - The zoom readout is the view's `zoom` (1× is Fit, whatever `fit-scale` is), one decimal, the one zoom number on the bar: it follows the buttons, the wheel, a pinch, a double press, Fit, the keys, the address, `set` and a changed limit. A screen reader is told "Zoom level 2.4×" once the zoom has settled for a moment, not for every step of a pinch.
42
167
  - Auto-turn with play and pause; its speed is Slow (half the `speed` attribute), Medium (the `speed` attribute, 30 degrees a second) or Fast (twice it), kept in the address as `?speed=slow` or `?speed=fast`.
43
- - Zoom is about the pointer (and Ctrl or Command with the wheel, or a trackpad's pinch).
168
+ - **The wheel zooms.** The wheel over the view zooms it about the pointer, like a map; a trackpad's pinch (and Ctrl or Command with the wheel) zooms too. Shift and the wheel, or a sideways swipe, turns it. Tipping is a drag, the arrow keys or the tilt slider. With the pointer anywhere else the wheel scrolls the page, and a wheel that starts while the zoom is already at its end that way (turned down at Fit) scrolls the page too, so the page is never trapped.
44
169
  - Touch: one finger turns at 1x and moves the view when zoomed in; two fingers pinch.
45
170
  - Keys on the view: arrows turn and tip it, `+` and `-` zoom, `0` or Home is Front and Fit.
46
171
  - The choices are kept in the address, with keys you can rename.
172
+ - **Four layouts** (`layout`: classic, horizontal, vertical, compact) and a **zoom slider** with marks at 1×, 2× and the ends: see [Layouts](#layouts).
47
173
  - Labels in English and Japanese; light and dark; 48 px touch targets; a compact bar on a phone; no automatic turn for a reader who prefers reduced motion.
48
174
  - **A control never stops or resets the turn.** Zoom, tilt, pan, the Move or Turn choice, Fit and Front all leave Auto-turn running (Front only sets the angle to 0 and the turn goes on from there). A hand on the angle slider, or turning the view by dragging it, holds the turn still while it is down and the turn carries on from where the hand left it. Only the Auto-turn button pauses it.
49
175
 
176
+ ### Layouts
177
+
178
+ <picture>
179
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/layouts-dark.webp">
180
+ <img alt="The four layouts side by side, each under a turned card. Horizontal: an Angle slider with Auto-turn and More on one row, and under it a Zoom slider marked 1×, 2×, 3× and 5× with zoom out, the readout 1.6×, zoom in and Fit. Vertical: a column beside the view with Auto-turn and More at the top, then the Angle slider, the Zoom slider with its marks, and zoom out, 1.6×, zoom in and Fit. Compact: one short row of Auto-turn, zoom out, 1.6×, zoom in and More, here opened to show the zoom slider and Fit, the angle and tilt, the three speeds, Front and the pan pad. Classic: the bar as before. Each has its code and a Copy button." src="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/layouts-light.webp" width="720">
181
+ </picture>
182
+
183
+ Try them on the [layouts demo](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html): every layout live, side by side, each with its code to copy, and a choice of zoom range to see the slider's marks change.
184
+
185
+ The `layout` attribute says where the controls sit; `controls` (`basic` or `advanced`, and the reader's More) still says what is shown, the same in every layout. A page that names no layout keeps the bar it had.
186
+
187
+ | `layout` | What it looks like | Basic shows | More adds |
188
+ |---|---|---|---|
189
+ | `classic` (the default) | The bar of 0.10 and before, unchanged: one wrapping row at a desk, two rows on a phone. No zoom slider. | Angle, Auto-turn, − readout +, Fit, More | Tilt, Slow, Medium, Fast, Front; the pan pad once zoomed in |
190
+ | `horizontal` | A tidy bar under the view: the turn on one row (Angle, Auto-turn, More), the **zoom slider** on the next (Zoom, − readout +, Fit). One row when the bar is 900 px or wider. Under 560 px the labels are read by a screen reader only, and the readout is Fit. Centre, once the view is zoomed or moved, comes in at the end of the zoom row (an icon in the two rows, so Auto-turn and More never move). | Angle, Auto-turn, the zoom slider, − readout +, Fit, More | Tilt and Front, then the speeds, between the two rows; the pan pad last |
191
+ | `vertical` | A column beside the view, `--kazoku-column-width` wide (16rem). Auto-turn and More at the top, so More does not move when it opens; then the Angle slider, the zoom slider, and − readout + and Fit. On a narrow screen put it under the view and give it `width: auto`. | Auto-turn, More, Angle, the zoom slider, − readout +, Fit | Tilt, the speeds, Front, the pan pad, in the column |
192
+ | `compact` | The least room, for a card or a phone: **one row** of Auto-turn, −, the readout, + and More (icons, named for a screen reader). It never takes a second row until More is pressed; while it turns, the speed is in More, and Centre is there with Fit. | Auto-turn, − readout +, More | Everything else, one press away, under the row: the zoom slider and Fit, Angle, Tilt, the speeds, Front, the pan pad |
193
+
194
+ ```html no-check
195
+ <!-- Horizontal: under the view -->
196
+ <div id="view" tabindex="0">...your scene...</div>
197
+ <kazoku-view-controls for="view" layout="horizontal"></kazoku-view-controls>
198
+
199
+ <!-- Vertical: beside the view -->
200
+ <div style="display: flex; gap: 12px">
201
+ <div id="view" tabindex="0" style="flex: 1">...your scene...</div>
202
+ <kazoku-view-controls for="view" layout="vertical"></kazoku-view-controls>
203
+ </div>
204
+
205
+ <!-- Compact: one row; with overlay it floats over the bottom corner of a positioned card -->
206
+ <div style="position: relative">
207
+ <div id="view" tabindex="0">...your scene...</div>
208
+ <kazoku-view-controls for="view" layout="compact" overlay></kazoku-view-controls>
209
+ </div>
210
+ ```
211
+
212
+ **The zoom slider** (horizontal, vertical and compact) runs from `zoom-min` to `zoom-max` on a logarithmic scale, so a press of − or + moves it the same distance anywhere along it. It has marks, labelled, at both ends and at the common zooms between them that have room for a label: 1× to 5× gives 1×, 2×, 3×, 5×; 0.5× to 4× gives 0.5×, 1×, 2×, 4×; 1× to 10× gives 1×, 2×, 4×, 10×. The readout beside it is the true zoom, and the slider always shows it too, whatever moved the view (the buttons, the wheel, a pinch, a double press, Fit, the keys on the view, the address, `set`, the limits). A drag or a press that ends within 8 px of a mark goes to it; anywhere else it stays where it was let go. − and + still step by 1.4×. Keys on the slider: the arrows step by 1.1× and stop on a mark they would pass, Page Up and Page Down go to the next mark, Home and End to the ends. A screen reader hears its value as the readout says it (`2.4×`), and its name is Zoom. Without the element, `zoomMarks(min, max)`, `zoomPosition`, `zoomAtPosition`, `zoomStep`, `zoomToMark` and `zoomSnap` are the same scale in `@johnmorrisdotca/kazoku-ui/view-controls`.
213
+
50
214
  ### In plain HTML
51
215
 
52
216
  ```html
@@ -97,7 +261,7 @@ export function Viewer({ draw }) {
97
261
 
98
262
  `createViewController` is the same hands and state with no markup of its own, for a page that draws its own buttons. It touches the DOM only in `bind`.
99
263
 
100
- ```js
264
+ ```js no-run
101
265
  import { createViewController } from "@johnmorrisdotca/kazoku-ui/view-controls";
102
266
 
103
267
  const view = createViewController({
@@ -125,7 +289,7 @@ The hands capture the pointer on the `for` element, so a press on a button or li
125
289
 
126
290
  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:
127
291
 
128
- ```js
292
+ ```js no-run
129
293
  controls.anchor = { x: 60, y: -30 }; // pixels from the view's middle when it is at Fit
130
294
  ```
131
295
 
@@ -141,13 +305,13 @@ In every turnable view the surface under the pointer follows the pointer, the ar
141
305
 
142
306
  ### Keys, the wheel and the turn
143
307
 
144
- 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.
308
+ The wheel over the view zooms it about the pointer, and the steps of one turn of the wheel or one swipe stay with the view even when the zoom reaches its end part-way through, so the page does not jump. A new turn of the wheel at that end scrolls the page. Shift and the wheel, or a sideways swipe, turns the view (on a flat view they are left to the page). Arrow Up tips like a drag up (`tilt` gets larger); `key-tilt="wheel"` makes it tip the other way (`tilt` gets smaller), the way the wheel turned up tipped the view before 0.13.0, 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.
145
309
 
146
310
  ### Opening at the scene's natural size
147
311
 
148
312
  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:
149
313
 
150
- ```js
314
+ ```js no-run
151
315
  const controls = document.querySelector("kazoku-view-controls");
152
316
  controls.setAttribute("zoom", "1"); // natural size
153
317
  controls.setFitScale(measure()); // the scale the scene is drawn at when it fits, for example 0.4
@@ -184,7 +348,7 @@ An `init` event is sent once, a moment after the element connects, with the star
184
348
  | `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. |
185
349
  | `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. |
186
350
  | `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). |
187
- | `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. |
351
+ | `key-tilt` | Which way Arrow Up tips the view: `drag` (the default: like a drag up) or `wheel` (the other way, as the wheel turned up tipped it before 0.13.0; the wheel zooms now). Arrow Down goes the other way from Up. |
188
352
  | `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. |
189
353
  | `tilt-step` | Round the tilt of a drag to a multiple of this many degrees (`1` gives whole degrees). 0 by default: not rounded. |
190
354
  | `object-size` | `w,h`: the object's size at Fit in CSS pixels, when it is smaller than the view. A pan then keeps at least a quarter of the object in view, however far it is dragged; left out, the object is the picture, which keeps covering the view. Or set `element.objectSize = { width, height }`. |
@@ -193,16 +357,17 @@ An `init` event is sent once, a moment after the element connects, with the star
193
357
  | `autoplay-for` | Seconds of turning after which the `autoplay` turn stops by itself (as Auto-turn pressed). Left out, it never stops. Time spent paused off screen or in a hidden tab is not counted, and a reader's own Auto-turn press has no limit. |
194
358
  | `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
195
359
  | `controls` | `basic` (the default) or `advanced`: which controls are shown (see above; the zoom readout in both, the speed in both while the view turns). Written in the page's markup it is where the reader starts; what the reader chose with More is remembered under `kazoku-view-controls` in `localStorage` (or the key `storage` names) and wins. Setting the attribute later changes the level at once. The host carries `data-level` with the level now. |
360
+ | `layout` | `classic` (the default), `horizontal`, `vertical` or `compact`: where the controls sit (see [Layouts](#layouts)). Can change at any time. Anything else is `classic`. The host carries `data-layout` with the layout in use. |
196
361
  | `storage` | The `localStorage` key the level is remembered under (`kazoku-view-controls`). |
197
362
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
198
363
  | `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
199
364
  | `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn&speed=fast`). |
200
365
  | `address-keys` | Other keys: `yaw:a,zoom:z`. |
201
366
  | `theme` | `light` or `dark`; otherwise the page's colour scheme. |
202
- | `overlay` | Sit over the bottom of a positioned parent. |
367
+ | `overlay` | Sit over the bottom of a positioned parent. With `layout="compact"` the row floats 8 px in from the bottom start corner instead of spanning the bottom. |
203
368
 
204
369
  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.
205
- Colours are custom properties: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-line`, `--kazoku-accent`; the parts are `bar`, `field`, `slider` and `button`.
370
+ Colours are custom properties: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-line`, `--kazoku-accent`; the parts are `bar`, `field`, `slider` and `button`, and the zoom slider's `zoom-slider` (on its field and its input).
206
371
 
207
372
  ### The address
208
373
 
@@ -238,7 +403,7 @@ See [docs/language-picker.md](docs/language-picker.md). The demo's own English a
238
403
 
239
404
  Choices kept in the address, without an element: `createAddressState({ defaults })` with `get`, `set`, `remove`, `subscribe` and `flush`, writing with `replaceState` (no history entry, one write for the changes of a quarter of a second, a value at its default leaves the address, other parameters and the hash kept), and hearing back and forward. The theme switch, the language picker and the view controls keep their choices through it.
240
405
 
241
- ```js
406
+ ```js no-run
242
407
  import { createAddressState } from "@johnmorrisdotca/kazoku-ui/address-state";
243
408
 
244
409
  const state = createAddressState({ defaults: { size: "m" } });
@@ -266,7 +431,7 @@ materials, textures and models are counted and freed when the last view lets go.
266
431
  requested at a time, and a canvas never passes 6 million pixels or 4096 a side. Context loss is handled in one place. **three.js is a peer dependency and is never bundled**:
267
432
  your page passes `load: () => import("three")`, so it loads only when the first view goes live. How a package uses it, the API and the freeze checks: [docs/renderer.md](docs/renderer.md). Demo: `renderer.html`.
268
433
 
269
- ```ts
434
+ ```ts no-check
270
435
  import { createSharedRenderer } from "@johnmorrisdotca/kazoku-ui/renderer";
271
436
  const renderer = createSharedRenderer({ load: () => import("three") });
272
437
  const view = renderer.view(host, item); // item: { key, build({ three, assets, width, height }) => { scene, camera } }
@@ -295,14 +460,110 @@ The styles are adopted as a constructable stylesheet (`adoptedStyleSheets`), so
295
460
 
296
461
  ## Size
297
462
 
298
- Gzipped, unbundled. View controls: the element and its definition about 7.3 KB, the controller 4.6 KB, the sight watch 0.6 KB, the maths 1.4 KB, the address 1.4 KB, the levels 0.4 KB, the labels 0.9 KB and the styles 2.0 KB. Theme switch: the element and its definition 3.3 KB, the choices 1.0 KB, the labels 0.3 KB and the styles 0.3 KB. Language picker: the element and its definition 2.8 KB, the codes and names 1.7 KB, the labels 0.3 KB. Save image: the element and its definition 3.0 KB, the rendering 3.6 KB, the formats and file names 1.0 KB, the labels 0.4 KB. Address state: 2.1 KB. Shared 3D renderer: 8.9 KB (the renderer 4.5, the scheduler 1.2, the assets 1.4, the stills 0.6, the pixel budget 0.8, the index 0.4), the test kit 1.6 KB; three.js is the page's own and is not counted. Shared by parts: the style adoption 0.8 KB, the base styles 1.0 KB, the page's colours 0.5 KB and the target and the store 0.6 KB. `pnpm test:package` prints them. Nothing is loaded from the network.
463
+ Gzipped, unbundled. View controls: the element and its definition about 9.3 KB, the controller 5.0 KB, the sight watch 0.6 KB, the maths 2.1 KB, the address 1.4 KB, the levels and layouts 0.6 KB, the zoom scale 1.5 KB, the labels 0.9 KB and the styles 4.0 KB (the four layouts and the slider). Theme switch: the element and its definition 3.3 KB, the choices 1.0 KB, the labels 0.3 KB and the styles 0.3 KB. Language picker: the element and its definition 2.8 KB, the codes and names 1.7 KB, the labels 0.3 KB. Save image: the element and its definition 3.0 KB, the rendering 3.6 KB, the formats and file names 1.0 KB, the labels 0.4 KB. Address state: 2.1 KB. Shared 3D renderer: 8.9 KB (the renderer 4.5, the scheduler 1.2, the assets 1.4, the stills 0.6, the pixel budget 0.8, the index 0.4), the test kit 1.6 KB; three.js is the page's own and is not counted. Shared by parts: the style adoption 0.8 KB, the base styles 1.0 KB, the page's colours 0.5 KB and the target and the store 0.6 KB. `pnpm test:package` prints them. Nothing is loaded from the network.
299
464
 
300
465
  ## Checks
301
466
 
302
- `pnpm check` (lint, types, 104 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, the frame loop counted at `requestAnimationFrame` (nothing out of sight or hidden, one callback a page frame otherwise, a moved element too), the zoom readout against the state after every way of zooming, the speed button adding no row, 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, the controls styled under a strict `style-src` with no violation, `demo/csp.html`, the theme switch and the language picker in each browser and size, the address state, back and forward and no history added, on `demo/address.html`, and the save buttons: each file read back from disk, on `demo/save-image.html`, and the shared 3D renderer on `demo/renderer.html`: one WebGL context over fifty focus changes, nothing drawn or requested at rest, the GPU objects and heap flat, the canvas within budget at 5K, context loss, out of sight, the stills' byte budget, and three.js fetched only when a view goes live).
467
+ `pnpm check` (lint, types, 122 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, the frame loop counted at `requestAnimationFrame` (nothing out of sight or hidden, one callback a page frame otherwise, a moved element too), the zoom readout against the state after every way of zooming, the speed button adding no row, each layout's controls, rows and sizes (compact one row on a phone, turning adding no row), the zoom slider by keys, mouse and touch, its marks for several ranges and always the true zoom, the layouts page, 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, the controls styled under a strict `style-src` with no violation, `demo/csp.html`, the theme switch and the language picker in each browser and size, the address state, back and forward and no history added, on `demo/address.html`, and the save buttons: each file read back from disk, on `demo/save-image.html`, and the shared 3D renderer on `demo/renderer.html`: one WebGL context over fifty focus changes, nothing drawn or requested at rest, the GPU objects and heap flat, the canvas within budget at 5K, context loss, out of sight, the stills' byte budget, and three.js fetched only when a view goes live).
468
+
469
+ ## Theming
470
+
471
+ Every part follows the page's colours: `data-theme="light"` or `"dark"` on the page forces them, and without it the device decides. `theme="light"` or `theme="dark"` on one element gives it a look of its own. The colours are custom properties on the element: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-line` and `--kazoku-accent`; the parts are `bar`, `field`, `slider` and `button` (and `group` for the others).
472
+
473
+ ```html
474
+ <style>
475
+ kazoku-theme-switch, kazoku-language-picker { --kazoku-accent: #2457d6; --kazoku-line: #c9cdd6; }
476
+ </style>
477
+ <kazoku-theme-switch></kazoku-theme-switch>
478
+ <script type="module">import "@johnmorrisdotca/kazoku-ui/theme-switch/define";</script>
479
+ ```
480
+
481
+ ## Accessibility
482
+
483
+ Controls are on or right beside the view and visible without scrolling; changing a control never stops or resets the turn; every control is 48 px; the choices are radio groups with one tab stop and the arrow keys; the zoom is announced politely once it has settled; each language button carries its own `lang`; reduced motion gets no automatic turn. The rules and the labels are in [Accessibility and labels](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/accessibility-and-labels.md).
484
+
485
+ ## Limits
486
+
487
+ - **The Japanese labels have not yet been read by a native speaker.**
488
+ - **The demo's scene is a CSS 3D card;** the package has no opinion about what draws.
489
+ - **A picture of an element** (save image) cannot carry web fonts, `::before` or `::after`, or a cross-origin image: draw those into a canvas and save the canvas.
490
+ - **Several visible live 3D views at once are not supported** by the shared renderer: that is its point.
491
+ - **WebKit has no software GL switch,** so off CI its tests skip the pages that draw WebGL.
492
+ - **Early development (0.x):** a minor version can add to the interface. [Upgrading](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/upgrading.md) lists what asks something of you.
493
+
494
+ ## Browser and runtime support
495
+
496
+ The elements need custom elements, shadow DOM and constructable stylesheets (Chrome, Edge, Firefox and Safari 16.4 or later; older Safari falls back to a `<style>` that carries the element's `nonce`). The demo is played in Chromium and WebKit, at a phone's width and a desk's. Node 22 or later builds and tests it (22.18 or later to run the TypeScript tests directly); the pure modules import on a server. To load an element only where it can run:
497
+
498
+ ```js no-run
499
+ if ("customElements" in window) {
500
+ await import("@johnmorrisdotca/kazoku-ui/theme-switch/define");
501
+ }
502
+ ```
503
+
504
+ ## Languages
505
+
506
+ Every label is in English and in Japanese, and a unit test holds that every key is in both. `lang="ja"` (on the element or on a parent) chooses Japanese; anything else is English. Your own words go over either set: `element.words = { front: "Home" }`, or `wordsFor("ja", { front: "ホーム" })`. The language picker names each language in its own language. The guides have a Japanese page each, in [docs/guides/ja](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/ja).
507
+
508
+ ## Roadmap
509
+
510
+ Parts that arrive later: picture-tile pickers, Help, GitHub and npm pills and the family's backgrounds, each a new subpath that changes nothing that exists. Next for what is here: the Japanese labels read by a native speaker, and the family's other packages moving to the shared parts.
511
+
512
+ ## Architecture
513
+
514
+ One part, one subpath; the pure code, the element and the registration are three files; a part never draws the page's scene; the shared internals (styles, adoption, scheme, store, target) are not exported. See the [architecture guide](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/architecture.md).
515
+
516
+ ## Demos
517
+
518
+ [The demo](https://johnmorrisdotca.github.io/kazoku-ui/) has the view controls, the language picker and the theme switch in its header. Beside it: [layouts](https://johnmorrisdotca.github.io/kazoku-ui/layouts.html), [the builder](https://johnmorrisdotca.github.io/kazoku-ui/builder.html), [address state](https://johnmorrisdotca.github.io/kazoku-ui/address.html), [save image](https://johnmorrisdotca.github.io/kazoku-ui/save-image.html), [the shared renderer](https://johnmorrisdotca.github.io/kazoku-ui/renderer.html), [exact grab](https://johnmorrisdotca.github.io/kazoku-ui/grab.html) and [a strict Content-Security-Policy](https://johnmorrisdotca.github.io/kazoku-ui/csp.html).
519
+
520
+ <picture>
521
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/builder-dark.webp">
522
+ <img alt="The builder: the part chosen in a row of buttons, its options beside the live preview, and the code to copy under them" src="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/builder-light.webp">
523
+ </picture>
524
+
525
+ ## Documentation
526
+
527
+ | Guide | What it covers |
528
+ |---|---|
529
+ | [Getting started](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/getting-started.md) | The parts in a few minutes |
530
+ | [View controls](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/view-controls.md) | Every attribute, the event, the keys, the address |
531
+ | [Layouts and the zoom slider](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/layouts-and-zoom.md) | The four layouts and the slider's scale |
532
+ | [The view controller and its arithmetic](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/view-controller.md) | The controller without the element |
533
+ | [Theme switch](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/theme-switch.md) | Light, Dark and Auto |
534
+ | [Language picker](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/language-picker.md) | Each language in its own name |
535
+ | [Address state](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/address-state.md) | Choices kept in the link |
536
+ | [Save image](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/save-image.md) | A canvas, an SVG or an element as a picture |
537
+ | [The shared renderer](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/renderer.md) | One WebGL context, and the freeze checks |
538
+ | [Frameworks and CSP](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/frameworks-and-csp.md) | React, Vue, Svelte, Angular, servers, strict policies |
539
+ | [Accessibility and labels](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/accessibility-and-labels.md) | The rules and the words |
540
+ | [Architecture](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/architecture.md) | The parts and the rules |
541
+ | [Troubleshooting and FAQ](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/troubleshooting.md) | A bar that does nothing, an unknown tag |
542
+ | [Upgrading](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/upgrading.md) | What each version asks of you |
543
+
544
+ Each guide has a Japanese page of the same name in [docs/guides/ja](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/guides/ja). The reference for every export is [docs/API.md](https://github.com/johnmorrisdotca/kazoku-ui/blob/main/docs/API.md), written from the built declarations. The repository's own notes: [theme switch](docs/theme-switch.md), [language picker](docs/language-picker.md), [address state](docs/address-state.md), [save image](docs/save-image.md), [renderer](docs/renderer.md), [the drag rule](docs/drag-rule.md), [CSP](docs/csp.md), [labels](docs/labels.md), [the package standard](docs/package-standard.md).
545
+
546
+ ## Development
547
+
548
+ ```sh
549
+ pnpm check # lint, types, tests, the build and the packed package
550
+ pnpm test:package # pack it as npm does, install it in an empty project, import every entry
551
+ pnpm test:demo # build the demo and play it in Chromium and WebKit
552
+ pnpm test:readme # every example of the README and the guides, run in Node and in a browser
553
+ pnpm docs:api # rewrite docs/API.md from the built declarations
554
+ pnpm docs:check # the reference is current, every guide has its Japanese page, every link and picture is there
555
+ pnpm screenshots # the pictures of the README and the guides (docs/images)
556
+ pnpm dev # the demo on http://127.0.0.1:8400/
557
+ ```
558
+
559
+ ## Contributing
560
+
561
+ See [CONTRIBUTING.md](CONTRIBUTING.md): no runtime dependencies, one part to a subpath, a part never draws the page's scene, labels in both languages, no marks, and tests for what you change. Add a line to `CHANGELOG.md` under **Unreleased** for a change a user would notice. `templates/` and `docs/package-standard.md` are what the family's other packages copy unchanged.
562
+
563
+ ## Changes
303
564
 
304
- ## Not yet
565
+ Every release, dated, is in [CHANGELOG.md](CHANGELOG.md).
305
566
 
306
- Not published to npm yet. The Japanese labels have not yet been read by a native speaker. The demo's scene is a CSS 3D card; the package has no opinion about what draws.
567
+ ## Licence
307
568
 
308
- Licence: MIT. No brand, league or sponsor mark is in this package.
569
+ MIT. No brand, league or sponsor mark is in this package.
@@ -14,7 +14,7 @@ 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). */
17
+ /** Which way Arrow Up tips the view: like a drag up (`"drag"`, the default) or the other way (`"wheel"`, the way the wheel turned up tipped it before 0.13.0; the wheel zooms now). */
18
18
  export type KeyTilt = "drag" | "wheel";
19
19
  export interface Clock {
20
20
  request(callback: (now: number) => void): number;
@@ -35,7 +35,7 @@ export interface ViewControllerOptions {
35
35
  ignore?: string;
36
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
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). */
38
+ /** Which way Arrow Up tips the view: `"drag"` (the default, as a drag up) or `"wheel"` (the other way, as the wheel turned up tipped it before 0.13.0; the wheel zooms now). */
39
39
  keyTilt?: KeyTilt;
40
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
41
  stopTurn?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { turnOnlyInSight } from "./sight.js";
2
- import { between, clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
2
+ import { between, clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, 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. */
@@ -390,27 +390,36 @@ export function createViewController(options = {}) {
390
390
  }
391
391
  }
392
392
  };
393
+ // The wheel over the view zooms it about the pointer, as a map does; the page scrolls when the pointer is anywhere else. Ctrl or Command
394
+ // and the wheel (a trackpad's pinch) zooms too. Shift and the wheel, or a sideways swipe, turns it. Tipping is left to a drag, the keys
395
+ // and the buttons. A wheel that starts while the zoom is already at its end that way (zooming out at Fit) goes on to scroll the page,
396
+ // so the page is never trapped; one that reaches the end part-way through stops there rather than flinging the page.
397
+ let wheelTaken = Number.NEGATIVE_INFINITY;
393
398
  const onWheel = (e) => {
394
- if (e.ctrlKey || e.metaKey) {
395
- // A trackpad's pinch arrives as Ctrl and the wheel; Command and the wheel does the same for a mouse.
399
+ const pinch = e.ctrlKey || e.metaKey;
400
+ const now = Number.isFinite(e.timeStamp) ? e.timeStamp : 0;
401
+ const continuing = now - wheelTaken < WHEEL_GESTURE_MS && now >= wheelTaken;
402
+ const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
403
+ if (!pinch && (horizontal || e.shiftKey)) {
404
+ if (!canTurn())
405
+ return;
396
406
  e.preventDefault();
407
+ wheelTaken = now;
397
408
  grab();
398
- controller.zoomAt(state.zoom * wheelZoomFactor(e.deltaY, e.deltaMode), { x: e.clientX, y: e.clientY });
409
+ controller.set({ yaw: state.yaw + (e.deltaX || e.deltaY) * 0.3 }, true, "wheel");
399
410
  return;
400
411
  }
401
- const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
402
- const focused = view.ownerDocument.activeElement === view;
403
- // A sideways swipe turns it wherever the pointer is over it; an up and down wheel only when the view has focus, as the page's scroll comes first.
404
- if (!horizontal && !focused)
412
+ const factor = wheelZoomFactor(e.deltaY, e.deltaMode, pinch ? "pinch" : "wheel");
413
+ const atEnd = factor >= 1 ? state.zoom >= limits.zoomMax : state.zoom <= limits.zoomMin;
414
+ // Ctrl and the wheel is always the view's, or the browser would zoom the whole page.
415
+ if (!pinch && atEnd && !continuing)
405
416
  return;
406
417
  e.preventDefault();
407
- if (!canTurn())
418
+ wheelTaken = now;
419
+ if (atEnd)
408
420
  return;
409
421
  grab();
410
- if (horizontal || e.shiftKey)
411
- controller.set({ yaw: state.yaw + (e.deltaX || e.deltaY) * 0.3 }, true, "wheel");
412
- else
413
- controller.set({ tilt: Math.round(state.tilt + e.deltaY * 0.15) }, true, "wheel");
422
+ controller.zoomAt(state.zoom * factor, { x: e.clientX, y: e.clientY });
414
423
  };
415
424
  const onKey = (e) => {
416
425
  if (e.ctrlKey || e.metaKey || e.altKey)
@@ -12,8 +12,9 @@ export interface ViewChangeDetail extends ViewState {
12
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
13
13
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
14
14
  * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
15
- * `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),
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; the wheel itself zooms),
16
16
  * `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),
17
+ * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
17
18
  * `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), `object-size` (`w,h`: the object's size at Fit in pixels when it is smaller than the view, so a pan keeps a quarter of it in view), `grab-radius` (exact grab: the on-screen radius of the piece in pixels, or `fit`; a drag then keeps the touched point under the pointer), `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
18
19
  * or dark), `overlay` (sit over the bottom of a positioned parent).
19
20
  */
@@ -6,11 +6,12 @@ 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, PACE_FACTOR, PACES, parseLevel, zoomText } from "./levels.js";
9
+ import { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, 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 { STYLES } from "./styles.js";
13
13
  import { languageOf, wordsFor } from "./words.js";
14
+ import { SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark } from "./zoomScale.js";
14
15
  export { EVENT, TAG };
15
16
  const icon = (path, cls = "") => `<svg viewBox="0 0 24 24" aria-hidden="true" class="${cls}"><path d="${path}"/></svg>`;
16
17
  const ICON = {
@@ -25,6 +26,9 @@ const ICON = {
25
26
  centre: icon("M11 2h2v5h-2zM11 17h2v5h-2zM2 11h5v2H2zM17 11h5v2h-5zM12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 1 0 0-7z"),
26
27
  more: icon("M3 7h18v2H3zM3 15h18v2H3zM8 4a3 3 0 1 0 0 6 3 3 0 1 0 0-6zM16 12a3 3 0 1 0 0 6 3 3 0 1 0 0-6z"),
27
28
  };
29
+ /** The zoom slider's steps from end to end, and the width of its thumb in pixels (the styles draw it 28 wide). */
30
+ const SLIDER_SPAN = 1000;
31
+ const THUMB = 28;
28
32
  const HTML = `
29
33
  <div class="bar" part="bar" role="group">
30
34
  <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>
@@ -39,6 +43,7 @@ const HTML = `
39
43
  <button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
40
44
  <button type="button" class="centre" data-b="centre" part="button" hidden>${ICON.centre}<span class="text" data-w="centre"></span></button>
41
45
  <div class="group zoom" role="group" data-g="zoom">
46
+ <div class="field zslide" part="field zoom-slider"><label data-w="zoom" for="zoom"></label><div class="zbox"><div class="ztrack" aria-hidden="true"><div class="zfill"></div></div><div class="zmarks" aria-hidden="true"></div><input id="zoom" type="range" min="0" max="1000" step="1" value="0" part="slider zoom-slider" /></div></div>
42
47
  <button type="button" data-b="out" part="button">${ICON.minus}</button>
43
48
  <button type="button" class="level" data-b="level" part="button"><span data-o="zoom">1×</span></button>
44
49
  <button type="button" data-b="in" part="button">${ICON.plus}</button>
@@ -61,13 +66,14 @@ const HTML = `
61
66
  * 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
62
67
  * 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;
63
68
  * 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),
64
- * `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),
69
+ * `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),
65
70
  * `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),
71
+ * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
66
72
  * `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), `object-size` (`w,h`: the object's size at Fit in pixels when it is smaller than the view, so a pan keeps a quarter of it in view), `grab-radius` (exact grab: the on-screen radius of the piece in pixels, or `fit`; a drag then keeps the touched point under the pointer), `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
67
73
  * or dark), `overlay` (sit over the bottom of a positioned parent).
68
74
  */
69
75
  export class ViewControlsElement extends HTMLElement {
70
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "controls", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
76
+ static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "controls", "layout", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
71
77
  #root;
72
78
  #controller;
73
79
  #view;
@@ -80,6 +86,11 @@ export class ViewControlsElement extends HTMLElement {
80
86
  #unwatch;
81
87
  #offPage = false;
82
88
  #level = "basic";
89
+ #layout = "classic";
90
+ #marks = [];
91
+ #marksFor = "";
92
+ /** The zoom the slider shows now; NaN until it has shown one, and again when the marks are redrawn for new limits. */
93
+ #zoomShown = Number.NaN;
83
94
  #pace = "medium";
84
95
  #labels = wordsFor("en");
85
96
  #said = "";
@@ -149,6 +160,7 @@ export class ViewControlsElement extends HTMLElement {
149
160
  if (this.#root.querySelector(".bar") === null)
150
161
  this.#build();
151
162
  this.#keys = parseKeys(this.getAttribute("address-keys"));
163
+ this.#arrange();
152
164
  this.#limits();
153
165
  const start = {};
154
166
  for (const key of ["yaw", "tilt"]) {
@@ -226,6 +238,10 @@ export class ViewControlsElement extends HTMLElement {
226
238
  this.#option(name);
227
239
  else if (name === "speed")
228
240
  this.#speed();
241
+ else if (name === "layout") {
242
+ this.#arrange();
243
+ this.#show();
244
+ }
229
245
  else if (name === "controls") {
230
246
  this.#level = parseLevel(value) ?? "basic";
231
247
  this.#show();
@@ -313,6 +329,7 @@ export class ViewControlsElement extends HTMLElement {
313
329
  };
314
330
  slider("yaw");
315
331
  slider("tilt");
332
+ this.#zoomSlider(q("#zoom"), c);
316
333
  const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
317
334
  on("play", () => c().toggle());
318
335
  on("front", () => c().front());
@@ -343,6 +360,76 @@ export class ViewControlsElement extends HTMLElement {
343
360
  on("move", () => c().setDrag("move"));
344
361
  on("turn", () => c().setDrag("turn"));
345
362
  }
363
+ /** The zoom slider: logarithmic, so a press of + or - moves it the same way anywhere; a drag that ends near a mark goes to it, and the keys step, jump between marks and go to the ends. */
364
+ #zoomSlider(input, c) {
365
+ const go = (final) => {
366
+ const { zoomMin: min, zoomMax: max } = c().limits;
367
+ const length = input.getBoundingClientRect().width - THUMB;
368
+ const zoom = zoomAtPosition(Number(input.value) / SLIDER_SPAN, min, max);
369
+ c().zoomAt(zoomSnap(zoom, this.#marks, min, max, length > 0 ? SNAP_PX / length : 0), undefined, final);
370
+ };
371
+ input.addEventListener("input", () => go(false));
372
+ input.addEventListener("change", () => go(true));
373
+ input.addEventListener("keydown", (event) => {
374
+ const { zoomMin: min, zoomMax: max } = c().limits;
375
+ const zoom = c().state.zoom;
376
+ const up = event.key === "ArrowRight" || event.key === "ArrowUp";
377
+ const down = event.key === "ArrowLeft" || event.key === "ArrowDown";
378
+ let next;
379
+ if (up || down)
380
+ next = zoomStep(zoom, this.#marks, up ? 1 : -1, min, max);
381
+ else if (event.key === "PageUp" || event.key === "PageDown")
382
+ next = zoomToMark(zoom, this.#marks, event.key === "PageUp" ? 1 : -1, min, max);
383
+ else if (event.key === "Home")
384
+ next = min;
385
+ else if (event.key === "End")
386
+ next = max;
387
+ if (next === undefined)
388
+ return;
389
+ event.preventDefault();
390
+ c().zoomAt(next);
391
+ });
392
+ }
393
+ /** The layout from the attribute, on the host as `data-layout` for the styles. */
394
+ #arrange() {
395
+ this.#layout = parseLayout(this.getAttribute("layout"));
396
+ if (this.dataset.layout !== this.#layout)
397
+ this.dataset.layout = this.#layout;
398
+ }
399
+ /** The slider's marks for the limits now: ticks on the track and their labels under it, made again only when the limits change. */
400
+ #drawMarks() {
401
+ const { zoomMin: min, zoomMax: max } = this.#ensure().limits;
402
+ const key = `${min} ${max}`;
403
+ if (key === this.#marksFor)
404
+ return;
405
+ this.#marksFor = key;
406
+ this.#marks = zoomMarks(min, max);
407
+ this.#zoomShown = Number.NaN;
408
+ const track = this.#root.querySelector(".ztrack");
409
+ const labels = this.#root.querySelector(".zmarks");
410
+ if (track === null || labels === null)
411
+ return;
412
+ for (const tick of track.querySelectorAll(".ztick"))
413
+ tick.remove();
414
+ labels.replaceChildren();
415
+ const doc = this.ownerDocument;
416
+ for (const mark of this.#marks) {
417
+ // Placed through the CSSOM, which a strict style-src allows, not a style attribute in markup.
418
+ const tick = doc.createElement("span");
419
+ tick.className = "ztick";
420
+ tick.style.setProperty("--at", String(mark.at));
421
+ track.append(tick);
422
+ const label = doc.createElement("span");
423
+ label.className = "zmark";
424
+ label.dataset.zoom = String(mark.zoom);
425
+ if (mark.end)
426
+ label.dataset.end = mark.at === 0 ? "start" : "end";
427
+ label.style.setProperty("--at", String(mark.at));
428
+ label.textContent = zoomText(mark.zoom);
429
+ labels.append(label);
430
+ }
431
+ this.#root.querySelector(".zslide")?.toggleAttribute("hidden", this.#marks.length === 0);
432
+ }
346
433
  #storageKey() {
347
434
  return this.getAttribute("storage") || LEVEL_STORAGE_KEY;
348
435
  }
@@ -372,6 +459,7 @@ export class ViewControlsElement extends HTMLElement {
372
459
  tilt.min = String(-c.limits.tiltMax);
373
460
  tilt.max = String(c.limits.tiltMax);
374
461
  }
462
+ this.#drawMarks();
375
463
  }
376
464
  #resolveView() {
377
465
  const id = this.getAttribute("for");
@@ -451,6 +539,18 @@ export class ViewControlsElement extends HTMLElement {
451
539
  node.setAttribute("aria-label", label);
452
540
  };
453
541
  write(q('[data-o="zoom"]'), zoom);
542
+ // The slider always shows the true zoom, wherever it came from. A frame of the automatic turn leaves the zoom alone, so it costs one comparison here.
543
+ if (s.zoom !== this.#zoomShown) {
544
+ this.#zoomShown = s.zoom;
545
+ const slider = q("#zoom");
546
+ const at = zoomPosition(s.zoom, c.limits.zoomMin, c.limits.zoomMax);
547
+ const value = String(Math.round(at * SLIDER_SPAN));
548
+ if (slider.value !== value)
549
+ slider.value = value;
550
+ q(".zbox").style.setProperty("--pos", String(at));
551
+ if (slider.getAttribute("aria-valuetext") !== zoom)
552
+ slider.setAttribute("aria-valuetext", zoom);
553
+ }
454
554
  say(q('[data-b="level"]'), `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
455
555
  this.#sayZoom(`${words.zoomLevel} ${zoom}`);
456
556
  for (const pace of PACES)
@@ -474,6 +574,8 @@ export class ViewControlsElement extends HTMLElement {
474
574
  // 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.
475
575
  q(".pace-now").hidden = flat || advanced || !s.turning;
476
576
  this.toggleAttribute("data-turning", s.turning);
577
+ if (this.hasAttribute("data-flat") !== flat)
578
+ this.toggleAttribute("data-flat", flat);
477
579
  }
478
580
  /** Tells a screen reader the zoom once it has settled for a moment (a pinch or a wheel makes many steps). */
479
581
  #sayZoom(text) {
@@ -1,7 +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, 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, 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";
4
4
  export { languageOf, WORDS, wordsFor, type Language, type ViewWords } from "./words.ts";
5
5
  export declare const EVENT = "kazoku-viewchange";
6
6
  export declare const TAG = "kazoku-view-controls";
7
- export { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, parsePace, zoomText, type Level, type Pace } from "./levels.ts";
7
+ export { LAYOUTS, LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLayout, parseLevel, parsePace, zoomText, type Layout, type Level, type Pace } from "./levels.ts";
8
+ export { COMMON_ZOOMS, MARK_GAP, SLIDER_STEP, SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark, type ZoomMark } from "./zoomScale.ts";
@@ -1,8 +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, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout } from "./math.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";
5
5
  export { languageOf, WORDS, wordsFor } from "./words.js";
6
6
  export const EVENT = "kazoku-viewchange";
7
7
  export const TAG = "kazoku-view-controls";
8
- export { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, parsePace, zoomText } from "./levels.js";
8
+ export { LAYOUTS, LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLayout, parseLevel, parsePace, zoomText } from "./levels.js";
9
+ export { COMMON_ZOOMS, MARK_GAP, SLIDER_STEP, SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark } from "./zoomScale.js";
@@ -10,3 +10,12 @@ export declare const PACE_FACTOR: Record<Pace, number>;
10
10
  export declare const parsePace: (text: string | null | undefined) => Pace | undefined;
11
11
  /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
12
12
  export declare const zoomText: (zoom: number) => string;
13
+ /**
14
+ * How the controls are laid out, the `layout` attribute. `classic` is the default: the bar of 0.10 and before, unchanged. `horizontal` is a tidy bar under the view with the
15
+ * zoom slider; `vertical` a column beside the view; `compact` one short row for a card or a phone, with everything else one press of More away. The level (`controls`) still
16
+ * says what is shown; the layout says where.
17
+ */
18
+ export type Layout = "classic" | "horizontal" | "vertical" | "compact";
19
+ export declare const LAYOUTS: readonly Layout[];
20
+ /** A layout is one of its words; anything else (or none) is `classic`. */
21
+ export declare const parseLayout: (text: string | null | undefined) => Layout;
@@ -7,3 +7,6 @@ export const PACE_FACTOR = { slow: 0.5, medium: 1, fast: 2 };
7
7
  export const parsePace = (text) => PACES.find((pace) => pace === text);
8
8
  /** The zoom as the readout shows it: `1×`, `2.4×`; below 1, two figures, so a small zoom never reads `0×`. */
9
9
  export const zoomText = (zoom) => `${Number(zoom < 1 ? zoom.toPrecision(2) : zoom.toFixed(1))}×`;
10
+ export const LAYOUTS = ["classic", "horizontal", "vertical", "compact"];
11
+ /** A layout is one of its words; anything else (or none) is `classic`. */
12
+ export const parseLayout = (text) => LAYOUTS.find((layout) => layout === text) ?? "classic";
@@ -56,5 +56,11 @@ export declare const degreesPerPixel: (radius: number) => number;
56
56
  export declare function dragTurn(start: Camera, dx: number, dy: number, size: Size, tips: boolean, limits?: Limits, step?: number, grabRadius?: number): Pick<Camera, "yaw" | "tilt">;
57
57
  /** The camera `fraction` of the way (0 to 1) from `from` to `to`, eased out: quick at first, slow at the end. */
58
58
  export declare function between(from: Camera, to: Camera, fraction: number): Camera;
59
- /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
60
- export declare const wheelZoomFactor: (deltaY: number, deltaMode: number) => number;
59
+ /**
60
+ * The factor a wheel turn zooms by: up (a negative `deltaY`) zooms in. `deltaMode` 1 is lines and 2 is pages. `kind` `"pinch"` (the default) is a
61
+ * trackpad's pinch, which arrives as Ctrl and the wheel in small steps; `"wheel"` is a plain wheel or two-finger scroll, whose steps are larger
62
+ * (about 100 pixels a notch), so one notch zooms about 1.2 times, close to the + key, and one such event never zooms more than 2 times either way.
63
+ */
64
+ export declare const wheelZoomFactor: (deltaY: number, deltaMode: number, kind?: "pinch" | "wheel") => number;
65
+ /** Wheel steps closer together than this, in milliseconds, belong to one turn of the wheel or one swipe (with its glide). */
66
+ export declare const WHEEL_GESTURE_MS = 300;
@@ -60,5 +60,14 @@ export function between(from, to, fraction) {
60
60
  const mix = (a, b) => a + (b - a) * t;
61
61
  return { yaw: mix(from.yaw, to.yaw), tilt: mix(from.tilt, to.tilt), zoom: mix(from.zoom, to.zoom), panX: mix(from.panX, to.panX), panY: mix(from.panY, to.panY) };
62
62
  }
63
- /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
64
- export const wheelZoomFactor = (deltaY, deltaMode) => Math.exp(-deltaY * (deltaMode === 1 ? 0.05 : 0.01));
63
+ /**
64
+ * The factor a wheel turn zooms by: up (a negative `deltaY`) zooms in. `deltaMode` 1 is lines and 2 is pages. `kind` `"pinch"` (the default) is a
65
+ * trackpad's pinch, which arrives as Ctrl and the wheel in small steps; `"wheel"` is a plain wheel or two-finger scroll, whose steps are larger
66
+ * (about 100 pixels a notch), so one notch zooms about 1.2 times, close to the + key, and one such event never zooms more than 2 times either way.
67
+ */
68
+ export const wheelZoomFactor = (deltaY, deltaMode, kind = "pinch") => {
69
+ const pixels = deltaMode === 1 ? deltaY * (kind === "pinch" ? 5 : 33) : deltaMode === 2 ? deltaY * 800 : deltaY;
70
+ return kind === "pinch" ? Math.exp(-pixels * 0.01) : clamp(Math.exp(-pixels * 0.002), 0.5, 2);
71
+ };
72
+ /** Wheel steps closer together than this, in milliseconds, belong to one turn of the wheel or one swipe (with its glide). */
73
+ export const WHEEL_GESTURE_MS = 300;
@@ -1 +1 @@
1
- export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n:host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }\n:host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }\n[hidden] { display: none !important; }\n.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }\n.field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }\n.field.tilt { flex-basis: 150px; }\n.field label { flex: none; }\n.field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\ninput[type=\"range\"] { -webkit-appearance: none; appearance: none; flex: 1 1 auto; width: 100%; min-width: 60px; height: 48px; margin: 0; padding: 0; background: transparent; cursor: pointer; }\ninput[type=\"range\"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -11px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton[aria-expanded=\"true\"] { border-color: var(--_ink); box-shadow: inset 0 0 0 1px var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\nbutton[aria-pressed=\"true\"] .i-play, button[aria-pressed=\"false\"] .i-pause { display: none; }\n.zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }\n.pace .lbl { margin-inline-end: 4px; }\n/* One small button: the word over the speed, so it takes little room. It comes in on Auto-turn's left, so Auto-turn stays under the pointer that just pressed it. The angle's running number makes the room, so the turning bar is never wider than the still one and adds no row; it stays for a screen reader. */\n.pace-now { flex-direction: column; gap: 0; padding: 0 10px; line-height: 1.1; }\n.pace-now .lbl { font-size: 0.7em; font-weight: 400; }\n:host([data-turning][data-level=\"basic\"]) .field.angle { flex-basis: 110px; }\n:host([data-turning][data-level=\"basic\"]) .field.angle output { position: absolute; width: 1px; min-width: 0; height: 1px; overflow: hidden; clip-path: inset(50%); }\n.drag { margin-inline-start: 8px; }\n.more { margin-inline-start: auto; }\n/* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */\n@media (max-width: 640px) {\n .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: \"angle play more\" \"tilt tilt front\" \"pace pace pace\" \"zoom zoom zoom\" \"pan pan pan\"; gap: 0 8px; padding: 2px 8px; }\n .field.angle { grid-area: angle; }\n .field.tilt { grid-area: tilt; }\n .field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .play, .more { padding: 0; }\n .play { grid-area: play; }\n .more { grid-area: more; }\n .front { grid-area: front; padding: 0 12px; }\n .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n .pace button { padding: 0 12px; }\n .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n .zoom button { padding: 0 8px; }\n .zoom button.level { padding: 0 6px; }\n /* In the free room left of the zoom buttons, so showing it adds no row. */\n .pace-now { grid-area: zoom; justify-self: start; }\n .pan { grid-area: pan; justify-content: center; }\n .drag { margin-inline-start: 4px; }\n}\n";
1
+ export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n:host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }\n:host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }\n[hidden] { display: none !important; }\n.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }\n.field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }\n.field.tilt { flex-basis: 150px; }\n.field label { flex: none; }\n.field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\ninput[type=\"range\"] { -webkit-appearance: none; appearance: none; flex: 1 1 auto; width: 100%; min-width: 60px; height: 48px; margin: 0; padding: 0; background: transparent; cursor: pointer; }\ninput[type=\"range\"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }\ninput[type=\"range\"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 28px; height: 28px; margin-top: -11px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--_accent); border: 3px solid var(--_surface); box-shadow: 0 0 0 1px var(--_ink); }\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton[aria-expanded=\"true\"] { border-color: var(--_ink); box-shadow: inset 0 0 0 1px var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\nbutton[aria-pressed=\"true\"] .i-play, button[aria-pressed=\"false\"] .i-pause { display: none; }\n.zoom button.level { min-width: 64px; padding: 0 8px; font-variant-numeric: tabular-nums; }\n.pace .lbl { margin-inline-end: 4px; }\n/* One small button: the word over the speed, so it takes little room. It comes in on Auto-turn's left, so Auto-turn stays under the pointer that just pressed it. The angle's running number makes the room, so the turning bar is never wider than the still one and adds no row; it stays for a screen reader. */\n.pace-now { flex-direction: column; gap: 0; padding: 0 10px; line-height: 1.1; }\n.pace-now .lbl { font-size: 0.7em; font-weight: 400; }\n:host([data-turning][data-level=\"basic\"]) .field.angle { flex-basis: 110px; }\n:host([data-turning][data-level=\"basic\"]) .field.angle output { position: absolute; width: 1px; min-width: 0; height: 1px; overflow: hidden; clip-path: inset(50%); }\n.drag { margin-inline-start: 8px; }\n.more { margin-inline-start: auto; }\n/* The classic bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */\n@media (max-width: 640px) {\n :host([data-layout=\"classic\"]) .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: \"angle play more\" \"tilt tilt front\" \"pace pace pace\" \"zoom zoom zoom\" \"pan pan pan\"; gap: 0 8px; padding: 2px 8px; }\n :host([data-layout=\"classic\"]) .field.angle { grid-area: angle; }\n :host([data-layout=\"classic\"]) .field.tilt { grid-area: tilt; }\n :host([data-layout=\"classic\"]) .field label, :host([data-layout=\"classic\"]) .field output, :host([data-layout=\"classic\"]) .play .text, :host([data-layout=\"classic\"]) .more .text, :host([data-layout=\"classic\"]) .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n :host([data-layout=\"classic\"]) .play, :host([data-layout=\"classic\"]) .more { padding: 0; }\n :host([data-layout=\"classic\"]) .play { grid-area: play; }\n :host([data-layout=\"classic\"]) .more { grid-area: more; }\n :host([data-layout=\"classic\"]) .front { grid-area: front; padding: 0 12px; }\n :host([data-layout=\"classic\"]) .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }\n :host([data-layout=\"classic\"]) .pace button { padding: 0 12px; }\n :host([data-layout=\"classic\"]) .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n :host([data-layout=\"classic\"]) .zoom button { padding: 0 8px; }\n :host([data-layout=\"classic\"]) .zoom button.level { padding: 0 6px; }\n /* In the free room left of the zoom buttons, so showing it adds no row. */\n :host([data-layout=\"classic\"]) .pace-now { grid-area: zoom; justify-self: start; }\n :host([data-layout=\"classic\"]) .pan { grid-area: pan; justify-content: center; }\n :host([data-layout=\"classic\"]) .drag { margin-inline-start: 4px; }\n}\n\n/* The zoom slider (horizontal, vertical and compact): a logarithmic track with ticks at its marks, the labels under it, and the part from the least zoom to the zoom now filled. */\n.zslide { display: none; }\n:host([data-layout=\"horizontal\"]) .zslide, :host([data-layout=\"vertical\"]) .zslide, :host([data-layout=\"compact\"]) .zslide { display: flex; }\n.zbox { position: relative; flex: 1 1 auto; min-width: 96px; height: 52px; }\n.zbox input { position: absolute; inset: 0 0 auto; z-index: 1; min-width: 0; }\n.zbox input::-webkit-slider-runnable-track { background: transparent; }\n.zbox input::-moz-range-track { background: transparent; }\n.ztrack { position: absolute; inset-inline: 14px; top: 21px; height: 6px; border-radius: 3px; background: var(--_line); }\n.zfill { position: absolute; inset-block: 0; left: 0; width: calc(var(--pos, 0) * 100%); border-radius: 3px; background: var(--_accent); }\n.ztick { position: absolute; top: 50%; left: calc(var(--at) * 100%); width: 2px; height: 14px; border-radius: 1px; background: var(--_ink); opacity: 0.5; transform: translate(-50%, -50%); }\n.zmarks { position: absolute; inset-inline: 14px; top: 36px; height: 16px; pointer-events: none; }\n.zmark { position: absolute; left: calc(var(--at) * 100%); transform: translateX(-50%); font-size: 0.72rem; font-weight: 500; line-height: 16px; opacity: 0.85; white-space: nowrap; font-variant-numeric: tabular-nums; }\n.zmark[data-end=\"start\"] { transform: translateX(-35%); }\n.zmark[data-end=\"end\"] { transform: translateX(-65%); }\n\n/* Horizontal: a tidy bar under the view. Two rows (the turn, then the zoom with its slider); one row when its own width allows. More adds its rows between them. */\n:host([data-layout=\"horizontal\"]) { container-type: inline-size; }\n:host([data-layout=\"horizontal\"]) .bar, :host([data-layout=\"vertical\"]) .bar, :host([data-layout=\"compact\"]) .bar { display: grid; row-gap: 0; align-items: center; }\n:host([data-layout=\"horizontal\"]) .bar > *, :host([data-layout=\"vertical\"]) .bar > *, :host([data-layout=\"compact\"]) .zoom > * { margin-block: 2px; }\n:host([data-layout=\"horizontal\"]) .bar { grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: \"angle speed play more\" \"tilt tilt front front\" \"pace pace pace pace\" \"zoom zoom zoom centre\" \"pan pan pan pan\"; column-gap: 8px; padding: 4px 8px; }\n:host([data-layout=\"horizontal\"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: \"zoom centre more\" \"pan pan pan\"; }\n:host([data-layout=\"horizontal\"]) .angle, :host([data-layout=\"vertical\"]) .angle, :host([data-layout=\"compact\"]) .angle { grid-area: angle; }\n:host([data-layout=\"horizontal\"]) .tilt, :host([data-layout=\"vertical\"]) .tilt, :host([data-layout=\"compact\"]) .tilt { grid-area: tilt; }\n:host([data-layout=\"horizontal\"]) .pace-now, :host([data-layout=\"vertical\"]) .pace-now { grid-area: speed; }\n:host([data-layout=\"horizontal\"]) .play, :host([data-layout=\"vertical\"]) .play, :host([data-layout=\"compact\"]) .play { grid-area: play; }\n:host([data-layout=\"horizontal\"]) .pace, :host([data-layout=\"vertical\"]) .pace, :host([data-layout=\"compact\"]) .pace { grid-area: pace; flex-wrap: nowrap; }\n:host([data-layout=\"horizontal\"]) .front, :host([data-layout=\"vertical\"]) .front, :host([data-layout=\"compact\"]) .front { grid-area: front; justify-self: start; }\n:host([data-layout=\"horizontal\"]) .zoom, :host([data-layout=\"vertical\"]) .zoom { grid-area: zoom; }\n:host([data-layout=\"horizontal\"]) .pan, :host([data-layout=\"vertical\"]) .pan, :host([data-layout=\"compact\"]) .pan { grid-area: pan; justify-self: start; }\n:host([data-layout=\"horizontal\"]) .more, :host([data-layout=\"vertical\"]) .more, :host([data-layout=\"compact\"]) .more { grid-area: more; margin-inline-start: 0; }\n/* Centre, shown once the view is zoomed or moved: beside the zoom, never in the turn's row, so Auto-turn and More stay where they are. */\n:host([data-layout=\"horizontal\"]) .centre, :host([data-layout=\"vertical\"]) .centre, :host([data-layout=\"compact\"]) .centre { grid-area: centre; justify-self: end; }\n/* In the two rows it shares More's column, so it is an icon (its name is read) and never widens that column, which would move Auto-turn. */\n@container (max-width: 899px) {\n :host([data-layout=\"horizontal\"]) .centre .text { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n :host([data-layout=\"horizontal\"]) .centre { padding: 0; }\n}\n:host([data-layout=\"horizontal\"]) .zoom { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 0; }\n:host([data-layout=\"horizontal\"]) .zslide { flex: 1 1 auto; min-width: 0; }\n:host([data-layout=\"horizontal\"]) .zoom button, :host([data-layout=\"vertical\"]) .zoom button, :host([data-layout=\"compact\"]) .zoom button { padding: 0 10px; }\n:host([data-layout=\"horizontal\"]) .zoom button.level, :host([data-layout=\"vertical\"]) .zoom button.level, :host([data-layout=\"compact\"]) .zoom button.level { padding: 0 6px; }\n@container (max-width: 559px) {\n :host([data-layout=\"horizontal\"]) .field label, :host([data-layout=\"horizontal\"]) .field output, :host([data-layout=\"horizontal\"]) .play .text, :host([data-layout=\"horizontal\"]) .more .text, :host([data-layout=\"horizontal\"]) .pace .lbl { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n :host([data-layout=\"horizontal\"]) .play, :host([data-layout=\"horizontal\"]) .more { padding: 0; }\n :host([data-layout=\"horizontal\"]) .zbox { min-width: 72px; }\n /* The readout is Fit here (its name says so), which leaves the slider the room for its marks. */\n :host([data-layout=\"horizontal\"]) [data-b=\"fit\"] { display: none; }\n}\n@container (min-width: 900px) {\n :host([data-layout=\"horizontal\"]) .bar { grid-template-columns: minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: \"angle speed play zoom centre more\" \"tilt tilt tilt pace front front\" \"pan pan pan pan pan pan\"; column-gap: 12px; }\n :host([data-layout=\"horizontal\"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: \"zoom centre more\" \"pan pan pan\"; }\n :host([data-layout=\"horizontal\"]) .zslide { flex: 0 0 15rem; }\n :host([data-layout=\"horizontal\"]) .pan { justify-self: end; }\n}\n\n/* Vertical: a column beside the view, its width --kazoku-column-width (16rem). Auto-turn and More at the top, so More stays put when it opens; then the turn, the zoom and its slider. */\n:host([data-layout=\"vertical\"]) { width: var(--kazoku-column-width, 16rem); max-width: 100%; }\n:host([data-layout=\"vertical\"]) .bar { grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: \"play speed . more\" \"angle angle angle angle\" \"tilt tilt tilt tilt\" \"pace pace pace pace\" \"front front front front\" \"zoom zoom zoom zoom\" \"centre centre centre centre\" \"pan pan pan pan\"; column-gap: 8px; padding: 6px 8px; }\n:host([data-layout=\"vertical\"]) .field { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: \"label value\" \"slider slider\"; gap: 0 8px; }\n:host([data-layout=\"vertical\"]) .field label { grid-area: label; padding-top: 4px; }\n:host([data-layout=\"vertical\"]) .field output { grid-area: value; padding-top: 4px; }\n:host([data-layout=\"vertical\"]) .field input, :host([data-layout=\"vertical\"]) .zbox { grid-area: slider; }\n:host([data-layout=\"vertical\"]) .zoom { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr); grid-template-areas: \"zs zs zs zs\" \"out level in fit\"; gap: 0 4px; }\n:host([data-layout=\"vertical\"]) .zslide { grid-area: zs; }\n:host([data-layout=\"vertical\"]) [data-b=\"out\"] { grid-area: out; }\n:host([data-layout=\"vertical\"]) [data-b=\"level\"] { grid-area: level; }\n:host([data-layout=\"vertical\"]) [data-b=\"in\"] { grid-area: in; }\n:host([data-layout=\"vertical\"]) [data-b=\"fit\"] { grid-area: fit; justify-self: start; }\n:host([data-layout=\"vertical\"]) .pace .lbl { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n:host([data-layout=\"vertical\"]) .pace button { padding: 0 10px; }\n:host([data-layout=\"vertical\"]) .pan { flex-wrap: wrap; }\n:host([data-layout=\"vertical\"]) .drag { margin-inline-start: 0; }\n\n/* Compact: one short row (Auto-turn, zoom out, the zoom now, zoom in, More) for a card or a phone. More opens the rest under it: the zoom slider and Fit, the angle, and the advanced controls. */\n:host([data-layout=\"compact\"]) { display: block; }\n:host([data-layout=\"compact\"][overlay]) { inset-inline: 8px auto; bottom: 8px; }\n/* Over a card, an open More never reaches past the card's top: the controls scroll inside themselves. */\n:host([data-layout=\"compact\"][overlay]) { display: flex; flex-direction: column; max-height: calc(100% - 16px); }\n:host([data-layout=\"compact\"][overlay]) .bar { min-height: 0; overflow-y: auto; }\n:host([data-layout=\"compact\"]) .bar { width: fit-content; max-width: 100%; grid-template-columns: repeat(5, auto); grid-template-areas: \"play out level in more\" \"zs zs zs zs fit\" \"centre centre centre centre centre\" \"angle angle angle angle angle\" \"tilt tilt tilt tilt tilt\" \"pace pace pace pace pace\" \"front front front front front\" \"pan pan pan pan pan\"; column-gap: 2px; padding: 2px 4px; border: 1px solid var(--_line); border-radius: 28px; background: var(--_surface); }\n:host([data-layout=\"compact\"][data-level=\"advanced\"]) .bar { border-radius: 20px; padding: 2px 8px 6px; column-gap: 4px; }\n:host([data-layout=\"compact\"][data-flat]) .bar { grid-template-areas: \"out level in more .\" \"zs zs zs fit fit\" \"centre centre centre centre centre\" \"pan pan pan pan pan\"; }\n:host([data-layout=\"compact\"]) .zoom { display: contents; }\n:host([data-layout=\"compact\"]) .zslide { grid-area: zs; }\n:host([data-layout=\"compact\"]) [data-b=\"out\"] { grid-area: out; }\n:host([data-layout=\"compact\"]) [data-b=\"level\"] { grid-area: level; }\n:host([data-layout=\"compact\"]) [data-b=\"in\"] { grid-area: in; }\n:host([data-layout=\"compact\"]) [data-b=\"fit\"] { grid-area: fit; }\n:host([data-layout=\"compact\"]) .play, :host([data-layout=\"compact\"]) .more, :host([data-layout=\"compact\"]) [data-b=\"out\"], :host([data-layout=\"compact\"]) [data-b=\"level\"], :host([data-layout=\"compact\"]) [data-b=\"in\"] { border-color: transparent; }\n:host([data-layout=\"compact\"]) .play:hover:not(:disabled), :host([data-layout=\"compact\"]) .more:hover:not(:disabled), :host([data-layout=\"compact\"]) .zoom button:hover:not(:disabled) { border-color: var(--_ink); }\n:host([data-layout=\"compact\"]) .play .text, :host([data-layout=\"compact\"]) .more .text { position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n:host([data-layout=\"compact\"]) .play, :host([data-layout=\"compact\"]) .more { padding: 0; }\n:host([data-layout=\"compact\"]) .pace-now { display: none; }\n:host([data-layout=\"vertical\"]) .centre, :host([data-layout=\"compact\"]) .centre { justify-self: start; }\n/* The row stays one row: Centre is with Fit, one press of More away. */\n:host([data-layout=\"compact\"][data-level=\"basic\"]) .centre { display: none; }\n:host([data-layout=\"compact\"][data-level=\"basic\"]) .angle, :host([data-layout=\"compact\"][data-level=\"basic\"]) .zslide, :host([data-layout=\"compact\"][data-level=\"basic\"]) [data-b=\"fit\"] { display: none; }\n";
@@ -1,5 +1,11 @@
1
1
  // The element's styles. Colours are custom properties on the host, so a page can set its own:
2
2
  // --kazoku-ink, --kazoku-surface, --kazoku-line, --kazoku-accent. Light and dark follow the page's colour scheme unless `theme="light"` or `theme="dark"` is set.
3
+ const CLASSIC = ':host([data-layout="classic"])';
4
+ const H = ':host([data-layout="horizontal"])';
5
+ const V = ':host([data-layout="vertical"])';
6
+ const C = ':host([data-layout="compact"])';
7
+ /** Read by a screen reader, not shown. */
8
+ const UNSEEN = "position: absolute; width: 1px; min-width: 0; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap;";
3
9
  export const STYLES = `
4
10
  :host {
5
11
  --_ink: var(--kazoku-ink, #1b1b1f);
@@ -61,24 +67,122 @@ button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { dis
61
67
  :host([data-turning][data-level="basic"]) .field.angle output { position: absolute; width: 1px; min-width: 0; height: 1px; overflow: hidden; clip-path: inset(50%); }
62
68
  .drag { margin-inline-start: 8px; }
63
69
  .more { margin-inline-start: auto; }
64
- /* A compact bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */
70
+ /* The classic bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */
65
71
  @media (max-width: 640px) {
66
- .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: "angle play more" "tilt tilt front" "pace pace pace" "zoom zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
67
- .field.angle { grid-area: angle; }
68
- .field.tilt { grid-area: tilt; }
69
- .field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
70
- .play, .more { padding: 0; }
71
- .play { grid-area: play; }
72
- .more { grid-area: more; }
73
- .front { grid-area: front; padding: 0 12px; }
74
- .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }
75
- .pace button { padding: 0 12px; }
76
- .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
77
- .zoom button { padding: 0 8px; }
78
- .zoom button.level { padding: 0 6px; }
72
+ ${CLASSIC} .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: "angle play more" "tilt tilt front" "pace pace pace" "zoom zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
73
+ ${CLASSIC} .field.angle { grid-area: angle; }
74
+ ${CLASSIC} .field.tilt { grid-area: tilt; }
75
+ ${CLASSIC} .field label, ${CLASSIC} .field output, ${CLASSIC} .play .text, ${CLASSIC} .more .text, ${CLASSIC} .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
76
+ ${CLASSIC} .play, ${CLASSIC} .more { padding: 0; }
77
+ ${CLASSIC} .play { grid-area: play; }
78
+ ${CLASSIC} .more { grid-area: more; }
79
+ ${CLASSIC} .front { grid-area: front; padding: 0 12px; }
80
+ ${CLASSIC} .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }
81
+ ${CLASSIC} .pace button { padding: 0 12px; }
82
+ ${CLASSIC} .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
83
+ ${CLASSIC} .zoom button { padding: 0 8px; }
84
+ ${CLASSIC} .zoom button.level { padding: 0 6px; }
79
85
  /* In the free room left of the zoom buttons, so showing it adds no row. */
80
- .pace-now { grid-area: zoom; justify-self: start; }
81
- .pan { grid-area: pan; justify-content: center; }
82
- .drag { margin-inline-start: 4px; }
86
+ ${CLASSIC} .pace-now { grid-area: zoom; justify-self: start; }
87
+ ${CLASSIC} .pan { grid-area: pan; justify-content: center; }
88
+ ${CLASSIC} .drag { margin-inline-start: 4px; }
83
89
  }
90
+
91
+ /* The zoom slider (horizontal, vertical and compact): a logarithmic track with ticks at its marks, the labels under it, and the part from the least zoom to the zoom now filled. */
92
+ .zslide { display: none; }
93
+ ${H} .zslide, ${V} .zslide, ${C} .zslide { display: flex; }
94
+ .zbox { position: relative; flex: 1 1 auto; min-width: 96px; height: 52px; }
95
+ .zbox input { position: absolute; inset: 0 0 auto; z-index: 1; min-width: 0; }
96
+ .zbox input::-webkit-slider-runnable-track { background: transparent; }
97
+ .zbox input::-moz-range-track { background: transparent; }
98
+ .ztrack { position: absolute; inset-inline: 14px; top: 21px; height: 6px; border-radius: 3px; background: var(--_line); }
99
+ .zfill { position: absolute; inset-block: 0; left: 0; width: calc(var(--pos, 0) * 100%); border-radius: 3px; background: var(--_accent); }
100
+ .ztick { position: absolute; top: 50%; left: calc(var(--at) * 100%); width: 2px; height: 14px; border-radius: 1px; background: var(--_ink); opacity: 0.5; transform: translate(-50%, -50%); }
101
+ .zmarks { position: absolute; inset-inline: 14px; top: 36px; height: 16px; pointer-events: none; }
102
+ .zmark { position: absolute; left: calc(var(--at) * 100%); transform: translateX(-50%); font-size: 0.72rem; font-weight: 500; line-height: 16px; opacity: 0.85; white-space: nowrap; font-variant-numeric: tabular-nums; }
103
+ .zmark[data-end="start"] { transform: translateX(-35%); }
104
+ .zmark[data-end="end"] { transform: translateX(-65%); }
105
+
106
+ /* Horizontal: a tidy bar under the view. Two rows (the turn, then the zoom with its slider); one row when its own width allows. More adds its rows between them. */
107
+ ${H} { container-type: inline-size; }
108
+ ${H} .bar, ${V} .bar, ${C} .bar { display: grid; row-gap: 0; align-items: center; }
109
+ ${H} .bar > *, ${V} .bar > *, ${C} .zoom > * { margin-block: 2px; }
110
+ ${H} .bar { grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: "angle speed play more" "tilt tilt front front" "pace pace pace pace" "zoom zoom zoom centre" "pan pan pan pan"; column-gap: 8px; padding: 4px 8px; }
111
+ :host([data-layout="horizontal"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "zoom centre more" "pan pan pan"; }
112
+ ${H} .angle, ${V} .angle, ${C} .angle { grid-area: angle; }
113
+ ${H} .tilt, ${V} .tilt, ${C} .tilt { grid-area: tilt; }
114
+ ${H} .pace-now, ${V} .pace-now { grid-area: speed; }
115
+ ${H} .play, ${V} .play, ${C} .play { grid-area: play; }
116
+ ${H} .pace, ${V} .pace, ${C} .pace { grid-area: pace; flex-wrap: nowrap; }
117
+ ${H} .front, ${V} .front, ${C} .front { grid-area: front; justify-self: start; }
118
+ ${H} .zoom, ${V} .zoom { grid-area: zoom; }
119
+ ${H} .pan, ${V} .pan, ${C} .pan { grid-area: pan; justify-self: start; }
120
+ ${H} .more, ${V} .more, ${C} .more { grid-area: more; margin-inline-start: 0; }
121
+ /* Centre, shown once the view is zoomed or moved: beside the zoom, never in the turn's row, so Auto-turn and More stay where they are. */
122
+ ${H} .centre, ${V} .centre, ${C} .centre { grid-area: centre; justify-self: end; }
123
+ /* In the two rows it shares More's column, so it is an icon (its name is read) and never widens that column, which would move Auto-turn. */
124
+ @container (max-width: 899px) {
125
+ ${H} .centre .text { ${UNSEEN} }
126
+ ${H} .centre { padding: 0; }
127
+ }
128
+ ${H} .zoom { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 0; }
129
+ ${H} .zslide { flex: 1 1 auto; min-width: 0; }
130
+ ${H} .zoom button, ${V} .zoom button, ${C} .zoom button { padding: 0 10px; }
131
+ ${H} .zoom button.level, ${V} .zoom button.level, ${C} .zoom button.level { padding: 0 6px; }
132
+ @container (max-width: 559px) {
133
+ ${H} .field label, ${H} .field output, ${H} .play .text, ${H} .more .text, ${H} .pace .lbl { ${UNSEEN} }
134
+ ${H} .play, ${H} .more { padding: 0; }
135
+ ${H} .zbox { min-width: 72px; }
136
+ /* The readout is Fit here (its name says so), which leaves the slider the room for its marks. */
137
+ ${H} [data-b="fit"] { display: none; }
138
+ }
139
+ @container (min-width: 900px) {
140
+ ${H} .bar { grid-template-columns: minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: "angle speed play zoom centre more" "tilt tilt tilt pace front front" "pan pan pan pan pan pan"; column-gap: 12px; }
141
+ :host([data-layout="horizontal"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "zoom centre more" "pan pan pan"; }
142
+ ${H} .zslide { flex: 0 0 15rem; }
143
+ ${H} .pan { justify-self: end; }
144
+ }
145
+
146
+ /* Vertical: a column beside the view, its width --kazoku-column-width (16rem). Auto-turn and More at the top, so More stays put when it opens; then the turn, the zoom and its slider. */
147
+ ${V} { width: var(--kazoku-column-width, 16rem); max-width: 100%; }
148
+ ${V} .bar { grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: "play speed . more" "angle angle angle angle" "tilt tilt tilt tilt" "pace pace pace pace" "front front front front" "zoom zoom zoom zoom" "centre centre centre centre" "pan pan pan pan"; column-gap: 8px; padding: 6px 8px; }
149
+ ${V} .field { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "label value" "slider slider"; gap: 0 8px; }
150
+ ${V} .field label { grid-area: label; padding-top: 4px; }
151
+ ${V} .field output { grid-area: value; padding-top: 4px; }
152
+ ${V} .field input, ${V} .zbox { grid-area: slider; }
153
+ ${V} .zoom { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr); grid-template-areas: "zs zs zs zs" "out level in fit"; gap: 0 4px; }
154
+ ${V} .zslide { grid-area: zs; }
155
+ ${V} [data-b="out"] { grid-area: out; }
156
+ ${V} [data-b="level"] { grid-area: level; }
157
+ ${V} [data-b="in"] { grid-area: in; }
158
+ ${V} [data-b="fit"] { grid-area: fit; justify-self: start; }
159
+ ${V} .pace .lbl { ${UNSEEN} }
160
+ ${V} .pace button { padding: 0 10px; }
161
+ ${V} .pan { flex-wrap: wrap; }
162
+ ${V} .drag { margin-inline-start: 0; }
163
+
164
+ /* Compact: one short row (Auto-turn, zoom out, the zoom now, zoom in, More) for a card or a phone. More opens the rest under it: the zoom slider and Fit, the angle, and the advanced controls. */
165
+ ${C} { display: block; }
166
+ :host([data-layout="compact"][overlay]) { inset-inline: 8px auto; bottom: 8px; }
167
+ /* Over a card, an open More never reaches past the card's top: the controls scroll inside themselves. */
168
+ :host([data-layout="compact"][overlay]) { display: flex; flex-direction: column; max-height: calc(100% - 16px); }
169
+ :host([data-layout="compact"][overlay]) .bar { min-height: 0; overflow-y: auto; }
170
+ ${C} .bar { width: fit-content; max-width: 100%; grid-template-columns: repeat(5, auto); grid-template-areas: "play out level in more" "zs zs zs zs fit" "centre centre centre centre centre" "angle angle angle angle angle" "tilt tilt tilt tilt tilt" "pace pace pace pace pace" "front front front front front" "pan pan pan pan pan"; column-gap: 2px; padding: 2px 4px; border: 1px solid var(--_line); border-radius: 28px; background: var(--_surface); }
171
+ :host([data-layout="compact"][data-level="advanced"]) .bar { border-radius: 20px; padding: 2px 8px 6px; column-gap: 4px; }
172
+ :host([data-layout="compact"][data-flat]) .bar { grid-template-areas: "out level in more ." "zs zs zs fit fit" "centre centre centre centre centre" "pan pan pan pan pan"; }
173
+ ${C} .zoom { display: contents; }
174
+ ${C} .zslide { grid-area: zs; }
175
+ ${C} [data-b="out"] { grid-area: out; }
176
+ ${C} [data-b="level"] { grid-area: level; }
177
+ ${C} [data-b="in"] { grid-area: in; }
178
+ ${C} [data-b="fit"] { grid-area: fit; }
179
+ ${C} .play, ${C} .more, ${C} [data-b="out"], ${C} [data-b="level"], ${C} [data-b="in"] { border-color: transparent; }
180
+ ${C} .play:hover:not(:disabled), ${C} .more:hover:not(:disabled), ${C} .zoom button:hover:not(:disabled) { border-color: var(--_ink); }
181
+ ${C} .play .text, ${C} .more .text { ${UNSEEN} }
182
+ ${C} .play, ${C} .more { padding: 0; }
183
+ ${C} .pace-now { display: none; }
184
+ ${V} .centre, ${C} .centre { justify-self: start; }
185
+ /* The row stays one row: Centre is with Fit, one press of More away. */
186
+ :host([data-layout="compact"][data-level="basic"]) .centre { display: none; }
187
+ :host([data-layout="compact"][data-level="basic"]) .angle, :host([data-layout="compact"][data-level="basic"]) .zslide, :host([data-layout="compact"][data-level="basic"]) [data-b="fit"] { display: none; }
84
188
  `;
@@ -0,0 +1,26 @@
1
+ /** The zooms a mark goes to, the most wanted first: when two are too close for both labels, the earlier one stays. */
2
+ export declare const COMMON_ZOOMS: readonly number[];
3
+ /** The least distance between two marks, as a share of the slider's length, so their labels never touch. */
4
+ export declare const MARK_GAP = 0.18;
5
+ /** One key press on the slider multiplies or divides the zoom by this: finer than a press of + or -. */
6
+ export declare const SLIDER_STEP = 1.1;
7
+ /** How near a mark a drag lets go to it, in pixels: a gentle pull, never a forced step. */
8
+ export declare const SNAP_PX = 8;
9
+ /** A mark of the slider: its zoom, where it sits from 0 (the least zoom) to 1 (the most), and whether it is an end. */
10
+ export interface ZoomMark {
11
+ zoom: number;
12
+ at: number;
13
+ end: boolean;
14
+ }
15
+ /** Where a zoom sits on the slider, from 0 at `min` to 1 at `max`, held to that range. */
16
+ export declare function zoomPosition(zoom: number, min: number, max: number): number;
17
+ /** The zoom at a place on the slider, 0 to 1. */
18
+ export declare function zoomAtPosition(at: number, min: number, max: number): number;
19
+ /** The slider's marks for the range `min` to `max`, in order: both ends, then the common zooms inside that keep `gap` from every other mark. None when there is no range. */
20
+ export declare function zoomMarks(min: number, max: number, gap?: number): ZoomMark[];
21
+ /** Page Up and Page Down: the next mark above (`direction` 1) or below (-1) the zoom now, else the end. */
22
+ export declare function zoomToMark(zoom: number, marks: readonly ZoomMark[], direction: 1 | -1, min: number, max: number): number;
23
+ /** An arrow key: one `SLIDER_STEP` up or down, held to the range, stopping first on a mark it would pass, so 1.9× goes to 2× and the next press goes on past it. */
24
+ export declare function zoomStep(zoom: number, marks: readonly ZoomMark[], direction: 1 | -1, min: number, max: number, factor?: number): number;
25
+ /** A drag that ends near a mark goes to it: within `tolerance` of the slider's length (`SNAP_PX` over its length in pixels); anywhere else it stays where it is. */
26
+ export declare function zoomSnap(zoom: number, marks: readonly ZoomMark[], min: number, max: number, tolerance: number): number;
@@ -0,0 +1,66 @@
1
+ // The zoom slider's scale: pure, so a server or a test can use it. The slider is logarithmic, so one press of + or - moves it the same distance anywhere
2
+ // along it, and its marks are the ends and the common zooms between them (1×, 2× and so on) that have room for a label.
3
+ /** The zooms a mark goes to, the most wanted first: when two are too close for both labels, the earlier one stays. */
4
+ export const COMMON_ZOOMS = [1, 2, 4, 0.5, 3, 10, 8, 5, 0.25, 6, 16, 20, 32, 50, 64, 100];
5
+ /** The least distance between two marks, as a share of the slider's length, so their labels never touch. */
6
+ export const MARK_GAP = 0.18;
7
+ /** One key press on the slider multiplies or divides the zoom by this: finer than a press of + or -. */
8
+ export const SLIDER_STEP = 1.1;
9
+ /** How near a mark a drag lets go to it, in pixels: a gentle pull, never a forced step. */
10
+ export const SNAP_PX = 8;
11
+ const span = (min, max) => Math.log(max / min);
12
+ /** Where a zoom sits on the slider, from 0 at `min` to 1 at `max`, held to that range. */
13
+ export function zoomPosition(zoom, min, max) {
14
+ const whole = span(min, max);
15
+ if (!(whole > 0) || !(zoom > 0))
16
+ return 0;
17
+ return Math.min(1, Math.max(0, Math.log(zoom / min) / whole));
18
+ }
19
+ /** The zoom at a place on the slider, 0 to 1. */
20
+ export function zoomAtPosition(at, min, max) {
21
+ const place = Math.min(1, Math.max(0, at));
22
+ if (place === 0)
23
+ return min;
24
+ if (place === 1)
25
+ return max;
26
+ return min * Math.exp(place * span(min, max));
27
+ }
28
+ /** The slider's marks for the range `min` to `max`, in order: both ends, then the common zooms inside that keep `gap` from every other mark. None when there is no range. */
29
+ export function zoomMarks(min, max, gap = MARK_GAP) {
30
+ if (!(min > 0) || !(max > min * 1.0001))
31
+ return [];
32
+ const marks = [
33
+ { zoom: min, at: 0, end: true },
34
+ { zoom: max, at: 1, end: true },
35
+ ];
36
+ for (const zoom of COMMON_ZOOMS) {
37
+ if (zoom <= min || zoom >= max)
38
+ continue;
39
+ const at = zoomPosition(zoom, min, max);
40
+ if (marks.every((mark) => Math.abs(mark.at - at) >= gap - 1e-9))
41
+ marks.push({ zoom, at, end: false });
42
+ }
43
+ return marks.sort((a, b) => a.zoom - b.zoom);
44
+ }
45
+ const NEAR = 1e-6;
46
+ /** Page Up and Page Down: the next mark above (`direction` 1) or below (-1) the zoom now, else the end. */
47
+ export function zoomToMark(zoom, marks, direction, min, max) {
48
+ if (direction > 0)
49
+ return marks.find((mark) => mark.zoom > zoom * (1 + NEAR))?.zoom ?? max;
50
+ return [...marks].reverse().find((mark) => mark.zoom < zoom * (1 - NEAR))?.zoom ?? min;
51
+ }
52
+ /** An arrow key: one `SLIDER_STEP` up or down, held to the range, stopping first on a mark it would pass, so 1.9× goes to 2× and the next press goes on past it. */
53
+ export function zoomStep(zoom, marks, direction, min, max, factor = SLIDER_STEP) {
54
+ const next = Math.min(max, Math.max(min, direction > 0 ? zoom * factor : zoom / factor));
55
+ const passed = direction > 0 ? marks.find((mark) => mark.zoom > zoom * (1 + NEAR) && mark.zoom < next * (1 - NEAR)) : [...marks].reverse().find((mark) => mark.zoom < zoom * (1 - NEAR) && mark.zoom > next * (1 + NEAR));
56
+ return passed?.zoom ?? next;
57
+ }
58
+ /** A drag that ends near a mark goes to it: within `tolerance` of the slider's length (`SNAP_PX` over its length in pixels); anywhere else it stays where it is. */
59
+ export function zoomSnap(zoom, marks, min, max, tolerance) {
60
+ const at = zoomPosition(zoom, min, max);
61
+ let best;
62
+ for (const mark of marks)
63
+ if (Math.abs(mark.at - at) <= tolerance && (best === undefined || Math.abs(mark.at - at) < Math.abs(best.at - at)))
64
+ best = mark;
65
+ return best?.zoom ?? zoom;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnmorrisdotca/kazoku-ui",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Kazoku UI 家族, the family's shared interface parts as their own versioned package: view controls for any 3D or zoomable view (angle, tilt, auto-turn, zoom about the pointer, pan, touch, address state) a light, dark and auto theme switch, a language picker, choices kept in the address and a save-as-image button, each a framework-free custom element and a plain controller. Zero dependencies; the shared 3D renderer takes three.js as an optional peer.",
5
5
  "keywords": [
6
6
  "view-controls",
@@ -163,7 +163,10 @@
163
163
  "site": "pnpm build && node scripts/site.mjs",
164
164
  "test:demo": "pnpm site && playwright test",
165
165
  "test:freeze": "pnpm site && playwright test e2e/renderer.demo.mjs --project=chromium-desk",
166
- "screenshots": "pnpm site && node scripts/screenshots.mjs"
166
+ "screenshots": "pnpm site && node scripts/screenshots.mjs",
167
+ "docs:api": "pnpm build && node scripts/docs.mjs api",
168
+ "docs:check": "pnpm build && node scripts/docs.mjs check",
169
+ "test:readme": "pnpm build && node scripts/check-readme-examples.mjs"
167
170
  },
168
171
  "devDependencies": {
169
172
  "@eslint/js": "^9.0.0",