Book › Developers › Architecture
Architecture
How Nekudot is put together, for anyone hacking on it.
Stack
Vanilla TypeScript + the Canvas 2D API, bundled by
Vite. No UI framework - the DOM is built by hand.
d3-quadtree
powers neighbour lookups, zod validates all persisted
data, and fflate zips the .nekudot archive.
Module map
src/
main.ts composition root: builds the stage, panels and navbar, wires src/app
app/ the app wiring, one concern per module
drawing-input.ts the pointer → brush stroke loop
overlay.ts pointer-transparent canvases (invisible glow, symmetry guides)
map-highlight.ts flash a memory map's dots over the canvas
history.ts AppHistory - undo snapshots + paint persistence plumbing
maps-control.ts controller behind the Maps box
presets.ts custom connection presets (save/update/delete/import/export)
app-shortcuts.ts the global shortcut table + panel hide/show toggle
paint-host.ts PaintHost - the one drawing surface a brush paints through;
createBareHost() for tests, GEOMETRY_METHODS for symmetry
base.ts BrushBase - the brush template; delegates connecting to a Connection
renderer.ts IRenderer + CanvasRenderer (Canvas2D, dpr-scaled)
neighbor-finder.ts NeighborFinder + quadtree (capped point cloud)
connecting-types.ts shared types: ConnectMap/Mode, ConnectRouter, dash
pixel-log.ts append-only per-pixel provenance log (IndexedDB)
save-artwork.ts export a .nekudot zip
load-artwork.ts validate + import a .nekudot (zip-bomb safe)
export.ts flatten layers to a PNG
brushes/ one file per brush (Shapes = squares + circles)
brushes.json ordered brush index (name/file/shortcut/group/connections)
registry.ts builds BRUSH_DEFS from brushes.json - map, menu, shortcuts, names
connections/ the connecting engine + art-style "connections"
base.ts ConnectionBase - connecting state + the web/fan engine
registry.ts builds the connection list from connections.json
connections.json ordered index of connection styles
routing.ts ROUTING_PRESETS - where a stroke reads/stores neighbours
classic.ts fur.ts generic + code styles (the rest are data in connections.json)
symmetry/ symmetry modes, each a plugin (like brushes), + the proxy
tool.ts SymmetryTool base - transforms(), settings(), guides
registry.ts builds the tool list from symmetry.json
symmetry.json ordered index of symmetry modes
transforms.ts shared affine primitives (Transform + builders)
proxy.ts replays every mark + deposit at each transform
controller.ts active mode + shared movable centre + guide style
menu-section.ts box.ts the panel (generic, from each tool's settings())
tools/ radial.ts mirror.ts concentric.ts spiral.ts tile.ts
clip/ record a few seconds of drawing to an animated GIF
recorder.ts captures downscaled canvas frames on a timer
encode-gif.ts gifenc encoder + the nekudot.app provenance comment
exporters.ts Exporter registry (format-agnostic; GIF today)
timeline.ts speed + trim math (pure)
preview-box.ts preview/edit modal (loop, speed, trim)
record-flow.ts arms on Record, starts on first stroke, the REC pill
layered/
manager.ts LayerManager - the app's PaintHost over the layer stack + maps
layer.ts Layer = one canvas + renderer
wet-stroke.ts WetStrokeBuffer - faint continuous strokes composite at one alpha
neighbors-map.ts NeighborsMap = a named point cloud
schema.ts zod schema for the layer/map config
box.ts Layers panel UI
maps-box.ts Maps panel UI
size-picker.ts New-art size dialog
store/ LocalStorage + IndexedDB stores, PaintStore, UndoStore
menu.ts settings-panel.ts shortcuts.ts confirm.ts chip.ts help.ts drag.ts window-stack.ts
The drawing surface: PaintHost
Everything that draws goes through IRenderer
(renderer.ts) - drawLine, fillCircle,
strokeEllipse, drawChisel, etc.
CanvasRenderer wraps a single CanvasRenderingContext2D
and is scaled by the device pixel ratio once at construction, so brushes
work in CSS pixels and stay crisp on retina.
A brush, though, holds one PaintHost
(paint-host.ts): a single object serving three roles -
IRenderer (marks land on the active layer),
NeighborFinder (points land in the selected memory
map) and ConnectRouter (target layers/maps by stable id).
At runtime the host is the LayerManager, wrapped in the
symmetry proxy so every mark and deposit mirrors under the
active symmetry tool (Radial, Mirror, Concentric, Spiral, Tile). Tests
and the headless render harnesses build one
with createBareHost(renderer, finder) - a neutral router
over a bare canvas - so they run the exact same code paths as the app.
The symmetry proxy overrides exactly the geometry-bearing methods listed
in GEOMETRY_METHODS (paint-host.ts); its
override table is typed against that list, and
tests/symmetry-coverage.test.ts forces every new renderer
method to be classified - so a new draw call can't silently skip
symmetry.
Layers & memory maps
A Layer is one DOM canvas + renderer. The LayerManager owns the stack, composites by z-index, and exposes layer ops (add/duplicate/delete/opacity).
Point clouds are separate and top-level: each
NeighborsMap owns a NeighborFinder (a
d3-quadtree) - see the Memory maps page.
The manager implements NeighborFinder too, routing
addPixel/findNeighbors to the selected map.
The cloud is capped (MAX_PIXELS) with oldest-eviction so
lookups stay fast.
The stroke loop
app/drawing-input.ts turns pointer input into brush calls.
Pointer moves are de-coalesced so fast gestures keep full resolution -
but a connecting brush weaves its web only on the last sample
per frame (Harmony's per-move cadence), or the web would build up
quadratically with the pointer's report rate:
stage.addEventListener("pointerdown", (e) => {
symmetry.beginStroke(...); // freeze the transforms for this stroke
brush.strokeStart(e.offsetX, e.offsetY);
brush.stroke(e.offsetX, e.offsetY);
});
stage.addEventListener("pointermove", (e) => {
const list = e.getCoalescedEvents();
const frameCadence = brush.supportsConnecting();
for (let i = 0; i < list.length; i++) // mark: every sample; web: last only
brush.stroke(list[i].offsetX, list[i].offsetY,
!frameCadence || i === list.length - 1);
});
// pointerup → brush.strokeEnd(); refresh previews; persist; pushUndo
BrushBase.stroke() is a template method: it
deposits the point into the trail map, calls the subclass's
onStroke() to paint the mark, then - if the brush has a
Connection attached - lets it weave the web via
connection.connect().
A faint continuous stroke (Round below full opacity) is buffered in a
wet-stroke buffer (layered/wet-stroke.ts):
the line draws opaque into an off-buffer shown at the stroke's opacity,
then composites onto the layer in one pass on pointer-up - so it reads
as one uniform alpha instead of darkening where segment caps overlap.
Connecting & ConnectRouter
To target specific layers/maps by stable id, the manager implements
ConnectRouter (connecting-types.ts):
listLayers/listMaps, addPixelToMap,
findNeighborsInMap, drawConnectionToLayer,
activeLayerId/selectedMapId/strokeWidth, etc. The router is
the third role of the brush's PaintHost, so connections
call it directly; createBareHost supplies a neutral one
(no layers, a single map) for tests and the headless harnesses.
The connecting half is its own module under
src/brushes/connections/. ConnectionBase holds
all connecting state and the web/fan engine
(connectingNeighbors, drawFanned,
drawHair); each art style - Classic, Web, Shaded,
Fur, Lace - is a subclass that just sets defaults, names the dials it
exposes, and may override a texture hook (Fur's drawHair).
A connecting brush attaches one with initConnection("classic")
and the Web tab swaps it via applyArtStylePreset;
routing presets live in connections/routing.ts. Both the
brush list and the connection list are data-driven from JSON indexes
(brushes.json, connections.json) resolved with
import.meta.glob.
Persistence
- Settings & layer config → LocalStorage, keyed like
brush.<name>.<setting>; validated by zod (layered/schema.ts). - Paint & point clouds → IndexedDB via
PaintStore(PNG blobs per layer + map points), debounced on stroke end. - Undo/redo →
UndoStoresnapshots (config + paint). - Pixel log → append-only JSONL of every deposited point (
pixel-log.ts).
Save / load / export
save-artwork.ts writes a .nekudot zip (manifest
+ per-layer PNGs + map JSON + pixel log). load-artwork.ts
imports it defensively: validate the manifest with zod first, cap each
entry's uncompressed size before inflating (zip-bomb defence), decode
images via createImageBitmap. export.ts
flattens the visible layers into a single PNG. build.sh
bundles the whole app into one self-contained HTML file.
Clip capture (GIF)
src/clip/ records a few seconds of drawing into an animated
GIF. It's frame-based, not a video:
recorder.ts composites the layers into one downscaled canvas
on a 12fps timer (independent of the draw loop) and holds the frames in
memory, so the preview can change speed and trim instantly without
re-encoding. record-flow.ts arms on the Record action and
starts capturing on your first stroke (so idle time isn't
recorded); the modal in preview-box.ts loops the frames with
a speed slider and a two-handle trim. Save runs encode-gif.ts
-
gifenc
on the main thread (the single-file build inlines one JS chunk, so a
worker encoder would break it), which also writes a provenance comment
into the GIF metadata. Output formats go through an Exporter
registry (exporters.ts), so adding e.g. WebM is one entry;
timeline.ts holds the speed/trim math.