// overlays.js — vector overlays (GeoJSON, graticule, measure path) and HTML markers for the active view import { linesFromGeoJSON, graticuleLines } from './overlay_geom.js'; import { api, el, toast } from './ui.js'; import { riverMinOrder, riverStats } from './riverlod.js'; export const COLORS = { coast: 0xf2efe6, lakes: 0x8fd0ff, rivers: 0x5ab4ff, plates: 0xff6a4d, graticule: 0x8a93a8, measure: 0x7fd4ff, regions: 0xffd166 }; export class Overlays { constructor(defs, markerBox, onChange = () => {}) { this.defs = defs; // [{id, name, file}] from /api/meta this.onChange = onChange; // lines changed (often after an await): the app must redraw this.box = markerBox; this.on = new Set(); this.cache = new Map(); // id → Promise (rivers: {all, big}) this.extra = new Map(); // always-shown line sets, e.g. the measure path this.markers = []; this.view = null; this.kmPerPx = null; // rivers: only orders that stay apart on screen at this scale (riverlod.js) this.riverOrder = null; this.cellKm = 10; // the build's cell spacing (setCellKm) this.grat = null; this.suppressed = new Set(); // enabled but hidden (coast/lakes under deep-zoom tiles) this.regionLines = []; // refined-region outlines (from /api/regions) } list() { return [...this.defs.map(({ id, name }) => ({ id, name })), { id: 'graticule', name: 'Graticule 30°' }, { id: 'regions', name: 'Refined regions' }, { id: 'vents', name: 'Vents (refined areas)' }, { id: 'pins', name: 'Pins' }]; } setDefs(defs) { // another era: its own coast, lakes, rivers… (loaded again when shown) this.defs = defs; this.cache.clear(); for (const id of this.#ids()) this.#push(id); } attach(view) { this.view = view; for (const id of this.#ids()) this.#push(id); this.#buildMarkers(); } setEnabled(ids) { const before = this.on; this.on = new Set(ids); for (const id of this.#ids()) if (before.has(id) !== this.on.has(id)) this.#push(id); this.updateMarkers(); } setCellKm(km) { this.cellKm = km; } setScale(kmPerPx) { // the map's scale changed: rivers may gain or lose an order this.kmPerPx = kmPerPx; const data = this.cache.get('rivers')?.ready; if (!data || !this.on.has('rivers')) return; if (riverMinOrder(kmPerPx, data.stats, this.cellKm) !== this.riverOrder) this.#push('rivers'); } setSuppressed(ids) { const next = new Set(ids); const changed = [...next, ...this.suppressed].filter(id => next.has(id) !== this.suppressed.has(id)); this.suppressed = next; for (const id of changed) this.#push(id); } setRegions(lines) { this.regionLines = lines; this.#push('regions'); } removeExtra(id) { if (!this.extra.delete(id)) return; this.view?.removeLines(id); this.onChange(); } setExtra(id, lines, color = COLORS.measure) { this.extra.set(id, { lines, color }); this.#push(id); } setMarkers(kind, list) { const old = this.markers.filter(m => m.kind === kind); if (old.length === list.length && old.every((m, i) => m.label === list[i].label && m.onClick === list[i].onClick && m.title === list[i].title && m.cls === list[i].cls)) { old.forEach((m, i) => { m.lat = list[i].lat; m.lon = list[i].lon; }); // same markers moved: keep the DOM return this.updateMarkers(); } this.markers = this.markers.filter(m => m.kind !== kind).concat(list.map(m => ({ ...m, kind }))); this.#buildMarkers(); } updateMarkers() { for (const m of this.markers) { const shown = (m.kind !== 'pin' || this.on.has('pins')) && (m.kind !== 'vent' || this.on.has('vents')); const p = this.view && shown ? this.view.toScreen(m.lat, m.lon) : null; m.el.style.display = p ? '' : 'none'; if (p) m.el.style.transform = `translate(${p.x}px, ${p.y}px) translate(-50%, -50%)`; } } #ids() { return [...this.defs.map(d => d.id), 'graticule', 'regions', ...this.extra.keys()]; } #buildMarkers() { for (const m of this.markers) { m.el ??= el('div', { class: `marker ${m.kind}${m.cls ? ` ${m.cls}` : ''}${m.onClick ? ' clickable' : ''}`, title: m.title, onclick: m.onClick }, el('div', { class: 'dot' }), m.label ? el('div', { class: 'label' }, m.label) : null); } this.box.replaceChildren(...this.markers.map(m => m.el)); this.updateMarkers(); } async #push(id) { const view = this.view; if (!view) return; if (this.extra.has(id)) view.setLines(id, this.extra.get(id).lines, this.extra.get(id).color); else if (!this.on.has(id) || this.suppressed.has(id)) view.removeLines(id); else if (id === 'graticule') view.setLines(id, (this.grat ??= graticuleLines(30)), COLORS.graticule); else if (id === 'regions') view.setLines(id, this.regionLines, COLORS.regions); else return this.#pushData(id, view); this.onChange(); } async #pushData(id, view) { try { const data = await this.#load(id); if (this.view !== view || !this.on.has(id) || this.suppressed.has(id)) return; // switched, toggled or hidden meanwhile let lines = data; if (id === 'rivers') { this.riverOrder = riverMinOrder(this.kmPerPx, data.stats, this.cellKm); lines = data.from(this.riverOrder); } view.setLines(id, lines, COLORS[id] ?? 0xffffff); this.onChange(); } catch (e) { toast(`Overlay “${id}”: ${e.message}`); } } #load(id) { if (!this.cache.has(id)) { const def = this.defs.find(d => d.id === id); const p = api(def.file).then(fc => (id === 'rivers' ? riverSets(fc) : linesFromGeoJSON(fc))); if (id === 'rivers') p.then(d => { p.ready = d; }, () => { }); p.catch(() => this.cache.delete(id)); this.cache.set(id, p); } return this.cache.get(id); } } function riverSets(fc) { // the rivers overlay: its statistics, and the lines of order ≥ k (built when first asked) const stats = riverStats(fc), sets = new Map(); return { stats, from: k => sets.get(k) ?? sets.set(k, linesFromGeoJSON(fc, q => (q.order ?? 1) >= k)).get(k) }; }