@clankagent/puck 0.1.0 → 0.3.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,230 @@
1
+ import { defaultGestureTune } from './tune.js';
2
+ /** Experimental pulse recognizer. No timers, DOM, transport, or motion side effects. */
3
+ export function createGestures(configuration = defaultGestureTune) {
4
+ const options = 'toOptions' in configuration ? configuration.toOptions() : configuration;
5
+ const activation = options.activation ?? .35;
6
+ const release = options.release ?? .12;
7
+ const pressActivation = options.pressActivation ?? options.activation;
8
+ const pressRelease = options.pressRelease ?? options.release;
9
+ const twistActivation = options.twistActivation ?? options.activation ?? defaultGestureTune.rotation.activation;
10
+ const twistRelease = options.twistRelease ?? options.release ?? defaultGestureTune.rotation.release;
11
+ const standalone = options.standaloneTilt ?? false;
12
+ const st = defaultGestureTune.standaloneTilt;
13
+ const tx = { activation: options.tiltXActivation ?? st.rx.activation, release: options.tiltXRelease ?? st.rx.release };
14
+ const ty = { activation: options.tiltYActivation ?? st.ry.activation, release: options.tiltYRelease ?? st.ry.release };
15
+ const thresholds = {
16
+ clockwise: { activation: options.clockwiseActivation ?? twistActivation, release: options.clockwiseRelease ?? twistRelease },
17
+ counterclockwise: { activation: options.counterclockwiseActivation ?? twistActivation, release: options.counterclockwiseRelease ?? twistRelease },
18
+ push: { activation: options.pushActivation ?? pressActivation ?? defaultGestureTune.push.activation, release: options.pushRelease ?? pressRelease ?? defaultGestureTune.push.release },
19
+ pull: { activation: options.pullActivation ?? pressActivation ?? defaultGestureTune.pull.activation, release: options.pullRelease ?? pressRelease ?? defaultGestureTune.pull.release },
20
+ 'rx+': tx, 'rx-': tx, 'ry+': ty, 'ry-': ty,
21
+ };
22
+ const minPulseMs = options.minPulseMs ?? 35;
23
+ const maxPulseMs = options.maxPulseMs ?? 650;
24
+ const neutralMs = options.neutralMs ?? defaultGestureTune.timing.neutralMs;
25
+ const doubleMs = options.doubleMs ?? defaultGestureTune.timing.doubleMs;
26
+ const singleMode = options.singleMode ?? 'exclusive';
27
+ const standaloneMin = options.standaloneMinPulseMs ?? st.timing.minPulseMs, standaloneMax = options.standaloneMaxPulseMs ?? st.timing.maxPulseMs;
28
+ const standaloneNeutral = options.standaloneNeutralMs ?? st.timing.neutralMs, standaloneDouble = options.standaloneDoubleMs ?? st.timing.doubleMs;
29
+ if (typeof standalone !== 'boolean' || ![standaloneMin, standaloneMax, standaloneNeutral, standaloneDouble].every(Number.isFinite) || standaloneMin < 0 || standaloneMax < standaloneMin || standaloneNeutral < 0 || standaloneDouble < 0)
30
+ throw new RangeError('Invalid standalone tilt options.');
31
+ const isTilt = (d) => d.startsWith('rx') || d.startsWith('ry');
32
+ const dwell = (d) => isTilt(d) ? standaloneNeutral : neutralMs;
33
+ const doubleWindow = (d) => isTilt(d) ? standaloneDouble : doubleMs;
34
+ const dominance = options.dominance ?? 1.4;
35
+ const modes = { push: options.pushMode ?? options.pressMode ?? 'simple', pull: options.pullMode ?? options.pressMode ?? 'simple' };
36
+ const tiltActivation = options.tiltActivation ?? defaultGestureTune.pressTilt.force.activation, tiltRelease = options.tiltRelease ?? defaultGestureTune.pressTilt.force.release;
37
+ const tiltMinMs = options.tiltMinMs ?? 25, tiltArmMs = options.tiltArmMs ?? 450;
38
+ const tiltRelaxMs = options.tiltRelaxMs ?? 180, tiltMaxMs = options.tiltMaxMs ?? 1000;
39
+ const tiltDominance = options.tiltDominance ?? 1.25;
40
+ const combinedEnabled = modes.push !== 'simple' || modes.pull !== 'simple';
41
+ const tiltEnabled = combinedEnabled || standalone;
42
+ const tiltMode = (d) => (d === 'push' || d === 'pull') && modes[d] !== 'simple';
43
+ if ([options.pressMode, options.pushMode, options.pullMode].some(v => v !== undefined && !['simple', 'tilt', 'auto'].includes(v))
44
+ || ![tiltActivation, tiltRelease, tiltMinMs, tiltArmMs, tiltRelaxMs, tiltMaxMs, tiltDominance].every(Number.isFinite)
45
+ || tiltRelease < 0 || tiltActivation <= tiltRelease || tiltActivation > 1 || tiltDominance <= 1
46
+ || tiltMinMs < 0 || tiltArmMs < tiltMinMs || tiltArmMs > tiltMaxMs || tiltMaxMs > 10000
47
+ || tiltRelaxMs < 0 || tiltRelaxMs > tiltArmMs || (combinedEnabled && singleMode !== 'exclusive'))
48
+ throw new RangeError('Invalid press-tilt options; combined gestures require exclusive single mode.');
49
+ if (![activation, release, twistActivation, twistRelease, minPulseMs, maxPulseMs, neutralMs, doubleMs, dominance].every(Number.isFinite)
50
+ || release < 0 || activation <= release || activation > 1 || minPulseMs < 0
51
+ || twistRelease < 0 || twistActivation <= twistRelease || twistActivation > 1
52
+ || Object.values(thresholds).some(v => !Number.isFinite(v.activation) || !Number.isFinite(v.release) || v.release < 0 || v.activation <= v.release || v.activation > 1)
53
+ || maxPulseMs < minPulseMs || neutralMs < 0 || doubleMs < 0 || dominance < 1
54
+ || !['exclusive', 'immediate'].includes(singleMode))
55
+ throw new RangeError('Invalid gesture options.');
56
+ let last = -Infinity;
57
+ let z = 0, rz = 0, rx = 0, ry = 0;
58
+ let phase = 'blocked';
59
+ let active = null;
60
+ let releasedAt = null;
61
+ let pending = null;
62
+ const tiltNeutral = () => Math.max(Math.abs(rx), Math.abs(ry)) <= tiltRelease;
63
+ const standaloneRest = () => Math.abs(rx) <= tx.release && Math.abs(ry) <= ty.release;
64
+ const neutral = () => active && isTilt(active.direction) ? standaloneRest() : Math.abs(z) <= thresholds[z >= 0 ? 'push' : 'pull'].release
65
+ && Math.abs(rz) <= thresholds[rz >= 0 ? 'clockwise' : 'counterclockwise'].release
66
+ && (!(active ? tiltMode(active.direction) : combinedEnabled) || tiltNeutral())
67
+ && (Boolean(active) || !standalone || standaloneRest());
68
+ const allowSingle = (e) => e.direction !== 'push' && e.direction !== 'pull' || modes[e.direction] !== 'tilt';
69
+ const pendingStart = () => pending.timestamp - neutralMs - pending.durationMs;
70
+ const canResume = (t) => pending && tiltMode(pending.direction) && t <= pending.timestamp - neutralMs + tiltRelaxMs && t <= pendingStart() + tiltArmMs;
71
+ const deadline = (e) => e.timestamp + (tiltMode(e.direction) ? Math.max(doubleMs, tiltRelaxMs + tiltMinMs) : doubleWindow(e.direction));
72
+ function tiltDirection() {
73
+ if (Math.abs(rx) >= tiltActivation && Math.abs(rx) >= Math.abs(ry) * tiltDominance)
74
+ return rx > 0 ? 'rx+' : 'rx-';
75
+ if (Math.abs(ry) >= tiltActivation && Math.abs(ry) >= Math.abs(rx) * tiltDominance)
76
+ return ry > 0 ? 'ry+' : 'ry-';
77
+ return undefined;
78
+ }
79
+ function clock(t) {
80
+ if (!Number.isFinite(t) || t < last)
81
+ throw new RangeError('Gesture timestamps must be finite and monotonic.');
82
+ last = t;
83
+ }
84
+ function tick(t) {
85
+ const events = [];
86
+ if (active?.candidate && active.candidateAt !== undefined && t >= active.candidateAt + tiltMinMs
87
+ && (!active.tilt || active.candidate === active.tilt || active.candidatePeak > active.tiltPeak * tiltDominance)) {
88
+ if (canResume(active.candidateAt) && pending.direction === active.direction) {
89
+ active.start = pendingStart();
90
+ pending = null;
91
+ }
92
+ active.tilt = active.candidate;
93
+ active.tiltPeak = Math.max(active.tiltPeak ?? 0, active.candidatePeak ?? 0);
94
+ }
95
+ if (phase === 'releasing' && releasedAt !== null && active && t >= releasedAt + dwell(active.direction)) {
96
+ const durationMs = releasedAt - active.start;
97
+ const completedAt = releasedAt + dwell(active.direction);
98
+ if (active.tilt && durationMs <= tiltMaxMs) {
99
+ // Combined gestures consume one excursion; repeats are separate actions, not tilt doubles.
100
+ if (pending && allowSingle(pending))
101
+ events.push({ ...pending, timestamp: Math.min(completedAt, deadline(pending)) });
102
+ pending = null;
103
+ events.push({ direction: active.direction, tilt: active.tilt, kind: 'single', timestamp: completedAt, durationMs });
104
+ }
105
+ else if (!active.borrowed && !active.attemptedTilt && durationMs >= (isTilt(active.direction) ? standaloneMin : minPulseMs) && durationMs <= (isTilt(active.direction) ? standaloneMax : maxPulseMs)) {
106
+ const pulse = { direction: active.direction, kind: 'single', timestamp: completedAt, durationMs };
107
+ if (pending && pending.direction === pulse.direction && completedAt - pending.timestamp <= doubleWindow(pulse.direction)) {
108
+ events.push({ ...pulse, kind: 'double' });
109
+ pending = null;
110
+ }
111
+ else {
112
+ if (pending && singleMode === 'exclusive' && allowSingle(pending))
113
+ events.push({ ...pending, timestamp: Math.min(completedAt, deadline(pending)) });
114
+ pending = pulse;
115
+ if (singleMode === 'immediate' && allowSingle(pulse))
116
+ events.push(pulse);
117
+ }
118
+ }
119
+ active = null;
120
+ releasedAt = null;
121
+ phase = 'neutral';
122
+ }
123
+ if (active && phase === 'active' && t - active.start > (isTilt(active.direction) ? standaloneMax : active.tilt || active.candidate ? tiltMaxMs : maxPulseMs)) {
124
+ phase = 'blocked';
125
+ active = null;
126
+ }
127
+ // A double is defined by completion-to-completion time, including neutral dwell.
128
+ if (pending && t > deadline(pending)) {
129
+ if (singleMode === 'exclusive' && allowSingle(pending))
130
+ events.push({ ...pending, timestamp: deadline(pending) });
131
+ pending = null;
132
+ }
133
+ return events;
134
+ }
135
+ return {
136
+ get state() { return { phase, direction: active?.direction ?? null, pending: pending?.direction ?? null }; },
137
+ reset() { last = -Infinity; z = rz = rx = ry = 0; active = pending = null; releasedAt = null; phase = 'blocked'; },
138
+ advance(t) { clock(t); return tick(t); },
139
+ update(input, t) {
140
+ if (![input.z, input.rz, ...(tiltEnabled ? [input.rx, input.ry] : [])].every(Number.isFinite))
141
+ throw new RangeError('Gesture axes must be finite.');
142
+ clock(t);
143
+ const events = tick(t);
144
+ const tiltWasBelowActivation = Math.max(Math.abs(rx), Math.abs(ry)) < tiltActivation;
145
+ z = Math.max(-1, Math.min(1, input.z));
146
+ rz = Math.max(-1, Math.min(1, input.rz));
147
+ if (tiltEnabled) {
148
+ rx = Math.max(-1, Math.min(1, input.rx));
149
+ ry = Math.max(-1, Math.min(1, input.ry));
150
+ }
151
+ if (phase === 'blocked') {
152
+ if (neutral())
153
+ phase = 'neutral';
154
+ return events;
155
+ }
156
+ const side = tiltEnabled ? tiltDirection() : undefined;
157
+ if (!active && side && canResume(t)) {
158
+ active = { direction: pending.direction, start: pendingStart(), borrowed: true, tiltEligible: true };
159
+ phase = 'active';
160
+ }
161
+ if (active) {
162
+ if (tiltMode(active.direction) && active.tiltEligible) {
163
+ if (Math.max(Math.abs(rx), Math.abs(ry)) >= tiltActivation)
164
+ active.attemptedTilt = true;
165
+ if (side && (active.tilt || active.candidate === side || t - active.start <= tiltArmMs)) {
166
+ const strength = Math.abs(side.startsWith('rx') ? rx : ry);
167
+ if (active.candidate !== side) {
168
+ active.candidate = side;
169
+ active.candidateAt = t;
170
+ active.candidatePeak = strength;
171
+ }
172
+ else
173
+ active.candidatePeak = Math.max(active.candidatePeak ?? 0, strength);
174
+ }
175
+ else {
176
+ active.candidate = undefined;
177
+ active.candidateAt = undefined;
178
+ active.candidatePeak = undefined;
179
+ }
180
+ }
181
+ const signed = active.direction === 'push' ? z : active.direction === 'pull' ? -z : active.direction === 'clockwise' ? rz : active.direction === 'counterclockwise' ? -rz : (active.direction.startsWith('rx') ? rx : ry) * (active.direction.endsWith('+') ? 1 : -1);
182
+ if (neutral()) {
183
+ if (releasedAt === null)
184
+ releasedAt = t;
185
+ phase = 'releasing';
186
+ }
187
+ else if (signed < -thresholds[active.direction].release) {
188
+ // Reversal without a neutral dwell is not a completed pulse.
189
+ active = null;
190
+ releasedAt = null;
191
+ phase = 'blocked';
192
+ }
193
+ else {
194
+ phase = 'active';
195
+ releasedAt = null;
196
+ }
197
+ }
198
+ else {
199
+ const vertical = Math.abs(z) >= Math.abs(rz);
200
+ let strong = vertical ? Math.abs(z) : Math.abs(rz);
201
+ let weak = vertical ? Math.abs(rz) : Math.abs(z);
202
+ let direction = vertical ? (z > 0 ? 'push' : 'pull') : (rz > 0 ? 'clockwise' : 'counterclockwise');
203
+ if (standalone) {
204
+ const axis = Math.abs(rx) >= Math.abs(ry) ? 'rx' : 'ry';
205
+ const sideStrength = Math.abs(axis === 'rx' ? rx : ry);
206
+ if (sideStrength >= (axis === 'rx' ? tx.activation : ty.activation)) {
207
+ if (sideStrength > strong) {
208
+ weak = Math.max(strong, Math.abs(axis === 'rx' ? ry : rx));
209
+ strong = sideStrength;
210
+ direction = (axis + ((axis === 'rx' ? rx : ry) > 0 ? '+' : '-'));
211
+ }
212
+ else
213
+ weak = Math.max(weak, sideStrength);
214
+ }
215
+ }
216
+ if (strong >= thresholds[direction].activation && strong >= weak * dominance) {
217
+ active = { direction, start: t, tiltEligible: tiltWasBelowActivation };
218
+ if (tiltMode(direction) && active.tiltEligible && side) {
219
+ active.candidate = side;
220
+ active.candidateAt = t;
221
+ active.candidatePeak = Math.abs(side.startsWith('rx') ? rx : ry);
222
+ active.attemptedTilt = true;
223
+ }
224
+ phase = 'active';
225
+ }
226
+ }
227
+ return events.concat(tick(t));
228
+ },
229
+ };
230
+ }
@@ -0,0 +1,56 @@
1
+ import type { PulseDirection as GestureDirection, TiltDirection } from './gestures.js';
2
+ import type { PressTiltAction } from './press-tilt-calibration.js';
3
+ import type { TiltCalibrationAction } from './tilt-calibration.js';
4
+ import type { GestureTune } from './tune.js';
5
+ import type { GestureRecording } from './recording.js';
6
+ import type { CalibrationAction } from './calibration.js';
7
+ export interface GestureGraphLane {
8
+ direction: GestureDirection | TiltDirection;
9
+ label: string;
10
+ center: number;
11
+ low: number;
12
+ high: number;
13
+ activation: number;
14
+ release: number;
15
+ points: {
16
+ t: number;
17
+ value: number;
18
+ }[];
19
+ actions: {
20
+ start: number;
21
+ end: number;
22
+ kind: 'single' | 'double';
23
+ label?: string;
24
+ }[];
25
+ }
26
+ export interface GestureGraph {
27
+ start: number;
28
+ end: number;
29
+ lanes: GestureGraphLane[];
30
+ }
31
+ /** Renderer-neutral graph data. Use your charting library or the optional SVG renderer. */
32
+ export declare function createGestureGraph(recording: GestureRecording, tune?: GestureTune, config?: {
33
+ start?: number;
34
+ end?: number;
35
+ actions?: readonly CalibrationAction[];
36
+ recordingIndex?: number;
37
+ }): GestureGraph;
38
+ /** Pressure and tilt in six separate lanes, with each inferred combination labeled on both. */
39
+ export declare function createPressTiltGraph(recording: GestureRecording, tune?: GestureTune, config?: {
40
+ start?: number;
41
+ end?: number;
42
+ actions?: readonly PressTiltAction[];
43
+ recordingIndex?: number;
44
+ }): GestureGraph;
45
+ /** Four standalone tilt lanes with independent rx/ry bands and single/double action spans. */
46
+ export declare function createTiltGraph(recording: GestureRecording, tune?: GestureTune, config?: {
47
+ start?: number;
48
+ end?: number;
49
+ actions?: readonly TiltCalibrationAction[];
50
+ recordingIndex?: number;
51
+ }): GestureGraph;
52
+ /** Standalone accessible SVG. No DOM, canvas, frameworks or global listeners. */
53
+ export declare function renderGestureGraphSvg(graph: GestureGraph, options?: {
54
+ width?: number;
55
+ title?: string;
56
+ }): string;
package/dist/graph.js ADDED
@@ -0,0 +1,111 @@
1
+ import { defaultGestureTune } from './tune.js';
2
+ import { validateGestureRecording } from './recording.js';
3
+ const labels = { clockwise: 'Clockwise', counterclockwise: 'Counterclockwise', push: 'Push down', pull: 'Pull up' };
4
+ /** Renderer-neutral graph data. Use your charting library or the optional SVG renderer. */
5
+ export function createGestureGraph(recording, tune = defaultGestureTune, config = {}) {
6
+ validateGestureRecording(recording);
7
+ const start = config.start ?? 0, end = config.end ?? Math.max(1, recording.durationMs);
8
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end <= start)
9
+ throw new RangeError('Invalid graph window.');
10
+ const lanes = Object.keys(labels).map(direction => {
11
+ const vertical = direction === 'push' || direction === 'pull', sign = direction === 'clockwise' || direction === 'push' ? 1 : -1;
12
+ const band = vertical ? tune[direction] : tune.rotation;
13
+ const all = [];
14
+ for (const row of recording.timeline) {
15
+ if (row.type === 'input')
16
+ all.push({ t: row.t, value: Math.max(0, sign * row.input[vertical ? 'z' : 'rz']) });
17
+ else if (row.type === 'reset')
18
+ all.push({ t: row.t, value: 0 });
19
+ }
20
+ const preceding = [...all].reverse().find(p => p.t < start);
21
+ const points = [{ t: start, value: preceding?.value ?? 0 }, ...all.filter(p => p.t >= start && p.t <= end)];
22
+ points.push({ t: end, value: points[points.length - 1].value });
23
+ return { direction, label: labels[direction], ...band, points, actions: (config.actions ?? []).filter(a => a.recording === (config.recordingIndex ?? 0) && a.direction === direction && a.end >= start && a.start <= end).map(a => ({ start: a.start, end: a.end, kind: a.kind })) };
24
+ });
25
+ return { start, end, lanes };
26
+ }
27
+ /** Pressure and tilt in six separate lanes, with each inferred combination labeled on both. */
28
+ export function createPressTiltGraph(recording, tune = defaultGestureTune, config = {}) {
29
+ const graph = createGestureGraph(recording, tune, { start: config.start, end: config.end });
30
+ const actions = (config.actions ?? []).filter(a => a.recording === (config.recordingIndex ?? 0) && a.end >= graph.start && a.start <= graph.end);
31
+ graph.lanes = graph.lanes.filter(l => l.direction === 'push' || l.direction === 'pull');
32
+ for (const direction of ['rx+', 'rx-', 'ry+', 'ry-']) {
33
+ const axis = direction.startsWith('rx') ? 'rx' : 'ry', sign = direction.endsWith('+') ? 1 : -1;
34
+ const points = [];
35
+ let held = 0;
36
+ for (const row of recording.timeline) {
37
+ if (row.t > graph.end)
38
+ break;
39
+ if (row.type === 'input')
40
+ held = Math.max(0, row.input[axis] * sign);
41
+ else if (row.type === 'reset')
42
+ held = 0;
43
+ else
44
+ continue;
45
+ if (row.t < graph.start)
46
+ continue;
47
+ if (!points.length)
48
+ points.push({ t: graph.start, value: 0 });
49
+ points.push({ t: row.t, value: held });
50
+ }
51
+ // Seed the left edge with the held sample preceding the requested window.
52
+ let before = 0;
53
+ for (const row of recording.timeline) {
54
+ if (row.t >= graph.start)
55
+ break;
56
+ if (row.type === 'input')
57
+ before = Math.max(0, row.input[axis] * sign);
58
+ else if (row.type === 'reset')
59
+ before = 0;
60
+ }
61
+ if (!points.length)
62
+ points.push({ t: graph.start, value: before });
63
+ else
64
+ points[0].value = before;
65
+ points.push({ t: graph.end, value: held });
66
+ graph.lanes.push({ direction, label: `Tilt ${direction} (device axis)`, ...(tune.pressTilt ?? defaultGestureTune.pressTilt).force, points, actions: [] });
67
+ }
68
+ for (const lane of graph.lanes)
69
+ lane.actions = actions.filter(a => a.direction === lane.direction || a.tilt === lane.direction).map(a => ({ start: a.start, end: a.end, kind: 'single', label: `${a.direction} + ${a.tilt}` }));
70
+ return graph;
71
+ }
72
+ const escape = (value) => value.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
73
+ /** Four standalone tilt lanes with independent rx/ry bands and single/double action spans. */
74
+ export function createTiltGraph(recording, tune = defaultGestureTune, config = {}) {
75
+ const graph = createPressTiltGraph(recording, tune, { start: config.start, end: config.end });
76
+ const bands = tune.standaloneTilt ?? defaultGestureTune.standaloneTilt;
77
+ graph.lanes = graph.lanes.filter(l => l.direction.startsWith('r')).map(l => ({ ...l, ...(l.direction.startsWith('rx') ? bands.rx : bands.ry), actions: (config.actions ?? []).filter(a => a.recording === (config.recordingIndex ?? 0) && a.direction === l.direction && a.end >= graph.start && a.start <= graph.end).map(a => ({ start: a.start, end: a.end, kind: a.kind })) }));
78
+ return graph;
79
+ }
80
+ /** Standalone accessible SVG. No DOM, canvas, frameworks or global listeners. */
81
+ export function renderGestureGraphSvg(graph, options = {}) {
82
+ const width = options.width ?? 960;
83
+ if (!Number.isFinite(width) || width < 280 || width > 4000)
84
+ throw new RangeError('SVG width must be between 280 and 4000.');
85
+ const height = graph.lanes.length * 150 + 64, left = 45, right = 20, plotWidth = width - left - right;
86
+ const x = (t) => left + (Math.max(graph.start, Math.min(graph.end, t)) - graph.start) / (graph.end - graph.start) * plotWidth;
87
+ const svg = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escape(options.title ?? 'Gesture recording: independent input directions')}"><desc>Blue is input strength, green dashed is activation, gray dotted is release, pale blue is the typical peak range. Shaded time spans mark inferred actions. Stronger inputs remain accepted above the typical range.</desc><rect width="100%" height="100%" fill="white"/><g font-family="Segoe UI,Arial,sans-serif" font-size="12" fill="#283c55">`];
88
+ graph.lanes.forEach((lane, index) => {
89
+ const top = 30 + index * 150, y = (v) => top + 100 - Math.max(0, Math.min(1, v)) * 76;
90
+ svg.push(`<text x="${left}" y="${top}" font-size="15" font-weight="600">${escape(lane.label)}</text><text x="${width - right}" y="${top}" text-anchor="end" font-size="11">peak ${lane.center.toFixed(2)}</text><text x="${left}" y="${top + 19}">Activate ${lane.activation.toFixed(2)} · release ${lane.release.toFixed(2)}</text>`);
91
+ svg.push(`<rect x="${left}" y="${y(lane.high)}" width="${plotWidth}" height="${y(lane.low) - y(lane.high)}" fill="#edf3fc"/>`);
92
+ for (const a of lane.actions) {
93
+ svg.push(`<rect x="${x(a.start)}" y="${top + 24}" width="${Math.max(2, x(a.end) - x(a.start))}" height="76" fill="#cfdfef" opacity=".45"><title>${escape(a.label ?? a.kind)}</title></rect>`);
94
+ if (x(a.end) - x(a.start) > (a.label ? 90 : 42))
95
+ svg.push(`<text x="${x(a.start) + 3}" y="${top + 116}" font-size="11">${escape(a.label ?? a.kind)}</text>`);
96
+ }
97
+ for (const [v, color, dash] of [[0, '#afbac8', ''], [lane.activation, '#237442', '6 4'], [lane.release, '#68778b', '2 4']])
98
+ svg.push(`<line x1="${left}" x2="${width - right}" y1="${y(v)}" y2="${y(v)}" stroke="${color}" stroke-dasharray="${dash}"/>`);
99
+ svg.push(`<text x="12" y="${y(1) + 4}">1</text><text x="12" y="${y(0) + 4}">0</text>`);
100
+ // Steps preserve held input and report silence; no invented sloping interpolation.
101
+ let path = '';
102
+ lane.points.forEach((p, i) => { path += i ? `H${x(p.t).toFixed(2)}V${y(p.value).toFixed(2)}` : `M${x(p.t).toFixed(2)},${y(p.value).toFixed(2)}`; });
103
+ svg.push(`<path d="${path}" fill="none" stroke="#1b4fce" stroke-width="1.8"/>`);
104
+ });
105
+ for (let i = 0; i <= 4; i++) {
106
+ const t = graph.start + (graph.end - graph.start) * i / 4;
107
+ svg.push(`<text x="${x(t)}" y="${height - 24}" text-anchor="${i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}">${(t / 1000).toFixed(2)} s</text>`);
108
+ }
109
+ svg.push('</g></svg>');
110
+ return svg.join('');
111
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,15 @@ export { decodeCombinedReport, neutralInput } from './input.js';
2
2
  export type { InputState } from './input.js';
