@johnmorrisdotca/kazoku-ui 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.12.0] - 2026-11-03
6
+
7
+ 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.
8
+
9
+ - **`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.
10
+ - **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`.
11
+ - **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`.
12
+ - **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.
13
+ - 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).
14
+ - **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.
15
+
16
+ ## [0.11.0] - 2026-11-03
17
+
18
+ - **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.
19
+ - **A Centre button** (KAZOKU-UI-33). Once the view is zoomed or moved, a Centre button (中央) puts the object back in the middle at Fit and the default angle, with a quarter of a second's move (at once under reduced motion; a hand on the view stops it): `element.centre()`, `controller.centre(animate)`, the `centre` word in both languages, `between()` in the maths. The keys Home and 0 do the same without the move. `object-size` (`objectSize`) holds a pan so that at least a quarter of an object smaller than the view stays in view.
20
+ - **The drag rule**, written down in `docs/drag-rule.md`: the surface under the pointer follows the pointer, the arrow keys agree, Auto-turn goes the object's real way.
21
+ - **`templates/e2e-ground.mjs`**, the shared browser check of the rule (`checkFollowsTheHand`, `checkExactGrab`, and the parts; `picture` names what is photographed and `window` the part of it that is followed, for a thin object such as a rocket), copied unchanged into a package's `e2e/`; a unit test holds its finder. The demo's `grab.html` draws a globe turned with exact grab and is tested with the helper.
22
+
3
23
  ## [0.10.1] - 2026-11-02
4
24
 
5
25
  - **`onContext("restored")`** (KAZOKU-UI-15, found by chikyu-earth). The shared renderer's `onContext` was told `"lost"` and `"unavailable"` but never `"restored"`, although `docs/renderer.md` and the `SharedRendererOptions` type said it was. It is now called when the browser gives the context back, after the focused view draws again. `e2e/renderer.demo.mjs`'s lost-and-restored check reads the events the demo records (`["lost"]`, then `["lost", "restored"]`); it fails on 0.10.0, where the second list is `["lost"]`.
package/README.md CHANGED
@@ -9,7 +9,7 @@ The family's shared interface parts, as their own versioned package: one part pe
9
9
  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
10
  SVG or CSS 3D applies it.
11
11
 
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) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
12
+ [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)
13
13
 
14
14
  ```sh
15
15
  npm install @johnmorrisdotca/kazoku-ui
