Showcase
The demos that put several features together. Each one is still a single .ts file — flip to
the Code tab to read exactly how it’s built.
10,000 shapes with a tooltip
Section titled “10,000 shapes with a tooltip”Thousands of circles share one tooltip. A single delegated pointermove hit-tests the layer
and repositions a DOM tooltip — no per-shape listeners, no per-shape text nodes. Bump the count
and keep hovering.
import { Circle } from '@veyrajs/core'import { button, createStage, cycle, disposeStage, readout, toolbar } from './_kit'
// A stress test: thousands of tiny circles sharing ONE tooltip. The trick (the same one Konva// uses) is delegation — a single pointermove handler hit-tests the layer and updates one DOM// tooltip, instead of attaching a listener or a text node to every circle. The canvas draws the// shapes once and then stays static; hover only runs a hit-test and repositions the tooltip.export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer()
host.style.position = 'relative' const tip = document.createElement('div') tip.className = 'vy-tooltip' host.append(tip)
const populate = (count: number): void => { layer.removeChildren() const w = stage.width const h = stage.height for (let i = 0; i < count; i++) { layer.add( new Circle({ name: String(i), x: 6 + Math.random() * (w - 12), y: 6 + Math.random() * (h - 12), radius: 3, fill: cycle[i % cycle.length], }), ) } }
const bar = toolbar(host) const hint = document.createElement('span') hint.className = 'veyrajs-demo__hint' hint.textContent = 'Hover the dots' bar.append(hint) const out = readout(bar, '') const setCount = (n: number): void => { populate(n) out.textContent = `${n.toLocaleString()} shapes` } bar.insertBefore( button('2k', () => setCount(2000)), hint, ) bar.insertBefore( button('5k', () => setCount(5000)), hint, ) bar.insertBefore( button('10k', () => setCount(10000)), hint, ) setCount(2000)
// rAF-throttle hover so a fast pointer can't outrun the O(n) hit-test on a big scene. let queued: { sx: number; sy: number; wx: number; wy: number } | null = null let frame = 0 let lastHit = '' const run = (): void => { frame = 0 if (!queued) return const q = queued queued = null const hit = stage.getIntersection({ x: q.wx, y: q.wy }, { tolerance: 2 }) if (hit) { tip.style.display = 'block' tip.style.left = `${q.sx + 12}px` tip.style.top = `${q.sy + 12}px` if (hit.id !== lastHit) { lastHit = hit.id tip.textContent = `node #${hit.name} · ${(hit as Circle).fill}` } } else { lastHit = '' tip.style.display = 'none' } } stage.on('pointermove', (e) => { queued = { sx: e.screenPoint.x, sy: e.screenPoint.y, wx: e.worldPoint.x, wy: e.worldPoint.y } if (!frame) frame = requestAnimationFrame(run) }) stage.on('pointerleave', () => { queued = null lastHit = '' tip.style.display = 'none' })
return () => { if (frame) cancelAnimationFrame(frame) disposeStage(stage) }}Free drawing
Section titled “Free drawing”Each stroke is its own Line, grown point-by-point as you drag — so strokes stay real,
selectable nodes. The eraser hit-tests under the pointer and removes what it finds.
import { Line } from '@veyrajs/core'import { button, createStage, disposeStage, palette, toolbar } from './_kit'
// Free drawing builds a Line incrementally: pointerdown starts one, pointermove appends a point// (the points setter clones, so each stroke stays its own undoable, selectable node), pointerup// ends it. The eraser hit-tests under the pointer and removes whatever line it finds.export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer() let mode: 'brush' | 'eraser' = 'brush' let color: string = palette.blue let current: Line | null = null
function setMode(m: 'brush' | 'eraser'): void { mode = m brushBtn.classList.toggle('is-active', m === 'brush') eraserBtn.classList.toggle('is-active', m === 'eraser') }
stage.on('pointerdown', (e) => { if (mode === 'eraser') { stage.getIntersection(e.worldPoint, { tolerance: 10 })?.remove() return } current = new Line({ points: [{ x: e.worldPoint.x, y: e.worldPoint.y }], stroke: color, strokeWidth: 4, lineCap: 'round', lineJoin: 'round', }) layer.add(current) }) stage.on('pointermove', (e) => { if (mode === 'eraser') { if (e.buttons) stage.getIntersection(e.worldPoint, { tolerance: 10 })?.remove() return } if (current) current.points = [...current.points, { x: e.worldPoint.x, y: e.worldPoint.y }] }) stage.on('pointerup', () => { current = null })
const bar = toolbar(host) const brushBtn = button('Brush', () => setMode('brush')) const eraserBtn = button('Eraser', () => setMode('eraser')) bar.append(brushBtn, eraserBtn) for (const c of [palette.blue, palette.cyan, palette.amber, palette.teal]) { const sw = button('', () => { color = c setMode('brush') }) sw.className = 'vy-swatch' sw.style.background = c sw.setAttribute('aria-label', `brush color ${c}`) bar.append(sw) } bar.append(button('Clear', () => layer.removeChildren())) setMode('brush')
return () => disposeStage(stage)}Connected nodes
Section titled “Connected nodes”Drag the boxes; the connectors re-route to follow them. Nodes are Groups, edges are Lines
whose endpoints are recomputed on every drag.
import { Group, Line, type Node, Rect, Text } from '@veyrajs/core'import { createStage, cycle, disposeStage, palette, toolbar } from './_kit'
// A draggable flowchart: each node is a Group (rect + label); connectors are Lines between node// centers. Dragging a node re-routes every connector touching it — the edges follow the nodes.const NODE_W = 124const NODE_H = 56
export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer()
const makeNode = (name: string, x: number, y: number, fill: string): Group => { const g = new Group({ name, x, y }) g.add( new Rect({ width: NODE_W, height: NODE_H, fill, stroke: '#0f172a', strokeWidth: 1 }), new Text({ x: 12, y: 20, text: name, fontSize: 14, fill: '#0f172a' }), ) return g } const nodes = [ makeNode('Start', 50, 40, cycle[0]), makeNode('Process', 280, 130, cycle[1]), makeNode('Decision', 520, 40, cycle[2]), makeNode('Done', 520, 210, cycle[3]), ] const edges: [number, number][] = [ [0, 1], [1, 2], [1, 3], ] const center = (g: Group): { x: number; y: number } => ({ x: g.x + NODE_W / 2, y: g.y + NODE_H / 2, }) // Connectors go on first so they render beneath the nodes. const lines = edges.map( ([a, b]) => new Line({ points: [center(nodes[a]), center(nodes[b])], stroke: palette.slate, strokeWidth: 2, }), ) for (const l of lines) layer.add(l) for (const n of nodes) layer.add(n)
const reroute = (): void => { edges.forEach(([a, b], i) => { lines[i].points = [center(nodes[a]), center(nodes[b])] }) }
let dragging: Group | null = null let dx = 0 let dy = 0 stage.on('pointerdown', (e) => { // Walk up from the hit shape to its node Group. let n: Node | null = e.target while (n && !nodes.includes(n as Group)) n = n.parent if (n) { dragging = n as Group dx = e.worldPoint.x - dragging.x dy = e.worldPoint.y - dragging.y host.style.cursor = 'grabbing' } }) stage.on('pointermove', (e) => { if (dragging) { dragging.x = e.worldPoint.x - dx dragging.y = e.worldPoint.y - dy reroute() } }) stage.on('pointerup', () => { dragging = null host.style.cursor = '' })
const bar = toolbar(host) const hint = document.createElement('span') hint.className = 'veyrajs-demo__hint' hint.textContent = 'Drag the nodes — connectors follow' bar.append(hint)
return () => disposeStage(stage)}Collision detection
Section titled “Collision detection”Drag the rectangles — any that overlap turn red, via a cheap world-AABB intersects test.
import { Rect, Shape } from '@veyrajs/core'import { createStage, cycle, disposeStage, toolbar } from './_kit'
// Drag the rectangles around; any that overlap turn red. Overlap is a cheap world-AABB test —// `getWorldBounds().intersects(...)` — re-run on every drag move. (For many shapes you'd add a// spatial index; here a quadratic pass is plenty.)const COLLIDE = '#ef4444'
export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer() const rects = [ new Rect({ x: 80, y: 70, width: 120, height: 90, fill: cycle[0] }), new Rect({ x: 250, y: 120, width: 120, height: 90, fill: cycle[1] }), new Rect({ x: 420, y: 80, width: 120, height: 90, fill: cycle[2] }), new Rect({ x: 300, y: 40, width: 120, height: 90, fill: cycle[3] }), ] for (const r of rects) layer.add(r)
const check = (): void => { for (const r of rects) { r.stroke = null r.strokeWidth = 0 } for (let i = 0; i < rects.length; i++) { for (let j = i + 1; j < rects.length; j++) { if (rects[i].getWorldBounds().intersects(rects[j].getWorldBounds())) { for (const r of [rects[i], rects[j]]) { r.stroke = COLLIDE r.strokeWidth = 3 } } } } stage.requestRender() }
let dragging: Shape | null = null let dx = 0 let dy = 0 stage.on('pointerdown', (e) => { if (e.target instanceof Shape) { dragging = e.target dx = e.worldPoint.x - dragging.x dy = e.worldPoint.y - dragging.y } }) stage.on('pointermove', (e) => { if (dragging) { dragging.x = e.worldPoint.x - dx dragging.y = e.worldPoint.y - dy check() } }) stage.on('pointerup', () => { dragging = null })
const bar = toolbar(host) const hint = document.createElement('span') hint.className = 'veyrajs-demo__hint' hint.textContent = 'Drag the rectangles — overlaps turn red' bar.append(hint)
check() return () => disposeStage(stage)}Mini canvas editor
Section titled “Mini canvas editor”Selection + transform handles, undoable add/delete through the command history, and a PNG export — composed entirely from the public API.
import { AddNodeCommand, Circle, History, Rect, RemoveNodeCommand, SelectionController, SelectionManager, Text,} from '@veyrajs/core'import { button, createStage, cycle, disposeStage, roles, toolbar } from './_kit'
// A tiny editor that stitches several engine pieces together: SelectionController for// select/move/resize/rotate, History + Add/RemoveNodeCommand for undoable add/delete, and// stage.canvas.toDataURL() to export a PNG. Each adds a layer with no new engine code.export function init(host: HTMLElement): () => void { const stage = createStage(host) const layer = stage.createLayer() const history = new History() const selection = new SelectionManager() new SelectionController(stage, { selection, history })
let colorIndex = 0 const nextColor = (): string => cycle[colorIndex++ % cycle.length] as string const spot = (): { x: number; y: number } => ({ x: stage.width / 2 + (Math.random() - 0.5) * 160, y: stage.height / 2 + (Math.random() - 0.5) * 90, })
const add = (make: () => Rect | Circle | Text): void => { history.run(new AddNodeCommand(layer, make())) }
const bar = toolbar(host) const undo = button('Undo', () => history.undo()) const redo = button('Redo', () => history.redo()) undo.disabled = true redo.disabled = true bar.append( button('+ Rect', () => add(() => { const s = spot() return new Rect({ x: s.x - 60, y: s.y - 40, width: 120, height: 80, fill: nextColor() }) }), ), button('+ Circle', () => add(() => { const s = spot() return new Circle({ x: s.x, y: s.y, radius: 44, fill: nextColor() }) }), ), button('+ Text', () => add(() => { const s = spot() return new Text({ x: s.x - 36, y: s.y - 14, text: 'Label', fontSize: 24, fill: roles().ink, }) }), ), button('Delete', () => { const targets = [...selection.nodes] selection.clear() for (const n of targets) history.run(new RemoveNodeCommand(n)) }), undo, redo, button('Export PNG', () => { selection.clear() stage.render() const url = stage.canvas?.toDataURL('image/png') if (!url) return const a = document.createElement('a') a.href = url a.download = 'veyrajs-scene.png' a.click() }), ) const hint = document.createElement('span') hint.className = 'veyrajs-demo__hint' hint.textContent = 'Add · select & transform · delete · undo/redo · export' bar.append(hint)
// Seed two shapes directly (not through history) so the first Undo doesn't empty the canvas. layer.add( new Rect({ x: 120, y: 90, width: 150, height: 96, fill: cycle[0], stroke: '#0f172a', strokeWidth: 1, }), new Circle({ x: 380, y: 160, radius: 52, fill: cycle[1] }), )
const sync = (): void => { undo.disabled = !history.canUndo redo.disabled = !history.canRedo } const offHist = history.onChange(sync) sync()
return () => { offHist() disposeStage(stage) }}