updated map styling
This commit is contained in:
parent
5439173524
commit
2159c73131
30
.gitignore
vendored
30
.gitignore
vendored
@ -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
187
copilot-instructions.md
Normal 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: `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
|
||||
```
|
||||
109
fog.js
Normal file
109
fog.js
Normal 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
34
index.html
Normal 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
182
main.js
Normal 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
1158
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/javascript.svg
Normal file
1
src/assets/javascript.svg
Normal 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
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
9
src/counter.js
Normal 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
60
src/main.js
Normal 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
296
src/style.css
Normal 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
85
style.css
Normal 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
32
sync.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user