@stroke-stabilizer/core
v0.3.1
Published
Mutable Pipeline Pattern for real-time stroke stabilization
Downloads
1,511
Maintainers
Readme
@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.
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/coreQuick 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 pointsPresets
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 balanceFilter 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 toleranceSVG 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 pointsAPI 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 OutputReal-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
