updated map styling

This commit is contained in:
Nathan 2026-04-20 05:56:05 -04:00
parent 5439173524
commit 2159c73131
15 changed files with 2194 additions and 7 deletions

30
.gitignore vendored
View File

@ -1,8 +1,24 @@
node_modules/
_site/
.env
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
.idea/
pass-deployment
deployment.json
src/css/styles.min.css
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

187
copilot-instructions.md Normal file
View File

@ -0,0 +1,187 @@
# 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
```

109
fog.js Normal file
View File

@ -0,0 +1,109 @@
export class FogCanvas {
constructor(canvasEl, mapInstance) {
this.canvas = canvasEl;
this.ctx = canvasEl.getContext('2d');
this.map = mapInstance;
this.points = []; // { lngLat: [lng,lat], radiusMeters: number }
this.dirty = true;
this._raf = null;
this.resize();
}
addErasePoint(lngLat, radiusMeters) {
this.points.push({ lngLat, radiusMeters });
// Bake if over 2000 points
if (this.points.length > 2000) this._bake();
this.dirty = true;
this._scheduleRender();
}
render() {
const { ctx, canvas } = this;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
// If we have a baked image, draw it first then punch through it
if (this._bakedImg) {
ctx.drawImage(this._bakedImg, 0, 0, w, h);
} else {
ctx.fillStyle = 'rgba(180,185,195,0.88)';
ctx.fillRect(0, 0, w, h);
}
ctx.globalCompositeOperation = 'destination-out';
for (const pt of this.points) {
const px = this.map.project(pt.lngLat);
const r = metersToPixels(this.map, pt.lngLat, pt.radiusMeters);
if (r < 0.5) continue;
const grad = ctx.createRadialGradient(px.x, px.y, 0, px.x, px.y, r);
grad.addColorStop(0, 'rgba(0,0,0,1)');
grad.addColorStop(0.3, 'rgba(0,0,0,0.85)');
grad.addColorStop(0.55, 'rgba(0,0,0,0.5)');
grad.addColorStop(0.8, 'rgba(0,0,0,0.15)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(px.x, px.y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
this.dirty = false;
}
reset() {
this.points = [];
this._bakedImg = null;
this.dirty = true;
this.render();
}
setBrushRadius(meters) {
this._brushRadius = meters;
}
resize() {
const dpr = window.devicePixelRatio || 1;
const w = this.canvas.clientWidth;
const h = this.canvas.clientHeight;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.ctx.scale(dpr, dpr);
this.dirty = true;
this._scheduleRender();
}
markDirty() {
this.dirty = true;
this._scheduleRender();
}
_scheduleRender() {
if (this._raf) return;
this._raf = requestAnimationFrame(() => {
this._raf = null;
if (this.dirty) this.render();
});
}
_bake() {
// Rasterize current fog state to an offscreen canvas
const off = document.createElement('canvas');
off.width = this.canvas.width;
off.height = this.canvas.height;
const octx = off.getContext('2d');
octx.drawImage(this.canvas, 0, 0);
this._bakedImg = off;
this.points = [];
}
}
function metersToPixels(map, lngLat, meters) {
const lat = lngLat[1] * Math.PI / 180;
const metersPerPixel = (40075016.686 * Math.cos(lat)) / (256 * Math.pow(2, map.getZoom()));
return meters / metersPerPixel;
}
export { metersToPixels };

34
index.html Normal file
View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Veilmap</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="container">
<div id="map-bottom"></div>
<canvas id="fog"></canvas>
<div id="map-top"></div>
<div id="brush-cursor"></div>
</div>
<div id="toolbar">
<button id="btn-explore" class="tool-btn active">Explore</button>
<button id="btn-navigate" class="tool-btn">Navigate</button>
<span class="divider"></span>
<label class="tool-label">Brush</label>
<input type="range" id="brush-slider" min="2000" max="40000" value="8000" step="500" />
<span class="divider"></span>
<button id="btn-reset" class="tool-btn">Reset</button>
<span class="divider"></span>
<button id="btn-zoom-in" class="tool-btn">+</button>
<button id="btn-zoom-out" class="tool-btn"></button>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>

182
main.js Normal file
View File

@ -0,0 +1,182 @@
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { FogCanvas, metersToPixels } from './fog.js';
import { MapSync } from './sync.js';
const BOTTOM_STYLE = 'https://tiles.openfreemap.org/styles/bright';
const TOP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
let mode = 'explore'; // 'explore' | 'navigate'
let brushRadius = 8000;
// ── Maps ──
const mapBottom = new maplibregl.Map({
container: 'map-bottom',
style: BOTTOM_STYLE,
center: [0, 30],
zoom: 3,
attributionControl: false,
});
const mapTop = new maplibregl.Map({
container: 'map-top',
style: TOP_STYLE,
center: [0, 30],
zoom: 3,
attributionControl: false,
interactive: false, // will be toggled
});
// Make top map transparent background and mute layers
mapTop.on('load', () => {
const layers = mapTop.getStyle().layers;
for (const layer of layers) {
const id = layer.id.toLowerCase();
const src = (layer['source-layer'] || '').toLowerCase();
const isWater = id.includes('ocean') || id.includes('sea') || id.includes('water') ||
src.includes('water') || src.includes('ocean');
const isAdmin = id.includes('admin') || id.includes('boundar') || src.includes('admin') || src.includes('boundar');
const isPlace = id.includes('place') || id.includes('label') || id.includes('capital') ||
id.includes('city') || id.includes('country') || id.includes('continent') ||
src.includes('place');
if (!isWater && !isAdmin && !isPlace) {
mapTop.setLayoutProperty(layer.id, 'visibility', 'none');
} else if (isPlace && layer.type === 'symbol') {
// Only keep country labels and major/capital cities, hide small places
const isCountryOrCapital = id.includes('country') || id.includes('capital') ||
id.includes('continent') || id.includes('state');
if (!isCountryOrCapital) {
// Filter to only show cities with high rank (major cities)
try {
mapTop.setFilter(layer.id, ['<=', ['get', 'rank'], 3]);
} catch {}
}
try { mapTop.setPaintProperty(layer.id, 'text-color', 'rgba(200,215,255,0.45)'); } catch {}
try { mapTop.setPaintProperty(layer.id, 'text-halo-color', 'rgba(6,10,24,0.7)'); } catch {}
} else if (isAdmin && layer.type === 'line') {
try { mapTop.setPaintProperty(layer.id, 'line-color', 'rgba(200,215,255,0.25)'); } catch {}
try { mapTop.setPaintProperty(layer.id, 'line-opacity', 0.4); } catch {}
} else if (isWater) {
// Mute water to a subtle blue-grey
if (layer.type === 'fill') {
try { mapTop.setPaintProperty(layer.id, 'fill-color', 'rgba(140,160,185,0.3)'); } catch {}
}
}
}
});
// Sync maps
const sync = new MapSync(mapTop, mapBottom);
mapTop.on('load', () => { mapBottom.on('load', () => sync.enable()); });
// ── Fog ──
const fogCanvas = new FogCanvas(document.getElementById('fog'), mapTop);
// Re-render fog on map move
mapTop.on('move', () => fogCanvas.markDirty());
mapBottom.on('load', () => {
// initial render once both ready
fogCanvas.render();
});
// Resize
const ro = new ResizeObserver(() => fogCanvas.resize());
ro.observe(document.getElementById('container'));
// ── Brush cursor ──
const cursor = document.getElementById('brush-cursor');
function updateCursorSize(e) {
const r = metersToPixels(mapTop, [e.lngLat.lng, e.lngLat.lat], brushRadius);
const d = r * 2;
cursor.style.width = d + 'px';
cursor.style.height = d + 'px';
cursor.style.left = e.point.x + 'px';
cursor.style.top = e.point.y + 'px';
}
// ── Interaction ──
let dragging = false;
let lastPt = null;
function setMode(m) {
mode = m;
document.getElementById('btn-explore').classList.toggle('active', m === 'explore');
document.getElementById('btn-navigate').classList.toggle('active', m === 'navigate');
if (m === 'explore') {
mapTop.dragPan.disable();
mapTop.scrollZoom.enable();
mapTop.doubleClickZoom.enable();
document.getElementById('map-top').style.cursor = 'none';
cursor.style.display = 'block';
} else {
mapTop.dragPan.enable();
mapTop.scrollZoom.enable();
mapTop.doubleClickZoom.enable();
document.getElementById('map-top').style.cursor = '';
cursor.style.display = 'none';
}
}
mapTop.on('load', () => setMode('explore'));
mapTop.on('mousedown', (e) => {
if (mode !== 'explore') return;
dragging = true;
lastPt = e.point;
addPoint(e);
});
mapTop.on('mousemove', (e) => {
if (mode === 'explore') updateCursorSize(e);
if (!dragging || mode !== 'explore') return;
// Interpolate between last and current to avoid gaps
const dx = e.point.x - lastPt.x;
const dy = e.point.y - lastPt.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const step = 10;
if (dist > step) {
const steps = Math.ceil(dist / step);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const px = lastPt.x + dx * t;
const py = lastPt.y + dy * t;
const ll = mapTop.unproject([px, py]);
fogCanvas.addErasePoint([ll.lng, ll.lat], brushRadius);
}
} else {
addPoint(e);
}
lastPt = e.point;
});
mapTop.on('mouseup', () => { dragging = false; lastPt = null; });
mapTop.on('mouseleave', () => { dragging = false; lastPt = null; cursor.style.display = 'none'; });
mapTop.on('mouseenter', () => { if (mode === 'explore') cursor.style.display = 'block'; });
function addPoint(e) {
fogCanvas.addErasePoint([e.lngLat.lng, e.lngLat.lat], brushRadius);
}
// ── Toolbar ──
document.getElementById('btn-explore').addEventListener('click', () => setMode('explore'));
document.getElementById('btn-navigate').addEventListener('click', () => setMode('navigate'));
document.getElementById('btn-reset').addEventListener('click', () => fogCanvas.reset());
document.getElementById('btn-zoom-in').addEventListener('click', () => mapTop.zoomIn());
document.getElementById('btn-zoom-out').addEventListener('click', () => mapTop.zoomOut());
document.getElementById('brush-slider').addEventListener('input', (e) => {
brushRadius = Number(e.target.value);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'e' || e.key === 'E') setMode('explore');
if (e.key === 'n' || e.key === 'N') setMode('navigate');
});

1158
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "veilmap",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^8.0.4"
},
"dependencies": {
"maplibre-gl": "^5.23.0"
}
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"/><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"/></svg>

After

Width:  |  Height:  |  Size: 863 B

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

9
src/counter.js Normal file
View File

@ -0,0 +1,9 @@
export function setupCounter(element) {
let counter = 0
const setCounter = (count) => {
counter = count
element.innerHTML = `Count is ${counter}`
}
element.addEventListener('click', () => setCounter(counter + 1))
setCounter(0)
}

60
src/main.js Normal file
View File

@ -0,0 +1,60 @@
import './style.css'
import javascriptLogo from './assets/javascript.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import { setupCounter } from './counter.js'
document.querySelector('#app').innerHTML = `
<section id="center">
<div class="hero">
<img src="${heroImg}" class="base" width="170" height="179">
<img src="${javascriptLogo}" class="framework" alt="JavaScript logo"/>
<img src=${viteLogo} class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/main.js</code> and save to test <code>HMR</code></p>
</div>
<button id="counter" type="button" class="counter"></button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" src=${viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript" target="_blank">
<img class="button-icon" src="${javascriptLogo}" alt="">
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
`
setupCounter(document.querySelector('#counter'))

296
src/style.css Normal file
View File

@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

85
style.css Normal file
View File

@ -0,0 +1,85 @@
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #060a18;
font-family: 'JetBrains Mono', monospace;
}
#container {
position: relative;
width: 100%; height: 100%;
}
#map-bottom, #map-top, #fog {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
}
#map-bottom { z-index: 1; }
#fog { z-index: 2; pointer-events: none; will-change: transform; }
#map-top { z-index: 3; }
#brush-cursor {
position: fixed;
border: 1.5px solid rgba(255,255,255,0.55);
border-radius: 50%;
pointer-events: none;
z-index: 10;
transform: translate(-50%, -50%);
display: none;
}
/* Toolbar */
#toolbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: rgba(6,10,24,0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 0.5px solid rgba(200,215,255,0.15);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: rgba(220,230,255,0.8);
}
.tool-btn {
background: transparent;
border: 0.5px solid rgba(200,215,255,0.2);
color: rgba(220,230,255,0.7);
padding: 4px 10px;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tool-btn:hover { background: rgba(200,215,255,0.08); color: #fff; }
.tool-btn.active { background: rgba(200,215,255,0.14); color: #fff; }
.divider {
width: 1px; height: 18px;
background: rgba(200,215,255,0.15);
}
.tool-label { font-size: 11px; opacity: 0.6; }
#brush-slider {
width: 90px;
accent-color: rgba(200,215,255,0.5);
}
/* Hide maplibre controls on top map so they don't double up */
#map-top .maplibregl-control-container { display: none; }
#map-bottom .maplibregl-control-container { display: none; }

32
sync.js Normal file
View File

@ -0,0 +1,32 @@
export class MapSync {
constructor(primary, secondary) {
this.primary = primary;
this.secondary = secondary;
this._syncing = false;
this._onMove = () => {
if (this._syncing) return;
this._syncing = true;
this.secondary.jumpTo({
center: this.primary.getCenter(),
zoom: this.primary.getZoom(),
bearing: this.primary.getBearing(),
pitch: this.primary.getPitch(),
});
this._syncing = false;
};
this._enabled = false;
}
enable() {
if (this._enabled) return;
this._enabled = true;
this.primary.on('move', this._onMove);
this._onMove();
}
disable() {
this._enabled = false;
this.primary.off('move', this._onMove);
}
}