Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`;
Expand Down
12 changes: 8 additions & 4 deletions docs/src/components/examples/signature-pad.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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<SinglePointerSignal, { offsetX: number, offsetY: number }>;
type CanvasSignal = ExtendSignalValue<SinglePointerSignal, { offset: [number, number] }>;

class CanvasManager {
private ctx: CanvasRenderingContext2D;
Expand Down Expand Up @@ -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));
Expand All @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions docs/src/config/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
]
}
]
}
146 changes: 146 additions & 0 deletions docs/src/content/core-concepts/behavior-and-event.mdx
Original file line number Diff line number Diff line change
@@ -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<A> {
sample(): A; // Get current value
map<B>(f: (a: A) => B): Behavior<B>; // 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.
11 changes: 6 additions & 5 deletions docs/src/content/core-concepts/key-models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ A **Signal** is an immutable data object representing a discrete event. Every po

```typescript
interface Signal<K extends string, V> {
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)
}
```

Expand Down Expand Up @@ -61,7 +62,7 @@ singlePointer(element)
offset({ target: canvas }), // Add element-relative coords
)
.on((signal) => {
// Transformed signal with offsetX, offsetY properties
// Transformed signal with offset property
});
```

Expand Down
11 changes: 6 additions & 5 deletions docs/src/content/examples/signature-pad.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Loading