188 lines
6.8 KiB
Markdown
188 lines
6.8 KiB
Markdown
# 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: `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
|
||
```
|