@@ -44,9 +44,48 @@ Parts (each its own subpath; later ones: picture-tile pickers, Help, GitHub and
44
44
  - Touch: one finger turns at 1x and moves the view when zoomed in; two fingers pinch.
45
45
  - Keys on the view: arrows turn and tip it, `+` and `-` zoom, `0` or Home is Front and Fit.
46
46
  - The choices are kept in the address, with keys you can rename.
47
+ - **Four layouts** (`layout`: classic, horizontal, vertical, compact) and a **zoom slider** with marks at 1×, 2× and the ends: see [Layouts](#layouts).
47
48
  - 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
49
  - **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
50
 
51
+ ### Layouts
52
+
53
+ <picture>
54
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/layouts-dark.webp">
55
+ <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">
56
+ </picture>
57
+
58
+ 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.
59
+
60
+ 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.
61
+
62
+ | `layout` | What it looks like | Basic shows | More adds |
63
+ |---|---|---|---|
64
+ | `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 |
65
+ | `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 |
66
+ | `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 |
67
+ | `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 |
68
+
69
+ ```html
70
+ <!-- Horizontal: under the view -->
71
+ <div id="view" tabindex="0">...your scene...</div>
72
+ <kazoku-view-controls for="view" layout="horizontal"></kazoku-view-controls>
73
+
74
+ <!-- Vertical: beside the view -->
75
+ <div style="display: flex; gap: 12px">
76
+ <div id="view" tabindex="0" style="flex: 1">...your scene...</div>
77
+ <kazoku-view-controls for="view" layout="vertical"></kazoku-view-controls>
78
+ </div>
79
+
80
+ <!-- Compact: one row; with overlay it floats over the bottom corner of a positioned card -->
81
+ <div style="position: relative">
82
+ <div id="view" tabindex="0">...your scene...</div>
83
+ <kazoku-view-controls for="view" layout="compact" overlay></kazoku-view-controls>
84
+ </div>
85
+ ```
86
+
87
+ **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`.
88
+
50
89
  ### In plain HTML
51
90
 
52
91
  ```html
@@ -113,7 +152,7 @@ myZoomIn.onclick = () => view.zoomBy(1.4);
113
152
 
114
153
  The automatic turn is idle whenever nothing can see the view: while the `for` element (else the controls) is scrolled out of sight, and while the tab is hidden. Its frame loop is stopped, so no animation frame is asked for and no `kazoku-viewchange` event with reason `auto` is sent; `state.turning` stays true, and the turn goes on from where it was when the view is back (the first frame back only restarts the clock). A view that opens off screen asks for no frame until it is seen, and an element on no page asks for none. One frame is asked for at a time, and at most one `auto` change is sent a page frame. A page does not need to pause it for these. Without the element, `view.bind(element)` watches the bound element the same way; `view.suspendTurn()` and `view.resumeTurn()` do the same for your own visibility test (they nest, on top of what `bind` does), and `view.play(seconds)` stops the turn by itself after that many seconds of turning.
115
154
 
116
- Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`, `anchor`, `keyTilt`, `stopTurn`, `tiltStep`. `anchor`, `keyTilt`, `stopTurn` and `tiltStep` are also members you can change at any time. The controller also has `ignore` (changeable), `fitScale`, `startZoom` (the `zoom` the view opens at) and `setFitScale(n)`, `setStartZoom(zoom)`; `DEFAULT_IGNORE` is the default selector.
155
+ Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`, `anchor`, `keyTilt`, `stopTurn`, `tiltStep`, `grabRadius`. `anchor`, `keyTilt`, `stopTurn`, `tiltStep` and `grabRadius` are also members you can change at any time. The controller also has `ignore` (changeable), `fitScale`, `startZoom` (the `zoom` the view opens at) and `setFitScale(n)`, `setStartZoom(zoom)`; `DEFAULT_IGNORE` is the default selector.
117
156
 
118
157
  ### Buttons and links inside the view
119
158
 
@@ -131,6 +170,14 @@ controls.anchor = { x: 60, y: -30 }; // pixels from the view's middle when it is
131
170
 
132
171
  Zoom is about that point of the picture, so it stays put on screen however far in the reader is and wherever the view has been moved to: `+` then `-` leaves it where it was within a pixel. Without the element, set `view.anchor` on the controller, or pass `anchor` to `createViewController`; `view.zoomBy(f)` and `view.zoomAt(z)` with no point use it.
133
172
 
173
+ ### Centre
174
+
175
+ Zoom in and drag the object away and a **Centre** button (中央) comes into the bar. It puts the object back in the middle at Fit and the default angle (yaw and tilt 0) with a quarter of a second's move, at once for a reader who prefers reduced motion, and a hand on the view (a press, the wheel, a key) stops the move where it is. `element.centre()` and `controller.centre(animate = true)` do the same from code, ending with a change whose `reason` is `"centre"`. The keys Home and 0 are the same without the move (they send `front` and `fit`). The pan is held so the object cannot be lost: the picture keeps covering the view, or, with `object-size`, a quarter of the object stays in view.
176
+
177
+ ### The drag rule and exact grab
178
+
179
+ In every turnable view the surface under the pointer follows the pointer, the arrow keys agree, and Auto-turn goes the object's real way; `grab-radius` makes a drag keep the touched point under the pointer on a globe. The rule, the option and the shared browser check (`templates/e2e-ground.mjs`) are in [docs/drag-rule.md](docs/drag-rule.md); the demo's `grab.html` shows it.
180
+
134
181
  ### Keys, the wheel and the turn
135
182
 
136
183
  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.
@@ -179,20 +226,23 @@ An `init` event is sent once, a moment after the element connects, with the star
179
226
  | `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. |
180
227
  | `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. |
181
228
  | `tilt-step` | Round the tilt of a drag to a multiple of this many degrees (`1` gives whole degrees). 0 by default: not rounded. |
229
+ | `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 }`. |
230
+ | `grab-radius` | Exact grab, for a globe or any turnable piece: its on-screen radius in CSS pixels at Fit, or `fit` for half the smaller side of the view. A drag then turns it by the angle that keeps the touched point under the pointer (a drag of *d* pixels moves the ground at the middle by *d* pixels), and the radius grows with the zoom. Left out, a drag turns by a fixed angle across the view, as before. See [docs/drag-rule.md](docs/drag-rule.md). |
182
231
  | `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
183
232
  | `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. |
184
233
  | `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
185
234
  | `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. |
235
+ | `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. |
186
236
  | `storage` | The `localStorage` key the level is remembered under (`kazoku-view-controls`). |
187
237
  | `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
188
238
  | `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
189
239
  | `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn&speed=fast`). |
190
240
  | `address-keys` | Other keys: `yaw:a,zoom:z`. |
191
241
  | `theme` | `light` or `dark`; otherwise the page's colour scheme. |
192
- | `overlay` | Sit over the bottom of a positioned parent. |
242
+ | `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. |
193
243
 
194
244
  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.
195
- Colours are custom properties: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-line`, `--kazoku-accent`; the parts are `bar`, `field`, `slider` and `button`.
245
+ 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).
196
246
 
197
247
  ### The address
198
248
 
@@ -285,11 +335,11 @@ The styles are adopted as a constructable stylesheet (`adoptedStyleSheets`), so
285
335
 
286
336
  ## Size
287
337
 
288
- 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.
338
+ 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.
289
339
 
290
340
  ## Checks
291
341
 
292
- `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).
342
+ `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).
293
343
 
294
344
  ## Not yet
295
345
 
@@ -41,6 +41,10 @@ export interface ViewControllerOptions {
41
41
  stopTurn?: boolean;
42
42
  /** Round the tilt of a drag to a multiple of this many degrees; 0 (the default) leaves it unrounded, 1 gives whole degrees. */
43
43
  tiltStep?: number;
44
+ /** Exact grab: the on-screen radius, in CSS pixels at Fit, of the globe or turnable piece, or `"fit"` for half the smaller side of the view. A drag then turns it by the angle that keeps the touched point under the pointer (`degreesPerPixel`), and at a zoom the radius grows with it. 0 (the default) keeps the fixed angle across the view. */
45
+ grabRadius?: number | "fit";
46
+ /** 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 it in view (see `clampPan`). Left out, the object is the picture, which covers the view. */
47
+ objectSize?: Size;
44
48
  /** The view's size in CSS pixels, for holding the pan; `bind` gives it from the element when this is left out. */
45
49
  size?: () => Size | undefined;
46
50
  onChange?: (state: ViewState, info: ChangeInfo) => void;
@@ -59,6 +63,10 @@ export interface ViewController {
59
63
  keyTilt: KeyTilt;
60
64
  stopTurn: boolean;
61
65
  tiltStep: number;
66
+ /** Exact grab (see `ViewControllerOptions.grabRadius`). Can be changed at any time. */
67
+ grabRadius: number | "fit";
68
+ /** The object's size at Fit (see `ViewControllerOptions.objectSize`). Can be changed at any time. */
69
+ objectSize: Size | undefined;
62
70
  /** What zoom 1 means to the page (see `ViewControllerOptions.fitScale`). */
63
71
  readonly fitScale: number;
64
72
  /** The `state.zoom` the view opens at: 1 for "fit", else the starting scale divided by `fitScale`. The address leaves a zoom at this value out. */
@@ -81,6 +89,8 @@ export interface ViewController {
81
89
  releaseTurn(): void;
82
90
  front(): void;
83
91
  fit(): void;
92
+ /** Put the object back in the middle at Fit and the default angle (yaw and tilt 0). It moves there over a quarter of a second, and at once for `animate` false or for a reader who prefers reduced motion; any hand on the view stops the move where it is. Ends with a change `{ final: true, reason: "centre" }`. */
93
+ centre(animate?: boolean): void;
84
94
  /** Zoom to `next` about the point `at` (client pixels); left out, about the `anchor`, else the view's middle. */
85
95
  zoomAt(next: number, at?: Point, final?: boolean): void;
86
96
  zoomBy(factor: number, at?: Point): void;
@@ -1,7 +1,9 @@
1
1
  import { turnOnlyInSight } from "./sight.js";
2
- import { 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, 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
+ /** How long a move to the middle takes. */
6
+ const CENTRE_MS = 250;
5
7
  const HANDLED_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "_", "0", "Home"];
6
8
  const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined);
7
9
  const browserClock = () => ({ request: (callback) => globalThis.requestAnimationFrame(callback), cancel: (id) => globalThis.cancelAnimationFrame(id) });
@@ -17,6 +19,8 @@ export function createViewController(options = {}) {
17
19
  let limit = 0;
18
20
  let turned = 0;
19
21
  let destroyed = false;
22
+ let centring = 0;
23
+ let stepping = false;
20
24
  let fitScale = positive(options.fitScale) ?? 1;
21
25
  let start = positive(options.zoom) ?? "fit";
22
26
  let atStart = true;
@@ -39,10 +43,18 @@ export function createViewController(options = {}) {
39
43
  bound.style.touchAction = state.zoom > ZOOMED ? "none" : "pan-y";
40
44
  options.onChange?.({ ...state }, { final, reason });
41
45
  };
46
+ const stopCentre = () => {
47
+ if (centring === 0)
48
+ return;
49
+ clock.cancel(centring);
50
+ centring = 0;
51
+ };
42
52
  const camera = () => ({ yaw: state.yaw, tilt: state.tilt, zoom: state.zoom, panX: state.panX, panY: state.panY });
43
53
  /** Take a camera in, held to the limits and, once the view's size is known, to the room the zoom gives. */
44
54
  const take = (next) => {
45
- const held = clampPan({ ...next, zoom: clamp(next.zoom, limits.zoomMin, limits.zoomMax), tilt: clamp(next.tilt, -limits.tiltMax, limits.tiltMax) }, size());
55
+ if (!stepping)
56
+ stopCentre();
57
+ const held = clampPan({ ...next, zoom: clamp(next.zoom, limits.zoomMin, limits.zoomMax), tilt: clamp(next.tilt, -limits.tiltMax, limits.tiltMax) }, size(), controller.objectSize);
46
58
  state.yaw = controller.turnable ? wrapYaw(held.yaw) : state.yaw;
47
59
  state.tilt = controller.turnable ? held.tilt : state.tilt;
48
60
  if (held.zoom !== state.zoom)
@@ -70,7 +82,7 @@ export function createViewController(options = {}) {
70
82
  if (!state.turning || suspends > 0)
71
83
  return;
72
84
  frame = clock.request(tick);
73
- if (holds > 0) {
85
+ if (holds > 0 || centring !== 0) {
74
86
  last = 0;
75
87
  return;
76
88
  }
@@ -94,6 +106,8 @@ export function createViewController(options = {}) {
94
106
  keyTilt: options.keyTilt ?? "drag",
95
107
  stopTurn: options.stopTurn ?? false,
96
108
  tiltStep: options.tiltStep ?? 0,
109
+ grabRadius: options.grabRadius ?? 0,
110
+ objectSize: options.objectSize,
97
111
  get fitScale() {
98
112
  return fitScale;
99
113
  },
@@ -186,6 +200,35 @@ export function createViewController(options = {}) {
186
200
  take({ ...camera(), zoom: 1, panX: 0, panY: 0 });
187
201
  emit(true, "fit");
188
202
  },
203
+ centre(animate = true) {
204
+ stopCentre();
205
+ const from = camera();
206
+ const to = { yaw: 0, tilt: 0, zoom: 1, panX: 0, panY: 0 };
207
+ const reduced = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
208
+ const same = from.yaw === 0 && from.tilt === 0 && from.zoom === 1 && from.panX === 0 && from.panY === 0;
209
+ if (!animate || reduced || same || destroyed) {
210
+ take(to);
211
+ emit(true, "centre");
212
+ return;
213
+ }
214
+ let began = -1;
215
+ const step = (now) => {
216
+ if (began < 0)
217
+ began = now;
218
+ const fraction = (now - began) / CENTRE_MS;
219
+ stepping = true;
220
+ take(between(from, to, fraction));
221
+ stepping = false;
222
+ if (fraction >= 1) {
223
+ centring = 0;
224
+ emit(true, "centre");
225
+ return;
226
+ }
227
+ emit(false, "centre");
228
+ centring = clock.request(step);
229
+ };
230
+ centring = clock.request(step);
231
+ },
189
232
  zoomAt(next, at, final = true) {
190
233
  take(zoomAbout(camera(), next, fromMiddle(at), limits));
191
234
  emit(final, "zoom");
@@ -209,6 +252,7 @@ export function createViewController(options = {}) {
209
252
  },
210
253
  destroy() {
211
254
  destroyed = true;
255
+ stopCentre();
212
256
  clock.cancel(frame);
213
257
  state.turning = false;
214
258
  unbind?.();
@@ -233,6 +277,7 @@ export function createViewController(options = {}) {
233
277
  };
234
278
  const canTurn = () => controller.turnable;
235
279
  const grab = () => {
280
+ stopCentre();
236
281
  if (controller.stopTurn)
237
282
  controller.pause();
238
283
  };
@@ -311,7 +356,8 @@ export function createViewController(options = {}) {
311
356
  return;
312
357
  const r = view.getBoundingClientRect();
313
358
  // Up and down tips it for a mouse; on a finger the browser keeps the vertical drag to scroll the page, and the tilt slider tips it.
314
- take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits, controller.tiltStep) });
359
+ const radius = controller.grabRadius === "fit" ? Math.min(r.width, r.height) / 2 : controller.grabRadius;
360
+ take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits, controller.tiltStep, radius * gesture.camera.zoom) });
315
361
  emit(false, "drag");
316
362
  }
317
363
  };
@@ -14,7 +14,8 @@ export interface ViewChangeDetail extends ViewState {
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
15
  * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
16
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
- * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
17
+ * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
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
  */
20
21
  export declare class ViewControlsElement extends HTMLElement {
@@ -33,6 +34,8 @@ export declare class ViewControlsElement extends HTMLElement {
33
34
  play(): void;
34
35
  pause(): void;
35
36
  front(): void;
37
+ /** Put the object back in the middle at Fit and the default angle, with a brief move (at once for a reader who prefers reduced motion, or with `false`). */
38
+ centre(animate?: boolean): void;
36
39
  fit(): void;
37
40
  set(next: Partial<ViewState>): void;
38
41
  /** The point the zoom buttons and keys zoom about: `{ x, y }` in CSS pixels from the view's middle when it is at Fit, moving with the picture. `undefined` zooms about the view's middle. The `anchor` attribute (`"x,y"`) sets the same. */
@@ -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 = {
@@ -22,8 +23,12 @@ const ICON = {
22
23
  right: icon("M9 5l9 7-9 7z"),
23
24
  up: icon("M5 15l7-9 7 9z"),
24
25
  down: icon("M5 9l7 9 7-9z"),
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"),
25
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"),
26
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;
27
32
  const HTML = `
28
33
  <div class="bar" part="bar" role="group">
29
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>
@@ -36,7 +41,9 @@ const HTML = `
36
41
  <button type="button" data-b="fast" aria-pressed="false" part="button"><span data-w="fast"></span></button>
37
42
  </div>
38
43
  <button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
44
+ <button type="button" class="centre" data-b="centre" part="button" hidden>${ICON.centre}<span class="text" data-w="centre"></span></button>
39
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>
40
47
  <button type="button" data-b="out" part="button">${ICON.minus}</button>
41
48
  <button type="button" class="level" data-b="level" part="button"><span data-o="zoom">1×</span></button>
42
49
  <button type="button" data-b="in" part="button">${ICON.plus}</button>
@@ -61,11 +68,12 @@ const HTML = `
61
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),
62
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),
63
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),
64
- * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
71
+ * `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
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
65
73
  * or dark), `overlay` (sit over the bottom of a positioned parent).
66
74
  */
67
75
  export class ViewControlsElement extends HTMLElement {
68
- static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "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"];
69
77
  #root;
70
78
  #controller;
71
79
  #view;
@@ -78,6 +86,11 @@ export class ViewControlsElement extends HTMLElement {
78
86
  #unwatch;
79
87
  #offPage = false;
80
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;
81
94
  #pace = "medium";
82
95
  #labels = wordsFor("en");
83
96
  #said = "";
@@ -115,6 +128,10 @@ export class ViewControlsElement extends HTMLElement {
115
128
  front() {
116
129
  this.#ensure().front();
117
130
  }
131
+ /** Put the object back in the middle at Fit and the default angle, with a brief move (at once for a reader who prefers reduced motion, or with `false`). */
132
+ centre(animate = true) {
133
+ this.#ensure().centre(animate);
134
+ }
118
135
  fit() {
119
136
  this.#ensure().fit();
120
137
  }
@@ -143,6 +160,7 @@ export class ViewControlsElement extends HTMLElement {
143
160
  if (this.#root.querySelector(".bar") === null)
144
161
  this.#build();
145
162
  this.#keys = parseKeys(this.getAttribute("address-keys"));
163
+ this.#arrange();
146
164
  this.#limits();
147
165
  const start = {};
148
166
  for (const key of ["yaw", "tilt"]) {
@@ -157,7 +175,7 @@ export class ViewControlsElement extends HTMLElement {
157
175
  this.#pace = pace ?? "medium";
158
176
  this.#speed();
159
177
  c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
160
- for (const name of ["key-tilt", "stop-turn", "tilt-step"])
178
+ for (const name of ["key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size"])
161
179
  this.#option(name);
162
180
  if (this.hasAttribute("anchor"))
163
181
  this.#option("anchor");
@@ -216,10 +234,14 @@ export class ViewControlsElement extends HTMLElement {
216
234
  c.setFitScale(Number(value));
217
235
  else if (name === "ignore")
218
236
  c.ignore = value ?? DEFAULT_IGNORE;
219
- else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step")
237
+ else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step" || name === "grab-radius" || name === "object-size")
220
238
  this.#option(name);
221
239
  else if (name === "speed")
222
240
  this.#speed();
241
+ else if (name === "layout") {
242
+ this.#arrange();
243
+ this.#show();
244
+ }
223
245
  else if (name === "controls") {
224
246
  this.#level = parseLevel(value) ?? "basic";
225
247
  this.#show();
@@ -249,6 +271,12 @@ export class ViewControlsElement extends HTMLElement {
249
271
  c.stopTurn = value !== null;
250
272
  else if (name === "tilt-step")
251
273
  c.tiltStep = Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : 0;
274
+ else if (name === "object-size") {
275
+ const [w, h] = (value ?? "").trim().split(/[\s,x]+/).map(Number);
276
+ c.objectSize = w !== undefined && w > 0 && Number.isFinite(w) ? { width: w, height: h !== undefined && h > 0 && Number.isFinite(h) ? h : w } : undefined;
277
+ }
278
+ else if (name === "grab-radius")
279
+ c.grabRadius = value?.trim() === "fit" ? "fit" : Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : 0;
252
280
  }
253
281
  #startZoom() {
254
282
  const value = Number(this.getAttribute("zoom"));
@@ -301,12 +329,14 @@ export class ViewControlsElement extends HTMLElement {
301
329
  };
302
330
  slider("yaw");
303
331
  slider("tilt");
332
+ this.#zoomSlider(q("#zoom"), c);
304
333
  const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
305
334
  on("play", () => c().toggle());
306
335
  on("front", () => c().front());
307
336
  on("out", () => c().zoomBy(1 / ZOOM_STEP));
308
337
  on("in", () => c().zoomBy(ZOOM_STEP));
309
338
  on("fit", () => c().fit());
339
+ on("centre", () => c().centre());
310
340
  on("level", () => c().fit());
311
341
  on("more", () => {
312
342
  this.#level = this.#level === "basic" ? "advanced" : "basic";
@@ -330,6 +360,76 @@ export class ViewControlsElement extends HTMLElement {
330
360
  on("move", () => c().setDrag("move"));
331
361
  on("turn", () => c().setDrag("turn"));
332
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
+ }
333
433
  #storageKey() {
334
434
  return this.getAttribute("storage") || LEVEL_STORAGE_KEY;
335
435
  }
@@ -359,6 +459,7 @@ export class ViewControlsElement extends HTMLElement {
359
459
  tilt.min = String(-c.limits.tiltMax);
360
460
  tilt.max = String(c.limits.tiltMax);
361
461
  }
462
+ this.#drawMarks();
362
463
  }
363
464
  #resolveView() {
364
465
  const id = this.getAttribute("for");
@@ -421,6 +522,8 @@ export class ViewControlsElement extends HTMLElement {
421
522
  q('[data-o="yaw"]').value = `${Math.round(s.yaw)}°`;
422
523
  q('[data-o="tilt"]').value = `${Math.round(s.tilt)}°`;
423
524
  q('[data-b="play"]').setAttribute("aria-pressed", String(s.turning));
525
+ // Centre comes in once the view has been zoomed or moved, which is when the object can be lost; it puts it back in the middle at Fit and the default angle.
526
+ q(".centre").hidden = s.zoom === 1 && s.panX === 0 && s.panY === 0;
424
527
  q('[data-b="out"]').disabled = s.zoom <= c.limits.zoomMin + 1e-6;
425
528
  q('[data-b="in"]').disabled = s.zoom >= c.limits.zoomMax - 1e-6;
426
529
  const advanced = this.#level === "advanced";
@@ -436,6 +539,18 @@ export class ViewControlsElement extends HTMLElement {
436
539
  node.setAttribute("aria-label", label);
437
540
  };
438
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
+ }
439
554
  say(q('[data-b="level"]'), `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
