From 3096300a2f1f1fd26b4c2b690a66ffc80cb92627 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Wed, 21 Jan 2026 22:27:38 +0900 Subject: [PATCH 1/3] feat(core): add momentum, rotate3d, and translate operators --- README.md | 16 +- docs/public/llms-full.txt | 168 ++++++-- docs/public/llms.txt | 5 +- .../components/examples/space-adventure.astro | 382 +++++++++++++----- .../components/examples/space-adventure.css | 5 + docs/src/config/sidebar.json | 12 + docs/src/content/examples/space-adventure.mdx | 89 ++-- docs/src/content/operator-api/momentum.mdx | 109 +++++ docs/src/content/operator-api/rotate3d.mdx | 95 +++++ docs/src/content/operator-api/translate.mdx | 94 +++++ docs/src/content/operator-api/zoom.mdx | 91 +++-- docs/src/pages/operator-api/momentum.astro | 17 + docs/src/pages/operator-api/rotate3d.astro | 17 + docs/src/pages/operator-api/translate.astro | 15 + docs/src/pages/operator-api/zoom.astro | 4 +- packages/cereb/src/features/dom/mouse.ts | 2 +- packages/cereb/src/features/dom/pointer.ts | 8 +- .../single-pointer/recognizer-from-mouse.ts | 13 + .../single-pointer/recognizer-from-pointer.ts | 10 + packages/cereb/src/operators/index.ts | 6 + packages/cereb/src/operators/momentum.ts | 199 +++++++++ packages/cereb/src/operators/rotate3d.ts | 152 +++++++ packages/cereb/src/operators/translate.ts | 95 +++++ packages/cereb/src/operators/zoom.ts | 159 ++------ 24 files changed, 1442 insertions(+), 321 deletions(-) create mode 100644 docs/src/content/operator-api/momentum.mdx create mode 100644 docs/src/content/operator-api/rotate3d.mdx create mode 100644 docs/src/content/operator-api/translate.mdx create mode 100644 docs/src/pages/operator-api/momentum.astro create mode 100644 docs/src/pages/operator-api/rotate3d.astro create mode 100644 docs/src/pages/operator-api/translate.astro create mode 100644 packages/cereb/src/operators/momentum.ts create mode 100644 packages/cereb/src/operators/rotate3d.ts create mode 100644 packages/cereb/src/operators/translate.ts diff --git a/README.md b/README.md index df26843..024c392 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,19 @@ npm install --save cereb import { pinch } from "cereb"; import { zoom } from "cereb/operators"; +let scale = 1.0; +const MIN_SCALE = 0.5, MAX_SCALE = 3.0; + // pipe creates a pipeline where signals flow through operators // Each operator extends the signal (signals are immutable) pinch(element) - // Operator: Determine scale value. - .pipe(zoom({ minScale: 0.5, maxScale: 3.0 })).on((signal) => { - // The scale property is extended from the value. - // - pinch emits distance → zoom calculates scale - // - zoom also works with other inputs (keyboard, wheel, etc.) - element.style.transform = `scale(${signal.value.scale})`; + // Operator: Convert ratio to scale delta + .pipe(zoom()) + .on((signal) => { + // zoom outputs frame-by-frame delta, accumulate and clamp + scale += signal.value.scale; + scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale)); + element.style.transform = `scale(${scale})`; }); ``` diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 9587574..e0225ea 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -501,21 +501,109 @@ singlePointer(canvas) ### zoom -Calculate bounded scale from ratio input. +Convert ratio input to frame-by-frame scale delta. Consumer accumulates and clamps. ```typescript -function zoom>(options?: { - minScale?: number; - maxScale?: number; - baseScale?: number | (() => number); - mode?: "multiply" | "add"; -}): Operator +function zoom>( + options?: ZoomOptions +): Operator ``` +**Output:** +- `scale`: Frame-by-frame scale delta (not absolute scale) +- `deltaScale`: Same as scale (deprecated) + ```typescript +let scale = 1.0; +const MIN_SCALE = 0.5, MAX_SCALE = 3.0; + pinch(element) - .pipe(zoom({ minScale: 0.5, maxScale: 3.0 })) - .on((s) => element.style.transform = `scale(${s.value.scale})`); + .pipe(zoom()) + .on((s) => { + scale += s.value.scale; + scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale)); + element.style.transform = `scale(${scale})`; + }); +``` + +### rotate3d + +Convert 2D pan to 3D rotation delta. Horizontal pan → Y-axis rotation, vertical pan → X-axis rotation. + +```typescript +function rotate3d>(options?: { + sensitivityX?: number; // default 1.0 + sensitivityY?: number; // default 1.0 + invertX?: boolean; // default false + invertY?: boolean; // default false +}): Operator +``` + +**Output:** +- `rotation`: Frame-by-frame rotation delta in radians `[rx, ry, rz]` + +```typescript +let rotation = [0, 0, 0]; + +pan(element) + .pipe(rotate3d({ sensitivityX: 0.5, sensitivityY: 0.5 })) + .on((s) => { + const [drx, dry, drz] = s.value.rotation; + rotation[0] += drx; + rotation[1] += dry; + element.style.transform = `rotateX(${rotation[0]}rad) rotateY(${rotation[1]}rad)`; + }); +``` + +### translate + +Convert pan delta to 2D translation coordinates. + +```typescript +function translate>(options?: { + baseTranslate?: [number, number] | (() => [number, number]); // default [0, 0] + sensitivity?: number; // default 1.0 +}): Operator +``` + +```typescript +pan(element) + .pipe(translate()) + .on((s) => { + const [x, y] = s.value.translate; + element.style.transform = `translate(${x}px, ${y}px)`; + }); +``` + +### momentum + +Add physics-based inertia after gesture ends. Generates synthetic signals with decelerating velocity. + +```typescript +function momentum>(options?: { + friction?: number; // default 0.95, velocity retention per frame + minVelocity?: number; // default 0.1 + maxDuration?: number; // default 2000ms +}): Operator +``` + +**Output:** +- `momentumDelta`: Synthetic delta from momentum physics +- `isMomentum`: `true` for synthetic momentum signals, `false` for direct input + +```typescript +pan(element) + .pipe( + rotate3d({ sensitivityX: 0.5 }), + momentum({ friction: 0.95 }) + ) + .on((s) => { + if (s.value.isMomentum) { + rotationManager.addDelta(...s.value.momentumDelta); + } else { + rotationManager.addDelta(...s.value.rotation); + } + }); ``` ### when @@ -650,38 +738,62 @@ singlePointer(element) import { wheel, keyheld, keydown, pinch } from "cereb"; import { when, extend, spy, zoom } from "cereb/operators"; -let currentScale = 1; -const zoomOp = () => zoom({ minScale: 0.5, maxScale: 3.0, baseScale: () => currentScale }); +let scale = 1; +const MIN_SCALE = 0.5, MAX_SCALE = 3.0; +const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const zoomMode$ = keyheld(window, { code: "KeyZ" }) .pipe(extend((s) => ({ opened: s.value.held }))); -// Pinch zoom -pinch(element).pipe(zoomOp()).on(apply); +// Pinch zoom - uses delta-based zoom operator +pinch(element) + .pipe(zoom()) + .on((s) => { + scale = clamp(scale + s.value.scale, MIN_SCALE, MAX_SCALE); + element.style.transform = `scale(${scale})`; + }); -// Z + wheel zoom +// Z + wheel zoom - compute scale directly wheel(element, { passive: false }) .pipe( when(zoomMode$), - spy((s) => s.value.originalEvent.preventDefault()), - extend((s) => ({ ratio: Math.exp(-s.value.deltaY * 0.005) })), - zoomOp() + spy((s) => s.value.originalEvent.preventDefault()) ) - .on(apply); + .on((s) => { + const multiplier = Math.exp(-s.value.deltaY * 0.005); + scale = clamp(scale * multiplier, MIN_SCALE, MAX_SCALE); + element.style.transform = `scale(${scale})`; + }); -// Z + +/- keyboard zoom +// Z + +/- keyboard zoom - compute scale directly keydown(window, { code: ["Equal", "Minus"] }) + .pipe(when(zoomMode$)) + .on((s) => { + const multiplier = s.value.code === "Equal" ? 1.2 : 1 / 1.2; + scale = clamp(scale * multiplier, MIN_SCALE, MAX_SCALE); + element.style.transform = `scale(${scale})`; + }); +``` + +### 3D Rotation with Momentum + +```typescript +import { pan } from "cereb"; +import { rotate3d, momentum } from "cereb/operators"; + +let rotation = [0, 0, 0]; + +pan(element) .pipe( - when(zoomMode$), - extend((s) => ({ ratio: s.value.code === "Equal" ? 1.2 : 1 / 1.2 })), - zoomOp() + rotate3d({ sensitivityX: 0.5, sensitivityY: 0.5 }), + momentum({ friction: 0.95, minVelocity: 0.01 }) ) - .on(apply); - -function apply(signal) { - currentScale = signal.value.scale; - element.style.transform = `scale(${currentScale})`; -} + .on((s) => { + const delta = s.value.isMomentum ? s.value.momentumDelta : s.value.rotation; + rotation[0] += delta[0]; + rotation[1] += delta[1]; + element.style.transform = `rotateX(${rotation[0]}rad) rotateY(${rotation[1]}rad)`; + }); ``` ### Drawing Application diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 2193f9f..3f264d2 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -37,7 +37,10 @@ - [extend](https://cereb.dev/operator-api/extend): Add properties to signal value - [session](https://cereb.dev/operator-api/session): Group start-to-end as session - [offset](https://cereb.dev/operator-api/offset): Element-relative coordinates -- [zoom](https://cereb.dev/operator-api/zoom): Bounded scale calculation +- [zoom](https://cereb.dev/operator-api/zoom): Convert ratio to scale delta +- [rotate3d](https://cereb.dev/operator-api/rotate3d): Convert pan to 3D rotation delta +- [translate](https://cereb.dev/operator-api/translate): Convert pan to 2D translation +- [momentum](https://cereb.dev/operator-api/momentum): Add physics-based inertia after gesture - [when](https://cereb.dev/operator-api/when): Gate by another stream's state - [throttle](https://cereb.dev/operator-api/throttle): Rate limiting - [debounce](https://cereb.dev/operator-api/debounce): Wait for silence diff --git a/docs/src/components/examples/space-adventure.astro b/docs/src/components/examples/space-adventure.astro index 68d2b01..28cabf4 100644 --- a/docs/src/components/examples/space-adventure.astro +++ b/docs/src/components/examples/space-adventure.astro @@ -8,11 +8,13 @@ import "./space-adventure.css";
- +
+ +
Zoom Mode
Tip. - Pinch, double tap, or change slider to zoom.
+ Drag to rotate. Pinch or double tap to zoom.
In Desktop, use 'z' + '+/-' or 'wheel' to zoom.
@@ -32,6 +34,10 @@ import "./space-adventure.css"; SCALE 1.00
+
+ ROTATION + 0, 0 +
INPUT - @@ -41,8 +47,8 @@ import "./space-adventure.css";