npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stroke-stabilizer/core

v0.3.1

Published

Mutable Pipeline Pattern for real-time stroke stabilization

Downloads

1,511

Readme

@stroke-stabilizer/core

npm version CI Coverage

日本語

This is part of the stroke-stabilizer monorepo

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

Live Demo

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

Features

  • Mutable Pipeline Pattern - 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

npm install @stroke-stabilizer/core

Quick Start

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() 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.

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?.()

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

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 - 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

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

// 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

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.

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

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.

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.

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.

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:

// 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

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)

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

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

kalmanFilter

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

linearPredictionFilter

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

stringFilter (Lazy Brush)

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

bilateralKernel

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.

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.

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.

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.

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

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

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