anvas.addEventListener('pointerup', this.handleUp);
canvas.addEventListener('pointercancel', this.handleUp);
}
private handleDown = (e: PointerEvent): void => {
e.preventDefault();
this.activeId = e.pointerId;
this.config.canvas.setPointerCapture(e.pointerId);
this.config.onStrokeStart(e.pointerId);
};
private handleMove = (e: PointerEvent): void => {
if (e.pointerId !== this.activeId) return;
const samples = this.recoverSamples(e);
this.config.onStrokeMove(e.pointerId, samples);
};
private handleUp = (e: PointerEvent): void => {
if (e.pointerId !== this.activeId) return;
this.config.canvas.releasePointerCapture(e.pointerId);
this.activeId = null;
this.config.onStrokeEnd(e.pointerId);
};
private recoverSamples(e: PointerEvent): PointerEvent[] {
if (typeof e.getCoalescedEvents !== 'function') return [e];
const coalesced = e.getCoalescedEvents();
return coalesced.length > 0 ? coalesced : [e];
}
}
**Rationale:** `setPointerCapture` guarantees `pointerup` fires even if the cursor leaves the viewport. The `recoverSamples` method bridges the 60 Hz display refresh with 240 Hz hardware sampling. The `length > 0` guard prevents synthetic events from dropping intermediate data.
### 2. Pressure Normalization & Fallback Strategy
Raw pressure values require contextual mapping. The normalization function applies a fallback only when hardware reports zero, clamps outliers, and scales to a perceptual band.
```typescript
interface PressureOptions {
minFactor: number;
maxFactor: number;
fallbackValue: number;
}
class PressureMapper {
private options: PressureOptions;
constructor(options: Partial<PressureOptions> = {}) {
this.options = {
minFactor: 0.4,
maxFactor: 1.6,
fallbackValue: 0.5,
...options,
};
}
normalize(rawPressure: number, baseWidth: number): number {
const { minFactor, maxFactor, fallbackValue } = this.options;
const p = rawPressure === 0 ? fallbackValue : Math.min(1, Math.max(0, rawPressure));
const factor = minFactor + (maxFactor - minFactor) * p;
return baseWidth * factor;
}
}
Rationale: Mouse inputs report 0.5 when held and 0 otherwise. Android touch typically reports 0. The fallback activates exclusively at 0 to preserve low-pressure stylus data (0.001 remains valid). The 0.4β1.6 band ensures mouse defaults to the midpoint, while stylus pressure modulates naturally around it.
3. Stroke Simplification (Ramer-Douglas-Peucker)
Coalesced events generate dense point arrays. Applying RDP after stroke completion reduces vertex count while preserving geometric intent.
interface Point2D {
x: number;
y: number;
pressure: number;
}
class PathReducer {
static simplify(points: Point2D[], tolerance: number): Point2D[] {
if (points.length < 3) return [...points];
const sqTolerance = tolerance * tolerance;
const keep = new Array(points.length).fill(false);
keep[0] = true;
keep[points.length - 1] = true;
const perpendicularSq = (p: Point2D, a: Point2D, b: Point2D): number => {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return (p.x - a.x) ** 2 + (p.y - a.y) ** 2;
const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
const projX = a.x + t * dx;
const projY = a.y + t * dy;
return (p.x - projX) ** 2 + (p.y - projY) ** 2;
};
const recurse = (start: number, end: number): void => {
let maxDist = 0;
let maxIdx = -1;
for (let i = start + 1; i < end; i++) {
const d = perpendicularSq(points[i], points[start], points[end]);
if (d > maxDist) {
maxDist = d;
maxIdx = i;
}
}
if (maxDist > sqTolerance && maxIdx !== -1) {
keep[maxIdx] = true;
recurse(start, maxIdx);
recurse(maxIdx, end);
}
};
recurse(0, points.length - 1);
return points.filter((_, i) => keep[i]);
}
}
Rationale: A 0.4 px tolerance operates below human visual acuity on standard displays. The algorithm recursively preserves vertices that deviate significantly from chord lines, typically reducing point counts by 3β5Γ without perceptible shape loss. Pressure metadata remains attached to surviving vertices.
4. Variable-Width Canvas Rendering
Canvas 2D lacks native variable-width strokes. The reliable approach segments the path and averages endpoint widths, relying on round caps to eliminate seams.
class CanvasRenderer {
private ctx: CanvasRenderingContext2D;
constructor(canvas: HTMLCanvasElement) {
this.ctx = canvas.getContext('2d')!;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
}
drawStroke(points: Point2D[], color: string, baseWidth: number, mapper: PressureMapper): void {
this.ctx.strokeStyle = color;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const wPrev = mapper.normalize(prev.pressure, baseWidth);
const wCurr = mapper.normalize(curr.pressure, baseWidth);
this.ctx.lineWidth = (wPrev + wCurr) / 2;
this.ctx.beginPath();
this.ctx.moveTo(prev.x, prev.y);
this.ctx.lineTo(curr.x, curr.y);
this.ctx.stroke();
}
}
}
Rationale: Attempting quadraticCurveTo with per-segment width changes introduces path discontinuities and gap artifacts in closed shapes. Segment-by-segment drawing with round caps ensures overlapping radii mask joins seamlessly. This is a deliberate trade-off: slightly higher draw calls for guaranteed visual continuity.
5. SVG Export Strategy
SVG <path> elements support only uniform stroke-width. Variable-width modulation must be approximated or baked into geometry.
class SvgSerializer {
static escapeAttr(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
static generate(strokes: Array<{ points: Point2D[]; color: string; baseWidth: number }>, width: number, height: number): string {
const mapper = new PressureMapper();
const paths = strokes.map(s => {
const d = s.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' ');
const avgWidth = s.points.reduce((sum, p) => sum + mapper.normalize(p.pressure, s.baseWidth), 0) / s.points.length;
return `<path d="${d}" fill="none" stroke="${this.escapeAttr(s.color)}" stroke-width="${avgWidth.toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"/>`;
}).join('');
return `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${paths}</svg>`;
}
}
Rationale: Baking variable widths into polygon outlines increases file size by 5β10Γ and complicates editability. Averaging per-stroke width preserves reasonable file sizes while maintaining visual coherence. The escapeAttr function prevents injection vectors if colors or metadata originate from untrusted sources.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Naive pressure mapping | Using e.pressure directly as a multiplier causes mouse strokes to render at 50% width and Android touch strokes to disappear. | Apply a fallback exclusively when pressure === 0. Clamp values to [0, 1] and map to a perceptual band (e.g., 0.4β1.6). |
| Ignoring coalesced event emptiness | getCoalescedEvents() returns an empty array for synthetic or programmatic events in Chromium. Blind iteration drops all intermediate points. | Always verify coalesced.length > 0 before replacing the primary event. Fall back to [e] when empty. |
| Over-aggressive RDP tolerance | Setting tolerance above 1.0 px removes legitimate curvature, causing strokes to appear polygonal or lose pressure modulation peaks. | Keep tolerance at 0.3β0.5 px. Test with fast, curved strokes to verify shape preservation. |
| BΓ©zier smoothing with variable widths | Using quadraticCurveTo or bezierCurveTo while changing lineWidth per segment creates path gaps and rendering artifacts in closed loops. | Stick to straight segments with lineCap: 'round'. Overlapping caps mask joins reliably without path math complexity. |
| Missing pointer capture | Without setPointerCapture, dragging outside the canvas prevents pointerup from firing, leaving strokes in a perpetual active state. | Call setPointerCapture(e.pointerId) on pointerdown and releasePointerCapture on pointerup/pointercancel. |
| Assuming uniform pressure bands | Different stylus manufacturers calibrate pressure curves differently. A fixed band may feel too sensitive or too flat on specific hardware. | Expose minFactor, maxFactor, and fallbackValue as configurable parameters. Allow user calibration in settings. |
| SVG attribute injection | Directly interpolating color strings or metadata into SVG markup enables XSS if inputs originate from URLs, files, or shared state. | Implement strict HTML entity escaping for all dynamic attributes before serialization. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Professional illustration app | Coalesced events + RDP + per-segment rendering | Preserves 240 Hz hardware fidelity and pressure modulation | Higher CPU during capture, lower memory post-simplify |
| Quick annotation tool | Basic PointerEvent + fixed width | Simpler implementation, acceptable for casual use | Minimal compute, larger raw point arrays |
| SVG-heavy export pipeline | Average-width SVG + PNG fallback | Balances file size with visual accuracy | SVG loses intra-stroke modulation; PNG retains full fidelity |
| Cross-device web app | Normalized pressure band + configurable fallback | Handles mouse, touch, and pen baselines uniformly | Slight initial setup overhead, eliminates device-specific bugs |
Configuration Template
export interface DrawingEngineConfig {
canvas: HTMLCanvasElement;
baseWidth: number;
pressureBand: {
min: number;
max: number;
fallback: number;
};
rdpTolerance: number;
exportFormats: ('png' | 'svg')[];
onStrokeComplete?: (strokeId: string, points: Point2D[]) => void;
}
export const DEFAULT_CONFIG: DrawingEngineConfig = {
canvas: document.getElementById('drawing-surface') as HTMLCanvasElement,
baseWidth: 4,
pressureBand: { min: 0.4, max: 1.6, fallback: 0.5 },
rdpTolerance: 0.4,
exportFormats: ['png', 'svg'],
onStrokeComplete: undefined,
};
Quick Start Guide
- Initialize the input bridge: Instantiate
InputBridge with your canvas element and callback handlers for stroke lifecycle events.
- Configure pressure mapping: Create a
PressureMapper instance using your target band and fallback values. Pass it to your renderer.
- Wire up the render loop: On
pointermove, collect coalesced samples, store them in a stroke buffer, and trigger a canvas redraw.
- Apply simplification on release: On
pointerup, run PathReducer.simplify() on the collected points, then finalize the stroke for export or persistence.
- Export safely: Use
SvgSerializer.generate() for vector output or canvas.toDataURL() for raster. Verify attribute escaping if colors originate from external sources.