@johnmorrisdotca/kazoku-ui 0.10.1 → 0.11.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 +9 -0
- package/README.md +12 -2
- package/dist/view-controls/controller.d.ts +10 -0
- package/dist/view-controls/controller.js +50 -4
- package/dist/view-controls/element.d.ts +3 -1
- package/dist/view-controls/element.js +19 -4
- package/dist/view-controls/index.d.ts +1 -1
- package/dist/view-controls/index.js +1 -1
- package/dist/view-controls/math.d.ts +14 -4
- package/dist/view-controls/math.js +23 -8
- package/dist/view-controls/words.d.ts +1 -0
- package/dist/view-controls/words.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.11.0] - 2026-11-03
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **A Centre button** (KAZOKU-UI-33). Once the view is zoomed or moved, a Centre button (中央) puts the object back in the middle at Fit and the default angle, with a quarter of a second's move (at once under reduced motion; a hand on the view stops it): `element.centre()`, `controller.centre(animate)`, the `centre` word in both languages, `between()` in the maths. The keys Home and 0 do the same without the move. `object-size` (`objectSize`) holds a pan so that at least a quarter of an object smaller than the view stays in view.
|
|
9
|
+
- **The drag rule**, written down in `docs/drag-rule.md`: the surface under the pointer follows the pointer, the arrow keys agree, Auto-turn goes the object's real way.
|
|
10
|
+
- **`templates/e2e-ground.mjs`**, the shared browser check of the rule (`checkFollowsTheHand`, `checkExactGrab`, and the parts; `picture` names what is photographed and `window` the part of it that is followed, for a thin object such as a rocket), copied unchanged into a package's `e2e/`; a unit test holds its finder. The demo's `grab.html` draws a globe turned with exact grab and is tested with the helper.
|
|
11
|
+
|
|
3
12
|
## [0.10.1] - 2026-11-02
|
|
4
13
|
|
|
5
14
|
- **`onContext("restored")`** (KAZOKU-UI-15, found by chikyu-earth). The shared renderer's `onContext` was told `"lost"` and `"unavailable"` but never `"restored"`, although `docs/renderer.md` and the `SharedRendererOptions` type said it was. It is now called when the browser gives the context back, after the focused view draws again. `e2e/renderer.demo.mjs`'s lost-and-restored check reads the events the demo records (`["lost"]`, then `["lost", "restored"]`); it fails on 0.10.0, where the second list is `["lost"]`.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ The family's shared interface parts, as their own versioned package: one part pe
|
|
|
9
9
|
The first part is the **view controls** for any 3D or zoomable view. They never draw the scene: they tell your page what the reader chose, and your canvas, WebGL,
|
|
10
10
|
SVG or CSS 3D applies it.
|
|
11
11
|
|
|
12
|
-
[Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Shared 3D renderer](docs/renderer.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
|
|
12
|
+
[Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Shared 3D renderer](docs/renderer.md) · [The drag rule](docs/drag-rule.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
15
|
npm install @johnmorrisdotca/kazoku-ui
|
|
@@ -113,7 +113,7 @@ myZoomIn.onclick = () => view.zoomBy(1.4);
|
|
|
113
113
|
|
|
114
114
|
The automatic turn is idle whenever nothing can see the view: while the `for` element (else the controls) is scrolled out of sight, and while the tab is hidden. Its frame loop is stopped, so no animation frame is asked for and no `kazoku-viewchange` event with reason `auto` is sent; `state.turning` stays true, and the turn goes on from where it was when the view is back (the first frame back only restarts the clock). A view that opens off screen asks for no frame until it is seen, and an element on no page asks for none. One frame is asked for at a time, and at most one `auto` change is sent a page frame. A page does not need to pause it for these. Without the element, `view.bind(element)` watches the bound element the same way; `view.suspendTurn()` and `view.resumeTurn()` do the same for your own visibility test (they nest, on top of what `bind` does), and `view.play(seconds)` stops the turn by itself after that many seconds of turning.
|
|
115
115
|
|
|
116
|
-
Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`, `anchor`, `keyTilt`, `stopTurn`, `tiltStep`. `anchor`, `keyTilt`, `stopTurn` and `
|
|
116
|
+
Options of `createViewController` that match: `zoom` (`"fit"` or a number), `fitScale`, `ignore`, `anchor`, `keyTilt`, `stopTurn`, `tiltStep`, `grabRadius`. `anchor`, `keyTilt`, `stopTurn`, `tiltStep` and `grabRadius` are also members you can change at any time. The controller also has `ignore` (changeable), `fitScale`, `startZoom` (the `zoom` the view opens at) and `setFitScale(n)`, `setStartZoom(zoom)`; `DEFAULT_IGNORE` is the default selector.
|
|
117
117
|
|
|
118
118
|
### Buttons and links inside the view
|
|
119
119
|
|
|
@@ -131,6 +131,14 @@ controls.anchor = { x: 60, y: -30 }; // pixels from the view's middle when it is
|
|
|
131
131
|
|
|
132
132
|
Zoom is about that point of the picture, so it stays put on screen however far in the reader is and wherever the view has been moved to: `+` then `-` leaves it where it was within a pixel. Without the element, set `view.anchor` on the controller, or pass `anchor` to `createViewController`; `view.zoomBy(f)` and `view.zoomAt(z)` with no point use it.
|
|
133
133
|
|
|
134
|
+
### Centre
|
|
135
|
+
|
|
136
|
+
Zoom in and drag the object away and a **Centre** button (中央) comes into the bar. It puts the object back in the middle at Fit and the default angle (yaw and tilt 0) with a quarter of a second's move, at once for a reader who prefers reduced motion, and a hand on the view (a press, the wheel, a key) stops the move where it is. `element.centre()` and `controller.centre(animate = true)` do the same from code, ending with a change whose `reason` is `"centre"`. The keys Home and 0 are the same without the move (they send `front` and `fit`). The pan is held so the object cannot be lost: the picture keeps covering the view, or, with `object-size`, a quarter of the object stays in view.
|
|
137
|
+
|
|
138
|
+
### The drag rule and exact grab
|
|
139
|
+
|
|
140
|
+
In every turnable view the surface under the pointer follows the pointer, the arrow keys agree, and Auto-turn goes the object's real way; `grab-radius` makes a drag keep the touched point under the pointer on a globe. The rule, the option and the shared browser check (`templates/e2e-ground.mjs`) are in [docs/drag-rule.md](docs/drag-rule.md); the demo's `grab.html` shows it.
|
|
141
|
+
|
|
134
142
|
### Keys, the wheel and the turn
|
|
135
143
|
|
|
136
144
|
Arrow Up tips like a drag up (`tilt` gets larger). A page whose wheel and keys should agree sets `key-tilt="wheel"`: Arrow Up then tips the way a wheel turned up does (`tilt` gets smaller), and Arrow Down the other way. `stop-turn` stops the automatic turn when the reader takes hold of the view with a press, the wheel or a key (a `pause` event is sent first); the defaults keep it turning, and a button or slider never stops it. `tilt-step="1"` makes a drag give whole degrees of tilt.
|
|
@@ -179,6 +187,8 @@ An `init` event is sent once, a moment after the element connects, with the star
|
|
|
179
187
|
| `key-tilt` | Which way Arrow Up tips the view: `drag` (the default: like a drag up) or `wheel` (the way a wheel turned up tips it, the other way). Arrow Down goes the other way from Up. |
|
|
180
188
|
| `stop-turn` | A press on the view, the wheel over it or a key it handles stops the automatic turn. Off by default, so the turn carries on. Buttons and sliders never stop it. |
|
|
181
189
|
| `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
|
+
| `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 }`. |
|
|
191
|
+
| `grab-radius` | Exact grab, for a globe or any turnable piece: its on-screen radius in CSS pixels at Fit, or `fit` for half the smaller side of the view. A drag then turns it by the angle that keeps the touched point under the pointer (a drag of *d* pixels moves the ground at the middle by *d* pixels), and the radius grows with the zoom. Left out, a drag turns by a fixed angle across the view, as before. See [docs/drag-rule.md](docs/drag-rule.md). |
|
|
182
192
|
| `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
|
|
183
193
|
| `autoplay-for` | Seconds of turning after which the `autoplay` turn stops by itself (as Auto-turn pressed). Left out, it never stops. Time spent paused off screen or in a hidden tab is not counted, and a reader's own Auto-turn press has no limit. |
|
|
184
194
|
| `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
|
|
@@ -41,6 +41,10 @@ export interface ViewControllerOptions {
|
|
|
41
41
|
stopTurn?: boolean;
|
|
42
42
|
/** Round the tilt of a drag to a multiple of this many degrees; 0 (the default) leaves it unrounded, 1 gives whole degrees. */
|
|
43
43
|
tiltStep?: number;
|
|
44
|
+
/** Exact grab: the on-screen radius, in CSS pixels at Fit, of the globe or turnable piece, or `"fit"` for half the smaller side of the view. A drag then turns it by the angle that keeps the touched point under the pointer (`degreesPerPixel`), and at a zoom the radius grows with it. 0 (the default) keeps the fixed angle across the view. */
|
|
45
|
+
grabRadius?: number | "fit";
|
|
46
|
+
/** The object's size at Fit in CSS pixels, when it is smaller than the view: a pan then keeps at least a quarter of it in view (see `clampPan`). Left out, the object is the picture, which covers the view. */
|
|
47
|
+
objectSize?: Size;
|
|
44
48
|
/** The view's size in CSS pixels, for holding the pan; `bind` gives it from the element when this is left out. */
|
|
45
49
|
size?: () => Size | undefined;
|
|
46
50
|
onChange?: (state: ViewState, info: ChangeInfo) => void;
|
|
@@ -59,6 +63,10 @@ export interface ViewController {
|
|
|
59
63
|
keyTilt: KeyTilt;
|
|
60
64
|
stopTurn: boolean;
|
|
61
65
|
tiltStep: number;
|
|
66
|
+
/** Exact grab (see `ViewControllerOptions.grabRadius`). Can be changed at any time. */
|
|
67
|
+
grabRadius: number | "fit";
|
|
68
|
+
/** The object's size at Fit (see `ViewControllerOptions.objectSize`). Can be changed at any time. */
|
|
69
|
+
objectSize: Size | undefined;
|
|
62
70
|
/** What zoom 1 means to the page (see `ViewControllerOptions.fitScale`). */
|
|
63
71
|
readonly fitScale: number;
|
|
64
72
|
/** The `state.zoom` the view opens at: 1 for "fit", else the starting scale divided by `fitScale`. The address leaves a zoom at this value out. */
|
|
@@ -81,6 +89,8 @@ export interface ViewController {
|
|
|
81
89
|
releaseTurn(): void;
|
|
82
90
|
front(): void;
|
|
83
91
|
fit(): void;
|
|
92
|
+
/** Put the object back in the middle at Fit and the default angle (yaw and tilt 0). It moves there over a quarter of a second, and at once for `animate` false or for a reader who prefers reduced motion; any hand on the view stops the move where it is. Ends with a change `{ final: true, reason: "centre" }`. */
|
|
93
|
+
centre(animate?: boolean): void;
|
|
84
94
|
/** Zoom to `next` about the point `at` (client pixels); left out, about the `anchor`, else the view's middle. */
|
|
85
95
|
zoomAt(next: number, at?: Point, final?: boolean): void;
|
|
86
96
|
zoomBy(factor: number, at?: Point): void;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { turnOnlyInSight } from "./sight.js";
|
|
2
|
-
import { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
|
|
2
|
+
import { between, clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
|
|
3
3
|
/** What `bind` leaves alone by default: the page's own controls inside the view. */
|
|
4
4
|
export const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
|
|
5
|
+
/** How long a move to the middle takes. */
|
|
6
|
+
const CENTRE_MS = 250;
|
|
5
7
|
const HANDLED_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "_", "0", "Home"];
|
|
6
8
|
const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined);
|
|
7
9
|
const browserClock = () => ({ request: (callback) => globalThis.requestAnimationFrame(callback), cancel: (id) => globalThis.cancelAnimationFrame(id) });
|
|
@@ -17,6 +19,8 @@ export function createViewController(options = {}) {
|
|
|
17
19
|
let limit = 0;
|
|
18
20
|
let turned = 0;
|
|
19
21
|
let destroyed = false;
|
|
22
|
+
let centring = 0;
|
|
23
|
+
let stepping = false;
|
|
20
24
|
let fitScale = positive(options.fitScale) ?? 1;
|
|
21
25
|
let start = positive(options.zoom) ?? "fit";
|
|
22
26
|
let atStart = true;
|
|
@@ -39,10 +43,18 @@ export function createViewController(options = {}) {
|
|
|
39
43
|
bound.style.touchAction = state.zoom > ZOOMED ? "none" : "pan-y";
|
|
40
44
|
options.onChange?.({ ...state }, { final, reason });
|
|
41
45
|
};
|
|
46
|
+
const stopCentre = () => {
|
|
47
|
+
if (centring === 0)
|
|
48
|
+
return;
|
|
49
|
+
clock.cancel(centring);
|
|
50
|
+
centring = 0;
|
|
51
|
+
};
|
|
42
52
|
const camera = () => ({ yaw: state.yaw, tilt: state.tilt, zoom: state.zoom, panX: state.panX, panY: state.panY });
|
|
43
53
|
/** Take a camera in, held to the limits and, once the view's size is known, to the room the zoom gives. */
|
|
44
54
|
const take = (next) => {
|
|
45
|
-
|
|
55
|
+
if (!stepping)
|
|
56
|
+
stopCentre();
|
|
57
|
+
const held = clampPan({ ...next, zoom: clamp(next.zoom, limits.zoomMin, limits.zoomMax), tilt: clamp(next.tilt, -limits.tiltMax, limits.tiltMax) }, size(), controller.objectSize);
|
|
46
58
|
state.yaw = controller.turnable ? wrapYaw(held.yaw) : state.yaw;
|
|
47
59
|
state.tilt = controller.turnable ? held.tilt : state.tilt;
|
|
48
60
|
if (held.zoom !== state.zoom)
|
|
@@ -70,7 +82,7 @@ export function createViewController(options = {}) {
|
|
|
70
82
|
if (!state.turning || suspends > 0)
|
|
71
83
|
return;
|
|
72
84
|
frame = clock.request(tick);
|
|
73
|
-
if (holds > 0) {
|
|
85
|
+
if (holds > 0 || centring !== 0) {
|
|
74
86
|
last = 0;
|
|
75
87
|
return;
|
|
76
88
|
}
|
|
@@ -94,6 +106,8 @@ export function createViewController(options = {}) {
|
|
|
94
106
|
keyTilt: options.keyTilt ?? "drag",
|
|
95
107
|
stopTurn: options.stopTurn ?? false,
|
|
96
108
|
tiltStep: options.tiltStep ?? 0,
|
|
109
|
+
grabRadius: options.grabRadius ?? 0,
|
|
110
|
+
objectSize: options.objectSize,
|
|
97
111
|
get fitScale() {
|
|
98
112
|
return fitScale;
|
|
99
113
|
},
|
|
@@ -186,6 +200,35 @@ export function createViewController(options = {}) {
|
|
|
186
200
|
take({ ...camera(), zoom: 1, panX: 0, panY: 0 });
|
|
187
201
|
emit(true, "fit");
|
|
188
202
|
},
|
|
203
|
+
centre(animate = true) {
|
|
204
|
+
stopCentre();
|
|
205
|
+
const from = camera();
|
|
206
|
+
const to = { yaw: 0, tilt: 0, zoom: 1, panX: 0, panY: 0 };
|
|
207
|
+
const reduced = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
|
|
208
|
+
const same = from.yaw === 0 && from.tilt === 0 && from.zoom === 1 && from.panX === 0 && from.panY === 0;
|
|
209
|
+
if (!animate || reduced || same || destroyed) {
|
|
210
|
+
take(to);
|
|
211
|
+
emit(true, "centre");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
let began = -1;
|
|
215
|
+
const step = (now) => {
|
|
216
|
+
if (began < 0)
|
|
217
|
+
began = now;
|
|
218
|
+
const fraction = (now - began) / CENTRE_MS;
|
|
219
|
+
stepping = true;
|
|
220
|
+
take(between(from, to, fraction));
|
|
221
|
+
stepping = false;
|
|
222
|
+
if (fraction >= 1) {
|
|
223
|
+
centring = 0;
|
|
224
|
+
emit(true, "centre");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
emit(false, "centre");
|
|
228
|
+
centring = clock.request(step);
|
|
229
|
+
};
|
|
230
|
+
centring = clock.request(step);
|
|
231
|
+
},
|
|
189
232
|
zoomAt(next, at, final = true) {
|
|
190
233
|
take(zoomAbout(camera(), next, fromMiddle(at), limits));
|
|
191
234
|
emit(final, "zoom");
|
|
@@ -209,6 +252,7 @@ export function createViewController(options = {}) {
|
|
|
209
252
|
},
|
|
210
253
|
destroy() {
|
|
211
254
|
destroyed = true;
|
|
255
|
+
stopCentre();
|
|
212
256
|
clock.cancel(frame);
|
|
213
257
|
state.turning = false;
|
|
214
258
|
unbind?.();
|
|
@@ -233,6 +277,7 @@ export function createViewController(options = {}) {
|
|
|
233
277
|
};
|
|
234
278
|
const canTurn = () => controller.turnable;
|
|
235
279
|
const grab = () => {
|
|
280
|
+
stopCentre();
|
|
236
281
|
if (controller.stopTurn)
|
|
237
282
|
controller.pause();
|
|
238
283
|
};
|
|
@@ -311,7 +356,8 @@ export function createViewController(options = {}) {
|
|
|
311
356
|
return;
|
|
312
357
|
const r = view.getBoundingClientRect();
|
|
313
358
|
// Up and down tips it for a mouse; on a finger the browser keeps the vertical drag to scroll the page, and the tilt slider tips it.
|
|
314
|
-
|
|
359
|
+
const radius = controller.grabRadius === "fit" ? Math.min(r.width, r.height) / 2 : controller.grabRadius;
|
|
360
|
+
take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits, controller.tiltStep, radius * gesture.camera.zoom) });
|
|
315
361
|
emit(false, "drag");
|
|
316
362
|
}
|
|
317
363
|
};
|
|
@@ -14,7 +14,7 @@ export interface ViewChangeDetail extends ViewState {
|
|
|
14
14
|
* buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
|
|
15
15
|
* `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
|
|
16
16
|
* `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
|
|
17
|
-
* `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
|
|
17
|
+
* `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
18
|
* or dark), `overlay` (sit over the bottom of a positioned parent).
|
|
19
19
|
*/
|
|
20
20
|
export declare class ViewControlsElement extends HTMLElement {
|
|
@@ -33,6 +33,8 @@ export declare class ViewControlsElement extends HTMLElement {
|
|
|
33
33
|
play(): void;
|
|
34
34
|
pause(): void;
|
|
35
35
|
front(): void;
|
|
36
|
+
/** Put the object back in the middle at Fit and the default angle, with a brief move (at once for a reader who prefers reduced motion, or with `false`). */
|
|
37
|
+
centre(animate?: boolean): void;
|
|
36
38
|
fit(): void;
|
|
37
39
|
set(next: Partial<ViewState>): void;
|
|
38
40
|
/** The point the zoom buttons and keys zoom about: `{ x, y }` in CSS pixels from the view's middle when it is at Fit, moving with the picture. `undefined` zooms about the view's middle. The `anchor` attribute (`"x,y"`) sets the same. */
|
|
@@ -22,6 +22,7 @@ const ICON = {
|
|
|
22
22
|
right: icon("M9 5l9 7-9 7z"),
|
|
23
23
|
up: icon("M5 15l7-9 7 9z"),
|
|
24
24
|
down: icon("M5 9l7 9 7-9z"),
|
|
25
|
+
centre: icon("M11 2h2v5h-2zM11 17h2v5h-2zM2 11h5v2H2zM17 11h5v2h-5zM12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 1 0 0-7z"),
|
|
25
26
|
more: icon("M3 7h18v2H3zM3 15h18v2H3zM8 4a3 3 0 1 0 0 6 3 3 0 1 0 0-6zM16 12a3 3 0 1 0 0 6 3 3 0 1 0 0-6z"),
|
|
26
27
|
};
|
|
27
28
|
const HTML = `
|
|
@@ -36,6 +37,7 @@ const HTML = `
|
|
|
36
37
|
<button type="button" data-b="fast" aria-pressed="false" part="button"><span data-w="fast"></span></button>
|
|
37
38
|
</div>
|
|
38
39
|
<button type="button" class="front adv" data-b="front" part="button"><span data-w="front"></span></button>
|
|
40
|
+
<button type="button" class="centre" data-b="centre" part="button" hidden>${ICON.centre}<span class="text" data-w="centre"></span></button>
|
|
39
41
|
<div class="group zoom" role="group" data-g="zoom">
|
|
40
42
|
<button type="button" data-b="out" part="button">${ICON.minus}</button>
|
|
41
43
|
<button type="button" class="level" data-b="level" part="button"><span data-o="zoom">1×</span></button>
|
|
@@ -61,11 +63,11 @@ const HTML = `
|
|
|
61
63
|
* buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
|
|
62
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),
|
|
63
65
|
* `controls` (`basic`, the default, or `advanced`: which controls are shown, the zoom readout in both, and the speed in both while the view turns; the reader's own choice with the More button is remembered in localStorage, under `kazoku-view-controls` or the key `storage` names, and wins),
|
|
64
|
-
* `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
|
|
66
|
+
* `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `object-size` (`w,h`: the object's size at Fit in pixels when it is smaller than the view, so a pan keeps a quarter of it in view), `grab-radius` (exact grab: the on-screen radius of the piece in pixels, or `fit`; a drag then keeps the touched point under the pointer), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
|
|
65
67
|
* or dark), `overlay` (sit over the bottom of a positioned parent).
|
|
66
68
|
*/
|
|
67
69
|
export class ViewControlsElement extends HTMLElement {
|
|
68
|
-
static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "anchor", "key-tilt", "stop-turn", "tilt-step", "speed", "controls", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
|
|
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"];
|
|
69
71
|
#root;
|
|
70
72
|
#controller;
|
|
71
73
|
#view;
|
|
@@ -115,6 +117,10 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
115
117
|
front() {
|
|
116
118
|
this.#ensure().front();
|
|
117
119
|
}
|
|
120
|
+
/** Put the object back in the middle at Fit and the default angle, with a brief move (at once for a reader who prefers reduced motion, or with `false`). */
|
|
121
|
+
centre(animate = true) {
|
|
122
|
+
this.#ensure().centre(animate);
|
|
123
|
+
}
|
|
118
124
|
fit() {
|
|
119
125
|
this.#ensure().fit();
|
|
120
126
|
}
|
|
@@ -157,7 +163,7 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
157
163
|
this.#pace = pace ?? "medium";
|
|
158
164
|
this.#speed();
|
|
159
165
|
c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
|
|
160
|
-
for (const name of ["key-tilt", "stop-turn", "tilt-step"])
|
|
166
|
+
for (const name of ["key-tilt", "stop-turn", "tilt-step", "grab-radius", "object-size"])
|
|
161
167
|
this.#option(name);
|
|
162
168
|
if (this.hasAttribute("anchor"))
|
|
163
169
|
this.#option("anchor");
|
|
@@ -216,7 +222,7 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
216
222
|
c.setFitScale(Number(value));
|
|
217
223
|
else if (name === "ignore")
|
|
218
224
|
c.ignore = value ?? DEFAULT_IGNORE;
|
|
219
|
-
else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step")
|
|
225
|
+
else if (name === "anchor" || name === "key-tilt" || name === "stop-turn" || name === "tilt-step" || name === "grab-radius" || name === "object-size")
|
|
220
226
|
this.#option(name);
|
|
221
227
|
else if (name === "speed")
|
|
222
228
|
this.#speed();
|
|
@@ -249,6 +255,12 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
249
255
|
c.stopTurn = value !== null;
|
|
250
256
|
else if (name === "tilt-step")
|
|
251
257
|
c.tiltStep = Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : 0;
|
|
258
|
+
else if (name === "object-size") {
|
|
259
|
+
const [w, h] = (value ?? "").trim().split(/[\s,x]+/).map(Number);
|
|
260
|
+
c.objectSize = w !== undefined && w > 0 && Number.isFinite(w) ? { width: w, height: h !== undefined && h > 0 && Number.isFinite(h) ? h : w } : undefined;
|
|
261
|
+
}
|
|
262
|
+
else if (name === "grab-radius")
|
|
263
|
+
c.grabRadius = value?.trim() === "fit" ? "fit" : Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : 0;
|
|
252
264
|
}
|
|
253
265
|
#startZoom() {
|
|
254
266
|
const value = Number(this.getAttribute("zoom"));
|
|
@@ -307,6 +319,7 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
307
319
|
on("out", () => c().zoomBy(1 / ZOOM_STEP));
|
|
308
320
|
on("in", () => c().zoomBy(ZOOM_STEP));
|
|
309
321
|
on("fit", () => c().fit());
|
|
322
|
+
on("centre", () => c().centre());
|
|
310
323
|
on("level", () => c().fit());
|
|
311
324
|
on("more", () => {
|
|
312
325
|
this.#level = this.#level === "basic" ? "advanced" : "basic";
|
|
@@ -421,6 +434,8 @@ export class ViewControlsElement extends HTMLElement {
|
|
|
421
434
|
q('[data-o="yaw"]').value = `${Math.round(s.yaw)}°`;
|
|
422
435
|
q('[data-o="tilt"]').value = `${Math.round(s.tilt)}°`;
|
|
423
436
|
q('[data-b="play"]').setAttribute("aria-pressed", String(s.turning));
|
|
437
|
+
// Centre comes in once the view has been zoomed or moved, which is when the object can be lost; it puts it back in the middle at Fit and the default angle.
|
|
438
|
+
q(".centre").hidden = s.zoom === 1 && s.panX === 0 && s.panY === 0;
|
|
424
439
|
q('[data-b="out"]').disabled = s.zoom <= c.limits.zoomMin + 1e-6;
|
|
425
440
|
q('[data-b="in"]').disabled = s.zoom >= c.limits.zoomMax - 1e-6;
|
|
426
441
|
const advanced = this.#level === "advanced";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys, type AddressChoices, type AddressKeys, type DragMode } from "./address.ts";
|
|
2
2
|
export { createViewController, DEFAULT_IGNORE, type ChangeInfo, type Clock, type ViewController, type ViewControllerOptions, type ViewState } from "./controller.ts";
|
|
3
|
-
export { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout, type Camera, type Limits, type PinchStart, type Point, type Size } from "./math.ts";
|
|
3
|
+
export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout, type Camera, type Limits, type PinchStart, type Point, type Size } from "./math.ts";
|
|
4
4
|
export { languageOf, WORDS, wordsFor, type Language, type ViewWords } from "./words.ts";
|
|
5
5
|
export declare const EVENT = "kazoku-viewchange";
|
|
6
6
|
export declare const TAG = "kazoku-view-controls";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The view controls without the element: the controller, the arithmetic, the address and the labels. Nothing here touches the DOM until `bind` is called, so it imports on a server.
|
|
2
2
|
export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
|
|
3
3
|
export { createViewController, DEFAULT_IGNORE } from "./controller.js";
|
|
4
|
-
export { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout } from "./math.js";
|
|
4
|
+
export { between, clamp, clampPan, DEFAULT_LIMITS, degreesPerPixel, dragTurn, PAN_STEP, pinchTo, TILT_ACROSS, TURN_ACROSS, wheelZoomFactor, wrapYaw, ZOOM_STEP, ZOOMED, zoomAbout } from "./math.js";
|
|
5
5
|
export { languageOf, WORDS, wordsFor } from "./words.js";
|
|
6
6
|
export const EVENT = "kazoku-viewchange";
|
|
7
7
|
export const TAG = "kazoku-view-controls";
|
|
@@ -34,8 +34,11 @@ export declare const clamp: (value: number, low: number, high: number) => number
|
|
|
34
34
|
export declare const wrapYaw: (angle: number) => number;
|
|
35
35
|
/** Zoom to `next` (held between the limits) keeping the point `at` (pixels from the view's middle) where it is. */
|
|
36
36
|
export declare function zoomAbout(camera: Camera, next: number, at: Point, limits?: Limits): Camera;
|
|
37
|
-
/**
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Hold the pan so the object cannot be lost. By default the object is the picture, which fills the view at Fit: at zoom z it may move (z - 1) / 2 of the view's size either way, so the picture still covers the view.
|
|
39
|
+
* A page whose object is smaller than the view gives its `object` (its size at Fit, in CSS pixels): it may then move until a quarter of it is still in view, in a view of any size, so it can never be dragged out.
|
|
40
|
+
*/
|
|
41
|
+
export declare function clampPan(camera: Camera, size: Size | undefined, object?: Size): Camera;
|
|
39
42
|
/** What a pinch starts from: the distance between two fingers, their middle, and the camera at that moment. */
|
|
40
43
|
export interface PinchStart {
|
|
41
44
|
distance: number;
|
|
@@ -44,7 +47,14 @@ export interface PinchStart {
|
|
|
44
47
|
}
|
|
45
48
|
/** The camera for two fingers now at `a` and `b`: zoom by the change of distance about where the pinch began, and the middle of the fingers also drags the view. `centre` is the view's middle in client pixels. */
|
|
46
49
|
export declare function pinchTo(start: PinchStart, a: Point, b: Point, centre: Point, limits?: Limits): Camera;
|
|
47
|
-
/**
|
|
48
|
-
export declare
|
|
50
|
+
/** Degrees of turn for one pixel of drag when the piece's on-screen radius is `radius` pixels: the pixel that a point at the middle of a sphere's face moves by, so the touched point stays under the pointer. */
|
|
51
|
+
export declare const degreesPerPixel: (radius: number) => number;
|
|
52
|
+
/**
|
|
53
|
+
* A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it.
|
|
54
|
+
* A `grabRadius` above 0 is the on-screen radius, in pixels, of a globe or any turnable piece: both axes then turn by the angle that carries a point at the middle of its face by the pixels dragged (`degreesPerPixel`), instead of a fixed angle across the view.
|
|
55
|
+
*/
|
|
56
|
+
export declare function dragTurn(start: Camera, dx: number, dy: number, size: Size, tips: boolean, limits?: Limits, step?: number, grabRadius?: number): Pick<Camera, "yaw" | "tilt">;
|
|
57
|
+
/** The camera `fraction` of the way (0 to 1) from `from` to `to`, eased out: quick at first, slow at the end. */
|
|
58
|
+
export declare function between(from: Camera, to: Camera, fraction: number): Camera;
|
|
49
59
|
/** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
|
|
50
60
|
export declare const wheelZoomFactor: (deltaY: number, deltaMode: number) => number;
|
|
@@ -19,14 +19,17 @@ export function zoomAbout(camera, next, at, limits = DEFAULT_LIMITS) {
|
|
|
19
19
|
const atOne = zoom <= 1;
|
|
20
20
|
return { ...camera, zoom, panX: atOne ? 0 : at.x - (at.x - camera.panX) * k, panY: atOne ? 0 : at.y - (at.y - camera.panY) * k };
|
|
21
21
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Hold the pan so the object cannot be lost. By default the object is the picture, which fills the view at Fit: at zoom z it may move (z - 1) / 2 of the view's size either way, so the picture still covers the view.
|
|
24
|
+
* A page whose object is smaller than the view gives its `object` (its size at Fit, in CSS pixels): it may then move until a quarter of it is still in view, in a view of any size, so it can never be dragged out.
|
|
25
|
+
*/
|
|
26
|
+
export function clampPan(camera, size, object) {
|
|
24
27
|
if (camera.zoom <= 1)
|
|
25
28
|
return { ...camera, panX: 0, panY: 0 };
|
|
26
29
|
if (size === undefined)
|
|
27
30
|
return camera;
|
|
28
|
-
const roomX = (size.width * (camera.zoom - 1)) / 2;
|
|
29
|
-
const roomY = (size.height * (camera.zoom - 1)) / 2;
|
|
31
|
+
const roomX = object === undefined ? (size.width * (camera.zoom - 1)) / 2 : size.width / 2 + (object.width * camera.zoom) / 4;
|
|
32
|
+
const roomY = object === undefined ? (size.height * (camera.zoom - 1)) / 2 : size.height / 2 + (object.height * camera.zoom) / 4;
|
|
30
33
|
return { ...camera, panX: clamp(camera.panX, -roomX, roomX), panY: clamp(camera.panY, -roomY, roomY) };
|
|
31
34
|
}
|
|
32
35
|
/** The camera for two fingers now at `a` and `b`: zoom by the change of distance about where the pinch began, and the middle of the fingers also drags the view. `centre` is the view's middle in client pixels. */
|
|
@@ -38,12 +41,24 @@ export function pinchTo(start, a, b, centre, limits = DEFAULT_LIMITS) {
|
|
|
38
41
|
return zoomed;
|
|
39
42
|
return { ...zoomed, panX: zoomed.panX + (middle.x - start.middle.x), panY: zoomed.panY + (middle.y - start.middle.y) };
|
|
40
43
|
}
|
|
41
|
-
/**
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
/** Degrees of turn for one pixel of drag when the piece's on-screen radius is `radius` pixels: the pixel that a point at the middle of a sphere's face moves by, so the touched point stays under the pointer. */
|
|
45
|
+
export const degreesPerPixel = (radius) => (180 / Math.PI) / radius;
|
|
46
|
+
/**
|
|
47
|
+
* A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). A `step` above 0 rounds the tilt to a multiple of it.
|
|
48
|
+
* A `grabRadius` above 0 is the on-screen radius, in pixels, of a globe or any turnable piece: both axes then turn by the angle that carries a point at the middle of its face by the pixels dragged (`degreesPerPixel`), instead of a fixed angle across the view.
|
|
49
|
+
*/
|
|
50
|
+
export function dragTurn(start, dx, dy, size, tips, limits = DEFAULT_LIMITS, step = 0, grabRadius = 0) {
|
|
51
|
+
const exact = grabRadius > 0 && Number.isFinite(grabRadius) ? degreesPerPixel(grabRadius) : 0;
|
|
52
|
+
const yaw = wrapYaw(start.yaw + (exact > 0 ? dx * exact : (dx / (size.width || 360)) * TURN_ACROSS));
|
|
53
|
+
const raw = start.tilt - (exact > 0 ? dy * exact : (dy / (size.height || 360)) * TILT_ACROSS);
|
|
45
54
|
const tilt = tips ? clamp(step > 0 ? Math.round(raw / step) * step : raw, -limits.tiltMax, limits.tiltMax) : start.tilt;
|
|
46
55
|
return { yaw, tilt };
|
|
47
56
|
}
|
|
57
|
+
/** The camera `fraction` of the way (0 to 1) from `from` to `to`, eased out: quick at first, slow at the end. */
|
|
58
|
+
export function between(from, to, fraction) {
|
|
59
|
+
const t = 1 - (1 - clamp(fraction, 0, 1)) ** 3;
|
|
60
|
+
const mix = (a, b) => a + (b - a) * t;
|
|
61
|
+
return { yaw: mix(from.yaw, to.yaw), tilt: mix(from.tilt, to.tilt), zoom: mix(from.zoom, to.zoom), panX: mix(from.panX, to.panX), panY: mix(from.panY, to.panY) };
|
|
62
|
+
}
|
|
48
63
|
/** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
|
|
49
64
|
export const wheelZoomFactor = (deltaY, deltaMode) => Math.exp(-deltaY * (deltaMode === 1 ? 0.05 : 0.01));
|
|
@@ -11,6 +11,7 @@ export const WORDS = {
|
|
|
11
11
|
zoomTwo: "Zoom to 2 times",
|
|
12
12
|
zoomIn: "Zoom in",
|
|
13
13
|
fit: "Fit",
|
|
14
|
+
centre: "Centre",
|
|
14
15
|
pan: "Move the view",
|
|
15
16
|
panLeft: "Move left",
|
|
16
17
|
panRight: "Move right",
|
|
@@ -38,6 +39,7 @@ export const WORDS = {
|
|
|
38
39
|
zoomTwo: "2倍にする",
|
|
39
40
|
zoomIn: "拡大",
|
|
40
41
|
fit: "全体",
|
|
42
|
+
centre: "中央",
|
|
41
43
|
pan: "表示を動かす",
|
|
42
44
|
panLeft: "左へ",
|
|
43
45
|
panRight: "右へ",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnmorrisdotca/kazoku-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|