@clankagent/puck 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Puck contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # Puck
2
+
3
+ A small TypeScript library for responsive SpaceMouse pan and zoom. Zero runtime dependencies.
4
+
5
+ Incoming reports update input state. Your animation loop consumes time-based motion. Your application owns the camera and renderer.
6
+
7
+ ```sh
8
+ pnpm add @clankagent/puck
9
+ ```
10
+
11
+ ```js
12
+ import { createPanZoom } from '@clankagent/puck';
13
+ import { connectWebHid } from '@clankagent/puck/webhid';
14
+
15
+ const motion = createPanZoom({ zoomInput: 'press' });
16
+
17
+ connectButton.onclick = async () => {
18
+ connectButton.disabled = true;
19
+ try {
20
+ connection = await connectWebHid({
21
+ onInput: motion.setInput,
22
+ onReset: motion.reset,
23
+ onDisconnect() { connection = null; connectButton.disabled = false; },
24
+ });
25
+ } finally {
26
+ connectButton.disabled = Boolean(connection);
27
+ }
28
+ };
29
+
30
+ function frame(timestamp) {
31
+ const delta = motion.step(timestamp);
32
+ if (delta.moving) {
33
+ // Apply zoom about your chosen anchor, then pan in screen pixels.
34
+ camera.zoomAround(viewCenter, delta.zoomFactor);
35
+ camera.panBy(delta.panX, delta.panY);
36
+ render(camera);
37
+ }
38
+ requestAnimationFrame(frame);
39
+ }
40
+ let connection = null;
41
+ requestAnimationFrame(frame);
42
+
43
+ // A control can change this live. Pan and camera position are unaffected.
44
+ motion.setZoomInput('twist');
45
+
46
+ // On application teardown:
47
+ // await connection?.close();
48
+ // Cancel the application's animation frame as part of its own cleanup.
49
+ ```
50
+
51
+ `camera`, `render`, `viewCenter` and `connectButton` above belong to your app. There is no camera implementation or framework dependency in the package. See `examples/camera.mjs` for the complete anchor calculation, including zoom limits.
52
+
53
+ ## Responsibilities
54
+
55
+ | Part | Responsibility |
56
+ |---|---|
57
+ | Decoder | Convert a supported motion report to normalized six-axis cap deflection; ignore status packets. |
58
+ | WebHID adapter | Device selection, report delivery, foreground policy and connection cleanup. |
59
+ | Pan/zoom controller | Hold the latest input, apply deadzones, integrate response over frame time, emit movement deltas. |
60
+ | Your application | Own the render loop, input ownership, camera, zoom anchor, limits, and rendering. |
61
+
62
+ The core imports in Node without a browser. The browser adapter is a separate entry point and accesses browser APIs only when connecting. It starts no animation loop, timers, server, storage, or telemetry.
63
+
64
+ ## Motion behavior
65
+
66
+ - `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.
67
+ - A held cap requests velocity. Input report count does not determine movement distance. The latest deflection remains active between reports, including bursty delivery.
68
+ - 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.
69
+ - Neutral input stops on the next step, without software coasting. Reversal discards response in the old direction.
70
+ - `twist`: clockwise zooms in. `press`: downward pressure zooms in, lifting zooms out. These directions refer to the verified profile.
71
+ - The default 50 ms frame cap limits jumps after rendering stalls. It is not a timeout for input reports.
72
+ - 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.
73
+ - Use one controller per independent input stream. Do not drive both the report callback and frame loop with movement updates.
74
+
75
+ ## Hardware scope
76
+
77
+ 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.
78
+
79
+ 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).
80
+
81
+ ## Development
82
+
83
+ ```sh
84
+ pnpm install
85
+ pnpm check
86
+ ```
87
+
88
+ The build emits ESM and declarations. Tests run against those emitted modules. Runtime dependencies: none. Licensed under MIT. See [CONTRIBUTING.md](CONTRIBUTING.md) for development and device support.
@@ -0,0 +1,4 @@
1
+ export { decodeCombinedReport, neutralInput } from './input.js';
2
+ export type { InputState } from './input.js';
3
+ export { createPanZoom } from './motion.js';
4
+ export type { PanZoomController, PanZoomOptions, MotionDelta, ZoomInput } from './motion.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { decodeCombinedReport, neutralInput } from './input.js';
2
+ export { createPanZoom } from './motion.js';
@@ -0,0 +1,12 @@
1
+ /** Normalized cap deflection, in device coordinates. Not distance or velocity. */
2
+ export interface InputState {
3
+ x: number;
4
+ y: number;
5
+ z: number;
6
+ rx: number;
7
+ ry: number;
8
+ rz: number;
9
+ }
10
+ export declare const neutralInput: Readonly<InputState>;
11
+ /** Decode the measured combined report. Non-motion reports return null. */
12
+ export declare function decodeCombinedReport(reportId: number, data: DataView): InputState | null;
package/dist/input.js ADDED
@@ -0,0 +1,10 @@
1
+ export const neutralInput = Object.freeze({
2
+ x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0,
3
+ });
4
+ /** Decode the measured combined report. Non-motion reports return null. */
5
+ export function decodeCombinedReport(reportId, data) {
6
+ if (reportId !== 1 || data.byteLength !== 12)
7
+ return null;
8
+ const axis = (offset) => Math.max(-1, Math.min(1, data.getInt16(offset, true) / 350));
9
+ return { x: axis(0), y: axis(2), z: axis(4), rx: axis(6), ry: axis(8), rz: axis(10) };
10
+ }
@@ -0,0 +1,33 @@
1
+ import type { InputState } from './input.js';
2
+ export type ZoomInput = 'twist' | 'press';
3
+ export interface PanZoomOptions {
4
+ /** Positive twist or downward pressure zooms in. Default: twist. */
5
+ zoomInput?: ZoomInput;
6
+ /** Screen pixels per second at full deflection. Default: 1320. */
7
+ panSpeed?: number;
8
+ /** Logarithmic zoom change per second at full deflection. Default: 1.5. */
9
+ zoomSpeed?: number;
10
+ panDeadzone?: number;
11
+ zoomDeadzone?: number;
12
+ /** Acceleration response time. Neutral and reversal do not coast. Default: 25 ms. */
13
+ responseMs?: number;
14
+ /** Maximum integrated interval after a render stall. Default: 50 ms. */
15
+ maxFrameMs?: number;
16
+ }
17
+ export interface MotionDelta {
18
+ panX: number;
19
+ panY: number;
20
+ zoomFactor: number;
21
+ moving: boolean;
22
+ }
23
+ export interface PanZoomController {
24
+ /** Copy the latest deflection. Safe to pass directly as a callback. */
25
+ setInput(input: Readonly<InputState>): void;
26
+ /** Call once per render frame with a monotonic timestamp in milliseconds. */
27
+ step(timestampMs: number): MotionDelta;
28
+ setZoomInput(input: ZoomInput): void;
29
+ /** Clear input and response when application input ownership changes. */
30
+ reset(): void;
31
+ }
32
+ /** Stateful input processing; owns no camera, render loop, DOM, or event listeners. */
33
+ export declare function createPanZoom(options?: PanZoomOptions): PanZoomController;
package/dist/motion.js ADDED
@@ -0,0 +1,55 @@
1
+ import { neutralInput } from './input.js';
2
+ const idle = Object.freeze({ panX: 0, panY: 0, zoomFactor: 1, moving: false });
3
+ function deadzone(value, threshold) {
4
+ const unit = Math.max(-1, Math.min(1, value));
5
+ return Math.abs(unit) <= threshold ? 0 : Math.sign(unit) * (Math.abs(unit) - threshold) / (1 - threshold);
6
+ }
7
+ /** Stateful input processing; owns no camera, render loop, DOM, or event listeners. */
8
+ export function createPanZoom(options = {}) {
9
+ let zoomInput = options.zoomInput ?? 'twist';
10
+ const panSpeed = options.panSpeed ?? 1320;
11
+ const zoomSpeed = options.zoomSpeed ?? 1.5;
12
+ const panDeadzone = options.panDeadzone ?? .05;
13
+ const zoomDeadzone = options.zoomDeadzone ?? .1;
14
+ const responseMs = options.responseMs ?? 25;
15
+ const maxFrameMs = options.maxFrameMs ?? 50;
16
+ if (![panSpeed, zoomSpeed, responseMs, maxFrameMs].every(v => Number.isFinite(v) && v >= 0)
17
+ || ![panDeadzone, zoomDeadzone].every(v => Number.isFinite(v) && v >= 0 && v < 1)) {
18
+ throw new RangeError('Speeds and times must be finite and non-negative; deadzones must be in [0, 1).');
19
+ }
20
+ let current = { ...neutralInput };
21
+ let previous;
22
+ const velocity = [0, 0, 0];
23
+ function integrate(target, i, dt) {
24
+ if (target === 0) {
25
+ velocity[i] = 0;
26
+ return 0;
27
+ }
28
+ if (responseMs === 0) {
29
+ velocity[i] = target;
30
+ return target * dt / 1000;
31
+ }
32
+ const initial = velocity[i] * target < 0 ? 0 : velocity[i];
33
+ const blend = -Math.expm1(-dt / responseMs);
34
+ velocity[i] = initial + (target - initial) * blend;
35
+ return (target * dt + (initial - target) * responseMs * blend) / 1000;
36
+ }
37
+ return {
38
+ setInput(input) { current = { ...input }; },
39
+ setZoomInput(input) { zoomInput = input; velocity[2] = 0; },
40
+ reset() { current = { ...neutralInput }; velocity.fill(0); previous = undefined; },
41
+ step(timestamp) {
42
+ // Duplicate/out-of-order timestamps must not introduce extra elapsed time.
43
+ if (previous !== undefined && timestamp <= previous)
44
+ return idle;
45
+ const dt = previous === undefined ? 0 : Math.min(maxFrameMs, timestamp - previous);
46
+ previous = timestamp;
47
+ const panX = integrate(deadzone(-current.x, panDeadzone), 0, dt) * panSpeed;
48
+ const panY = integrate(deadzone(-current.y, panDeadzone), 1, dt) * panSpeed;
49
+ const logZoom = integrate(deadzone(zoomInput === 'press' ? current.z : current.rz, zoomDeadzone), 2, dt) * zoomSpeed;
50
+ if (panX === 0 && panY === 0 && logZoom === 0)
51
+ return idle;
52
+ return { panX, panY, zoomFactor: Math.exp(logZoom), moving: true };
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,51 @@
1
+ import type { InputState } from './input.js';
2
+ /** Structural browser interfaces keep WebHID globals out of the core package. */
3
+ export interface HidReportEvent extends Event {
4
+ reportId: number;
5
+ data: DataView;
6
+ }
7
+ export interface HidDevice extends EventTarget {
8
+ readonly vendorId: number;
9
+ readonly productId: number;
10
+ readonly opened: boolean;
11
+ open(): Promise<void>;
12
+ close(): Promise<void>;
13
+ }
14
+ export interface HidAccess extends EventTarget {
15
+ requestDevice(options: {
16
+ filters: Array<{
17
+ vendorId: number;
18
+ productId: number;
19
+ usagePage?: number;
20
+ usage?: number;
21
+ }>;
22
+ }): Promise<HidDevice[]>;
23
+ }
24
+ export interface DeviceProfile {
25
+ vendorId: number;
26
+ productId: number;
27
+ usagePage?: number;
28
+ usage?: number;
29
+ decode(reportId: number, data: DataView): InputState | null;
30
+ }
31
+ /** Hardware-verified combined six-axis report profile. */
32
+ export declare const combinedProfile: Readonly<DeviceProfile>;
33
+ export interface ConnectionOptions {
34
+ onInput(input: Readonly<InputState>): void;
35
+ /** Reset processor timing/response on pause, blur, hidden, close or disconnect. */
36
+ onReset?(): void;
37
+ onDisconnect?(): void;
38
+ profile?: DeviceProfile;
39
+ /** Inject for tests or an alternative WebHID implementation. */
40
+ hid?: HidAccess;
41
+ /** Default true: blur/hidden clears input and waits for a fresh report on return. */
42
+ pauseOnBlur?: boolean;
43
+ }
44
+ export interface InputConnection {
45
+ readonly device: HidDevice;
46
+ pause(): void;
47
+ resume(): void;
48
+ close(): Promise<void>;
49
+ }
50
+ /** Call from a user gesture. Cancellation returns null. No frame loop is started. */
51
+ export declare function connectWebHid(options: ConnectionOptions): Promise<InputConnection | null>;
package/dist/webhid.js ADDED
@@ -0,0 +1,75 @@
1
+ import { decodeCombinedReport, neutralInput } from './input.js';
2
+ /** Hardware-verified combined six-axis report profile. */
3
+ export const combinedProfile = Object.freeze({
4
+ vendorId: 0x256f, productId: 0xc63a, usagePage: 1, usage: 8, decode: decodeCombinedReport,
5
+ });
6
+ /** Call from a user gesture. Cancellation returns null. No frame loop is started. */
7
+ export async function connectWebHid(options) {
8
+ const hid = options.hid ?? globalThis.navigator?.hid;
9
+ if (!hid)
10
+ throw new Error('This browser does not provide WebHID.');
11
+ const profile = options.profile ?? combinedProfile;
12
+ const candidates = await hid.requestDevice({ filters: [{ vendorId: profile.vendorId, productId: profile.productId, usagePage: profile.usagePage, usage: profile.usage }] });
13
+ const device = candidates.find(candidate => candidate.vendorId === profile.vendorId && candidate.productId === profile.productId);
14
+ if (!device)
15
+ return null;
16
+ await device.open();
17
+ const page = typeof window === 'undefined' ? undefined : window;
18
+ const document = page?.document;
19
+ const focusPolicy = options.pauseOnBlur ?? true;
20
+ let paused = false;
21
+ let closed = false;
22
+ const isForeground = () => !focusPolicy || !document || document.hasFocus() && !document.hidden;
23
+ const clear = () => { options.onInput(neutralInput); options.onReset?.(); };
24
+ const report = (event) => {
25
+ if (paused || closed || !isForeground())
26
+ return;
27
+ const input = event;
28
+ const decoded = profile.decode(input.reportId, input.data);
29
+ if (decoded)
30
+ options.onInput(decoded);
31
+ };
32
+ const blur = () => { if (focusPolicy)
33
+ clear(); };
34
+ const visibility = () => { if (document?.hidden)
35
+ blur(); };
36
+ const detach = () => {
37
+ device.removeEventListener('inputreport', report);
38
+ hid.removeEventListener('disconnect', disconnect);
39
+ page?.removeEventListener('blur', blur);
40
+ document?.removeEventListener('visibilitychange', visibility);
41
+ };
42
+ const disconnect = (event) => {
43
+ if (event.device !== device)
44
+ return;
45
+ closed = true;
46
+ detach();
47
+ clear();
48
+ options.onDisconnect?.();
49
+ };
50
+ device.addEventListener('inputreport', report);
51
+ hid.addEventListener('disconnect', disconnect);
52
+ page?.addEventListener('blur', blur);
53
+ document?.addEventListener('visibilitychange', visibility);
54
+ return {
55
+ device,
56
+ pause() { if (!closed) {
57
+ paused = true;
58
+ clear();
59
+ } },
60
+ resume() { if (!closed)
61
+ paused = false; },
62
+ async close() {
63
+ if (closed)
64
+ return;
65
+ closed = true;
66
+ detach();
67
+ try {
68
+ clear();
69
+ }
70
+ finally {
71
+ await device.close();
72
+ }
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,10 @@
1
+ /** A renderer-owned camera. Coordinates are screen-pixel offsets from world origin. */
2
+ export function applyMotion(camera, delta, anchor, minZoom = .1, maxZoom = 5) {
3
+ const zoom = Math.max(minZoom, Math.min(maxZoom, camera.zoom * delta.zoomFactor));
4
+ const ratio = zoom / camera.zoom;
5
+ return {
6
+ x: anchor.x + (camera.x - anchor.x) * ratio + delta.panX,
7
+ y: anchor.y + (camera.y - anchor.y) * ratio + delta.panY,
8
+ zoom,
9
+ };
10
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@clankagent/puck",
3
+ "version": "0.1.0",
4
+ "description": "Frame-independent six-axis input and pan/zoom motion for the web",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "examples/camera.mjs"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./webhid": {
18
+ "types": "./dist/webhid.d.ts",
19
+ "import": "./dist/webhid.js"
20
+ }
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^7.0.2"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/clankagent/puck.git"
29
+ },
30
+ "homepage": "https://github.com/clankagent/puck#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/clankagent/puck/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "keywords": [
38
+ "spacemouse",
39
+ "webhid",
40
+ "pan",
41
+ "zoom",
42
+ "input"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "test": "node --test tests/*.test.mjs",
47
+ "check": "pnpm run build && pnpm test"
48
+ }
49
+ }