Skip to content

Commit 61afda0

Browse files
committed
feat(signal): design signal concepts and add single-pointer signal stream
1 parent 5fb0cb0 commit 61afda0

File tree

20 files changed

+1000
-0
lines changed

20 files changed

+1000
-0
lines changed

.github/workflows/publish-package.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ jobs:
3838
- name: Build package
3939
run: npx nx build @gesturejs/${{ inputs.package }}
4040

41+
- name: Remove npm protocol from dependencies
42+
run: sed -i 's/"npm:@[^@]*@/""/g' packages/${{ inputs.package }}/package.json
43+
4144
- name: Publish to npm
4245
run: pnpm --filter @gesturejs/${{ inputs.package }} publish --access public --no-git-checks
4346
env:

packages/signal/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# @gesturejs/signal
2+
3+
**Signal** is a higher-level abstraction over events that merges and refines disparate inputs into a single, composable stream.
4+
5+
## Why @gesturejs/signal?
6+
7+
- **Unified Interface** - Subscribe to diverse event sources through one consistent API by normalizing them into a unified signal stream.
8+
- **GC Optimized** - Built-in object pooling prevents garbage collection pauses during high-frequency input (60+ events/sec)
9+
- **Observable-Based** - Works seamlessly with reactive patterns via `@gesturejs/stream`.
10+
11+
## Installation
12+
13+
```bash
14+
npm install @gesturejs/signal
15+
```
16+
17+
## Quick Start
18+
19+
### Single Pointer
20+
21+
```typescript
22+
import { singlePointer } from "@gesturejs/signal";
23+
24+
const stream = singlePointer(canvasElement);
25+
const unsub = stream.subscribe((pointer) => {
26+
console.log(pointer.x, pointer.y);
27+
console.log(pointer.phase); // start, move, end, cancel
28+
});
29+
```
30+
31+
## Recipes
32+
33+
### Use Other Events
34+
35+
```typescript
36+
import { touchEventsToSinglePointer } from "@gesturejs/signal";
37+
import { fromEvent, merge, pipe, filter } from "@gesturejs/stream";
38+
39+
/**
40+
* The `singlePointer()` factory is primarily designed for PointerEvents.
41+
* If you're working with TouchEvents, you can convert a TouchEvent stream via
42+
* `touchEventsToSinglePointer()` and keep using the same SinglePointer pipeline.
43+
*/
44+
const stream = pipe(
45+
merge(
46+
fromEvent(el, "touchstart"),
47+
fromEvent(el, "touchmove"),
48+
fromEvent(el, "touchend"),
49+
fromEvent(el, "touchcancel")
50+
),
51+
touchEventsToSinglePointer(),
52+
filter((p) => p.phase === "move")
53+
);
54+
const unsub = stream.subscribe((pointer) => {
55+
draw(pointer.x, pointer.y);
56+
});
57+
```
58+
59+
### Object Pooling
60+
61+
```typescript
62+
/**
63+
* Pooling reduces allocations/GC pressure for high-frequency input
64+
* - but emitted objects are reused (mutated/reset). Don't keep references.
65+
* - If you need to persist data, copy the fields you need.
66+
*/
67+
const spStream = singlePointer(canvasElement, { pooling: true });
68+
```
69+
70+
## Documentation
71+
72+
- [API Reference](./docs/api.md)
73+
- [Signal Structure](./docs/signal.md)
74+
75+
## License
76+
77+
MIT

