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