@johnmorrisdotca/kazoku-ui 0.11.0 → 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 +11 -0
- package/README.md +45 -5
- package/dist/view-controls/element.d.ts +1 -0
- package/dist/view-controls/element.js +104 -2
- package/dist/view-controls/index.d.ts +2 -1
- package/dist/view-controls/index.js +2 -1
- package/dist/view-controls/levels.d.ts +9 -0
- package/dist/view-controls/levels.js +3 -0
- package/dist/view-controls/styles.d.ts +1 -1
- package/dist/view-controls/styles.js +121 -17
- package/dist/view-controls/zoomScale.d.ts +26 -0
- package/dist/view-controls/zoomScale.js +66 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
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
|
+
|
|
5
16
|
## [0.11.0] - 2026-11-03
|
|
6
17
|
|
|
7
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.
|
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) · [The drag rule](docs/drag-rule.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
|
|
@@ -193,16 +232,17 @@ An `init` event is sent once, a moment after the element connects, with the star
|
|
|
193
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. |
|
|
194
233
|
| `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
|
|
195
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. |
|
|
196
236
|
| `storage` | The `localStorage` key the level is remembered under (`kazoku-view-controls`). |
|
|
197
237
|
| `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
|
|
198
238
|
| `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
|
|
199
239
|
| `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn&speed=fast`). |
|
|
200
240
|
| `address-keys` | Other keys: `yaw:a,zoom:z`. |
|
|
201
241
|
| `theme` | `light` or `dark`; otherwise the page's colour scheme. |
|
|
202
|
-
| `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. |
|
|
203
243
|
|
|
204
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.
|
|
205
|
-
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).
|
|
206
246
|
|
|
207
247
|
### The address
|
|
208
248
|
|
|
@@ -295,11 +335,11 @@ The styles are adopted as a constructable stylesheet (`adoptedStyleSheets`), so
|
|
|
295
335
|
|
|
296
336
|
## Size
|
|
297
337
|
|
|
298
|
-
Gzipped, unbundled. View controls: the element and its definition about
|
|
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.
|
|
299
339
|
|
|
300
340
|
## Checks
|
|
301
341
|
|
|
302
|
-
`pnpm check` (lint, types,
|
|
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).
|
|
303
343
|
|
|
304
344
|
## Not yet
|
|
305
345
|
|
|
@@ -14,6 +14,7 @@ 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
|
+
* `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
|
|
17
18
|
* `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `object-size` (`w,h`: the object's size at Fit in pixels when it is smaller than the view, so a pan keeps a quarter of it in view), `grab-radius` (exact grab: the on-screen radius of the piece in pixels, or `fit`; a drag then keeps the touched point under the pointer), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
|
|
18
19
|
* or dark), `overlay` (sit over the bottom of a positioned parent).
|
|
19
20
|
*/
|
|
@@ -6,11 +6,12 @@ import { localStore, remember } from "../shared/store.js";
|
|
|
6
6
|
import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
|
|
7
7
|
import { createViewController, DEFAULT_IGNORE } from "./controller.js";
|
|
8
8
|
import { EVENT, TAG } from "./index.js";
|
|
9
|
-
import { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLevel, zoomText } from "./levels.js";
|
|
9
|
+
import { LEVEL_STORAGE_KEY, PACE_FACTOR, PACES, parseLayout, parseLevel, zoomText } from "./levels.js";
|
|
10
10
|
import { ZOOM_STEP, ZOOMED } from "./math.js";
|
|
11
11
|
import { turnOnlyInSight } from "./sight.js";
|
|
12
12
|
import { STYLES } from "./styles.js";
|
|
13
13
|
import { languageOf, wordsFor } from "./words.js";
|
|
14
|
+
import { SNAP_PX, zoomAtPosition, zoomMarks, zoomPosition, zoomSnap, zoomStep, zoomToMark } from "./zoomScale.js";
|
|
14
15
|
export { EVENT, TAG };
|
|
15
16
|
const icon = (path, cls = "") => `<svg viewBox="0 0 24 24" aria-hidden="true" class="${cls}"><path d="${path}"/></svg>`;
|
|
16
17
|
const ICON = {
|
|
@@ -25,6 +26,9 @@ const ICON = {
|
|
|
25
26
|
centre: icon("M11 2h2v5h-2zM11 17h2v5h-2zM2 11h5v2H2zM17 11h5v2h-5zM12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 1 0 0-7z"),
|
|
26
27
|
more: icon("M3 7h18v2H3zM3 15h18v2H3zM8 4a3 3 0 1 0 0 6 3 3 0 1 0 0-6zM16 12a3 3 0 1 0 0 6 3 3 0 1 0 0-6z"),
|
|
27
28
|
};
|
|
29
|
+
/** The zoom slider's steps from end to end, and the width of its thumb in pixels (the styles draw it 28 wide). */
|
|
30
|
+
const SLIDER_SPAN = 1000;
|
|
31
|
+
const THUMB = 28;
|
|
28
32
|
const HTML = `
|
|
29
33
|
<div class="bar" part="bar" role="group">
|
|
30
34
|
<div class="field angle" part="field angle"><label data-w="angle" for="yaw"></label><input id="yaw" type="range" min="-180" max="180" step="1" value="0" part="slider" /><output for="yaw" data-o="yaw">0°</output></div>
|
|
@@ -39,6 +43,7 @@ const HTML = `
|
|
|
39
43
|
<button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
|
|
40
44
|
<button type="button" class="centre" data-b="centre" part="button" hidden>${ICON.centre}<span class="text" data-w="centre"></span></button>
|
|
41
45
|
<div class="group zoom" role="group" data-g="zoom">
|
|
46
|
+
<div class="field zslide" part="field zoom-slider"><label data-w="zoom" for="zoom"></label><div class="zbox"><div class="ztrack" aria-hidden="true"><div class="zfill"></div></div><div class="zmarks" aria-hidden="true"></div><input id="zoom" type="range" min="0" max="1000" step="1" value="0" part="slider zoom-slider" /></div></div>
|
|
42
47
|
<button type="button" data-b="out" part="button">${ICON.minus}</button>
|
|
43
48
|
<button type="button" class="level" data-b="level" part="button"><span data-o="zoom">1×</span></button>
|
|
44
49
|
<button type="button" data-b="in" part="button">${ICON.plus}</button>
|
|
@@ -63,11 +68,12 @@ const HTML = `
|
|
|
63
68
|
* buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
|
|
64
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),
|
|
65
70
|
* `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
|
|
71
|
+
* `layout` (`classic`, the default, `horizontal`, `vertical` or `compact`: where the controls sit; the horizontal, vertical and compact layouts have the zoom slider),
|
|
66
72
|
* `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `object-size` (`w,h`: the object's size at Fit in pixels when it is smaller than the view, so a pan keeps a quarter of it in view), `grab-radius` (exact grab: the on-screen radius of the piece in pixels, or `fit`; a drag then keeps the touched point under the pointer), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
|
|
67
73
|
* or dark), `overlay` (sit over the bottom of a positioned parent).
|
|
68
74
|
*/
|
|
69
75
|
export class ViewControlsElement extends HTMLElement {
|
|
70
|
-
static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "controls", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
|
|
76
|
+
static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size", "speed", "controls", "layout", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
|
|
71
77
|
#root;
|
|
72
78
|
#controller;
|
|
73
79
|
#view;
|
|
@@ -80,6 +86,11 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
80
86
|
#unwatch;
|
|
81
87
|
#offPage = false;
|
|
82
88
|
#level = "basic";
|
|
89
|
+
#layout = "classic";
|
|
90
|
+
#marks = [];
|
|
91
|
+
#marksFor = "";
|
|
92
|
+
/** The zoom the slider shows now; NaN until it has shown one, and again when the marks are redrawn for new limits. */
|
|
93
|
+
#zoomShown = Number.NaN;
|
|
83
94
|
#pace = "medium";
|
|
84
95
|
#labels = wordsFor("en");
|
|
85
96
|
#said = "";
|
|
@@ -149,6 +160,7 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
149
160
|
if (this.#root.querySelector(".bar") === null)
|
|
150
161
|
this.#build();
|
|
151
162
|
this.#keys = parseKeys(this.getAttribute("address-keys"));
|
|
163
|
+
this.#arrange();
|
|
152
164
|
this.#limits();
|
|
153
165
|
const start = {};
|
|
154
166
|
for (const key of ["yaw", "tilt"]) {
|
|
@@ -226,6 +238,10 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
226
238
|
this.#option(name);
|
|
227
239
|
else if (name === "speed")
|
|
228
240
|
this.#speed();
|
|
241
|
+
else if (name === "layout") {
|
|
242
|
+
this.#arrange();
|
|
243
|
+
this.#show();
|
|
244
|
+
}
|
|
229
245
|
else if (name === "controls") {
|
|
230
246
|
this.#level = parseLevel(value) ?? "basic";
|
|
231
247
|
this.#show();
|
|
@@ -313,6 +329,7 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
313
329
|
};
|
|
314
330
|
slider("yaw");
|
|
315
331
|
slider("tilt");
|
|
332
|
+
this.#zoomSlider(q("#zoom"), c);
|
|
316
333
|
const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
|
|
317
334
|
on("play", () => c().toggle());
|
|
318
335
|
on("front", () => c().front());
|
|
@@ -343,6 +360,76 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
343
360
|
on("move", () => c().setDrag("move"));
|
|
344
361
|
on("turn", () => c().setDrag("turn"));
|
|
345
362
|
}
|
|
363
|
+
/** The zoom slider: logarithmic, so a press of + or - moves it the same way anywhere; a drag that ends near a mark goes to it, and the keys step, jump between marks and go to the ends. */
|
|
364
|
+
#zoomSlider(input, c) {
|
|
365
|
+
const go = (final) => {
|
|
366
|
+
const { zoomMin: min, zoomMax: max } = c().limits;
|
|
367
|
+
const length = input.getBoundingClientRect().width - THUMB;
|
|
368
|
+
const zoom = zoomAtPosition(Number(input.value) / SLIDER_SPAN, min, max);
|
|
369
|
+
c().zoomAt(zoomSnap(zoom, this.#marks, min, max, length > 0 ? SNAP_PX / length : 0), undefined, final);
|
|
370
|
+
};
|
|
371
|
+
input.addEventListener("input", () => go(false));
|
|
372
|
+
input.addEventListener("change", () => go(true));
|
|
373
|
+
input.addEventListener("keydown", (event) => {
|
|
374
|
+
const { zoomMin: min, zoomMax: max } = c().limits;
|
|
375
|
+
const zoom = c().state.zoom;
|
|
376
|
+
const up = event.key === "ArrowRight" || event.key === "ArrowUp";
|
|
377
|
+
const down = event.key === "ArrowLeft" || event.key === "ArrowDown";
|
|
378
|
+
let next;
|
|
379
|
+
if (up || down)
|
|
380
|
+
next = zoomStep(zoom, this.#marks, up ? 1 : -1, min, max);
|
|
381
|
+
else if (event.key === "PageUp" || event.key === "PageDown")
|
|
382
|
+
next = zoomToMark(zoom, this.#marks, event.key === "PageUp" ? 1 : -1, min, max);
|
|
383
|
+
else if (event.key === "Home")
|
|
384
|
+
next = min;
|
|
385
|
+
else if (event.key === "End")
|
|
386
|
+
next = max;
|
|
387
|
+
if (next === undefined)
|
|
388
|
+
return;
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
c().zoomAt(next);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/** The layout from the attribute, on the host as `data-layout` for the styles. */
|
|
394
|
+
#arrange() {
|
|
395
|
+
this.#layout = parseLayout(this.getAttribute("layout"));
|
|
396
|
+
if (this.dataset.layout !== this.#layout)
|
|
397
|
+
this.dataset.layout = this.#layout;
|
|
398
|
+
}
|
|
399
|
+
/** The slider's marks for the limits now: ticks on the track and their labels under it, made again only when the limits change. */
|
|
400
|
+
#drawMarks() {
|
|
401
|
+
const { zoomMin: min, zoomMax: max } = this.#ensure().limits;
|
|
402
|
+
const key = `${min} ${max}`;
|
|
403
|
+
if (key === this.#marksFor)
|
|
404
|
+
return;
|
|
405
|
+
this.#marksFor = key;
|
|
406
|
+
this.#marks = zoomMarks(min, max);
|
|
407
|
+
this.#zoomShown = Number.NaN;
|
|
408
|
+
const track = this.#root.querySelector(".ztrack");
|
|
409
|
+
const labels = this.#root.querySelector(".zmarks");
|
|
410
|
+
if (track === null || labels === null)
|
|
411
|
+
return;
|
|
412
|
+
for (const tick of track.querySelectorAll(".ztick"))
|
|
413
|
+
tick.remove();
|
|
414
|
+
labels.replaceChildren();
|
|
415
|
+
const doc = this.ownerDocument;
|
|
416
|
+
for (const mark of this.#marks) {
|
|
417
|
+
// Placed through the CSSOM, which a strict style-src allows, not a style attribute in markup.
|
|
418
|
+
const tick = doc.createElement("span");
|
|
419
|
+
tick.className = "ztick";
|
|
420
|
+
tick.style.setProperty("--at", String(mark.at));
|
|
421
|
+
track.append(tick);
|
|
422
|
+
const label = doc.createElement("span");
|
|
423
|
+
label.className = "zmark";
|
|
424
|
+
label.dataset.zoom = String(mark.zoom);
|
|
425
|
+
if (mark.end)
|
|
426
|
+
label.dataset.end = mark.at === 0 ? "start" : "end";
|
|
427
|
+
label.style.setProperty("--at", String(mark.at));
|
|
428
|
+
label.textContent = zoomText(mark.zoom);
|
|
429
|
+
labels.append(label);
|
|
430
|
+
}
|
|
431
|
+
this.#root.querySelector(".zslide")?.toggleAttribute("hidden", this.#marks.length === 0);
|
|
432
|
+
}
|
|
346
433
|
#storageKey() {
|
|
347
434
|
return this.getAttribute("storage") || LEVEL_STORAGE_KEY;
|
|
348
435
|
}
|
|
@@ -372,6 +459,7 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
372
459
|
tilt.min = String(-c.limits.tiltMax);
|
|
373
460
|
tilt.max = String(c.limits.tiltMax);
|
|
374
461
|
}
|
|
462
|
+
this.#drawMarks();
|
|
375
463
|
}
|
|
376
464
|
#resolveView() {
|
|
377
465
|
const id = this.getAttribute("for");
|
|
@@ -451,6 +539,18 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
451
539
|
node.setAttribute("aria-label", label);
|
|
452
540
|
};
|
|
453
541
|
write(q('[data-o="zoom"]'), zoom);
|
|
542
|
+
// The slider always shows the true zoom, wherever it came from. A frame of the automatic turn leaves the zoom alone, so it costs one comparison here.
|
|
543
|
+
if (s.zoom !== this.#zoomShown) {
|
|
544
|
+
this.#zoomShown = s.zoom;
|
|
545
|
+
const slider = q("#zoom");
|
|
546
|
+
const at = zoomPosition(s.zoom, c.limits.zoomMin, c.limits.zoomMax);
|
|
547
|
+
const value = String(Math.round(at * SLIDER_SPAN));
|
|
548
|
+
if (slider.value !== value)
|
|
549
|
+
slider.value = value;
|
|
550
|
+
q(".zbox").style.setProperty("--pos", String(at));
|
|
551
|
+
if (slider.getAttribute("aria-valuetext") !== zoom)
|
|
552
|
+
slider.setAttribute("aria-valuetext", zoom);
|
|
553
|
+
}
|
|
454
554
|
say(q('[data-b="level"]'), `${words.zoomLevel} ${zoom}, ${words.zoomReset}`);
|
|
455
555
|
this.#sayZoom(`${words.zoomLevel} ${zoom}`);
|
|
456
556
|
for (const pace of PACES)
|
|
@@ -474,6 +574,8 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
474
574
|
// The speed is always in sight while the view turns: one small button in basic, which steps through the three; advanced always has the three.
|
|
475
575
|
q(".pace-now").hidden = flat || advanced || !s.turning;
|
|
476
576
|
this.toggleAttribute("data-turning", s.turning);
|
|
577
|
+
if (this.hasAttribute("data-flat") !== flat)
|
|
578
|
+
this.toggleAttribute("data-flat", flat);
|
|
477
579
|
}
|
|
478
580
|
/** Tells a screen reader the zoom once it has settled for a moment (a pinch or a wheel makes many steps). */
|
|
479
581
|
#sayZoom(text) {
|
|
@@ -4,4 +4,5 @@ export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, PA
|
|
|
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";
|
|
@@ -5,4 +5,5 @@ export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, PA
|
|
|
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";
|
|
@@ -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
|
-
/*
|
|
70
|
+
/* The classic bar on a phone: two rows to start with (angle, Auto-turn and More; the zoom), more rows when the reader asks for more; labels read only by a screen reader. */
|
|
65
71
|
@media (max-width: 640px) {
|
|
66
|
-
.bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: "angle play more" "tilt tilt front" "pace pace pace" "zoom zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
|
|
67
|
-
.field.angle { grid-area: angle; }
|
|
68
|
-
.field.tilt { grid-area: tilt; }
|
|
69
|
-
.field label, .field output, .play .text, .more .text, .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
70
|
-
.play, .more { padding: 0; }
|
|
71
|
-
.play { grid-area: play; }
|
|
72
|
-
.more { grid-area: more; }
|
|
73
|
-
.front { grid-area: front; padding: 0 12px; }
|
|
74
|
-
.pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }
|
|
75
|
-
.pace button { padding: 0 12px; }
|
|
76
|
-
.zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
|
|
77
|
-
.zoom button { padding: 0 8px; }
|
|
78
|
-
.zoom button.level { padding: 0 6px; }
|
|
72
|
+
${CLASSIC} .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px 48px; grid-template-areas: "angle play more" "tilt tilt front" "pace pace pace" "zoom zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
|
|
73
|
+
${CLASSIC} .field.angle { grid-area: angle; }
|
|
74
|
+
${CLASSIC} .field.tilt { grid-area: tilt; }
|
|
75
|
+
${CLASSIC} .field label, ${CLASSIC} .field output, ${CLASSIC} .play .text, ${CLASSIC} .more .text, ${CLASSIC} .pace .lbl { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
76
|
+
${CLASSIC} .play, ${CLASSIC} .more { padding: 0; }
|
|
77
|
+
${CLASSIC} .play { grid-area: play; }
|
|
78
|
+
${CLASSIC} .more { grid-area: more; }
|
|
79
|
+
${CLASSIC} .front { grid-area: front; padding: 0 12px; }
|
|
80
|
+
${CLASSIC} .pace { grid-area: pace; justify-self: end; flex-wrap: nowrap; }
|
|
81
|
+
${CLASSIC} .pace button { padding: 0 12px; }
|
|
82
|
+
${CLASSIC} .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
|
|
83
|
+
${CLASSIC} .zoom button { padding: 0 8px; }
|
|
84
|
+
${CLASSIC} .zoom button.level { padding: 0 6px; }
|
|
79
85
|
/* In the free room left of the zoom buttons, so showing it adds no row. */
|
|
80
|
-
.pace-now { grid-area: zoom; justify-self: start; }
|
|
81
|
-
.pan { grid-area: pan; justify-content: center; }
|
|
82
|
-
.drag { margin-inline-start: 4px; }
|
|
86
|
+
${CLASSIC} .pace-now { grid-area: zoom; justify-self: start; }
|
|
87
|
+
${CLASSIC} .pan { grid-area: pan; justify-content: center; }
|
|
88
|
+
${CLASSIC} .drag { margin-inline-start: 4px; }
|
|
83
89
|
}
|
|
90
|
+
|
|
91
|
+
/* The zoom slider (horizontal, vertical and compact): a logarithmic track with ticks at its marks, the labels under it, and the part from the least zoom to the zoom now filled. */
|
|
92
|
+
.zslide { display: none; }
|
|
93
|
+
${H} .zslide, ${V} .zslide, ${C} .zslide { display: flex; }
|
|
94
|
+
.zbox { position: relative; flex: 1 1 auto; min-width: 96px; height: 52px; }
|
|
95
|
+
.zbox input { position: absolute; inset: 0 0 auto; z-index: 1; min-width: 0; }
|
|
96
|
+
.zbox input::-webkit-slider-runnable-track { background: transparent; }
|
|
97
|
+
.zbox input::-moz-range-track { background: transparent; }
|
|
98
|
+
.ztrack { position: absolute; inset-inline: 14px; top: 21px; height: 6px; border-radius: 3px; background: var(--_line); }
|
|
99
|
+
.zfill { position: absolute; inset-block: 0; left: 0; width: calc(var(--pos, 0) * 100%); border-radius: 3px; background: var(--_accent); }
|
|
100
|
+
.ztick { position: absolute; top: 50%; left: calc(var(--at) * 100%); width: 2px; height: 14px; border-radius: 1px; background: var(--_ink); opacity: 0.5; transform: translate(-50%, -50%); }
|
|
101
|
+
.zmarks { position: absolute; inset-inline: 14px; top: 36px; height: 16px; pointer-events: none; }
|
|
102
|
+
.zmark { position: absolute; left: calc(var(--at) * 100%); transform: translateX(-50%); font-size: 0.72rem; font-weight: 500; line-height: 16px; opacity: 0.85; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
103
|
+
.zmark[data-end="start"] { transform: translateX(-35%); }
|
|
104
|
+
.zmark[data-end="end"] { transform: translateX(-65%); }
|
|
105
|
+
|
|
106
|
+
/* Horizontal: a tidy bar under the view. Two rows (the turn, then the zoom with its slider); one row when its own width allows. More adds its rows between them. */
|
|
107
|
+
${H} { container-type: inline-size; }
|
|
108
|
+
${H} .bar, ${V} .bar, ${C} .bar { display: grid; row-gap: 0; align-items: center; }
|
|
109
|
+
${H} .bar > *, ${V} .bar > *, ${C} .zoom > * { margin-block: 2px; }
|
|
110
|
+
${H} .bar { grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: "angle speed play more" "tilt tilt front front" "pace pace pace pace" "zoom zoom zoom centre" "pan pan pan pan"; column-gap: 8px; padding: 4px 8px; }
|
|
111
|
+
:host([data-layout="horizontal"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "zoom centre more" "pan pan pan"; }
|
|
112
|
+
${H} .angle, ${V} .angle, ${C} .angle { grid-area: angle; }
|
|
113
|
+
${H} .tilt, ${V} .tilt, ${C} .tilt { grid-area: tilt; }
|
|
114
|
+
${H} .pace-now, ${V} .pace-now { grid-area: speed; }
|
|
115
|
+
${H} .play, ${V} .play, ${C} .play { grid-area: play; }
|
|
116
|
+
${H} .pace, ${V} .pace, ${C} .pace { grid-area: pace; flex-wrap: nowrap; }
|
|
117
|
+
${H} .front, ${V} .front, ${C} .front { grid-area: front; justify-self: start; }
|
|
118
|
+
${H} .zoom, ${V} .zoom { grid-area: zoom; }
|
|
119
|
+
${H} .pan, ${V} .pan, ${C} .pan { grid-area: pan; justify-self: start; }
|
|
120
|
+
${H} .more, ${V} .more, ${C} .more { grid-area: more; margin-inline-start: 0; }
|
|
121
|
+
/* Centre, shown once the view is zoomed or moved: beside the zoom, never in the turn's row, so Auto-turn and More stay where they are. */
|
|
122
|
+
${H} .centre, ${V} .centre, ${C} .centre { grid-area: centre; justify-self: end; }
|
|
123
|
+
/* In the two rows it shares More's column, so it is an icon (its name is read) and never widens that column, which would move Auto-turn. */
|
|
124
|
+
@container (max-width: 899px) {
|
|
125
|
+
${H} .centre .text { ${UNSEEN} }
|
|
126
|
+
${H} .centre { padding: 0; }
|
|
127
|
+
}
|
|
128
|
+
${H} .zoom { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 0; }
|
|
129
|
+
${H} .zslide { flex: 1 1 auto; min-width: 0; }
|
|
130
|
+
${H} .zoom button, ${V} .zoom button, ${C} .zoom button { padding: 0 10px; }
|
|
131
|
+
${H} .zoom button.level, ${V} .zoom button.level, ${C} .zoom button.level { padding: 0 6px; }
|
|
132
|
+
@container (max-width: 559px) {
|
|
133
|
+
${H} .field label, ${H} .field output, ${H} .play .text, ${H} .more .text, ${H} .pace .lbl { ${UNSEEN} }
|
|
134
|
+
${H} .play, ${H} .more { padding: 0; }
|
|
135
|
+
${H} .zbox { min-width: 72px; }
|
|
136
|
+
/* The readout is Fit here (its name says so), which leaves the slider the room for its marks. */
|
|
137
|
+
${H} [data-b="fit"] { display: none; }
|
|
138
|
+
}
|
|
139
|
+
@container (min-width: 900px) {
|
|
140
|
+
${H} .bar { grid-template-columns: minmax(0, 1fr) auto auto auto auto auto; grid-template-areas: "angle speed play zoom centre more" "tilt tilt tilt pace front front" "pan pan pan pan pan pan"; column-gap: 12px; }
|
|
141
|
+
:host([data-layout="horizontal"][data-flat]) .bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "zoom centre more" "pan pan pan"; }
|
|
142
|
+
${H} .zslide { flex: 0 0 15rem; }
|
|
143
|
+
${H} .pan { justify-self: end; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Vertical: a column beside the view, its width --kazoku-column-width (16rem). Auto-turn and More at the top, so More stays put when it opens; then the turn, the zoom and its slider. */
|
|
147
|
+
${V} { width: var(--kazoku-column-width, 16rem); max-width: 100%; }
|
|
148
|
+
${V} .bar { grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: "play speed . more" "angle angle angle angle" "tilt tilt tilt tilt" "pace pace pace pace" "front front front front" "zoom zoom zoom zoom" "centre centre centre centre" "pan pan pan pan"; column-gap: 8px; padding: 6px 8px; }
|
|
149
|
+
${V} .field { flex: none; display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "label value" "slider slider"; gap: 0 8px; }
|
|
150
|
+
${V} .field label { grid-area: label; padding-top: 4px; }
|
|
151
|
+
${V} .field output { grid-area: value; padding-top: 4px; }
|
|
152
|
+
${V} .field input, ${V} .zbox { grid-area: slider; }
|
|
153
|
+
${V} .zoom { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr); grid-template-areas: "zs zs zs zs" "out level in fit"; gap: 0 4px; }
|
|
154
|
+
${V} .zslide { grid-area: zs; }
|
|
155
|
+
${V} [data-b="out"] { grid-area: out; }
|
|
156
|
+
${V} [data-b="level"] { grid-area: level; }
|
|
157
|
+
${V} [data-b="in"] { grid-area: in; }
|
|
158
|
+
${V} [data-b="fit"] { grid-area: fit; justify-self: start; }
|
|
159
|
+
${V} .pace .lbl { ${UNSEEN} }
|
|
160
|
+
${V} .pace button { padding: 0 10px; }
|
|
161
|
+
${V} .pan { flex-wrap: wrap; }
|
|
162
|
+
${V} .drag { margin-inline-start: 0; }
|
|
163
|
+
|
|
164
|
+
/* Compact: one short row (Auto-turn, zoom out, the zoom now, zoom in, More) for a card or a phone. More opens the rest under it: the zoom slider and Fit, the angle, and the advanced controls. */
|
|
165
|
+
${C} { display: block; }
|
|
166
|
+
:host([data-layout="compact"][overlay]) { inset-inline: 8px auto; bottom: 8px; }
|
|
167
|
+
/* Over a card, an open More never reaches past the card's top: the controls scroll inside themselves. */
|
|
168
|
+
:host([data-layout="compact"][overlay]) { display: flex; flex-direction: column; max-height: calc(100% - 16px); }
|
|
169
|
+
:host([data-layout="compact"][overlay]) .bar { min-height: 0; overflow-y: auto; }
|
|
170
|
+
${C} .bar { width: fit-content; max-width: 100%; grid-template-columns: repeat(5, auto); grid-template-areas: "play out level in more" "zs zs zs zs fit" "centre centre centre centre centre" "angle angle angle angle angle" "tilt tilt tilt tilt tilt" "pace pace pace pace pace" "front front front front front" "pan pan pan pan pan"; column-gap: 2px; padding: 2px 4px; border: 1px solid var(--_line); border-radius: 28px; background: var(--_surface); }
|
|
171
|
+
:host([data-layout="compact"][data-level="advanced"]) .bar { border-radius: 20px; padding: 2px 8px 6px; column-gap: 4px; }
|
|
172
|
+
:host([data-layout="compact"][data-flat]) .bar { grid-template-areas: "out level in more ." "zs zs zs fit fit" "centre centre centre centre centre" "pan pan pan pan pan"; }
|
|
173
|
+
${C} .zoom { display: contents; }
|
|
174
|
+
${C} .zslide { grid-area: zs; }
|
|
175
|
+
${C} [data-b="out"] { grid-area: out; }
|
|
176
|
+
${C} [data-b="level"] { grid-area: level; }
|
|
177
|
+
${C} [data-b="in"] { grid-area: in; }
|
|
178
|
+
${C} [data-b="fit"] { grid-area: fit; }
|
|
179
|
+
${C} .play, ${C} .more, ${C} [data-b="out"], ${C} [data-b="level"], ${C} [data-b="in"] { border-color: transparent; }
|
|
180
|
+
${C} .play:hover:not(:disabled), ${C} .more:hover:not(:disabled), ${C} .zoom button:hover:not(:disabled) { border-color: var(--_ink); }
|
|
181
|
+
${C} .play .text, ${C} .more .text { ${UNSEEN} }
|
|
182
|
+
${C} .play, ${C} .more { padding: 0; }
|
|
183
|
+
${C} .pace-now { display: none; }
|
|
184
|
+
${V} .centre, ${C} .centre { justify-self: start; }
|
|
185
|
+
/* The row stays one row: Centre is with Fit, one press of More away. */
|
|
186
|
+
:host([data-layout="compact"][data-level="basic"]) .centre { display: none; }
|
|
187
|
+
:host([data-layout="compact"][data-level="basic"]) .angle, :host([data-layout="compact"][data-level="basic"]) .zslide, :host([data-layout="compact"][data-level="basic"]) [data-b="fit"] { display: none; }
|
|
84
188
|
`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The zooms a mark goes to, the most wanted first: when two are too close for both labels, the earlier one stays. */
|
|
2
|
+
export declare const COMMON_ZOOMS: readonly number[];
|
|
3
|
+
/** The least distance between two marks, as a share of the slider's length, so their labels never touch. */
|
|
4
|
+
export declare const MARK_GAP = 0.18;
|
|
5
|
+
/** One key press on the slider multiplies or divides the zoom by this: finer than a press of + or -. */
|
|
6
|
+
export declare const SLIDER_STEP = 1.1;
|
|
7
|
+
/** How near a mark a drag lets go to it, in pixels: a gentle pull, never a forced step. */
|
|
8
|
+
export declare const SNAP_PX = 8;
|
|
9
|
+
/** A mark of the slider: its zoom, where it sits from 0 (the least zoom) to 1 (the most), and whether it is an end. */
|
|
10
|
+
export interface ZoomMark {
|
|
11
|
+
zoom: number;
|
|
12
|
+
at: number;
|
|
13
|
+
end: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Where a zoom sits on the slider, from 0 at `min` to 1 at `max`, held to that range. */
|
|
16
|
+
export declare function zoomPosition(zoom: number, min: number, max: number): number;
|
|
17
|
+
/** The zoom at a place on the slider, 0 to 1. */
|
|
18
|
+
export declare function zoomAtPosition(at: number, min: number, max: number): number;
|
|
19
|
+
/** The slider's marks for the range `min` to `max`, in order: both ends, then the common zooms inside that keep `gap` from every other mark. None when there is no range. */
|
|
20
|
+
export declare function zoomMarks(min: number, max: number, gap?: number): ZoomMark[];
|
|
21
|
+
/** Page Up and Page Down: the next mark above (`direction` 1) or below (-1) the zoom now, else the end. */
|
|
22
|
+
export declare function zoomToMark(zoom: number, marks: readonly ZoomMark[], direction: 1 | -1, min: number, max: number): number;
|
|
23
|
+
/** An arrow key: one `SLIDER_STEP` up or down, held to the range, stopping first on a mark it would pass, so 1.9× goes to 2× and the next press goes on past it. */
|
|
24
|
+
export declare function zoomStep(zoom: number, marks: readonly ZoomMark[], direction: 1 | -1, min: number, max: number, factor?: number): number;
|
|
25
|
+
/** A drag that ends near a mark goes to it: within `tolerance` of the slider's length (`SNAP_PX` over its length in pixels); anywhere else it stays where it is. */
|
|
26
|
+
export declare function zoomSnap(zoom: number, marks: readonly ZoomMark[], min: number, max: number, tolerance: number): number;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The zoom slider's scale: pure, so a server or a test can use it. The slider is logarithmic, so one press of + or - moves it the same distance anywhere
|
|
2
|
+
// along it, and its marks are the ends and the common zooms between them (1×, 2× and so on) that have room for a label.
|
|
3
|
+
/** The zooms a mark goes to, the most wanted first: when two are too close for both labels, the earlier one stays. */
|
|
4
|
+
export const COMMON_ZOOMS = [1, 2, 4, 0.5, 3, 10, 8, 5, 0.25, 6, 16, 20, 32, 50, 64, 100];
|
|
5
|
+
/** The least distance between two marks, as a share of the slider's length, so their labels never touch. */
|
|
6
|
+
export const MARK_GAP = 0.18;
|
|
7
|
+
/** One key press on the slider multiplies or divides the zoom by this: finer than a press of + or -. */
|
|
8
|
+
export const SLIDER_STEP = 1.1;
|
|
9
|
+
/** How near a mark a drag lets go to it, in pixels: a gentle pull, never a forced step. */
|
|
10
|
+
export const SNAP_PX = 8;
|
|
11
|
+
const span = (min, max) => Math.log(max / min);
|
|
12
|
+
/** Where a zoom sits on the slider, from 0 at `min` to 1 at `max`, held to that range. */
|
|
13
|
+
export function zoomPosition(zoom, min, max) {
|
|
14
|
+
const whole = span(min, max);
|
|
15
|
+
if (!(whole > 0) || !(zoom > 0))
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.min(1, Math.max(0, Math.log(zoom / min) / whole));
|
|
18
|
+
}
|
|
19
|
+
/** The zoom at a place on the slider, 0 to 1. */
|
|
20
|
+
export function zoomAtPosition(at, min, max) {
|
|
21
|
+
const place = Math.min(1, Math.max(0, at));
|
|
22
|
+
if (place === 0)
|
|
23
|
+
return min;
|
|
24
|
+
if (place === 1)
|
|
25
|
+
return max;
|
|
26
|
+
return min * Math.exp(place * span(min, max));
|
|
27
|
+
}
|
|
28
|
+
/** The slider's marks for the range `min` to `max`, in order: both ends, then the common zooms inside that keep `gap` from every other mark. None when there is no range. */
|
|
29
|
+
export function zoomMarks(min, max, gap = MARK_GAP) {
|
|
30
|
+
if (!(min > 0) || !(max > min * 1.0001))
|
|
31
|
+
return [];
|
|
32
|
+
const marks = [
|
|
33
|
+
{ zoom: min, at: 0, end: true },
|
|
34
|
+
{ zoom: max, at: 1, end: true },
|
|
35
|
+
];
|
|
36
|
+
for (const zoom of COMMON_ZOOMS) {
|
|
37
|
+
if (zoom <= min || zoom >= max)
|
|
38
|
+
continue;
|
|
39
|
+
const at = zoomPosition(zoom, min, max);
|
|
40
|
+
if (marks.every((mark) => Math.abs(mark.at - at) >= gap - 1e-9))
|
|
41
|
+
marks.push({ zoom, at, end: false });
|
|
42
|
+
}
|
|
43
|
+
return marks.sort((a, b) => a.zoom - b.zoom);
|
|
44
|
+
}
|
|
45
|
+
const NEAR = 1e-6;
|
|
46
|
+
/** Page Up and Page Down: the next mark above (`direction` 1) or below (-1) the zoom now, else the end. */
|
|
47
|
+
export function zoomToMark(zoom, marks, direction, min, max) {
|
|
48
|
+
if (direction > 0)
|
|
49
|
+
return marks.find((mark) => mark.zoom > zoom * (1 + NEAR))?.zoom ?? max;
|
|
50
|
+
return [...marks].reverse().find((mark) => mark.zoom < zoom * (1 - NEAR))?.zoom ?? min;
|
|
51
|
+
}
|
|
52
|
+
/** An arrow key: one `SLIDER_STEP` up or down, held to the range, stopping first on a mark it would pass, so 1.9× goes to 2× and the next press goes on past it. */
|
|
53
|
+
export function zoomStep(zoom, marks, direction, min, max, factor = SLIDER_STEP) {
|
|
54
|
+
const next = Math.min(max, Math.max(min, direction > 0 ? zoom * factor : zoom / factor));
|
|
55
|
+
const passed = direction > 0 ? marks.find((mark) => mark.zoom > zoom * (1 + NEAR) && mark.zoom < next * (1 - NEAR)) : [...marks].reverse().find((mark) => mark.zoom < zoom * (1 - NEAR) && mark.zoom > next * (1 + NEAR));
|
|
56
|
+
return passed?.zoom ?? next;
|
|
57
|
+
}
|
|
58
|
+
/** A drag that ends near a mark goes to it: within `tolerance` of the slider's length (`SNAP_PX` over its length in pixels); anywhere else it stays where it is. */
|
|
59
|
+
export function zoomSnap(zoom, marks, min, max, tolerance) {
|
|
60
|
+
const at = zoomPosition(zoom, min, max);
|
|
61
|
+
let best;
|
|
62
|
+
for (const mark of marks)
|
|
63
|
+
if (Math.abs(mark.at - at) <= tolerance && (best === undefined || Math.abs(mark.at - at) < Math.abs(best.at - at)))
|
|
64
|
+
best = mark;
|
|
65
|
+
return best?.zoom ?? zoom;
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/kazoku-ui",
|
|
3
|
-
"version": "0.
|
|
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",
|