3
3
  export { createPanZoom } from './motion.js';
4
4
  export type { PanZoomController, PanZoomOptions, MotionDelta, ZoomInput } from './motion.js';
5
+ export { createGestures } from './gestures.js';
6
+ export type { GestureDirection, PulseDirection, TiltDirection, PressMode, GestureOptions, GestureEvent, GestureRecognizer } from './gestures.js';
7
+ export { createGestureTune, defaultGestureTune, gesturePresets } from './tune.js';
8
+ export type { GestureTune, GestureTuneData, ForceBand, PressTiltTune, StandaloneTiltTune } from './tune.js';
9
+ export { createGestureRecorder, validateGestureRecording } from './recording.js';
10
+ export type { GestureRecorder, GestureRecording, RecordingEntry } from './recording.js';
11
+ export { calibrateGestures, gestureDirections } from './calibration.js';
12
+ export type { GestureCalibration, CalibrationAction, CalibrationPulse, CalibrationStats, GestureActionName } from './calibration.js';
13
+ export { calibratePressTilts, tiltDirections } from './press-tilt-calibration.js';
14
+ export type { PressTiltCalibration, PressTiltAction, PressTiltActionName } from './press-tilt-calibration.js';
15
+ export { calibrateTilts } from './tilt-calibration.js';
16
+ export type { TiltCalibration, TiltCalibrationAction, TiltActionName } from './tilt-calibration.js';
package/dist/index.js CHANGED
@@ -1,2 +1,8 @@
1
1
  export { decodeCombinedReport, neutralInput } from './input.js';
