Mutable Pipeline Pattern for real-time stroke stabilization
npm install @stroke-stabilizer/core


> This is part of the stroke-stabilizer monorepo
A lightweight, framework-agnostic stroke stabilization library for digital drawing applications.
Reduce hand tremor and smooth pen/mouse input in real-time using a flexible filter pipeline.
- 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
``bash`
npm install @stroke-stabilizer/core
`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() for details.
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.
> 📖 Detailed Filter Reference - Mathematical formulas, technical explanations, and usage recommendations
| 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 |
| Kernel | Description |
| ----------------- | ------------------------- |
| gaussianKernel | Gaussian blur |boxKernel
| | Simple average |triangleKernel
| | Linear falloff |bilateralKernel
| | Edge-preserving smoothing |
`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 })
`
`ts
// Add filter
pointer.addFilter(emaFilter({ alpha: 0.3 }))
// Update parameters at runtime
pointer.updateFilter('ema', { alpha: 0.5 })
// Remove filter
pointer.removeFilter('ema')
`
`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()
`
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()
| | ✅ | ✅ |
`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',
})
`
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 })
`
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,
})
`
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
`
`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
`
`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)
})
`ts`
emaFilter({
alpha: 0.5, // 0-1, higher = more responsive
})
`ts`
kalmanFilter({
processNoise: 0.1, // Expected movement variance
measurementNoise: 0.5, // Input noise level
})
`ts`
linearPredictionFilter({
historySize: 4, // Points used for prediction
predictionFactor: 0.5, // Prediction strength (0-1)
smoothing: 0.6, // Output smoothing
})
`ts`
stringFilter({
stringLength: 10, // Distance before anchor moves
})
`ts`
bilateralKernel({
size: 7, // Kernel size (odd number)
sigmaValue: 10, // Edge preservation (lower = sharper edges)
sigmaSpace: 2, // Spatial falloff (optional)
})
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
`
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 = `
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)
}
}
})
`
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
`
`ts
class StabilizedPointer {
// Constructor
constructor(options?: StabilizedPointerOptions)
// Filter management
addFilter(filter: Filter): this
removeFilter(type: string): boolean
updateFilter
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
}
`
`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)
}
`
``
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.
- @stroke-stabilizer/react - React hooks@stroke-stabilizer/vue` - Vue composables
-