From 9f24a50744c8c4f070e847ce5602f5a5ea323ba8 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Wed, 7 Jan 2026 00:24:07 +0900 Subject: [PATCH 1/4] feat(core): introduce geometry types and refactor signals to use tuples - Add geometry module with Point, Vector types and operations - Refactor SinglePointer, MultiPointer, and gesture signals to use coordinate tuples - Update offset operator to return tuple-based offset property - Update all documentation and examples for new coordinate syntax --- README.md | 3 +- docs/src/content/core-concepts/key-models.mdx | 2 +- docs/src/content/examples/signature-pad.mdx | 11 +-- .../content/getting-started/quick-start.mdx | 5 +- docs/src/content/operator-api/offset.mdx | 17 +++-- docs/src/content/operator-api/reduce.mdx | 8 +- docs/src/content/operator-api/spy.mdx | 5 +- docs/src/content/operator-api/throttle.mdx | 6 +- docs/src/content/stream-api/multi-pointer.mdx | 13 ++-- docs/src/content/stream-api/pan.mdx | 22 +++--- docs/src/content/stream-api/pinch.mdx | 12 +-- .../src/content/stream-api/single-pointer.mdx | 14 ++-- docs/src/content/stream-api/tap.mdx | 15 ++-- packages/cereb/README.md | 3 +- packages/cereb/package.json | 5 ++ .../multi-pointer/multi-pointer-signal.ts | 13 ++-- .../recognizer-from-pointer.spec.ts | 8 +- .../multi-pointer/recognizer-from-pointer.ts | 6 +- .../single-pointer/recognizer-from-mouse.ts | 6 +- .../single-pointer/recognizer-from-pointer.ts | 6 +- .../single-pointer/recognizer-from-touch.ts | 6 +- .../single-pointer/single-pointer-signal.ts | 13 ++-- packages/cereb/src/geometry/creators.ts | 25 +++++++ packages/cereb/src/geometry/index.ts | 16 ++++ packages/cereb/src/geometry/operations.ts | 73 +++++++++++++++++++ packages/cereb/src/geometry/types.ts | 12 +++ packages/cereb/src/operators/offset.spec.ts | 18 ++--- packages/cereb/src/operators/offset.ts | 10 +-- packages/pan/README.md | 22 +++--- packages/pan/src/operators/axis-lock.ts | 43 +++++------ packages/pan/src/pan-signal.ts | 40 ++++------ packages/pan/src/pan-types.ts | 6 +- packages/pan/src/recognizer.ts | 58 +++++++-------- packages/pinch/README.md | 12 +-- packages/pinch/src/geometry.spec.ts | 43 ++++++----- packages/pinch/src/geometry.ts | 23 ++---- packages/pinch/src/pinch-signal.ts | 21 ++---- packages/pinch/src/pinch-types.ts | 6 +- packages/pinch/src/recognizer.ts | 10 +-- packages/tap/README.md | 9 +-- packages/tap/src/recognizer.spec.ts | 10 +-- packages/tap/src/recognizer.ts | 38 +++++----- packages/tap/src/state.spec.ts | 33 +++------ packages/tap/src/state.ts | 36 ++++----- packages/tap/src/tap-signal.ts | 19 ++--- packages/tap/src/tap-types.ts | 6 +- 46 files changed, 418 insertions(+), 370 deletions(-) create mode 100644 packages/cereb/src/geometry/creators.ts create mode 100644 packages/cereb/src/geometry/index.ts create mode 100644 packages/cereb/src/geometry/operations.ts create mode 100644 packages/cereb/src/geometry/types.ts diff --git a/README.md b/README.md index 1b8be83..9f49d3e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ singlePointer(canvas) // Listen to stream events .on((signal) => { // Receive signals from the stream - const { phase, x, y } = signal.value; + const { phase, cursor } = signal.value; + const [x, y] = cursor; switch (phase){ case "move": element.style.transform = `translate(${x}px, ${y}px)`; diff --git a/docs/src/content/core-concepts/key-models.mdx b/docs/src/content/core-concepts/key-models.mdx index c3843a5..ea33050 100644 --- a/docs/src/content/core-concepts/key-models.mdx +++ b/docs/src/content/core-concepts/key-models.mdx @@ -61,7 +61,7 @@ singlePointer(element) offset({ target: canvas }), // Add element-relative coords ) .on((signal) => { - // Transformed signal with offsetX, offsetY properties + // Transformed signal with offset property }); ``` diff --git a/docs/src/content/examples/signature-pad.mdx b/docs/src/content/examples/signature-pad.mdx index 5c28811..a1adedc 100644 --- a/docs/src/content/examples/signature-pad.mdx +++ b/docs/src/content/examples/signature-pad.mdx @@ -21,16 +21,17 @@ singlePointer(window) // Treat start → end as one session (signals outside the session are ignored). singlePointerSession(), - // `singlePointer(window)` yields window-relative x/y. - // Compute canvas-relative coordinates and add `offsetX`/`offsetY`. + // `singlePointer(window)` yields window-relative cursor. + // Compute canvas-relative coordinates and add `offset`. offset({ target: canvas }), ).on((signal) => { // Read values from the signal and draw. - const { phase, offsetX, offsetY, pointerType } = signal.value; + const { phase, offset, pointerType } = signal.value; + const [ox, oy] = offset; drawSignature({ - x: offsetX, - y: offsetY, + x: ox, + y: oy, phase, pointerType, }); diff --git a/docs/src/content/getting-started/quick-start.mdx b/docs/src/content/getting-started/quick-start.mdx index c614075..10a5691 100644 --- a/docs/src/content/getting-started/quick-start.mdx +++ b/docs/src/content/getting-started/quick-start.mdx @@ -139,10 +139,11 @@ Nothing happens until you subscribe. Call `.on()` to start receiving signals: ```typescript stream.on((signal) => { - const { phase, scale, centerX, centerY } = signal.value; + const { phase, scale, center } = signal.value; + const [cx, cy] = center; if (phase === "change") { - applyZoom(scale, centerX, centerY); + applyZoom(scale, cx, cy); } }); ``` diff --git a/docs/src/content/operator-api/offset.mdx b/docs/src/content/operator-api/offset.mdx index 158495d..0cea9c9 100644 --- a/docs/src/content/operator-api/offset.mdx +++ b/docs/src/content/operator-api/offset.mdx @@ -1,14 +1,14 @@ # `offset` -Adds element-relative coordinates (`offsetX`, `offsetY`) to pointer signals. +Adds element-relative coordinates (`offset`) to pointer signals. ## Signature ```typescript -function offset>(options: { +function offset>(options: { target: Element; recalculate$?: Stream; -}): Operator +}): Operator ``` ## Options @@ -28,7 +28,8 @@ singlePointer(canvas) .pipe(offset({ target: canvas })) .on((signal) => { // Draw at element-relative position - draw(signal.value.offsetX, signal.value.offsetY); + const [ox, oy] = signal.value.offset; + draw(ox, oy); }); ``` @@ -59,7 +60,8 @@ singlePointer(canvas) offset({ target: canvas }) ) .on((signal) => { - ctx.lineTo(signal.value.offsetX, signal.value.offsetY); + const [ox, oy] = signal.value.offset; + ctx.lineTo(ox, oy); ctx.stroke(); }); ``` @@ -70,7 +72,8 @@ singlePointer(canvas) pan(container) .pipe(offset({ target: container })) .on((signal) => { - draggable.style.left = `${signal.value.offsetX}px`; - draggable.style.top = `${signal.value.offsetY}px`; + const [ox, oy] = signal.value.offset; + draggable.style.left = `${ox}px`; + draggable.style.top = `${oy}px`; }); ``` diff --git a/docs/src/content/operator-api/reduce.mdx b/docs/src/content/operator-api/reduce.mdx index 4c278ae..5be3770 100644 --- a/docs/src/content/operator-api/reduce.mdx +++ b/docs/src/content/operator-api/reduce.mdx @@ -22,8 +22,8 @@ singlePointer(element) reduce( (acc, signal) => ({ count: acc.count + 1, - totalDistance: acc.totalDistance + Math.abs(signal.value.x - acc.lastX), - lastX: signal.value.x + totalDistance: acc.totalDistance + Math.abs(signal.value.cursor[0] - acc.lastX), + lastX: signal.value.cursor[0] }), { count: 0, totalDistance: 0, lastX: 0 } ) @@ -46,8 +46,8 @@ pan(element) .pipe( reduce( (acc, s) => ({ - cumulativeX: acc.cumulativeX + Math.abs(s.value.deltaX), - cumulativeY: acc.cumulativeY + Math.abs(s.value.deltaY) + cumulativeX: acc.cumulativeX + Math.abs(s.value.delta[0]), + cumulativeY: acc.cumulativeY + Math.abs(s.value.delta[1]) }), { cumulativeX: 0, cumulativeY: 0 } ) diff --git a/docs/src/content/operator-api/spy.mdx b/docs/src/content/operator-api/spy.mdx index 8f0e104..5a1ad83 100644 --- a/docs/src/content/operator-api/spy.mdx +++ b/docs/src/content/operator-api/spy.mdx @@ -22,7 +22,8 @@ import { spy } from "cereb/operators"; singlePointer(element) .pipe( spy((signal) => { - console.log(signal.value.x, signal.value.y); + const [x, y] = signal.value.cursor; + console.log(x, y); }) ) .on(handlePointer); @@ -34,7 +35,7 @@ singlePointer(element) ```typescript pan(element) - .pipe(spy((s) => console.log("Pan:", s.value.phase, s.value.deltaX))) + .pipe(spy((s) => console.log("Pan:", s.value.phase, s.value.delta[0]))) .on(handlePan); ``` diff --git a/docs/src/content/operator-api/throttle.mdx b/docs/src/content/operator-api/throttle.mdx index c4743fb..982e0a2 100644 --- a/docs/src/content/operator-api/throttle.mdx +++ b/docs/src/content/operator-api/throttle.mdx @@ -63,7 +63,8 @@ wheel(element) singlePointer(element) .pipe(throttle(16)) .on((s) => { - element.style.transform = `translate(${s.value.x}px, ${s.value.y}px)`; + const [x, y] = s.value.cursor; + element.style.transform = `translate(${x}px, ${y}px)`; }); ``` @@ -74,6 +75,7 @@ singlePointer(element) singlePointer(element) .pipe(throttleLast(100)) .on((s) => { - socket.emit("position", { x: s.value.x, y: s.value.y }); + const [x, y] = s.value.cursor; + socket.emit("position", { x, y }); }); ``` diff --git a/docs/src/content/stream-api/multi-pointer.mdx b/docs/src/content/stream-api/multi-pointer.mdx index 490fd1e..1f3db6c 100644 --- a/docs/src/content/stream-api/multi-pointer.mdx +++ b/docs/src/content/stream-api/multi-pointer.mdx @@ -16,7 +16,9 @@ multiPointer(element, { maxPointers: 2 }).on((signal) => { if (count === 2) { const [p1, p2] = pointers; - console.log(`Two fingers at (${p1.x}, ${p1.y}) and (${p2.x}, ${p2.y})`); + const [x1, y1] = p1.cursor; + const [x2, y2] = p2.cursor; + console.log(`Two fingers at (${x1}, ${y1}) and (${x2}, ${y2})`); } }); ``` @@ -51,10 +53,8 @@ Each pointer in the `pointers` array contains: |----------|------|-------------| | `id` | `string` | Unique pointer identifier | | `phase` | `"start" \| "move" \| "end" \| "cancel"` | Individual pointer phase | -| `x` | `number` | Current clientX | -| `y` | `number` | Current clientY | -| `pageX` | `number` | Current pageX | -| `pageY` | `number` | Current pageY | +| `cursor` | `[number, number]` | Current position [x, y] (client) | +| `pageCursor` | `[number, number]` | Current position [pageX, pageY] (page) | | `pointerType` | `"mouse" \| "touch" \| "pen" \| "unknown"` | Input device type | | `button` | `"none" \| "primary" \| "secondary" \| ...` | Mouse button pressed | | `pressure` | `number` | Pressure value (0.0-1.0) | @@ -84,7 +84,8 @@ multiPointer(element, { maxPointers: 2 }) pinchRecognizer() ) .on((signal) => { - const { ratio, centerX, centerY } = signal.value; + const { ratio, center } = signal.value; + const [cx, cy] = center; // Handle pinch... }); ``` diff --git a/docs/src/content/stream-api/pan.mdx b/docs/src/content/stream-api/pan.mdx index 6114396..7bfc8f3 100644 --- a/docs/src/content/stream-api/pan.mdx +++ b/docs/src/content/stream-api/pan.mdx @@ -12,10 +12,11 @@ npm install --save @cereb/pan import { pan } from "@cereb/pan"; pan(element).on((signal) => { - const { phase, deltaX, deltaY } = signal.value; + const { phase, delta } = signal.value; + const [dx, dy] = delta; if (phase === "move") { - element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + element.style.transform = `translate(${dx}px, ${dy}px)`; } }); ``` @@ -53,16 +54,12 @@ The `signal.value` contains: | Property | Type | Description | |----------|------|-------------| | `phase` | `"start" \| "move" \| "end" \| "cancel"` | Current gesture phase | -| `deltaX` | `number` | X displacement from start (px) | -| `deltaY` | `number` | Y displacement from start (px) | +| `cursor` | `[number, number]` | Current position [x, y] (client) | +| `pageCursor` | `[number, number]` | Current position [pageX, pageY] (page) | +| `delta` | `[number, number]` | Displacement from start [deltaX, deltaY] | +| `velocity` | `[number, number]` | Velocity [vx, vy] (px/ms) | | `distance` | `number` | Total cumulative distance (px) | | `direction` | `"up" \| "down" \| "left" \| "right" \| "none"` | Current movement direction | -| `velocityX` | `number` | X velocity (px/ms) | -| `velocityY` | `number` | Y velocity (px/ms) | -| `x` | `number` | Current clientX | -| `y` | `number` | Current clientY | -| `pageX` | `number` | Current pageX | -| `pageY` | `number` | Current pageY | ## Phase Lifecycle @@ -88,8 +85,9 @@ import { axisLock } from "@cereb/pan/operators"; pan(element) .pipe(axisLock()) .on((signal) => { - // One of deltaX/deltaY will be 0 after axis is determined - element.style.transform = `translate(${signal.value.deltaX}px, ${signal.value.deltaY}px)`; + const [dx, dy] = signal.value.delta; + // One of dx/dy will be 0 after axis is determined + element.style.transform = `translate(${dx}px, ${dy}px)`; }); ``` diff --git a/docs/src/content/stream-api/pinch.mdx b/docs/src/content/stream-api/pinch.mdx index ad7b83b..d265b17 100644 --- a/docs/src/content/stream-api/pinch.mdx +++ b/docs/src/content/stream-api/pinch.mdx @@ -12,7 +12,8 @@ npm install --save @cereb/pinch import { pinch } from "@cereb/pinch"; pinch(element).on((signal) => { - const { phase, ratio, centerX, centerY } = signal.value; + const { phase, ratio, center } = signal.value; + const [cx, cy] = center; if (phase === "change") { element.style.transform = `scale(${ratio})`; @@ -44,10 +45,8 @@ The `signal.value` contains: | `ratio` | `number` | Current distance / initial distance | | `deltaDistance` | `number` | Distance change since last event (px) | | `velocity` | `number` | Distance change velocity (px/ms) | -| `centerX` | `number` | Center X between pointers (clientX) | -| `centerY` | `number` | Center Y between pointers (clientY) | -| `pageCenterX` | `number` | Center X between pointers (pageX) | -| `pageCenterY` | `number` | Center Y between pointers (pageY) | +| `center` | `[number, number]` | Center between pointers [x, y] (client) | +| `pageCenter` | `[number, number]` | Center between pointers [pageX, pageY] (page) | ## Phase Lifecycle @@ -71,7 +70,8 @@ import { zoom } from "cereb/operators"; pinch(element) .pipe(zoom({ minScale: 0.5, maxScale: 3.0 })) .on((signal) => { - const { scale, centerX, centerY } = signal.value; + const { scale, center } = signal.value; + const [cx, cy] = center; element.style.transform = `scale(${scale})`; }); ``` diff --git a/docs/src/content/stream-api/single-pointer.mdx b/docs/src/content/stream-api/single-pointer.mdx index 8d7e0e5..9702a15 100644 --- a/docs/src/content/stream-api/single-pointer.mdx +++ b/docs/src/content/stream-api/single-pointer.mdx @@ -12,7 +12,8 @@ npm install --save cereb import { singlePointer } from "cereb"; singlePointer(element).on((signal) => { - const { phase, x, y, pointerType } = signal.value; + const { phase, cursor, pointerType } = signal.value; + const [x, y] = cursor; if (phase === "start") { console.log(`${pointerType} started at (${x}, ${y})`); @@ -33,10 +34,8 @@ The `signal.value` contains: | Property | Type | Description | |----------|------|-------------| | `phase` | `"start" \| "move" \| "end" \| "cancel"` | Current pointer phase | -| `x` | `number` | Current clientX | -| `y` | `number` | Current clientY | -| `pageX` | `number` | Current pageX | -| `pageY` | `number` | Current pageY | +| `cursor` | `[number, number]` | Current position [x, y] (client) | +| `pageCursor` | `[number, number]` | Current position [pageX, pageY] (page) | | `pointerType` | `"mouse" \| "touch" \| "pen" \| "unknown"` | Input device type | | `button` | `"none" \| "primary" \| "secondary" \| ...` | Mouse button pressed | | `pressure` | `number` | Pressure value (0.0-1.0, default 0.5) | @@ -61,7 +60,8 @@ pointer down → "start" → "move"* → "end" or "cancel" // Even with multiple fingers, only the first touch is tracked singlePointer(canvas).on((signal) => { // signal.value contains only primary pointer data - drawAt(signal.value.x, signal.value.y); + const [x, y] = signal.value.cursor; + drawAt(x, y); }); ``` @@ -81,7 +81,7 @@ singlePointer(element) offset({ target }) // Add element-relative coordinates ) .on((signal) => { - const { offsetX, offsetY } = signal.value; + const [offsetX, offsetY] = signal.value.offset; element.style.left = `${offsetX}px`; element.style.top = `${offsetY}px`; }); diff --git a/docs/src/content/stream-api/tap.mdx b/docs/src/content/stream-api/tap.mdx index 802347d..6a95b0c 100644 --- a/docs/src/content/stream-api/tap.mdx +++ b/docs/src/content/stream-api/tap.mdx @@ -12,7 +12,8 @@ npm install --save @cereb/tap import { tap } from "@cereb/tap"; tap(element).on((signal) => { - const { tapCount, x, y } = signal.value; + const { tapCount, cursor } = signal.value; + const [x, y] = cursor; if (tapCount === 1) { console.log("Single tap"); @@ -60,10 +61,8 @@ The `signal.value` contains: | Property | Type | Description | |----------|------|-------------| | `phase` | `"start" \| "end" \| "cancel"` | Current gesture phase | -| `x` | `number` | Tap X position (clientX) | -| `y` | `number` | Tap Y position (clientY) | -| `pageX` | `number` | Tap X position (pageX) | -| `pageY` | `number` | Tap Y position (pageY) | +| `cursor` | `[number, number]` | Tap position (client coordinates) | +| `pageCursor` | `[number, number]` | Tap position (page coordinates) | | `tapCount` | `number` | Consecutive tap count (1, 2, 3, ...) | | `duration` | `number` | How long pointer was pressed (ms) | | `pointerType` | `"mouse" \| "touch" \| "pen" \| "unknown"` | Input device type | @@ -164,10 +163,8 @@ function handlePointerEvent(signal: TapSourceSignal) { interface TapSourceSignal { value: { phase: "start" | "move" | "end" | "cancel"; - x: number; - y: number; - pageX: number; - pageY: number; + cursor: readonly [number, number]; + pageCursor: readonly [number, number]; pointerType: "touch" | "mouse" | "pen" | "unknown"; }; createdAt: number; diff --git a/packages/cereb/README.md b/packages/cereb/README.md index f7a233c..c85be65 100644 --- a/packages/cereb/README.md +++ b/packages/cereb/README.md @@ -21,7 +21,8 @@ singlePointer(canvas) // Listen to stream events .on((signal) => { // Receive signals from the stream - const { phase, x, y } = signal.value; + const { phase, cursor } = signal.value; + const [x, y] = cursor; switch (phase){ case "move": element.style.transform = `translate(${x}px, ${y}px)`; diff --git a/packages/cereb/package.json b/packages/cereb/package.json index 546b3a6..721def6 100644 --- a/packages/cereb/package.json +++ b/packages/cereb/package.json @@ -58,6 +58,11 @@ "types": "./dist/single-pointer/pointer.d.ts", "import": "./dist/single-pointer/pointer.js", "require": "./dist/single-pointer/pointer.cjs" + }, + "./geometry": { + "types": "./dist/geometry/index.d.ts", + "import": "./dist/geometry/index.js", + "require": "./dist/geometry/index.cjs" } }, "files": [ diff --git a/packages/cereb/src/browser/multi-pointer/multi-pointer-signal.ts b/packages/cereb/src/browser/multi-pointer/multi-pointer-signal.ts index d9324a1..4de5188 100644 --- a/packages/cereb/src/browser/multi-pointer/multi-pointer-signal.ts +++ b/packages/cereb/src/browser/multi-pointer/multi-pointer-signal.ts @@ -1,4 +1,5 @@ import { createSignal, type Signal } from "../../core/signal.js"; +import type { Point } from "../../geometry/types.js"; import type { SinglePointerButton, SinglePointerPhase, @@ -36,10 +37,8 @@ export type MultiPointerPhase = "idle" | "active" | "ended"; export interface PointerInfo { id: string; phase: SinglePointerPhase; - x: number; - y: number; - pageX: number; - pageY: number; + cursor: Point; + pageCursor: Point; pointerType: SinglePointerType; button: SinglePointerButton; /** 0.0 ~ 1.0, default 0.5 if unsupported */ @@ -54,10 +53,8 @@ export function createDefaultPointerInfo(): PointerInfo { return { id: "", phase: "move", - x: 0, - y: 0, - pageX: 0, - pageY: 0, + cursor: [0, 0], + pageCursor: [0, 0], pointerType: "unknown", button: "none", pressure: 0.5, diff --git a/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.spec.ts b/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.spec.ts index 725429a..f510cbc 100644 --- a/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.spec.ts +++ b/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.spec.ts @@ -87,10 +87,10 @@ describe("multiPointerFromPointer", () => { const lastSnapshot = values[2].value; expect(lastSnapshot.count).toBe(2); - expect(lastSnapshot.pointers[0].x).toBe(15); - expect(lastSnapshot.pointers[0].y).toBe(25); - expect(lastSnapshot.pointers[1].x).toBe(100); - expect(lastSnapshot.pointers[1].y).toBe(200); + expect(lastSnapshot.pointers[0].cursor[0]).toBe(15); + expect(lastSnapshot.pointers[0].cursor[1]).toBe(25); + expect(lastSnapshot.pointers[1].cursor[0]).toBe(100); + expect(lastSnapshot.pointers[1].cursor[1]).toBe(200); }); it("should correctly handle pointer end and removal", () => { diff --git a/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.ts b/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.ts index 0ff4957..2836d74 100644 --- a/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.ts +++ b/packages/cereb/src/browser/multi-pointer/recognizer-from-pointer.ts @@ -108,10 +108,8 @@ function createPointerInfo(e: PointerEvent, phase: SinglePointerPhase): PointerI return { id: `${e.pointerType}-${e.pointerId}`, phase, - x: e.clientX, - y: e.clientY, - pageX: e.pageX, - pageY: e.pageY, + cursor: [e.clientX, e.clientY], + pageCursor: [e.pageX, e.pageY], pointerType: normalizePointerType(e.pointerType), button, pressure: e.pressure, diff --git a/packages/cereb/src/browser/single-pointer/recognizer-from-mouse.ts b/packages/cereb/src/browser/single-pointer/recognizer-from-mouse.ts index 6c63464..db188c4 100644 --- a/packages/cereb/src/browser/single-pointer/recognizer-from-mouse.ts +++ b/packages/cereb/src/browser/single-pointer/recognizer-from-mouse.ts @@ -38,10 +38,8 @@ export function createMouseRecognizer( const v = signal.value as DeepMutable; v.id = ""; v.phase = phase; - v.x = e.clientX; - v.y = e.clientY; - v.pageX = e.pageX; - v.pageY = e.pageY; + v.cursor = [e.clientX, e.clientY]; + v.pageCursor = [e.pageX, e.pageY]; v.pointerType = "mouse"; v.button = button; v.pressure = phase === "move" && e.buttons === 0 ? 0 : 0.5; diff --git a/packages/cereb/src/browser/single-pointer/recognizer-from-pointer.ts b/packages/cereb/src/browser/single-pointer/recognizer-from-pointer.ts index 3fdb5d4..3ffd705 100644 --- a/packages/cereb/src/browser/single-pointer/recognizer-from-pointer.ts +++ b/packages/cereb/src/browser/single-pointer/recognizer-from-pointer.ts @@ -48,10 +48,8 @@ export function createPointerRecognizer( const v = signal.value as DeepMutable; v.id = `${e.pointerType}-${e.pointerId}`; v.phase = phase; - v.x = e.clientX; - v.y = e.clientY; - v.pageX = e.pageX; - v.pageY = e.pageY; + v.cursor = [e.clientX, e.clientY]; + v.pageCursor = [e.pageX, e.pageY]; v.pointerType = normalizePointerType(e.pointerType); v.button = button; v.pressure = e.pressure; diff --git a/packages/cereb/src/browser/single-pointer/recognizer-from-touch.ts b/packages/cereb/src/browser/single-pointer/recognizer-from-touch.ts index ef03a24..937bac3 100644 --- a/packages/cereb/src/browser/single-pointer/recognizer-from-touch.ts +++ b/packages/cereb/src/browser/single-pointer/recognizer-from-touch.ts @@ -33,10 +33,8 @@ export function createTouchRecognizer( const v = signal.value as DeepMutable; v.phase = phase; - v.x = touch.clientX; - v.y = touch.clientY; - v.pageX = touch.pageX; - v.pageY = touch.pageY; + v.cursor = [touch.clientX, touch.clientY]; + v.pageCursor = [touch.pageX, touch.pageY]; v.pointerType = "touch"; v.button = "none"; v.pressure = touch.force || 0.5; diff --git a/packages/cereb/src/browser/single-pointer/single-pointer-signal.ts b/packages/cereb/src/browser/single-pointer/single-pointer-signal.ts index 98986fd..f20be82 100644 --- a/packages/cereb/src/browser/single-pointer/single-pointer-signal.ts +++ b/packages/cereb/src/browser/single-pointer/single-pointer-signal.ts @@ -1,4 +1,5 @@ import { createSignal, type Signal } from "../../core/signal.js"; +import type { Point } from "../../geometry/types.js"; import type { SinglePointerButton, SinglePointerPhase, SinglePointerType } from "./types.js"; export interface SinglePointerSignal extends Signal<"single-pointer", SinglePointer> {} @@ -11,10 +12,8 @@ export const SINGLE_POINTER_SIGNAL_KIND = "single-pointer" as const; */ export interface SinglePointer { phase: SinglePointerPhase; - x: number; - y: number; - pageX: number; - pageY: number; + cursor: Point; + pageCursor: Point; pointerType: SinglePointerType; button: SinglePointerButton; /** 0.0 ~ 1.0, default 0.5 if unsupported */ @@ -30,10 +29,8 @@ export function createDefaultSinglePointerSignal(): SinglePointerSignal { return createSinglePointerSignal({ id: "", phase: "move", - x: 0, - y: 0, - pageX: 0, - pageY: 0, + cursor: [0, 0], + pageCursor: [0, 0], pointerType: "unknown", button: "none", pressure: 0.5, diff --git a/packages/cereb/src/geometry/creators.ts b/packages/cereb/src/geometry/creators.ts new file mode 100644 index 0000000..b4c4561 --- /dev/null +++ b/packages/cereb/src/geometry/creators.ts @@ -0,0 +1,25 @@ +import type { Point, Vector } from "./types.js"; + +export function point2D(x: number, y: number): Point { + return [x, y]; +} + +export function point3D(x: number, y: number, z: number): Point { + return [x, y, z]; +} + +export function origin(dimensions: number = 2): Point { + return Array(dimensions).fill(0); +} + +export function vector2D(dx: number, dy: number): Vector { + return [dx, dy]; +} + +export function vector3D(dx: number, dy: number, dz: number): Vector { + return [dx, dy, dz]; +} + +export function zero(dimensions: number = 2): Vector { + return Array(dimensions).fill(0); +} diff --git a/packages/cereb/src/geometry/index.ts b/packages/cereb/src/geometry/index.ts new file mode 100644 index 0000000..a9c9c60 --- /dev/null +++ b/packages/cereb/src/geometry/index.ts @@ -0,0 +1,16 @@ +export { origin, point2D, point3D, vector2D, vector3D, zero } from "./creators.js"; +export { + add, + difference, + distance, + dot, + lerp, + magnitude, + midpoint, + negate, + normalize, + scale, + subtract, + translate, +} from "./operations.js"; +export type { Point, Tuple, Vector } from "./types.js"; diff --git a/packages/cereb/src/geometry/operations.ts b/packages/cereb/src/geometry/operations.ts new file mode 100644 index 0000000..5bef312 --- /dev/null +++ b/packages/cereb/src/geometry/operations.ts @@ -0,0 +1,73 @@ +import type { Point, Tuple, Vector } from "./types.js"; + +export function add(a: Tuple, b: Tuple): Tuple { + const len = Math.max(a.length, b.length); + const result: number[] = []; + for (let i = 0; i < len; i++) { + result[i] = (a[i] ?? 0) + (b[i] ?? 0); + } + return result; +} + +export function subtract(a: Tuple, b: Tuple): Tuple { + const len = Math.max(a.length, b.length); + const result: number[] = []; + for (let i = 0; i < len; i++) { + result[i] = (a[i] ?? 0) - (b[i] ?? 0); + } + return result; +} + +export function scale(v: Tuple, scalar: number): Tuple { + return v.map((c) => c * scalar); +} + +export function magnitude(v: Vector): number { + return Math.sqrt(v.reduce((sum, c) => sum + c * c, 0)); +} + +export function normalize(v: Vector): Vector { + const mag = magnitude(v); + if (mag === 0) return v.map(() => 0); + return v.map((c) => c / mag); +} + +export function dot(a: Tuple, b: Tuple): number { + const len = Math.min(a.length, b.length); + let result = 0; + for (let i = 0; i < len; i++) { + result += a[i] * b[i]; + } + return result; +} + +export function negate(v: Tuple): Tuple { + return v.map((c) => -c); +} + +export function translate(point: Point, vector: Vector): Point { + return add(point, vector); +} + +export function difference(from: Point, to: Point): Vector { + return subtract(to, from); +} + +export function distance(a: Point, b: Point): number { + return magnitude(difference(a, b)); +} + +export function midpoint(a: Point, b: Point): Point { + return a.map((c, i) => (c + (b[i] ?? 0)) / 2); +} + +export function lerp(a: Tuple, b: Tuple, t: number): Tuple { + const len = Math.max(a.length, b.length); + const result: number[] = []; + for (let i = 0; i < len; i++) { + const ai = a[i] ?? 0; + const bi = b[i] ?? 0; + result[i] = ai + (bi - ai) * t; + } + return result; +} diff --git a/packages/cereb/src/geometry/types.ts b/packages/cereb/src/geometry/types.ts new file mode 100644 index 0000000..10a3e21 --- /dev/null +++ b/packages/cereb/src/geometry/types.ts @@ -0,0 +1,12 @@ +/** + * Tuple-based geometric types providing semantic distinction between + * positions (Point) and displacements (Vector) with unified 2D/3D support. + */ + +export type Tuple = number[]; + +/** A position in space: [x, y] or [x, y, z] */ +export type Point = Tuple; + +/** A direction and magnitude: [dx, dy] or [dx, dy, dz] */ +export type Vector = Tuple; diff --git a/packages/cereb/src/operators/offset.spec.ts b/packages/cereb/src/operators/offset.spec.ts index 7137ebf..58aa67f 100644 --- a/packages/cereb/src/operators/offset.spec.ts +++ b/packages/cereb/src/operators/offset.spec.ts @@ -11,10 +11,8 @@ function createMockPointerSignal(x: number, y: number): SinglePointerSignal { return createSinglePointerSignal({ id: "mouse-1", phase: "move", - x, - y, - pageX: x, - pageY: y, + cursor: [x, y], + pageCursor: [x, y], pointerType: "mouse", button: "none", pressure: 0.5, @@ -43,11 +41,11 @@ function createMockElement(rect: Partial = {}): { } describe("offset operator", () => { - it("should calculate offsetX and offsetY relative to target element", () => { + it("should calculate offset relative to target element", () => { const { element } = createMockElement({ top: 100, left: 50 }); const op = offset({ target: element }); - const values: Array<{ offsetX: number; offsetY: number }> = []; + const values: Array<{ offset: [number, number] }> = []; const source = createStream((observer) => { observer.next(createMockPointerSignal(150, 200)); @@ -55,10 +53,10 @@ describe("offset operator", () => { }); source.pipe(op).on((v: OffsetPointerSignal) => { - values.push({ offsetX: v.value.offsetX, offsetY: v.value.offsetY }); + values.push({ offset: v.value.offset as [number, number] }); }); - expect(values[0]).toEqual({ offsetX: 100, offsetY: 100 }); + expect(values[0]).toEqual({ offset: [100, 100] }); }); it("should preserve original signal properties", () => { @@ -71,8 +69,8 @@ describe("offset operator", () => { }); source.pipe(op).on((v: OffsetPointerSignal) => { - expect(v.value.x).toBe(150); - expect(v.value.y).toBe(200); + expect(v.value.cursor[0]).toBe(150); + expect(v.value.cursor[1]).toBe(200); expect(v.kind).toBe("single-pointer"); }); }); diff --git a/packages/cereb/src/operators/offset.ts b/packages/cereb/src/operators/offset.ts index 8d2bba8..e698986 100644 --- a/packages/cereb/src/operators/offset.ts +++ b/packages/cereb/src/operators/offset.ts @@ -1,6 +1,7 @@ import type { ExtendSignalValue, Signal, SignalWith } from "../core/signal.js"; import type { Operator, Stream } from "../core/stream.js"; import { createStream } from "../core/stream.js"; +import type { Vector } from "../geometry/types.js"; export interface OffsetOptions { target: Element; @@ -22,13 +23,11 @@ export interface OffsetOptions { } export interface PointerValue { - x: number; - y: number; + cursor: readonly [number, number]; } export interface OffsetValue { - offsetX: number; - offsetY: number; + offset: Vector; } type OffsetInputValue = PointerValue & Partial; @@ -42,8 +41,7 @@ function applyOffset>( signal: T, rect: DOMRect, ): asserts signal is T & ExtendSignalValue { - signal.value.offsetX = signal.value.x - rect.left; - signal.value.offsetY = signal.value.y - rect.top; + signal.value.offset = [signal.value.cursor[0] - rect.left, signal.value.cursor[1] - rect.top]; } /** diff --git a/packages/pan/README.md b/packages/pan/README.md index 43aeb2c..61d79ca 100644 --- a/packages/pan/README.md +++ b/packages/pan/README.md @@ -14,7 +14,9 @@ npm install --save cereb @cereb/pan import { pan } from "@cereb/pan"; pan(element).on((signal) => { - const { phase, deltaX, deltaY, velocityX, velocityY } = signal.value; + const { phase, delta, velocity } = signal.value; + const [deltaX, deltaY] = delta; + const [velocityX, velocityY] = velocity; if (phase === "move") { console.log(`Delta: (${deltaX}, ${deltaY}), Velocity: (${velocityX}, ${velocityY})`); @@ -33,7 +35,7 @@ import { axisLock } from "@cereb/pan/operators"; pan(element, { threshold: 10 }) .pipe(axisLock()) .on((signal) => { - const { deltaX, deltaY } = signal.value; + const [deltaX, deltaY] = signal.value.delta; // One of deltaX/deltaY will always be 0 after axis is determined element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; @@ -92,7 +94,9 @@ const recognizer = createPanRecognizer({ threshold: 10 }); function handlePointerEvent(signal: PanSourceSignal) { const panEvent = recognizer.process(signal); if (panEvent) { - console.log(panEvent.value.deltaX, panEvent.value.velocityX); + const [deltaX] = panEvent.value.delta; + const [velocityX] = panEvent.value.velocity; + console.log(deltaX, velocityX); } } ``` @@ -102,16 +106,12 @@ function handlePointerEvent(signal: PanSourceSignal) { | Property | Type | Description | |----------|------|-------------| | `phase` | `"start" \| "move" \| "end" \| "cancel"` | Current gesture phase | -| `deltaX` | `number` | X displacement from start point | -| `deltaY` | `number` | Y displacement from start point | +| `cursor` | `[number, number]` | Current position (client coordinates) | +| `pageCursor` | `[number, number]` | Current position (page coordinates) | +| `delta` | `[number, number]` | Displacement from start point `[deltaX, deltaY]` | +| `velocity` | `[number, number]` | Velocity (px/ms) `[velocityX, velocityY]` | | `distance` | `number` | Total cumulative distance traveled | | `direction` | `"up" \| "down" \| "left" \| "right" \| "none"` | Current movement direction | -| `velocityX` | `number` | X velocity (px/ms) | -| `velocityY` | `number` | Y velocity (px/ms) | -| `x` | `number` | Current X position (client) | -| `y` | `number` | Current Y position (client) | -| `pageX` | `number` | Current X position (page) | -| `pageY` | `number` | Current Y position (page) | ## Contributing diff --git a/packages/pan/src/operators/axis-lock.ts b/packages/pan/src/operators/axis-lock.ts index 872596b..d739de3 100644 --- a/packages/pan/src/operators/axis-lock.ts +++ b/packages/pan/src/operators/axis-lock.ts @@ -19,18 +19,15 @@ export interface AxisLockOptions { * After the axis is determined based on initial movement direction, * values for the opposite axis are zeroed out. * - * Preserves any extensions (like velocity) added via withVelocity() or other operators. - * * @example * ```typescript - * pipe( - * singlePointer(element), - * singlePointerToPan({ threshold: 10 }), - * axisLock() - * ).on(event => { - * // After axis is determined, one of deltaX/deltaY will always be 0 - * element.style.transform = `translate(${event.deltaX}px, ${event.deltaY}px)`; - * }); + * pan(element, { threshold: 10 }) + * .pipe(axisLock()) + * .on((signal) => { + * const [dx, dy] = signal.value.delta; + * // After axis is determined, one of dx/dy will always be 0 + * element.style.transform = `translate(${dx}px, ${dy}px)`; + * }); * ``` */ export function axisLock(options: AxisLockOptions = {}): Operator { @@ -42,8 +39,9 @@ export function axisLock(options: AxisLockOptions = {}): Operator 0 ? "right" : signal.value.deltaX < 0 ? "left" : "none"; + signal.value.direction = deltaX > 0 ? "right" : deltaX < 0 ? "left" : "none"; } } else if (lockedAxis === "vertical") { - signal.value.deltaX = 0; - if ("velocityX" in signal.value) { - (signal.value as { velocityX: number }).velocityX = 0; - } + signal.value.delta = [0, deltaY]; + signal.value.velocity = [0, vy]; if (signal.value.direction === "left" || signal.value.direction === "right") { - signal.value.direction = - signal.value.deltaY > 0 ? "down" : signal.value.deltaY < 0 ? "up" : "none"; + signal.value.direction = deltaY > 0 ? "down" : deltaY < 0 ? "up" : "none"; } } } diff --git a/packages/pan/src/pan-signal.ts b/packages/pan/src/pan-signal.ts index c328fa3..13227c9 100644 --- a/packages/pan/src/pan-signal.ts +++ b/packages/pan/src/pan-signal.ts @@ -1,5 +1,6 @@ import type { Signal } from "cereb"; import { createSignal } from "cereb"; +import type { Point, Vector } from "cereb/geometry"; import type { PanDirection, PanPhase } from "./pan-types.js"; /** @@ -9,30 +10,21 @@ import type { PanDirection, PanPhase } from "./pan-types.js"; export interface PanValue { phase: PanPhase; - /** X displacement from start point */ - deltaX: number; - /** Y displacement from start point */ - deltaY: number; + /** Current position [x, y] (client coordinates) */ + cursor: Point; + /** Current position [pageX, pageY] (page coordinates) */ + pageCursor: Point; + + /** Displacement from start [deltaX, deltaY] */ + delta: Vector; + /** Velocity [vx, vy] in pixels per millisecond */ + velocity: Vector; /** Total cumulative distance traveled */ distance: number; /** Current movement direction */ direction: PanDirection; - - /** X velocity in pixels per millisecond */ - velocityX: number; - /** Y velocity in pixels per millisecond */ - velocityY: number; - - /** Current clientX */ - x: number; - /** Current clientY */ - y: number; - /** Current pageX */ - pageX: number; - /** Current pageY */ - pageY: number; } export interface PanSignal extends Signal<"pan", PanValue & T> {} @@ -42,16 +34,12 @@ export const PAN_SIGNAL_KIND = "pan" as const; export function createDefaultPanValue(): PanValue { return { phase: "unknown", - deltaX: 0, - deltaY: 0, + cursor: [0, 0], + pageCursor: [0, 0], + delta: [0, 0], + velocity: [0, 0], distance: 0, direction: "none", - velocityX: 0, - velocityY: 0, - x: 0, - y: 0, - pageX: 0, - pageY: 0, }; } diff --git a/packages/pan/src/pan-types.ts b/packages/pan/src/pan-types.ts index d3151e2..070c144 100644 --- a/packages/pan/src/pan-types.ts +++ b/packages/pan/src/pan-types.ts @@ -15,10 +15,8 @@ export type PanSourcePhase = "start" | "move" | "end" | "cancel"; */ export interface PanSourceValue { readonly phase: PanSourcePhase; - readonly x: number; - readonly y: number; - readonly pageX: number; - readonly pageY: number; + readonly cursor: readonly [number, number]; + readonly pageCursor: readonly [number, number]; } /** diff --git a/packages/pan/src/recognizer.ts b/packages/pan/src/recognizer.ts index 004f5d2..5266d57 100644 --- a/packages/pan/src/recognizer.ts +++ b/packages/pan/src/recognizer.ts @@ -1,3 +1,4 @@ +import type { Vector } from "cereb/geometry"; import { calculateDistance, getDirection } from "./geometry.js"; import { createPanSignal, type PanSignal } from "./pan-signal.js"; import type { PanDirectionMode, PanOptions, PanPhase, PanSourceSignal } from "./pan-types.js"; @@ -12,17 +13,14 @@ function calculateVelocity( prevX: number, prevY: number, prevTimestamp: number, -): { velocityX: number; velocityY: number } { +): Vector { const timeDelta = currentTimestamp - prevTimestamp; if (timeDelta <= 0) { - return { velocityX: 0, velocityY: 0 }; + return [0, 0]; } - return { - velocityX: (currentX - prevX) / timeDelta, - velocityY: (currentY - prevY) / timeDelta, - }; + return [(currentX - prevX) / timeDelta, (currentY - prevY) / timeDelta]; } /** @@ -81,12 +79,13 @@ export function createPanRecognizer(options: PanOptions = {}): PanRecognizer { const state: PanState = createInitialPanState(); function createPanSignalFromSource(signal: PanSourceSignal, phase: PanPhase): PanSignal { - const deltaX = signal.value.x - state.startX; - const deltaY = signal.value.y - state.startY; + const [x, y] = signal.value.cursor; + const deltaX = x - state.startX; + const deltaY = y - state.startY; - const { velocityX, velocityY } = calculateVelocity( - signal.value.x, - signal.value.y, + const velocity = calculateVelocity( + x, + y, signal.createdAt, state.prevX, state.prevY, @@ -95,27 +94,24 @@ export function createPanRecognizer(options: PanOptions = {}): PanRecognizer { return createPanSignal({ phase, - deltaX, - deltaY, + cursor: [x, y], + pageCursor: [...signal.value.pageCursor], + delta: [deltaX, deltaY], + velocity, distance: state.totalDistance, direction: getDirection(deltaX, deltaY), - velocityX, - velocityY, - x: signal.value.x, - y: signal.value.y, - pageX: signal.value.pageX, - pageY: signal.value.pageY, }); } function handleStart(signal: PanSourceSignal): null { + const [x, y] = signal.value.cursor; state.isActive = true; state.thresholdMet = false; - state.startX = signal.value.x; - state.startY = signal.value.y; + state.startX = x; + state.startY = y; state.startTimestamp = signal.createdAt; - state.prevX = signal.value.x; - state.prevY = signal.value.y; + state.prevX = x; + state.prevY = y; state.prevTimestamp = signal.createdAt; state.totalDistance = 0; state.deviceId = signal.deviceId; @@ -125,15 +121,11 @@ export function createPanRecognizer(options: PanOptions = {}): PanRecognizer { function handleMove(signal: PanSourceSignal): PanSignal | null { if (!state.isActive) return null; - const deltaX = signal.value.x - state.startX; - const deltaY = signal.value.y - state.startY; + const [x, y] = signal.value.cursor; + const deltaX = x - state.startX; + const deltaY = y - state.startY; - const segmentDistance = calculateDistance( - state.prevX, - state.prevY, - signal.value.x, - signal.value.y, - ); + const segmentDistance = calculateDistance(state.prevX, state.prevY, x, y); state.totalDistance += segmentDistance; let result: PanSignal | null = null; @@ -147,8 +139,8 @@ export function createPanRecognizer(options: PanOptions = {}): PanRecognizer { result = createPanSignalFromSource(signal, "move"); } - state.prevX = signal.value.x; - state.prevY = signal.value.y; + state.prevX = x; + state.prevY = y; state.prevTimestamp = signal.createdAt; return result; diff --git a/packages/pinch/README.md b/packages/pinch/README.md index bd018f3..624c83b 100644 --- a/packages/pinch/README.md +++ b/packages/pinch/README.md @@ -14,10 +14,11 @@ npm install --save cereb @cereb/pinch import { pinch } from "@cereb/pinch"; pinch(element).on((signal) => { - const { phase, distance, velocity, centerX, centerY } = signal.value; + const { phase, distance, velocity, center } = signal.value; + const [centerX, centerY] = center; if (phase === "change") { - console.log(`Distance: ${distance}px, Velocity: ${velocity}px/ms`); + console.log(`Distance: ${distance}px, Velocity: ${velocity}px/ms, Center: (${centerX}, ${centerY})`); } }); ``` @@ -105,12 +106,11 @@ function handleMultiPointerEvent(signal: PinchSourceSignal) { | `phase` | `"start" \| "change" \| "end" \| "cancel"` | Current gesture phase | | `initialDistance` | `number` | Distance between pointers at gesture start | | `distance` | `number` | Current distance between pointers | +| `ratio` | `number` | Current distance / initial distance | | `deltaDistance` | `number` | Distance change since last event | | `velocity` | `number` | Velocity of distance change (px/ms) | -| `centerX` | `number` | Center X between pointers (client) | -| `centerY` | `number` | Center Y between pointers (client) | -| `pageCenterX` | `number` | Center X between pointers (page) | -| `pageCenterY` | `number` | Center Y between pointers (page) | +| `center` | `[number, number]` | Center point between pointers (client coordinates) | +| `pageCenter` | `[number, number]` | Center point between pointers (page coordinates) | ## Contributing diff --git a/packages/pinch/src/geometry.spec.ts b/packages/pinch/src/geometry.spec.ts index bd2b0dc..f342c7e 100644 --- a/packages/pinch/src/geometry.spec.ts +++ b/packages/pinch/src/geometry.spec.ts @@ -8,14 +8,17 @@ import { getPointerDistance, } from "./geometry.js"; -function createPointerInfo(overrides: Partial = {}): PointerInfo { +function createPointerInfo( + overrides: Partial> & { + cursor?: [number, number]; + pageCursor?: [number, number]; + } = {}, +): PointerInfo { return { id: "touch-1", phase: "move", - x: 0, - y: 0, - pageX: 0, - pageY: 0, + cursor: [0, 0], + pageCursor: [0, 0], pointerType: "touch", button: "none", pressure: 0.5, @@ -47,8 +50,8 @@ describe("calculateDistance", () => { describe("getPointerDistance", () => { it("should calculate distance between two PointerInfo objects", () => { - const p1 = createPointerInfo({ x: 0, y: 0 }); - const p2 = createPointerInfo({ x: 3, y: 4 }); + const p1 = createPointerInfo({ cursor: [0, 0] }); + const p2 = createPointerInfo({ cursor: [3, 4] }); expect(getPointerDistance(p1, p2)).toBe(5); }); @@ -56,32 +59,32 @@ describe("getPointerDistance", () => { describe("getCenter", () => { it("should calculate center point between two pointers", () => { - const p1 = createPointerInfo({ x: 0, y: 0 }); - const p2 = createPointerInfo({ x: 100, y: 100 }); + const p1 = createPointerInfo({ cursor: [0, 0] }); + const p2 = createPointerInfo({ cursor: [100, 100] }); const center = getCenter(p1, p2); - expect(center.centerX).toBe(50); - expect(center.centerY).toBe(50); + expect(center[0]).toBe(50); + expect(center[1]).toBe(50); }); it("should handle pointers at same position", () => { - const p1 = createPointerInfo({ x: 50, y: 50 }); - const p2 = createPointerInfo({ x: 50, y: 50 }); + const p1 = createPointerInfo({ cursor: [50, 50] }); + const p2 = createPointerInfo({ cursor: [50, 50] }); const center = getCenter(p1, p2); - expect(center.centerX).toBe(50); - expect(center.centerY).toBe(50); + expect(center[0]).toBe(50); + expect(center[1]).toBe(50); }); }); describe("getPageCenter", () => { it("should calculate page center between two pointers", () => { - const p1 = createPointerInfo({ pageX: 0, pageY: 0 }); - const p2 = createPointerInfo({ pageX: 200, pageY: 200 }); + const p1 = createPointerInfo({ pageCursor: [0, 0] }); + const p2 = createPointerInfo({ pageCursor: [200, 200] }); - const center = getPageCenter(p1, p2); - expect(center.pageCenterX).toBe(100); - expect(center.pageCenterY).toBe(100); + const pageCenter = getPageCenter(p1, p2); + expect(pageCenter[0]).toBe(100); + expect(pageCenter[1]).toBe(100); }); }); diff --git a/packages/pinch/src/geometry.ts b/packages/pinch/src/geometry.ts index 276304d..ba2a28e 100644 --- a/packages/pinch/src/geometry.ts +++ b/packages/pinch/src/geometry.ts @@ -1,3 +1,4 @@ +import type { Point } from "cereb/geometry"; import type { PinchSourcePointer } from "./pinch-types.js"; /** @@ -13,33 +14,21 @@ export function calculateDistance(x1: number, y1: number, x2: number, y2: number * Calculate distance between two pointers. */ export function getPointerDistance(p1: PinchSourcePointer, p2: PinchSourcePointer): number { - return calculateDistance(p1.x, p1.y, p2.x, p2.y); + return calculateDistance(p1.cursor[0], p1.cursor[1], p2.cursor[0], p2.cursor[1]); } /** * Calculate center point between two pointers (client coordinates). */ -export function getCenter( - p1: PinchSourcePointer, - p2: PinchSourcePointer, -): { centerX: number; centerY: number } { - return { - centerX: (p1.x + p2.x) / 2, - centerY: (p1.y + p2.y) / 2, - }; +export function getCenter(p1: PinchSourcePointer, p2: PinchSourcePointer): Point { + return [(p1.cursor[0] + p2.cursor[0]) / 2, (p1.cursor[1] + p2.cursor[1]) / 2]; } /** * Calculate center point between two pointers (page coordinates). */ -export function getPageCenter( - p1: PinchSourcePointer, - p2: PinchSourcePointer, -): { pageCenterX: number; pageCenterY: number } { - return { - pageCenterX: (p1.pageX + p2.pageX) / 2, - pageCenterY: (p1.pageY + p2.pageY) / 2, - }; +export function getPageCenter(p1: PinchSourcePointer, p2: PinchSourcePointer): Point { + return [(p1.pageCursor[0] + p2.pageCursor[0]) / 2, (p1.pageCursor[1] + p2.pageCursor[1]) / 2]; } /** diff --git a/packages/pinch/src/pinch-signal.ts b/packages/pinch/src/pinch-signal.ts index e50df6a..318ebc7 100644 --- a/packages/pinch/src/pinch-signal.ts +++ b/packages/pinch/src/pinch-signal.ts @@ -1,5 +1,6 @@ import type { Signal } from "cereb"; import { createSignal } from "cereb"; +import type { Point } from "cereb/geometry"; import type { PinchPhase } from "./pinch-types.js"; /** @@ -24,17 +25,11 @@ export interface PinchValue { /** Velocity of distance change (pixels per millisecond) */ velocity: number; - /** Center X between two pointers (client coordinates) */ - centerX: number; + /** Center between two pointers [x, y] (client coordinates) */ + center: Point; - /** Center Y between two pointers (client coordinates) */ - centerY: number; - - /** Center X between two pointers (page coordinates) */ - pageCenterX: number; - - /** Center Y between two pointers (page coordinates) */ - pageCenterY: number; + /** Center between two pointers [pageX, pageY] (page coordinates) */ + pageCenter: Point; } export interface PinchSignal extends Signal<"pinch", PinchValue & T> {} @@ -49,10 +44,8 @@ export function createDefaultPinchValue(): PinchValue { ratio: 0, deltaDistance: 0, velocity: 0, - centerX: 0, - centerY: 0, - pageCenterX: 0, - pageCenterY: 0, + center: [0, 0], + pageCenter: [0, 0], }; } diff --git a/packages/pinch/src/pinch-types.ts b/packages/pinch/src/pinch-types.ts index 5f64eae..39ae2fa 100644 --- a/packages/pinch/src/pinch-types.ts +++ b/packages/pinch/src/pinch-types.ts @@ -12,10 +12,8 @@ export type PinchSourcePhase = "start" | "move" | "end" | "cancel"; export interface PinchSourcePointer { readonly id: string; readonly phase: PinchSourcePhase; - readonly x: number; - readonly y: number; - readonly pageX: number; - readonly pageY: number; + readonly cursor: readonly [number, number]; + readonly pageCursor: readonly [number, number]; } /** diff --git a/packages/pinch/src/recognizer.ts b/packages/pinch/src/recognizer.ts index 2c382a2..3a55f17 100644 --- a/packages/pinch/src/recognizer.ts +++ b/packages/pinch/src/recognizer.ts @@ -116,8 +116,8 @@ export function createPinchRecognizer(options: PinchOptions = {}): PinchRecogniz timestamp, state.prevTimestamp, ); - const { centerX, centerY } = getCenter(p1, p2); - const { pageCenterX, pageCenterY } = getPageCenter(p1, p2); + const center = getCenter(p1, p2); + const pageCenter = getPageCenter(p1, p2); state.prevDistance = distance; state.prevTimestamp = timestamp; @@ -129,10 +129,8 @@ export function createPinchRecognizer(options: PinchOptions = {}): PinchRecogniz ratio: distance / state.initialDistance, deltaDistance, velocity, - centerX, - centerY, - pageCenterX, - pageCenterY, + center, + pageCenter, }); } diff --git a/packages/tap/README.md b/packages/tap/README.md index 1c586d4..6d4be42 100644 --- a/packages/tap/README.md +++ b/packages/tap/README.md @@ -14,7 +14,8 @@ npm install --save cereb @cereb/tap import { tap } from "@cereb/tap"; tap(element).on((signal) => { - const { tapCount, x, y } = signal.value; + const { tapCount, cursor } = signal.value; + const [x, y] = cursor; if (tapCount === 2) { console.log(`Double tap at (${x}, ${y})`); @@ -122,10 +123,8 @@ function handlePointerEvent(signal: TapSourceSignal) { | Property | Type | Description | |----------|------|-------------| | `phase` | `"start" \| "end" \| "cancel"` | Current gesture phase | -| `x` | `number` | Tap X position (client) | -| `y` | `number` | Tap Y position (client) | -| `pageX` | `number` | Tap X position (page) | -| `pageY` | `number` | Tap Y position (page) | +| `cursor` | `[number, number]` | Tap position (client coordinates) | +| `pageCursor` | `[number, number]` | Tap position (page coordinates) | | `tapCount` | `number` | Consecutive tap count (1=single, 2=double, etc.) | | `duration` | `number` | How long the pointer was pressed (ms) | | `pointerType` | `"mouse" \| "touch" \| "pen" \| "unknown"` | Type of pointer | diff --git a/packages/tap/src/recognizer.spec.ts b/packages/tap/src/recognizer.spec.ts index 79b7441..6a2ac2c 100644 --- a/packages/tap/src/recognizer.spec.ts +++ b/packages/tap/src/recognizer.spec.ts @@ -12,10 +12,8 @@ function createMockPointerSignal( kind: "single-pointer", value: { phase, - x, - y, - pageX: x, - pageY: y, + cursor: [x, y], + pageCursor: [x, y], pointerType: "mouse", button: "primary", pressure: 0.5, @@ -36,8 +34,8 @@ describe("createTapRecognizer", () => { expect(result).not.toBeNull(); expect(result?.value.phase).toBe("start"); expect(result?.value.tapCount).toBe(1); - expect(result?.value.x).toBe(100); - expect(result?.value.y).toBe(100); + expect(result?.value.cursor[0]).toBe(100); + expect(result?.value.cursor[1]).toBe(100); }); it("should emit end phase on pointer end within thresholds", () => { diff --git a/packages/tap/src/recognizer.ts b/packages/tap/src/recognizer.ts index 74be811..e2340ca 100644 --- a/packages/tap/src/recognizer.ts +++ b/packages/tap/src/recognizer.ts @@ -59,10 +59,8 @@ export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { return createTapSignal({ phase, - x: state.startX, - y: state.startY, - pageX: state.startPageX, - pageY: state.startPageY, + cursor: [...state.startCursor] as [number, number], + pageCursor: [...state.startPageCursor] as [number, number], tapCount, duration: Math.max(0, duration), pointerType: signal.value.pointerType, @@ -70,8 +68,7 @@ export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { } function shouldIncrementTapCount( - currentX: number, - currentY: number, + currentCursor: readonly [number, number], currentTimestamp: number, ): boolean { if (state.lastTapEndTimestamp === 0) { @@ -83,7 +80,12 @@ export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { return false; } - const distance = calculateDistance(state.lastTapX, state.lastTapY, currentX, currentY); + const distance = calculateDistance( + state.lastTapCursor[0], + state.lastTapCursor[1], + currentCursor[0], + currentCursor[1], + ); if (distance > chainMovementThreshold) { return false; } @@ -92,15 +94,13 @@ export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { } function handleStart(signal: TapSourceSignal): TapSignal { - const { x, y, pageX, pageY, pointerType } = signal.value; + const { cursor, pageCursor, pointerType } = signal.value; - const continuesMultiTap = shouldIncrementTapCount(x, y, signal.createdAt); + const continuesMultiTap = shouldIncrementTapCount(cursor, signal.createdAt); state.isActive = true; - state.startX = x; - state.startY = y; - state.startPageX = pageX; - state.startPageY = pageY; + state.startCursor = [cursor[0], cursor[1]]; + state.startPageCursor = [pageCursor[0], pageCursor[1]]; state.startTimestamp = signal.createdAt; state.deviceId = signal.deviceId; state.pointerType = pointerType; @@ -118,9 +118,14 @@ export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { function handleMove(signal: TapSourceSignal): TapSignal | null { if (!state.isActive || state.isCancelled) return null; - const { x, y } = signal.value; + const { cursor } = signal.value; - const movement = calculateDistance(state.startX, state.startY, x, y); + const movement = calculateDistance( + state.startCursor[0], + state.startCursor[1], + cursor[0], + cursor[1], + ); if (movement > movementThreshold) { state.isCancelled = true; state.lastTapEndTimestamp = 0; @@ -158,8 +163,7 @@ export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { const tapCount = state.currentTapCount; state.lastTapEndTimestamp = signal.createdAt; - state.lastTapX = state.startX; - state.lastTapY = state.startY; + state.lastTapCursor = [state.startCursor[0], state.startCursor[1]]; const result = createTapSignalFromState(signal, "end", tapCount); resetCurrentTap(state); diff --git a/packages/tap/src/state.spec.ts b/packages/tap/src/state.spec.ts index 74f7297..35699dd 100644 --- a/packages/tap/src/state.spec.ts +++ b/packages/tap/src/state.spec.ts @@ -6,16 +6,13 @@ describe("createInitialTapState", () => { const state = createInitialTapState(); expect(state.isActive).toBe(false); - expect(state.startX).toBe(0); - expect(state.startY).toBe(0); - expect(state.startPageX).toBe(0); - expect(state.startPageY).toBe(0); + expect(state.startCursor).toEqual([0, 0]); + expect(state.startPageCursor).toEqual([0, 0]); expect(state.startTimestamp).toBe(0); expect(state.deviceId).toBe(""); expect(state.pointerType).toBe("unknown"); expect(state.lastTapEndTimestamp).toBe(0); - expect(state.lastTapX).toBe(0); - expect(state.lastTapY).toBe(0); + expect(state.lastTapCursor).toEqual([0, 0]); expect(state.currentTapCount).toBe(0); expect(state.isCancelled).toBe(false); }); @@ -25,25 +22,21 @@ describe("resetCurrentTap", () => { it("should reset current tap fields but preserve multi-tap tracking", () => { const state = createInitialTapState(); state.isActive = true; - state.startX = 100; - state.startY = 200; + state.startCursor = [100, 200]; state.startTimestamp = 1000; state.deviceId = "mouse-1"; state.isCancelled = true; state.lastTapEndTimestamp = 500; - state.lastTapX = 50; - state.lastTapY = 60; + state.lastTapCursor = [50, 60]; state.currentTapCount = 2; resetCurrentTap(state); expect(state.isActive).toBe(false); - expect(state.startX).toBe(0); - expect(state.startY).toBe(0); + expect(state.startCursor).toEqual([0, 0]); expect(state.isCancelled).toBe(false); expect(state.lastTapEndTimestamp).toBe(500); - expect(state.lastTapX).toBe(50); - expect(state.lastTapY).toBe(60); + expect(state.lastTapCursor).toEqual([50, 60]); expect(state.currentTapCount).toBe(2); }); }); @@ -52,23 +45,19 @@ describe("resetTapState", () => { it("should reset all fields to initial values", () => { const state = createInitialTapState(); state.isActive = true; - state.startX = 100; - state.startY = 200; + state.startCursor = [100, 200]; state.startTimestamp = 1000; state.deviceId = "mouse-1"; state.lastTapEndTimestamp = 500; - state.lastTapX = 50; - state.lastTapY = 60; + state.lastTapCursor = [50, 60]; state.currentTapCount = 2; resetTapState(state); expect(state.isActive).toBe(false); - expect(state.startX).toBe(0); - expect(state.startY).toBe(0); + expect(state.startCursor).toEqual([0, 0]); expect(state.lastTapEndTimestamp).toBe(0); - expect(state.lastTapX).toBe(0); - expect(state.lastTapY).toBe(0); + expect(state.lastTapCursor).toEqual([0, 0]); expect(state.currentTapCount).toBe(0); }); }); diff --git a/packages/tap/src/state.ts b/packages/tap/src/state.ts index 93a63eb..46b4e9d 100644 --- a/packages/tap/src/state.ts +++ b/packages/tap/src/state.ts @@ -1,3 +1,5 @@ +import type { Point } from "cereb/geometry"; + /** * Internal state for tap gesture tracking. * Tracks both current tap attempt and history for multi-tap detection. @@ -6,13 +8,10 @@ export interface TapState { /** Whether a tap attempt is currently in progress */ isActive: boolean; - /** Start position X */ - startX: number; - /** Start position Y */ - startY: number; - /** Page coordinates at start */ - startPageX: number; - startPageY: number; + /** Start position (client coordinates) */ + startCursor: Point; + /** Start position (page coordinates) */ + startPageCursor: Point; /** Timestamp when tap started */ startTimestamp: number; /** Device identifier */ @@ -22,9 +21,8 @@ export interface TapState { /** Timestamp of last successful tap end */ lastTapEndTimestamp: number; - /** Position of last successful tap */ - lastTapX: number; - lastTapY: number; + /** Position of last successful tap (client coordinates) */ + lastTapCursor: Point; /** Current consecutive tap count */ currentTapCount: number; @@ -35,16 +33,13 @@ export interface TapState { export function createInitialTapState(): TapState { return { isActive: false, - startX: 0, - startY: 0, - startPageX: 0, - startPageY: 0, + startCursor: [0, 0], + startPageCursor: [0, 0], startTimestamp: 0, deviceId: "", pointerType: "unknown", lastTapEndTimestamp: 0, - lastTapX: 0, - lastTapY: 0, + lastTapCursor: [0, 0], currentTapCount: 0, isCancelled: false, }; @@ -52,10 +47,8 @@ export function createInitialTapState(): TapState { export function resetCurrentTap(state: TapState): void { state.isActive = false; - state.startX = 0; - state.startY = 0; - state.startPageX = 0; - state.startPageY = 0; + state.startCursor = [0, 0]; + state.startPageCursor = [0, 0]; state.startTimestamp = 0; state.deviceId = ""; state.pointerType = "unknown"; @@ -65,7 +58,6 @@ export function resetCurrentTap(state: TapState): void { export function resetTapState(state: TapState): void { resetCurrentTap(state); state.lastTapEndTimestamp = 0; - state.lastTapX = 0; - state.lastTapY = 0; + state.lastTapCursor = [0, 0]; state.currentTapCount = 0; } diff --git a/packages/tap/src/tap-signal.ts b/packages/tap/src/tap-signal.ts index a90e487..32ff29e 100644 --- a/packages/tap/src/tap-signal.ts +++ b/packages/tap/src/tap-signal.ts @@ -1,5 +1,6 @@ import type { Signal } from "cereb"; import { createSignal } from "cereb"; +import type { Point } from "cereb/geometry"; import type { TapPhase, TapSourcePointerType } from "./tap-types.js"; /** @@ -9,15 +10,11 @@ import type { TapPhase, TapSourcePointerType } from "./tap-types.js"; export interface TapValue { phase: TapPhase; - /** Tap position X (client coordinates) */ - x: number; - /** Tap position Y (client coordinates) */ - y: number; + /** Tap position (client coordinates) */ + cursor: Point; - /** Tap position X (page coordinates) */ - pageX: number; - /** Tap position Y (page coordinates) */ - pageY: number; + /** Tap position (page coordinates) */ + pageCursor: Point; /** * Number of consecutive taps (1=single, 2=double, 3=triple, etc.) @@ -39,10 +36,8 @@ export const TAP_SIGNAL_KIND = "tap" as const; export function createDefaultTapValue(): TapValue { return { phase: "end", - x: 0, - y: 0, - pageX: 0, - pageY: 0, + cursor: [0, 0], + pageCursor: [0, 0], tapCount: 1, duration: 0, pointerType: "unknown", diff --git a/packages/tap/src/tap-types.ts b/packages/tap/src/tap-types.ts index 9716a1e..b393437 100644 --- a/packages/tap/src/tap-types.ts +++ b/packages/tap/src/tap-types.ts @@ -17,10 +17,8 @@ export type TapSourcePointerType = "touch" | "mouse" | "pen" | "unknown"; */ export interface TapSourceValue { readonly phase: TapSourcePhase; - readonly x: number; - readonly y: number; - readonly pageX: number; - readonly pageY: number; + readonly cursor: readonly [number, number]; + readonly pageCursor: readonly [number, number]; readonly pointerType: TapSourcePointerType; } From 5cee29d53e1dac887bfff99d9f5cb8fa7fbd55e7 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Fri, 9 Jan 2026 23:17:22 +0900 Subject: [PATCH 2/4] feat(core): introduce FRP (Functional Reactive Programming) module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Behavior abstraction for continuous time-varying values - Implement core behaviors: constant(), stepper(), time() - Add combinators: combine(), switcher(), lift() for composing behaviors - Add Behavior ↔ Event conversions: changes(), sample(), sampleOn(), animationFrame(), elapsedTime() - Include comprehensive test suite validating applicative/functor laws --- packages/cereb/package.json | 20 +- packages/cereb/src/frp.ts | 20 ++ packages/cereb/src/frp/behavior.spec.ts | 275 +++++++++++++++++ packages/cereb/src/frp/behavior.ts | 329 +++++++++++++++++++++ packages/cereb/src/frp/combinators.spec.ts | 270 +++++++++++++++++ packages/cereb/src/frp/combinators.ts | 317 ++++++++++++++++++++ packages/cereb/src/frp/conversions.spec.ts | 225 ++++++++++++++ packages/cereb/src/frp/conversions.ts | 305 +++++++++++++++++++ packages/cereb/src/frp/event.ts | 15 + packages/cereb/src/frp/index.ts | 17 ++ packages/cereb/src/index.ts | 1 + 11 files changed, 1779 insertions(+), 15 deletions(-) create mode 100644 packages/cereb/src/frp.ts create mode 100644 packages/cereb/src/frp/behavior.spec.ts create mode 100644 packages/cereb/src/frp/behavior.ts create mode 100644 packages/cereb/src/frp/combinators.spec.ts create mode 100644 packages/cereb/src/frp/combinators.ts create mode 100644 packages/cereb/src/frp/conversions.spec.ts create mode 100644 packages/cereb/src/frp/conversions.ts create mode 100644 packages/cereb/src/frp/event.ts create mode 100644 packages/cereb/src/frp/index.ts diff --git a/packages/cereb/package.json b/packages/cereb/package.json index 721def6..ffc157e 100644 --- a/packages/cereb/package.json +++ b/packages/cereb/package.json @@ -44,25 +44,15 @@ "import": "./dist/operators.js", "require": "./dist/operators.cjs" }, - "./single-pointer/touch": { - "types": "./dist/single-pointer/touch.d.ts", - "import": "./dist/single-pointer/touch.js", - "require": "./dist/single-pointer/touch.cjs" - }, - "./single-pointer/mouse": { - "types": "./dist/single-pointer/mouse.d.ts", - "import": "./dist/single-pointer/mouse.js", - "require": "./dist/single-pointer/mouse.cjs" - }, - "./single-pointer/pointer": { - "types": "./dist/single-pointer/pointer.d.ts", - "import": "./dist/single-pointer/pointer.js", - "require": "./dist/single-pointer/pointer.cjs" - }, "./geometry": { "types": "./dist/geometry/index.d.ts", "import": "./dist/geometry/index.js", "require": "./dist/geometry/index.cjs" + }, + "./frp": { + "types": "./dist/frp.d.ts", + "import": "./dist/frp.js", + "require": "./dist/frp.cjs" } }, "files": [ diff --git a/packages/cereb/src/frp.ts b/packages/cereb/src/frp.ts new file mode 100644 index 0000000..b078a12 --- /dev/null +++ b/packages/cereb/src/frp.ts @@ -0,0 +1,20 @@ +/** + * FRP (Functional Reactive Programming) module for Cereb. + * + * This module introduces the classic FRP concepts: + * - Behavior: Continuous time-varying values (always has a current value) + * - Event: Discrete occurrences (alias for Stream) + * + * Key differences from Stream: + * - Behavior.sample() - Get current value anytime + * - Behavior.onChange() - Subscribe to value changes + * - Behavior.dispose() - Clean up resources + * + * Use cases: + * - Animation frames that need current position/scale/rotation + * - VR headset tracking with continuous position sampling + * - Combining multiple input sources into a unified transform + * + * @module cereb/frp + */ +export * from "./frp/index.js"; diff --git a/packages/cereb/src/frp/behavior.spec.ts b/packages/cereb/src/frp/behavior.spec.ts new file mode 100644 index 0000000..c41ae34 --- /dev/null +++ b/packages/cereb/src/frp/behavior.spec.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSignal, createStream, type Signal } from "../core/index.js"; +import { constant, stepper, time } from "./behavior.js"; + +type TestSignal = Signal<"test", number>; + +function testSignal(value: number): TestSignal { + return createSignal("test", value); +} + +describe("constant", () => { + it("should always return the same value", () => { + const b = constant(42); + + expect(b.sample()).toBe(42); + expect(b.sample()).toBe(42); + }); + + it("should map values", () => { + const b = constant(10); + const doubled = b.map((x) => x * 2); + + expect(doubled.sample()).toBe(20); + }); + + it("should apply functions with ap", () => { + const bf = constant((x: number) => x + 1); + const ba = constant(5); + + const result = bf.ap(ba); + expect(result.sample()).toBe(6); + }); + + it("should never call onChange callback", () => { + const callback = vi.fn(); + const b = constant(42); + + b.onChange(callback); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should dispose properly", () => { + const b = constant(42); + expect(b.isDisposed).toBe(false); + + b.dispose(); + expect(b.isDisposed).toBe(true); + expect(() => b.sample()).toThrow("Cannot sample a disposed Behavior"); + }); +}); + +describe("stepper", () => { + it("should return initial value before any events", () => { + const stream = createStream(() => {}); + const b = stepper(0, stream, (s) => s.value); + + expect(b.sample()).toBe(0); + }); + + it("should update value when events occur", () => { + let emit: ((s: TestSignal) => void) | null = null; + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + + expect(b.sample()).toBe(0); + + emit!(testSignal(10)); + expect(b.sample()).toBe(10); + + emit!(testSignal(20)); + expect(b.sample()).toBe(20); + }); + + it("should call onChange when value changes", () => { + let emit: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + b.onChange(callback); + + emit!(testSignal(10)); + expect(callback).toHaveBeenCalledWith(10); + + emit!(testSignal(20)); + expect(callback).toHaveBeenCalledWith(20); + }); + + it("should not call onChange when value is the same", () => { + let emit: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(10, stream, (s) => s.value); + b.onChange(callback); + + emit!(testSignal(10)); // Same as initial + expect(callback).not.toHaveBeenCalled(); + + emit!(testSignal(20)); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("should map values", () => { + let emit: ((s: TestSignal) => void) | null = null; + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(5, stream, (s) => s.value); + const doubled = b.map((x) => x * 2); + + expect(doubled.sample()).toBe(10); + + emit!(testSignal(10)); + expect(doubled.sample()).toBe(20); + }); + + it("should unsubscribe onChange listener", () => { + let emit: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + const unsub = b.onChange(callback); + + emit!(testSignal(10)); + expect(callback).toHaveBeenCalledTimes(1); + + unsub(); + + emit!(testSignal(20)); + expect(callback).toHaveBeenCalledTimes(1); // Not called again + }); + + it("should dispose and cleanup", () => { + let emit: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + b.onChange(callback); + + b.dispose(); + + expect(b.isDisposed).toBe(true); + expect(() => b.sample()).toThrow("Cannot sample a disposed Behavior"); + + // Events after dispose should not trigger callback + emit!(testSignal(10)); + expect(callback).not.toHaveBeenCalled(); + }); +}); + +describe("time", () => { + it("should return current timestamp", () => { + const t = time(); + const now = performance.now(); + + const sampled = t.sample(); + expect(sampled).toBeGreaterThanOrEqual(now); + expect(sampled).toBeLessThan(now + 100); + }); + + it("should return different values on each sample", async () => { + const t = time(); + const first = t.sample(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const second = t.sample(); + expect(second).toBeGreaterThan(first); + }); + + it("should map time values", () => { + const t = time(); + const seconds = t.map((ms) => ms / 1000); + + const sampled = seconds.sample(); + expect(sampled).toBeGreaterThan(0); + }); + + it("should dispose properly", () => { + const t = time(); + t.dispose(); + + expect(t.isDisposed).toBe(true); + expect(() => t.sample()).toThrow("Cannot sample a disposed Behavior"); + }); +}); + +describe("Applicative Laws", () => { + it("satisfies identity: pure(id).ap(v) === v", () => { + const v = constant(42); + const id = (x: A) => x; + const result = constant(id).ap(v); + + expect(result.sample()).toBe(v.sample()); + }); + + it("satisfies homomorphism: pure(f).ap(pure(x)) === pure(f(x))", () => { + const f = (x: number) => x * 2; + const x = 21; + + const left = constant(f).ap(constant(x)); + const right = constant(f(x)); + + expect(left.sample()).toBe(right.sample()); + }); + + it("satisfies interchange: u.ap(pure(x)) === pure(f => f(x)).ap(u)", () => { + const u = constant((x: number) => x + 10); + const x = 5; + + const left = u.ap(constant(x)); + const right = constant((f: (n: number) => number) => f(x)).ap(u); + + expect(left.sample()).toBe(right.sample()); + }); + + it("satisfies composition: pure(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w))", () => { + const compose = + (f: (b: B) => C) => + (g: (a: A) => B) => + (a: A): C => + f(g(a)); + const u = constant((x: number) => x + 1); + const v = constant((x: number) => x * 2); + const w = constant(5); + + // Left: pure(compose).ap(u).ap(v).ap(w) + const left = constant(compose).ap(u).ap(v).ap(w); + // Right: u.ap(v.ap(w)) + const right = u.ap(v.ap(w)); + + expect(left.sample()).toBe(right.sample()); + }); +}); + +describe("Functor Laws", () => { + it("satisfies identity: b.map(x => x) === b", () => { + const b = constant(42); + const result = b.map((x) => x); + + expect(result.sample()).toBe(b.sample()); + }); + + it("satisfies composition: b.map(f).map(g) === b.map(x => g(f(x)))", () => { + const b = constant(5); + const f = (x: number) => x * 2; + const g = (x: number) => x + 1; + + const left = b.map(f).map(g); + const right = b.map((x) => g(f(x))); + + expect(left.sample()).toBe(right.sample()); + }); +}); diff --git a/packages/cereb/src/frp/behavior.ts b/packages/cereb/src/frp/behavior.ts new file mode 100644 index 0000000..8003951 --- /dev/null +++ b/packages/cereb/src/frp/behavior.ts @@ -0,0 +1,329 @@ +import type { Signal, Stream } from "../core/index.js"; + +/** + * Behavior represents a value that changes continuously over time. + * Unlike Stream (Event), Behavior always has a current value that can be sampled. + * + * Key concepts: + * - sample(): Get the current value at any point in time + * - map(): Transform the value (Functor) + * - ap(): Apply a function behavior to this behavior (Applicative) + * - onChange(): Subscribe to value changes (Push notification) + * - dispose(): Clean up resources and subscriptions + * + * @typeParam A - The type of the value + */ +export interface Behavior { + /** Sample the current value */ + sample(): A; + + /** Transform the value (Functor map) */ + map(f: (a: A) => B): Behavior; + + /** + * Apply a function from another Behavior to this value (Applicative apply) + * Note: The function behavior is the receiver, value behavior is the argument + */ + ap(this: Behavior<(b: B) => A>, bb: Behavior): Behavior; + + /** Subscribe to value changes. Returns an unsubscribe function. */ + onChange(callback: (a: A) => void): () => void; + + /** Dispose the behavior and clean up all resources */ + dispose(): void; + + /** Returns true if the behavior has been disposed */ + readonly isDisposed: boolean; +} + +/** + * Internal helper to create a base behavior with common functionality. + * Manages listener set and disposed state. + */ +function createBaseBehavior( + getSample: () => A, + onDispose?: () => void, +): { + behavior: Omit, "map" | "ap">; + listeners: Set<(a: A) => void>; + notifyListeners: () => void; + isDisposed: () => boolean; +} { + const listeners = new Set<(a: A) => void>(); + let disposed = false; + + const notifyListeners = () => { + if (disposed) return; + const value = getSample(); + for (const listener of listeners) { + listener(value); + } + }; + + return { + behavior: { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return getSample(); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + listeners.clear(); + onDispose?.(); + }, + get isDisposed() { + return disposed; + }, + }, + listeners, + notifyListeners, + isDisposed: () => disposed, + }; +} + +/** + * Creates a constant Behavior that never changes. + * The value is fixed at creation time. + * + * @example + * const always42 = constant(42); + * always42.sample(); // 42 + */ +export function constant(value: A): Behavior { + let disposed = false; + + const behavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return value; + }, + map: (f: (a: A) => B): Behavior => { + if (disposed) { + throw new Error("Cannot map a disposed Behavior"); + } + return constant(f(value)); + }, + ap: function (this: Behavior<(b: B) => A>, bb: Behavior): Behavior { + if (disposed) { + throw new Error("Cannot apply a disposed Behavior"); + } + return constant(this.sample()(bb.sample())); + }, + onChange: () => () => {}, + dispose: () => { + disposed = true; + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} + +/** + * Creates a Behavior from an event stream with an initial value. + * The behavior holds the latest value from the stream. + * + * @param initial - The initial value before any events + * @param event - The event stream to listen to + * @param selector - Function to extract the value from each signal + * + * @example + * const position = stepper( + * { x: 0, y: 0 }, + * pointerStream, + * (signal) => signal.value.position + * ); + */ +export function stepper( + initial: A, + event: Stream, + selector: (signal: S) => A, +): Behavior { + let current = initial; + let unsub: (() => void) | null = null; + + const { behavior, notifyListeners, isDisposed } = createBaseBehavior( + () => current, + () => { + unsub?.(); + unsub = null; + }, + ); + + unsub = event.on((signal) => { + if (isDisposed()) return; + const newValue = selector(signal); + if (!Object.is(newValue, current)) { + current = newValue; + notifyListeners(); + } + }); + + const fullBehavior: Behavior = { + sample: behavior.sample, + onChange: behavior.onChange, + dispose: behavior.dispose, + get isDisposed() { + return behavior.isDisposed; + }, + map: (f: (a: A) => B): Behavior => { + return mappedBehavior(fullBehavior, f); + }, + ap: function (this: Behavior<(b: B) => A>, bb: Behavior): Behavior { + return appliedBehavior(this, bb); + }, + }; + + return fullBehavior; +} + +/** + * Creates a mapped Behavior that transforms values from a source behavior. + */ +function mappedBehavior(source: Behavior, f: (a: A) => B): Behavior { + const { behavior, notifyListeners, isDisposed } = createBaseBehavior( + () => f(source.sample()), + () => { + sourceUnsub?.(); + }, + ); + + const sourceUnsub: (() => void) | null = source.onChange(() => { + if (!isDisposed()) { + notifyListeners(); + } + }); + + const fullBehavior: Behavior = { + sample: behavior.sample, + onChange: behavior.onChange, + dispose: behavior.dispose, + get isDisposed() { + return behavior.isDisposed; + }, + map: (g: (b: B) => C): Behavior => { + return mappedBehavior(fullBehavior, g); + }, + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedBehavior(this, bc); + }, + }; + + return fullBehavior; +} + +/** + * Creates an applied Behavior from a function behavior and value behavior. + */ +function appliedBehavior(bf: Behavior<(a: A) => B>, ba: Behavior): Behavior { + const { behavior, notifyListeners, isDisposed } = createBaseBehavior( + () => bf.sample()(ba.sample()), + () => { + unsubF?.(); + unsubA?.(); + }, + ); + + const unsubF: (() => void) | null = bf.onChange(() => { + if (!isDisposed()) { + notifyListeners(); + } + }); + + const unsubA: (() => void) | null = ba.onChange(() => { + if (!isDisposed()) { + notifyListeners(); + } + }); + + const fullBehavior: Behavior = { + sample: behavior.sample, + onChange: behavior.onChange, + dispose: behavior.dispose, + get isDisposed() { + return behavior.isDisposed; + }, + map: (g: (b: B) => C): Behavior => { + return mappedBehavior(fullBehavior, g); + }, + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedBehavior(this, bc); + }, + }; + + return fullBehavior; +} + +/** + * Creates a Behavior that represents the current time. + * Each sample() call returns the current timestamp (performance.now()). + * + * Note: onChange() is not meaningful for time as it always changes. + * Use animationFrame() from conversions if you need time-based events. + * + * @example + * const t = time(); + * const now = t.sample(); // current timestamp + */ +export function time(): Behavior { + let disposed = false; + + const behavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return performance.now(); + }, + map: (f: (t: number) => B): Behavior => { + if (disposed) { + throw new Error("Cannot map a disposed Behavior"); + } + // Return a new behavior that samples time and transforms + let innerDisposed = false; + return { + sample: () => { + if (innerDisposed || disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return f(performance.now()); + }, + map: (g) => behavior.map((t) => g(f(t))), + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedBehavior(this, bc); + }, + onChange: () => () => {}, + dispose: () => { + innerDisposed = true; + }, + get isDisposed() { + return innerDisposed || disposed; + }, + }; + }, + ap: function (this: Behavior<(b: B) => number>, bb: Behavior): Behavior { + return appliedBehavior(this, bb); + }, + onChange: () => () => {}, + dispose: () => { + disposed = true; + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} diff --git a/packages/cereb/src/frp/combinators.spec.ts b/packages/cereb/src/frp/combinators.spec.ts new file mode 100644 index 0000000..e28e185 --- /dev/null +++ b/packages/cereb/src/frp/combinators.spec.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSignal, createStream, type Signal } from "../core/index.js"; +import { constant, stepper } from "./behavior.js"; +import { combine, lift, switcher } from "./combinators.js"; + +type TestSignal = Signal<"test", number>; + +function testSignal(value: number): TestSignal { + return createSignal("test", value); +} + +describe("combine", () => { + it("should combine two behaviors", () => { + const a = constant(10); + const b = constant(20); + const combined = combine(a, b, (x, y) => x + y); + + expect(combined.sample()).toBe(30); + }); + + it("should combine three behaviors", () => { + const a = constant(1); + const b = constant(2); + const c = constant(3); + const combined = combine(a, b, c, (x, y, z) => x + y + z); + + expect(combined.sample()).toBe(6); + }); + + it("should combine four behaviors", () => { + const a = constant(1); + const b = constant(2); + const c = constant(3); + const d = constant(4); + const combined = combine(a, b, c, d, (w, x, y, z) => w + x + y + z); + + expect(combined.sample()).toBe(10); + }); + + it("should update when any source behavior changes", () => { + let emitA: ((s: TestSignal) => void) | null = null; + let emitB: ((s: TestSignal) => void) | null = null; + + const streamA = createStream((observer) => { + emitA = (s) => observer.next(s); + }); + const streamB = createStream((observer) => { + emitB = (s) => observer.next(s); + }); + + const a = stepper(10, streamA, (s) => s.value); + const b = stepper(20, streamB, (s) => s.value); + const combined = combine(a, b, (x, y) => x + y); + + expect(combined.sample()).toBe(30); + + emitA!(testSignal(15)); + expect(combined.sample()).toBe(35); + + emitB!(testSignal(25)); + expect(combined.sample()).toBe(40); + }); + + it("should call onChange when any source changes", () => { + let emitA: ((s: TestSignal) => void) | null = null; + let emitB: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const streamA = createStream((observer) => { + emitA = (s) => observer.next(s); + }); + const streamB = createStream((observer) => { + emitB = (s) => observer.next(s); + }); + + const a = stepper(10, streamA, (s) => s.value); + const b = stepper(20, streamB, (s) => s.value); + const combined = combine(a, b, (x, y) => x + y); + combined.onChange(callback); + + emitA!(testSignal(15)); + expect(callback).toHaveBeenCalledWith(35); + + emitB!(testSignal(25)); + expect(callback).toHaveBeenCalledWith(40); + }); + + it("should dispose and cleanup all subscriptions", () => { + let emitA: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const streamA = createStream((observer) => { + emitA = (s) => observer.next(s); + }); + + const a = stepper(10, streamA, (s) => s.value); + const b = constant(20); + const combined = combine(a, b, (x, y) => x + y); + combined.onChange(callback); + + combined.dispose(); + + expect(combined.isDisposed).toBe(true); + expect(() => combined.sample()).toThrow(); + + emitA!(testSignal(15)); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should map combined behavior", () => { + const a = constant(10); + const b = constant(20); + const combined = combine(a, b, (x, y) => x + y); + const doubled = combined.map((x) => x * 2); + + expect(doubled.sample()).toBe(60); + }); +}); + +describe("switcher", () => { + it("should select ifTrue when condition is true", () => { + const condition = constant(true); + const ifTrue = constant("yes"); + const ifFalse = constant("no"); + const result = switcher(condition, ifTrue, ifFalse); + + expect(result.sample()).toBe("yes"); + }); + + it("should select ifFalse when condition is false", () => { + const condition = constant(false); + const ifTrue = constant("yes"); + const ifFalse = constant("no"); + const result = switcher(condition, ifTrue, ifFalse); + + expect(result.sample()).toBe("no"); + }); + + it("should switch when condition changes", () => { + let emitCond: ((s: Signal<"test", boolean>) => void) | null = null; + + const condStream = createStream>((observer) => { + emitCond = (s) => observer.next(s); + }); + + const condition = stepper(true, condStream, (s) => s.value); + const ifTrue = constant("yes"); + const ifFalse = constant("no"); + const result = switcher(condition, ifTrue, ifFalse); + + expect(result.sample()).toBe("yes"); + + emitCond!(createSignal("test", false)); + expect(result.sample()).toBe("no"); + + emitCond!(createSignal("test", true)); + expect(result.sample()).toBe("yes"); + }); + + it("should update when selected branch changes", () => { + let emitTrue: ((s: TestSignal) => void) | null = null; + let emitFalse: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const trueStream = createStream((observer) => { + emitTrue = (s) => observer.next(s); + }); + const falseStream = createStream((observer) => { + emitFalse = (s) => observer.next(s); + }); + + const condition = constant(true); + const ifTrue = stepper(10, trueStream, (s) => s.value); + const ifFalse = stepper(20, falseStream, (s) => s.value); + const result = switcher(condition, ifTrue, ifFalse); + result.onChange(callback); + + emitTrue!(testSignal(15)); + expect(callback).toHaveBeenCalledWith(15); + + // ifFalse changes but condition is true, should not notify + callback.mockClear(); + emitFalse!(testSignal(25)); + expect(callback).not.toHaveBeenCalled(); + }); + + it("should dispose properly", () => { + const condition = constant(true); + const ifTrue = constant("yes"); + const ifFalse = constant("no"); + const result = switcher(condition, ifTrue, ifFalse); + + result.dispose(); + + expect(result.isDisposed).toBe(true); + expect(() => result.sample()).toThrow(); + }); +}); + +describe("lift", () => { + it("should lift unary function", () => { + const double = (x: number) => x * 2; + const liftedDouble = lift(double); + + const b = constant(5); + const result = liftedDouble(b); + + expect(result.sample()).toBe(10); + }); + + it("should lift binary function", () => { + const add = (a: number, b: number) => a + b; + const liftedAdd = lift(add); + + const ba = constant(3); + const bb = constant(4); + const result = liftedAdd(ba, bb); + + expect(result.sample()).toBe(7); + }); + + it("should lift ternary function", () => { + const sum3 = (a: number, b: number, c: number) => a + b + c; + const liftedSum3 = lift(sum3); + + const ba = constant(1); + const bb = constant(2); + const bc = constant(3); + const result = liftedSum3(ba, bb, bc); + + expect(result.sample()).toBe(6); + }); +}); + +describe("glitch behavior (push-based FRP limitation)", () => { + it("combine notifies for each source change separately", () => { + // This test documents the expected glitch behavior in push-based FRP. + // When multiple sources change "simultaneously", onChange is called + // for each change, exposing intermediate states. + let emitA: ((s: TestSignal) => void) | null = null; + let emitB: ((s: TestSignal) => void) | null = null; + + const streamA = createStream((observer) => { + emitA = (s) => observer.next(s); + }); + const streamB = createStream((observer) => { + emitB = (s) => observer.next(s); + }); + + const a = stepper(1, streamA, (s) => s.value); + const b = stepper(2, streamB, (s) => s.value); + const sum = combine(a, b, (x, y) => x + y); + + const values: number[] = []; + sum.onChange((v) => values.push(v)); + + // Initial: a=1, b=2, sum=3 + expect(sum.sample()).toBe(3); + + // Update both sources + emitA!(testSignal(10)); // a=10, b=2, sum=12 (intermediate) + emitB!(testSignal(20)); // a=10, b=20, sum=30 (final) + + // Glitch: two separate notifications with intermediate state + expect(values).toEqual([12, 30]); + + // However, sample() always returns the correct current value + expect(sum.sample()).toBe(30); + }); +}); diff --git a/packages/cereb/src/frp/combinators.ts b/packages/cereb/src/frp/combinators.ts new file mode 100644 index 0000000..a943671 --- /dev/null +++ b/packages/cereb/src/frp/combinators.ts @@ -0,0 +1,317 @@ +import type { Behavior } from "./behavior.js"; + +/** + * Combines multiple Behaviors into one using a combining function. + * The result updates whenever any source behavior changes. + * + * @example + * const transform = combine( + * positionBehavior, + * scaleBehavior, + * rotationBehavior, + * (pos, scale, rot) => ({ pos, scale, rot }) + * ); + */ +export function combine(a: Behavior, b: Behavior, f: (a: A, b: B) => R): Behavior; + +export function combine( + a: Behavior, + b: Behavior, + c: Behavior, + f: (a: A, b: B, c: C) => R, +): Behavior; + +export function combine( + a: Behavior, + b: Behavior, + c: Behavior, + d: Behavior, + f: (a: A, b: B, c: C, d: D) => R, +): Behavior; + +export function combine( + a: Behavior, + b: Behavior, + c: Behavior, + d: Behavior, + e: Behavior, + f: (a: A, b: B, c: C, d: D, e: E) => R, +): Behavior; + +export function combine(...args: unknown[]): Behavior { + const behaviors = args.slice(0, -1) as Behavior[]; + const f = args[args.length - 1] as (...values: unknown[]) => unknown; + + let disposed = false; + const listeners = new Set<(value: unknown) => void>(); + const unsubscribes: (() => void)[] = []; + + const sample = () => f(...behaviors.map((b) => b.sample())); + + const notifyListeners = () => { + if (disposed) return; + const value = sample(); + for (const listener of listeners) { + listener(value); + } + }; + + // Subscribe to all source behaviors + for (const behavior of behaviors) { + const unsub = behavior.onChange(() => { + notifyListeners(); + }); + unsubscribes.push(unsub); + } + + const combinedBehavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return sample(); + }, + + map: (g: (r: unknown) => B): Behavior => { + return mappedCombine(combinedBehavior, g); + }, + + ap: function (this: Behavior<(b: B) => unknown>, bb: Behavior): Behavior { + return appliedCombine(this, bb); + }, + + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + + dispose: () => { + if (disposed) return; + disposed = true; + listeners.clear(); + for (const unsub of unsubscribes) { + unsub(); + } + unsubscribes.length = 0; + }, + + get isDisposed() { + return disposed; + }, + }; + + return combinedBehavior; +} + +/** + * Helper for mapping a combined behavior + */ +function mappedCombine(source: Behavior, f: (a: A) => B): Behavior { + let disposed = false; + const listeners = new Set<(value: B) => void>(); + + const sample = () => f(source.sample()); + + const notifyListeners = () => { + if (disposed) return; + const value = sample(); + for (const listener of listeners) { + listener(value); + } + }; + + const unsub = source.onChange(() => notifyListeners()); + + const behavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return sample(); + }, + map: (g: (b: B) => C): Behavior => { + return mappedCombine(behavior, g); + }, + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedCombine(this, bc); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + listeners.clear(); + unsub(); + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} + +/** + * Helper for ap on combined behaviors + */ +function appliedCombine(bf: Behavior<(a: A) => B>, ba: Behavior): Behavior { + let disposed = false; + const listeners = new Set<(value: B) => void>(); + + const sample = () => bf.sample()(ba.sample()); + + const notifyListeners = () => { + if (disposed) return; + const value = sample(); + for (const listener of listeners) { + listener(value); + } + }; + + const unsubF = bf.onChange(() => notifyListeners()); + const unsubA = ba.onChange(() => notifyListeners()); + + const behavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return sample(); + }, + map: (g: (b: B) => C): Behavior => { + return appliedCombine( + bf.map((f) => (a: A) => g(f(a))), + ba, + ); + }, + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedCombine(this, bc); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + listeners.clear(); + unsubF(); + unsubA(); + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} + +/** + * Selects between two Behaviors based on a boolean condition Behavior. + * When condition is true, samples from ifTrue; otherwise from ifFalse. + * + * @example + * const displayValue = switcher( + * isEditMode, + * editableValue, + * readonlyValue + * ); + */ +export function switcher( + condition: Behavior, + ifTrue: Behavior, + ifFalse: Behavior, +): Behavior { + let disposed = false; + const listeners = new Set<(value: A) => void>(); + + const sample = () => (condition.sample() ? ifTrue.sample() : ifFalse.sample()); + + const notifyListeners = () => { + if (disposed) return; + const value = sample(); + for (const listener of listeners) { + listener(value); + } + }; + + const unsubCond = condition.onChange(() => notifyListeners()); + const unsubTrue = ifTrue.onChange(() => { + if (condition.sample()) notifyListeners(); + }); + const unsubFalse = ifFalse.onChange(() => { + if (!condition.sample()) notifyListeners(); + }); + + const behavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return sample(); + }, + map: (f: (a: A) => B): Behavior => { + return switcher(condition, ifTrue.map(f), ifFalse.map(f)); + }, + ap: function (this: Behavior<(b: B) => A>, bb: Behavior): Behavior { + return appliedCombine(this, bb); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + listeners.clear(); + unsubCond(); + unsubTrue(); + unsubFalse(); + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} + +/** + * Lifts a pure function to work with Behaviors. + * Equivalent to combining behaviors and applying a function. + * + * @example + * const add = (a: number, b: number) => a + b; + * const sumBehavior = lift(add)(behaviorA, behaviorB); + */ +export function lift(f: (a: A) => R): (ba: Behavior) => Behavior; +export function lift( + f: (a: A, b: B) => R, +): (ba: Behavior, bb: Behavior) => Behavior; +export function lift( + f: (a: A, b: B, c: C) => R, +): (ba: Behavior, bb: Behavior, bc: Behavior) => Behavior; + +export function lift(f: (...args: unknown[]) => unknown) { + return (...behaviors: Behavior[]) => { + if (behaviors.length === 1) { + return behaviors[0].map(f); + } + // Use explicit overload calls to avoid spread type issues + if (behaviors.length === 2) { + return combine(behaviors[0], behaviors[1], (a, b) => f(a, b)); + } + if (behaviors.length === 3) { + return combine(behaviors[0], behaviors[1], behaviors[2], (a, b, c) => f(a, b, c)); + } + // For 4+ arguments, fall through to variadic combine + // This requires explicit handling due to TypeScript limitations + throw new Error("lift supports up to 3 behaviors. Use combine() directly for more."); + }; +} diff --git a/packages/cereb/src/frp/conversions.spec.ts b/packages/cereb/src/frp/conversions.spec.ts new file mode 100644 index 0000000..f4696c1 --- /dev/null +++ b/packages/cereb/src/frp/conversions.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSignal, createStream, type Signal } from "../core/index.js"; +import { constant, stepper } from "./behavior.js"; +import { changes, sample, sampleOn } from "./conversions.js"; + +type TestSignal = Signal<"test", number>; + +function testSignal(value: number): TestSignal { + return createSignal("test", value); +} + +describe("changes", () => { + it("should emit when behavior value changes", () => { + let emit: ((s: TestSignal) => void) | null = null; + const values: number[] = []; + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + const changeStream = changes(b); + + changeStream.on((signal) => { + values.push(signal.value); + }); + + emit!(testSignal(10)); + emit!(testSignal(20)); + emit!(testSignal(30)); + + expect(values).toEqual([10, 20, 30]); + }); + + it("should emit signals with correct kind", () => { + let emit: ((s: TestSignal) => void) | null = null; + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + const changeStream = changes(b); + + let receivedSignal: Signal | null = null; + changeStream.on((signal) => { + receivedSignal = signal; + }); + + emit!(testSignal(10)); + + expect(receivedSignal).not.toBeNull(); + expect(receivedSignal!.kind).toBe("behavior-change"); + expect(receivedSignal!.value).toBe(10); + }); + + it("should not emit for constant behavior", () => { + const callback = vi.fn(); + const b = constant(42); + const changeStream = changes(b); + + changeStream.on(callback); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("should cleanup on unsubscribe", () => { + let emit: ((s: TestSignal) => void) | null = null; + const callback = vi.fn(); + + const stream = createStream((observer) => { + emit = (s) => observer.next(s); + }); + + const b = stepper(0, stream, (s) => s.value); + const changeStream = changes(b); + + const unsub = changeStream.on(callback); + + emit!(testSignal(10)); + expect(callback).toHaveBeenCalledTimes(1); + + unsub(); + + emit!(testSignal(20)); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +describe("sample (interval)", () => { + it("should sample behavior at intervals", async () => { + const b = constant(42); + const values: number[] = []; + + const sampleStream = sample(b, 50); + const unsub = sampleStream.on((signal) => { + values.push(signal.value); + }); + + await new Promise((resolve) => setTimeout(resolve, 175)); + + unsub(); + + expect(values.length).toBeGreaterThanOrEqual(3); + expect(values.every((v) => v === 42)).toBe(true); + }); + + it("should emit signals with correct kind", async () => { + const b = constant(42); + let receivedSignal: Signal | null = null; + + const sampleStream = sample(b, 50); + const unsub = sampleStream.on((signal) => { + receivedSignal = signal; + }); + + await new Promise((resolve) => setTimeout(resolve, 75)); + unsub(); + + expect(receivedSignal).not.toBeNull(); + expect(receivedSignal!.kind).toBe("sampled"); + }); + + it("should cleanup interval on unsubscribe", async () => { + const b = constant(42); + const callback = vi.fn(); + + const sampleStream = sample(b, 50); + const unsub = sampleStream.on(callback); + + await new Promise((resolve) => setTimeout(resolve, 75)); + const countBefore = callback.mock.calls.length; + + unsub(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const countAfter = callback.mock.calls.length; + + expect(countAfter).toBe(countBefore); + }); +}); + +describe("sampleOn", () => { + it("should sample behavior when trigger fires", () => { + let emitBehavior: ((s: TestSignal) => void) | null = null; + let emitTrigger: ((s: Signal<"trigger", string>) => void) | null = null; + + const behaviorStream = createStream((observer) => { + emitBehavior = (s) => observer.next(s); + }); + const triggerStream = createStream>((observer) => { + emitTrigger = (s) => observer.next(s); + }); + + const b = stepper(0, behaviorStream, (s) => s.value); + const sampledStream = sampleOn(b, triggerStream); + + const results: Array<{ value: number; trigger: Signal<"trigger", string> }> = []; + sampledStream.on((signal) => { + results.push(signal.value); + }); + + emitBehavior!(testSignal(10)); + emitTrigger!(createSignal("trigger", "click1")); + + expect(results.length).toBe(1); + expect(results[0].value).toBe(10); + expect(results[0].trigger.value).toBe("click1"); + + emitBehavior!(testSignal(20)); + emitTrigger!(createSignal("trigger", "click2")); + + expect(results.length).toBe(2); + expect(results[1].value).toBe(20); + }); + + it("should emit signals with correct kind", () => { + let emitTrigger: ((s: Signal<"trigger", string>) => void) | null = null; + + const triggerStream = createStream>((observer) => { + emitTrigger = (s) => observer.next(s); + }); + + const b = constant(42); + const sampledStream = sampleOn(b, triggerStream); + + let receivedSignal: Signal | null = null; + sampledStream.on((signal) => { + receivedSignal = signal; + }); + + emitTrigger!(createSignal("trigger", "click")); + + expect(receivedSignal).not.toBeNull(); + expect(receivedSignal!.kind).toBe("sampled-on"); + }); + + it("should cleanup on unsubscribe", () => { + type TriggerObserver = { next: (s: Signal<"trigger", string>) => void } | null; + let activeObserver: TriggerObserver = null; + const callback = vi.fn(); + + const triggerStream = createStream>((observer) => { + activeObserver = observer; + return () => { + activeObserver = null; + }; + }); + + const emitTrigger = (s: Signal<"trigger", string>) => activeObserver?.next(s); + + const b = constant(42); + const sampledStream = sampleOn(b, triggerStream); + + const unsub = sampledStream.on(callback); + + emitTrigger(createSignal("trigger", "click1")); + expect(callback).toHaveBeenCalledTimes(1); + + unsub(); + + emitTrigger(createSignal("trigger", "click2")); + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cereb/src/frp/conversions.ts b/packages/cereb/src/frp/conversions.ts new file mode 100644 index 0000000..738304c --- /dev/null +++ b/packages/cereb/src/frp/conversions.ts @@ -0,0 +1,305 @@ +import { createSignal, createStream, type Signal, type Stream } from "../core/index.js"; +import type { Behavior } from "./behavior.js"; + +/** + * Signal type for behavior change events + */ +export type BehaviorChangeSignal = Signal<"behavior-change", A>; + +/** + * Signal type for sampled values + */ +export type SampledSignal = Signal<"sampled", A>; + +/** + * Signal type for animation frame samples + */ +export type FrameSignal = Signal<"frame", { value: A; timestamp: number }>; + +/** + * Converts a Behavior to an Event (Stream) that fires when the value changes. + * This is the Behavior → Event conversion. + * + * @param behavior - The behavior to observe + * @returns A stream that emits signals whenever the behavior's value changes + * + * @example + * const positionChanges = changes(positionBehavior); + * positionChanges.on(signal => { + * console.log('Position changed to:', signal.value); + * }); + */ +export function changes(behavior: Behavior): Stream> { + return createStream((observer) => { + const unsub = behavior.onChange((value) => { + observer.next(createSignal("behavior-change", value)); + }); + + return () => { + unsub(); + }; + }); +} + +/** + * Samples a Behavior at regular intervals. + * + * @param behavior - The behavior to sample + * @param intervalMs - The sampling interval in milliseconds + * @returns A stream that emits the sampled value at each interval + * + * @example + * const positionSamples = sample(positionBehavior, 16); // ~60fps + * positionSamples.on(signal => { + * updateDisplay(signal.value); + * }); + */ +export function sample(behavior: Behavior, intervalMs: number): Stream> { + return createStream((observer) => { + const id = setInterval(() => { + if (!behavior.isDisposed) { + observer.next(createSignal("sampled", behavior.sample())); + } + }, intervalMs); + + return () => { + clearInterval(id); + }; + }); +} + +/** + * Samples a Behavior whenever another event occurs. + * Useful for getting the current state at specific moments. + * + * @param behavior - The behavior to sample + * @param trigger - The trigger event stream + * @returns A stream that emits the sampled value along with the trigger signal + * + * @example + * const positionOnClick = sampleOn(positionBehavior, clickStream); + * positionOnClick.on(signal => { + * console.log('Position at click:', signal.value.value); + * console.log('Click event:', signal.value.trigger); + * }); + */ +export function sampleOn( + behavior: Behavior, + trigger: Stream, +): Stream> { + return createStream((observer) => { + const unsub = trigger.on((signal) => { + if (!behavior.isDisposed) { + observer.next( + createSignal("sampled-on", { + value: behavior.sample(), + trigger: signal, + }), + ); + } + }); + + return unsub; + }); +} + +/** + * Samples a Behavior on every animation frame using requestAnimationFrame. + * Ideal for rendering loops and smooth animations. + * + * @param behavior - The behavior to sample + * @returns A stream that emits on each animation frame with the sampled value and timestamp + * + * @example + * const frameStream = animationFrame(transformBehavior); + * frameStream.on(({ value }) => { + * element.style.transform = `translate(${value.x}px, ${value.y}px)`; + * }); + */ +export function animationFrame(behavior: Behavior): Stream> { + return createStream((observer) => { + let running = true; + let frameId: number; + + const loop = (timestamp: number) => { + if (!running) return; + + if (!behavior.isDisposed) { + observer.next( + createSignal("frame", { + value: behavior.sample(), + timestamp, + }), + ); + } + + frameId = requestAnimationFrame(loop); + }; + + frameId = requestAnimationFrame(loop); + + return () => { + running = false; + cancelAnimationFrame(frameId); + }; + }); +} + +/** + * Creates a Behavior that tracks elapsed time since creation. + * The elapsed time is updated on each animation frame. + * + * Note: This returns both a Behavior and a dispose function. + * Call dispose() when you no longer need the elapsed time tracking. + * + * @returns Object containing the elapsed behavior and dispose function + * + * @example + * const { elapsed, dispose } = elapsedTime(); + * // Use elapsed.sample() to get time since creation + * // Call dispose() when done + */ +export function elapsedTime(): { elapsed: Behavior; dispose: () => void } { + const startTime = performance.now(); + let currentElapsed = 0; + let disposed = false; + let frameId: number; + const listeners = new Set<(value: number) => void>(); + + const loop = () => { + if (disposed) return; + currentElapsed = performance.now() - startTime; + for (const listener of listeners) { + listener(currentElapsed); + } + frameId = requestAnimationFrame(loop); + }; + + frameId = requestAnimationFrame(loop); + + const elapsed: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return performance.now() - startTime; + }, + map: (f: (t: number) => B): Behavior => { + return mappedElapsed(elapsed, f); + }, + ap: function (this: Behavior<(b: B) => number>, bb: Behavior): Behavior { + return appliedElapsed(this, bb); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + cancelAnimationFrame(frameId); + listeners.clear(); + }, + get isDisposed() { + return disposed; + }, + }; + + return { + elapsed, + dispose: () => elapsed.dispose(), + }; +} + +function mappedElapsed(source: Behavior, f: (a: A) => B): Behavior { + let disposed = false; + const listeners = new Set<(value: B) => void>(); + + const unsub = source.onChange((value) => { + if (disposed) return; + const mapped = f(value); + for (const listener of listeners) { + listener(mapped); + } + }); + + const behavior: Behavior = { + sample: () => { + if (disposed || source.isDisposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return f(source.sample()); + }, + map: (g: (b: B) => C): Behavior => { + return mappedElapsed(behavior, g); + }, + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedElapsed(this, bc); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + unsub(); + listeners.clear(); + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} + +function appliedElapsed(bf: Behavior<(a: A) => B>, ba: Behavior): Behavior { + let disposed = false; + const listeners = new Set<(value: B) => void>(); + + const notify = () => { + if (disposed) return; + const value = bf.sample()(ba.sample()); + for (const listener of listeners) { + listener(value); + } + }; + + const unsubF = bf.onChange(notify); + const unsubA = ba.onChange(notify); + + const behavior: Behavior = { + sample: () => { + if (disposed) { + throw new Error("Cannot sample a disposed Behavior"); + } + return bf.sample()(ba.sample()); + }, + map: (g: (b: B) => C): Behavior => { + return mappedElapsed(behavior, g); + }, + ap: function (this: Behavior<(c: C) => B>, bc: Behavior): Behavior { + return appliedElapsed(this, bc); + }, + onChange: (callback) => { + if (disposed) return () => {}; + listeners.add(callback); + return () => listeners.delete(callback); + }, + dispose: () => { + if (disposed) return; + disposed = true; + unsubF(); + unsubA(); + listeners.clear(); + }, + get isDisposed() { + return disposed; + }, + }; + + return behavior; +} diff --git a/packages/cereb/src/frp/event.ts b/packages/cereb/src/frp/event.ts new file mode 100644 index 0000000..476ee92 --- /dev/null +++ b/packages/cereb/src/frp/event.ts @@ -0,0 +1,15 @@ +import type { Signal, Stream } from "../core/index.js"; + +/** + * Event represents discrete occurrences over time. + * This is a semantic alias for Stream to align with FRP terminology. + * + * In FRP theory: + * - Event = discrete occurrences (clicks, key presses, gesture signals) + * - Behavior = continuous values over time (position, scale) + * + * Event uses all existing Stream operations: pipe(), filter(), map(), etc. + * + * @typeParam S - The Signal type for this event stream + */ +export type Event = Stream; diff --git a/packages/cereb/src/frp/index.ts b/packages/cereb/src/frp/index.ts new file mode 100644 index 0000000..3961e4b --- /dev/null +++ b/packages/cereb/src/frp/index.ts @@ -0,0 +1,17 @@ +// Behavior - continuous time-varying values +export { type Behavior, constant, stepper, time } from "./behavior.js"; +// Combinators - composing behaviors +export { combine, lift, switcher } from "./combinators.js"; +// Conversions - Behavior ↔ Event transformations +export { + animationFrame, + type BehaviorChangeSignal, + changes, + elapsedTime, + type FrameSignal, + type SampledSignal, + sample, + sampleOn, +} from "./conversions.js"; +// Event - discrete occurrences (alias for Stream) +export type { Event } from "./event.js"; diff --git a/packages/cereb/src/index.ts b/packages/cereb/src/index.ts index aa95071..b6d9e62 100644 --- a/packages/cereb/src/index.ts +++ b/packages/cereb/src/index.ts @@ -1,2 +1,3 @@ export * from "./browser/index.js"; export * from "./core/index.js"; +export * from "./geometry/index.js"; From 878bdccdfd29bb6607d76ee6d174d2de0e82633b Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Fri, 9 Jan 2026 23:28:07 +0900 Subject: [PATCH 3/4] feat(docs): add FRP API and behavior/event documentation - Add Behavior & Event core concepts guide - Document behavior creation (constant, stepper, time) - Add combinators documentation (combine, switcher, lift) - Add conversions documentation (changes, sample, sampleOn, animationFrame, elapsedTime) - Update sidebar navigation with FRP API section --- docs/src/config/sidebar.json | 21 ++ .../core-concepts/behavior-and-event.mdx | 146 ++++++++++++ docs/src/content/frp-api/behavior.mdx | 155 ++++++++++++ docs/src/content/frp-api/combinators.mdx | 149 ++++++++++++ docs/src/content/frp-api/conversions.mdx | 225 ++++++++++++++++++ .../core-concepts/behavior-and-event.astro | 17 ++ docs/src/pages/frp-api/behavior.astro | 15 ++ docs/src/pages/frp-api/combinators.astro | 14 ++ docs/src/pages/frp-api/conversions.astro | 16 ++ 9 files changed, 758 insertions(+) create mode 100644 docs/src/content/core-concepts/behavior-and-event.mdx create mode 100644 docs/src/content/frp-api/behavior.mdx create mode 100644 docs/src/content/frp-api/combinators.mdx create mode 100644 docs/src/content/frp-api/conversions.mdx create mode 100644 docs/src/pages/core-concepts/behavior-and-event.astro create mode 100644 docs/src/pages/frp-api/behavior.astro create mode 100644 docs/src/pages/frp-api/combinators.astro create mode 100644 docs/src/pages/frp-api/conversions.astro diff --git a/docs/src/config/sidebar.json b/docs/src/config/sidebar.json index 06d7c3d..216010c 100644 --- a/docs/src/config/sidebar.json +++ b/docs/src/config/sidebar.json @@ -37,6 +37,10 @@ "label": "[] Key Models", "slug": "core-concepts/key-models" }, + { + "label": "Behavior & Event", + "slug": "core-concepts/behavior-and-event" + }, { "label": "Creating Operators", "slug": "core-concepts/creating-operators" @@ -168,6 +172,23 @@ "slug": "operator-api/zoom" } ] + }, + { + "label": "FRP API", + "items": [ + { + "label": "behavior", + "slug": "frp-api/behavior" + }, + { + "label": "combinators", + "slug": "frp-api/combinators" + }, + { + "label": "conversions", + "slug": "frp-api/conversions" + } + ] } ] } diff --git a/docs/src/content/core-concepts/behavior-and-event.mdx b/docs/src/content/core-concepts/behavior-and-event.mdx new file mode 100644 index 0000000..7cdce15 --- /dev/null +++ b/docs/src/content/core-concepts/behavior-and-event.mdx @@ -0,0 +1,146 @@ +# Behavior & Event + +Cereb provides two complementary abstractions for modeling time-varying values: **Behavior** and **Event** (Stream). Understanding when to use each is key to building effective reactive systems. + +## The Core Distinction + +``` +Stream (Event) → "What happened?" (clicks, touches, gestures) +Behavior → "What is the current value?" (position, scale, time) +``` + +| Aspect | Stream (Event) | Behavior | +|--------|----------------|----------| +| **Semantics** | Discrete occurrences | Continuous values | +| **Access** | Subscribe and wait | Sample anytime | +| **Example** | Click events, gesture phases | Current position, elapsed time | + +## Behavior + +A **Behavior** represents a value that varies continuously over time. Unlike a Stream, a Behavior always has a current value that can be sampled at any moment. + +```typescript +interface Behavior { + sample(): A; // Get current value + map(f: (a: A) => B): Behavior; // Transform value + onChange(callback: (a: A) => void): () => void; // Subscribe to changes + dispose(): void; // Clean up resources +} +``` + +### Creating Behaviors + +```typescript +import { constant, stepper, time } from "cereb/frp"; + +// Fixed value that never changes +const always42 = constant(42); +always42.sample(); // 42 + +// Track the latest value from a stream +const position = stepper( + { x: 0, y: 0 }, // Initial value + pointerStream, // Source stream + (signal) => signal.value.position // Selector +); +position.sample(); // Current position + +// Current time (always changing) +const t = time(); +t.sample(); // Current timestamp +``` + +## When to Use Each + +### Use Behavior When: + +- **Animation frames need current state**: You're rendering at 60fps and need to sample multiple values each frame +- **Combining multiple values**: You need to compute a transform from position, scale, and rotation +- **VR/AR tracking**: Continuous sampling of headset or controller position +- **Physics simulations**: Values that change continuously between discrete events + +```typescript +import { combine, animationFrame } from "cereb/frp"; + +// Combine multiple behaviors into a transform +const transform = combine( + positionBehavior, + scaleBehavior, + rotationBehavior, + (pos, scale, rot) => ({ + transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale}) rotate(${rot}deg)` + }) +); + +// Sample on every animation frame +animationFrame(transform).on(({ value }) => { + element.style.transform = value.transform; +}); +``` + +### Use Stream When: + +- **Reacting to events**: Something happened that you need to respond to +- **Filtering/transforming events**: Building gesture recognition pipelines +- **Event-driven logic**: Conditional flows based on event types + +```typescript +import { singlePointer } from "cereb"; +import { filter, session } from "cereb/operators"; + +// React to pointer events +singlePointer(element) + .pipe( + filter((s) => s.value.phase === "start"), + session() + ) + .on((signal) => { + // Handle gesture start + }); +``` + +## Push-Pull Hybrid Model + +Cereb's FRP implementation uses a **push-pull hybrid** approach: + +- **Push**: When a source value changes, listeners are notified via `onChange()` +- **Pull**: At any time, you can `sample()` the current value + +This gives you the best of both worlds: + +```typescript +// Push: React when value changes +position.onChange((pos) => { + console.log("Position changed to:", pos); +}); + +// Pull: Get current value on demand +const currentPos = position.sample(); +``` + +## Glitch Behavior + +When multiple source Behaviors change "simultaneously", `onChange` callbacks may see intermediate states. This is a known characteristic of push-based FRP: + +```typescript +const sum = combine(a, b, (x, y) => x + y); + +// If a changes to 10 and b changes to 20: +// onChange may fire twice: once with (10 + oldB), once with (10 + 20) +``` + +**Mitigation**: Use `sample()` when you need a consistent snapshot of the current state, rather than relying solely on `onChange` notifications. + +## Converting Between Behavior and Stream + +The `cereb/frp` module provides conversion functions: + +| Function | From | To | Use Case | +|----------|------|------|----------| +| `stepper(initial, stream, selector)` | Stream | Behavior | Track latest value from events | +| `changes(behavior)` | Behavior | Stream | Emit when value changes | +| `sample(behavior, intervalMs)` | Behavior | Stream | Periodic sampling | +| `sampleOn(behavior, trigger)` | Behavior | Stream | Sample at specific moments | +| `animationFrame(behavior)` | Behavior | Stream | Sample every frame | + +See the [FRP API](/frp-api/behavior) documentation for detailed usage. diff --git a/docs/src/content/frp-api/behavior.mdx b/docs/src/content/frp-api/behavior.mdx new file mode 100644 index 0000000..3a21f93 --- /dev/null +++ b/docs/src/content/frp-api/behavior.mdx @@ -0,0 +1,155 @@ +# behavior + +Functions for creating Behaviors - continuous time-varying values that can be sampled at any time. + +## `constant` + +Creates a Behavior that always returns the same value. + +### Signature + +```typescript +function constant(value: A): Behavior +``` + +### Example + +```typescript +import { constant } from "cereb/frp"; + +const always42 = constant(42); +always42.sample(); // 42 +always42.sample(); // 42 (always the same) + +// Useful as a default or placeholder +const defaultScale = constant(1.0); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `value` | `A` | The constant value | + +--- + +## `stepper` + +Creates a Behavior that tracks the latest value from a Stream. The Behavior holds the initial value until the first event arrives. + +### Signature + +```typescript +function stepper( + initial: A, + event: Stream, + selector: (signal: S) => A +): Behavior +``` + +### Example + +```typescript +import { singlePointer } from "cereb"; +import { stepper } from "cereb/frp"; + +const pointerStream = singlePointer(element); + +// Track current pointer position +const position = stepper( + { x: 0, y: 0 }, // Initial value + pointerStream, // Source stream + (signal) => ({ // Selector + x: signal.value.x, + y: signal.value.y + }) +); + +// Sample current position anytime +const currentPos = position.sample(); + +// Subscribe to position changes +position.onChange((pos) => { + console.log("Position updated:", pos); +}); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `initial` | `A` | The initial value before any events | +| `event` | `Stream` | The source event stream | +| `selector` | `(signal: S) => A` | Function to extract value from each signal | + +### Notes + +- The selector is called for each signal, and the Behavior updates only if the new value differs (using `Object.is`) +- `onChange` is not called if the selected value is the same as the current value + +--- + +## `time` + +Creates a Behavior that represents the current time. Each `sample()` call returns the current timestamp from `performance.now()`. + +### Signature + +```typescript +function time(): Behavior +``` + +### Example + +```typescript +import { time } from "cereb/frp"; + +const t = time(); + +const now = t.sample(); // Current timestamp +// ... some work ... +const later = t.sample(); // Later timestamp + +console.log(later - now); // Elapsed time + +// Map to seconds +const seconds = t.map((ms) => ms / 1000); +``` + +### Notes + +- `onChange()` returns a no-op function because time changes continuously +- For time-based events, use `animationFrame()` or `sample()` from conversions + +--- + +## Behavior Interface + +All Behaviors implement this interface: + +```typescript +interface Behavior { + sample(): A; // Get current value + map(f: (a: A) => B): Behavior; // Transform value (Functor) + ap(this: Behavior<(b: B) => A>, bb: Behavior): Behavior; // Apply (Applicative) + onChange(callback: (a: A) => void): () => void; // Subscribe to changes + dispose(): void; // Clean up resources + readonly isDisposed: boolean; // Check if disposed +} +``` + +### Functor Laws + +Behaviors satisfy the Functor laws: + +```typescript +// Identity +b.map(x => x) ≡ b + +// Composition +b.map(f).map(g) ≡ b.map(x => g(f(x))) +``` + +### Applicative Laws + +Behaviors also satisfy Applicative laws (Identity, Homomorphism, Interchange, Composition). diff --git a/docs/src/content/frp-api/combinators.mdx b/docs/src/content/frp-api/combinators.mdx new file mode 100644 index 0000000..4ce464b --- /dev/null +++ b/docs/src/content/frp-api/combinators.mdx @@ -0,0 +1,149 @@ +# combinators + +Functions for combining and transforming multiple Behaviors. + +## `combine` + +Combines multiple Behaviors into one using a combining function. The result updates whenever any source Behavior changes. + +### Signature + +```typescript +function combine(a: Behavior, b: Behavior, f: (a: A, b: B) => R): Behavior +function combine(a: Behavior, b: Behavior, c: Behavior, f: (a: A, b: B, c: C) => R): Behavior +// ... up to 5 behaviors +``` + +### Example + +```typescript +import { stepper, combine } from "cereb/frp"; + +const position = stepper({ x: 0, y: 0 }, posStream, (s) => s.value.position); +const scale = stepper(1, scaleStream, (s) => s.value.scale); +const rotation = stepper(0, rotStream, (s) => s.value.rotation); + +// Combine into a transform +const transform = combine( + position, + scale, + rotation, + (pos, s, rot) => `translate(${pos.x}px, ${pos.y}px) scale(${s}) rotate(${rot}deg)` +); + +// Sample the combined value +element.style.transform = transform.sample(); + +// Or subscribe to changes +transform.onChange((t) => { + element.style.transform = t; +}); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `a, b, ...` | `Behavior<*>` | Source behaviors (2-5) | +| `f` | `(...values) => R` | Combining function | + +### Notes + +- When multiple sources change, `onChange` fires for each change (glitch behavior) +- Use `sample()` for consistent snapshots + +--- + +## `switcher` + +Selects between two Behaviors based on a boolean condition Behavior. + +### Signature + +```typescript +function switcher( + condition: Behavior, + ifTrue: Behavior, + ifFalse: Behavior +): Behavior +``` + +### Example + +```typescript +import { constant, stepper, switcher } from "cereb/frp"; + +const isEditing = stepper(false, modeStream, (s) => s.value.editing); +const editValue = stepper("", inputStream, (s) => s.value.text); +const savedValue = constant("Saved content"); + +// Switch between edit and display mode +const displayValue = switcher(isEditing, editValue, savedValue); + +displayValue.sample(); // Returns editValue.sample() or savedValue.sample() +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `condition` | `Behavior` | The condition behavior | +| `ifTrue` | `Behavior` | Value when condition is true | +| `ifFalse` | `Behavior` | Value when condition is false | + +### Notes + +- Only the active branch triggers `onChange` notifications +- When condition changes, the new branch's current value is emitted + +--- + +## `lift` + +Lifts a pure function to work with Behaviors. This is a convenience for applying functions to Behavior values. + +### Signature + +```typescript +function lift(f: (a: A) => R): (ba: Behavior) => Behavior +function lift(f: (a: A, b: B) => R): (ba: Behavior, bb: Behavior) => Behavior +function lift(f: (a: A, b: B, c: C) => R): (ba: Behavior, bb: Behavior, bc: Behavior) => Behavior +``` + +### Example + +```typescript +import { constant, lift } from "cereb/frp"; + +// Lift a binary function +const add = (a: number, b: number) => a + b; +const liftedAdd = lift(add); + +const a = constant(3); +const b = constant(4); +const sum = liftedAdd(a, b); + +sum.sample(); // 7 + +// Lift a unary function +const double = (x: number) => x * 2; +const liftedDouble = lift(double); + +const doubled = liftedDouble(a); +doubled.sample(); // 6 +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `f` | `(...args) => R` | The pure function to lift | + +### Returns + +A function that takes Behaviors and returns a Behavior. + +### Notes + +- `lift` supports up to 3 behaviors. For more, use `combine()` directly +- Equivalent to `combine(a, b, c, f)` but with curried syntax diff --git a/docs/src/content/frp-api/conversions.mdx b/docs/src/content/frp-api/conversions.mdx new file mode 100644 index 0000000..a178124 --- /dev/null +++ b/docs/src/content/frp-api/conversions.mdx @@ -0,0 +1,225 @@ +# conversions + +Functions for converting between Behaviors and Streams. + +## `changes` + +Converts a Behavior to a Stream that emits whenever the value changes. + +### Signature + +```typescript +function changes(behavior: Behavior): Stream> +``` + +### Example + +```typescript +import { stepper, changes } from "cereb/frp"; + +const position = stepper({ x: 0, y: 0 }, pointerStream, (s) => s.value); + +// Convert to a stream of changes +const positionChanges = changes(position); + +positionChanges.on((signal) => { + console.log("Position changed to:", signal.value); +}); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `behavior` | `Behavior` | The behavior to observe | + +### Returns + +A `Stream>` that emits when the behavior's value changes. + +### Notes + +- Does not emit for constant behaviors (they never change) +- Emits immediately when the underlying source changes + +--- + +## `sample` + +Samples a Behavior at regular intervals. + +### Signature + +```typescript +function sample(behavior: Behavior, intervalMs: number): Stream> +``` + +### Example + +```typescript +import { time, sample } from "cereb/frp"; + +const t = time(); + +// Sample time every 100ms +const timeSamples = sample(t, 100); + +timeSamples.on((signal) => { + console.log("Time:", signal.value); +}); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `behavior` | `Behavior` | The behavior to sample | +| `intervalMs` | `number` | Sampling interval in milliseconds | + +### Returns + +A `Stream>` that emits at each interval. + +### Notes + +- Uses `setInterval` internally +- Unsubscribing clears the interval + +--- + +## `sampleOn` + +Samples a Behavior whenever a trigger Stream fires. + +### Signature + +```typescript +function sampleOn( + behavior: Behavior, + trigger: Stream +): Stream> +``` + +### Example + +```typescript +import { constant, sampleOn } from "cereb/frp"; +import { domEvent } from "cereb"; + +const counter = stepper(0, incrementStream, (s) => s.value); +const clicks = domEvent(button, "click"); + +// Sample counter value on each click +const counterOnClick = sampleOn(counter, clicks); + +counterOnClick.on((signal) => { + console.log("Counter at click:", signal.value.value); + console.log("Click event:", signal.value.trigger); +}); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `behavior` | `Behavior` | The behavior to sample | +| `trigger` | `Stream` | The trigger stream | + +### Returns + +A `Stream>` containing both the sampled value and the trigger signal. + +--- + +## `animationFrame` + +Samples a Behavior on every animation frame using `requestAnimationFrame`. + +### Signature + +```typescript +function animationFrame(behavior: Behavior): Stream> +``` + +### Example + +```typescript +import { combine, animationFrame } from "cereb/frp"; + +const transform = combine( + positionBehavior, + scaleBehavior, + (pos, scale) => `translate(${pos.x}px, ${pos.y}px) scale(${scale})` +); + +// Render on every frame +const unsubscribe = animationFrame(transform).on(({ value }) => { + element.style.transform = value.value; +}); + +// Later: stop the animation loop +unsubscribe(); +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `behavior` | `Behavior` | The behavior to sample | + +### Returns + +A `Stream>` that emits on each animation frame with the sampled value and the frame timestamp. + +### Notes + +- Ideal for smooth animations at 60fps +- Unsubscribing cancels the animation frame loop + +--- + +## `elapsedTime` + +Creates a Behavior that tracks elapsed time since creation, updating every animation frame. + +### Signature + +```typescript +function elapsedTime(): { elapsed: Behavior; dispose: () => void } +``` + +### Example + +```typescript +import { elapsedTime } from "cereb/frp"; + +const { elapsed, dispose } = elapsedTime(); + +// Sample elapsed time +console.log("Elapsed:", elapsed.sample()); // 0 +// ... later ... +console.log("Elapsed:", elapsed.sample()); // e.g., 1234.56 + +// Use with animations +elapsed.onChange((t) => { + const progress = Math.min(t / 1000, 1); // 0 to 1 over 1 second + element.style.opacity = String(progress); +}); + +// Clean up when done +dispose(); +``` + +### Returns + +An object with: + +| Property | Type | Description | +|----------|------|-------------| +| `elapsed` | `Behavior` | Elapsed time in milliseconds | +| `dispose` | `() => void` | Function to stop tracking and clean up | + +### Notes + +- Updates via `requestAnimationFrame` internally +- Call `dispose()` to stop the animation loop and clean up resources diff --git a/docs/src/pages/core-concepts/behavior-and-event.astro b/docs/src/pages/core-concepts/behavior-and-event.astro new file mode 100644 index 0000000..4c70e6f --- /dev/null +++ b/docs/src/pages/core-concepts/behavior-and-event.astro @@ -0,0 +1,17 @@ +--- +import DocsLayout from "@/layouts/docs-layout.astro"; +import Content from "@/content/core-concepts/behavior-and-event.mdx"; + +const headings = [ + { depth: 2, slug: "the-core-distinction", text: "The Core Distinction" }, + { depth: 2, slug: "behavior", text: "Behavior" }, + { depth: 2, slug: "when-to-use-each", text: "When to Use Each" }, + { depth: 2, slug: "push-pull-hybrid-model", text: "Push-Pull Hybrid Model" }, + { depth: 2, slug: "glitch-behavior", text: "Glitch Behavior" }, + { depth: 2, slug: "converting-between-behavior-and-stream", text: "Converting Between Behavior and Stream" }, +]; +--- + + + + diff --git a/docs/src/pages/frp-api/behavior.astro b/docs/src/pages/frp-api/behavior.astro new file mode 100644 index 0000000..8224be5 --- /dev/null +++ b/docs/src/pages/frp-api/behavior.astro @@ -0,0 +1,15 @@ +--- +import DocsLayout from "@/layouts/docs-layout.astro"; +import Content from "@/content/frp-api/behavior.mdx"; + +const headings = [ + { depth: 2, slug: "constant", text: "constant" }, + { depth: 2, slug: "stepper", text: "stepper" }, + { depth: 2, slug: "time", text: "time" }, + { depth: 2, slug: "behavior-interface", text: "Behavior Interface" }, +]; +--- + + + + diff --git a/docs/src/pages/frp-api/combinators.astro b/docs/src/pages/frp-api/combinators.astro new file mode 100644 index 0000000..92f4ca5 --- /dev/null +++ b/docs/src/pages/frp-api/combinators.astro @@ -0,0 +1,14 @@ +--- +import DocsLayout from "@/layouts/docs-layout.astro"; +import Content from "@/content/frp-api/combinators.mdx"; + +const headings = [ + { depth: 2, slug: "combine", text: "combine" }, + { depth: 2, slug: "switcher", text: "switcher" }, + { depth: 2, slug: "lift", text: "lift" }, +]; +--- + + + + diff --git a/docs/src/pages/frp-api/conversions.astro b/docs/src/pages/frp-api/conversions.astro new file mode 100644 index 0000000..0581a60 --- /dev/null +++ b/docs/src/pages/frp-api/conversions.astro @@ -0,0 +1,16 @@ +--- +import DocsLayout from "@/layouts/docs-layout.astro"; +import Content from "@/content/frp-api/conversions.mdx"; + +const headings = [ + { depth: 2, slug: "changes", text: "changes" }, + { depth: 2, slug: "sample", text: "sample" }, + { depth: 2, slug: "sampleon", text: "sampleOn" }, + { depth: 2, slug: "animationframe", text: "animationFrame" }, + { depth: 2, slug: "elapsedtime", text: "elapsedTime" }, +]; +--- + + + + From 91c2c006fa649985a819ed78fc89040fe33ffdf2 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Fri, 9 Jan 2026 23:36:31 +0900 Subject: [PATCH 4/4] docs(docs): update signature pad example and signal documentation - Update signature-pad example to use tuple-based geometry types - Add updatedAt field documentation to Signal interface - Update single-pointer documentation for multi-pointer guidance --- .../src/components/examples/signature-pad.astro | 12 ++++++++---- docs/src/content/core-concepts/key-models.mdx | 9 +++++---- docs/src/content/stream-api/single-pointer.mdx | 17 ++++++++++------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/src/components/examples/signature-pad.astro b/docs/src/components/examples/signature-pad.astro index 6d677e3..1cd64d3 100644 --- a/docs/src/components/examples/signature-pad.astro +++ b/docs/src/components/examples/signature-pad.astro @@ -55,7 +55,7 @@ import { singlePointer, type ExtendSignalValue, type SinglePointerSignal } from import { offset, singlePointerSession } from "cereb/operators"; type Point = { x: number; y: number }; -type CanvasSignal = ExtendSignalValue; +type CanvasSignal = ExtendSignalValue; class CanvasManager { private ctx: CanvasRenderingContext2D; @@ -233,7 +233,9 @@ class PointerEventHandler { } private updatePointerInfo(signal: CanvasSignal) { - const { x, y, offsetX, offsetY, phase, pointerType } = signal.value; + const { cursor, offset, phase, pointerType } = signal.value; + const [x, y] = cursor; + const [offsetX, offsetY] = offset; this.infoElements.x.textContent = String(Math.round(x)); this.infoElements.y.textContent = String(Math.round(y)); this.infoElements.offsetX.textContent = String(Math.round(offsetX)); @@ -244,13 +246,15 @@ class PointerEventHandler { private handleStart(signal: CanvasSignal) { this.box.classList.add("active"); - this.drawingState.startDrawing({ x: signal.value.offsetX, y: signal.value.offsetY }); + const [offsetX, offsetY] = signal.value.offset; + this.drawingState.startDrawing({ x: offsetX, y: offsetY }); } private handleMove(signal: CanvasSignal) { if (!this.drawingState.isActive()) return; - this.drawingState.addPoint({ x: signal.value.offsetX, y: signal.value.offsetY }); + const [offsetX, offsetY] = signal.value.offset; + this.drawingState.addPoint({ x: offsetX, y: offsetY }); this.canvasManager.drawSmoothCurve( this.drawingState.getPoints(), this.drawingState.getHue() diff --git a/docs/src/content/core-concepts/key-models.mdx b/docs/src/content/core-concepts/key-models.mdx index ea33050..4db0bc4 100644 --- a/docs/src/content/core-concepts/key-models.mdx +++ b/docs/src/content/core-concepts/key-models.mdx @@ -8,10 +8,11 @@ A **Signal** is an immutable data object representing a discrete event. Every po ```typescript interface Signal { - readonly kind: K; // Type discriminator (e.g., "single-pointer", "pan") - readonly value: V; // Event payload - readonly deviceId: string; // Unique device identifier - readonly createdAt: number; // Timestamp (performance.now()) + readonly kind: K; // Type discriminator (e.g., "single-pointer", "pan") + readonly value: V; // Event payload + readonly deviceId: string; // Unique device identifier + readonly createdAt: number; // Timestamp (performance.now()) + readonly updatedAt?: number; // Updated timestamp (for modified signals) } ``` diff --git a/docs/src/content/stream-api/single-pointer.mdx b/docs/src/content/stream-api/single-pointer.mdx index 9702a15..e7a13ea 100644 --- a/docs/src/content/stream-api/single-pointer.mdx +++ b/docs/src/content/stream-api/single-pointer.mdx @@ -87,15 +87,18 @@ singlePointer(element) }); ``` -## Advanced: singlePointerRecognizer +## Multi-pointer Handling -Use as an operator with custom event sources: +For tracking multiple touch points simultaneously, use `multiPointer` instead: ```typescript -import { pointerEvents } from "cereb"; -import { singlePointerRecognizer } from "cereb/pointer"; +import { multiPointer } from "cereb"; -pointerEvents(element) - .pipe(singlePointerRecognizer()) - .on((signal) => { /* ... */ }); +multiPointer(element).on((signal) => { + // Tracks all active pointers + const pointers = signal.value.pointers; + console.log(`${pointers.length} active touch points`); +}); ``` + +See the [multiPointer](/stream-api/multi-pointer) documentation for details.