2
2
  export { createPanZoom } from './motion.js';
3
+ export { createGestures } from './gestures.js';
4
+ export { createGestureTune, defaultGestureTune, gesturePresets } from './tune.js';
5
+ export { createGestureRecorder, validateGestureRecording } from './recording.js';
6
+ export { calibrateGestures, gestureDirections } from './calibration.js';
7
+ export { calibratePressTilts, tiltDirections } from './press-tilt-calibration.js';
8
+ export { calibrateTilts } from './tilt-calibration.js';
@@ -0,0 +1,32 @@
1
+ import type { TiltDirection } from './gestures.js';
2
+ import type { GestureRecording } from './recording.js';
3
+ import type { GestureTune } from './tune.js';
4
+ export declare const tiltDirections: readonly ['rx+', 'rx-', 'ry+', 'ry-'];
5
+ export type PressTiltActionName = `${'push' | 'pull'}.${TiltDirection}`;
6
+ export interface PressTiltAction {
7
+ direction: 'push' | 'pull';
8
+ tilt: TiltDirection;
9
+ kind: 'single';
10
+ recording: number;
11
+ start: number;
12
+ end: number;
13
+ pressurePeak: number;
14
+ tiltPeak: number;
15
+ pressureAtTiltPeak: number;
16
+ onsetDelayMs: number;
17
+ }
18
+ export interface PressTiltCalibration {
19
+ status: 'ready' | 'incomplete' | 'ambiguous';
20
+ tune: GestureTune | null;
21
+ counts: Record<PressTiltActionName, number>;
22
+ missing: PressTiltActionName[];
23
+ actions: PressTiltAction[];
24
+ issues: string[];
25
+ }
26
+ /** Infer combined single excursions from raw z/rx/ry. Preserves the base simple/double tune.
27
+ * Coverage is inferred evidence, not ground-truth labels or measured classification accuracy.
28
+ */
29
+ export declare function calibratePressTilts(input: GestureRecording | readonly GestureRecording[], settings?: {
30
+ baseTune?: GestureTune;
31
+ minimumPerAction?: number;
32
+ }): PressTiltCalibration;
@@ -0,0 +1,88 @@
1
+ import { validateGestureRecording } from './recording.js';
2
+ import { createGestureTune, defaultGestureTune } from './tune.js';
3
+ export const tiltDirections = ['rx+', 'rx-', 'ry+', 'ry-'];
4
+ const quantile = (v, p) => { const a = [...v].sort((x, y) => x - y), at = (a.length - 1) * p, i = Math.floor(at); return a[i] + (a[Math.ceil(at)] - a[i]) * (at - i); };
5
+ /** Infer combined single excursions from raw z/rx/ry. Preserves the base simple/double tune.
6
+ * Coverage is inferred evidence, not ground-truth labels or measured classification accuracy.
7
+ */
8
+ export function calibratePressTilts(input, settings = {}) {
9
+ const recordings = Array.isArray(input) ? input : [input];
10
+ const minimum = settings.minimumPerAction ?? 3, base = settings.baseTune ?? defaultGestureTune;
11
+ if (!Number.isInteger(minimum) || minimum < 3 || minimum > 100 || recordings.length < 1 || recordings.length > 20)
12
+ throw new RangeError('Use 1–20 recordings and at least three examples per action.');
13
+ const counts = {};
14
+ for (const d of ['push', 'pull'])
15
+ for (const tilt of tiltDirections)
16
+ counts[`${d}.${tilt}`] = 0;
17
+ const actions = [], issues = [];
18
+ let ambiguous = false;
19
+ function finish(rows, recording) {
20
+ if (!rows.length)
21
+ return;
22
+ const peak = (k) => rows.reduce((a, b) => Math.abs(b.input[k]) > Math.abs(a.input[k]) ? b : a);
23
+ const px = peak('rx'), py = peak('ry'), pz = peak('z');
24
+ const axis = Math.abs(px.input.rx) >= Math.abs(py.input.ry) ? 'rx' : 'ry', top = axis === 'rx' ? px : py;
25
+ const strength = Math.abs(top.input[axis]), other = Math.max(...rows.map(r => Math.abs(r.input[axis === 'rx' ? 'ry' : 'rx'])));
26
+ if (strength < .25)
27
+ return; // Plain presses are evidence for the separate simple/double calibration.
28
+ const direction = pz.input.z > 0 ? 'push' : 'pull', sign = direction === 'push' ? 1 : -1;
29
+ const pressure = rows.find(r => r.input.z * sign >= base[direction].activation);
30
+ const onset = rows.find(r => Math.abs(r.input[axis]) >= .25);
31
+ if (!pressure || !onset || onset.t < pressure.t || rows[rows.length - 1].t - rows[0].t > 1500 || rows.some(r => r.input.z * sign < -base[direction === 'push' ? 'pull' : 'push'].activation) || strength < other * 1.25) {
32
+ ambiguous = true;
33
+ issues.push('Excluded a tilt with ambiguous direction, pressure order, reversal or duration.');
34
+ return;
35
+ }
36
+ const tilt = (axis + (top.input[axis] > 0 ? '+' : '-'));
37
+ actions.push({ direction, tilt, kind: 'single', recording, start: pressure.t, end: rows[rows.length - 1].t, pressurePeak: Math.abs(pz.input.z), tiltPeak: strength, pressureAtTiltPeak: Math.abs(top.input.z), onsetDelayMs: onset.t - pressure.t });
38
+ counts[`${direction}.${tilt}`]++;
39
+ }
40
+ recordings.forEach((recording, index) => {
41
+ validateGestureRecording(recording);
42
+ let run = [], quietAt = null, armed = false;
43
+ for (const row of recording.timeline) {
44
+ if (row.type === 'reset') {
45
+ if (run.length)
46
+ issues.push('Discarded an interrupted excursion.');
47
+ run = [];
48
+ quietAt = null;
49
+ armed = false;
50
+ continue;
51
+ }
52
+ if (row.type !== 'input')
53
+ continue;
54
+ const magnitude = Math.max(Math.abs(row.input.z), Math.abs(row.input.rx), Math.abs(row.input.ry));
55
+ if (magnitude <= .06) {
56
+ armed = true;
57
+ quietAt ?? (quietAt = row.t);
58
+ continue;
59
+ }
60
+ if (run.length && quietAt !== null && row.t - quietAt > 180) {
61
+ finish(run, index);
62
+ run = [];
63
+ }
64
+ if (armed)
65
+ run.push(row);
66
+ quietAt = null;
67
+ }
68
+ const inputs = recording.timeline.filter(r => r.type === 'input'), last = inputs[inputs.length - 1];
69
+ if (run.length) {
70
+ if (last?.type === 'input' && Math.max(Math.abs(last.input.z), Math.abs(last.input.rx), Math.abs(last.input.ry)) <= .06)
71
+ finish(run, index);
72
+ else
73
+ issues.push('Discarded an unfinished excursion at recording end.');
74
+ }
75
+ });
76
+ const missing = Object.keys(counts).filter(k => counts[k] < minimum);
77
+ let tune = null;
78
+ if (!missing.length && !ambiguous) {
79
+ const peaks = actions.map(a => a.tiltPeak), activation = Math.min(.25, Math.min(...peaks) * .8);
80
+ const center = peaks[0] + peaks.reduce((sum, v) => sum + v - peaks[0], 0) / peaks.length;
81
+ tune = createGestureTune({ ...base.toJSON(), pressTilt: {
82
+ force: { center, low: Math.min(center, quantile(peaks, .1)), high: Math.max(center, quantile(peaks, .9)), activation, release: activation * .48 },
83
+ minMs: 25, armMs: Math.max(450, Math.ceil((Math.max(...actions.map(a => a.onsetDelayMs)) + 80) / 50) * 50), relaxMs: 180,
84
+ maxMs: Math.max(1000, Math.ceil(Math.max(...actions.map(a => a.end - a.start)) * 1.5 / 50) * 50), dominance: 1.25,
85
+ } });
86
+ }
87
+ return { status: ambiguous ? 'ambiguous' : missing.length ? 'incomplete' : 'ready', tune, counts, missing, actions, issues: [...new Set(issues)] };
88
+ }
@@ -0,0 +1,40 @@
1
+ import type { InputState } from './input.js';
2
+ import type { GestureEvent, GestureOptions } from './gestures.js';
3
+ import type { GestureTune } from './tune.js';
4
+ export type RecordingEntry = {
5
+ type: 'input';
6
+ t: number;
7
+ input: InputState;
8
+ } | {
9
+ type: 'advance' | 'reset';
10
+ t: number;
11
+ };
12
+ export interface GestureRecording {
13
+ version: 1;
14
+ source: 'device' | 'simulator';
15
+ note: string;
16
+ durationMs: number;
17
+ options: GestureOptions;
18
+ timeline: RecordingEntry[];
19
+ events: GestureEvent[];
20
+ }
21
+ export interface GestureRecorder {
22
+ readonly full: boolean;
23
+ input(input: Readonly<InputState>, timestampMs: number): void;
24
+ advance(timestampMs: number): void;
25
+ reset(timestampMs: number): void;
26
+ events(events: readonly GestureEvent[]): void;
27
+ snapshot(timestampMs: number): GestureRecording;
28
+ }
29
+ /** Transport-independent bounded recording. The app owns clock, saving and storage. */
30
+ export declare function createGestureRecorder(config: {
31
+ startTimeMs: number;
32
+ tune?: GestureTune;
33
+ options?: GestureOptions;
34
+ source?: 'device' | 'simulator';
35
+ note?: string;
36
+ maxDurationMs?: number;
37
+ maxEntries?: number;
38
+ }): GestureRecorder;
39
+ /** Validate data before calibration or graphing; recordings contain untrusted input. */
40
+ export declare function validateGestureRecording(recording: GestureRecording): void;
@@ -0,0 +1,48 @@
1
+ import { defaultGestureTune } from './tune.js';
2
+ /** Transport-independent bounded recording. The app owns clock, saving and storage. */
3
+ export function createGestureRecorder(config) {
4
+ const start = config.startTimeMs, max = config.maxDurationMs ?? 120000, limit = config.maxEntries ?? 50000;
5
+ if (!Number.isFinite(start) || !Number.isFinite(max) || max <= 0 || max > 120000 || !Number.isInteger(limit) || limit < 1 || limit > 60000)
6
+ throw new RangeError('Invalid recording bounds.');
7
+ const options = { ...(config.options ?? (config.tune ?? defaultGestureTune).toOptions()) };
8
+ const timeline = [], events = [];
9
+ let last = start, full = false;
10
+ function time(t) { if (!Number.isFinite(t) || t < last)
11
+ throw new RangeError('Recording clock must be finite and monotonic.'); last = t; return t - start; }
12
+ function add(row) { if (full)
13
+ return; if (row.t > max || timeline.length >= limit) {
14
+ full = true;
15
+ return;
16
+ } timeline.push(row); }
17
+ const copyInput = (input) => { const copy = {}; for (const key of ['x', 'y', 'z', 'rx', 'ry', 'rz']) {
18
+ if (!Number.isFinite(input[key]) || Math.abs(input[key]) > 1)
19
+ throw new RangeError('Recording axes must be in [-1,1].');
20
+ copy[key] = input[key];
21
+ } return copy; };
22
+ return {
23
+ get full() { return full; },
24
+ input(input, t) { const copied = copyInput(input); add({ type: 'input', t: time(t), input: copied }); },
25
+ advance(t) { add({ type: 'advance', t: time(t) }); }, reset(t) { add({ type: 'reset', t: time(t) }); },
26
+ events(values) { for (const e of values) {
27
+ const timestamp = e.timestamp - start;
28
+ if (!full && timestamp >= 0 && timestamp <= max && events.length < 10000)
29
+ events.push({ ...e, timestamp });
30
+ } },
31
+ snapshot(t) { const durationMs = Math.min(max, time(t)); return { version: 1, source: config.source ?? 'device', note: (config.note ?? '').slice(0, 500), durationMs, options: { ...options }, timeline: timeline.map(row => row.type === 'input' ? { ...row, input: { ...row.input } } : { ...row }), events: events.filter(e => e.timestamp <= durationMs).map(e => ({ ...e })) }; },
32
+ };
33
+ }
34
+ /** Validate data before calibration or graphing; recordings contain untrusted input. */
35
+ export function validateGestureRecording(recording) {
36
+ if (!recording || recording.version !== 1 || !Number.isFinite(recording.durationMs) || recording.durationMs < 0 || recording.durationMs > 122000 || !Array.isArray(recording.timeline) || recording.timeline.length > 60000)
37
+ throw new RangeError('Invalid recording.');
38
+ let last = 0;
39
+ for (const row of recording.timeline) {
40
+ if (!row || !Number.isFinite(row.t) || row.t < last || row.t > recording.durationMs || !['input', 'advance', 'reset'].includes(row.type))
41
+ throw new RangeError('Invalid recording timeline.');
42
+ last = row.t;
43
+ if (row.type === 'input')
44
+ for (const k of ['x', 'y', 'z', 'rx', 'ry', 'rz'])
45
+ if (!row.input || !Number.isFinite(row.input[k]) || Math.abs(row.input[k]) > 1)
46
+ throw new RangeError('Invalid recording input.');
47
+ }
48
+ }