updated map styling
This commit is contained in:
parent
5439173524
commit
2159c73131
30
.gitignore
vendored
30
.gitignore
vendored
@ -1,8 +1,24 @@
|
|||||||
node_modules/
|
# Logs
|
||||||
_site/
|
logs
|
||||||
.env
|
*.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
|
.DS_Store
|
||||||
.idea/
|
*.suo
|
||||||
pass-deployment
|
*.ntvs*
|
||||||
deployment.json
|
*.njsproj
|
||||||
src/css/styles.min.css
|
*.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