veilmap/copilot-instructions.md
2026-04-20 05:56:05 -04:00

188 lines
6.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<canvas id="fog">` 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: `200040000` 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
```