# 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: 1. **Top layer** — minimal country borders + capital/major city labels only. Muted, low-saturation. Always visible above the fog. 2. **Fog layer** — a dark, near-opaque canvas that obscures everything below. Erased by mouse drag. 3. **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` (or `main.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 `` sits between the two map divs (z-index between bottom and top maps). It must: - Match viewport dimensions exactly (`resize` observer on the container) - Be composited with `destination-out` for 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: ```js { 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: ```js 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-cursor` div) ### 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: `8000` meters - Range: `2000–40000` meters (slider in toolbar) - Brush cursor div sized to match projected pixel radius, updated on `mousemove` and on map `zoom` - 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.5px` borders, 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 `admin` and `place` layer groups, or - Use OpenFreeMap `liberty` style with all layers except admin/place removed via `map.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 ```js 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 ```js 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: transform` on the fog canvas ## Dev setup ```bash npm create vite@latest veilmap -- --template vanilla cd veilmap npm install maplibre-gl npm run dev ```