contour-deck 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.
- package/LICENSE +373 -0
- package/README.md +261 -2
- package/dist/astro/Deck.astro +68 -0
- package/dist/astro/host.d.ts +20 -0
- package/dist/astro/host.js +170 -0
- package/dist/astro/legacy.d.ts +22 -0
- package/dist/astro/legacy.js +42 -0
- package/dist/core/deck.d.ts +2 -0
- package/dist/core/deck.js +598 -0
- package/dist/core/dom.d.ts +8 -0
- package/dist/core/dom.js +22 -0
- package/dist/core/gestures.d.ts +6 -0
- package/dist/core/gestures.js +24 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +2 -0
- package/dist/core/labels.d.ts +2 -0
- package/dist/core/labels.js +20 -0
- package/dist/core/model.d.ts +37 -0
- package/dist/core/model.js +51 -0
- package/dist/core/navigator.d.ts +6 -0
- package/dist/core/navigator.js +12 -0
- package/dist/core/notes.d.ts +11 -0
- package/dist/core/notes.js +34 -0
- package/dist/core/overview.d.ts +11 -0
- package/dist/core/overview.js +104 -0
- package/dist/core/rail.d.ts +8 -0
- package/dist/core/rail.js +26 -0
- package/dist/core/stops.d.ts +17 -0
- package/dist/core/stops.js +40 -0
- package/dist/core/transitions.d.ts +9 -0
- package/dist/core/transitions.js +37 -0
- package/dist/core/types.d.ts +82 -0
- package/dist/core/types.js +1 -0
- package/dist/rehype/hast.d.ts +26 -0
- package/dist/rehype/hast.js +48 -0
- package/dist/rehype/index.d.ts +12 -0
- package/dist/rehype/index.js +221 -0
- package/dist/rehype/page.d.ts +2 -0
- package/dist/rehype/page.js +84 -0
- package/dist/rehype/reading.d.ts +11 -0
- package/dist/rehype/reading.js +9 -0
- package/dist/rehype/titles.d.ts +19 -0
- package/dist/rehype/titles.js +63 -0
- package/dist/remark/index.d.ts +20 -0
- package/dist/remark/index.js +117 -0
- package/dist/remote/clock.d.ts +35 -0
- package/dist/remote/clock.js +48 -0
- package/dist/remote/guest.d.ts +41 -0
- package/dist/remote/guest.js +223 -0
- package/dist/remote/index.d.ts +18 -0
- package/dist/remote/index.js +11 -0
- package/dist/remote/ink.d.ts +11 -0
- package/dist/remote/ink.js +74 -0
- package/dist/remote/labels.d.ts +72 -0
- package/dist/remote/labels.js +68 -0
- package/dist/remote/links.d.ts +2 -0
- package/dist/remote/links.js +12 -0
- package/dist/remote/overlay.d.ts +11 -0
- package/dist/remote/overlay.js +63 -0
- package/dist/remote/pairing.d.ts +40 -0
- package/dist/remote/pairing.js +97 -0
- package/dist/remote/panel.d.ts +38 -0
- package/dist/remote/panel.js +131 -0
- package/dist/remote/phone.d.ts +10 -0
- package/dist/remote/phone.js +407 -0
- package/dist/remote/presenter.d.ts +2 -0
- package/dist/remote/presenter.js +126 -0
- package/dist/remote/protocol.d.ts +106 -0
- package/dist/remote/protocol.js +123 -0
- package/dist/remote/qr.d.ts +8 -0
- package/dist/remote/qr.js +266 -0
- package/dist/remote/sensor.d.ts +9 -0
- package/dist/remote/sensor.js +50 -0
- package/dist/remote/session.d.ts +39 -0
- package/dist/remote/session.js +287 -0
- package/dist/remote/store.d.ts +9 -0
- package/dist/remote/store.js +27 -0
- package/dist/remote/style.d.ts +2 -0
- package/dist/remote/style.js +131 -0
- package/dist/remote/tilt.d.ts +78 -0
- package/dist/remote/tilt.js +148 -0
- package/dist/remote/transport.d.ts +35 -0
- package/dist/remote/transport.js +162 -0
- package/dist/remote/view.d.ts +48 -0
- package/dist/remote/view.js +119 -0
- package/dist/shared/markers.d.ts +15 -0
- package/dist/shared/markers.js +15 -0
- package/dist/style.css +629 -0
- package/docs/CONTRACT.md +178 -0
- package/docs/PROTOCOL.md +99 -0
- package/package.json +89 -4
- package/styles/base.css +3 -0
- package/styles/deck.css +151 -0
- package/styles/index.css +3 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The orientation sensor of the phone: permission (iOS asks, and only on a tap), events, and the screen's turn. */
|
|
2
|
+
import { aimOf } from "./tilt.js";
|
|
3
|
+
/** How long to wait for the first reading before deciding there is no sensor (a desktop browser). */
|
|
4
|
+
const FIRST_READING = 1500;
|
|
5
|
+
function screenAngle() {
|
|
6
|
+
const legacy = window.orientation;
|
|
7
|
+
return screen.orientation?.angle ?? (Number(legacy) || 0);
|
|
8
|
+
}
|
|
9
|
+
/** Calls `onAim` with the direction the phone points at, and the time in seconds, for every reading. */
|
|
10
|
+
export function createSensor(onAim) {
|
|
11
|
+
let abort = null;
|
|
12
|
+
return {
|
|
13
|
+
async start() {
|
|
14
|
+
if (typeof DeviceOrientationEvent === "undefined")
|
|
15
|
+
return "unsupported";
|
|
16
|
+
const gate = DeviceOrientationEvent;
|
|
17
|
+
if (gate.requestPermission) {
|
|
18
|
+
try {
|
|
19
|
+
// Only a refusal ends it here; without a decision, the wait for a first reading below tells.
|
|
20
|
+
if ((await gate.requestPermission()) === "denied")
|
|
21
|
+
return "denied";
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "denied";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
abort?.abort();
|
|
28
|
+
abort = new AbortController();
|
|
29
|
+
const { signal } = abort;
|
|
30
|
+
return new Promise(resolve => {
|
|
31
|
+
const timer = window.setTimeout(() => {
|
|
32
|
+
abort?.abort();
|
|
33
|
+
resolve("unsupported");
|
|
34
|
+
}, FIRST_READING);
|
|
35
|
+
addEventListener("deviceorientation", event => {
|
|
36
|
+
const { alpha, beta, gamma } = event;
|
|
37
|
+
if (alpha === null || beta === null || gamma === null)
|
|
38
|
+
return;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve("ok");
|
|
41
|
+
onAim(aimOf({ alpha, beta, gamma }, screenAngle()), event.timeStamp / 1000);
|
|
42
|
+
}, { signal });
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
stop() {
|
|
46
|
+
abort?.abort();
|
|
47
|
+
abort = null;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host side of the remote: one session per open deck. It owns the room, answers pairing,
|
|
3
|
+
* runs what the granted phone and the presenter window ask for, and tells them where the deck is.
|
|
4
|
+
*/
|
|
5
|
+
import type { Deck } from "../core/index.js";
|
|
6
|
+
import type { RemoteLabels } from "./labels.js";
|
|
7
|
+
import type { QrEncoder } from "./panel.js";
|
|
8
|
+
export interface RemoteOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Base URL of the relay, `wss://…` or `https://…` (see docs/PROTOCOL.md). Without it the remote stays
|
|
11
|
+
* on this computer: the presenter window and the notes link work, a phone cannot control the deck.
|
|
12
|
+
*/
|
|
13
|
+
relay?: string;
|
|
14
|
+
/** Draws a QR code as an SVG string. Default: the built-in encoder, for links up to 287 bytes. */
|
|
15
|
+
qr?: QrEncoder;
|
|
16
|
+
/** Block kinds outside the talk, whose minutes are not planned time. Default `["appendix"]`, as `linear.skip` of the deck. */
|
|
17
|
+
skip?: string[];
|
|
18
|
+
labels?: Partial<RemoteLabels>;
|
|
19
|
+
/** Nonce for the injected `<style>`, for a page with a `style-src` policy. */
|
|
20
|
+
nonce?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RemoteHost {
|
|
23
|
+
/** The room of this session: the name of the relay room and of the BroadcastChannel. */
|
|
24
|
+
readonly room: string;
|
|
25
|
+
/** Whether the panel is showing. */
|
|
26
|
+
readonly open: boolean;
|
|
27
|
+
/** Shows or hides the panel; without an argument, the other state. */
|
|
28
|
+
toggle(open?: boolean): void;
|
|
29
|
+
/** Ends the session for good: control is taken back, the room closes, and the next session gets a new room. */
|
|
30
|
+
end(): void;
|
|
31
|
+
/** Closes the connections and removes the overlays. The room and the token stay in `sessionStorage`, so the next session in this tab continues. */
|
|
32
|
+
destroy(): void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Starts the remote for an open deck, or returns the session it already has.
|
|
36
|
+
* `root` is the deck element given to `createDeck`. It resolves once the room is known: the room is
|
|
37
|
+
* the hash of the session's key, which takes `crypto.subtle`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function remoteHost(deck: Deck, root: HTMLElement, options?: RemoteOptions): Promise<RemoteHost>;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { readModel } from "../core/model.js";
|
|
2
|
+
import { createClock, plannedSeconds } from "./clock.js";
|
|
3
|
+
import { defaultRemoteLabels } from "./labels.js";
|
|
4
|
+
import { pageLink } from "./links.js";
|
|
5
|
+
import { createOverlay } from "./overlay.js";
|
|
6
|
+
import { createPairing } from "./pairing.js";
|
|
7
|
+
import { createPanel } from "./panel.js";
|
|
8
|
+
import { guestMessage, isRoom, newKey, roomOf } from "./protocol.js";
|
|
9
|
+
import { hostKey, load, save } from "./store.js";
|
|
10
|
+
import { injectStyle } from "./style.js";
|
|
11
|
+
import { broadcast, relay } from "./transport.js";
|
|
12
|
+
function stored() {
|
|
13
|
+
const value = load(hostKey());
|
|
14
|
+
if (typeof value !== "object" || value === null)
|
|
15
|
+
return null;
|
|
16
|
+
const { key, token, clock } = value;
|
|
17
|
+
if (!isRoom(key))
|
|
18
|
+
return null;
|
|
19
|
+
const state = clock;
|
|
20
|
+
const usable = !!state && typeof state === "object" && typeof state.spent === "object" && state.spent !== null;
|
|
21
|
+
return { key, token: typeof token === "string" ? token : null, clock: usable ? state : null };
|
|
22
|
+
}
|
|
23
|
+
/** A request shows once it is this old: a phone that resumes with its token in that time never flashes one. */
|
|
24
|
+
const SETTLE = 300;
|
|
25
|
+
/** `state` goes out on every change and this often between changes. */
|
|
26
|
+
const STATE_EVERY = 15;
|
|
27
|
+
const hosts = new WeakMap();
|
|
28
|
+
/**
|
|
29
|
+
* Starts the remote for an open deck, or returns the session it already has.
|
|
30
|
+
* `root` is the deck element given to `createDeck`. It resolves once the room is known: the room is
|
|
31
|
+
* the hash of the session's key, which takes `crypto.subtle`.
|
|
32
|
+
*/
|
|
33
|
+
export function remoteHost(deck, root, options = {}) {
|
|
34
|
+
const known = hosts.get(deck);
|
|
35
|
+
if (known)
|
|
36
|
+
return known;
|
|
37
|
+
const app = root.closest(".ct-app");
|
|
38
|
+
const stage = app?.querySelector(".ct-stage");
|
|
39
|
+
const viewport = app?.querySelector(".ct-vp");
|
|
40
|
+
if (!app || !stage || !viewport)
|
|
41
|
+
return Promise.reject(new Error("contour-deck: remoteHost needs an open deck"));
|
|
42
|
+
const before = stored();
|
|
43
|
+
const key = before?.key ?? newKey();
|
|
44
|
+
let closed = false;
|
|
45
|
+
const stop = deck.on("destroy", () => (closed = true));
|
|
46
|
+
// Without a relay the room is only the name of a BroadcastChannel; a page without `crypto.subtle` (plain http
|
|
47
|
+
// off localhost) then uses the key itself, which nothing outside this browser ever sees.
|
|
48
|
+
const room = options.relay || globalThis.crypto?.subtle ? roomOf(key) : Promise.resolve(key);
|
|
49
|
+
const pending = room
|
|
50
|
+
.then(room => {
|
|
51
|
+
if (closed)
|
|
52
|
+
throw new Error("contour-deck: the deck closed before its remote started");
|
|
53
|
+
return start(deck, root, options, { app, stage, viewport }, { key, room, before });
|
|
54
|
+
})
|
|
55
|
+
.finally(stop);
|
|
56
|
+
hosts.set(deck, pending);
|
|
57
|
+
pending.catch(() => hosts.delete(deck));
|
|
58
|
+
return pending;
|
|
59
|
+
}
|
|
60
|
+
function start(deck, root, options, { app, stage, viewport }, { key, room, before }) {
|
|
61
|
+
injectStyle(options.nonce);
|
|
62
|
+
const labels = { ...defaultRemoteLabels, ...options.labels };
|
|
63
|
+
const blocks = readModel(root, root.dataset.title || document.title);
|
|
64
|
+
const total = plannedSeconds(blocks, options.skip ?? ["appendix"]);
|
|
65
|
+
const clock = createClock(before?.clock);
|
|
66
|
+
const overlay = createOverlay(stage);
|
|
67
|
+
const local = broadcast(room);
|
|
68
|
+
// The key goes to the relay only; the links and the QR carry the room.
|
|
69
|
+
const wan = options.relay ? relay(options.relay, { room, role: "host", key }) : null;
|
|
70
|
+
const barButton = app.querySelector('[data-ct-act="remote"]');
|
|
71
|
+
let alive = true;
|
|
72
|
+
let last = deck.state;
|
|
73
|
+
let seated = null;
|
|
74
|
+
let ticks = 0;
|
|
75
|
+
const pairing = createPairing({
|
|
76
|
+
send: message => wan?.send(message),
|
|
77
|
+
token: before?.token,
|
|
78
|
+
changed() {
|
|
79
|
+
persist();
|
|
80
|
+
// A new holder of control learns where the deck is at once; when it leaves, its dot goes too.
|
|
81
|
+
if (pairing.controller !== seated) {
|
|
82
|
+
seated = pairing.controller;
|
|
83
|
+
if (seated)
|
|
84
|
+
sendState();
|
|
85
|
+
else
|
|
86
|
+
overlay.hide();
|
|
87
|
+
}
|
|
88
|
+
refresh();
|
|
89
|
+
setTimeout(refresh, SETTLE + 20);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const persist = () => save(hostKey(), { key, token: pairing.token, clock: clock.state });
|
|
93
|
+
function state() {
|
|
94
|
+
const { mode, block, slide, page, step } = deck.state;
|
|
95
|
+
const current = blocks[block - 1];
|
|
96
|
+
const reading = clock.read(Date.now());
|
|
97
|
+
return {
|
|
98
|
+
t: "state",
|
|
99
|
+
mode: mode === "stage" ? "stage" : "overview",
|
|
100
|
+
at: `${block}.${slide}.${page}`,
|
|
101
|
+
step,
|
|
102
|
+
steps: current.slides[slide - 1].pages[page - 1].steps.length,
|
|
103
|
+
elapsed: reading.elapsed,
|
|
104
|
+
total,
|
|
105
|
+
blockElapsed: reading.blockElapsed,
|
|
106
|
+
blockMinutes: current.minutes || null,
|
|
107
|
+
ratio: Math.round((stage.clientWidth / (stage.clientHeight || 1)) * 1000) / 1000,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** To the presenter window, and to the phone that holds control; nobody else is told where the deck is. */
|
|
111
|
+
function sendState() {
|
|
112
|
+
const message = state();
|
|
113
|
+
local.send(message);
|
|
114
|
+
if (pairing.controller)
|
|
115
|
+
wan?.send({ ...message, to: pairing.controller });
|
|
116
|
+
}
|
|
117
|
+
const commands = {
|
|
118
|
+
next: () => deck.next(),
|
|
119
|
+
prev: () => deck.prev(),
|
|
120
|
+
slideNext: () => deck.slideBy(1),
|
|
121
|
+
slidePrev: () => deck.slideBy(-1),
|
|
122
|
+
pageNext: () => deck.pageBy(1),
|
|
123
|
+
pagePrev: () => deck.pageBy(-1),
|
|
124
|
+
overview: () => deck.overview(),
|
|
125
|
+
open: at => at && deck.open(at),
|
|
126
|
+
};
|
|
127
|
+
/** Runs a message from a source that may control the deck. */
|
|
128
|
+
function act(message, source) {
|
|
129
|
+
if (message.t === "cmd")
|
|
130
|
+
void commands[message.c](message.at);
|
|
131
|
+
else if (message.t === "ptr")
|
|
132
|
+
overlay.point(message.x, message.y, message.on);
|
|
133
|
+
else if (message.t === "ink")
|
|
134
|
+
overlay.ink(`${source}:${message.s}`, message.pts);
|
|
135
|
+
else if (message.t === "ink-clear")
|
|
136
|
+
overlay.clear();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* From the relay. The relay stamps `from` on everything a guest sends, so a frame with `from` is a guest's,
|
|
140
|
+
* whatever its `t` claims, and a frame without it is the relay's own (`hello`, `join`, `leave`).
|
|
141
|
+
*/
|
|
142
|
+
function fromRelay(message) {
|
|
143
|
+
const from = message.from;
|
|
144
|
+
if (from === undefined) {
|
|
145
|
+
const id = typeof message.id === "string" && message.id.length <= 32 ? message.id : "";
|
|
146
|
+
if (message.t === "join" && id)
|
|
147
|
+
pairing.join(id);
|
|
148
|
+
else if (message.t === "leave" && id)
|
|
149
|
+
pairing.leave(id);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const parsed = typeof from === "string" ? guestMessage(message) : null;
|
|
153
|
+
if (!parsed || typeof from !== "string")
|
|
154
|
+
return;
|
|
155
|
+
if (parsed.t === "resume")
|
|
156
|
+
return void pairing.resume(from, parsed.token);
|
|
157
|
+
if (!pairing.controls(from))
|
|
158
|
+
return;
|
|
159
|
+
if (parsed.t === "ping")
|
|
160
|
+
wan?.send({ t: "pong", to: from });
|
|
161
|
+
else
|
|
162
|
+
act(parsed, from);
|
|
163
|
+
}
|
|
164
|
+
/** From a window of this browser on the room's channel: trusted, no pairing. */
|
|
165
|
+
function fromLocal(message) {
|
|
166
|
+
const parsed = guestMessage(message);
|
|
167
|
+
if (!parsed || parsed.t === "resume")
|
|
168
|
+
return;
|
|
169
|
+
if (parsed.t === "ping") {
|
|
170
|
+
local.send({ t: "pong" });
|
|
171
|
+
local.send(state());
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
act(parsed, "local");
|
|
175
|
+
}
|
|
176
|
+
const panel = createPanel({
|
|
177
|
+
parent: viewport,
|
|
178
|
+
labels,
|
|
179
|
+
qr: options.qr ?? (text => import("./qr.js").then(module => module.qrSvg(text))),
|
|
180
|
+
remoteLink: wan ? pageLink("remote", room) : null,
|
|
181
|
+
notesLink: pageLink("notes"),
|
|
182
|
+
allow: id => pairing.allow(id),
|
|
183
|
+
deny: id => pairing.deny(id),
|
|
184
|
+
revoke: () => pairing.revoke(),
|
|
185
|
+
presenter: () => void window.open(pageLink("presenter", room), "contour-deck-presenter", "popup,width=1180,height=760"),
|
|
186
|
+
resetTimer() {
|
|
187
|
+
clock.reset();
|
|
188
|
+
persist();
|
|
189
|
+
sendState();
|
|
190
|
+
refresh();
|
|
191
|
+
},
|
|
192
|
+
end: () => api.end(),
|
|
193
|
+
close: () => api.toggle(false),
|
|
194
|
+
});
|
|
195
|
+
function refresh() {
|
|
196
|
+
if (!alive)
|
|
197
|
+
return;
|
|
198
|
+
const settled = Date.now() - SETTLE;
|
|
199
|
+
const requests = pairing.requests.filter(request => request.at <= settled);
|
|
200
|
+
panel.update({
|
|
201
|
+
relay: wan?.status ?? null,
|
|
202
|
+
code: wan?.code ?? 0,
|
|
203
|
+
requests,
|
|
204
|
+
paired: pairing.controller ? "online" : pairing.token ? "offline" : "no",
|
|
205
|
+
elapsed: clock.read(Date.now()).elapsed,
|
|
206
|
+
});
|
|
207
|
+
// With the panel closed, a waiting phone shows as a count on the bar button; nothing opens by itself.
|
|
208
|
+
if (requests.length) {
|
|
209
|
+
barButton?.setAttribute("data-ct-count", String(requests.length));
|
|
210
|
+
barButton?.setAttribute("aria-label", labels.waiting(requests.length));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
barButton?.removeAttribute("data-ct-count");
|
|
214
|
+
barButton?.removeAttribute("aria-label");
|
|
215
|
+
}
|
|
216
|
+
barButton?.setAttribute("aria-pressed", String(panel.open));
|
|
217
|
+
}
|
|
218
|
+
const stops = [
|
|
219
|
+
deck.on("change", change => {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
// The talk starts with the first move on the stage, not with opening a slide from the outline.
|
|
222
|
+
if (clock.running)
|
|
223
|
+
clock.enter(change.block - 1, now);
|
|
224
|
+
else if (last.mode === "stage" && change.mode === "stage")
|
|
225
|
+
clock.start(change.block - 1, now);
|
|
226
|
+
if (change.mode !== last.mode || change.block !== last.block || change.slide !== last.slide || change.page !== last.page)
|
|
227
|
+
overlay.clear();
|
|
228
|
+
last = { ...change };
|
|
229
|
+
persist();
|
|
230
|
+
sendState();
|
|
231
|
+
refresh();
|
|
232
|
+
}),
|
|
233
|
+
deck.on("destroy", () => api.destroy()),
|
|
234
|
+
local.subscribe(fromLocal),
|
|
235
|
+
wan?.subscribe(fromRelay),
|
|
236
|
+
wan?.watch(status => {
|
|
237
|
+
// Without the relay nobody is connected; the token stays, so the phone resumes when the room is back.
|
|
238
|
+
if (status !== "open")
|
|
239
|
+
pairing.offline();
|
|
240
|
+
refresh();
|
|
241
|
+
}),
|
|
242
|
+
];
|
|
243
|
+
const interval = window.setInterval(() => {
|
|
244
|
+
pairing.expire();
|
|
245
|
+
if (++ticks % STATE_EVERY === 0)
|
|
246
|
+
sendState();
|
|
247
|
+
if (panel.open)
|
|
248
|
+
refresh();
|
|
249
|
+
}, 1000);
|
|
250
|
+
const api = {
|
|
251
|
+
room,
|
|
252
|
+
get open() {
|
|
253
|
+
return panel.open;
|
|
254
|
+
},
|
|
255
|
+
toggle(open = !panel.open) {
|
|
256
|
+
if (!alive)
|
|
257
|
+
return;
|
|
258
|
+
panel.show(open);
|
|
259
|
+
refresh();
|
|
260
|
+
},
|
|
261
|
+
end() {
|
|
262
|
+
if (!alive)
|
|
263
|
+
return;
|
|
264
|
+
pairing.revoke();
|
|
265
|
+
api.destroy();
|
|
266
|
+
save(hostKey(), null);
|
|
267
|
+
},
|
|
268
|
+
destroy() {
|
|
269
|
+
if (!alive)
|
|
270
|
+
return;
|
|
271
|
+
alive = false;
|
|
272
|
+
clearInterval(interval);
|
|
273
|
+
stops.forEach(stop => stop?.());
|
|
274
|
+
local.close();
|
|
275
|
+
wan?.close();
|
|
276
|
+
overlay.destroy();
|
|
277
|
+
panel.destroy();
|
|
278
|
+
for (const name of ["data-ct-count", "aria-label", "aria-pressed"])
|
|
279
|
+
barButton?.removeAttribute(name);
|
|
280
|
+
hosts.delete(deck);
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
persist();
|
|
284
|
+
sendState();
|
|
285
|
+
refresh();
|
|
286
|
+
return api;
|
|
287
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** What survives a reload, in `sessionStorage`: one tab, one talk. Storage may be unavailable; then nothing is kept. */
|
|
2
|
+
/** The host's room, token, and clock, per page. Its presence means "the remote was on in this tab". */
|
|
3
|
+
export declare const hostKey: () => string;
|
|
4
|
+
/** The guest's resume token for a room. */
|
|
5
|
+
export declare const guestKey: (room: string) => string;
|
|
6
|
+
/** The tilt calibration of this session. */
|
|
7
|
+
export declare const TILT_KEY = "contour-deck:tilt";
|
|
8
|
+
export declare function load(key: string): unknown;
|
|
9
|
+
export declare function save(key: string, value: unknown): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** What survives a reload, in `sessionStorage`: one tab, one talk. Storage may be unavailable; then nothing is kept. */
|
|
2
|
+
/** The host's room, token, and clock, per page. Its presence means "the remote was on in this tab". */
|
|
3
|
+
export const hostKey = () => `contour-deck:host:${location.pathname}`;
|
|
4
|
+
/** The guest's resume token for a room. */
|
|
5
|
+
export const guestKey = (room) => `contour-deck:guest:${room}`;
|
|
6
|
+
/** The tilt calibration of this session. */
|
|
7
|
+
export const TILT_KEY = "contour-deck:tilt";
|
|
8
|
+
export function load(key) {
|
|
9
|
+
try {
|
|
10
|
+
const text = sessionStorage.getItem(key);
|
|
11
|
+
return text === null ? null : JSON.parse(text);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function save(key, value) {
|
|
18
|
+
try {
|
|
19
|
+
if (value === null)
|
|
20
|
+
sessionStorage.removeItem(key);
|
|
21
|
+
else
|
|
22
|
+
sessionStorage.setItem(key, JSON.stringify(value));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Private mode or a full quota: the session works, it only does not survive a reload.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles of the remote, injected with the module so a page pays for them only when the remote loads.
|
|
3
|
+
* They build on contour-deck/style.css (`.ct-app`, `.ct-btn`, `.ct-badge`, `.ct-meta`, the `--ct-*` variables)
|
|
4
|
+
* and go first in <head>, so a host's own rules win a tie.
|
|
5
|
+
*/
|
|
6
|
+
const css = `
|
|
7
|
+
.ct-btn[data-ct-act="remote"][data-ct-count]::after { content: attr(data-ct-count); content: attr(data-ct-count) / ""; margin-left: .4em; padding: .1em .4em; background: var(--ct-accent); color: var(--ct-accent-fg); }
|
|
8
|
+
|
|
9
|
+
/* ---- over the stage: laser dot and ink ---- */
|
|
10
|
+
.ct-ink { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; color: var(--ct-accent); }
|
|
11
|
+
.ct-laser { position: absolute; left: 0; top: 0; z-index: 6; width: 0; height: 0; pointer-events: none; opacity: 0; transition: opacity .28s; }
|
|
12
|
+
.ct-laser::after { content: ""; position: absolute; width: clamp(12px, 1.6cqw, 28px); aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 50%; background: #ff2d1a; box-shadow: 0 0 0 2px rgb(255 255 255 / .85), 0 0 14px 5px rgb(255 45 26 / .55); }
|
|
13
|
+
.ct-laser[data-on] { opacity: 1; }
|
|
14
|
+
|
|
15
|
+
/* ---- the panel on the deck ---- */
|
|
16
|
+
.ct-rp { position: absolute; top: .6rem; right: .6rem; z-index: 9; width: min(23rem, calc(100% - 1.2rem)); max-height: calc(100% - 1.2rem); overflow: auto; display: flex; flex-direction: column; gap: .7rem; padding: .8rem; background: var(--ct-bg); border: 1px solid var(--ct-border); outline: none; font-size: .86rem; }
|
|
17
|
+
.ct-app .ct-rp:focus-visible { outline: none; }
|
|
18
|
+
:is(.ct-rp, .ct-rmt, .ct-pr) [hidden], .ct-rp[hidden] { display: none !important; }
|
|
19
|
+
.ct-rp-head { display: flex; align-items: center; gap: .5rem; }
|
|
20
|
+
.ct-rp h2 { margin: 0 auto 0 0; font-size: 1rem; font-weight: 800; }
|
|
21
|
+
.ct-rp h3 { margin: 0; font-size: .86rem; font-weight: 800; }
|
|
22
|
+
.ct-rp p { margin: 0; color: var(--ct-muted); line-height: 1.5; }
|
|
23
|
+
.ct-rp-ask { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .6rem; padding: .6rem .7rem; background: var(--ct-tint); }
|
|
24
|
+
.ct-rp-ask h3 { flex-basis: 100%; }
|
|
25
|
+
.ct-rp-code { font: 800 1.9rem/1 var(--ct-mono); letter-spacing: .14em; margin-right: auto; }
|
|
26
|
+
.ct-rp-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
|
27
|
+
.ct-rp-row > :last-child { margin-left: auto; }
|
|
28
|
+
.ct-rp-qrs { display: grid; grid-template-columns: 1fr 1fr; gap: .7rem; }
|
|
29
|
+
.ct-rp-qr { margin: 0; display: flex; flex-direction: column; gap: .35rem; min-width: 0; }
|
|
30
|
+
.ct-rp-img { aspect-ratio: 1; background: #fff; border: 1px solid var(--ct-border); }
|
|
31
|
+
.ct-rp-img svg { display: block; width: 100%; height: 100%; }
|
|
32
|
+
.ct-rp-url { font: .62rem/1.35 var(--ct-mono); color: var(--ct-muted); overflow-wrap: anywhere; }
|
|
33
|
+
.ct-rp-time { font: 700 1rem/1 var(--ct-mono); }
|
|
34
|
+
.ct-app .ct-btn[data-ct-primary], .ct-app .ct-btn[data-ct-primary]:hover { background: var(--ct-accent); color: var(--ct-accent-fg); border-color: var(--ct-accent); }
|
|
35
|
+
/* A touch screen keeps :hover on the last button tapped; there the tint would stay on it. */
|
|
36
|
+
@media (hover: none) { .ct-app .ct-btn:not([data-ct-primary], [aria-pressed="true"]):hover { background: var(--ct-bg); } }
|
|
37
|
+
|
|
38
|
+
/* ---- a page of the deck drawn small: the same rules as the stage, on a 1280 px wide sheet scaled down ---- */
|
|
39
|
+
.ct-pv { position: relative; overflow: hidden; aspect-ratio: var(--ct-ratio, 16 / 9); background: var(--ct-bg); border: 1px solid var(--ct-border); }
|
|
40
|
+
.ct-app .ct-pv > .ct-stage { inset: auto; left: 0; top: 0; width: 1280px; height: calc(1280px / var(--ct-ratio, 16 / 9)); transform: scale(var(--ct-k, .25)); transform-origin: 0 0; border: 0; z-index: 0; visibility: visible; pointer-events: none; }
|
|
41
|
+
.ct-pvw { height: 100%; overflow: hidden; }
|
|
42
|
+
|
|
43
|
+
/* ---- the phone ---- */
|
|
44
|
+
.ct-app.ct-rmt { display: flex; flex-direction: column; grid-template-rows: none; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); font-size: 1rem; -webkit-user-select: none; user-select: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
|
|
45
|
+
.ct-rmt .ct-btn { min-height: 3rem; font-size: .8rem; padding: .5rem .3rem; touch-action: manipulation; }
|
|
46
|
+
.ct-rmt-top { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .6rem; padding: .55rem .8rem; background: var(--ct-panel); }
|
|
47
|
+
.ct-rmt-st[data-st="granted"] { background: var(--ct-fg); color: var(--ct-bg); }
|
|
48
|
+
.ct-rmt-st[data-st="offline"] { background: var(--ct-accent); color: var(--ct-accent-fg); }
|
|
49
|
+
.ct-rmt-pos { font: 650 .74rem/1 var(--ct-mono); }
|
|
50
|
+
.ct-rmt-time { margin-left: auto; font: 800 1.15rem/1 var(--ct-mono); }
|
|
51
|
+
.ct-rmt-time[data-over], .ct-rmt-blk b[data-over] { color: var(--ct-accent); }
|
|
52
|
+
.ct-rmt-blk { flex-basis: 100%; display: flex; gap: .5rem; font-size: .78rem; color: var(--ct-muted); min-width: 0; }
|
|
53
|
+
.ct-rmt-blk span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: auto; }
|
|
54
|
+
.ct-rmt-blk b { font: 650 .78rem/1.5 var(--ct-mono); color: var(--ct-fg); white-space: nowrap; }
|
|
55
|
+
.ct-rmt-tabs { display: grid; grid-template-columns: repeat(4, 1fr); gap: .35rem; padding: .55rem .8rem 0; }
|
|
56
|
+
.ct-rmt-main { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: .6rem; padding: .7rem .8rem; overflow: hidden; }
|
|
57
|
+
.ct-rmt-notes { flex: 1; min-height: 0; overflow: auto; font-size: 1.22rem; line-height: 1.75; -webkit-user-select: text; user-select: text; overscroll-behavior: contain; }
|
|
58
|
+
.ct-rmt-notes p { margin: 0 0 .8em; }
|
|
59
|
+
.ct-rmt-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .4rem; }
|
|
60
|
+
.ct-rmt-list .ct-btn { width: 100%; display: flex; align-items: baseline; gap: .6rem; text-align: left; font: inherit; font-size: 1rem; font-weight: 700; letter-spacing: 0; padding: .7rem .8rem; white-space: normal; }
|
|
61
|
+
.ct-rmt-list .ct-btn[aria-current="true"] { background: var(--ct-tint); }
|
|
62
|
+
.ct-rmt-list .ct-btn > :nth-child(3) { margin-left: auto; }
|
|
63
|
+
.ct-rmt-padbox { flex: 1; min-height: 0; display: grid; place-items: end center; container-type: size; }
|
|
64
|
+
.ct-rmt-pad { position: relative; width: min(100cqw, 100cqh * var(--ct-ratio, 16 / 9)); touch-action: none; cursor: crosshair; }
|
|
65
|
+
.ct-rmt-pad .ct-ink { z-index: 2; }
|
|
66
|
+
.ct-rmt-dot { position: absolute; left: 0; top: 0; z-index: 3; width: 18px; height: 18px; margin: -9px; border-radius: 50%; background: #ff2d1a; box-shadow: 0 0 0 2px #fff; pointer-events: none; opacity: 0; }
|
|
67
|
+
.ct-rmt-dot[data-on] { opacity: 1; }
|
|
68
|
+
.ct-rmt-dot[data-target] { opacity: 1; background: none; box-shadow: 0 0 0 3px var(--ct-accent), 0 0 0 6px var(--ct-bg); width: 30px; height: 30px; margin: -15px; }
|
|
69
|
+
.ct-rmt-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
|
70
|
+
.ct-rmt-tools p { margin: 0; flex: 1 1 100%; font-size: .82rem; line-height: 1.5; color: var(--ct-muted); }
|
|
71
|
+
.ct-rmt-tools .ct-btn { flex: 1; }
|
|
72
|
+
.ct-rmt-tools .ct-rmt-hold { flex: 1 1 100%; min-height: 5rem; font-size: 1.1rem; }
|
|
73
|
+
.ct-rmt .ct-rmt-hold[aria-pressed="true"] { background: var(--ct-accent); color: var(--ct-accent-fg); border-color: var(--ct-accent); }
|
|
74
|
+
.ct-rmt-next { display: flex; align-items: baseline; gap: .5rem; padding: .5rem .8rem; background: var(--ct-panel); font-size: .88rem; min-width: 0; }
|
|
75
|
+
.ct-rmt-next span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
76
|
+
.ct-rmt-nav { display: grid; grid-template-columns: repeat(4, 1fr); gap: .35rem; padding: .55rem .8rem; }
|
|
77
|
+
.ct-rmt .ct-rmt-go { margin: 0 .8rem .8rem; min-height: 5.4rem; font-size: 1.5rem; letter-spacing: .12em; }
|
|
78
|
+
.ct-rmt-card { margin: auto; padding: 1.5rem 1.2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem; text-align: center; max-width: 24rem; }
|
|
79
|
+
.ct-rmt-card p { margin: 0; line-height: 1.6; }
|
|
80
|
+
.ct-rmt-card .ct-btn { min-width: 12rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
|
|
81
|
+
.ct-rmt-code { font: 800 4.6rem/1 var(--ct-mono); letter-spacing: .12em; padding-left: .12em; }
|
|
82
|
+
.ct-rmt:not([data-ct-phase="granted"]) > :not(.ct-rmt-card), .ct-rmt[data-ct-phase="granted"] > .ct-rmt-card { display: none; }
|
|
83
|
+
.ct-rmt[data-ct-tab="notes"] :is(.ct-rmt-padbox, .ct-rmt-tools), .ct-rmt:not([data-ct-tab="notes"]) .ct-rmt-notes { display: none; }
|
|
84
|
+
.ct-rmt-tools > [data-for]:not([data-show]) { display: none; }
|
|
85
|
+
/* A phone on its side: the page and the tools on the left, the step buttons on the right. */
|
|
86
|
+
@media (orientation: landscape) and (max-height: 520px) {
|
|
87
|
+
.ct-app.ct-rmt[data-ct-phase="granted"] { display: grid; grid-template: "top top" auto "tabs next" auto "main nav" minmax(0, 1fr) "main go" auto / minmax(0, 3fr) minmax(0, 2fr); }
|
|
88
|
+
.ct-rmt-top { grid-area: top; padding-block: .35rem; }
|
|
89
|
+
.ct-rmt-blk { flex-basis: auto; flex: 1; order: 1; }
|
|
90
|
+
.ct-rmt-time { order: 2; }
|
|
91
|
+
.ct-rmt-tabs { grid-area: tabs; }
|
|
92
|
+
.ct-rmt-main { grid-area: main; flex-direction: row; }
|
|
93
|
+
.ct-rmt-tools { flex: 0 0 38%; align-content: start; }
|
|
94
|
+
.ct-rmt-next { grid-area: next; margin: .55rem .8rem 0 0; }
|
|
95
|
+
.ct-rmt-nav { grid-area: nav; grid-template-columns: 1fr 1fr; padding-left: 0; align-content: start; }
|
|
96
|
+
.ct-rmt .ct-rmt-go { grid-area: go; margin-left: 0; min-height: 4.2rem; }
|
|
97
|
+
.ct-rmt .ct-rmt-tools .ct-rmt-hold { min-height: 3.6rem; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ---- the presenter window ---- */
|
|
101
|
+
.ct-app.ct-pr { display: grid; grid-template: "top top" auto "now notes" minmax(0, 3fr) "next notes" minmax(0, 2fr) / minmax(0, 3fr) minmax(0, 2fr); gap: .8rem; padding: .8rem; }
|
|
102
|
+
.ct-pr-top { grid-area: top; display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; }
|
|
103
|
+
.ct-pr-top .ct-rmt-time { margin-left: auto; font-size: 1.8rem; }
|
|
104
|
+
.ct-pr-top .ct-rmt-blk { flex-basis: auto; align-items: baseline; }
|
|
105
|
+
.ct-pr-cell { min-height: 0; min-width: 0; display: flex; flex-direction: column; gap: .4rem; }
|
|
106
|
+
.ct-pr-cell > h2 { margin: 0; display: flex; align-items: baseline; gap: .6rem; font: 650 .7rem/1 var(--ct-mono); letter-spacing: .12em; color: var(--ct-muted); }
|
|
107
|
+
.ct-pr-cell > h2 > span { letter-spacing: 0; font: 700 .9rem/1.2 inherit; font-family: inherit; color: var(--ct-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
108
|
+
.ct-pr-box { flex: 1; min-height: 0; display: grid; place-items: center start; container-type: size; }
|
|
109
|
+
.ct-pr-box .ct-pv { width: min(100cqw, 100cqh * var(--ct-ratio, 16 / 9)); }
|
|
110
|
+
.ct-pr-notes { grid-area: notes; overflow: auto; padding: .9rem 1rem; border: 1px solid var(--ct-border); font-size: 1.35rem; line-height: 1.75; }
|
|
111
|
+
.ct-pr-notes p { margin: 0 0 .8em; }
|
|
112
|
+
.ct-pr[data-ct-wait] :is(.ct-pr-cell, .ct-pr-notes) { opacity: .35; }
|
|
113
|
+
@media (max-width: 760px) {
|
|
114
|
+
.ct-app.ct-pr { grid-template: "top" auto "now" minmax(0, 2fr) "notes" minmax(0, 3fr) / minmax(0, 1fr); }
|
|
115
|
+
.ct-pr-cell[data-next] { display: none; }
|
|
116
|
+
}
|
|
117
|
+
@media (prefers-reduced-motion: reduce) {
|
|
118
|
+
.ct-laser { transition: none; }
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
/** Adds the stylesheet once per document. `nonce` is for a host with a `style-src` policy. */
|
|
122
|
+
export function injectStyle(nonce) {
|
|
123
|
+
if (document.querySelector("style[data-ct-remote]"))
|
|
124
|
+
return;
|
|
125
|
+
const style = document.createElement("style");
|
|
126
|
+
style.setAttribute("data-ct-remote", "");
|
|
127
|
+
if (nonce)
|
|
128
|
+
style.nonce = nonce;
|
|
129
|
+
style.textContent = css;
|
|
130
|
+
document.head.prepend(style);
|
|
131
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pointing with the phone: from device orientation to a point on the screen. Pure math, no sensors:
|
|
3
|
+
* the phone is held like a laser pointer, its top edge towards the screen, and five aimed samples
|
|
4
|
+
* (centre and four corners) calibrate where the screen is in the room.
|
|
5
|
+
*/
|
|
6
|
+
/** A `deviceorientation` reading, in degrees. */
|
|
7
|
+
export interface TiltSample {
|
|
8
|
+
alpha: number;
|
|
9
|
+
beta: number;
|
|
10
|
+
gamma: number;
|
|
11
|
+
}
|
|
12
|
+
/** Where the phone points, in radians: `yaw` grows to the left (counter-clockwise from above), `pitch` grows upwards. */
|
|
13
|
+
export interface Aim {
|
|
14
|
+
yaw: number;
|
|
15
|
+
pitch: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The screen as seen from where the presenter stands: the aim at its centre, and the signed angle
|
|
19
|
+
* from the centre to each edge. Left and right have opposite signs, and so have top and bottom.
|
|
20
|
+
*/
|
|
21
|
+
export interface Calibration extends Aim {
|
|
22
|
+
left: number;
|
|
23
|
+
right: number;
|
|
24
|
+
top: number;
|
|
25
|
+
bottom: number;
|
|
26
|
+
}
|
|
27
|
+
export interface Point {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
}
|
|
31
|
+
export type CalibrationTarget = "centre" | "topLeft" | "topRight" | "bottomRight" | "bottomLeft";
|
|
32
|
+
/** The order the guided calibration asks for. */
|
|
33
|
+
export declare const CALIBRATION_TARGETS: readonly CalibrationTarget[];
|
|
34
|
+
/** An angle difference brought into (-π, π], so 358° to 2° is +4° and not -356°. */
|
|
35
|
+
export declare function wrap(angle: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* The direction the top of the screen points at, from a sensor reading.
|
|
38
|
+
* The reading becomes the rotation matrix R = Rz(alpha)·Rx(beta)·Ry(gamma) of the W3C specification, and
|
|
39
|
+
* the pointing axis is the edge at the top of the screen as it is turned now (`screenAngle`, the value of
|
|
40
|
+
* `screen.orientation.angle`): the device's +Y in portrait, +X after a quarter turn. Reading the axis in
|
|
41
|
+
* the room, and not the three angles one by one, keeps the aim independent of how the wrist is rolled.
|
|
42
|
+
*/
|
|
43
|
+
export declare function aimOf({ alpha, beta, gamma }: TiltSample, screenAngle?: number): Aim;
|
|
44
|
+
/** The average of a few readings taken close together (the hand shakes when the thumb taps), across the yaw seam. */
|
|
45
|
+
export declare function meanAim(aims: readonly Aim[]): Aim;
|
|
46
|
+
/**
|
|
47
|
+
* The calibration from the five aimed samples, or `null` when they cannot be a screen:
|
|
48
|
+
* an edge too close to the centre, or both sides of the centre on the same side.
|
|
49
|
+
*/
|
|
50
|
+
export declare function calibrate(aims: Record<CalibrationTarget, Aim>): Calibration | null;
|
|
51
|
+
/** True for a calibration that maps to a screen; also the check for one read back from storage. */
|
|
52
|
+
export declare function isCalibration(value: unknown): value is Calibration;
|
|
53
|
+
/** A calibration for a screen of ordinary size seen from the middle of a room: ±20° wide, ±12° high around `centre`. */
|
|
54
|
+
export declare function defaultCalibration(centre: Aim): Calibration;
|
|
55
|
+
/** The aimed point in screen coordinates, origin at the top left; outside [0, 1] when the aim is past an edge. */
|
|
56
|
+
export declare function project(calibration: Calibration, aim: Aim): Point;
|
|
57
|
+
/** The same screen size, with the centre where the phone points now: the answer to drift, or to a step sideways. */
|
|
58
|
+
export declare function recentre(calibration: Calibration, aim: Aim): Calibration;
|
|
59
|
+
/**
|
|
60
|
+
* Drift correction while pointing: an aim past an edge drags the calibration along, so the edge stays
|
|
61
|
+
* under the aim and the pointer answers at once when the hand comes back, as a mouse does at the screen edge.
|
|
62
|
+
*/
|
|
63
|
+
export declare function follow(calibration: Calibration, aim: Aim): Calibration;
|
|
64
|
+
/**
|
|
65
|
+
* The 1€ filter (Casiez, Roussel, Vogel 2012): a low-pass whose cutoff rises with speed, so a still hand
|
|
66
|
+
* gives a still pointer and a fast sweep is not held back. `t` is in seconds.
|
|
67
|
+
*/
|
|
68
|
+
export declare function oneEuro(minCutoff?: number, beta?: number, derivativeCutoff?: number): (value: number, t: number) => number;
|
|
69
|
+
export interface Pointer {
|
|
70
|
+
/** The calibration in force; `follow` moves it while the phone points past an edge. */
|
|
71
|
+
calibration: Calibration;
|
|
72
|
+
/** The smoothed point of an aim, clamped to the screen. `t` is in seconds. */
|
|
73
|
+
update(aim: Aim, t: number): Point;
|
|
74
|
+
/** Puts the centre where the phone points and forgets the filter's history. */
|
|
75
|
+
recentre(aim: Aim): void;
|
|
76
|
+
}
|
|
77
|
+
/** Calibration, drift correction, and smoothing in one: feed it aims, read points. */
|
|
78
|
+
export declare function createPointer(calibration: Calibration): Pointer;
|