
Code Map Visualization
- 74 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
code-map-visualization is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- code-map-visualization
- AI & Agent Building
- AI-coding skill
Code Map Visualization by the numbers
- 74 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,508 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill code-map-visualizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 74 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with code-map-visualization.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when code-map-visualization is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted co
What you get
Structured output aligned to code-map-visualization: code-map-visualization; AI & Agent Building; AI-coding skill.
Files
Code Map Visualization Best Practices
How to render and visually navigate a codebase that has been projected and geohashed into a 2D map — making it honest, fast, legible, interactive, animated, and accessible. This is the rendering and perception craft on top of the spatial structure: it draws on decades of information-visualisation, cartography, computer-graphics, and nature-/biology-inspired layout knowledge so a code map reads correctly and runs at interactive rates. Contains 47 rules across 9 categories, prioritised by impact.
When to Apply
Reference these guidelines when:
- Deciding what code attribute to put on which visual channel (position, size, colour) and which colour scale tells the truth
- Drawing tens of thousands to millions of cells on the web with Canvas2D, WebGL, or deck.gl, and keeping the render loop inside the frame budget
- Placing and decluttering region/file labels, and rendering legible text over a busy map
- Wiring up interaction — hover, picking, selection, a camera/view-state model, deep links, keyboard control
- Animating camera moves, level-of-detail transitions, and data updates without disorienting the viewer
- Making the canvas accessible to color-vision-deficient, motion-sensitive, keyboard-only, and screen-reader users
A note on scope
This skill is the rendering and perception layer. The companion skill geohash-spatial-code-maps owns the spatial math — geohash encoding, the code→plane projection, bbox/covering-set queries, tiling, and the precision↔zoom navigation model. Rules here cross-link into that skill (e.g. `map-deterministic-projection`, `nav-level-of-detail-aggregation`) rather than re-deriving it. Examples target TypeScript with Canvas2D + WebGL/deck.gl and d3-scale/d3-color; the perceptual reasoning generalises to any rendering stack.
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Visual Encoding & Perceptual Channels | CRITICAL | encode- | 6 |
| 2 | Color & Perceptual Color Scales | CRITICAL | color- | 5 |
| 3 | GPU Render Pipeline (Canvas2D + WebGL) | HIGH | gpu- | 6 |
| 4 | Render Performance & Frame Budget | HIGH | perf- | 5 |
| 5 | Labels & Text Rendering | MEDIUM-HIGH | text- | 5 |
| 6 | Interaction, Picking & Camera | MEDIUM-HIGH | interact- | 5 |
| 7 | Animation & Transitions | MEDIUM | anim- | 4 |
| 8 | Accessibility & Inclusive Rendering | MEDIUM | access- | 4 |
| 9 | Nature- & Cell-Inspired Layout & Rendering | MEDIUM | bio- | 7 |
Quick Reference
1. Visual Encoding & Perceptual Channels (CRITICAL)
- `encode-rank-channels-by-perceptual-accuracy` — Put the decision metric on position/length, not hue
- `encode-let-projection-own-position` — The geohash projection owns x/y; don't re-layout
- `encode-size-by-area-not-radius` — Map value to area; take the square root for radius
- `encode-separate-categorical-from-quantitative` — Hue for category, ordered channels for magnitude
- `encode-redundant-encoding-for-key-signals` — Double-encode the few signals that must never be missed
- `encode-maximize-data-ink-drop-chartjunk` — Strip shadows, bevels, heavy grids that fight the data
2. Color & Perceptual Color Scales (CRITICAL)
- `color-perceptually-uniform-sequential-ramp` — Viridis/OKLCH, never rainbow/jet
- `color-match-scale-type-to-data` — Sequential vs diverging vs categorical, matched to the data
- `color-design-for-color-vision-deficiency` — CVD-safe palettes, verified by simulation
- `color-limit-categorical-hues` — Cap at ~8–12 hues; bucket the long tail
- `color-control-contrast-against-basemap` — Keep cells legible on light or dark backgrounds
3. GPU Render Pipeline — Canvas2D + WebGL (HIGH)
- `gpu-layer-canvas2d-over-webgl` — Bulk cells in GL, crisp labels/overlays in Canvas2D
- `gpu-instance-cells-not-per-quad-draw` — One instanced draw call, not one per cell
- `gpu-batch-to-cut-draw-calls-and-state-changes` — Group by program/texture to avoid pipeline flushes
- `gpu-pack-attributes-into-typed-arrays` — A reusable Float32Array, zero per-frame allocation
- `gpu-atlas-tiles-and-glyphs` — One atlas bound once, addressed by UV offset
- `gpu-size-canvas-to-devicepixelratio` — Size the backing store to DPR for crisp, non-overdrawn output
4. Render Performance & Frame Budget (HIGH)
- `perf-render-in-a-single-raf-loop` — Coalesce input into one redraw per frame
- `perf-redraw-only-dirty-regions` — Repaint the overlay, not the static cell layer
- `perf-debounce-viewport-recompute` — Throttle the covering-set recompute; draw every frame
- `perf-offload-to-worker-and-offscreencanvas` — Heavy projection/packing off the main thread
- `perf-bound-the-tile-cache` — LRU-cap the tile cache; free GPU buffers on eviction
5. Labels & Text Rendering (MEDIUM-HIGH)
- `text-place-and-declutter-labels-greedily` — Greedy collision placement by priority
- `text-show-labels-by-level-of-detail` — Domain labels low-zoom, file labels only when big
- `text-render-glyphs-on-canvas2d-not-per-glyph-textures` — Native fillText, not GL glyph uploads
- `text-add-halo-for-legibility` — A halo keeps labels readable over any fill
- `text-anchor-region-labels-at-centroid` — Pole of inaccessibility, not the bbox centre
6. Interaction, Picking & Camera (MEDIUM-HIGH)
- `interact-pick-with-gpu-color-id-or-spatial-index` — O(1)/O(log n) picking, not a linear scan
- `interact-keep-hover-feedback-under-one-frame` — Instant overlay highlight, async details
- `interact-model-the-camera-not-the-dom` — One view-state object as the source of truth
- `interact-sync-view-state-to-the-url` — Deep-linkable, shareable views
- `interact-support-keyboard-pan-zoom-and-focus` — Arrow and +/- keys drive the camera
7. Animation & Transitions (MEDIUM)
- `anim-ease-camera-transitions-not-jumps` — Ease centre and zoom so the eye can follow
- `anim-crossfade-between-lod-levels` — Dissolve aggregates into points, don't pop
- `anim-preserve-object-constancy-on-data-update` — Key by geohash; animate only real changes
- `anim-keep-transitions-interruptible-and-budgeted` — Cancel and retarget in-flight tweens
8. Accessibility & Inclusive Rendering (MEDIUM)
- `access-encode-redundantly-never-color-alone` — Pair colour with shape or label (WCAG 1.4.1)
- `access-honor-prefers-reduced-motion` — Collapse animation for motion-sensitive users
- `access-make-the-map-keyboard-operable` — Focusable, focus-visible, no keyboard trap
- `access-provide-a-text-alternative-for-the-canvas` — A synced DOM/ARIA outline for screen readers
9. Nature- & Cell-Inspired Layout & Rendering (MEDIUM)
A situational toolbox for rendering the map organically; two of these were invented for software visualisation.
- `bio-voronoi-regions-for-space-filling-tessellation` — Gapless Voronoi cells seeded by projected points
- `bio-relax-weights-for-even-cell-areas` — Lloyd/CVT on weights; keep seed positions fixed
- `bio-circle-packing-for-nested-counts` — Packed circles for hierarchy and counts
- `bio-phyllotaxis-packing-for-even-point-spread` — Golden-angle spiral for dense, even fills
- `bio-metaball-hulls-for-region-membranes` — Marching-squares organic region outlines
- `bio-edge-bundling-for-dependency-overlays` — Holten bundling vs a straight-line hairball
- `bio-flow-fields-for-animated-dependency-load` — Physarum/boids flow when motion encodes load
How to Use
Read individual reference files for detailed explanations, code examples, and "when NOT to apply" guidance:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Rules cross-link via [[other-rule-slug]]; follow them when a related pattern is referenced. Cross-links to enc-, prec-, dec-, qry-, nbr-, idx-, map-, and nav- rules point into the companion geohash-spatial-code-maps skill — read them there for the spatial math.
To build a renderer end to end, the spine is: decide the encoding (category 1) → choose colour (category 2) → stand up the GPU pipeline (category 3) → hold the frame budget (category 4) → add labels, interaction, animation, and accessibility (categories 5–8); reach for organic, cell-based rendering — Voronoi regions, packing, membranes, edge bundling — when it suits the map (category 9). The upstream projection and tiling come from geohash-spatial-code-maps.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
| AGENTS.md | Auto-built TOC navigation |
Related Skills
geohash-spatial-code-maps— The spatial layer this renders: geohash encoding, the code→plane projection, tiling, and the precision↔zoom navigation modelcomputer-science-algorithms— The spatial-index, sorting, and complexity primitives behind fast picking, declutter, and the render loop
Code-as-Geohash-Map Rendering (TypeScript, Canvas2D + WebGL/deck.gl)
Version 0.1.0 Code Map Visualization May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Rendering and perception guide for visualising a codebase that has been projected and geohashed into a navigable 2D map. Contains 47 rules across 9 categories, prioritised by impact from critical (visual-channel encoding and perceptually honest colour scales) through the GPU render pipeline and frame-budget performance, down to label placement, interaction and picking, camera animation, accessibility, and a situational toolbox of nature- and cell-inspired layout (Voronoi tessellation, centroidal relaxation, circle packing, golden-angle phyllotaxis, metaball membranes, hierarchical edge bundling, flow-field animation). Each rule explains why it matters and shows production-realistic incorrect vs. correct TypeScript examples (Canvas2D + WebGL/deck.gl, d3-scale/d3-color), with explicit when-NOT-to-apply guidance. It draws on established visualisation and rendering knowledge — graphical-perception research (Cleveland & McGill, Bertin, Munzner, Tufte), perceptual colour science (viridis, ColorBrewer, OKLCH, the rainbow-considered-harmful literature), cartographic label placement, the WebGL/Canvas rendering pipeline, biologically-inspired layout (Voronoi treemaps and edge bundling, both invented for software visualisation), and WCAG — and is the rendering layer on top of the geohash-spatial-code-maps skill, which owns the geohash encoding, projection, tiling, and navigation math.
---
Table of Contents
1. Visual Encoding & Perceptual Channels — CRITICAL
- 1.1 Encode Category and Magnitude on Different Channel Types — CRITICAL (prevents false ordering of nominal domains)
- 1.2 Encode Critical Signals Redundantly Across Channels — HIGH (prevents total signal loss under CVD or greyscale)
- 1.3 Let the Projection Own the Position Channel — CRITICAL (prevents erasing coupling-as-proximity)
- 1.4 Maximize Data-Ink and Drop Chartjunk — HIGH (reduces non-data pixels competing with the map)
- 1.5 Rank Visual Channels by Perceptual Accuracy — CRITICAL (prevents systematic misreading of the primary metric)
- 1.6 Scale Symbol Size by Area, Not Radius — CRITICAL (prevents up to 4x magnitude exaggeration)
2. Color & Perceptual Color Scales — CRITICAL
- 2.1 Control Cell Contrast Against the Basemap — HIGH (prevents cells washing out against the background)
- 2.2 Design Color Choices for Color-Vision Deficiency — CRITICAL (prevents red/green confusion for ~8% of male users)
- 2.3 Limit Categorical Hues to What the Eye Can Separate — HIGH (prevents indistinguishable domains past ~8-12 hues)
- 2.4 Match the Color Scale Type to the Data's Shape — CRITICAL (prevents hiding the zero crossing in diverging data)
- 2.5 Use a Perceptually Uniform Sequential Ramp, Not Rainbow — CRITICAL (prevents false boundaries the data does not contain)
3. GPU Render Pipeline — Canvas2D + WebGL — HIGH
- 3.1 Batch by State to Cut Draw Calls and GPU State Changes — HIGH (prevents a pipeline flush per domain group)
- 3.2 Draw Cells with Instanced Rendering, Not One Draw Call Each — HIGH (O(n) draw calls to O(1); 10-100x more marks)
- 3.3 Layer Canvas2D Over WebGL for Crisp Overlays — HIGH (prevents blurry text and per-frame GPU label cost)
- 3.4 Pack Per-Cell Attributes into Typed Arrays — HIGH (prevents per-frame GC pauses from object churn)
- 3.5 Pack Tiles and Glyphs into a Texture Atlas — HIGH (prevents a texture rebind per tile or glyph)
- 3.6 Size the Canvas to devicePixelRatio — HIGH (prevents blurry output or 4x overdraw on HiDPI)
4. Render Performance & Frame Budget — HIGH
- 4.1 Bound the Tile and Geometry Cache — HIGH (prevents unbounded memory growth over a long session)
- 4.2 Debounce Viewport-to-Cell Recomputation — HIGH (prevents recomputing the covering set per mouse move)
- 4.3 Offload Layout and Heavy Draw to a Worker — HIGH (prevents main-thread freezes beyond the 16ms budget)
- 4.4 Redraw Only Dirty Layers and Regions — HIGH (prevents repainting static layers every frame)
- 4.5 Render in a Single requestAnimationFrame Loop — HIGH (reduces redraws to one per frame)
5. Labels & Text Rendering — MEDIUM-HIGH
- 5.1 Add a Halo So Labels Stay Legible Over Busy Fills — MEDIUM (prevents text disappearing into varied cell colours)
- 5.2 Anchor Region Labels at the Visual Centroid — MEDIUM (prevents labels drifting outside their region)
- 5.3 Place and Declutter Labels Greedily by Priority — MEDIUM-HIGH (prevents overlapping, unreadable label pileups)
- 5.4 Render Label Text on Canvas2D, Not Per-Glyph GL Textures — MEDIUM-HIGH (prevents per-glyph texture uploads and blur)
- 5.5 Reveal Labels by Level of Detail — MEDIUM-HIGH (prevents leaf labels flooding a zoomed-out view)
6. Interaction, Picking & Camera — MEDIUM-HIGH
- 6.1 Drive the Camera and Selection from the Keyboard — MEDIUM (prevents a pointer-only, unnavigable map)
- 6.2 Keep Hover Feedback Under One Frame — MEDIUM (prevents hover lag behind the cursor)
- 6.3 Model the Camera as View-State, Not DOM Scroll — MEDIUM-HIGH (prevents drift between zoom, data, and URL)
- 6.4 Pick With a GPU Color ID or Spatial Index, Not a Linear Scan — MEDIUM-HIGH (O(n) hit-test to O(1) or O(log n))
- 6.5 Sync View-State to the URL for Deep Links — MEDIUM (prevents losing the view on reload or share)
7. Animation & Transitions — MEDIUM
- 7.1 Crossfade Between Level-of-Detail Tiers — MEDIUM (prevents jarring pops when buckets split or merge)
- 7.2 Ease Camera Transitions Instead of Jumping — MEDIUM (prevents loss of spatial context on view changes)
- 7.3 Keep Transitions Interruptible and Budgeted — MEDIUM (prevents queued animations from lagging input)
- 7.4 Preserve Object Constancy on Data Updates — MEDIUM (prevents cells teleporting when data refreshes)
8. Accessibility & Inclusive Rendering — MEDIUM
- 8.1 Honor prefers-reduced-motion — MEDIUM (prevents motion sickness from camera animation)
- 8.2 Make Every Map Action Keyboard-Operable — MEDIUM (prevents locking out non-pointer users)
- 8.3 Never Let Color Be the Only Encoding — MEDIUM (prevents state being lost without colour)
- 8.4 Provide a Text Alternative for the Canvas — MEDIUM (prevents the map being invisible to screen readers)
9. Nature- & Cell-Inspired Layout & Rendering — MEDIUM
- 9.1 Animate Dependency Load with a Bio-Inspired Flow Field — MEDIUM (prevents static edges hiding direction and volume)
- 9.2 Bundle Dependency Edges Along the Hierarchy — MEDIUM (prevents a straight-line dependency hairball)
- 9.3 Outline Regions with Metaball Hulls via Marching Squares — MEDIUM (prevents jagged or ambiguous region outlines)
- 9.4 Pack Files as Nested Circles to Show Hierarchy and Counts — MEDIUM (prevents losing hierarchy in a flat scatter)
- 9.5 Relax Voronoi Weights for Even Cell Areas, Not Seed Positions — MEDIUM (prevents unreadable slivers and oversized cells)
- 9.6 Spread Dense Points with a Golden-Angle Phyllotaxis Spiral — MEDIUM (prevents clumping and grid moiré in dense fills)
- 9.7 Tessellate Regions with a Voronoi Diagram, Not Scattered Marks — MEDIUM (prevents gaps and ambiguous region boundaries)
---
References
1. https://www.cs.ubc.ca/~tmm/vadbook/ 2. https://www.jstor.org/stable/2288400 3. https://doi.org/10.1109/MCG.2007.323435 4. https://bids.github.io/colormap/ 5. https://colorbrewer2.org/ 6. https://www.w3.org/TR/css-color-4/ 7. https://jfly.uni-koeln.de/color/ 8. https://d3js.org/ 9. https://deck.gl/ 10. https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices 11. https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas 12. https://vanwijk.win.tue.nl/zoompan.pdf 13. https://maplibre.org/ 14. https://www.w3.org/WAI/WCAG22/ 15. https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames 16. https://wettel.github.io/codecity.html 17. https://graphics.uni-konstanz.de/publikationen/Balzer2005VoronoiTreemapsVisualization/index.html 18. https://github.com/d3/d3-delaunay 19. https://d3js.org/d3-hierarchy/pack 20. https://github.com/d3/d3-contour 21. https://www.cs.jhu.edu/~misha/ReadingSeminar/Papers/Holten06.pdf 22. https://www.red3d.com/cwr/boids/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title}
{1-3 sentences explaining WHY this matters — what the viewer misreads or what breaks without it, and what the model should generalise from. Teach the perceptual or rendering reasoning, not just the rule. Cross-link the geohash spatial math ([[map-...]], [[nav-...]], [[qry-...]]) rather than re-deriving it; cross-link sibling rendering rules ([[encode-...]], [[gpu-...]]).}
Incorrect ({problem label}):
// Production-realistic anti-pattern. Comment explains the cost.
// (Use ```css or ```typescript with JSX for the access-/CSS rules where they fit.)Correct ({solution label}):
// Minimal diff from the incorrect example. Comment explains the benefit.When NOT to apply:
- {Realistic exception 1}
- {Realistic exception 2}
Reference: {Title}; [{Title 2}]({URL 2})
{
"version": "0.1.1",
"organization": "Code Map Visualization",
"technology": "Code-as-Geohash-Map Rendering (TypeScript, Canvas2D + WebGL/deck.gl)",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Rendering and perception guide for visualising a codebase that has been projected and geohashed into a navigable 2D map. Contains 47 rules across 9 categories, prioritised by impact from critical (visual-channel encoding and perceptually honest colour scales) through the GPU render pipeline and frame-budget performance, down to label placement, interaction and picking, camera animation, accessibility, and a situational toolbox of nature- and cell-inspired layout (Voronoi tessellation, centroidal relaxation, circle packing, golden-angle phyllotaxis, metaball membranes, hierarchical edge bundling, flow-field animation). Each rule explains why it matters and shows production-realistic incorrect vs. correct TypeScript examples (Canvas2D + WebGL/deck.gl, d3-scale/d3-color), with explicit when-NOT-to-apply guidance. It draws on established visualisation and rendering knowledge — graphical-perception research (Cleveland & McGill, Bertin, Munzner, Tufte), perceptual colour science (viridis, ColorBrewer, OKLCH, the rainbow-considered-harmful literature), cartographic label placement, the WebGL/Canvas rendering pipeline, biologically-inspired layout (Voronoi treemaps and edge bundling, both invented for software visualisation), and WCAG — and is the rendering layer on top of the geohash-spatial-code-maps skill, which owns the geohash encoding, projection, tiling, and navigation math.",
"references": [
"https://www.cs.ubc.ca/~tmm/vadbook/",
"https://www.jstor.org/stable/2288400",
"https://doi.org/10.1109/MCG.2007.323435",
"https://bids.github.io/colormap/",
"https://colorbrewer2.org/",
"https://www.w3.org/TR/css-color-4/",
"https://jfly.uni-koeln.de/color/",
"https://d3js.org/",
"https://deck.gl/",
"https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices",
"https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas",
"https://vanwijk.win.tue.nl/zoompan.pdf",
"https://maplibre.org/",
"https://www.w3.org/WAI/WCAG22/",
"https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames",
"https://wettel.github.io/codecity.html",
"https://dl.acm.org/doi/10.1145/1056018.1056041",
"https://github.com/d3/d3-delaunay",
"https://d3js.org/d3-hierarchy/pack",
"https://github.com/d3/d3-contour",
"https://www.cs.jhu.edu/~misha/ReadingSeminar/Papers/Holten06.pdf",
"https://www.red3d.com/cwr/boids/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Visual Encoding & Perceptual Channels (encode)
Impact: CRITICAL Description: Which visual channel carries which code attribute is the decision every later pixel inherits — get the channel ranking wrong (quantity on hue, magnitude on area, position spent on a force layout instead of the projection) and the map misleads no matter how beautifully it renders. This is the foundation: a perceptually wrong encoding cannot be fixed by faster GPU code or nicer animation.
2. Color & Perceptual Color Scales (color)
Impact: CRITICAL Description: Color is the channel most often used and most often abused. A rainbow/jet ramp invents boundaries that aren't in the data, a sequential ramp used for diverging data hides the zero crossing, and color-only encoding vanishes for the ~8% of users with color-vision deficiency. The ramp is chosen once and recolours every cell on every frame, so a wrong scale is a global, permanent lie.
3. GPU Render Pipeline — Canvas2D + WebGL (gpu)
Impact: HIGH Description: A code map is tens of thousands to millions of cells; the only way to draw that at interactive rates is to push geometry to the GPU in large instanced batches and reserve Canvas2D for the crisp overlay layer. Per-cell draw calls, per-frame object allocation, and redundant state changes are what turn a correct visualization into an unusable slideshow.
4. Render Performance & Frame Budget (perf)
Impact: HIGH Description: Interactivity lives or dies inside a 16 ms frame. Recomputing the covering set on every mousemove, redrawing static layers each frame, doing layout on the main thread, or letting tile caches grow unbounded all blow the budget and produce jank, dropped input, and eventual out-of-memory crashes during a long panning session.
5. Labels & Text Rendering (text)
Impact: MEDIUM-HIGH Description: Text is the most expensive layer to draw and the hardest to place — labels collide, overflow their cells, and pile up illegibly when every region wants a name at once. Level-of-detail label selection, greedy collision declutter, and legible glyph rendering are what make a code map readable rather than a wall of overlapping text.
6. Interaction, Picking & Camera (interact)
Impact: MEDIUM-HIGH Description: A map you cannot hover, select, deep-link, or drive from the keyboard is just a picture. Hit-testing by looping over every cell, coupling the camera to DOM scroll, and losing view state on reload are the mistakes that make interaction laggy or impossible; GPU/ spatial-index picking and a single camera view-state model fix them.
7. Animation & Transitions (anim)
Impact: MEDIUM Description: Motion either aids comprehension or destroys it. Jumping the camera instead of easing it, popping between level-of-detail tiers, and re-keying objects on every data update break the viewer's sense of where things are. Smooth, interruptible, object-constant transitions preserve the mental map; gratuitous animation just burns frames.
8. Accessibility & Inclusive Rendering (access)
Impact: MEDIUM Description: A canvas is opaque to assistive technology and indifferent to motion sensitivity and color vision by default. Redundant (non-color) encoding, honouring prefers-reduced-motion, full keyboard operability, and a real text alternative for the canvas are what make the map usable by everyone — and they are cheap to add up front, expensive to retrofit.
9. Nature- & Cell-Inspired Layout & Rendering (bio)
Impact: MEDIUM Description: A situational toolbox of algorithms from biology and nature for rendering the map organically — Voronoi tessellation (how living cells partition space), centroidal weight relaxation, circle packing, golden-angle phyllotaxis, metaball membranes, hierarchical edge bundling, and flow-field animation. Lower, optional cascade impact — rectangles and dots also work — but decisive when you want a gapless, cell-like map whose regions, fills, and dependency overlays read as living structure rather than scattered marks. Two of these (Voronoi treemaps, edge bundling) were invented for software visualisation.
Never Let Color Be the Only Encoding
WCAG 1.4.1 requires that colour is never the only way to convey information, because color-vision-deficient users, greyscale displays, and washed-out projectors all drop hue. On a code map this means a status shown only as red/green ([[color-design-for-color-vision-deficiency]]) is invisible to those users; pair it with a shape, icon, pattern, or text label so the meaning survives without colour. This is the rendering-side obligation that the encoding rule ([[encode-redundant-encoding-for-key-signals]]) implements.
Incorrect (legend distinguishes by colour swatch alone):
const legend = [
{ color: GREEN, label: "passing" },
{ color: RED, label: "failing" }, // distinguished only by hue
];Correct (colour plus a shape that survives without colour):
const legend = [
{ color: GREEN, glyph: "circle", label: "passing" },
{ color: RED, glyph: "triangle", label: "failing" }, // shape carries it too
];When NOT to apply:
- Purely aesthetic, non-informational colour (a brand tint that carries no meaning) has nothing to redundantly encode.
Reference: WCAG 2.2 — Use of Color (1.4.1); Okabe & Ito, Color Universal Design
Honor prefers-reduced-motion
Large camera fly-tos, crossfades, and zoom animations ([[anim-ease-camera-transitions-not-jumps]]) can trigger nausea and disorientation for users with vestibular disorders. The OS-level prefers-reduced-motion setting is their explicit request to tone motion down; honour it by collapsing transitions to near-instant (a quick fade rather than a sweeping fly) rather than ignoring it. Read the media query and react to changes, since users can toggle it at runtime.
Incorrect (always animate the full fly):
function goTo(target: ViewState) { flyTo(target, 400); } // 400ms sweep for everyoneCorrect (collapse duration when reduced motion is requested):
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
function goTo(target: ViewState) {
flyTo(target, reduce.matches ? 0 : 400); // instant for reduced-motion users
}
reduce.addEventListener("change", rerenderControls); // honour a runtime toggleWhen NOT to apply:
- There is no valid exception to skip the check — motion that conveys essential information should still be reduced and paired with a non-motion cue.
Reference: MDN — prefers-reduced-motion; WCAG 2.2 — Animation from Interactions (2.3.3)
Make Every Map Action Keyboard-Operable
WCAG 2.1.1 requires every action to be reachable by keyboard, and a canvas is a black box to assistive technology by default — focus order, visible focus, and freedom from keyboard traps must be added deliberately. Beyond wiring keys to the camera ([[interact-support-keyboard-pan-zoom-and-focus]]), make the canvas focusable, show a visible focus ring on the selected cell, expose the actions as real controls or documented shortcuts, and ensure focus can always leave the map. This is what makes the keyboard path usable, not merely present.
Incorrect (canvas cannot receive focus; selection has no visible state):
<canvas id="map" /> // keyboard users cannot reach it or see what is selectedCorrect (focusable, announces its role, draws a focus ring, never traps):
<canvas
id="map"
tabIndex={0}
role="application"
aria-label="Code map — arrow keys pan, plus/minus zoom, Enter selects"
onKeyDown={onMapKey} // Escape moves focus out; a ring is drawn for focusedCell
/>When NOT to apply:
- A static, non-interactive image of the map exposes no actions, so it needs alt text ([[access-provide-a-text-alternative-for-the-canvas]]) rather than keyboard handlers.
Reference: WCAG 2.2 — Keyboard (2.1.1); MDN — ARIA application role
Provide a Text Alternative for the Canvas
A <canvas> exposes nothing to a screen reader but its bitmap — the pixels are opaque to assistive technology. Provide a parallel, accessible representation: fallback DOM nested inside the canvas element (a list of regions and their key metrics, kept in sync), or an ARIA live region that summarises the current view ("Billing region, 1,240 files, 3 failing"). Screen-reader users then get the same structure sighted users see, and the map degrades to a navigable outline rather than a void.
Incorrect (bare canvas):
<canvas id="map" /> // a silent rectangle to assistive technologyCorrect (a synced DOM outline inside the canvas as its accessible alternative):
<canvas id="map" aria-label="Code map">
<ul aria-label="Regions">
{regions.map((r) => (
<li key={r.prefix}>{r.name}: {r.fileCount} files, {r.failing} failing</li>
))}
</ul>
</canvas> // screen readers read the list; the canvas paints the same dataWhen NOT to apply:
- A decorative canvas conveying no information uses
role="presentation"and empty alt text instead — there is no data to mirror.
Reference: MDN — `<canvas>` accessibility concerns; WCAG 2.2 — Non-text Content (1.1.1)
Crossfade Between Level-of-Detail Tiers
Level-of-detail rendering swaps aggregated prefix buckets for individual cells as you zoom ([[nav-level-of-detail-aggregation]]); doing it on a hard threshold makes a cluster suddenly burst into points (or points snap into a blob), a visual pop that breaks continuity and hides the relationship between the aggregate and its members. Crossfade across the threshold — fade the outgoing representation out while the incoming fades in over a short zoom band — so the viewer sees one becoming the other.
Incorrect (hard threshold):
if (zoom >= SPLIT_ZOOM) drawPoints(cell); // cluster pops into points in a single frame
else drawBucket(cell);Correct (crossfade across a zoom band):
const k = clamp01((zoom - (SPLIT_ZOOM - 0.5)) / 1.0); // 0 below the band, 1 above it
if (k < 1) drawBucket(cell, 1 - k); // aggregate fades out
if (k > 0) drawPoints(cell, k); // members fade inWhen NOT to apply:
- Under a reduced-motion preference ([[access-honor-prefers-reduced-motion]]), or at extreme cell counts where drawing both representations across the band blows the frame budget — then snap.
Reference: OSM Slippy Map; Munzner, Visualization Analysis & Design
Ease Camera Transitions Instead of Jumping
When the user clicks a search result or a breadcrumb ([[nav-breadcrumb-prefix-path]]), teleporting the camera to the target discards the relationship between where they were and where they land, so they lose their bearings and re-orient from scratch. Easing the camera — interpolating centre and zoom over a few hundred milliseconds, ideally along a smooth zoom-out-then-in arc for distant jumps — lets the eye track the motion and preserves the mental map. Shorten or skip the tween for reduced-motion users ([[access-honor-prefers-reduced-motion]]).
Incorrect (instant jump):
function goTo(target: ViewState) { view = target; render(); } // user loses the target's contextCorrect (ease centre and zoom so the eye can follow):
function goTo(target: ViewState, ms = 400) {
const from = { ...view }, t0 = performance.now();
const step = (now: number) => {
const k = easeInOutCubic(Math.min(1, (now - t0) / ms));
view = lerpViewState(from, target, k);
render();
if (k < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}When NOT to apply:
- Reduced-motion users, or moves within the same screenful where there is no surrounding context to lose.
Reference: van Wijk & Nuij, Smooth and Efficient Zooming and Panning (IEEE InfoVis 2003); deck.gl — FlyToInterpolator
Keep Transitions Interruptible and Budgeted
If a new interaction starts a fresh animation without cancelling the one in flight, transitions queue up and the camera lurches through stale targets — click three results quickly and you watch all three fly-bys in sequence. Make transitions interruptible: cancel or retarget the running tween from its current mid-animation state toward the new target, and cap how many marks animate at once so a huge update does not tween 100k cells simultaneously. Motion should always be heading toward the user's latest intent.
Incorrect (each call starts another loop):
function goTo(target: ViewState) {
requestAnimationFrame(function step() { /* ...tween... */ requestAnimationFrame(step); });
} // queued fly-bys through stale targetsCorrect (one cancellable tween, retargeted from the current state):
let raf = 0;
function goTo(target: ViewState, ms = 400) {
cancelAnimationFrame(raf); // drop the in-flight animation
const from = { ...view }, t0 = performance.now(); // retarget from where we are now
raf = requestAnimationFrame(function step(now) {
const k = easeInOutCubic(Math.min(1, (now - t0) / ms));
view = lerpViewState(from, target, k); render();
if (k < 1) raf = requestAnimationFrame(step);
});
}When NOT to apply:
- Short, non-overlapping transitions that can never collide do not need cancellation machinery.
Reference: van Wijk & Nuij, Smooth and Efficient Zooming and Panning; MDN — cancelAnimationFrame
Preserve Object Constancy on Data Updates
When the map's data refreshes (a new commit, a recomputed metric), re-creating marks from scratch makes every cell disappear and reappear — even ones whose position is unchanged ([[encode-let-projection-own-position]]) — so the eye cannot tell what moved, split, or merged. Key marks by a stable identity (the file's geohash or path) so the renderer matches old to new and animates only the genuine changes: a cell that gained churn grows, a deleted file fades out, a moved file slides. This is the data-join / object-constancy principle.
Incorrect (new objects each update):
function update(next: Cell[]) { current = next; render(current); } // every cell blinksCorrect (match by stable key; animate only what changed):
function update(next: Cell[]) {
const byKey = new Map(current.map((c) => [c.geohash, c]));
const nextKeys = new Set(next.map((n) => n.geohash));
for (const n of next) {
const prev = byKey.get(n.geohash);
prev ? tween(prev, n) : fadeIn(n); // grow/recolour in place, or fade a new file in
}
for (const old of current) if (!nextKeys.has(old.geohash)) fadeOut(old); // deleted files
current = next;
}When NOT to apply:
- A one-shot static render that never updates has no transitions to preserve.
Reference: Bostock — Object Constancy; Munzner, Visualization Analysis & Design
Pack Files as Nested Circles to Show Hierarchy and Counts
When the goal is showing how many files a module holds and how modules nest — rather than a space-filling map — pack each group's children as tangent circles inside their parent, the way cells cluster into tissue. The front-chain packing algorithm (Wang et al., the basis of d3's pack layout) makes containment and relative counts immediately legible, and the gaps between circles are what reveal the grouping. Size circles by area so counts read correctly ([[encode-size-by-area-not-radius]]).
Incorrect (a flat scatter of equal dots):
files.forEach((f) => drawCircle(ctx, f.xy, 4, domainColor(f.domain))); // hierarchy and counts invisibleCorrect (nested tangent circles expose hierarchy and per-group counts):
const root = hierarchy(moduleTree).sum((d) => d.loc); // area encodes size
pack<ModuleNode>().size([w, h]).padding(3)(root);
root.descendants().forEach((n) => drawCircle(ctx, [n.x, n.y], n.r, domainColor(n.data.domain)));When NOT to apply:
- Circle packing wastes the inter-circle space and breaks spatial adjacency — if regional proximity from the projection matters, use Voronoi tessellation ([[bio-voronoi-regions-for-space-filling-tessellation]]) instead.
Reference: Wang et al. — Visualization of Large Hierarchical Data by Circle Packing; d3-hierarchy: pack
Bundle Dependency Edges Along the Hierarchy
Overlaying inter-file dependencies as straight lines on the map produces an unreadable hairball the moment there are more than a few dozen. Hierarchical edge bundling (Holten, developed for software class dependencies) routes each edge as a B-spline along the path through the region hierarchy, so edges sharing a route bundle together like vascular or neural pathways — turning the hairball into a few legible flows whose thickness shows traffic. Draw the many curves efficiently with instancing ([[gpu-instance-cells-not-per-quad-draw]]) on the overlay layer ([[gpu-layer-canvas2d-over-webgl]]).
Incorrect (a straight line per dependency):
deps.forEach((e) => strokeLine(ctx, node[e.from].xy, node[e.to].xy)); // hairball past ~50 edgesCorrect (route each edge through the hierarchy and bundle shared paths):
const line = lineRadial().curve(curveBundle.beta(0.85)).radius((d) => d.y).angle((d) => d.x);
bundle(deps).forEach((path) => strokePath(ctx, line(path))); // edges merge into legible bundlesWhen NOT to apply:
- When exact source→target pairing must stay unambiguous (auditing one dependency), bundling deforms paths and hides endpoints — show that single edge straight and highlighted instead.
Reference: Holten — Hierarchical Edge Bundles (IEEE TVCG 2006); Hierarchical edge bundling — Data to Viz
Animate Dependency Load with a Bio-Inspired Flow Field
A static edge shows that A depends on B but not which way data flows or how much. Animating particles along the bundled edges — using a flux model like Physarum's tube-thickening (tubes carrying more flux grow) or boids-style steering — encodes direction as motion and volume as particle density, so heavy paths visibly pulse while idle ones stay quiet. This is decoration unless the motion carries data: gate it behind a real metric, respect reduced motion ([[access-honor-prefers-reduced-motion]]), and do not let it become chartjunk ([[encode-maximize-data-ink-drop-chartjunk]]).
Incorrect (constant particle stream on every edge):
edges.forEach((e) => emitParticles(e.path, FIXED_RATE)); // pure decoration; motion encodes nothingCorrect (emission rate encodes measured load; off for reduced-motion users):
if (matchMedia("(prefers-reduced-motion: reduce)").matches) drawWidthByLoad(edges);
else edges.forEach((e) => emitParticles(e.path, e.callsPerMin * FLOW_GAIN)); // motion = dataWhen NOT to apply:
- If you cannot tie particle motion to a real measured quantity, draw edge thickness or colour instead ([[bio-edge-bundling-for-dependency-overlays]]) — motion with no data behind it is the definition of chartjunk.
Reference: Tero et al. — Rules for Biologically Inspired Adaptive Network Design (Science 2010); Reynolds — Boids (flocking model)
Outline Regions with Metaball Hulls via Marching Squares
A region made of scattered cells needs an outline to read as one thing, but a convex hull swallows neighbouring regions and a concave hull looks jagged and arbitrary. Treat each file as a blob of "charge," sum them into a density field, and trace the isocontour at a threshold with marching squares — the metaball technique — to get a smooth, organic membrane that hugs the region like a cell wall and merges nearby members naturally. d3-contour computes the marching-squares polygons. Keep the outline contrasting with the basemap ([[color-control-contrast-against-basemap]]) and label at the centroid ([[text-anchor-region-labels-at-centroid]]).
Incorrect (a convex hull bridges gaps and engulfs other regions):
const outline = convexHull(region.cells.map((c) => c.xy));
strokePolygon(ctx, outline); // swallows cells that belong elsewhereCorrect (density field plus a marching-squares isocontour):
const density = splatToGrid(region.cells, w, h, RADIUS); // each cell a soft blob
const [membrane] = contours().size([w, h]).thresholds([ISO])(density);
strokeMultiPolygon(ctx, membrane.coordinates); // smooth organic hullWhen NOT to apply:
- At low zoom where a region is only a few pixels, a lightweight marker or bounding shape is cheaper and reads as clearly — reserve the density-field pass for regions large on screen.
Reference: d3-contour (marching squares); Jamie Wong — Metaballs and Marching Squares
Spread Dense Points with a Golden-Angle Phyllotaxis Spiral
Placing many markers inside a cell on a square grid produces axis-aligned moiré and obvious rows; placing them at random produces clumps and holes. The golden-angle spiral that sunflowers use (Vogel's model: the nth point at angle n·137.5° and radius c·√n) packs points at near-uniform density with no preferred direction and no clumping — the most efficient even spread on a disc. Use it to lay out leaf markers, sample points, or glyphs within a region where position carries no meaning of its own.
Incorrect (a grid shows rows and aliases against the cell):
items.forEach((it, i) => {
const col = i % cols, row = Math.floor(i / cols);
place(it, cx + col * gap, cy + row * gap); // visible grid, clumps at edges
});Correct (Vogel's sunflower spiral — uniform density, no grid, no clumps):
const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // the golden angle, ~137.5 degrees, in radians
items.forEach((it, i) => {
const r = spacing * Math.sqrt(i), a = i * GOLDEN;
place(it, cx + r * Math.cos(a), cy + r * Math.sin(a));
});When NOT to apply:
- When the data is genuinely gridded (a matrix, a calendar heatmap), a grid is the honest encoding — phyllotaxis is for when only even coverage matters.
Reference: Vogel — A Better Way to Construct the Sunflower Head (Math. Biosciences 1979); Sunflowers and Fibonacci — packing efficiency
Relax Voronoi Weights for Even Cell Areas, Not Seed Positions
A raw Voronoi diagram from clustered code produces wildly uneven cells — tiny slivers where files bunch up, huge cells in sparse areas — which misreads as importance. A weighted (power) Voronoi with Lloyd-style iteration on the cell weights drives each cell toward a target area (say, proportional to LOC), the technique behind Voronoi treemaps. Crucially, relax the weights, not the seed positions: moving seeds toward their centroids (plain Lloyd / centroidal Voronoi) would erase the coupling-as-proximity the projection encodes ([[encode-let-projection-own-position]]). Keep seeds fixed; adjust weights until the areas match.
Incorrect (Lloyd relaxation moves seeds to centroids):
for (let i = 0; i < 20; i++) {
const v = Delaunay.from(seeds).voronoi(bounds);
seeds = seeds.map((_, j) => centroid(v.cellPolygon(j))); // positions drift; projection destroyed
}Correct (seeds stay put; only weights move, until areas hit target):
for (let i = 0; i < 20; i++) {
const cells = weightedVoronoi(seeds, weights, bounds); // seeds fixed
weights = cells.map((c, j) => weights[j] + (targetArea[j] - area(c)) * GAIN);
if (cells.every((c, j) => closeEnough(area(c), targetArea[j]))) break;
}When NOT to apply:
- If cells are already even (files spread uniformly), the relaxation iterations are wasted compute — measure the area variance first.
Reference: Nocaj & Brandes — Computing Voronoi Treemaps; d3-delaunay
Tessellate Regions with a Voronoi Diagram, Not Scattered Marks
Drawing each file as a dot leaves the plane mostly empty and makes domain boundaries guesswork; drawing rectangular region boxes overlaps and wastes space. A Voronoi diagram seeded by the projected file positions partitions the whole plane into gapless convex cells — the way living cells fill tissue — so every pixel belongs to its nearest file and region edges become explicit polygon borders. This is the basis of Voronoi treemaps, introduced for visualising software metrics. Seed the diagram with the projected coordinates and leave them where the projection put them ([[encode-let-projection-own-position]]); compute with d3-delaunay.
Incorrect (a dot per file):
for (const f of files) drawDot(ctx, f.projectedXY, domainColor(f.domain)); // empty plane, unclear regionsCorrect (Voronoi cells fill the plane; borders make regions explicit):
const points = files.flatMap((f) => f.projectedXY); // seeds = projected positions
const voronoi = Delaunay.from(points).voronoi([0, 0, w, h]);
files.forEach((f, i) => fillPolygon(ctx, voronoi.cellPolygon(i), domainColor(f.domain)));When NOT to apply:
- When individual files must read as discrete, countable marks (a sparse overview), dots or packed circles ([[bio-circle-packing-for-nested-counts]]) communicate count better than a space-filling tessellation.
Reference: Balzer, Deussen & Lewerentz — Voronoi Treemaps for the Visualization of Software Metrics (SoftVis '05); d3-delaunay
Control Cell Contrast Against the Basemap
A colour only reads against its background. Light cells on a white basemap, or a dark theme that drops the same palette onto near-black, collapse the luminance contrast that lets the eye separate cell from ground and cell from cell. Set the basemap to a neutral mid-tone (or pick per-theme palettes), and check foreground/background contrast in a perceptual space rather than trusting raw RGB. The same data should stay legible whether the user is in light or dark mode.
Incorrect (one palette, any background):
canvas.style.background = theme.bg; // could be #fff or #111
getFillColor: (c) => domainColor(c.domain); // light hues vanish on white, dark on blackCorrect (theme-aware palette plus a verified contrast floor):
canvas.style.background = theme.bg;
const palette = theme.dark ? darkSafePalette : lightSafePalette;
getFillColor: (c) => ensureContrast(palette(c.domain), theme.bg, 3.0); // nudge L* to keep >=3:1When NOT to apply:
- A deliberately de-emphasised layer (greyed-out unchanged files behind a diff) should have low contrast — that low contrast is itself the encoding.
Reference: WCAG 2.2 — Non-text Contrast (1.4.11); W3C CSS Color 4 — OKLCH
Design Color Choices for Color-Vision Deficiency
Roughly 8% of men and 0.5% of women cannot distinguish red from green, so a red/green "regressed/improved" map is unreadable for them — and confirming it requires simulating the deficiency, not eyeballing. Choose CVD-safe palettes (viridis and ColorBrewer's flagged-safe schemes are designed for this), prefer blue/orange over red/green for diverging contrasts, and verify by running the palette through a simulator. Colour that fails here fails silently — the chart looks fine to its author.
Incorrect (red/green pair, unverified):
const status = scaleOrdinal<string>()
.domain(["regressed", "improved"])
.range(["#e41a1c", "#4daf4a"]); // indistinguishable under deuteranopiaCorrect (CVD-safe pair, verified in tests):
const status = scaleOrdinal<string>()
.domain(["regressed", "improved"])
.range(["#d55e00", "#0072b2"]); // Okabe-Ito orange/blue, CVD-safe
// CI guard: simulate("deuteranopia", status("regressed")) must differ from status("improved")Pair colour with a second channel so it is never load-bearing alone ([[encode-redundant-encoding-for-key-signals]], [[access-encode-redundantly-never-color-alone]]).
When NOT to apply:
- Brand-mandated colours you cannot change still need the redundant second channel — if you cannot fix the hue, you must add shape or label.
Reference: Okabe & Ito, Color Universal Design; ColorBrewer (colorblind-safe filter)
Limit Categorical Hues to What the Eye Can Separate
People can reliably tell apart only a handful of categorical colours at once — qualitative palettes top out around 8–12 distinct hues, and beyond that adjacent categories become guesses. A code map often has dozens of domains; assigning each a unique colour produces a palette where half the regions are "some kind of teal." Cap the palette: colour the top N domains explicitly, fold the rest into a neutral "other," and lean on position (the projection already groups them) plus labels ([[text-anchor-region-labels-at-centroid]]) to carry the long tail.
Incorrect (a unique hue per domain):
const hue = scaleOrdinal(quantize(interpolateRainbow, domains.length)) // 40 near-identical hues
.domain(domains);Correct (cap to a qualitative palette; bucket the tail):
const top = domainsBySize.slice(0, 10);
const hue = scaleOrdinal<string>()
.domain([...top, "other"])
.range([...schemeTableau10, "#bdbdbd"]) // 10 separable hues + neutral
.unknown("#bdbdbd");
getFillColor: (c) => hue(top.includes(c.domain) ? c.domain : "other");When NOT to apply:
- If categories are never compared across the whole map at once — only within a zoomed-in region of a few — a larger palette can work because only a few are ever on screen together.
Reference: ColorBrewer (qualitative schemes); d3-scale-chromatic: categorical
Match the Color Scale Type to the Data's Shape
Sequential, diverging, and categorical data each need their own scale type. A sequential ramp on diverging data (a coverage delta that can be positive or negative) buries the all-important zero crossing somewhere mid-ramp, so "improved" and "regressed" look similar. A diverging ramp on purely one-ended data invents a meaningless midpoint. Pick the scale whose structure matches the data: one-ended → sequential, signed around a midpoint → diverging, unordered → categorical ([[encode-separate-categorical-from-quantitative]]).
Incorrect (sequential ramp on signed data):
const ramp = scaleSequential(interpolateViridis).domain([-30, 30]);
getFillColor: (c) => rgb(ramp(c.coverageDelta)); // zero hidden; sign of change unclearCorrect (diverging ramp pinned at the meaningful midpoint):
const ramp = scaleDiverging(interpolateRdBu).domain([-30, 0, 30]);
getFillColor: (c) => rgb(ramp(c.coverageDelta)); // red regress, white zero, blue improveWhen NOT to apply:
- If the data has no meaningful midpoint, forcing a diverging ramp invents one — keep it sequential.
Reference: d3-scale: scaleDiverging; ColorBrewer
Use a Perceptually Uniform Sequential Ramp, Not Rainbow
The rainbow/jet ramp is not perceptually uniform: equal steps in the data produce wildly unequal perceived steps, so the bright cyan and yellow bands read as sharp boundaries that exist only in the colormap, while large changes inside the green stretch read as flat. For a sequential metric (churn, complexity, age) use a perceptually uniform ramp — viridis, magma, or an OKLCH-interpolated scale where lightness increases monotonically — so equal data differences look equal. This single choice decides whether the map's colour tells the truth.
Incorrect (rainbow invents banding):
const ramp = (t: number) => interpolateRainbow(t); // non-uniform; fake cyan/yellow edges
getFillColor: (c) => rgb(ramp(norm(c.complexity)));Correct (perceptually uniform, monotonic lightness):
const ramp = scaleSequential(interpolateViridis).domain([0, maxComplexity]);
getFillColor: (c) => rgb(ramp(c.complexity)); // equal steps look equalMatch the ramp shape to the data's shape — sequential here, diverging when there is a meaningful midpoint ([[color-match-scale-type-to-data]]).
When NOT to apply:
- Cyclic data (e.g. hour-of-day of last commit) genuinely wraps, so a cyclic uniform colormap (
interpolateSinebow, twilight) is correct — the harm is using rainbow for non-cyclic magnitude.
Reference: Borland & Taylor, Rainbow Color Map (Still) Considered Harmful (IEEE CG&A 2007); viridis / matplotlib colormaps
Let the Projection Own the Position Channel
Position is the single most accurately decoded channel, and on a code map it is already spent meaningfully: the geohash projection placed coupled code near coupled code ([[map-deterministic-projection]]), so x/y is the domain structure. Re-deriving position with a force-directed simulation or a per-metric layout overwrites that signal — coupling-as-proximity is gone, regions scatter, and because the simulation is non-deterministic the whole map reshuffles every run, destroying the viewer's mental model and object constancy ([[anim-preserve-object-constancy-on-data-update]]). Encode additional attributes on size and colour; never by moving the cell.
Incorrect (a second layout overwrites the projection):
const sim = forceSimulation(cells) // re-positions by degree, not domain
.force("charge", forceManyBody())
.force("link", forceLink(edges));
sim.tick(300);
draw(cells.map((c) => ({ ...c, xy: [c.x, c.y] }))); // projection discarded; jumps each runCorrect (keep projected coordinates; vary other channels):
draw(cells.map((c) => ({
xy: c.projectedXY, // domain proximity preserved & stable
radius: r(c.complexity),
color: domainColor(c.domain),
})));When NOT to apply:
- If you are deliberately visualising the raw dependency graph rather than the geohash map, a force layout is the right tool — but then you are not rendering the spatial code map this skill is about.
Reference: Munzner, Visualization Analysis & Design; CodeCity — Wettel & Lanza
Maximize Data-Ink and Drop Chartjunk
Every pixel that is not encoding data competes with the pixels that are (Tufte's data-ink ratio). On a code map the cells are the data; heavy grid lines, drop shadows, gradient backgrounds, 3-D bevels, and decorative legends steal attention and, worse, add visual variation the eye reads as meaningful. Strip the non-data ink so the structure of the code stands out, not the frame around it.
Incorrect (decoration competes with the cells):
ctx.shadowBlur = 12; // every cell drags a shadow -> visual noise
ctx.shadowColor = "rgba(0,0,0,.5)";
drawGrid(ctx, { lines: "heavy" }); // grid louder than the data
drawBeveledFrame(ctx);Correct (ink spent on data, not chrome):
ctx.shadowBlur = 0;
drawGrid(ctx, { lines: "hairline", color: "#eee" }); // present but recessive
drawCells(ctx, cells); // the cells are the figureWhen NOT to apply:
- A subtle shadow or outline used functionally — to lift a selected cell off a busy basemap ([[color-control-contrast-against-basemap]]) — is data-ink, not chartjunk. The test is whether the ink carries information.
Reference: Tufte, The Visual Display of Quantitative Information; Munzner, Visualization Analysis & Design
Rank Visual Channels by Perceptual Accuracy
People decode visual channels with very different accuracy: position and length are read precisely, while area, angle, and especially colour are read approximately (Cleveland & McGill's ranking, formalised in Munzner's effectiveness order). Put the attribute readers most need to compare — the metric that drives decisions — on the highest-accuracy channel still free, not on whatever is convenient. Encoding code churn or complexity on hue means a cell twice as risky as its neighbour looks merely "a different colour," and the map silently loses its ability to rank.
Incorrect (the decision metric lives on the least accurate channel):
new ScatterplotLayer({
data: cells,
getPosition: (c) => c.xy,
getRadius: () => 40, // size carries nothing
getFillColor: (c) => rainbow(c.complexity), // magnitude on hue -> unrankable
});Correct (decision metric on a high-accuracy channel; hue freed for category):
const r = scaleSqrt().domain([0, maxComplexity]).range([4, 40]);
new ScatterplotLayer({
data: cells,
getPosition: (c) => c.xy,
getRadius: (c) => r(c.complexity), // magnitude on size (area) -> comparable
getFillColor: (c) => domainColor(c.domain), // hue now means "which domain"
});Size is itself only mid-ranked, so scale it by area not radius ([[encode-size-by-area-not-radius]]); reserve hue for nominal data ([[encode-separate-categorical-from-quantitative]]).
When NOT to apply:
- If the metric only needs a coarse "hot vs cold" read rather than precise ranking, a sequential colour ramp ([[color-perceptually-uniform-sequential-ramp]]) is enough and frees size for another attribute.
Reference: Cleveland & McGill, Graphical Perception (JASA 1984); Munzner, Visualization Analysis & Design
Encode Critical Signals Redundantly Across Channels
A signal carried on a single channel disappears whenever that channel fails — colour vanishes for color-vision-deficient users and in greyscale print, size vanishes when cells cluster, position hides under overlap. For the one or two attributes that matter most (e.g. "this module is failing CI"), encode them on two channels at once — colour and an outline ring — so the signal survives any single channel loss. Redundancy is cheap insurance on the marks readers can least afford to miss.
Incorrect (failing state on colour alone):
getFillColor: (c) => (c.ciFailing ? RED : domainColor(c.domain)); // gone in greyscale/CVDCorrect (failing state on colour and an outline):
new ScatterplotLayer({
stroked: true,
getFillColor: (c) => domainColor(c.domain),
getLineColor: (c) => (c.ciFailing ? RED : TRANSPARENT),
getLineWidth: (c) => (c.ciFailing ? 3 : 0), // a thick ring also marks failure
});This is the encoding-side complement of the accessibility rule ([[access-encode-redundantly-never-color-alone]]).
When NOT to apply:
- Do not double-encode every attribute — redundancy spent on minor channels adds clutter ([[encode-maximize-data-ink-drop-chartjunk]]). Reserve it for the few signals that must never be missed.
Reference: WCAG 2.2 — Use of Color (1.4.1); Munzner, Visualization Analysis & Design
Encode Category and Magnitude on Different Channel Types
Nominal data (which domain a file belongs to) and quantitative data (how much churn it has) need different kinds of channel: hue is identity-preserving and unordered, so it suits categories; luminance, size, and length are ordered, so they suit magnitudes. Encoding a category on an ordered ramp invents a ranking that does not exist ("Billing > Search" because it is darker), and encoding magnitude on categorical hue destroys ordering. Keep one channel per data type so each reads correctly.
Incorrect (domain on a sequential ramp implies an order):
const ramp = scaleSequential(interpolateViridis).domain([0, domainCount]);
getFillColor: (c) => rgb(ramp(c.domainIndex)); // categories look ranked by darknessCorrect (domain on categorical hue; magnitude on the ordered channel):
const hue = scaleOrdinal(schemeTableau10).domain(domainNames);
getFillColor: (c) => hue(c.domain); // identity, no implied order
getRadius: (c) => r(c.complexity); // magnitude on size, which is orderedCap the number of distinct hues so they stay distinguishable ([[color-limit-categorical-hues]]).
When NOT to apply:
- Ordinal categories with a genuine order (severity: low/medium/high) should use an ordered channel — that is encoding the order that really exists.
Reference: Bertin, Semiology of Graphics; d3-scale-chromatic: categorical schemes
Scale Symbol Size by Area, Not Radius
When a metric drives the radius of a circle (or the side of a square), the mark's perceived quantity — its area — grows with the square of the value, so a file with twice the churn renders four times as big. Readers judge symbols by area (Flannery's proportional-symbol research, baked into d3's scaleSqrt), so map the value to area and take the square root for the radius. Otherwise every size comparison on the map is exaggerated quadratically.
Incorrect (value drives radius linearly):
const radius = scaleLinear().domain([0, maxLoc]).range([2, 60]);
getRadius: (c) => radius(c.loc); // 2x LOC -> 4x area, looks 4x biggerCorrect (value drives area; radius is its square root):
const radius = scaleSqrt().domain([0, maxLoc]).range([2, 60]);
getRadius: (c) => radius(c.loc); // 2x LOC -> 2x area, read correctlyWhen NOT to apply:
- One-dimensional marks (bar length, line height) already encode on a linear channel — only area marks need the square-root correction.
Reference: d3-scale: scaleSqrt; Munzner, Visualization Analysis & Design
Pack Tiles and Glyphs into a Texture Atlas
Binding a texture is a state change ([[gpu-batch-to-cut-draw-calls-and-state-changes]]), and a map drawing one texture per tile, or one per glyph, rebinds constantly and flushes the pipeline each time. Pack many tiles — or the glyph set for labels — into a single large atlas texture, bound once, and address each piece by a UV offset carried as a per-instance attribute. One bind then serves the whole frame.
Incorrect (a bind per tile):
for (const t of visibleTiles) {
gl.bindTexture(gl.TEXTURE_2D, t.texture); // N pipeline flushes per frame
drawTile(t);
}Correct (all tiles in one atlas; instance carries its UV rect):
gl.bindTexture(gl.TEXTURE_2D, tileAtlas); // bound once for the frame
for (const t of visibleTiles) packUV(buf, t.atlasRect);
gl.drawArraysInstanced(gl.TRIANGLE_FAN, 0, 4, visibleTiles.length);When NOT to apply:
- A handful of large tiles (a low-zoom overview) may exceed the GPU's max texture size — then page across several atlases, but still avoid one-bind-per-tile.
Reference: MDN — WebGL best practices; MapLibre GL JS
Batch by State to Cut Draw Calls and GPU State Changes
Beyond instancing, the next cost is state changes — switching shader program, texture, or blend mode between draws flushes the GPU pipeline. Drawing cells grouped by domain, each group binding a different texture or shader, multiplies these flushes. Sort and group draws so all geometry sharing a program and texture goes out together, and pass per-cell variation (colour, size) as attributes rather than as state. Fewer, larger batches keep the pipeline full.
Incorrect (rebind program and texture per domain group):
for (const [domain, group] of byDomain) {
gl.useProgram(programs[domain]);
gl.bindTexture(gl.TEXTURE_2D, textures[domain]); // pipeline flush each group
drawInstanced(group);
}Correct (one program plus one atlas; an attribute selects the look):
gl.useProgram(cellProgram);
gl.bindTexture(gl.TEXTURE_2D, atlas); // bound once for the frame
gl.drawArraysInstanced(gl.TRIANGLE_FAN, 0, 4, cells.length); // single batchThe shared atlas this depends on is its own concern ([[gpu-atlas-tiles-and-glyphs]]).
When NOT to apply:
- If every cell genuinely needs a unique shader (rare for a map), batching cannot help — but most "different look per domain" needs are an attribute, not a program.
Reference: MDN — WebGL best practices; W3C — WebGPU
Draw Cells with Instanced Rendering, Not One Draw Call Each
Each WebGL draw call carries fixed CPU and driver overhead; issuing one per cell caps you at a few thousand marks before the CPU — not the GPU — becomes the bottleneck. Instanced rendering uploads the cell quad once plus a per-instance attribute buffer (position, size, colour) and draws every cell in a single call, so the GPU does the work it is good at. This is exactly what a deck.gl layer does internally — reach for a layer before hand-rolling per-cell draws.
Incorrect (one draw call per cell):
for (const c of cells) {
setUniforms(gl, c.xy, c.radius, c.color);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4); // CPU-bound at a few thousand cells
}Correct (upload per-instance attributes once, draw all cells in one call):
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
gl.bufferData(gl.ARRAY_BUFFER, packed, gl.DYNAMIC_DRAW); // [x,y,r,rgba] per cell
gl.drawArraysInstanced(gl.TRIANGLE_FAN, 0, 4, cells.length); // one call, all cellsPack that instance buffer without per-frame allocation ([[gpu-pack-attributes-into-typed-arrays]]).
When NOT to apply:
- A static map rendered once to an image (server-side PNG export) does not care about per-frame draw-call overhead.
Reference: MDN — WebGL best practices; deck.gl
Layer Canvas2D Over WebGL for Crisp Overlays
WebGL is the only practical way to draw 100k+ cells per frame, but it is the wrong tool for crisp text, selection outlines, and UI chrome — rasterising glyphs into GL textures is fiddly and tends to blur. Stack two canvases: a WebGL canvas for the bulk cell geometry and a transparent Canvas2D canvas on top for labels, hover highlights, and the legend. The 2D layer redraws only when those overlays change ([[perf-redraw-only-dirty-regions]]), so labels stay sharp at any pixel ratio ([[gpu-size-canvas-to-devicepixelratio]]) without re-rendering the cells.
Incorrect (text drawn in WebGL):
glDrawCells(gl, cells);
glDrawText(gl, labels); // bespoke glyph atlas just to show region names -> blurry, costlyCorrect (bulk geometry on GL, overlays on a 2D canvas above it):
glDrawCells(gl, cells); // 100k cells, one pass
const ov = overlay.getContext("2d"); // transparent <canvas> stacked over the GL canvas
ov.clearRect(0, 0, w, h);
drawLabels(ov, labels); // crisp native text, redrawn only on changeWhen NOT to apply:
- If the whole map is a few thousand cells and a handful of labels, a single Canvas2D layer is simpler and fast enough — the split earns its keep at GL-scale counts.
Reference: MDN — WebGL best practices; MDN — Optimizing canvas
Pack Per-Cell Attributes into Typed Arrays
Building an array of {x, y, r, color} objects every frame allocates tens of thousands of short-lived objects, and the resulting garbage-collection pauses surface as periodic frame drops during panning. Pack attributes into a single reusable Float32Array (or interleaved buffer) sized once, write into it in place each frame, and upload that. Zero per-frame allocation means no GC sawtooth, and the contiguous layout is what the GPU wants anyway.
Incorrect (fresh objects every frame):
const data = cells.map((c) => ({ x: c.x, y: c.y, r: c.radius, color: c.color }));
upload(data); // 100k allocations per frame -> GC sawtooth -> periodic jankCorrect (one reusable buffer, written in place, uploaded each frame):
const buf = new Float32Array(cells.length * 6); // [x,y,r,rgb] allocated once
for (let i = 0; i < cells.length; i++) {
const c = cells[i], o = i * 6;
buf[o] = c.x; buf[o + 1] = c.y; buf[o + 2] = c.radius;
buf[o + 3] = c.r; buf[o + 4] = c.g; buf[o + 5] = c.b;
}
gl.bufferSubData(gl.ARRAY_BUFFER, 0, buf); // no allocation, no GCWhen NOT to apply:
- Small static datasets that upload once never hit the per-frame allocation path, so the readability cost of manual packing is not worth it.
Reference: MDN — WebGL best practices; MDN — Optimizing canvas
Size the Canvas to devicePixelRatio
A canvas has two sizes — its CSS layout size and its backing-store pixel size. Setting only the CSS size on a HiDPI display makes the browser upscale a low-resolution buffer (blurry cells and text); setting the backing store but forgetting to account for the ratio elsewhere can silently render 4x the pixels (overdraw, halved frame rate). Size the backing store to cssSize × devicePixelRatio, scale the 2D context by that ratio, and set the GL viewport to the backing-store size.
Incorrect (backing store ignores device pixel ratio):
canvas.width = cssWidth; // upscaled by the browser -> blurry on Retina
canvas.height = cssHeight;Correct (backing store at device resolution; context scaled to match):
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
canvas.style.width = `${cssWidth}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 1 unit == 1 CSS px, output stays sharpWhen NOT to apply:
- Capping the ratio at 2 on very high-density phones is a deliberate tradeoff — past 2x the sharpness gain rarely justifies the overdraw.
Reference: MDN — Optimizing canvas; MDN — Window.devicePixelRatio
Keep Hover Feedback Under One Frame
Hover is the map's main affordance — it must feel instant. If a hover triggers a full-scene repaint ([[perf-redraw-only-dirty-regions]]) or a synchronous data fetch, the highlight lags the cursor and the map feels broken. Resolve the hovered cell with fast picking ([[interact-pick-with-gpu-color-id-or-spatial-index]]), draw the highlight on the cheap overlay layer, and load any rich tooltip data asynchronously — show a lightweight label immediately and fill in details when they arrive.
Incorrect (hover blocks on a fetch and repaints everything):
async function onHover(id: number) {
const details = await fetchDetails(id); // cursor outruns the highlight
redrawEverythingWithTooltip(id, details);
}Correct (highlight now on the overlay; details stream in after):
function onHover(id: number) {
drawHighlight(overlayCtx, id); // instant, one cheap layer
fetchDetails(id).then((d) => { if (hoveredId === id) showTooltip(d); });
}When NOT to apply:
- If all tooltip data is already in memory, render it synchronously — the async split only matters when details require I/O.
Reference: deck.gl — picking; MDN — Optimizing canvas
Model the Camera as View-State, Not DOM Scroll
A map's camera is a small piece of state — centre (projected x/y) and zoom — from which the world-to-screen transform, the visible cell set, and the deep link all derive. Driving the view from DOM scroll position or ad-hoc CSS transforms scatters the source of truth, so zoom level, loaded data, and the URL drift apart, and pinch or programmatic "fly to" become impossible. Keep one view-state object; every consumer reads from it, and the precision-to-zoom mapping ([[nav-precision-to-zoom-levels]]) is derived, never duplicated.
Incorrect (camera scattered across DOM scroll and CSS):
container.scrollTop; // pan lives here
mapEl.style.transform = `scale(${k})`; // zoom lives here; the URL knows neitherCorrect (one view-state; transform, cells, and URL all derive from it):
type ViewState = { x: number; y: number; zoom: number };
const transform = transformFromViewState(view); // world -> screen
const cells = coverBbox(boundsOf(view), precisionForZoom(view.zoom));
syncUrl(view); // see interact-sync-view-state-to-the-urlWhen NOT to apply:
- A non-interactive thumbnail with a fixed view needs no camera model — hardcode the transform.
Reference: deck.gl — views and view state; MapLibre GL JS
Pick With a GPU Color ID or Spatial Index, Not a Linear Scan
Finding which cell is under the cursor by looping over every cell and testing distance is O(n) per mouse move — at 100k cells that misses the frame budget and makes hover lag. Two scalable options: GPU colour picking (render each cell to an offscreen buffer in a unique colour ID, then read back the single pixel under the cursor — O(1)), or query a spatial index (the same geohash structure the data already has, [[qry-search-cell-plus-neighbors]]) for O(log n) lookup. deck.gl ships GPU picking for exactly this.
Incorrect (O(n) distance test on every mouse move):
function pick(mx: number, my: number) {
return cells.find((c) => dist(c.screenXY, [mx, my]) < c.radius); // 100k checks per move
}Correct (cells drawn once to an ID buffer; pick reads a single pixel):
function pick(mx: number, my: number) {
const px = new Uint8Array(4);
gl.readPixels(mx, my, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); // from the id framebuffer
return cellById(rgbaToId(px)); // O(1), independent of cell count
}When NOT to apply:
- A few hundred cells are fine to scan linearly — GPU picking adds an extra render pass that only pays off at scale.
Reference: deck.gl — picking; MDN — WebGL best practices
Drive the Camera and Selection from the Keyboard
Pan, zoom, and select are usually wired to mouse and wheel only, which makes the map unusable without a pointer and breaks power-user flow. Bind arrow keys to pan the camera, +/- to zoom around the focused point, and a roving focus that moves a selection cursor and keeps the focused cell in view by easing the camera to it ([[anim-ease-camera-transitions-not-jumps]]). This is the interaction-mechanics half of full keyboard operability ([[access-make-the-map-keyboard-operable]]).
Incorrect (navigation is pointer-only):
canvas.addEventListener("wheel", onZoom);
canvas.addEventListener("pointerdown", onDragStart); // no keyboard path at allCorrect (keys drive the same view-state the pointer does):
canvas.tabIndex = 0; // canvas can hold focus
canvas.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") panBy(STEP, 0);
else if (e.key === "+") zoomTo(view.zoom + 1, focusedCell);
else if (e.key === "Enter") select(focusedCell);
else return;
e.preventDefault(); // stop arrows scrolling the page
});When NOT to apply:
- A purely decorative, non-interactive map exposes no actions to the keyboard because it has none — but any map with hover or click needs this.
Reference: WCAG 2.2 — Keyboard (2.1.1); MapLibre GL JS (keyboard handler)
Sync View-State to the URL for Deep Links
A code map is most useful when a specific view — "Billing region, zoomed to the failing module" — can be linked in a PR or bookmarked. If the camera lives only in memory, every reload resets to the overview and there is no way to share what you are looking at. Serialise the view-state (and the selected geohash prefix, [[nav-breadcrumb-prefix-path]]) into the URL, throttled so panning does not spam history, and restore from it on load. The URL becomes the map's shareable address.
Incorrect (view in memory only):
let view = defaultView;
onViewChange((v) => { view = v; }); // nothing leaves memory; reload loses itCorrect (view round-trips through the URL; throttled writes, restore on load):
const writeUrl = throttle((v: ViewState) =>
history.replaceState(null, "", `#${v.zoom.toFixed(2)}/${v.x.toFixed(4)}/${v.y.toFixed(4)}`), 200);
onViewChange((v) => { view = v; writeUrl(v); });
view = parseHash(location.hash) ?? defaultView; // a deep link restores the exact viewWhen NOT to apply:
- An ephemeral embedded preview that should always open at the overview deliberately omits URL state.
Reference: MapLibre GL JS (hash option); MDN — History.replaceState
Bound the Tile and Geometry Cache
Lazy tile loading ([[nav-tile-lazy-loading]]) caches fetched cells so panning back is instant — but an unbounded cache grows for the whole session, and a long exploration eventually exhausts memory, triggering GC thrash or a tab crash. Cap the cache with an LRU keyed by geohash cell, evicting the least-recently-viewed tiles (and freeing their GPU buffers) once over a size or count budget. Bounded memory is what lets the map run for hours.
Incorrect (cache grows forever):
const cache = new Map<string, TileGeometry>();
function get(cell: string) { return cache.get(cell); } // never evicts -> OOM eventuallyCorrect (LRU cap; evicting a tile frees its GPU buffer too):
const cache = new LRU<string, TileGeometry>({
max: 512,
dispose: (geom) => geom.glBuffer.delete(), // release VRAM, not just the JS reference
});
function get(cell: string) { return cache.get(cell); } // touch marks recently usedWhen NOT to apply:
- If the entire dataset's geometry fits comfortably in memory and VRAM, caching it all and skipping eviction is simpler.
Reference: MDN — Optimizing canvas; MDN — Memory management
Debounce Viewport-to-Cell Recomputation
Deriving which geohash cells cover the viewport ([[qry-bbox-range-decomposition]]) is real work — bbox decomposition, cache lookups, possibly a fetch ([[nav-tile-lazy-loading]]). Doing it on every pan delta recomputes essentially the same set 60 times a second. Render the existing geometry every frame for smoothness, but throttle the covering-set recompute so it fires only when the viewport has moved a meaningful fraction of a cell, or on a short trailing debounce after motion settles.
Incorrect (full covering-set recompute every pan frame):
function onViewState(vs: ViewState) {
const cells = coverBbox(vs.bounds, precisionForZoom(vs.zoom)); // 60x/sec, near-identical
loadAndRender(cells, vs);
}Correct (draw every frame; recompute the set only when it can have changed):
const recompute = throttle((vs: ViewState) => {
visibleCells = coverBbox(vs.bounds, precisionForZoom(vs.zoom));
ensureLoaded(visibleCells);
}, 120);
function onViewState(vs: ViewState) { render(vs); recompute(vs); } // smooth pan, cheap setWhen NOT to apply:
- At very low cell counts the covering-set recompute is negligible, and debouncing only adds latency to the first paint.
Reference: MDN — Optimizing canvas; OSM Slippy Map
Offload Layout and Heavy Draw to a Worker
Projection math, trie aggregation ([[nav-level-of-detail-aggregation]]), and attribute packing for hundreds of thousands of cells can blow past 16 ms; doing them on the main thread freezes scrolling and input. Move that work to a Web Worker, transfer results as a typed-array buffer (zero-copy), and — where supported — render on an OffscreenCanvas inside the worker so even the draw stays off the main thread. The UI thread stays responsive while heavy work proceeds in parallel.
Incorrect (heavy projection on the main thread):
const packed = projectAndPack(cells); // blocks the UI thread for ~100ms
render(packed);Correct (worker does the heavy work; transfer the buffer zero-copy):
worker.postMessage({ cells });
worker.onmessage = (e) => render(e.data.packed); // ArrayBuffer transferred, not cloned
// inside the worker:
// const packed = projectAndPack(cells);
// postMessage({ packed }, [packed.buffer]); // transfer ownership, no copyWhen NOT to apply:
- Tiny datasets where the worker round-trip and serialisation cost more than the work they offload.
Reference: MDN — Web Workers; MDN — OffscreenCanvas
Redraw Only Dirty Layers and Regions
Most frames change very little — a hover highlight moves, one cell is selected — yet a naive renderer repaints every cell and label each frame. Track what actually changed and repaint only that: keep static cell geometry on its own layer that you redraw only when the data or camera changes, and redraw the cheap overlay layer ([[gpu-layer-canvas2d-over-webgl]]) for hover and selection. Damage tracking turns a full-scene repaint into a few-pixel update.
Incorrect (a hover repaints the whole scene):
function onHover(id: number) {
ctx.clearRect(0, 0, w, h);
drawCells(ctx, cells); // unchanged, but redrawn on every mouse move
drawHighlight(ctx, id);
}Correct (the cells layer is untouched; only the overlay repaints):
function onHover(id: number) {
overlayCtx.clearRect(0, 0, w, h); // cheap transparent layer
drawHighlight(overlayCtx, id); // cells layer left as-is
}When NOT to apply:
- While the camera is actively animating, the whole scene is dirty anyway — dirty tracking helps idle and micro-interaction frames, not a full zoom.
Reference: MDN — Optimizing canvas; deck.gl
Render in a Single requestAnimationFrame Loop
Redrawing directly inside every input event (mousemove, wheel, resize) can fire the renderer dozens of times per frame, doing work the screen never shows and starving the browser. Decouple input from drawing: events update state and request a single requestAnimationFrame; the rAF callback reads the latest state and draws once. This coalesces a burst of events into one redraw per frame and keeps the loop synced to the display's refresh.
Incorrect (redraw inside every event):
canvas.onmousemove = (e) => { updateHover(e); render(); }; // many draws per frame
canvas.onwheel = (e) => { updateZoom(e); render(); };Correct (events request one frame; render runs once per frame):
let frame = 0;
const schedule = () => { frame ||= requestAnimationFrame(() => { frame = 0; render(); }); };
canvas.onmousemove = (e) => { updateHover(e); schedule(); };
canvas.onwheel = (e) => { updateZoom(e); schedule(); }; // coalesced to one redrawWhen NOT to apply:
- A static map that redraws only on an explicit user action (not continuous interaction) can draw on demand without maintaining a loop.
Reference: MDN — requestAnimationFrame; MDN — Optimizing canvas
Add a Halo So Labels Stay Legible Over Busy Fills
A label drawn straight onto the map crosses many cell colours, and wherever the text colour matches the fill beneath it the glyphs vanish. A halo — a contrasting outline drawn behind the glyphs (stroke first, then fill) — guarantees a consistent contrast edge no matter what is underneath, the same trick every cartographic map uses for place names. It is the cheapest fix for "the label is there but I cannot read it" and complements basemap contrast control ([[color-control-contrast-against-basemap]]).
Incorrect (fill only):
ctx.fillStyle = "#111";
ctx.fillText(region.name, x, y); // dark text vanishes wherever it crosses a dark cellCorrect (light halo behind dark glyphs):
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,0.9)"; // halo drawn first, under the glyphs
ctx.strokeText(region.name, x, y);
ctx.fillStyle = "#111";
ctx.fillText(region.name, x, y); // consistent contrast over any fillWhen NOT to apply:
- Labels confined to a flat, uniform background (a side panel, not the map) need no halo — their contrast is already controlled.
Reference: MapLibre GL JS (text-halo); MDN — strokeText
Anchor Region Labels at the Visual Centroid
A region's geohash prefix covers an irregular set of cells ([[map-prefix-as-domain-region]]); anchoring its label at the bounding-box centre, or at the arithmetic mean of cell positions, can land the text in a gap or outside the region entirely for L- or U-shaped domains. Anchor at the visual centre — the pole of inaccessibility (the point furthest from any edge, what cartographers use for country labels), or at least the centroid of the largest contiguous part — so the name sits inside the shape it names.
Incorrect (bbox centre can fall in a hole):
const [cx, cy] = bboxCenter(region.cells);
ctx.fillText(region.name, cx, cy); // for an L-shaped region the text lands outside itCorrect (anchor at the point furthest inside the polygon):
const [cx, cy] = poleOfInaccessibility(region.polygon); // e.g. polylabel()
ctx.fillText(region.name, cx, cy); // always within the shape it namesWhen NOT to apply:
- Compact, convex regions where the centroid is already well inside — the pole-of-inaccessibility computation is overkill there.
Reference: Mapbox polylabel (pole of inaccessibility); MapLibre GL JS
Place and Declutter Labels Greedily by Priority
Every region wants a label, but at any zoom only a fraction fit without overlapping; drawing them all produces a pile where no single name is readable. Sort candidate labels by importance (region size, selection, search match), then place greedily — for each label in priority order, reserve its bounding box only if it does not collide with an already-placed box. Lower-priority labels that would overlap are dropped, not stacked. The result is the most important names, always legible.
Incorrect (a label per region):
for (const region of regions) ctx.fillText(region.name, region.cx, region.cy); // smearCorrect (place by priority; skip any that collide):
const placed: Box[] = [];
for (const region of [...regions].sort((a, b) => b.weight - a.weight)) {
const box = measure(ctx, region.name, region.cx, region.cy);
if (placed.some((p) => overlaps(p, box))) continue; // drop, do not stack
placed.push(box);
ctx.fillText(region.name, region.cx, region.cy);
}Gate which tier of labels is even a candidate by zoom first ([[text-show-labels-by-level-of-detail]]) so the collision pass has less to reject.
When NOT to apply:
- A sparse map whose labels never collide does not need declutter — measure first, add it when overlap actually appears.
Reference: MapLibre GL JS (symbol collision); Munzner, Visualization Analysis & Design
Render Label Text on Canvas2D, Not Per-Glyph GL Textures
Native Canvas2D fillText is hinted, kerned, and crisp at any pixel ratio, and the browser caches its glyph rasterisation. Re-implementing text in WebGL by uploading a texture per glyph or per label is slow (uploads stall the pipeline), blurry (no hinting), and a lot of code. Draw labels with fillText on the 2D overlay layer ([[gpu-layer-canvas2d-over-webgl]]); only reach for GL text via a signed-distance-field atlas when labels must rotate and scale with the camera in 3D, which a flat code map rarely needs.
Incorrect (rasterise each label to a texture and upload it):
for (const l of labels) {
const tex = uploadTexture(rasterizeLabel(l.name)); // pipeline stall per label, blurry
drawTexturedQuad(gl, tex, l.xy);
}Correct (native text on the 2D overlay):
overlayCtx.font = "12px Inter";
for (const l of labels) overlayCtx.fillText(l.name, l.x, l.y); // hinted, kerned, cachedWhen NOT to apply:
- A true 3D scene where labels billboard and scale with perspective needs SDF glyph atlases — the 2D overlay cannot track 3D depth.
Reference: MDN — Optimizing canvas; MapLibre GL JS (SDF glyphs)
Reveal Labels by Level of Detail
A code map has a label hierarchy that mirrors the geohash prefix hierarchy ([[map-prefix-as-domain-region]]): top-level domain names at low zoom, sub-domains as you zoom in, individual file names only when a cell is large on screen. Showing leaf labels when zoomed out floods the view; showing only domain labels when zoomed in starves it. Gate each label by the zoom range where its cell is big enough to read, reusing the precision-to-zoom mapping the navigation layer already computes ([[nav-precision-to-zoom-levels]], [[nav-level-of-detail-aggregation]]).
Incorrect (file labels at every zoom):
for (const f of files) ctx.fillText(f.name, f.cx, f.cy); // thousands of names when zoomed outCorrect (pick the label tier from zoom; only that tier draws):
const tier = labelTierForZoom(zoom); // "domain" | "module" | "file"
for (const node of nodes) {
if (node.tier !== tier) continue; // others stay hidden until their zoom
ctx.fillText(node.name, node.cx, node.cy);
}When NOT to apply:
- A small map with a single, naturally non-overlapping label tier does not need tiering.
Reference: OSM Slippy Map; MapLibre GL JS
Related skills
FAQ
What does code-map-visualization do?
code-map-visualization is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use code-map-visualization?
When you need to helps with ai & agent building tasks during ai-assisted development, or when code-map-visualization is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
code-map-visualization; AI & Agent Building; AI-coding skill.