diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
| commit | 3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch) | |
| tree | 4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/overlays.js | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/overlays.js')
| -rw-r--r-- | viewer/js/overlays.js | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/viewer/js/overlays.js b/viewer/js/overlays.js new file mode 100644 index 0000000..b4eef86 --- /dev/null +++ b/viewer/js/overlays.js @@ -0,0 +1,159 @@ +// 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<Line[]> (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) }; +} |