packages/signal/docs/api.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# API Reference
2+
3+
## Factory
4+
5+
```typescript
6+
import { singlePointer } from "@gesturejs/signal";
7+
8+
const pointer$ = singlePointer(element, options?);
9+
```
10+
11+
### Options
12+
13+
```typescript
14+
interface SinglePointerOptions {
15+
deviceId?: string;
16+
pooling?: boolean;
17+
listenerOptions?: AddEventListenerOptions;
18+
}
19+
```
20+
21+
### Variants
22+
23+
```typescript
24+
// PointerEvent-based (default)
25+
import { singlePointer } from "@gesturejs/signal";
26+
27+
// TouchEvent-based
28+
import { touchSinglePointer } from "@gesturejs/signal";
29+
30+
// MouseEvent-based
31+
import { mouseSinglePointer } from "@gesturejs/signal";
32+
```
33+
34+
---
35+
36+
## Operator
37+
38+
For custom pipelines.
39+
40+
```typescript
41+
import { pointerEventsToSinglePointer } from "@gesturejs/signal";
42+
import { touchEventsToSinglePointer } from "@gesturejs/signal";
43+
import { mouseEventsToSinglePointer } from "@gesturejs/signal";
44+
```
45+
46+
```typescript
47+
import { pointerEventsToSinglePointer } from "@gesturejs/signal";
48+
import { fromEvent, merge, pipe, filter } from "@gesturejs/stream";
49+
50+
const pointer$ = pipe(
51+
merge(
52+
fromEvent(el, "pointerdown"),
53+
fromEvent(el, "pointermove"),
54+
fromEvent(el, "pointerup"),
55+
fromEvent(el, "pointercancel")
56+
),
57+
pointerEventsToSinglePointer({ pooling: true }),
58+
filter((p) => p.phase === "move")
59+
);
60+
```
61+
62+
---
63+
64+
## Emitter
65+
66+
For manual event handling.
67+
68+
```typescript
69+
import { createPointerEmitter } from "@gesturejs/signal";
70+
import { createTouchEmitter } from "@gesturejs/signal";
71+
import { createMouseEmitter } from "@gesturejs/signal";
72+
```
73+
74+
```typescript
75+
import { createPointerEmitter } from "@gesturejs/signal";
76+
77+
const emitter = createPointerEmitter({ pooling: true });
78+
79+
element.addEventListener("pointerdown", (e) => {
80+
const pointer = emitter.process(e);
81+
if (pointer) {
82+
console.log(pointer.x, pointer.y);
83+
}
84+
});
85+
86+
emitter.dispose();
87+
```
88+
89+
---
90+
91+
## Pooling
92+
93+
When `pooling: true`, objects are reused to reduce GC pressure. The emitter automatically releases the previous pointer when a new event arrives. No manual release is required.
94+
95+
**Warning**: Do not store pointer references outside the callback. They will be reused on the next event.

packages/signal/docs/signal.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Signal Structure
2+
3+
## SinglePointer
4+
5+
```typescript
6+
interface SinglePointer {
7+
type: "pointer";
8+
timestamp: number;
9+
deviceId: string;
10+
phase: PointerPhase;
11+
x: number;
12+
y: number;
13+
pointerType: PointerType;
14+
button: PointerButton;
15+
pressure: number; // 0.0 ~ 1.0
16+
}
17+
```
18+
19+
## Types
20+
21+
```typescript
22+
type PointerPhase = "start" | "move" | "end" | "cancel";
23+
24+
type PointerType = "mouse" | "touch" | "pen" | "unknown";
25+
26+
type PointerButton =
27+
| "none"
28+
| "primary"
29+
| "secondary"
30+
| "auxiliary"
31+
| "back"
32+
| "forward";
33+
```
34+
35+
## Phase
36+
37+
| Phase | Description |
38+
| -------- | ------------------------------------------ |
39+
| `start` | Pointer down (touch start, mouse down) |
40+
| `move` | Pointer moved |
41+
| `end` | Pointer up (touch end, mouse up) |
42+
| `cancel` | Pointer cancelled (e.g. touch interrupted) |

