@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.
- package/AGENTS.md +14 -0
- package/CHANGELOG.md +57 -0
- package/CONTRIBUTING.md +20 -0
- package/DESIGN.md +42 -0
- package/README.md +36 -71
- package/VERIFICATION.md +92 -0
- package/dist/calibration.d.ts +49 -0
- package/dist/calibration.js +139 -0
- package/dist/gestures.d.ts +74 -0
- package/dist/gestures.js +230 -0
- package/dist/graph.d.ts +56 -0
- package/dist/graph.js +111 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/press-tilt-calibration.d.ts +32 -0
- package/dist/press-tilt-calibration.js +88 -0
- package/dist/recording.d.ts +40 -0
- package/dist/recording.js +48 -0
- package/dist/tilt-calibration.d.ts +26 -0
- package/dist/tilt-calibration.js +46 -0
- package/dist/tune.d.ts +58 -0
- package/dist/tune.js +56 -0
- package/docs/agents.md +34 -0
- package/docs/api.md +93 -0
- package/docs/lab.md +62 -0
- package/docs/motion.md +20 -0
- package/docs/press-tilt.md +131 -0
- package/docs/quickstart.md +78 -0
- package/docs/roadmap.md +44 -0
- package/docs/troubleshooting.md +27 -0
- package/docs/tuning.md +146 -0
- package/docs/upgrading.md +59 -0
- package/examples/gesture-session.mjs +56 -0
- package/llms.txt +29 -0
- package/package.json +17 -3
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CalibrationStats } from './calibration.js';
|
|
2
|
+
import type { TiltDirection } from './gestures.js';
|
|
3
|
+
import type { GestureRecording } from './recording.js';
|
|
4
|
+
import type { GestureTune } from './tune.js';
|
|
5
|
+
export type TiltActionName = `${TiltDirection}.${'single' | 'double'}`;
|
|
6
|
+
export interface TiltCalibrationAction {
|
|
7
|
+
direction: TiltDirection;
|
|
8
|
+
kind: 'single' | 'double';
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
recording: number;
|
|
12
|
+
}
|
|
13
|
+
export interface TiltCalibration {
|
|
14
|
+
status: 'ready' | 'incomplete' | 'ambiguous';
|
|
15
|
+
tune: GestureTune | null;
|
|
16
|
+
counts: Record<TiltActionName, number>;
|
|
17
|
+
missing: TiltActionName[];
|
|
18
|
+
actions: TiltCalibrationAction[];
|
|
19
|
+
stats: Partial<Record<TiltDirection, CalibrationStats>>;
|
|
20
|
+
issues: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Reuse the raw pulse parser on rx/ry; never pair across capture/reset boundaries. */
|
|
23
|
+
export declare function calibrateTilts(input: GestureRecording | readonly GestureRecording[], settings?: {
|
|
24
|
+
baseTune?: GestureTune;
|
|
25
|
+
minimumPerAction?: number;
|
|
26
|
+
}): TiltCalibration;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { calibrateGestures } from './calibration.js';
|
|
2
|
+
import { validateGestureRecording } from './recording.js';
|
|
3
|
+
import { createGestureTune, defaultGestureTune } from './tune.js';
|
|
4
|
+
const names = { clockwise: 'rx+', counterclockwise: 'rx-', push: 'ry+', pull: 'ry-' };
|
|
5
|
+
/** Reuse the raw pulse parser on rx/ry; never pair across capture/reset boundaries. */
|
|
6
|
+
export function calibrateTilts(input, settings = {}) {
|
|
7
|
+
const captures = Array.isArray(input) ? input : [input];
|
|
8
|
+
const base = settings.baseTune ?? defaultGestureTune;
|
|
9
|
+
const mapped = captures.map(c => {
|
|
10
|
+
validateGestureRecording(c);
|
|
11
|
+
let pressureOwned = false;
|
|
12
|
+
const timeline = c.timeline.map(row => {
|
|
13
|
+
if (row.type === 'reset') {
|
|
14
|
+
pressureOwned = false;
|
|
15
|
+
return row;
|
|
16
|
+
}
|
|
17
|
+
if (row.type !== 'input')
|
|
18
|
+
return row;
|
|
19
|
+
const v = row.input;
|
|
20
|
+
if (Math.max(Math.abs(v.z), Math.abs(v.rz), Math.abs(v.rx), Math.abs(v.ry)) <= .06)
|
|
21
|
+
pressureOwned = false;
|
|
22
|
+
if (Math.max(Math.abs(v.rx), Math.abs(v.ry)) < .15 && (v.z >= base.push.activation || -v.z >= base.pull.activation || Math.abs(v.rz) >= base.rotation.activation))
|
|
23
|
+
pressureOwned = true;
|
|
24
|
+
if (pressureOwned)
|
|
25
|
+
return { type: 'reset', t: row.t };
|
|
26
|
+
return { ...row, input: { x: 0, y: 0, z: v.ry, rz: v.rx, rx: 0, ry: 0 } };
|
|
27
|
+
});
|
|
28
|
+
return { ...c, timeline };
|
|
29
|
+
});
|
|
30
|
+
const result = calibrateGestures(mapped, { minimumPerAction: settings.minimumPerAction, detectionFloor: .06 });
|
|
31
|
+
const rename = (key) => { const [direction, kind] = key.split('.'); return `${names[direction]}.${kind}`; };
|
|
32
|
+
const counts = Object.fromEntries(Object.entries(result.counts).map(([k, v]) => [rename(k), v]));
|
|
33
|
+
let tune = null;
|
|
34
|
+
if (result.tune) {
|
|
35
|
+
const t = result.tune;
|
|
36
|
+
const rxActivation = Math.min(t.rotation.activation, Math.min(result.stats.clockwise.min, result.stats.counterclockwise.min) * .85);
|
|
37
|
+
const rx = { ...t.rotation, activation: rxActivation, release: Math.min(t.rotation.release, rxActivation * .8) };
|
|
38
|
+
const ry = Object.fromEntries(['center', 'low', 'high', 'activation', 'release'].map(k => [k, (t.push[k] + t.pull[k]) / 2]));
|
|
39
|
+
tune = createGestureTune({ ...base.toJSON(), standaloneTilt: { rx, ry, timing: { ...t.timing, minPulseMs: Math.min(25, t.timing.minPulseMs), neutralMs: 10 } } });
|
|
40
|
+
}
|
|
41
|
+
return { status: result.status, tune, counts, missing: result.missing.map(rename),
|
|
42
|
+
actions: result.actions.map(a => ({ direction: names[a.direction], kind: a.kind, start: a.start, end: a.end, recording: a.recording })),
|
|
43
|
+
stats: Object.fromEntries(Object.entries(result.stats).map(([k, v]) => [names[k], v])),
|
|
44
|
+
issues: result.issues.map(issue => issue.replace(/counterclockwise|clockwise|push|pull/g, k => names[k])),
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/tune.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { GestureOptions } from './gestures.js';
|
|
2
|
+
export interface ForceBand {
|
|
3
|
+
readonly center: number;
|
|
4
|
+
readonly low: number;
|
|
5
|
+
readonly high: number;
|
|
6
|
+
readonly activation: number;
|
|
7
|
+
readonly release: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PressTiltTune {
|
|
10
|
+
readonly force: ForceBand;
|
|
11
|
+
readonly minMs: number;
|
|
12
|
+
readonly armMs: number;
|
|
13
|
+
readonly relaxMs: number;
|
|
14
|
+
readonly maxMs: number;
|
|
15
|
+
readonly dominance: number;
|
|
16
|
+
}
|
|
17
|
+
export interface StandaloneTiltTune {
|
|
18
|
+
readonly rx: ForceBand;
|
|
19
|
+
readonly ry: ForceBand;
|
|
20
|
+
readonly timing: {
|
|
21
|
+
readonly minPulseMs: number;
|
|
22
|
+
readonly maxPulseMs: number;
|
|
23
|
+
readonly neutralMs: number;
|
|
24
|
+
readonly doubleMs: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface GestureTuneData {
|
|
28
|
+
readonly version: 1;
|
|
29
|
+
readonly rotation: ForceBand;
|
|
30
|
+
readonly push: ForceBand;
|
|
31
|
+
readonly pull: ForceBand;
|
|
32
|
+
readonly timing: {
|
|
33
|
+
readonly minPulseMs: number;
|
|
34
|
+
readonly maxPulseMs: number;
|
|
35
|
+
readonly neutralMs: number;
|
|
36
|
+
readonly doubleMs: number;
|
|
37
|
+
};
|
|
38
|
+
readonly dominance: number;
|
|
39
|
+
/** Optional for backward-compatible restoration of 0.2 tunes. Does not enable tilt mode by itself. */
|
|
40
|
+
readonly pressTilt?: PressTiltTune;
|
|
41
|
+
readonly standaloneTilt?: StandaloneTiltTune;
|
|
42
|
+
}
|
|
43
|
+
export interface GestureTune extends GestureTuneData {
|
|
44
|
+
soften(amount: number): GestureTune;
|
|
45
|
+
harden(amount: number): GestureTune;
|
|
46
|
+
narrow(amount: number): GestureTune;
|
|
47
|
+
widen(amount: number): GestureTune;
|
|
48
|
+
toOptions(): GestureOptions;
|
|
49
|
+
toJSON(): GestureTuneData;
|
|
50
|
+
}
|
|
51
|
+
/** Restore a plain JSON tune; all nested objects are copied and frozen. */
|
|
52
|
+
export declare function createGestureTune(data?: GestureTuneData): GestureTune;
|
|
53
|
+
export declare const defaultGestureTune: GestureTune;
|
|
54
|
+
export declare const gesturePresets: Readonly<{
|
|
55
|
+
default: GestureTune;
|
|
56
|
+
soft: GestureTune;
|
|
57
|
+
hard: GestureTune;
|
|
58
|
+
}>;
|
package/dist/tune.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const base = {
|
|
2
|
+
version: 1,
|
|
3
|
+
rotation: { center: .465, low: .349, high: .57, activation: .25, release: .15 },
|
|
4
|
+
push: { center: .314, low: .274, high: .354, activation: .20, release: .08 },
|
|
5
|
+
pull: { center: .289, low: .18, high: .357, activation: .12, release: .06 },
|
|
6
|
+
timing: { minPulseMs: 35, maxPulseMs: 650, neutralMs: 25, doubleMs: 400 },
|
|
7
|
+
dominance: 1.4,
|
|
8
|
+
pressTilt: { force: { center: .596, low: .401, high: .803, activation: .24, release: .115 }, minMs: 25, armMs: 450, relaxMs: 180, maxMs: 1000, dominance: 1.25 },
|
|
9
|
+
standaloneTilt: { rx: { center: .458, low: .319, high: .601, activation: .13, release: .104 }, ry: { center: .671, low: .536, high: .806, activation: .28, release: .16 }, timing: { minPulseMs: 25, maxPulseMs: 650, neutralMs: 10, doubleMs: 400 } },
|
|
10
|
+
};
|
|
11
|
+
const finite = (x) => typeof x === 'number' && Number.isFinite(x);
|
|
12
|
+
/** Restore a plain JSON tune; all nested objects are copied and frozen. */
|
|
13
|
+
export function createGestureTune(data = base) {
|
|
14
|
+
if (!data || data.version !== 1 || !finite(data.dominance) || data.dominance < 1)
|
|
15
|
+
throw new RangeError('Invalid tune version or dominance.');
|
|
16
|
+
for (const b of [data.rotation, data.push, data.pull, ...(data.pressTilt ? [data.pressTilt.force] : []), ...(data.standaloneTilt ? [data.standaloneTilt.rx, data.standaloneTilt.ry] : [])]) {
|
|
17
|
+
if (!b || ![b.center, b.low, b.high, b.activation, b.release].every(finite)
|
|
18
|
+
|| b.low < 0 || b.low > b.center || b.center > b.high || b.high > 1
|
|
19
|
+
|| b.release < 0 || b.activation <= b.release || b.activation > 1 || b.center <= 0)
|
|
20
|
+
throw new RangeError('Invalid force band.');
|
|
21
|
+
}
|
|
22
|
+
const t = data.timing;
|
|
23
|
+
const p = data.pressTilt;
|
|
24
|
+
if (p && (![p.minMs, p.armMs, p.relaxMs, p.maxMs, p.dominance].every(finite) || p.minMs < 0 || p.armMs < p.minMs || p.relaxMs < 0 || p.relaxMs > p.armMs || p.armMs > p.maxMs || p.maxMs > 10000 || p.dominance <= 1))
|
|
25
|
+
throw new RangeError('Invalid press-tilt tune.');
|
|
26
|
+
for (const timing of [t, ...(data.standaloneTilt ? [data.standaloneTilt.timing] : [])])
|
|
27
|
+
if (!timing || ![timing.minPulseMs, timing.maxPulseMs, timing.neutralMs, timing.doubleMs].every(v => finite(v) && v >= 0)
|
|
28
|
+
|| timing.minPulseMs > timing.maxPulseMs || timing.maxPulseMs > 10000 || timing.doubleMs > 10000 || timing.neutralMs > 1000)
|
|
29
|
+
throw new RangeError('Invalid tune timing.');
|
|
30
|
+
const s = data.standaloneTilt;
|
|
31
|
+
const value = Object.freeze({ version: 1, rotation: Object.freeze({ ...data.rotation }), push: Object.freeze({ ...data.push }), pull: Object.freeze({ ...data.pull }), timing: Object.freeze({ ...t }), dominance: data.dominance, ...(p ? { pressTilt: Object.freeze({ ...p, force: Object.freeze({ ...p.force }) }) } : {}), ...(s ? { standaloneTilt: Object.freeze({ rx: Object.freeze({ ...s.rx }), ry: Object.freeze({ ...s.ry }), timing: Object.freeze({ ...s.timing }) }) } : {}) });
|
|
32
|
+
const amount = (x, subtract) => { if (!finite(x) || x < 0 || (subtract && x >= 1) || x > 10)
|
|
33
|
+
throw new RangeError('Amount must be a non-negative fraction; soften/narrow require less than 1.'); return subtract ? 1 - x : 1 + x; };
|
|
34
|
+
function edit(factor, mode) {
|
|
35
|
+
function band(b) {
|
|
36
|
+
if (mode === 'force') {
|
|
37
|
+
// Scale each band uniformly; saturation preserves ordering and hysteresis.
|
|
38
|
+
const f = Math.min(factor, 1 / Math.max(b.high, b.activation));
|
|
39
|
+
return { center: b.center * f, low: b.low * f, high: b.high * f, activation: b.activation * f, release: b.release * f };
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const activation = Math.max(.001, b.center - (b.center - b.activation) * factor);
|
|
43
|
+
return { center: b.center, low: Math.max(0, b.center - (b.center - b.low) * factor), high: Math.min(1, b.center + (b.high - b.center) * factor), activation, release: activation * b.release / b.activation };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return createGestureTune({ ...value, rotation: band(value.rotation), push: band(value.push), pull: band(value.pull), ...(value.pressTilt ? { pressTilt: { ...value.pressTilt, force: band(value.pressTilt.force) } } : {}), ...(value.standaloneTilt ? { standaloneTilt: { ...value.standaloneTilt, rx: band(value.standaloneTilt.rx), ry: band(value.standaloneTilt.ry) } } : {}) });
|
|
47
|
+
}
|
|
48
|
+
return Object.freeze({ ...value,
|
|
49
|
+
soften(x) { return edit(amount(x, true), 'force'); }, harden(x) { return edit(amount(x, false), 'force'); },
|
|
50
|
+
narrow(x) { return edit(amount(x, true), 'spread'); }, widen(x) { return edit(amount(x, false), 'spread'); },
|
|
51
|
+
toJSON() { return value; },
|
|
52
|
+
toOptions() { const p = value.pressTilt, s = value.standaloneTilt; return { twistActivation: value.rotation.activation, twistRelease: value.rotation.release, pushActivation: value.push.activation, pushRelease: value.push.release, pullActivation: value.pull.activation, pullRelease: value.pull.release, ...value.timing, dominance: value.dominance, singleMode: 'exclusive', ...(p ? { tiltActivation: p.force.activation, tiltRelease: p.force.release, tiltMinMs: p.minMs, tiltArmMs: p.armMs, tiltRelaxMs: p.relaxMs, tiltMaxMs: p.maxMs, tiltDominance: p.dominance } : {}), ...(s ? { tiltXActivation: s.rx.activation, tiltXRelease: s.rx.release, tiltYActivation: s.ry.activation, tiltYRelease: s.ry.release, standaloneMinPulseMs: s.timing.minPulseMs, standaloneMaxPulseMs: s.timing.maxPulseMs, standaloneNeutralMs: s.timing.neutralMs, standaloneDoubleMs: s.timing.doubleMs } : {}) }; },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export const defaultGestureTune = createGestureTune();
|
|
56
|
+
export const gesturePresets = Object.freeze({ default: defaultGestureTune, soft: defaultGestureTune.soften(.2), hard: defaultGestureTune.harden(.2) });
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Agent integration guide
|
|
2
|
+
|
|
3
|
+
[Documentation](../README.md) · [API reference](api.md) · [Complete integration](quickstart.md)
|
|
4
|
+
|
|
5
|
+
This guide is for agents building applications with Puck. Repository contribution instructions live separately in [AGENTS.md](../AGENTS.md).
|
|
6
|
+
|
|
7
|
+
1. Read the README version note. Match docs to the installed package version and its exports/declarations; experimental checkout APIs may not exist in the registry release.
|
|
8
|
+
2. Read the API reference for exact entry points, axis signs, units and defaults. Use public imports only. Core is transport-independent; WebHID and graph rendering are optional.
|
|
9
|
+
3. Adapt the complete session example for gestures/recording, or the motion example for cameras. The application owns timers, rendering, persistence and teardown.
|
|
10
|
+
4. Verify behavior with synthetic inputs and the actual application lifecycle. Do not claim physical compatibility or subjective feel from synthetic tests.
|
|
11
|
+
|
|
12
|
+
## Integration invariants
|
|
13
|
+
|
|
14
|
+
- Six axes are normalized deflection, not velocity or angle. Verified signs: +rz clockwise, +z push down. Gestures are cap pulses, not physical button events.
|
|
15
|
+
- Feed every input report to the recognizer and recorder. Call advance during report silence. Use one monotonic millisecond clock; do not mix performance.now() reports with rAF-supplied gesture timestamps.
|
|
16
|
+
- Ignore the adapter's exact neutralInput lifecycle sentinel in the gesture report callback; reset on onReset. Do not infer a physical release on blur/disconnect. Reset requires fresh neutral to rearm.
|
|
17
|
+
- Exclusive singles intentionally wait for the double window. Immediate mode produces an additive single then double. Choose consciously.
|
|
18
|
+
- Tune edits return new immutable values. Persist JSON data; restore through createGestureTune. Rotation shares one band for both directions. Typical low/high are not acceptance limits.
|
|
19
|
+
- Calibration needs at least three instances of EACH of eight actions, in any order. Check tune for null, show missing/ambiguous results, and retain raw input for inspection. Deduplicate recordings before combining. Inferred counts are not labeled intent accuracy.
|
|
20
|
+
- No automatic backend, upload, storage or device reconnection is supplied. Never invent those capabilities or promise unverified hardware support.
|
|
21
|
+
|
|
22
|
+
## Verification checklist
|
|
23
|
+
|
|
24
|
+
Exercise a single, a double, neutral rearming, a long hold, a reversal, report silence, blur/disconnect and teardown. Confirm one action handler invocation per expected event and no completed gesture caused by disconnect. Check incomplete calibration and JSON tune restoration. Use performance.now() consistently in a browser and an explicit monotonic clock in deterministic tests.
|
|
25
|
+
|
|
26
|
+
For library contributions, run `pnpm check` from the source checkout. The tests use built ESM modules. Read CONTRIBUTING before proposing API/profile changes. Do not edit generated dist files as the source of truth.
|
|
27
|
+
|
|
28
|
+
## Tilt integrations (0.3)
|
|
29
|
+
|
|
30
|
+
Read [standalone and combined tilt](press-tilt.md) before enabling these families.
|
|
31
|
+
Use `calibrateTilts` for standalone singles/doubles and `calibratePressTilts` for
|
|
32
|
+
pressure-first combinations; do not interpret one family's coverage as another.
|
|
33
|
+
Handle expanded direction unions and optional event.tilt. Keep modes separate
|
|
34
|
+
from persisted tune data, and never claim inferred labels are ground truth.
|
package/docs/api.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
[Documentation](../README.md) · [Quickstart](quickstart.md) · [Tuning guide](tuning.md)
|
|
4
|
+
|
|
5
|
+
This reference describes 0.2.0, including its experimental gesture APIs. Emitted `dist/*.d.ts` files are the exact TypeScript signatures for your build. Named exports only; use the three supported entry points below, not deep imports into `dist`.
|
|
6
|
+
|
|
7
|
+
## Imports and units
|
|
8
|
+
|
|
9
|
+
| Entry point | Runtime exports |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `@clankagent/puck` | `decodeCombinedReport`, `neutralInput`, `createPanZoom`, `createGestures`, `createGestureTune`, `defaultGestureTune`, `gesturePresets`, `createGestureRecorder`, `validateGestureRecording`, `calibrateGestures`, `gestureDirections`, `calibratePressTilts`, `calibrateTilts`, `tiltDirections` |
|
|
12
|
+
| `@clankagent/puck/webhid` | `connectWebHid`, `combinedProfile` |
|
|
13
|
+
| `@clankagent/puck/graph` | `createGestureGraph`, `createPressTiltGraph`, `createTiltGraph`, `renderGestureGraphSvg` |
|
|
14
|
+
|
|
15
|
+
`InputState` has six finite normalized axes: `x,y,z,rx,ry,rz`, each in [-1,1]. They represent deflection, not angles. For the verified profile, positive `rz` is clockwise and positive `z` is push down; negative values are counterclockwise and pull up. Force bands use positive magnitudes [0,1]. Times are milliseconds. Recorder and calibration times are relative to capture start; live recognizer event times use the supplied clock.
|
|
16
|
+
|
|
17
|
+
`decodeCombinedReport(reportId, DataView)` accepts report 1 with exactly 12 bytes, decodes six little-endian signed 16-bit values divided by 350 and clamps to [-1,1]. Other reports return `null`. `neutralInput` is a shared frozen zero input; the WebHID adapter also uses its identity as a lifecycle sentinel.
|
|
18
|
+
|
|
19
|
+
## Motion
|
|
20
|
+
|
|
21
|
+
`createPanZoom(options?)` returns `setInput(input)`, `step(time)`, `setZoomInput('twist'|'press')`, `reset()`. Step returns `{panX, panY, zoomFactor, moving}`. Pan is screen pixels; zoom is multiplicative. First step is idle. Input is held until replaced; neutral stops on the next step.
|
|
22
|
+
|
|
23
|
+
| Option | Default |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `zoomInput` | `'twist'` |
|
|
26
|
+
| `panSpeed`, `zoomSpeed` | 1320 pixels/s, 1.5 log-units/s |
|
|
27
|
+
| `panDeadzone`, `zoomDeadzone` | .05, .1 |
|
|
28
|
+
| `responseMs`, `maxFrameMs` | 25, 50 |
|
|
29
|
+
|
|
30
|
+
Speeds/times must be finite and nonnegative; deadzones are in [0,1). Invalid options throw `RangeError`.
|
|
31
|
+
|
|
32
|
+
## Gestures
|
|
33
|
+
|
|
34
|
+
`createGestures(tuneOrOptions?)` returns `update(input,time)`, `advance(time)`, `reset()`, and read-only `state` (`phase`, `direction`, `pending`). Both update and advance return event arrays. Process EVERY report; advance even when reports are silent. Timestamps must be finite and nondecreasing or throw `RangeError`.
|
|
35
|
+
|
|
36
|
+
An event is `{direction, kind, timestamp, durationMs}`. Direction is `clockwise|counterclockwise|push|pull`; kind is `single|double`. Timestamp is the logical recognition deadline, which can precede dispatch. Reset cancels pending actions and requires fresh neutral input. A hold is not repeated presses; reversal without neutral cancels the action.
|
|
37
|
+
|
|
38
|
+
| Default | Rotation (both signs) | Push | Pull |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| Center | .465 | .314 | .289 |
|
|
41
|
+
| Typical low–high | .349–.570 | .274–.354 | .180–.357 |
|
|
42
|
+
| Activation / release | .25 / .15 | .20 / .08 | .12 / .06 |
|
|
43
|
+
|
|
44
|
+
Timing defaults: `minPulseMs:35`, `maxPulseMs:650`, `neutralMs:25`, `doubleMs:400`; `dominance:1.4`. The double window is completion-to-completion. Both gesture axes must return below their release levels for neutral dwell.
|
|
45
|
+
|
|
46
|
+
`singleMode:'exclusive'` (default) waits before emitting a single so a double can replace it. `'immediate'` emits a single first and later an additive double; no undo is emitted. Prefer exclusive when actions must not both fire.
|
|
47
|
+
|
|
48
|
+
Low-level gates are `activation/release`, `pressActivation/pressRelease`, `twistActivation/twistRelease`, and `clockwiseActivation/clockwiseRelease` (likewise `counterclockwise`, `push`, `pull`). Precedence: direction → axis → shared → default tune. `createGestures({})` matches the no-argument form. Gates require `0 <= release < activation <= 1`; invalid options throw `RangeError`.
|
|
49
|
+
|
|
50
|
+
For opt-in standalone rx/ry and combined pressure/tilt options, event shapes,
|
|
51
|
+
timing, calibration results and graph constructors, see the [tilt API](press-tilt.md).
|
|
52
|
+
`PulseDirection` names the original four; `TiltDirection` names rx+/rx-/ry+/ry-;
|
|
53
|
+
`GestureDirection` is their union. `GestureEvent.tilt` is optional and only present
|
|
54
|
+
for a combined single. `PressMode` is simple/auto/tilt. Exported tune types include
|
|
55
|
+
`PressTiltTune` and `StandaloneTiltTune`.
|
|
56
|
+
|
|
57
|
+
## Tunes
|
|
58
|
+
|
|
59
|
+
`createGestureTune(data?)` validates and copies a version-1 `GestureTuneData`: `{version, rotation, push, pull, timing, dominance}`. Each band contains `{center,low,high,activation,release}`; timing contains the four millisecond fields above. Returned objects are deeply frozen.
|
|
60
|
+
|
|
61
|
+
`defaultGestureTune` and `gesturePresets.default` are the baseline; `.soft` and `.hard` use 20% force adjustments. Methods `soften(x)`, `harden(x)`, `narrow(x)`, `widen(x)` return new tunes and never change timing. Amounts are fractions: .1 means 10%. Soften/narrow allow [0,1), harden/widen [0,10]. See [exact editing semantics](tuning.md#gesture-tunes).
|
|
62
|
+
|
|
63
|
+
`toOptions()` produces recognizer configuration. `toJSON()` produces frozen portable data. Restore parsed JSON with `createGestureTune(data)`; JSON alone has no methods. For nested edits, use a new object, e.g. `createGestureTune({...tune.toJSON(), timing:{...tune.timing,doubleMs:450}})`. Typical low/high are descriptive, not rejection cutoffs.
|
|
64
|
+
|
|
65
|
+
## Recording
|
|
66
|
+
|
|
67
|
+
`createGestureRecorder({startTimeMs,tune?,options?,source?,note?,maxDurationMs?,maxEntries?})` returns `input(input,time)`, `advance(time)`, `reset(time)`, `events(events)`, `snapshot(time)` and `full`. Explicit `options` takes precedence over tune. Source defaults to `'device'`; use `'simulator'` for generated input.
|
|
68
|
+
|
|
69
|
+
Input/tick/reset calls share a finite nondecreasing clock. Samples are copied. Default bounds are 120000 ms and 50000 entries (maximum configurable entries 60000); events cap at 10000. `full` signals a rejected timeline entry due to bounds; the app owns stopping and saving. Snapshot is detached, not a stop operation.
|
|
70
|
+
|
|
71
|
+
`GestureRecording` shape: `{version:1,source,note,durationMs,options,timeline,events}`. Timeline entries are `{type:'input',t,input}` or `{type:'advance'|'reset',t}`. All times, including stored event timestamps, are relative to capture start. `validateGestureRecording(recording)` checks version, duration and timeline shape/bounds/axis values; it is not exhaustive validation of every metadata field. Invalid recording input throws `RangeError`.
|
|
72
|
+
|
|
73
|
+
## Calibration
|
|
74
|
+
|
|
75
|
+
`calibrateGestures(recordingOrArray, settings?)` accepts 1–20 recordings. Settings: `minimumPerAction` integer 3–100 (default 3), `detectionFloor` (0,.1] (default .025), `pairGapMs` 50–500 (default 250).
|
|
76
|
+
|
|
77
|
+
Returns `{status,tune,counts,missing,stats,pulses,actions,issues}`. Status is `ready|incomplete|ambiguous`; **check `result.tune !== null` before using it** (the TypeScript interface is not a discriminated union). Counts use eight keys such as `clockwise.single` and `pull.double`. Stats hold per-direction peak count/mean/percentiles/min/max. Actions hold direction, kind, start, end, pulses and zero-based recording index. Pulses also expose peak, peakTime, valleyBefore and reset segment.
|
|
78
|
+
|
|
79
|
+
The parser infers actions from raw shape, independent of order and recognized event labels. Never claim counts are labeled accuracy. Separate captures/resets cannot form pairs. Repeated copies of a recording are NOT deduplicated by the SDK; the app must avoid duplicate evidence. See [calibration behavior](tuning.md#recording-and-custom-calibration).
|
|
80
|
+
|
|
81
|
+
## Graphs
|
|
82
|
+
|
|
83
|
+
`createGestureGraph(recording,tune?,{start?,end?,actions?,recordingIndex?}?)` returns `{start,end,lanes}`. Each of four lanes has direction/label, band values, `{t,value}` points and action spans. Times are capture-relative; values are positive magnitudes. Default tune is the baseline. A custom time window must be finite with `0 <= start < end`. For combined analysis, pass the matching `recordingIndex` (default 0).
|
|
84
|
+
|
|
85
|
+
`renderGestureGraphSvg(model,{width?,title?}?)` returns a standalone SVG string. Width defaults to 960, range 280–4000. Blue steps are held input, green dashed is activation, gray dotted is release, pale fill is typical range; shaded spans are inferred actions. Labels, title and description supplement color. Provide a text count/status summary alongside graphs in your UI. The model can also feed your own accessible chart renderer.
|
|
86
|
+
|
|
87
|
+
## WebHID lifecycle
|
|
88
|
+
|
|
89
|
+
`connectWebHid({onInput,onReset?,onDisconnect?,profile?,hid?,pauseOnBlur?})` returns a promise of connection or null on chooser cancellation. Connection exposes `device`, `pause()`, `resume()`, async `close()`. Default `pauseOnBlur:true` clears on blur/hidden. Pause/close/disconnect also clear; returning to foreground/resume requires a fresh report.
|
|
90
|
+
|
|
91
|
+
A clear calls `onInput(neutralInput)` then `onReset()`. For gestures, ignore that exact sentinel in onInput and reset the recognizer in onReset, so a lifecycle change cannot finish a press. Real zero reports are separate objects. Custom decoders should return fresh input objects for physical reports. Motion can consume the sentinel directly. Close removes listeners; your app must cancel its own frame loop.
|
|
92
|
+
|
|
93
|
+
`DeviceProfile` contains vendorId, productId, optional usagePage/usage, and `decode(reportId,data)`. An injected `hid` implements `HidAccess` for tests/custom transport integration. The default `combinedProfile` uses the verified IDs from the README. Do not infer additional device support from vendor ID alone.
|
package/docs/lab.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Gesture lab
|
|
2
|
+
|
|
3
|
+
[Documentation](../README.md)
|
|
4
|
+
|
|
5
|
+
Run from a source checkout; the lab server is not shipped in the npm package.
|
|
6
|
+
|
|
7
|
+
## Local gesture lab
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm build
|
|
11
|
+
pnpm lab
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The example demonstrates the same public SDK: freeform recording, automatic
|
|
15
|
+
calibration, minimum-example coverage, reusable tune JSON, immutable adjustments,
|
|
16
|
+
and inspectable graphs. Choose **Analyze saved recording**, then select an action
|
|
17
|
+
to zoom into it. **Use this tune** applies the result to live input. The default,
|
|
18
|
+
soft and hard presets are also available. Recordings can be combined to supply
|
|
19
|
+
missing examples; replaying the same saved ID does not duplicate its evidence.
|
|
20
|
+
|
|
21
|
+
The example server binds to loopback. Recordings stay outside the repository in
|
|
22
|
+
the user's local application data directory under `Puck/recordings`
|
|
23
|
+
(`PUCK_RECORDINGS_DIR` overrides it), and are not in the npm package. Apps should
|
|
24
|
+
provide their own storage. This example uses a same-origin session token for
|
|
25
|
+
writes, no CORS and no user authentication: keep it on loopback or behind a
|
|
26
|
+
trusted private proxy. Failed saves retain a retry/download backup. `pnpm
|
|
27
|
+
lab:analyze` reads the newest device recording, or accepts a JSON file path.
|
|
28
|
+
|
|
29
|
+
## Combined-gesture research
|
|
30
|
+
|
|
31
|
+
Open the lab with `?capture=press-move` for freeform push-move/pull-move capture.
|
|
32
|
+
Use natural pressure, including relaxation while rotating or tilting the cap.
|
|
33
|
+
Aim for three examples per push/pull × movement direction, plus ordinary single and
|
|
34
|
+
double presses for comparison; no required order or guided sequence. Multiple
|
|
35
|
+
captures are fine if two minutes is too short.
|
|
36
|
+
|
|
37
|
+
The recorder saves all six raw axes regardless of existing recognition gates.
|
|
38
|
+
The combined-tilt parser shows eight-direction coverage and six lanes for
|
|
39
|
+
pressure and tilt. Its tune preserves the base simple/double-press settings.
|
|
40
|
+
See the [combined gesture guide](press-tilt.md) and [1.0 criteria](roadmap.md).
|
|
41
|
+
|
|
42
|
+
For standalone tilt research, use `?capture=tilt`. Record at least three
|
|
43
|
+
singles and three doubles for each of the four rx/ry directions, without
|
|
44
|
+
deliberate push/pull or twist. Any order is fine. These captures are marked
|
|
45
|
+
separately and analyzed with the standalone tuner. Choose the Analyze selector
|
|
46
|
+
to override automatic family detection. Enable Combine with loaded recordings
|
|
47
|
+
before loading another partial capture. Use this tune enables the matching
|
|
48
|
+
family. The standalone toggle and push/pull mode can also be set independently.
|
|
49
|
+
|
|
50
|
+
## Verify capture before starting
|
|
51
|
+
|
|
52
|
+
Connect the device and move the cap once. Start recording remains disabled
|
|
53
|
+
until an actual device movement report arrives. During capture the report and
|
|
54
|
+
movement counts must rise when you move; all six axes are retained independently
|
|
55
|
+
of the gesture gates. Captures without movement are not saved as usable
|
|
56
|
+
recordings. Disconnect ends and saves a partial capture if movement was received.
|
|
57
|
+
The download backup remains available even for an empty diagnostic capture.
|
|
58
|
+
|
|
59
|
+
Simulator recording is only enabled explicitly with `?source=simulator` (or
|
|
60
|
+
`&source=simulator` after an existing query). It is labeled as simulator input
|
|
61
|
+
and is not physical calibration evidence. Reload after lab updates; a reload
|
|
62
|
+
requires reconnecting the device.
|
package/docs/motion.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Motion and hardware
|
|
2
|
+
|
|
3
|
+
[Documentation](../README.md) · [API reference](api.md)
|
|
4
|
+
|
|
5
|
+
## Motion behavior
|
|
6
|
+
|
|
7
|
+
- `step(timestampMs)` uses the timestamp supplied by your render loop, once per frame. The first step produces no movement. It never assumes a display refresh rate.
|
|
8
|
+
- A held cap requests velocity. Input report count does not determine movement distance. The latest deflection remains active between reports, including bursty delivery.
|
|
9
|
+
- Pan has a 0.05 normalized deadzone and full-deflection speed of 1320 screen pixels/second. Zoom has a 0.1 deadzone and log-speed of 1.5/second. Both use a 25 ms acceleration response.
|
|
10
|
+
- Neutral input stops on the next step, without software coasting. Reversal discards response in the old direction.
|
|
11
|
+
- `twist`: clockwise zooms in. `press`: downward pressure zooms in, lifting zooms out. These directions refer to the verified profile.
|
|
12
|
+
- The default 50 ms frame cap limits jumps after rendering stalls. It is not a timeout for input reports.
|
|
13
|
+
- The adapter clears input on blur, hidden state and disconnect. Connect `onReset` to `motion.reset` as above so old response and frame timing are also cleared. Returning to the foreground waits for a fresh report. Use connection `pause()` / `resume()` when another tool owns the input; use controller `reset()` if you provide your own transport.
|
|
14
|
+
- Use one controller per independent input stream. Do not drive both the report callback and frame loop with movement updates.
|
|
15
|
+
|
|
16
|
+
## Hardware scope
|
|
17
|
+
|
|
18
|
+
The report layout and motion defaults were measured with vendor `0x256f`, product `0xc63a`, over Bluetooth on Windows: report 1, twelve bytes, six signed little-endian 16-bit axes, logical range ±350. Buttons and other layouts are not implemented yet. Other devices require a matching `DeviceProfile`; profile support should be backed by descriptors and captures, not guessed from the vendor alone.
|
|
19
|
+
|
|
20
|
+
WebHID requires browser support and device permission. The adapter inherits that availability; the motion core does not. [Official WebHID guide](https://developer.chrome.com/docs/capabilities/hid).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Standalone and combined tilts (0.3.0)
|
|
2
|
+
|
|
3
|
+
[Documentation](../README.md) · [Roadmap](roadmap.md)
|
|
4
|
+
|
|
5
|
+
These experimental APIs are opt-in. Existing simple gestures remain the default.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import {createGestures, defaultGestureTune} from '@clankagent/puck';
|
|
9
|
+
const gestures = createGestures({...defaultGestureTune.toOptions(), pressMode:'auto'});
|
|
10
|
+
// A combined event: {direction:'push', tilt:'rx+', kind:'single', timestamp, durationMs}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`pressMode` is `simple` (existing default), `auto` (choose plain or combined), or
|
|
14
|
+
`tilt` (combined actions plus plain doubles; suppress plain singles). `pushMode`
|
|
15
|
+
and `pullMode` override it independently. Combined modes require
|
|
16
|
+
`singleMode:'exclusive'`. The `rx+`, `rx-`, `ry+`, `ry-` labels are device-axis
|
|
17
|
+
directions; applications map these to their own screen orientation.
|
|
18
|
+
|
|
19
|
+
Pressure arms tilt detection. The cap may relax while tilting; it need not stay
|
|
20
|
+
above the pressure activation gate. A brief full release can still be joined
|
|
21
|
+
to a subsequent tilt. Sustained tilt on the strongest direction chooses the
|
|
22
|
+
action; a substantially stronger later direction can replace a preparatory
|
|
23
|
+
tilt. A pressure reversal cancels the excursion. Pre-existing tilt cannot arm a
|
|
24
|
+
combination. Neutral, interruption and maximum duration prevent repeated holds.
|
|
25
|
+
|
|
26
|
+
| Option | Default | Meaning |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `tiltActivation`, `tiltRelease` | .24, .115 | Normalized force gates |
|
|
29
|
+
| `tiltMinMs` | 25 | Short noise dwell, not a target execution speed |
|
|
30
|
+
| `tiltArmMs` | 450 | Maximum time from pressure to tilt onset |
|
|
31
|
+
| `tiltRelaxMs` | 180 | Maximum gap from pressure release to resumed tilt |
|
|
32
|
+
| `tiltMaxMs` | 1000 | Maximum combined excursion duration |
|
|
33
|
+
| `tiltDominance` | 1.25 | Required directional separation |
|
|
34
|
+
|
|
35
|
+
The reference input is treated as the slower end of normal. Faster movements
|
|
36
|
+
are accepted; the upper allowances are not mandatory waits. Combined events
|
|
37
|
+
emit on full release plus neutral dwell, without waiting for a plain double.
|
|
38
|
+
Repeated combinations are separate single actions; the recognizer does not pair
|
|
39
|
+
them into combined doubles. Ordinary doubles retain completion-to-completion
|
|
40
|
+
timing. With an unusually short `doubleMs`, plain singles wait at least
|
|
41
|
+
`tiltRelaxMs + tiltMinMs` to leave time for a possible combination.
|
|
42
|
+
|
|
43
|
+
## Tunes, recordings and graphs
|
|
44
|
+
|
|
45
|
+
Tune JSON version 1 accepts optional `pressTilt` containing `force` (a standard
|
|
46
|
+
force band), `minMs`, `armMs`, `relaxMs`, `maxMs`, and `dominance`. Old tunes
|
|
47
|
+
remain restorable. The default tune includes these settings, but does not
|
|
48
|
+
enable combined recognition. Immutable force edits also edit the tilt band;
|
|
49
|
+
timings stay unchanged. Save the mode separately from the tune.
|
|
50
|
+
|
|
51
|
+
`calibratePressTilts(recordingOrArray, {baseTune?, minimumPerAction?})` derives
|
|
52
|
+
tilt settings from raw z/rx/ry, independent of event labels and group order. It
|
|
53
|
+
requires at least three inferred examples of all eight push/pull × tilt
|
|
54
|
+
combinations. The result has `status`, `tune`, `counts`, `missing`, `actions`
|
|
55
|
+
and `issues`; only use a non-null tune. The base defaults to
|
|
56
|
+
`defaultGestureTune`; ordinary press/twist force and double timing are preserved.
|
|
57
|
+
No inferred count is a claim of labeled accuracy. Simulator input is useful
|
|
58
|
+
for tests, not physical evidence. The parser uses fixed exploratory boundaries
|
|
59
|
+
(.06 neutral, .25 tilt detection, 180 ms quiet gap); very different motion may
|
|
60
|
+
need manual review. It does not learn every timing value from one capture.
|
|
61
|
+
|
|
62
|
+
From `@clankagent/puck/graph`, `createPressTiltGraph(recording,tune,{actions,
|
|
63
|
+
recordingIndex?,start?,end?})` produces six lanes (push, pull, four tilts) for
|
|
64
|
+
`renderGestureGraphSvg`. Each combination marks both its pressure and tilt lane.
|
|
65
|
+
Select a short window to see pressure relaxation instead of comparing an
|
|
66
|
+
entire minute at once.
|
|
67
|
+
|
|
68
|
+
The session example accepts `options:{pressMode:'auto'}` and records the exact
|
|
69
|
+
configuration. Lifecycle reset still cancels all pending actions. Apps retain
|
|
70
|
+
ownership of camera arbitration, recording storage, clocks and rendering.
|
|
71
|
+
|
|
72
|
+
## Standalone directional singles and doubles
|
|
73
|
+
|
|
74
|
+
Enable `standaloneTilt:true` to recognize rx+/rx−/ry+/ry− without a deliberate
|
|
75
|
+
push, pull or twist. Events use `direction:'rx+'` (or another tilt direction)
|
|
76
|
+
and `kind:'single'|'double'`, with no `tilt` field. Signs are device axes, not
|
|
77
|
+
screen directions. Map them in the consuming app.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
const gestures = createGestures({
|
|
81
|
+
...defaultGestureTune.toOptions(),
|
|
82
|
+
standaloneTilt: true,
|
|
83
|
+
pressMode: 'auto', // optional: also recognize pressure-first combinations
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The first qualifying motion owns the excursion: pressure-first can become a
|
|
88
|
+
combination; tilt-first remains standalone even if incidental pressure follows.
|
|
89
|
+
Neutral rx/ry completes a standalone pulse. Reversals without neutral cancel.
|
|
90
|
+
A double pairs completions within its own double window. Exclusive singles
|
|
91
|
+
wait; immediate singles are additive, as with ordinary presses. Combined modes
|
|
92
|
+
still require exclusive singles. Long holds and lifecycle resets do not emit taps.
|
|
93
|
+
|
|
94
|
+
| Standalone option | Default |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `tiltXActivation`, `tiltXRelease` | .13, .104 |
|
|
97
|
+
| `tiltYActivation`, `tiltYRelease` | .28, .16 |
|
|
98
|
+
| `standaloneMinPulseMs`, `standaloneMaxPulseMs` | 25, 650 |
|
|
99
|
+
| `standaloneNeutralMs`, `standaloneDoubleMs` | 10, 400 |
|
|
100
|
+
|
|
101
|
+
Tune version 1 also accepts optional `standaloneTilt:{rx,ry,timing}`. Each axis
|
|
102
|
+
has a force band shared by its positive/negative directions; timing contains
|
|
103
|
+
`minPulseMs,maxPulseMs,neutralMs,doubleMs`. Old JSON remains valid. Presets and
|
|
104
|
+
immutable edits include these bands; `toOptions()` supplies thresholds without
|
|
105
|
+
enabling recognition. Store your mode choices alongside the tune.
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import {calibrateTilts, createGestureTune} from '@clankagent/puck';
|
|
109
|
+
import {createTiltGraph, renderGestureGraphSvg} from '@clankagent/puck/graph';
|
|
110
|
+
const result = calibrateTilts(recordings, {baseTune: defaultGestureTune});
|
|
111
|
+
if (result.tune) {
|
|
112
|
+
const saved = JSON.stringify(result.tune);
|
|
113
|
+
const restored = createGestureTune(JSON.parse(saved));
|
|
114
|
+
const gestures = createGestures({...restored.toOptions(), standaloneTilt:true});
|
|
115
|
+
}
|
|
116
|
+
// Display result.status, counts, missing and issues even when tune is null.
|
|
117
|
+
const model = createTiltGraph(recordings[0], result.tune ?? defaultGestureTune,
|
|
118
|
+
{actions:result.actions, recordingIndex:0});
|
|
119
|
+
const svg = renderGestureGraphSvg(model);
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`calibrateTilts(recordingOrArray,{baseTune?,minimumPerAction?})` requires three
|
|
123
|
+
singles and three doubles per direction (eight action types), in any order.
|
|
124
|
+
It accepts 1–20 captures, preserves capture/reset boundaries, and ignores
|
|
125
|
+
pressure-first excursions. The result includes status, tune, counts, missing,
|
|
126
|
+
actions, stats and issues. Other gesture families retain their base settings.
|
|
127
|
+
Use one copy of each capture; duplicates are not new evidence. The parser uses
|
|
128
|
+
.06 exploratory neutral/detection and .15 early-pressure separation; review
|
|
129
|
+
unusual motion manually. Closely spaced singles may be inferred as doubles.
|
|
130
|
+
The graph shows four signed tilt lanes, force bands and inferred action spans.
|
|
131
|
+
These inferred labels are useful tuning evidence, not measured recognition accuracy.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Browser quickstart
|
|
2
|
+
|
|
3
|
+
[Documentation](../README.md) · [API reference](api.md)
|
|
4
|
+
|
|
5
|
+
These examples target the source checkout described in the README. Use a browser with WebHID in a secure context and call connection from a user click. A cancelled chooser returns `null`; unsupported browsers and device errors reject the promise. Catch and display those errors in your UI.
|
|
6
|
+
|
|
7
|
+
Copy [gesture-session.mjs](../examples/gesture-session.mjs) into your app. It is a complete connection and recording wrapper using public imports, with one clock, lifecycle resets and frame cleanup. Resolve its package imports through your bundler. It is an example file, not a package export.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { connectGestureSession } from './gesture-session.mjs';
|
|
11
|
+
import { calibrateGestures, defaultGestureTune } from '@clankagent/puck';
|
|
12
|
+
import { createGestureGraph, renderGestureGraphSvg } from '@clankagent/puck/graph';
|
|
13
|
+
|
|
14
|
+
// Your page supplies buttons with these IDs and a pre with id="output".
|
|
15
|
+
const connect = document.querySelector('#connect');
|
|
16
|
+
const start = document.querySelector('#start');
|
|
17
|
+
const stop = document.querySelector('#stop');
|
|
18
|
+
const output = document.querySelector('#output');
|
|
19
|
+
let session = null;
|
|
20
|
+
let connecting = false;
|
|
21
|
+
connect.onclick = async () => {
|
|
22
|
+
if (session || connecting) return;
|
|
23
|
+
connecting = true;
|
|
24
|
+
try {
|
|
25
|
+
session = await connectGestureSession({
|
|
26
|
+
onEvents: events => { output.textContent = JSON.stringify(events, null, 2); },
|
|
27
|
+
onDisconnect: () => { output.textContent = 'Disconnected. Save the capture before reconnecting.'; },
|
|
28
|
+
});
|
|
29
|
+
} catch (error) { output.textContent = String(error); }
|
|
30
|
+
finally { connecting = false; }
|
|
31
|
+
};
|
|
32
|
+
start.onclick = () => {
|
|
33
|
+
try { session?.startRecording(); }
|
|
34
|
+
catch (error) { output.textContent = String(error); }
|
|
35
|
+
};
|
|
36
|
+
stop.onclick = () => {
|
|
37
|
+
const recording = session?.stopRecording();
|
|
38
|
+
if (!recording) return;
|
|
39
|
+
const result = calibrateGestures(recording);
|
|
40
|
+
output.textContent = JSON.stringify({ status: result.status, counts: result.counts, issues: result.issues }, null, 2);
|
|
41
|
+
const graph = createGestureGraph(recording, result.tune ?? defaultGestureTune, { actions: result.actions });
|
|
42
|
+
// Add this SVG to a graph container, or save it as an .svg file.
|
|
43
|
+
const svg = renderGestureGraphSvg(graph);
|
|
44
|
+
console.log(svg);
|
|
45
|
+
// Persist JSON.stringify(recording) using your app's download/storage UI.
|
|
46
|
+
// When result.tune is non-null, persist JSON.stringify(result.tune) separately.
|
|
47
|
+
};
|
|
48
|
+
// On component teardown or before reconnecting:
|
|
49
|
+
async function dispose() {
|
|
50
|
+
const recording = session?.stopRecording(); // Save this if wanted.
|
|
51
|
+
await session?.close();
|
|
52
|
+
session = null;
|
|
53
|
+
return recording;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Start capture while the cap is neutral. Perform at least three singles AND three doubles in EACH direction: clockwise, counterclockwise, push down, pull up. Any order works. Finish with neutral and allow the double window to expire before stopping. The recorder is bounded; check `session.recordingFull` in your UI and stop/save when full. Reconnect with a new session after disconnect. To apply a learned tune, close the old session and pass `{ tune: result.tune }` when connecting the next one.
|
|
58
|
+
|
|
59
|
+
## Pan and zoom
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { createPanZoom } from '@clankagent/puck';
|
|
63
|
+
import { connectWebHid } from '@clankagent/puck/webhid';
|
|
64
|
+
|
|
65
|
+
const motion = createPanZoom({ zoomInput: 'press' });
|
|
66
|
+
let frameId;
|
|
67
|
+
function frame(time) {
|
|
68
|
+
const delta = motion.step(time);
|
|
69
|
+
// Your camera consumes delta.panX, delta.panY and delta.zoomFactor.
|
|
70
|
+
frameId = requestAnimationFrame(frame);
|
|
71
|
+
}
|
|
72
|
+
// In your connect click handler:
|
|
73
|
+
const connection = await connectWebHid({ onInput: motion.setInput, onReset: motion.reset });
|
|
74
|
+
if (connection) frameId = requestAnimationFrame(frame);
|
|
75
|
+
// On teardown: cancelAnimationFrame(frameId); await connection?.close();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
[Camera arithmetic](../examples/camera.mjs) shows zoom anchors and limits. Motion-only code can use rAF timestamps because reports do not advance its clock. For gestures use `performance.now()` in both callbacks, as in the session example. If combining motion and gestures, fan each report out to both processors and reset both on lifecycle changes; keep a single app-owned frame loop.
|