aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/overlays.js
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
committergodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
commit3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch)
tree4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/overlays.js
downloadworldmap-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.js159
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) };
+}