@johnmorrisdotca/kazoku-ui 0.0.0-stage → 0.2.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.
@@ -0,0 +1,341 @@
1
+ // <kazoku-view-controls>: the view controls as a framework-free custom element. It never draws the scene. It binds the pointer, pinch, wheel and keys
2
+ // to the element named by `for`, and tells the page what the view is now with a `kazoku-viewchange` event; the page applies it to its canvas, WebGL, SVG or CSS 3D.
3
+ import { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
4
+ import { createViewController, DEFAULT_IGNORE } from "./controller.js";
5
+ import { EVENT, TAG } from "./index.js";
6
+ import { ZOOM_STEP, ZOOMED } from "./math.js";
7
+ import { STYLES } from "./styles.js";
8
+ import { languageOf, wordsFor } from "./words.js";
9
+ export { EVENT, TAG };
10
+ const icon = (path, cls = "") => `<svg viewBox="0 0 24 24" aria-hidden="true" class="${cls}"><path d="${path}"/></svg>`;
11
+ const ICON = {
12
+ play: icon("M8 5v14l11-7z", "i-play"),
13
+ pause: icon("M7 5h4v14H7zM13 5h4v14h-4z", "i-pause"),
14
+ minus: icon("M5 11h14v2H5z"),
15
+ plus: icon("M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6z"),
16
+ left: icon("M15 5l-9 7 9 7z"),
17
+ right: icon("M9 5l9 7-9 7z"),
18
+ up: icon("M5 15l7-9 7 9z"),
19
+ down: icon("M5 9l7 9 7-9z"),
20
+ };
21
+ const HTML = `
22
+ <div class="bar" part="bar" role="group">
23
+ <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>
24
+ <div class="field tilt" part="field tilt"><label data-w="tilt" for="tilt"></label><input id="tilt" type="range" min="-30" max="30" step="1" value="0" part="slider" /><output for="tilt" data-o="tilt">0°</output></div>
25
+ <button type="button" class="play" data-b="play" aria-pressed="false" part="button">${ICON.play}${ICON.pause}<span class="text" data-w="autoTurn"></span></button>
26
+ <button type="button" class="front" data-b="front" part="button"><span data-w="front"></span></button>
27
+ <div class="group zoom" role="group" data-g="zoom">
28
+ <button type="button" data-b="out" part="button">${ICON.minus}</button>
29
+ <button type="button" class="two" data-b="two" part="button">2×</button>
30
+ <button type="button" data-b="in" part="button">${ICON.plus}</button>
31
+ <button type="button" data-b="fit" part="button"><span data-w="fit"></span></button>
32
+ </div>
33
+ <div class="group pan" role="group" data-g="pan" hidden>
34
+ <button type="button" data-b="left" part="button">${ICON.left}</button>
35
+ <button type="button" data-b="up" part="button">${ICON.up}</button>
36
+ <button type="button" data-b="down" part="button">${ICON.down}</button>
37
+ <button type="button" data-b="right" part="button">${ICON.right}</button>
38
+ <div class="group drag" role="group" data-g="drag">
39
+ <button type="button" data-b="move" aria-pressed="true" part="button"><span data-w="dragMove"></span></button>
40
+ <button type="button" data-b="turn" aria-pressed="false" part="button"><span data-w="dragTurn"></span></button>
41
+ </div>
42
+ </div>
43
+ </div>`;
44
+ /**
45
+ * 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
46
+ * 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;
47
+ * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `speed` (degrees a second),
48
+ * `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
49
+ * or dark), `overlay` (sit over the bottom of a positioned parent).
50
+ */
51
+ export class ViewControlsElement extends HTMLElement {
52
+ static observedAttributes = ["for", "lang", "yaw", "tilt", "zoom", "fit-scale", "ignore", "speed", "flat", "tilt-max", "zoom-min", "zoom-max", "address-keys"];
53
+ #root;
54
+ #controller;
55
+ #view;
56
+ #unbind;
57
+ #words = {};
58
+ #ready = false;
59
+ #sliderHeld = 0;
60
+ #saveTimer = 0;
61
+ #keys = DEFAULT_KEYS;
62
+ constructor() {
63
+ super();
64
+ this.#root = this.attachShadow({ mode: "open" });
65
+ }
66
+ /** The controller behind the element: the same methods and state a page that wants no element would use. */
67
+ get controller() {
68
+ return this.#ensure();
69
+ }
70
+ /** The view now (a copy). */
71
+ get state() {
72
+ return { ...this.#ensure().state };
73
+ }
74
+ get view() {
75
+ return this.#view;
76
+ }
77
+ /** The element the hands bind to, in place of `for`. */
78
+ set view(element) {
79
+ this.#bindTo(element);
80
+ }
81
+ /** Your own labels over the language's, for example `{ front: "Home" }`. */
82
+ set words(words) {
83
+ this.#words = words;
84
+ this.#label();
85
+ }
86
+ play() {
87
+ this.#ensure().play();
88
+ }
89
+ pause() {
90
+ this.#ensure().pause();
91
+ }
92
+ front() {
93
+ this.#ensure().front();
94
+ }
95
+ fit() {
96
+ this.#ensure().fit();
97
+ }
98
+ set(next) {
99
+ this.#ensure().set(next);
100
+ }
101
+ /** What zoom 1 means to the page: the scene is drawn at `state.zoom * fitScale`. */
102
+ get fitScale() {
103
+ return this.#ensure().fitScale;
104
+ }
105
+ setFitScale(scale) {
106
+ this.#ensure().setFitScale(scale);
107
+ }
108
+ connectedCallback() {
109
+ const c = this.#ensure();
110
+ if (this.#root.querySelector(".bar") === null)
111
+ this.#build();
112
+ this.#keys = parseKeys(this.getAttribute("address-keys"));
113
+ this.#limits();
114
+ const start = {};
115
+ for (const key of ["yaw", "tilt"]) {
116
+ const value = Number(this.getAttribute(key));
117
+ if (this.hasAttribute(key) && Number.isFinite(value))
118
+ start[key] = value;
119
+ }
120
+ if (this.hasAttribute("address"))
121
+ Object.assign(start, parseAddress(globalThis.location?.search ?? "", this.#keys, c.limits));
122
+ c.turnable = !this.hasAttribute("flat");
123
+ c.speed = Number(this.getAttribute("speed")) || 30;
124
+ c.ignore = this.getAttribute("ignore") ?? DEFAULT_IGNORE;
125
+ const fitScale = Number(this.getAttribute("fit-scale"));
126
+ if (this.hasAttribute("fit-scale"))
127
+ c.setFitScale(fitScale);
128
+ c.setStartZoom(this.#startZoom());
129
+ c.set(start, true, "init");
130
+ this.#label();
131
+ this.#resolveView();
132
+ if (this.#view === undefined && this.ownerDocument.readyState === "loading")
133
+ this.ownerDocument.addEventListener("DOMContentLoaded", () => this.#resolveView(), { once: true });
134
+ this.#ready = true;
135
+ this.#show();
136
+ // Said once the page has had its turn to listen; a page can also read `state` at any time.
137
+ queueMicrotask(() => {
138
+ if (!this.isConnected)
139
+ return;
140
+ this.#dispatch(c.state, { final: true, reason: "init" });
141
+ if (this.hasAttribute("autoplay") && !globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches)
142
+ c.play();
143
+ });
144
+ }
145
+ disconnectedCallback() {
146
+ this.#unbind?.();
147
+ this.#unbind = undefined;
148
+ this.#controller?.destroy();
149
+ this.#controller = undefined;
150
+ this.#view = undefined;
151
+ this.#ready = false;
152
+ if (this.#saveTimer !== 0)
153
+ globalThis.clearTimeout(this.#saveTimer);
154
+ this.#saveTimer = 0;
155
+ }
156
+ attributeChangedCallback(name, _old, value) {
157
+ if (!this.#ready)
158
+ return;
159
+ const c = this.#ensure();
160
+ if (name === "for")
161
+ this.#resolveView();
162
+ else if (name === "lang")
163
+ this.#label();
164
+ else if (name === "yaw" || name === "tilt") {
165
+ if (value !== null && Number.isFinite(Number(value)))
166
+ c.set({ [name]: Number(value) });
167
+ }
168
+ else if (name === "zoom")
169
+ c.setStartZoom(this.#startZoom());
170
+ else if (name === "fit-scale")
171
+ c.setFitScale(Number(value));
172
+ else if (name === "ignore")
173
+ c.ignore = value ?? DEFAULT_IGNORE;
174
+ else if (name === "speed")
175
+ c.speed = Number(value) || 30;
176
+ else if (name === "flat") {
177
+ c.turnable = value === null;
178
+ this.#show();
179
+ }
180
+ else if (name === "address-keys")
181
+ this.#keys = parseKeys(value);
182
+ else
183
+ this.#limits();
184
+ }
185
+ #startZoom() {
186
+ const value = Number(this.getAttribute("zoom"));
187
+ return this.hasAttribute("zoom") && Number.isFinite(value) && value > 0 ? value : "fit";
188
+ }
189
+ #ensure() {
190
+ if (this.#controller === undefined) {
191
+ this.#controller = createViewController({ onChange: (state, info) => this.#changed(state, info) });
192
+ }
193
+ return this.#controller;
194
+ }
195
+ #build() {
196
+ this.#root.innerHTML = `<style>${STYLES}</style>${HTML}`;
197
+ const q = (selector) => this.#root.querySelector(selector);
198
+ const c = this.#ensure();
199
+ const slider = (id) => {
200
+ const input = q(`#${id}`);
201
+ // A hand on the angle slider holds the automatic turn still while it is down; the turn then goes on from where the hand left it.
202
+ const hold = () => {
203
+ this.#sliderHeld += 1;
204
+ if (this.#sliderHeld === 1 && id === "yaw")
205
+ c.holdTurn();
206
+ };
207
+ const free = () => {
208
+ if (this.#sliderHeld === 0)
209
+ return;
210
+ this.#sliderHeld = 0;
211
+ if (id === "yaw")
212
+ c.releaseTurn();
213
+ };
214
+ input.addEventListener("pointerdown", () => hold());
215
+ input.addEventListener("pointerup", free);
216
+ input.addEventListener("pointercancel", free);
217
+ input.addEventListener("input", () => c.set({ [id]: Number(input.value) }, false, "slider"));
218
+ input.addEventListener("change", () => c.set({ [id]: Number(input.value) }, true, "slider"));
219
+ };
220
+ slider("yaw");
221
+ slider("tilt");
222
+ const on = (b, run) => q(`[data-b="${b}"]`).addEventListener("click", run);
223
+ on("play", () => c.toggle());
224
+ on("front", () => c.front());
225
+ on("out", () => c.zoomBy(1 / ZOOM_STEP));
226
+ on("in", () => c.zoomBy(ZOOM_STEP));
227
+ on("two", () => c.zoomAt(2));
228
+ on("fit", () => c.fit());
229
+ on("left", () => c.panBy(-1, 0));
230
+ on("right", () => c.panBy(1, 0));
231
+ on("up", () => c.panBy(0, -1));
232
+ on("down", () => c.panBy(0, 1));
233
+ on("move", () => c.setDrag("move"));
234
+ on("turn", () => c.setDrag("turn"));
235
+ }
236
+ #limits() {
237
+ const c = this.#ensure();
238
+ const read = (name, fallback) => {
239
+ const value = Number(this.getAttribute(name));
240
+ return this.hasAttribute(name) && Number.isFinite(value) && value > 0 ? value : fallback;
241
+ };
242
+ c.limits.tiltMax = read("tilt-max", 30);
243
+ c.limits.zoomMin = Math.min(1, read("zoom-min", 1));
244
+ c.limits.zoomMax = Math.max(c.limits.zoomMin, read("zoom-max", 5));
245
+ const tilt = this.#root.querySelector("#tilt");
246
+ if (tilt !== null) {
247
+ tilt.min = String(-c.limits.tiltMax);
248
+ tilt.max = String(c.limits.tiltMax);
249
+ }
250
+ }
251
+ #resolveView() {
252
+ const id = this.getAttribute("for");
253
+ if (id === null)
254
+ return;
255
+ const root = this.getRootNode();
256
+ const found = root.getElementById?.(id) ?? this.ownerDocument.getElementById(id);
257
+ if (found !== null && found !== undefined)
258
+ this.#bindTo(found);
259
+ }
260
+ #bindTo(element) {
261
+ if (element === this.#view)
262
+ return;
263
+ this.#unbind?.();
264
+ this.#unbind = undefined;
265
+ this.#view = element;
266
+ if (element === undefined)
267
+ return;
268
+ // A view takes the keys only if it can be focused: a page that wants arrows and plus and minus on it gives it a tabindex.
269
+ this.#unbind = this.#ensure().bind(element);
270
+ }
271
+ #label() {
272
+ const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
273
+ const words = wordsFor(language, this.#words);
274
+ for (const node of this.#root.querySelectorAll("[data-w]"))
275
+ node.textContent = words[node.dataset.w];
276
+ const set = (selector, label) => this.#root.querySelector(selector)?.setAttribute("aria-label", label);
277
+ set(".bar", words.group);
278
+ set('[data-b="play"]', words.autoTurn);
279
+ set('[data-b="out"]', words.zoomOut);
280
+ set('[data-b="two"]', words.zoomTwo);
281
+ set('[data-b="in"]', words.zoomIn);
282
+ set('[data-g="zoom"]', words.zoom);
283
+ set('[data-g="pan"]', words.pan);
284
+ set('[data-g="drag"]', words.drag);
285
+ set('[data-b="left"]', words.panLeft);
286
+ set('[data-b="right"]', words.panRight);
287
+ set('[data-b="up"]', words.panUp);
288
+ set('[data-b="down"]', words.panDown);
289
+ this.#root.querySelector(".bar")?.setAttribute("lang", language);
290
+ }
291
+ #show() {
292
+ const c = this.#ensure();
293
+ const s = c.state;
294
+ const q = (selector) => this.#root.querySelector(selector);
295
+ const yaw = q("#yaw");
296
+ const tilt = q("#tilt");
297
+ yaw.value = String(Math.round(s.yaw));
298
+ tilt.value = String(Math.round(s.tilt));
299
+ q('[data-o="yaw"]').value = `${Math.round(s.yaw)}°`;
300
+ q('[data-o="tilt"]').value = `${Math.round(s.tilt)}°`;
301
+ q('[data-b="play"]').setAttribute("aria-pressed", String(s.turning));
302
+ q('[data-b="out"]').disabled = s.zoom <= c.limits.zoomMin + 1e-6;
303
+ q('[data-b="in"]').disabled = s.zoom >= c.limits.zoomMax - 1e-6;
304
+ q('[data-g="pan"]').hidden = s.zoom <= ZOOMED;
305
+ q('[data-b="move"]').setAttribute("aria-pressed", String(s.drag === "move"));
306
+ q('[data-b="turn"]').setAttribute("aria-pressed", String(s.drag === "turn"));
307
+ // A flat view cannot turn: the angle, tilt, auto-turn and front are not shown, and a drag only moves.
308
+ const flat = !c.turnable;
309
+ for (const selector of [".angle", ".tilt", ".play", ".front", '[data-g="drag"]'])
310
+ q(selector).hidden = flat;
311
+ this.toggleAttribute("data-turning", s.turning);
312
+ }
313
+ #changed(state, info) {
314
+ if (!this.#ready)
315
+ return;
316
+ this.#show();
317
+ this.#dispatch(state, info);
318
+ if (this.hasAttribute("address") && info.final)
319
+ this.#save();
320
+ }
321
+ #dispatch(state, info) {
322
+ this.dispatchEvent(new CustomEvent(EVENT, { detail: { ...state, ...info }, bubbles: true, composed: true }));
323
+ }
324
+ /** The address is written at most four times a second: Safari stops a page that changes its address more than a hundred times in thirty seconds. */
325
+ #save() {
326
+ if (this.#saveTimer !== 0)
327
+ return;
328
+ this.#saveTimer = globalThis.setTimeout(() => {
329
+ this.#saveTimer = 0;
330
+ try {
331
+ const s = this.#ensure().state;
332
+ const next = formatAddress(globalThis.location.search, s, this.#keys, s.turning, this.#ensure().startZoom);
333
+ if (next !== globalThis.location.search)
334
+ globalThis.history.replaceState(globalThis.history.state, "", `${globalThis.location.pathname}${next}${globalThis.location.hash}`);
335
+ }
336
+ catch {
337
+ /* an address that cannot be changed (a sandboxed frame): the view still works */
338
+ }
339
+ }, 250);
340
+ }
341
+ }
@@ -0,0 +1,6 @@
1
+ export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys, type AddressChoices, type AddressKeys, type DragMode } from "./address.ts";
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";
4
+ export { languageOf, WORDS, wordsFor, type Language, type ViewWords } from "./words.ts";
5
+ export declare const EVENT = "kazoku-viewchange";
6
+ export declare const TAG = "kazoku-view-controls";
@@ -0,0 +1,7 @@
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
+ export { DEFAULT_KEYS, formatAddress, parseAddress, parseKeys } from "./address.js";
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";
5
+ export { languageOf, WORDS, wordsFor } from "./words.js";
6
+ export const EVENT = "kazoku-viewchange";
7
+ export const TAG = "kazoku-view-controls";
@@ -0,0 +1,50 @@
1
+ export interface Point {
2
+ x: number;
3
+ y: number;
4
+ }
5
+ export interface Size {
6
+ width: number;
7
+ height: number;
8
+ }
9
+ /** What a gesture or a control changes. `panX` and `panY` are CSS pixels from the middle of the view. */
10
+ export interface Camera {
11
+ yaw: number;
12
+ tilt: number;
13
+ zoom: number;
14
+ panX: number;
15
+ panY: number;
16
+ }
17
+ export interface Limits {
18
+ tiltMax: number;
19
+ zoomMin: number;
20
+ zoomMax: number;
21
+ }
22
+ export declare const DEFAULT_LIMITS: Limits;
23
+ /** Degrees of turn across the view's whole width (a half turn), and of tilt across its whole height. */
24
+ export declare const TURN_ACROSS = 180;
25
+ export declare const TILT_ACROSS = 60;
26
+ /** The arrow pad moves the view by this fraction of the view's size. */
27
+ export declare const PAN_STEP = 0.25;
28
+ /** A zoom button multiplies the zoom by this. */
29
+ export declare const ZOOM_STEP = 1.4;
30
+ /** At or below this zoom the view counts as not zoomed (a drag turns it, the pan pad is hidden). */
31
+ export declare const ZOOMED = 1.05;
32
+ export declare const clamp: (value: number, low: number, high: number) => number;
33
+ /** An angle brought into [-180, 180). */
34
+ export declare const wrapYaw: (angle: number) => number;
35
+ /** Zoom to `next` (held between the limits) keeping the point `at` (pixels from the view's middle) where it is. */
36
+ export declare function zoomAbout(camera: Camera, next: number, at: Point, limits?: Limits): Camera;
37
+ /** Hold the pan so the picture still covers the view: at zoom z the picture may move (z - 1) / 2 of the view's size either way. */
38
+ export declare function clampPan(camera: Camera, size: Size | undefined): Camera;
39
+ /** What a pinch starts from: the distance between two fingers, their middle, and the camera at that moment. */
40
+ export interface PinchStart {
41
+ distance: number;
42
+ middle: Point;
43
+ camera: Camera;
44
+ }
45
+ /** 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
+ export declare function pinchTo(start: PinchStart, a: Point, b: Point, centre: Point, limits?: Limits): Camera;
47
+ /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). */
48
+ export declare function dragTurn(start: Camera, dx: number, dy: number, size: Size, tips: boolean, limits?: Limits): Pick<Camera, "yaw" | "tilt">;
49
+ /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
50
+ export declare const wheelZoomFactor: (deltaY: number, deltaMode: number) => number;
@@ -0,0 +1,48 @@
1
+ // The view's arithmetic: pure functions, no DOM, no clock. The controller and the tests both use them.
2
+ export const DEFAULT_LIMITS = { tiltMax: 30, zoomMin: 1, zoomMax: 5 };
3
+ /** Degrees of turn across the view's whole width (a half turn), and of tilt across its whole height. */
4
+ export const TURN_ACROSS = 180;
5
+ export const TILT_ACROSS = 60;
6
+ /** The arrow pad moves the view by this fraction of the view's size. */
7
+ export const PAN_STEP = 0.25;
8
+ /** A zoom button multiplies the zoom by this. */
9
+ export const ZOOM_STEP = 1.4;
10
+ /** At or below this zoom the view counts as not zoomed (a drag turns it, the pan pad is hidden). */
11
+ export const ZOOMED = 1.05;
12
+ export const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
13
+ /** An angle brought into [-180, 180). */
14
+ export const wrapYaw = (angle) => ((((angle + 180) % 360) + 360) % 360) - 180;
15
+ /** Zoom to `next` (held between the limits) keeping the point `at` (pixels from the view's middle) where it is. */
16
+ export function zoomAbout(camera, next, at, limits = DEFAULT_LIMITS) {
17
+ const zoom = clamp(next, limits.zoomMin, limits.zoomMax);
18
+ const k = zoom / camera.zoom;
19
+ const atOne = zoom <= 1;
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
+ }
22
+ /** Hold the pan so the picture still covers the view: at zoom z the picture may move (z - 1) / 2 of the view's size either way. */
23
+ export function clampPan(camera, size) {
24
+ if (camera.zoom <= 1)
25
+ return { ...camera, panX: 0, panY: 0 };
26
+ if (size === undefined)
27
+ return camera;
28
+ const roomX = (size.width * (camera.zoom - 1)) / 2;
29
+ const roomY = (size.height * (camera.zoom - 1)) / 2;
30
+ return { ...camera, panX: clamp(camera.panX, -roomX, roomX), panY: clamp(camera.panY, -roomY, roomY) };
31
+ }
32
+ /** 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. */
33
+ export function pinchTo(start, a, b, centre, limits = DEFAULT_LIMITS) {
34
+ const distance = Math.hypot(a.x - b.x, a.y - b.y) || 1;
35
+ const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
36
+ const zoomed = zoomAbout(start.camera, start.camera.zoom * (distance / start.distance), { x: start.middle.x - centre.x, y: start.middle.y - centre.y }, limits);
37
+ if (zoomed.zoom <= 1)
38
+ return zoomed;
39
+ return { ...zoomed, panX: zoomed.panX + (middle.x - start.middle.x), panY: zoomed.panY + (middle.y - start.middle.y) };
40
+ }
41
+ /** A one-pointer drag that turns: `dx` and `dy` pixels from where it began, in a view of `size`. A finger does not tilt (the page's own scroll keeps the vertical drag). */
42
+ export function dragTurn(start, dx, dy, size, tips, limits = DEFAULT_LIMITS) {
43
+ const yaw = wrapYaw(start.yaw + (dx / (size.width || 360)) * TURN_ACROSS);
44
+ const tilt = tips ? clamp(start.tilt - (dy / (size.height || 360)) * TILT_ACROSS, -limits.tiltMax, limits.tiltMax) : start.tilt;
45
+ return { yaw, tilt };
46
+ }
47
+ /** The factor a Ctrl or Command wheel turn zooms by (a trackpad's pinch arrives that way). `deltaMode` 1 is lines. */
48
+ export const wheelZoomFactor = (deltaY, deltaMode) => Math.exp(-deltaY * (deltaMode === 1 ? 0.05 : 0.01));
@@ -0,0 +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: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.two { min-width: 56px; }\n.drag { margin-inline-start: 8px; }\n/* A compact bar on a phone: three rows (angle, tilt and zoom, the pad when zoomed), labels read only by a screen reader. */\n@media (max-width: 640px) {\n .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px auto; grid-template-areas: \"angle play front\" \"tilt 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 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .play { grid-area: play; padding: 0; }\n .play .text { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n .front { grid-area: front; padding: 0 12px; }\n .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }\n .zoom button { padding: 0 8px; }\n .pan { grid-area: pan; justify-content: center; }\n .drag { margin-inline-start: 4px; }\n}\n";
@@ -0,0 +1,70 @@
1
+ // The element's styles. Colours are custom properties on the host, so a page can set its own:
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
+ export const STYLES = `
4
+ :host {
5
+ --_ink: var(--kazoku-ink, #1b1b1f);
6
+ --_surface: var(--kazoku-surface, #ffffff);
7
+ --_line: var(--kazoku-line, #c9ccd3);
8
+ --_accent: var(--kazoku-accent, #2457d6);
9
+ display: block;
10
+ box-sizing: border-box;
11
+ color: var(--_ink);
12
+ font: 600 0.9rem/1.2 system-ui, -apple-system, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif;
13
+ color-scheme: light dark;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :host(:not([theme="light"])) {
17
+ --_ink: var(--kazoku-ink, #f1f2f6);
18
+ --_surface: var(--kazoku-surface, #1c1d22);
19
+ --_line: var(--kazoku-line, #4a4d58);
20
+ --_accent: var(--kazoku-accent, #8fb0ff);
21
+ }
22
+ }
23
+ :host([theme="dark"]) {
24
+ --_ink: var(--kazoku-ink, #f1f2f6);
25
+ --_surface: var(--kazoku-surface, #1c1d22);
26
+ --_line: var(--kazoku-line, #4a4d58);
27
+ --_accent: var(--kazoku-accent, #8fb0ff);
28
+ color-scheme: dark;
29
+ }
30
+ :host([theme="light"]) { color-scheme: light; }
31
+ :host([overlay]) { position: absolute; inset-inline: 0; bottom: 0; z-index: 1; }
32
+ :host([overlay]) .bar { background: color-mix(in srgb, var(--_surface) 88%, transparent); }
33
+ [hidden] { display: none !important; }
34
+ .bar { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 8px; box-sizing: border-box; }
35
+ .field { display: flex; align-items: center; gap: 8px; flex: 1 1 200px; min-width: 0; }
36
+ .field.tilt { flex-basis: 150px; }
37
+ .field label { flex: none; }
38
+ .field output { flex: none; min-width: 3.2em; text-align: end; font-variant-numeric: tabular-nums; }
39
+ .group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }
40
+ .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
41
+ input[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; }
42
+ input[type="range"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--_line); }
43
+ input[type="range"]::-moz-range-track { height: 6px; border-radius: 3px; background: var(--_line); }
44
+ input[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); }
45
+ input[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); }
46
+ input[type="range"]:focus-visible { outline: 2px solid var(--_ink); outline-offset: -4px; border-radius: 8px; }
47
+ button { -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; }
48
+ button:hover:not(:disabled) { border-color: var(--_ink); }
49
+ button:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }
50
+ button[aria-pressed="true"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }
51
+ button:disabled { opacity: 0.4; cursor: default; }
52
+ svg { width: 18px; height: 18px; fill: currentColor; flex: none; }
53
+ button[aria-pressed="true"] .i-play, button[aria-pressed="false"] .i-pause { display: none; }
54
+ .zoom button.two { min-width: 56px; }
55
+ .drag { margin-inline-start: 8px; }
56
+ /* A compact bar on a phone: three rows (angle, tilt and zoom, the pad when zoomed), labels read only by a screen reader. */
57
+ @media (max-width: 640px) {
58
+ .bar { display: grid; grid-template-columns: minmax(0, 1fr) 48px auto; grid-template-areas: "angle play front" "tilt zoom zoom" "pan pan pan"; gap: 0 8px; padding: 2px 8px; }
59
+ .field.angle { grid-area: angle; }
60
+ .field.tilt { grid-area: tilt; }
61
+ .field label, .field output { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
62
+ .play { grid-area: play; padding: 0; }
63
+ .play .text { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
64
+ .front { grid-area: front; padding: 0 12px; }
65
+ .zoom { grid-area: zoom; justify-self: end; flex-wrap: nowrap; }
66
+ .zoom button { padding: 0 8px; }
67
+ .pan { grid-area: pan; justify-content: center; }
68
+ .drag { margin-inline-start: 4px; }
69
+ }
70
+ `;
@@ -0,0 +1,26 @@
1
+ export interface ViewWords {
2
+ group: string;
3
+ angle: string;
4
+ tilt: string;
5
+ autoTurn: string;
6
+ front: string;
7
+ zoom: string;
8
+ zoomOut: string;
9
+ zoomTwo: string;
10
+ zoomIn: string;
11
+ fit: string;
12
+ pan: string;
13
+ panLeft: string;
14
+ panRight: string;
15
+ panUp: string;
16
+ panDown: string;
17
+ drag: string;
18
+ dragMove: string;
19
+ dragTurn: string;
20
+ }
21
+ export type Language = "en" | "ja";
22
+ export declare const WORDS: Record<Language, ViewWords>;
23
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
24
+ export declare const languageOf: (lang: string | null | undefined) => Language;
25
+ /** The words for a language, with any of your own over them. */
26
+ export declare const wordsFor: (language: Language, overrides?: Partial<ViewWords>) => ViewWords;
@@ -0,0 +1,47 @@
1
+ // The labels, in English and Japanese. Every key is in both; a test holds that. Add a language by giving `words` a complete set.
2
+ export const WORDS = {
3
+ en: {
4
+ group: "View controls",
5
+ angle: "Angle",
6
+ tilt: "Tilt",
7
+ autoTurn: "Auto-turn",
8
+ front: "Front",
9
+ zoom: "Zoom",
10
+ zoomOut: "Zoom out",
11
+ zoomTwo: "Zoom to 2 times",
12
+ zoomIn: "Zoom in",
13
+ fit: "Fit",
14
+ pan: "Move the view",
15
+ panLeft: "Move left",
16
+ panRight: "Move right",
17
+ panUp: "Move up",
18
+ panDown: "Move down",
19
+ drag: "What a drag does",
20
+ dragMove: "Move",
21
+ dragTurn: "Turn",
22
+ },
23
+ ja: {
24
+ group: "表示の操作",
25
+ angle: "角度",
26
+ tilt: "傾き",
27
+ autoTurn: "自動回転",
28
+ front: "正面",
29
+ zoom: "ズーム",
30
+ zoomOut: "縮小",
31
+ zoomTwo: "2倍にする",
32
+ zoomIn: "拡大",
33
+ fit: "全体",
34
+ pan: "表示を動かす",
35
+ panLeft: "左へ",
36
+ panRight: "右へ",
37
+ panUp: "上へ",
38
+ panDown: "下へ",
39
+ drag: "ドラッグの動作",
40
+ dragMove: "移動",
41
+ dragTurn: "回転",
42
+ },
43
+ };
44
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
45
+ export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
46
+ /** The words for a language, with any of your own over them. */
47
+ export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });