npm package: @stroke-stabilizer/core
Description: Mutable Pipeline Pattern for real-time stroke stabilization
Author: usapopopooon
Latest version: 0.3.1

# @stroke-stabilizer/core

[![npm version](https://img.shields.io/npm/v/@stroke-stabilizer/core.svg)](https://www.npmjs.com/package/@stroke-stabilizer/core)
[![CI](https://github.com/usapopopooon/stroke-stabilizer/actions/workflows/ci.yml/badge.svg)](https://github.com/usapopopooon/stroke-stabilizer/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/badge/coverage-99.65%25-brightgreen)](https://github.com/usapopopooon/stroke-stabilizer)

[日本語](./docs/README.ja.md)

> This is part of the [stroke-stabilizer](https://github.com/usapopopooon/stroke-stabilizer) monorepo

A lightweight, framework-agnostic stroke stabilization library for digital drawing applications.

**[Live Demo](https://usapopopooon.github.io/stroke-stabilizer/)**

Reduce hand tremor and smooth pen/mouse input in real-time using a flexible filter pipeline.

## Features

- **[Mutable Pipeline Pattern](https://medium.com/@usapopopooon/the-mutable-pipeline-pattern-when-method-chains-need-to-change-at-runtime-8e835829de07)** - Add, remove, and update filters at runtime without rebuilding
- **Two-layer Processing** - Real-time filters + post-processing convolution
- **Automatic Endpoint Correction** - Strokes end at the actual input point
- **rAF Batch Processing** - Coalesce high-frequency pointer events into animation frames
- **8 Built-in Filters** - From simple moving average to adaptive One Euro Filter
- **Douglas-Peucker Simplification** - Reduce point count while preserving shape
- **SVG Path Output** - Convert strokes to SVG path data
- **Stroke Prediction** - Reduce perceived latency with velocity-based prediction
- **Catmull-Rom Interpolation** - Generate smooth curves between points
- **Edge-preserving Smoothing** - Bilateral kernel for sharp corner preservation
- **TypeScript First** - Full type safety with exported types
- **Zero Dependencies** - Pure JavaScript, works anywhere

## Installation

```bash
npm install @stroke-stabilizer/core
```

## Quick Start

```ts
import { StabilizedPointer, oneEuroFilter } from '@stroke-stabilizer/core'

const pointer = new StabilizedPointer().addFilter(
  oneEuroFilter({ minCutoff: 1.0, beta: 0.007 })
)

canvas.addEventListener('pointermove', (e) => {
  // IMPORTANT: Use getCoalescedEvents() for smoother input
  const events = e.getCoalescedEvents?.() ?? [e]

  for (const ce of events) {
    const result = pointer.process({
      x: ce.offsetX,
      y: ce.offsetY,
      pressure: ce.pressure,
      timestamp: ce.timeStamp,
    })
    if (result) draw(result.x, result.y)
  }
})

canvas.addEventListener('pointerup', () => {
  const finalPoints = pointer.finish()
  drawStroke(finalPoints)
})
```

> **Important:** Always use `getCoalescedEvents()` to capture all pointer events between frames. Without it, browsers throttle events and you'll get choppy strokes. See [Using getCoalescedEvents()](#using-getcoalescedevents) for details.

## Using getCoalescedEvents()

**This is essential for smooth strokes.** Browsers throttle `pointermove` events to ~60fps, but pen tablets can generate 200+ events per second. `getCoalescedEvents()` captures all the intermediate points that would otherwise be lost.

```ts
canvas.addEventListener('pointermove', (e) => {
  // Get all coalesced events (falls back to single event if unsupported)
  const events = e.getCoalescedEvents?.() ?? [e]

  for (const ce of events) {
    pointer.process({
      x: ce.offsetX,
      y: ce.offsetY,
      pressure: ce.pressure,
      timestamp: ce.timeStamp,
    })
  }
})
```

**React:** Access via `e.nativeEvent.getCoalescedEvents?.()`

```tsx
const handlePointerMove = (e: React.PointerEvent) => {
  const events = e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]
  for (const ce of events) {
    pointer.process({ x: ce.offsetX, y: ce.offsetY, ... })
  }
}
```

**Vue:** Access directly on the native event

```ts
function handlePointerMove(e: PointerEvent) {
  const events = e.getCoalescedEvents?.() ?? [e]
  for (const ce of events) {
    pointer.process({ x: ce.offsetX, y: ce.offsetY, ... })
  }
}
```

Without `getCoalescedEvents()`, fast strokes will appear jagged regardless of filter settings.

## Filters

> **📖 [Detailed Filter Reference](../../docs/filters.md)** - Mathematical formulas, technical explanations, and usage recommendations

### Real-time Filters

| Filter                   | Description                       | Use Case                           |
| ------------------------ | --------------------------------- | ---------------------------------- |
| `noiseFilter`            | Rejects points too close together | Remove jitter                      |
| `movingAverageFilter`    | Simple moving average (FIR)       | Basic smoothing                    |
| `emaFilter`              | Exponential moving average (IIR)  | Low-latency smoothing              |
| `kalmanFilter`           | Kalman filter                     | Noisy input smoothing              |
| `stringFilter`           | Lazy Brush algorithm              | Delayed, smooth strokes            |
| `oneEuroFilter`          | Adaptive lowpass filter           | Best balance of smoothness/latency |
| `linearPredictionFilter` | Predicts next position            | Lag compensation                   |
| `douglasPeuckerFilter`   | Simplifies point sequences        | Reduce data size                   |

### Post-processing Kernels

| Kernel            | Description               |
| ----------------- | ------------------------- |
| `gaussianKernel`  | Gaussian blur             |
| `boxKernel`       | Simple average            |
| `triangleKernel`  | Linear falloff            |
| `bilateralKernel` | Edge-preserving smoothing |

## Usage Examples

### Basic Real-time Stabilization

```ts
import { StabilizedPointer, oneEuroFilter } from '@stroke-stabilizer/core'

const pointer = new StabilizedPointer().addFilter(
  oneEuroFilter({ minCutoff: 1.0, beta: 0.007 })
)

// Process each point
const smoothed = pointer.process({ x, y, timestamp })
```

### Dynamic Filter Updates

```ts
// Add filter
pointer.addFilter(emaFilter({ alpha: 0.3 }))

// Update parameters at runtime
pointer.updateFilter('ema', { alpha: 0.5 })

// Remove filter
pointer.removeFilter('ema')
```

### Post-processing with Bidirectional Convolution

```ts
import { StabilizedPointer, gaussianKernel } from '@stroke-stabilizer/core'

const pointer = new StabilizedPointer()
  .addFilter(oneEuroFilter({ minCutoff: 1.0, beta: 0.007 }))
  .addPostProcess(gaussianKernel({ size: 7 }), { padding: 'reflect' })

// Process points in real-time
pointer.process(point)

// After stroke ends, apply post-processing
const finalPoints = pointer.finish()
```

### Re-applying Post-processing

Use `finishWithoutReset()` to preview or re-apply post-processing with different settings without losing the buffer.

```ts
import {
  StabilizedPointer,
  gaussianKernel,
  bilateralKernel,
} from '@stroke-stabilizer/core'

const pointer = new StabilizedPointer()

// Process points
pointer.process(point1)
pointer.process(point2)
pointer.process(point3)

// Preview with gaussian kernel
pointer.addPostProcess(gaussianKernel({ size: 5 }))
const preview1 = pointer.finishWithoutReset()
draw(preview1)

// Change to bilateral kernel and re-apply
pointer.removePostProcess('gaussian')
pointer.addPostProcess(bilateralKernel({ size: 7, sigmaValue: 10 }))
const preview2 = pointer.finishWithoutReset()
draw(preview2)

// Finalize when satisfied (resets buffer)
const final = pointer.finish()
```

**Difference between `finishWithoutReset()` and `finish()`:**

| Method                 | Post-process | Reset buffer |
| ---------------------- | ------------ | ------------ |
| `finishWithoutReset()` | ✅           | ❌           |
| `finish()`             | ✅           | ✅           |

### Edge-preserving Smoothing

```ts
import { smooth, bilateralKernel } from '@stroke-stabilizer/core'

// Smooth while preserving sharp corners
const smoothed = smooth(points, {
  kernel: bilateralKernel({ size: 7, sigmaValue: 10 }),
  padding: 'reflect',
})
```

### Automatic Endpoint Correction

By default, `finish()` automatically appends the raw endpoint to ensure the stroke ends at the actual input position. This can be disabled via options.

```ts
import { StabilizedPointer, oneEuroFilter } from '@stroke-stabilizer/core'

// Default: endpoint correction enabled (recommended)
const pointer = new StabilizedPointer()
pointer.addFilter(oneEuroFilter({ minCutoff: 1.0, beta: 0.007 }))

// Process points...
pointer.process(point1)
pointer.process(point2)

// finish() appends the last raw point automatically
const finalPoints = pointer.finish()

// Disable endpoint correction
const pointerNoEndpoint = new StabilizedPointer({ appendEndpoint: false })
```

### Endpoint Preservation in smooth()

By default, `smooth()` preserves exact start and end points so the stroke reaches the actual pointer position.

```ts
import { smooth, gaussianKernel } from '@stroke-stabilizer/core'

// Default: endpoints preserved (recommended)
const smoothed = smooth(points, {
  kernel: gaussianKernel({ size: 5 }),
})

// Disable endpoint preservation
const smoothedAll = smooth(points, {
  kernel: gaussianKernel({ size: 5 }),
  preserveEndpoints: false,
})
```

### rAF Batch Processing

For high-frequency input devices (pen tablets, etc.), batch processing reduces CPU load by coalescing pointer events into animation frames.

```ts
import { StabilizedPointer, oneEuroFilter } from '@stroke-stabilizer/core'

const pointer = new StabilizedPointer()
  .addFilter(oneEuroFilter({ minCutoff: 1.0, beta: 0.007 }))
  .enableBatching({
    onBatch: (points) => {
      // Called once per frame with all processed points
      drawPoints(points)
    },
    onPoint: (point) => {
      // Called for each processed point (optional)
      updatePreview(point)
    },
  })

canvas.addEventListener('pointermove', (e) => {
  // Points are queued and processed on next animation frame
  pointer.queue({
    x: e.clientX,
    y: e.clientY,
    pressure: e.pressure,
    timestamp: e.timeStamp,
  })
})

canvas.addEventListener('pointerup', () => {
  // Flush any pending points and apply post-processing
  const finalPoints = pointer.finish()
})
```

**Batch processing methods:**

```ts
// Enable/disable batching (method chaining)
pointer.enableBatching({ onBatch, onPoint })
pointer.disableBatching()

// Queue points for batch processing
pointer.queue(point)
pointer.queueAll(points)

// Force immediate processing
pointer.flushBatch()

// Check state
pointer.isBatchingEnabled // boolean
pointer.pendingCount // number of queued points
```

### Presets

```ts
import { createFromPreset } from '@stroke-stabilizer/core'

// Quick setup with predefined configurations
const pointer = createFromPreset('smooth') // Heavy smoothing
const pointer = createFromPreset('responsive') // Low latency
const pointer = createFromPreset('balanced') // Default balance
```

## Filter Parameters

### oneEuroFilter (Recommended)

```ts
oneEuroFilter({
  minCutoff: 1.0, // Smoothing at low speed (lower = smoother)
  beta: 0.007, // Speed adaptation (higher = more responsive)
  dCutoff: 1.0, // Derivative cutoff (usually 1.0)
})
```

### emaFilter

```ts
emaFilter({
  alpha: 0.5, // 0-1, higher = more responsive
})
```

### kalmanFilter

```ts
kalmanFilter({
  processNoise: 0.1, // Expected movement variance
  measurementNoise: 0.5, // Input noise level
})
```

### linearPredictionFilter

```ts
linearPredictionFilter({
  historySize: 4, // Points used for prediction
  predictionFactor: 0.5, // Prediction strength (0-1)
  smoothing: 0.6, // Output smoothing
})
```

### stringFilter (Lazy Brush)

```ts
stringFilter({
  stringLength: 10, // Distance before anchor moves
})
```

### bilateralKernel

```ts
bilateralKernel({
  size: 7, // Kernel size (odd number)
  sigmaValue: 10, // Edge preservation (lower = sharper edges)
  sigmaSpace: 2, // Spatial falloff (optional)
})
```

### Douglas-Peucker Simplification

Reduce the number of points while preserving the shape of the stroke.

```ts
import { douglasPeuckerFilter, simplify } from '@stroke-stabilizer/core'

// As a filter in the pipeline
const pointer = new StabilizedPointer().addFilter(
  douglasPeuckerFilter({ epsilon: 2 })
)

// As a standalone function
const simplified = simplify(points, 2) // epsilon = 2px tolerance
```

### SVG Path Output

Convert processed strokes to SVG path data for rendering or export.

```ts
import {
  toSVGPath,
  toSVGPathSmooth,
  toSVGPathCubic,
} from '@stroke-stabilizer/core'

const points = pointer.finish()

// Simple polyline (M/L commands)
const pathData = toSVGPath(points)
// "M 10.00 20.00 L 30.00 40.00 L 50.00 60.00"

// Quadratic Bezier curves (smoother)
const smoothPath = toSVGPathSmooth(points, { tension: 0.5 })

// Cubic Bezier curves (smoothest)
const cubicPath = toSVGPathCubic(points, { smoothing: 0.25 })

// Use in SVG
svgElement.innerHTML = `<path d="${pathData}" stroke="black" fill="none"/>`
```

### Stroke Prediction

Reduce perceived latency by predicting the next pen position based on velocity.

```ts
import { StrokePredictor } from '@stroke-stabilizer/core'

const predictor = new StrokePredictor({
  historySize: 4, // Points used for velocity estimation
  maxPredictionMs: 50, // Maximum prediction time
  minVelocity: 0.1, // Minimum velocity to trigger prediction
})

canvas.addEventListener('pointermove', (e) => {
  const stabilized = pointer.process({
    x: e.offsetX,
    y: e.offsetY,
    timestamp: e.timeStamp,
  })

  if (stabilized) {
    predictor.addPoint(stabilized)

    // Get predicted point 16ms ahead
    const predicted = predictor.predict(16)
    if (predicted) {
      drawPreview(predicted.x, predicted.y)
    }
  }
})
```

### Catmull-Rom Spline Interpolation

Generate smooth curves through a series of points, useful for upsampling or rendering.

```ts
import {
  interpolateCatmullRom,
  resampleByArcLength,
} from '@stroke-stabilizer/core'

const points = pointer.finish()

// Interpolate with Catmull-Rom spline
const smooth = interpolateCatmullRom(points, {
  tension: 0.5, // 0=loose, 1=tight
  segmentDivisions: 10, // Points per segment
})

// Resample at uniform arc length intervals
const uniform = resampleByArcLength(points, 5) // 5px between points
```

## API Reference

### StabilizedPointer

```ts
class StabilizedPointer {
  // Constructor
  constructor(options?: StabilizedPointerOptions)

  // Filter management
  addFilter(filter: Filter): this
  removeFilter(type: string): boolean
  updateFilter<T>(type: string, params: Partial<T>): boolean
  getFilter(type: string): Filter | undefined

  // Post-processing
  addPostProcess(kernel: Kernel, options?: { padding?: PaddingMode }): this
  removePostProcess(type: string): boolean

  // Processing
  process(point: PointerPoint): PointerPoint | null
  finish(): Point[] // Apply post-process and reset
  finishWithoutReset(): Point[] // Apply post-process without reset (for preview)
  reset(): void // Reset filters and clear buffer

  // Batch processing (rAF)
  enableBatching(config?: BatchConfig): this
  disableBatching(): this
  queue(point: PointerPoint): this
  queueAll(points: PointerPoint[]): this
  flushBatch(): PointerPoint[]
  isBatchingEnabled: boolean
  pendingCount: number
}
```

### Types

```ts
interface Point {
  x: number
  y: number
}

interface PointerPoint extends Point {
  pressure?: number // Pen pressure (0-1)
  tiltX?: number // Pen tilt on X axis (-90 to 90 degrees)
  tiltY?: number // Pen tilt on Y axis (-90 to 90 degrees)
  timestamp: number // Event timestamp in ms
}

type PaddingMode = 'reflect' | 'edge' | 'zero'

interface BatchConfig {
  onBatch?: (points: PointerPoint[]) => void
  onPoint?: (point: PointerPoint) => void
}

interface StabilizedPointerOptions {
  appendEndpoint?: boolean // Append raw endpoint on finish() (default: true)
}
```

## Architecture

```
Input → [Real-time Filters] → process() → Output
                                ↓
                            [Buffer]
                                ↓
                      [Post-processors] → finish() → Final Output
```

**Real-time filters** run on each input point with O(1) complexity.
**Post-processors** run once at stroke end with bidirectional convolution.

## Framework Adapters

- `@stroke-stabilizer/react` - React hooks
- `@stroke-stabilizer/vue` - Vue composables

## License

[MIT](../../LICENSE)