@johnmorrisdotca/kazoku-ui 0.16.0 → 0.17.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 +10 -0
- package/README.md +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/replay-bar/define.d.ts +1 -0
- package/dist/replay-bar/define.js +9 -0
- package/dist/replay-bar/element.d.ts +34 -0
- package/dist/replay-bar/element.js +219 -0
- package/dist/replay-bar/index.d.ts +4 -0
- package/dist/replay-bar/index.js +5 -0
- package/dist/replay-bar/state.d.ts +19 -0
- package/dist/replay-bar/state.js +27 -0
- package/dist/replay-bar/styles.d.ts +1 -0
- package/dist/replay-bar/styles.js +8 -0
- package/dist/replay-bar/words.d.ts +17 -0
- package/dist/replay-bar/words.js +7 -0
- package/dist/view-controls/address.d.ts +16 -1
- package/dist/view-controls/address.js +25 -4
- package/dist/view-controls/controller.d.ts +13 -2
- package/dist/view-controls/controller.js +72 -9
- package/dist/view-controls/element.js +154 -26
- package/dist/view-controls/index.d.ts +2 -2
- package/dist/view-controls/index.js +2 -2
- package/dist/view-controls/levels.d.ts +3 -0
- package/dist/view-controls/levels.js +1 -0
- package/dist/view-controls/math.d.ts +50 -0
- package/dist/view-controls/math.js +73 -2
- package/dist/view-controls/sight.js +23 -0
- package/dist/view-controls/styles.d.ts +1 -1
- package/dist/view-controls/styles.js +16 -7
- package/dist/view-controls/words.d.ts +5 -0
- package/dist/view-controls/words.js +6 -0
- package/package.json +18 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
- **The vertical demo sits beside the view.** The axis chips made that column tall enough that Centre's row scrolled the page and Auto-turn left the pointer. On a desk the column stands beside the scene, so Centre adds its row and Auto-turn stays where it was.
|
|
6
|
+
|
|
7
|
+
## [0.17.0] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
The view turns on all three axes (KAZOKU-UI-55), and a shared replay bar (KAZOKU-UI-57).
|
|
10
|
+
|
|
11
|
+
- **Roll, beside Angle and Tilt.** One slider, and chips for Angle, Tilt, Roll and All. The slider sets the axis the chips name (All keeps the angle). `roll` and `roll-max` (180) sit next to `tilt` and `tilt-max`.
|
|
12
|
+
- **Auto-turn chooses an axis:** Angle, Tilt, Roll or All. All is a tumble: three rates that drift, so it does not repeat. Each axis has its own direction. Tilt swings inside `tilt-max` and, at 90 or more, rolls over on a quaternion (`orientation` on the event) so the up axis does not flip. Off under reduced motion, as before. The choice is the `axis` attribute and `?axis=all`.
|
|
13
|
+
- **`<kazoku-replay-bar>`** (KAZOKU-UI-57). Play, pause, back to the start and repeat, with the family's speed buttons on the same row. The bar reports the choice (`kazoku-replay` with `{ action, playing, repeat, reason }`) and the page moves the thing. `playing` and `repeat` are attributes; `address` keeps them (`play=on`, `repeat=on`), and `address-keys` can rename either. The speed buttons use `steps` and `speed`, and their own address key `replay-speed` (`speed-key` renames it), so they do not share a view's `pace`. Reduced motion leaves play off. New part: `@johnmorrisdotca/kazoku-ui/replay-bar`, `/element` and `/define`.
|
|
14
|
+
|
|
5
15
|
## [0.16.0] - 2026-11-04
|
|
6
16
|
|
|
7
17
|
Which way Auto-turn goes is the reader's to choose.
|
package/README.md
CHANGED
|
@@ -553,7 +553,7 @@ One part, one subpath; the pure code, the element and the registration are three
|
|
|
553
553
|
|
|
554
554
|
## Demos
|
|
555
555
|
|
|
556
|
-
[The demo](https://johnmorrisdotca.github.io/kazoku-ui/) has the view controls, the language picker and the theme switch in its header. Beside it: [speed buttons](https://johnmorrisdotca.github.io/kazoku-ui/speed.html), [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).
|
|
556
|
+
[The demo](https://johnmorrisdotca.github.io/kazoku-ui/) has the view controls, the language picker and the theme switch in its header. Beside it: [speed buttons](https://johnmorrisdotca.github.io/kazoku-ui/speed.html), [the replay bar](https://johnmorrisdotca.github.io/kazoku-ui/replay.html), [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).
|
|
557
557
|
|
|
558
558
|
<picture>
|
|
559
559
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/builder-dark.webp">
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** The subpaths of the parts, in the order they were added. */
|
|
2
|
-
export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "embed-panel", "renderer", "testing"];
|
|
2
|
+
export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "embed-panel", "renderer", "testing", "replay-bar"];
|
|
3
3
|
export type Part = (typeof PARTS)[number];
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Kazoku UI 家族: the family's shared interface parts, each its own subpath so a page loads only what it uses.
|
|
2
2
|
/** The subpaths of the parts, in the order they were added. */
|
|
3
|
-
export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "embed-panel", "renderer", "testing"];
|
|
3
|
+
export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "speed-buttons", "embed-panel", "renderer", "testing", "replay-bar"];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function defineReplayBar(registry?: CustomElementRegistry): boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Importing this registers <kazoku-replay-bar>, unless the tag is already taken (the first definition wins).
|
|
2
|
+
import { ReplayBarElement, TAG } from "./element.js";
|
|
3
|
+
export function defineReplayBar(registry = customElements) {
|
|
4
|
+
if (registry.get(TAG) !== undefined)
|
|
5
|
+
return false;
|
|
6
|
+
registry.define(TAG, ReplayBarElement);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
defineReplayBar();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import "../speed-buttons/define.ts";
|
|
2
|
+
import { EVENT, TAG } from "./index.ts";
|
|
3
|
+
import { type ReplayWords } from "./words.ts";
|
|
4
|
+
export { EVENT, TAG };
|
|
5
|
+
/** What the `kazoku-replay` event carries in `detail`. */
|
|
6
|
+
export interface ReplayDetail {
|
|
7
|
+
/** What was pressed. `play` and `pause` are the one button. */
|
|
8
|
+
action: "play" | "pause" | "start" | "repeat";
|
|
9
|
+
playing: boolean;
|
|
10
|
+
repeat: boolean;
|
|
11
|
+
/** `init` (once, when it comes up), `user` (a button) or `set` (a property or attribute). */
|
|
12
|
+
reason: "init" | "user" | "set";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Attributes: `playing` (present, or `on`, while it plays), `repeat` (the loop), `steps` and `speed` (handed to the speed buttons),
|
|
16
|
+
* `address` (keep play, repeat and the speed in the address), `address-keys` (`play:p,repeat:r`), `speed-key` (the speed's own key, `replay-speed` by default),
|
|
17
|
+
* `lang`, `theme`.
|
|
18
|
+
*/
|
|
19
|
+
export declare class ReplayBarElement extends HTMLElement {
|
|
20
|
+
#private;
|
|
21
|
+
static observedAttributes: string[];
|
|
22
|
+
constructor();
|
|
23
|
+
/** Whether playback is going. Reduced motion keeps this false. */
|
|
24
|
+
get playing(): boolean;
|
|
25
|
+
set playing(next: boolean);
|
|
26
|
+
/** Whether the page should loop when it reaches the end. */
|
|
27
|
+
get repeat(): boolean;
|
|
28
|
+
set repeat(next: boolean);
|
|
29
|
+
/** Your own labels over the language's. */
|
|
30
|
+
set words(words: Partial<ReplayWords>);
|
|
31
|
+
connectedCallback(): void;
|
|
32
|
+
disconnectedCallback(): void;
|
|
33
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// <kazoku-replay-bar>: play, pause, back to the start, repeat, and the family's speed buttons, in one row. It moves nothing itself.
|
|
2
|
+
// The page hears `kazoku-replay` and applies it, and hears `kazoku-speedchange` from the speed buttons. Reduced motion leaves play off.
|
|
3
|
+
import { applyParams, createAddressWriter, readParam } from "../address-state/address.js";
|
|
4
|
+
import { adoptStyles } from "../shared/adopt.js";
|
|
5
|
+
import { watchScheme } from "../shared/scheme.js";
|
|
6
|
+
import "../speed-buttons/define.js";
|
|
7
|
+
import { EVENT, TAG } from "./index.js";
|
|
8
|
+
import { addressKeys, DEFAULT_STEPS, flagOn, SPEED_KEY } from "./state.js";
|
|
9
|
+
import { STYLES } from "./styles.js";
|
|
10
|
+
import { languageOf, wordsFor } from "./words.js";
|
|
11
|
+
export { EVENT, TAG };
|
|
12
|
+
/**
|
|
13
|
+
* Attributes: `playing` (present, or `on`, while it plays), `repeat` (the loop), `steps` and `speed` (handed to the speed buttons),
|
|
14
|
+
* `address` (keep play, repeat and the speed in the address), `address-keys` (`play:p,repeat:r`), `speed-key` (the speed's own key, `replay-speed` by default),
|
|
15
|
+
* `lang`, `theme`.
|
|
16
|
+
*/
|
|
17
|
+
export class ReplayBarElement extends HTMLElement {
|
|
18
|
+
static observedAttributes = ["playing", "repeat", "steps", "speed", "lang", "label"];
|
|
19
|
+
#root;
|
|
20
|
+
#playing = false;
|
|
21
|
+
#repeat = false;
|
|
22
|
+
#ready = false;
|
|
23
|
+
#styled = false;
|
|
24
|
+
#words = {};
|
|
25
|
+
#writer = createAddressWriter({ delay: 0 });
|
|
26
|
+
#stop;
|
|
27
|
+
#motion;
|
|
28
|
+
#reflecting = false;
|
|
29
|
+
constructor() {
|
|
30
|
+
super();
|
|
31
|
+
this.#root = this.attachShadow({ mode: "open" });
|
|
32
|
+
}
|
|
33
|
+
/** Whether playback is going. Reduced motion keeps this false. */
|
|
34
|
+
get playing() {
|
|
35
|
+
return this.#playing;
|
|
36
|
+
}
|
|
37
|
+
set playing(next) {
|
|
38
|
+
this.#play(next, "set");
|
|
39
|
+
}
|
|
40
|
+
/** Whether the page should loop when it reaches the end. */
|
|
41
|
+
get repeat() {
|
|
42
|
+
return this.#repeat;
|
|
43
|
+
}
|
|
44
|
+
set repeat(next) {
|
|
45
|
+
this.#loop(next, "set");
|
|
46
|
+
}
|
|
47
|
+
/** Your own labels over the language's. */
|
|
48
|
+
set words(words) {
|
|
49
|
+
this.#words = words;
|
|
50
|
+
this.#paint();
|
|
51
|
+
}
|
|
52
|
+
connectedCallback() {
|
|
53
|
+
if (!this.#styled) {
|
|
54
|
+
const outer = this.getRootNode();
|
|
55
|
+
adoptStyles(this.#root, STYLES, this.nonce || (outer instanceof ShadowRoot ? outer.host.nonce : "") || "");
|
|
56
|
+
this.#styled = true;
|
|
57
|
+
}
|
|
58
|
+
this.#stop = watchScheme(this);
|
|
59
|
+
this.#motion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
60
|
+
this.#motion?.addEventListener?.("change", this.#onMotion);
|
|
61
|
+
const keys = addressKeys(this.getAttribute("address-keys"));
|
|
62
|
+
const askedPlay = this.hasAttribute("address") ? flagOn(readParam(keys.play)) : this.hasAttribute("playing") && this.getAttribute("playing") !== "off";
|
|
63
|
+
const askedRepeat = this.hasAttribute("address") ? flagOn(readParam(keys.repeat)) : this.hasAttribute("repeat") && this.getAttribute("repeat") !== "off";
|
|
64
|
+
this.#playing = askedPlay && !this.#reduced();
|
|
65
|
+
this.#repeat = askedRepeat;
|
|
66
|
+
this.#reflect("playing", this.#playing);
|
|
67
|
+
this.#reflect("repeat", this.#repeat);
|
|
68
|
+
this.#render();
|
|
69
|
+
this.#ready = true;
|
|
70
|
+
queueMicrotask(() => {
|
|
71
|
+
if (this.isConnected)
|
|
72
|
+
this.#dispatch(this.#playing ? "play" : "pause", "init");
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
disconnectedCallback() {
|
|
76
|
+
this.#stop?.();
|
|
77
|
+
this.#stop = undefined;
|
|
78
|
+
this.#motion?.removeEventListener?.("change", this.#onMotion);
|
|
79
|
+
this.#writer.cancel();
|
|
80
|
+
this.#ready = false;
|
|
81
|
+
}
|
|
82
|
+
attributeChangedCallback(name, _old, value) {
|
|
83
|
+
if (!this.#ready || this.#reflecting)
|
|
84
|
+
return;
|
|
85
|
+
if (name === "playing")
|
|
86
|
+
this.#play(value !== "off" && value !== null, "set");
|
|
87
|
+
else if (name === "repeat")
|
|
88
|
+
this.#loop(value !== "off" && value !== null, "set");
|
|
89
|
+
else if (name === "steps" || name === "speed")
|
|
90
|
+
this.#speeds();
|
|
91
|
+
else
|
|
92
|
+
this.#paint();
|
|
93
|
+
}
|
|
94
|
+
#reduced() {
|
|
95
|
+
return this.#motion?.matches === true;
|
|
96
|
+
}
|
|
97
|
+
#onMotion = () => {
|
|
98
|
+
if (this.#reduced() && this.#playing)
|
|
99
|
+
this.#play(false, "set");
|
|
100
|
+
else
|
|
101
|
+
this.#paint();
|
|
102
|
+
};
|
|
103
|
+
#play(next, reason) {
|
|
104
|
+
const on = next && !this.#reduced();
|
|
105
|
+
if (on === this.#playing) {
|
|
106
|
+
this.#paint();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.#playing = on;
|
|
110
|
+
this.#reflect("playing", on);
|
|
111
|
+
this.#paint();
|
|
112
|
+
this.#write();
|
|
113
|
+
this.#dispatch(on ? "play" : "pause", reason);
|
|
114
|
+
}
|
|
115
|
+
#loop(next, reason) {
|
|
116
|
+
if (next === this.#repeat)
|
|
117
|
+
return;
|
|
118
|
+
this.#repeat = next;
|
|
119
|
+
this.#reflect("repeat", next);
|
|
120
|
+
this.#paint();
|
|
121
|
+
this.#write();
|
|
122
|
+
this.#dispatch("repeat", reason);
|
|
123
|
+
}
|
|
124
|
+
#reflect(name, on) {
|
|
125
|
+
const have = this.hasAttribute(name) && this.getAttribute(name) !== "off";
|
|
126
|
+
if (have === on)
|
|
127
|
+
return;
|
|
128
|
+
this.#reflecting = true;
|
|
129
|
+
if (on)
|
|
130
|
+
this.setAttribute(name, "");
|
|
131
|
+
else
|
|
132
|
+
this.removeAttribute(name);
|
|
133
|
+
this.#reflecting = false;
|
|
134
|
+
}
|
|
135
|
+
#write() {
|
|
136
|
+
if (!this.hasAttribute("address"))
|
|
137
|
+
return;
|
|
138
|
+
const keys = addressKeys(this.getAttribute("address-keys"));
|
|
139
|
+
this.#writer.write((search) => applyParams(search, { [keys.play]: this.#playing ? "on" : null, [keys.repeat]: this.#repeat ? "on" : null }));
|
|
140
|
+
}
|
|
141
|
+
#speeds() {
|
|
142
|
+
const speeds = this.#root.querySelector("kazoku-speed-buttons");
|
|
143
|
+
if (!speeds)
|
|
144
|
+
return;
|
|
145
|
+
speeds.setAttribute("steps", this.getAttribute("steps") || DEFAULT_STEPS);
|
|
146
|
+
const speed = this.getAttribute("speed");
|
|
147
|
+
if (speed)
|
|
148
|
+
speeds.setAttribute("value", speed);
|
|
149
|
+
}
|
|
150
|
+
#render() {
|
|
151
|
+
const doc = this.ownerDocument;
|
|
152
|
+
const row = doc.createElement("div");
|
|
153
|
+
row.className = "row";
|
|
154
|
+
row.setAttribute("role", "group");
|
|
155
|
+
for (const act of ["play", "start", "repeat"]) {
|
|
156
|
+
const button = doc.createElement("button");
|
|
157
|
+
button.type = "button";
|
|
158
|
+
button.dataset.act = act;
|
|
159
|
+
button.setAttribute("part", act);
|
|
160
|
+
button.addEventListener("click", () => this.#press(act));
|
|
161
|
+
row.append(button);
|
|
162
|
+
}
|
|
163
|
+
const speeds = doc.createElement("kazoku-speed-buttons");
|
|
164
|
+
speeds.setAttribute("no-label", "");
|
|
165
|
+
speeds.setAttribute("steps", this.getAttribute("steps") || DEFAULT_STEPS);
|
|
166
|
+
if (this.hasAttribute("address")) {
|
|
167
|
+
speeds.setAttribute("address", "");
|
|
168
|
+
speeds.setAttribute("address-key", this.getAttribute("speed-key") || SPEED_KEY);
|
|
169
|
+
}
|
|
170
|
+
const speed = this.getAttribute("speed");
|
|
171
|
+
if (speed)
|
|
172
|
+
speeds.setAttribute("value", speed);
|
|
173
|
+
row.append(speeds);
|
|
174
|
+
this.#root.replaceChildren(row);
|
|
175
|
+
this.#paint();
|
|
176
|
+
}
|
|
177
|
+
#press(act) {
|
|
178
|
+
if (act === "play")
|
|
179
|
+
this.#play(!this.#playing, "user");
|
|
180
|
+
else if (act === "repeat")
|
|
181
|
+
this.#loop(!this.#repeat, "user");
|
|
182
|
+
else {
|
|
183
|
+
if (this.#playing) {
|
|
184
|
+
this.#playing = false;
|
|
185
|
+
this.#reflect("playing", false);
|
|
186
|
+
this.#write();
|
|
187
|
+
}
|
|
188
|
+
this.#paint();
|
|
189
|
+
this.#dispatch("start", "user");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
#paint() {
|
|
193
|
+
const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
|
|
194
|
+
const words = wordsFor(language, this.#words);
|
|
195
|
+
const row = this.#root.querySelector(".row");
|
|
196
|
+
if (!row)
|
|
197
|
+
return;
|
|
198
|
+
row.setAttribute("aria-label", this.getAttribute("label") || words.group);
|
|
199
|
+
const play = row.querySelector('[data-act="play"]');
|
|
200
|
+
const start = row.querySelector('[data-act="start"]');
|
|
201
|
+
const repeat = row.querySelector('[data-act="repeat"]');
|
|
202
|
+
if (play) {
|
|
203
|
+
play.textContent = this.#playing ? words.pause : words.play;
|
|
204
|
+
play.setAttribute("aria-pressed", String(this.#playing));
|
|
205
|
+
play.disabled = this.#reduced();
|
|
206
|
+
}
|
|
207
|
+
if (start)
|
|
208
|
+
start.textContent = words.start;
|
|
209
|
+
if (repeat) {
|
|
210
|
+
repeat.textContent = words.repeat;
|
|
211
|
+
repeat.setAttribute("aria-pressed", String(this.#repeat));
|
|
212
|
+
}
|
|
213
|
+
const speeds = row.querySelector("kazoku-speed-buttons");
|
|
214
|
+
speeds?.setAttribute("lang", language);
|
|
215
|
+
}
|
|
216
|
+
#dispatch(action, reason) {
|
|
217
|
+
this.dispatchEvent(new CustomEvent(EVENT, { detail: { action, playing: this.#playing, repeat: this.#repeat, reason }, bubbles: true, composed: true }));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { addressKeys, DEFAULT_STEPS, flagOn, PLAY_KEY, REPEAT_KEY, SPEED_KEY } from "./state.ts";
|
|
2
|
+
export { languageOf, WORDS, wordsFor, type Language, type ReplayWords } from "./words.ts";
|
|
3
|
+
export declare const EVENT = "kazoku-replay";
|
|
4
|
+
export declare const TAG = "kazoku-replay-bar";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The replay bar without the element: the address keys and the on/off reading. Nothing here touches the DOM, so it imports on a server.
|
|
2
|
+
export { addressKeys, DEFAULT_STEPS, flagOn, PLAY_KEY, REPEAT_KEY, SPEED_KEY } from "./state.js";
|
|
3
|
+
export { languageOf, WORDS, wordsFor } from "./words.js";
|
|
4
|
+
export const EVENT = "kazoku-replay";
|
|
5
|
+
export const TAG = "kazoku-replay-bar";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The address key for playing, unless `address-keys` names another. */
|
|
2
|
+
export declare const PLAY_KEY = "play";
|
|
3
|
+
/** The address key for the repeat loop, unless `address-keys` names another. */
|
|
4
|
+
export declare const REPEAT_KEY = "repeat";
|
|
5
|
+
/** The address key the bar's speed buttons use, so they do not share a view's `pace`. */
|
|
6
|
+
export declare const SPEED_KEY = "replay-speed";
|
|
7
|
+
/** A replay's usual speeds. A page sets its own with `steps`. */
|
|
8
|
+
export declare const DEFAULT_STEPS = "0.25 0.5 1 2 4";
|
|
9
|
+
/** Whether an address or attribute value means on. Anything else, including nothing, is off. */
|
|
10
|
+
export declare function flagOn(value: string | null | undefined): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The address keys for play and repeat. `play:p,repeat:r` renames either; an unknown name is ignored.
|
|
13
|
+
* @param spec The `address-keys` attribute.
|
|
14
|
+
* @returns The key for each choice.
|
|
15
|
+
*/
|
|
16
|
+
export declare function addressKeys(spec: string | null | undefined): {
|
|
17
|
+
play: string;
|
|
18
|
+
repeat: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The replay bar's own choices, with no DOM, so a server can read an address the same way the element does.
|
|
2
|
+
/** The address key for playing, unless `address-keys` names another. */
|
|
3
|
+
export const PLAY_KEY = "play";
|
|
4
|
+
/** The address key for the repeat loop, unless `address-keys` names another. */
|
|
5
|
+
export const REPEAT_KEY = "repeat";
|
|
6
|
+
/** The address key the bar's speed buttons use, so they do not share a view's `pace`. */
|
|
7
|
+
export const SPEED_KEY = "replay-speed";
|
|
8
|
+
/** A replay's usual speeds. A page sets its own with `steps`. */
|
|
9
|
+
export const DEFAULT_STEPS = "0.25 0.5 1 2 4";
|
|
10
|
+
/** Whether an address or attribute value means on. Anything else, including nothing, is off. */
|
|
11
|
+
export function flagOn(value) {
|
|
12
|
+
return value === "on" || value === "true" || value === "1";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The address keys for play and repeat. `play:p,repeat:r` renames either; an unknown name is ignored.
|
|
16
|
+
* @param spec The `address-keys` attribute.
|
|
17
|
+
* @returns The key for each choice.
|
|
18
|
+
*/
|
|
19
|
+
export function addressKeys(spec) {
|
|
20
|
+
const out = { play: PLAY_KEY, repeat: REPEAT_KEY };
|
|
21
|
+
for (const part of (spec ?? "").split(",")) {
|
|
22
|
+
const [name, key] = part.split(":").map((piece) => piece.trim());
|
|
23
|
+
if ((name === "play" || name === "repeat") && key)
|
|
24
|
+
out[name] = key;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
@@ -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: inline-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\"]):not([data-scheme=\"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\"]), :host([data-scheme=\"dark\"]: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 color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n[hidden] { display: none !important; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\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; }\n\n:host([hidden]) { display: none; }\n:host { display: flex; vertical-align: middle; }\n.row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); }\n";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { BASE_STYLES } from "../shared/base.js";
|
|
2
|
+
// One row: play, back to the start, repeat, then the speed buttons. Nothing folds away. A narrow page wraps the row; it does not scroll the page sideways.
|
|
3
|
+
export const STYLES = `${BASE_STYLES}
|
|
4
|
+
:host([hidden]) { display: none; }
|
|
5
|
+
:host { display: flex; vertical-align: middle; }
|
|
6
|
+
.row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
|
7
|
+
button[aria-pressed="true"] { background: var(--_ink); color: var(--_surface); }
|
|
8
|
+
`;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ReplayWords {
|
|
2
|
+
/** The name of the bar, said by a screen reader. */
|
|
3
|
+
group: string;
|
|
4
|
+
/** The button that starts playback. */
|
|
5
|
+
play: string;
|
|
6
|
+
/** The same button while playback is going. */
|
|
7
|
+
pause: string;
|
|
8
|
+
/** The button that goes back to the start. */
|
|
9
|
+
start: string;
|
|
10
|
+
/** The button that loops. */
|
|
11
|
+
repeat: string;
|
|
12
|
+
}
|
|
13
|
+
export type Language = "en" | "ja";
|
|
14
|
+
export declare const WORDS: Record<Language, ReplayWords>;
|
|
15
|
+
/** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
|
|
16
|
+
export declare const languageOf: (lang: string | null | undefined) => Language;
|
|
17
|
+
export declare const wordsFor: (language: Language, overrides?: Partial<ReplayWords>) => ReplayWords;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const WORDS = {
|
|
2
|
+
en: { group: "Playback", play: "Play", pause: "Pause", start: "Start", repeat: "Repeat" },
|
|
3
|
+
ja: { group: "再生の操作", play: "再生", pause: "一時停止", start: "最初へ", repeat: "繰り返し" },
|
|
4
|
+
};
|
|
5
|
+
/** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
|
|
6
|
+
export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
|
|
7
|
+
export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Direction } from "./levels.ts";
|
|
1
|
+
import { type Direction, type TurnAxis } from "./levels.ts";
|
|
2
2
|
import { type Limits } from "./math.ts";
|
|
3
3
|
export type DragMode = "move" | "turn";
|
|
4
4
|
export interface AddressKeys {
|
|
@@ -10,6 +10,14 @@ export interface AddressKeys {
|
|
|
10
10
|
speed?: string;
|
|
11
11
|
/** Which way Auto-turn goes, `left` or `right`; `direction` by default. */
|
|
12
12
|
direction?: string;
|
|
13
|
+
/** The roll, degrees. `roll` by default. */
|
|
14
|
+
roll?: string;
|
|
15
|
+
/** Which axis Auto-turn drives. `axis` by default. */
|
|
16
|
+
axis?: string;
|
|
17
|
+
/** Which way tilt's auto-turn goes. `tiltDirection` by default. */
|
|
18
|
+
tiltDirection?: string;
|
|
19
|
+
/** Which way roll's auto-turn goes. `rollDirection` by default. */
|
|
20
|
+
rollDirection?: string;
|
|
13
21
|
}
|
|
14
22
|
export declare const DEFAULT_KEYS: AddressKeys;
|
|
15
23
|
export interface AddressChoices {
|
|
@@ -21,6 +29,13 @@ export interface AddressChoices {
|
|
|
21
29
|
pace?: number;
|
|
22
30
|
/** The way Auto-turn goes. In `formatAddress`: a way is written; `null` leaves it off the address (it is the page's own start); left out, the key is not touched. */
|
|
23
31
|
direction?: Direction | null;
|
|
32
|
+
roll?: number;
|
|
33
|
+
/** Which axis Auto-turn drives. `angle` is the start and leaves the address. Left out, the key is not touched. */
|
|
34
|
+
axis?: TurnAxis | null;
|
|
35
|
+
/** Tilt's own way. `null` leaves it off the address; left out, the key is not touched. */
|
|
36
|
+
tiltDirection?: Direction | null;
|
|
37
|
+
/** Roll's own way. `null` leaves it off the address; left out, the key is not touched. */
|
|
38
|
+
rollDirection?: Direction | null;
|
|
24
39
|
}
|
|
25
40
|
/** Read the choices out of a query string (`?yaw=40`, with or without the `?`). Anything missing or not a number is left out of the result. */
|
|
26
41
|
export declare function parseAddress(search: string, keys?: AddressKeys, limits?: Limits): Partial<AddressChoices>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The choices kept in the page's address: pure string functions, so a server or a test can use them. Only what a person chose is kept
|
|
2
2
|
// (angle, tilt, zoom, what a drag does); the pan is in pixels of one screen and the turning of a moment, so neither is.
|
|
3
|
-
import { PACE_FACTOR, parseDirection, parsePace } from "./levels.js";
|
|
3
|
+
import { PACE_FACTOR, parseAxis, parseDirection, parsePace } from "./levels.js";
|
|
4
4
|
import { clamp, DEFAULT_LIMITS, wrapYaw } from "./math.js";
|
|
5
|
-
export const DEFAULT_KEYS = { yaw: "yaw", tilt: "tilt", zoom: "zoom", drag: "drag", speed: "pace", direction: "direction" };
|
|
5
|
+
export const DEFAULT_KEYS = { yaw: "yaw", tilt: "tilt", zoom: "zoom", drag: "drag", speed: "pace", direction: "direction", roll: "roll", axis: "axis", tiltDirection: "tiltDirection", rollDirection: "rollDirection" };
|
|
6
6
|
const rounded = (value, places) => Number(value.toFixed(places));
|
|
7
7
|
/** Read the choices out of a query string (`?yaw=40`, with or without the `?`). Anything missing or not a number is left out of the result. */
|
|
8
8
|
export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMITS) {
|
|
@@ -22,6 +22,12 @@ export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMIT
|
|
|
22
22
|
out.yaw = rounded(wrapYaw(yaw), 0);
|
|
23
23
|
if (tilt !== undefined)
|
|
24
24
|
out.tilt = rounded(clamp(tilt, -limits.tiltMax, limits.tiltMax), 0);
|
|
25
|
+
const roll = number(keys.roll ?? "roll");
|
|
26
|
+
if (roll !== undefined)
|
|
27
|
+
out.roll = rounded(clamp(wrapYaw(roll), -limits.rollMax, limits.rollMax), 0);
|
|
28
|
+
const axis = parseAxis(params.get(keys.axis ?? "axis"));
|
|
29
|
+
if (axis !== undefined)
|
|
30
|
+
out.axis = axis;
|
|
25
31
|
if (zoom !== undefined)
|
|
26
32
|
out.zoom = rounded(clamp(zoom, limits.zoomMin, limits.zoomMax), 2);
|
|
27
33
|
const drag = params.get(keys.drag);
|
|
@@ -36,6 +42,12 @@ export function parseAddress(search, keys = DEFAULT_KEYS, limits = DEFAULT_LIMIT
|
|
|
36
42
|
const direction = parseDirection(params.get(keys.direction ?? "direction"));
|
|
37
43
|
if (direction !== undefined)
|
|
38
44
|
out.direction = direction;
|
|
45
|
+
const tiltDirection = parseDirection(params.get(keys.tiltDirection ?? "tiltDirection"));
|
|
46
|
+
if (tiltDirection !== undefined)
|
|
47
|
+
out.tiltDirection = tiltDirection;
|
|
48
|
+
const rollDirection = parseDirection(params.get(keys.rollDirection ?? "rollDirection"));
|
|
49
|
+
if (rollDirection !== undefined)
|
|
50
|
+
out.rollDirection = rollDirection;
|
|
39
51
|
return out;
|
|
40
52
|
}
|
|
41
53
|
/**
|
|
@@ -50,9 +62,12 @@ export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = f
|
|
|
50
62
|
else
|
|
51
63
|
params.set(key, value);
|
|
52
64
|
};
|
|
53
|
-
if (!leaveYaw)
|
|
65
|
+
if (!leaveYaw) {
|
|
54
66
|
set(keys.yaw, Math.round(choices.yaw) === 0 ? undefined : String(rounded(wrapYaw(choices.yaw), 0)));
|
|
55
|
-
|
|
67
|
+
set(keys.tilt, Math.round(choices.tilt) === 0 ? undefined : String(rounded(choices.tilt, 0)));
|
|
68
|
+
if (choices.roll !== undefined)
|
|
69
|
+
set(keys.roll ?? "roll", Math.round(choices.roll) === 0 ? undefined : String(rounded(wrapYaw(choices.roll), 0)));
|
|
70
|
+
}
|
|
56
71
|
set(keys.zoom, Math.abs(choices.zoom - startZoom) < 0.005 ? undefined : String(rounded(choices.zoom, 2)));
|
|
57
72
|
set(keys.drag, choices.drag === "turn" ? "turn" : undefined);
|
|
58
73
|
// The pace key is touched only when the caller has a pace; the start (1) leaves it off the address.
|
|
@@ -61,6 +76,12 @@ export function formatAddress(search, choices, keys = DEFAULT_KEYS, leaveYaw = f
|
|
|
61
76
|
set(paceKey, choices.pace === 1 ? undefined : String(rounded(choices.pace, 3)));
|
|
62
77
|
if (choices.direction !== undefined)
|
|
63
78
|
set(keys.direction ?? "direction", choices.direction === null ? undefined : choices.direction);
|
|
79
|
+
if (choices.axis !== undefined)
|
|
80
|
+
set(keys.axis ?? "axis", choices.axis === null || choices.axis === "angle" ? undefined : choices.axis);
|
|
81
|
+
if (choices.tiltDirection !== undefined)
|
|
82
|
+
set(keys.tiltDirection ?? "tiltDirection", choices.tiltDirection === null || choices.tiltDirection === "right" ? undefined : choices.tiltDirection);
|
|
83
|
+
if (choices.rollDirection !== undefined)
|
|
84
|
+
set(keys.rollDirection ?? "rollDirection", choices.rollDirection === null || choices.rollDirection === "right" ? undefined : choices.rollDirection);
|
|
64
85
|
const text = params.toString();
|
|
65
86
|
return text === "" ? "" : `?${text}`;
|
|
66
87
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { DragMode } from "./address.ts";
|
|
2
|
-
import
|
|
2
|
+
import type { TurnAxis } from "./levels.ts";
|
|
3
|
+
import { type Camera, type Limits, type Point, type Quat, type Size } from "./math.ts";
|
|
3
4
|
export interface ViewState extends Camera {
|
|
5
|
+
/** The pose the page should draw. A tumble keeps this continuous when the slider numbers fold at a pole. */
|
|
6
|
+
orientation: Quat;
|
|
4
7
|
/** True while the view turns by itself. A control never changes this; only play, pause and a grab of the view's own do not either (see `holdTurn`). */
|
|
5
8
|
turning: boolean;
|
|
6
9
|
/** What a plain drag does while zoomed in: move the view, or turn the piece. */
|
|
@@ -24,8 +27,10 @@ export interface ViewControllerOptions {
|
|
|
24
27
|
initial?: Partial<ViewState>;
|
|
25
28
|
/** False for a flat view that cannot turn: a drag then always moves it and the angle and tilt never change. */
|
|
26
29
|
turnable?: boolean;
|
|
27
|
-
/** Degrees a second when it turns by itself. */
|
|
30
|
+
/** Degrees a second when it turns by itself. The sign is the active axis's direction. */
|
|
28
31
|
speed?: number;
|
|
32
|
+
/** Which axis turns by itself. `angle` (yaw) by default. */
|
|
33
|
+
turnAxis?: TurnAxis;
|
|
29
34
|
limits?: Partial<Limits>;
|
|
30
35
|
/** The starting zoom: "fit" (the default) or a scale where 1 is the scene's natural size; it opens at that scale divided by `fitScale`. With `fitScale` left at 1 a number is a zoom as `ViewState.zoom` has it. `initial.zoom` is used only when this is left out. */
|
|
31
36
|
zoom?: "fit" | number;
|
|
@@ -55,6 +60,12 @@ export interface ViewController {
|
|
|
55
60
|
readonly state: ViewState;
|
|
56
61
|
turnable: boolean;
|
|
57
62
|
speed: number;
|
|
63
|
+
/** Which axis `play` turns. `angle` is yaw. `all` tumbles on three axes. */
|
|
64
|
+
turnAxis: TurnAxis;
|
|
65
|
+
/** Tilt's own direction while `turnAxis` is `all` (and the sign of `speed` while it is `tilt`). 1 or -1. */
|
|
66
|
+
tiltSign: number;
|
|
67
|
+
/** Roll's own direction. 1 or -1. */
|
|
68
|
+
rollSign: number;
|
|
58
69
|
readonly limits: Limits;
|
|
59
70
|
/** What a pointer press leaves alone inside the view (see `ViewControllerOptions.ignore`). Can be changed at any time. */
|
|
60
71
|
ignore: string;
|