Skip to content
D
Documentation

StrokeModel

reference
3 min readUpdated

Import it from @grafloria/engine.

A freehand ink stroke: an ordered point list, a style, and an identity.

A first-class DiagramEntity, which is not a formality — it is what makes every other gate in this wave close for free: • trackChange() → markDirty() → the GLOBAL MUTATION EPOCH, which is the thing both frame gates read (svg-renderer.ts:795, create-diagram.ts:369). A stroke that were a plain object would mutate the picture without moving the epoch, and both gates would skip the frame: you would draw and see nothing. • on('change') → OpCapture picks it up through the same funnel as everything else. No parallel bookkeeping to drift out of step. • serialize()/dispose()/version → the document contracts.

ts
class StrokeModel extends DiagramEntity

Methods

  • constructor( points: readonly StrokePoint[] = [], style: StrokeStyle = DEFAULT_STROKE_STYLE, options: { id?: string; uuid?: string; label?: string } = {} )
  • getPoints(): readonly StrokePoint[]
  • setPoints(points: readonly StrokePoint[]): void — Replace the geometry.

Emits trackChange('points', …) like any other register. NOTE that capture used to drop EVERY points change on the floor (DERIVED = new Set(['points'])), because on a LINK points is routed geometry that each peer recomputes for itself. On a stroke points is the authored content itself. That set is now scoped per target — see collab/capture.ts. Without that fix, editing a stroke's geometry would silently fail to reach any other peer.

  • get pointCount(): number
  • getBounds(): Rectangle — Axis-aligned bounds, INFLATED by the stroke's own half-width (ink has girth).
  • hitTest(x: number, y: number, tolerance = 0): boolean — Is (x, y) on the ink, within tolerance world units of it?
  • intersectsSegment(a: Point, b: Point, tolerance = 0): boolean — Did a pointer travelling a→b sweep across this ink? THE ERASER'S QUESTION. Segment-vs-segment, not point-vs-segment — see {@link segmentDistance}.
  • getStyle(): Readonly<StrokeStyle>
  • setStyle(style: Partial<StrokeStyle>): void
  • replaceStyle(style: Partial<StrokeStyle>): void — REPLACE the whole style object — the write setStyle cannot express, and the collab reducer's write path.

setStyle MERGES, so applying a style op with it meant a peer could gain a key and never lose one: the author clears an opacity and every other peer keeps the faded stroke forever. NodeModel.replaceStyle is the same seam for the same reason, and a plain field assignment is not a substitute — it would skip the bounds invalidation below and leave the peer measuring the stroke at its old width.

  • override getLabel(): string | undefined — The accessible name, when the author gave one. See the a11y note on the ink layer. Overrides the DiagramEntity canon (metadata.label): a stroke's label lives in its own serialized label property, not the metadata bag.
  • override setLabel(label: string | undefined): void — CANONICAL label write. Writes metadata.label (tracked → dirty → repaint) and mirrors into the legacy data['label'] slot when the entity has a data bag, so any reader still on the old field sees the same value. The mirror is a shadow: metadata is authoritative on every read path.
  • serialize(): SerializedStroke — Serialize entity to JSON
  • static fromJSON(data: SerializedStroke): StrokeModel (static)
  • static fromRawPoints( raw: readonly StrokePoint[], style: StrokeStyle = DEFAULT_STROKE_STYLE, options: { id?: string; label?: string; epsilon?: number } = {} ): StrokeModel (static) — Build a stroke from a RAW POINTER TRACE. This is the only constructor the draw tool uses, and the simplification is not optional decoration.

A two-second scribble at 120Hz is ~240 samples, most of them a fraction of a pixel apart. Persisting them all means a document that is mostly float noise, an op payload that is kilobytes per line, and an SVG path the browser re-parses on every frame. The brief's phrasing is exactly right: a 500-point stroke that serialises as 500 points is a bug.

Typical reduction on real ink is 85-95%.

IT PRESERVES PRESSURE, and that is a property of the algorithm rather than luck: Douglas-Peucker SELECTS a subset of the input points (it returns the very objects it was given — PathSimplifier.ts:123), it never interpolates new ones. So the pressure riding on each retained sample rides through untouched. If it ever grows an interpolating mode this breaks silently, so the spec pins it.

Was this page helpful?