Annotations
@veyrajs/annotations is the labeling toolkit built on top of the engine — every type is an
ordinary Shape subclass, so it lives entirely outside @veyrajs/core and proves the plugin
boundary the engine was designed around. You get six vector types — BoundingBox (axis-aligned and
rotated), PolygonAnnotation, PolylineAnnotation, PointAnnotation, Skeleton, and Cuboid —
plus the tools and overlays to draw and reshape them.
npm install @veyrajs/annotations @veyrajs/coreThere are two ways to put annotations on screen, and the split is worth holding in mind:
- Declarative — render annotations from your data with the framework components
(
<ACBoundingBox>,<ACPolygonAnnotation>, …) in React, Vue, Svelte, and Angular. - Imperative — let a user author and edit them with the draw tools and the
VertexEditor.
The demos below are the imperative path. Each one is a single file; open the Code tab to read exactly what runs.
Bounding boxes with label classes
Section titled “Bounding boxes with label classes”Drag in Draw mode to add a box; switch to Select to move, resize, and rotate it (undo is
wired in). A LabelSchema holds your classes — pick one to restyle the next box from its color. This
is the visual-customization surface: stroke, translucent fill, and a colored label chip all flow
from the class.
import { type AnnotationConfig, BoundingBox, DrawBoxTool, type DrawBoxToolOptions, LabelSchema,} from '@veyrajs/annotations'import { History, SelectionController } from '@veyrajs/core'import { button, createStage, disposeStage, toolbar } from './_kit'
// A LabelSchema is your set of annotation classes — each an id, a display name, and a color.// New boxes inherit the active class's color; this is the visual-customization entry point.const schema = new LabelSchema([ { id: 'vehicle', name: 'Vehicle', color: '#2563eb' }, { id: 'person', name: 'Person', color: '#16a34a' }, { id: 'sign', name: 'Sign', color: '#f59e0b' },])
// Resolve a class id into a BoundingBox config: stroke + translucent fill (the class color with an// `22` alpha suffix) + a colored label chip.function defaultsFor(id: string): AnnotationConfig { const color = schema.get(id)?.color ?? '#2563eb' return { stroke: color, fill: `${color}22`, label: schema.get(id)?.name ?? '', labelColor: color }}
export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer() const history = new History() let controller: SelectionController | null = null
// The tool reads `options.defaults` fresh on every pointerdown, so swapping classes is just a // reassignment of the live options object — the next box drawn picks up the new style. const options: DrawBoxToolOptions = { defaults: defaultsFor('vehicle'), onCreate: () => setMode('select'), } const tool = new DrawBoxTool(stage, layer, options)
// Draw and Select share the canvas and the same pointer events, so only one is live at a time: // Select mode spins up the core SelectionController (move/resize/rotate, undoable via History); // Draw mode tears it down so the box tool owns the pointer. function setMode(mode: 'draw' | 'select'): void { if (mode === 'draw') { controller?.destroy() controller = null tool.enable() } else { tool.disable() controller ??= new SelectionController(stage, { history }) } drawBtn.classList.toggle('is-active', mode === 'draw') selectBtn.classList.toggle('is-active', mode === 'select') }
// Seed two boxes so Select mode has something to grab right away. layer.add( new BoundingBox({ x: 70, y: 60, width: 150, height: 96, ...defaultsFor('vehicle') }), new BoundingBox({ x: 300, y: 120, width: 96, height: 150, ...defaultsFor('person') }), )
const bar = toolbar(host) const drawBtn = button('Draw', () => setMode('draw')) const selectBtn = button('Select', () => setMode('select')) const sep = document.createElement('span') sep.className = 'veyrajs-demo__sep' bar.append(drawBtn, selectBtn, sep)
// Class picker — each class in its own color; the active one gets the accent ring. const classButtons = schema.classes.map((cls) => { const el = button(cls.name, () => { options.defaults = defaultsFor(cls.id) for (const other of classButtons) other.classList.toggle('is-active', other === el) }) el.style.color = cls.color bar.append(el) return el }) classButtons[0]?.classList.add('is-active')
const hint = document.createElement('span') hint.className = 'veyrajs-demo__hint' hint.textContent = 'Draw to add a box · Select to move/resize · pick a class to restyle' bar.append(hint)
setMode('draw')
return () => { tool.disable() controller?.destroy() disposeStage(stage) }}Polygons and vertex editing
Section titled “Polygons and vertex editing”Click to drop vertices and click the first point to close the polygon. Once it’s closed, the
VertexEditor puts a draggable handle on every vertex — the counterpart to the box transformer, but
for arbitrary point geometry. The same editor reshapes polygons, polylines, skeletons, and cuboids,
because every vertex-based type exposes the same points accessor.
import { DrawPolygonTool, PolygonAnnotation, VertexEditor } from '@veyrajs/annotations'import { button, createStage, disposeStage, toolbar } from './_kit'
// Two overlays cooperate here: DrawPolygonTool (drops a vertex per click, listens on `click`) and// VertexEditor (a draggable handle per vertex, listens on capture-phase pointer events). Because// they use different event channels and the editor early-returns without a target, both can stay// mounted — drawing and reshaping never fight over the pointer.export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer()
// VertexEditor handle colors are configurable — match them to the polygon's stroke here. const editor = new VertexEditor(stage, { handleColor: '#16a34a' })
const tool = new DrawPolygonTool(stage, layer, { defaults: { stroke: '#16a34a', fill: 'rgba(22, 163, 74, 0.14)', label: 'Region' }, onCreate: (node) => { // Hand the finished polygon straight to the editor so its vertices are immediately draggable. tool.disable() editor.setTarget(node as PolygonAnnotation) setPhase('edit') }, })
function setPhase(phase: 'draw' | 'edit'): void { if (phase === 'draw') { editor.setTarget(null) tool.enable() } drawBtn.classList.toggle('is-active', phase === 'draw') hint.textContent = phase === 'draw' ? 'Click to drop points · click the first point to close' : 'Drag the square handles to reshape' }
// Seed a polygon already in edit mode so the handles are visible on load. const seed = new PolygonAnnotation({ points: [ { x: 96, y: 70 }, { x: 250, y: 96 }, { x: 224, y: 214 }, { x: 110, y: 200 }, ], stroke: '#16a34a', fill: 'rgba(22, 163, 74, 0.14)', label: 'Region', }) layer.add(seed) editor.setTarget(seed)
const bar = toolbar(host) const drawBtn = button('New polygon', () => setPhase('draw')) const finishBtn = button('Finish', () => tool.finish()) bar.append(drawBtn, finishBtn) const hint = document.createElement('span') hint.className = 'veyrajs-demo__hint' bar.append(hint)
setPhase('edit')
return () => { editor.destroy() tool.disable() disposeStage(stage) }}Keypoints and skeletons
Section titled “Keypoints and skeletons”A Skeleton is keypoints joined by bones, described by a SkeletonSchema. FACE_5 and COCO_17
ship as presets; pass your own { keypoints, edges } for any topology. The draw tool walks the
schema’s keypoints in order — the prompt tells you which to place next — then hands off to the
VertexEditor so you can fine-tune each joint.
import { DrawSkeletonTool, FACE_5, type Skeleton, VertexEditor } from '@veyrajs/annotations'import { button, createStage, disposeStage, readout, toolbar } from './_kit'
// A Skeleton is keypoints (vertices) joined by bones (edges from a SkeletonSchema). FACE_5 is a// shipped preset; pass any `{ keypoints, edges }` to define your own. DrawSkeletonTool walks the// schema's keypoints in order — click once per named point — then the VertexEditor takes over so// you can nudge them.export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer() const editor = new VertexEditor(stage, { handleColor: '#a855f7' })
let mode: 'place' | 'edit' = 'place' const tool = new DrawSkeletonTool(stage, layer, FACE_5, { defaults: { stroke: '#a855f7', label: 'Face' }, onCreate: (node) => { mode = 'edit' tool.disable() editor.setTarget(node as Skeleton) updatePrompt() }, })
const bar = toolbar(host) const newBtn = button('New face', () => { mode = 'place' editor.setTarget(null) tool.enable() updatePrompt() }) bar.append(newBtn) const prompt = readout(bar, '')
function updatePrompt(): void { if (mode === 'edit') { prompt.textContent = 'Drag the keypoints to adjust' } else { prompt.textContent = tool.nextKeypoint ? `Place: ${tool.nextKeypoint} · ${tool.remaining} left` : '' } }
// Enable the tool first so its placement handler runs before ours; then refresh the prompt after // each click to show the next keypoint to place. tool.enable() stage.on('click', updatePrompt) updatePrompt()
return () => { stage.off('click', updatePrompt) editor.destroy() tool.disable() disposeStage(stage) }}Custom annotation types
Section titled “Custom annotation types”The package is extensible by the same route it was built. Subclass AnnotationNode, describe your
geometry as draw ops, implement bounds and hit-testing, and register a factory — and your type draws,
hit-tests, and serializes like any built-in. The demo defines a StarAnnotation inline (read the
Code tab), then Save + Load round-trips the whole scene through JSON to prove the custom type
survives serialization.
import { type AnnotationConfig, AnnotationNode, registerAnnotations } from '@veyrajs/annotations'import { Bounds, type DrawOp, type Layer, SceneSerializer, type ShapeHitKind, type ShapeHitOptions, type Vec2, distanceToPolyline, pointInPolygon,} from '@veyrajs/core'import { button, createStage, disposeStage, readout, toolbar } from './_kit'
interface StarConfig extends AnnotationConfig { radius?: number spikes?: number}
// A custom annotation type — proof that new shapes live entirely outside @veyrajs/core. The whole// extension contract: subclass AnnotationNode, describe geometry as DrawOps, implement bounds +// hit-test, and (for persistence) serialize your extra fields. A factory registered below lets it// round-trip through JSON like any built-in.class StarAnnotation extends AnnotationNode { readonly type = 'StarAnnotation' private _radius: number private _spikes: number
constructor(config: StarConfig = {}) { super({ stroke: '#e11d48', fill: 'rgba(225, 29, 72, 0.16)', strokeWidth: 2, ...config }) this._radius = config.radius ?? 36 this._spikes = config.spikes ?? 5 }
private vertices(): Vec2[] { const points: Vec2[] = [] const count = this._spikes * 2 for (let i = 0; i < count; i++) { const r = i % 2 === 0 ? this._radius : this._radius * 0.45 const angle = (Math.PI * i) / this._spikes - Math.PI / 2 points.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r }) } return points }
override getLocalBounds(): Bounds { return Bounds.fromPoints(this.vertices()) }
override getVertices(): readonly Vec2[] { return this.vertices() }
drawOps(): DrawOp[] { return [ { type: 'polygon', points: this.vertices(), closed: true, ...this.fillStrokeStyle }, ...this.labelOps({ x: 0, y: -this._radius }), ] }
hitTest(p: Vec2, options?: ShapeHitOptions): ShapeHitKind | null { const verts = this.vertices() if ((options?.fill ?? true) && pointInPolygon(p, verts)) return 'fill' if (options?.stroke ?? true) { const band = (options?.tolerance ?? 0) + (this.stroke !== null ? this.strokeWidth / 2 : 0) if (distanceToPolyline(p, verts, true) <= band) return 'stroke' } return null }
protected override serializedExtras(): Record<string, unknown> { return { ...super.serializedExtras(), radius: this._radius, spikes: this._spikes } }}
export function init(host: HTMLElement): () => void { const stage = createStage(host) let layer = stage.createLayer()
// Register the built-in annotations, then add the custom type to the SAME registry so the entire // scene — stars included — survives `stringify` → `parse`. const registry = registerAnnotations() registry.register('StarAnnotation', (data) => new StarAnnotation(data as unknown as StarConfig)) const serializer = new SceneSerializer({ registry })
const colors = ['#e11d48', '#7c3aed', '#0891b2', '#ea580c'] let count = 0 const addStar = (x: number, y: number): void => { const color = colors[count % colors.length] as string layer.add( new StarAnnotation({ x, y, radius: 24 + (count % 3) * 8, stroke: color, fill: `${color}28`, labelColor: color, label: `Star ${count + 1}`, }), ) count++ }
addStar(120, 130) addStar(290, 160) addStar(430, 120)
stage.on('pointerdown', (e) => { addStar(e.worldPoint.x, e.worldPoint.y) updateReadout() })
const bar = toolbar(host) bar.append( button('Save + Load JSON', () => { const json = serializer.stringify(stage) serializer.parse(stage, json) // rebuilds the scene from JSON — custom stars included layer = stage.children[0] as Layer out.textContent = `round-tripped ${new Blob([json]).size} bytes` }), button('Clear', () => { layer.removeChildren() count = 0 updateReadout() }), ) const out = readout(bar, '') function updateReadout(): void { out.textContent = `${layer.children.length} stars` } updateReadout()
return () => disposeStage(stage)}Where to go next
Section titled “Where to go next”- Customize the look — every node takes
stroke,fill,strokeWidth,lineDash,opacity, pluslabel,labelColor, andshowLabel. Group them into classes withLabelSchema. - Customize the controls —
VertexEditortakeshandleSize,handleColor,fillColor, and anonChangehook; each draw tool takes adefaultsstyle and anonCreatecallback. - Render from data — reach for the framework components when the annotations come from your application state rather than a user’s pointer.