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

6.8 KiB
Raw Blame History

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:

{ 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-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 Mapshttps://tiles.stadiamaps.com/styles/outdoors.json (free tier, requires key)
  • OpenFreeMaphttps://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

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: transform on the fog canvas

Dev setup

npm create vite@latest veilmap -- --template vanilla
cd veilmap
npm install maplibre-gl
npm run dev