packages/signal/package.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "@gesturejs/signal",
3+
"description": "Unified pointer signal abstraction with object pooling for high-frequency gesture input",
4+
"version": "0.1.0",
5+
"private": true,
6+
"type": "module",
7+
"sideEffects": false,
8+
"main": "./dist/index.cjs",
9+
"module": "./dist/index.js",
10+
"types": "./dist/index.d.ts",
11+
"exports": {
12+
".": {
13+
"types": "./dist/index.d.ts",
14+
"import": "./dist/index.js",
15+
"require": "./dist/index.cjs"
16+
},
17+
"./core": {
18+
"types": "./dist/core/index.d.ts",
19+
"import": "./dist/core/index.js",
20+
"require": "./dist/core/index.cjs"
21+
},
22+
"./pointer-signal": {
23+
"types": "./dist/pointer-signal/index.d.ts",
24+
"import": "./dist/pointer-signal/index.js",
25+
"require": "./dist/pointer-signal/index.cjs"
26+
}
27+
},
28+
"files": [
29+
"dist",
30+
"README.md"
31+
],
32+
"scripts": {
33+
"build": "builder build",
34+
"test": "vitest run",
35+
"test:watch": "vitest"
36+
},
37+
"dependencies": {
38+
"@gesturejs/stream": "^0.1.0"
39+
},
40+
"devDependencies": {
41+
"@gesturejs/builder": "workspace:*"
42+
}
43+
}

packages/signal/src/core/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type {
2+
Signal,
3+
SignalWithMetadata,
4+
CancellableSignal,
5+
SignalPhase,
6+
} from "./signal.js";
7+
export { createSignal } from "./signal.js";
8+
9+
export type { ObjectPool } from "./pool.js";
10+
export { createObjectPool } from "./pool.js";

packages/signal/src/core/pool.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export interface ObjectPool<T> {
2+
acquire(): T;
3+
release(obj: T): void;
4+
clear(): void;
5+
readonly size: number;
6+
readonly active: number;
7+
}
8+
9+
/** Pre-allocates and reuses objects to reduce GC pressure. */
10+
export function createObjectPool<T>(
11+
factory: () => T,
12+
reset: (obj: T) => void,
13+
initialSize = 20,
14+
maxSize = 100
15+
): ObjectPool<T> {
16+
const pool: T[] = [];
17+
let activeCount = 0;
18+
19+
for (let i = 0; i < initialSize; i++) {
20+
pool.push(factory());
21+
}
22+
23+
return {
24+
acquire(): T {
25+
activeCount++;
26+
if (pool.length > 0) {
27+
return pool.pop()!;
28+
}
29+
return factory();
30+
},
31+
32+
release(obj: T): void {
33+
activeCount--;
34+
reset(obj);
35+
if (pool.length < maxSize) {
36+
pool.push(obj);
37+
}
38+
},
39+
40+
clear(): void {
41+
pool.length = 0;
42+
activeCount = 0;
43+
},
44+
45+
get size(): number {
46+
return pool.length;
47+
},
48+
49+
get active(): number {
50+
return activeCount;
51+
},
52+
};
53+
}

packages/signal/src/core/signal.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export interface Signal<TType extends string = string> {
2+
type: TType;
3+
timestamp: number;
4+
deviceId: string;
5+
}
6+
7+
export interface SignalWithMetadata<TType extends string = string>
8+
extends Signal<TType> {
9+
id?: string;
10+
sequence?: number;
11+
}
12+
13+
export interface CancellableSignal<TType extends string = string>
14+
extends Signal<TType> {
15+
cancelled: boolean;
16+
}
17+
18+
export type SignalPhase = "start" | "update" | "end" | "cancel";
19+
20+
export function createSignal<TType extends string>(
21+
type: TType,
22+
deviceId: string
23+
): Signal<TType> {
24+
return {
25+
type,
26+
timestamp: performance.now(),
27+
deviceId,
28+
};
29+
}

packages/signal/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from "./core/index.js";
2+
3+
export * from "./single-pointer/index.js";
4+
5+
export const VERSION = "0.1.0";

0 commit comments

Comments
 (0)