440
555
  this.#sayZoom(`${words.zoomLevel} ${zoom}`);
441
556
  for (const pace of PACES)
@@ -459,6 +574,8 @@ export class ViewControlsElement extends HTMLElement {
459
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.
460
575
  q(".pace-now").hidden = flat || advanced || !s.turning;
461
576
  this.toggleAttribute("data-turning", s.turning);
577
+ if (this.hasAttribute("data-flat") !== flat)
578
+ this.toggleAttribute("data-flat", flat);
462
579
  }
463
580
  /** Tells a screen reader the zoom once it has settled for a moment (a pinch or a wheel makes many steps). */
464
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 { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout, type Camera, type Limits, type PinchStart, type Point, type Size } from "./math.ts";
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";
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 { clamp, clampPan, DEFAULT_LIMITS, 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, 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";
@@ -34,8 +34,11 @@ export declare const clamp: (value: number, low: number, high: number) => number
34
34
  export declare const wrapYaw: (angle: number) => number;
35
35
  /** Zoom to `next` (held between the limits) keeping the point `at` (pixels from the view's middle) where it is. */
36
36
  export declare function zoomAbout(camera: Camera, next: number, at: Point, limits?: Limits): Camera;
37
- /** Hold the pan so the picture still covers the view: at zoom z the picture may move (z - 1) / 2 of the view's size either way. */
38
- export declare function clampPan(camera: Camera, size: Size | undefined): Camera;
37
+ /**
38
+ * Hold the pan so the object cannot be lost. By default the object is the picture, which fills the view at Fit: at zoom z it may move (z - 1) / 2 of the view's size either way, so the picture still covers the view.
39
+ * A page whose object is smaller than the view gives its `object` (its size at Fit, in CSS pixels): it may then move until a quarter of it is still in view, in a view of any size, so it can never be dragged out.
40
+ */
41
+ export declare function clampPan(camera: Camera, size: Size | undefined, object?: Size): Camera;
39
42
  /** What a pinch starts from: the distance between two fingers, their middle, and the camera at that moment. */
40
43
  export interface PinchStart {
41
44
  distance: number;
@@ -44,7 +47,14 @@ export interface PinchStart {
44
47
  }
45
48
  /** The camera for two fingers now at `a` and `b`: zoom by the change of distance about where the pinch began, and the middle of the fingers also drags the view. `centre` is the view's middle in client pixels. */
46
49
  export declare function pinchTo(start: PinchStart, a: Point, b: Point, centre: Point, limits?: Limits): Camera;
47
- /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it. */
48
- export declare function dragTurn(start: Camera, dx: number, dy: number, size: Size, tips: boolean, limits?: Limits, step?: number): Pick<Camera, "yaw" | "tilt">;
50
+ /** Degrees of turn for one pixel of drag when the piece's on-screen radius is `radius` pixels: the pixel that a point at the middle of a sphere's face moves by, so the touched point stays under the pointer. */
51
+ export declare const degreesPerPixel: (radius: number) => number;
52
+ /**
53
+ * A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it.
54
+ * A `grabRadius` above 0 is the on-screen radius, in pixels, of a globe or any turnable piece: both axes then turn by the angle that carries a point at the middle of its face by the pixels dragged (`degreesPerPixel`), instead of a fixed angle across the view.
55
+ */
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
+ /** The camera `fraction` of the way (0 to 1) from `from` to `to`, eased out: quick at first, slow at the end. */
58
+ export declare function between(from: Camera, to: Camera, fraction: number): Camera;
49
59
  /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
50
60
  export declare const wheelZoomFactor: (deltaY: number, deltaMode: number) => number;
@@ -19,14 +19,17 @@ export function zoomAbout(camera, next, at, limits = DEFAULT_LIMITS) {
19
19
  const atOne = zoom <= 1;
20
20
  return { ...camera, zoom, panX: atOne ? 0 : at.x - (at.x - camera.panX) * k, panY: atOne ? 0 : at.y - (at.y - camera.panY) * k };
21
21
  }
22
- /** Hold the pan so the picture still covers the view: at zoom z the picture may move (z - 1) / 2 of the view's size either way. */
23
- export function clampPan(camera, size) {
22
+ /**
23
+ * Hold the pan so the object cannot be lost. By default the object is the picture, which fills the view at Fit: at zoom z it may move (z - 1) / 2 of the view's size either way, so the picture still covers the view.
24
+ * A page whose object is smaller than the view gives its `object` (its size at Fit, in CSS pixels): it may then move until a quarter of it is still in view, in a view of any size, so it can never be dragged out.
25
+ */
26
+ export function clampPan(camera, size, object) {
24
27
  if (camera.zoom <= 1)
25
28
  return { ...camera, panX: 0, panY: 0 };
26
29
  if (size === undefined)
27
30
  return camera;
28
- const roomX = (size.width * (camera.zoom - 1)) / 2;
29
- const roomY = (size.height * (camera.zoom - 1)) / 2;
31
+ const roomX = object === undefined ? (size.width * (camera.zoom - 1)) / 2 : size.width / 2 + (object.width * camera.zoom) / 4;
32
+ const roomY = object === undefined ? (size.height * (camera.zoom - 1)) / 2 : size.height / 2 + (object.height * camera.zoom) / 4;
30
33
  return { ...camera, panX: clamp(camera.panX, -roomX, roomX), panY: clamp(camera.panY, -roomY, roomY) };
31
34
  }
32
35
  /** The camera for two fingers now at `a` and `b`: zoom by the change of distance about where the pinch began, and the middle of the fingers also drags the view. `centre` is the view's middle in client pixels. */
@@ -38,12 +41,24 @@ export function pinchTo(start, a, b, centre, limits = DEFAULT_LIMITS) {
38
41
  return zoomed;
39
42
  return { ...zoomed, panX: zoomed.panX + (middle.x - start.middle.x), panY: zoomed.panY + (middle.y - start.middle.y) };
40
43
  }
41
- /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it. */
42
- export function dragTurn(start, dx, dy, size, tips, limits = DEFAULT_LIMITS, step = 0) {
43
- const yaw = wrapYaw(start.yaw + (dx / (size.width || 360)) * TURN_ACROSS);
44
- const raw = start.tilt - (dy / (size.height || 360)) * TILT_ACROSS;
44
+ /** Degrees of turn for one pixel of drag when the piece's on-screen radius is `radius` pixels: the pixel that a point at the middle of a sphere's face moves by, so the touched point stays under the pointer. */
45
+ export const degreesPerPixel = (radius) => (180 / Math.PI) / radius;
46
+ /**
47
+ * A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it.
48
+ * A `grabRadius` above 0 is the on-screen radius, in pixels, of a globe or any turnable piece: both axes then turn by the angle that carries a point at the middle of its face by the pixels dragged (`degreesPerPixel`), instead of a fixed angle across the view.
49
+ */
50
+ export function dragTurn(start, dx, dy, size, tips, limits = DEFAULT_LIMITS, step = 0, grabRadius = 0) {
51
+ const exact = grabRadius > 0 && Number.isFinite(grabRadius) ? degreesPerPixel(grabRadius) : 0;
52
+ const yaw = wrapYaw(start.yaw + (exact > 0 ? dx * exact : (dx / (size.width || 360)) * TURN_ACROSS));
53
+ const raw = start.tilt - (exact > 0 ? dy * exact : (dy / (size.height || 360)) * TILT_ACROSS);
45
54
  const tilt = tips ? clamp(step > 0 ? Math.round(raw / step) * step : raw, -limits.tiltMax, limits.tiltMax) : start.tilt;
46
55
  return { yaw, tilt };
47
56
  }
57
+ /** The camera `fraction` of the way (0 to 1) from `from` to `to`, eased out: quick at first, slow at the end. */
58
+ export function between(from, to, fraction) {
59
+ const t = 1 - (1 - clamp(fraction, 0, 1)) ** 3;
60
+ const mix = (a, b) => a + (b - a) * t;
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
+ }
48
63
  /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
49
64
  export const wheelZoomFactor = (deltaY, deltaMode) => Math.exp(-deltaY * (deltaMode === 1 ? 0.05 : 0.01));
@@ -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
  `;
@@ -10,6 +10,7 @@ export interface ViewWords {
10
10
  zoomTwo: string;
11
11
  zoomIn: string;
12
12
  fit: string;
13
+ centre: string;
13
14
  pan: string;
14
15
  panLeft: string;
15
16
  panRight: string;
@@ -11,6 +11,7 @@ export const WORDS = {
11
11
  zoomTwo: "Zoom to 2 times",
12
12
  zoomIn: "Zoom in",
13
13
  fit: "Fit",
14
+ centre: "Centre",
14
15
  pan: "Move the view",
15
16
  panLeft: "Move left",
16
17
  panRight: "Move right",
@@ -38,6 +39,7 @@ export const WORDS = {
38
39
  zoomTwo: "2倍にする",
39
40
  zoomIn: "拡大",
40
41
  fit: "全体",
42
+ centre: "中央",
41
43
  pan: "表示を動かす",
42
44
  panLeft: "左へ",
43
45
  panRight: "右へ",
@@ -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.10.1",
3
+ "version": "0.12.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",