6.8 KiB
Veilmap — Claude Code Instructions
Project overview
Veilmap is a fog-of-war map explorer. The user reveals the world by painting over a fog layer with their mouse. Three distinct layers stack in order:
- Top layer — minimal country borders + capital/major city labels only. Muted, low-saturation. Always visible above the fog.
- Fog layer — a dark, near-opaque canvas that obscures everything below. Erased by mouse drag.
- Bottom layer — a rich, high-contrast map (terrain, satellite, or detailed vector style). Only visible through holes in the fog.
Tech stack
- MapLibre GL JS (v4+) via npm
- Vite for dev server and bundling
- Vanilla JS/TS — no framework needed
- Single
index.html+main.js(ormain.ts) entry point
Architecture
Map setup
Use two MapLibre map instances stacked via position: absolute, sharing the same center/zoom state, synced on every move/zoom/pitch/bearing event:
mapBottom— full-detail style (e.g. MapTiler Outdoor, Stadia Alidade Satellite, or a high-contrast vector style)mapTop— minimal style showing only admin boundary and place label layers, with heavily desaturated/muted colours
Both maps sit in #map-bottom and #map-top divs respectively, stacked with z-index.
Fog canvas
A <canvas id="fog"> sits between the two map divs (z-index between bottom and top maps). It must:
- Match viewport dimensions exactly (
resizeobserver on the container) - Be composited with
destination-outfor erasing (use an offscreen canvas as the fog accumulator, then composite onto the visible canvas each frame)
Erase point storage — critical
Never store erase points as pixel coordinates. Store them as:
{ lngLat: [lng, lat], radiusMeters: number }
On every map move, zoom, rotate, or pitch event, re-project all stored points from LngLat → screen pixels using map.project(lngLat) and redraw the fog canvas from scratch. This is what makes erased areas stick to geography correctly.
Radius conversion
Convert radiusMeters to pixels at render time:
function metersToPixels(map, lngLat, meters) {
const p1 = map.project(lngLat);
const lat = lngLat[1] * Math.PI / 180;
const metersPerPixel = (40075016.686 * Math.cos(lat)) / (256 * Math.pow(2, map.getZoom()));
return meters / metersPerPixel;
}
Fog canvas render loop
clearRect full canvas
fillRect full canvas with fog colour (#060a18, opacity ~0.92)
set globalCompositeOperation = 'destination-out'
for each stored erase point:
project lngLat → pixel
draw radial gradient circle (full opacity centre → transparent edge)
set globalCompositeOperation = 'source-over'
Use requestAnimationFrame throttling — only re-render fog when the map has actually moved or a new point was added.
Interaction modes
Explore mode (default)
- Left-click drag erases fog
- Scroll wheel zooms
- Double-click zooms in
- Map panning is disabled
- Custom circular cursor (CSS
cursor: none+ positioned#brush-cursordiv)
Navigate mode
- Normal MapLibre panning/zooming
- Fog erase disabled
- Standard cursor
Toggle via a toolbar button. Keyboard shortcut: E for explore, N for navigate.
Brush
- Default radius:
8000meters - Range:
2000–40000meters (slider in toolbar) - Brush cursor div sized to match projected pixel radius, updated on
mousemoveand on mapzoom - On drag, interpolate intermediate erase points between last position and current position (every ~10px) to avoid gaps at fast mouse speeds
UI / Toolbar
Minimal floating toolbar, dark glass style (rgba(6,10,24,0.92) bg, backdrop-filter: blur(8px)), centred top of viewport.
Controls (left to right):
- Explore / Navigate toggle buttons
- Divider
- Brush size label + range slider
- Divider
- Reset button (clears all erase points, re-renders full fog)
- Divider
- + / − zoom buttons
Typography: use a characterful monospace or condensed font (e.g. JetBrains Mono, IBM Plex Mono, or Barlow Condensed) for the toolbar — it fits the cartographic/military aesthetic.
Aesthetic direction
- Dark, cartographic, slightly militaristic — think mission planning room
- Fog colour: deep navy-black (
#060a18), not pure black - Fog edge: soft radial gradient falloff, not a hard circle
- Top layer map style: greyscale or heavily muted — borders
rgba(200,215,255,0.25), labels barely visible - Bottom layer: maximum contrast — terrain greens, ocean blues, hillshade shadows, vivid road colours
- Toolbar: glass-morphism dark, thin
0.5pxborders, muted white text - Brush cursor: thin white circle outline, no fill,
opacity: 0.55 - No drop shadows, no gradients on UI elements — flat except the map itself
Map tile sources
Use one of these free/open options for the bottom layer (pick based on what's available without a paid key):
- MapTiler Basic / Outdoor — requires free MapTiler key (sign up at maptiler.com)
- Stadia Maps —
https://tiles.stadiamaps.com/styles/outdoors.json(free tier, requires key) - OpenFreeMap —
https://tiles.openfreemap.org/styles/bright(no key required, open source) - Protomaps — self-hostable PMTiles if going fully offline
For the top (minimal) layer, either:
- Filter an existing style down to only
adminandplacelayer groups, or - Use OpenFreeMap
libertystyle with all layers except admin/place removed viamap.setLayoutProperty(layerId, 'visibility', 'none')on load
File structure
veilmap/
├── index.html
├── main.js # or main.ts
├── fog.js # FogCanvas class — all canvas logic isolated here
├── sync.js # MapSync utility — keeps two MapLibre instances in sync
├── style.css
├── package.json
└── vite.config.js
FogCanvas class interface
class FogCanvas {
constructor(canvasEl, mapInstance)
addErasePoint(lngLat, radiusMeters) // stores point, triggers re-render
render() // full fog redraw, called on map move
reset() // clears all points, re-renders
setBrushRadius(meters)
resize() // call on container resize
}
MapSync utility
class MapSync {
constructor(primary, secondary) // primary drives, secondary follows
enable()
disable()
}
// Syncs: center, zoom, bearing, pitch
// Use map.jumpTo({ ...primary.getCamera() }) on the 'move' event of primary
// Wrap in a flag to prevent feedback loops
Performance notes
- Cap stored erase points at ~2000; if exceeded, rasterize accumulated fog to an offscreen canvas and clear the points array (bake-in approach)
- Debounce the fog re-render to one RAF per frame maximum
- Use
will-change: transformon the fog canvas
Dev setup
npm create vite@latest veilmap -- --template vanilla
cd veilmap
npm install maplibre-gl
npm run dev