worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 6202 bytes

// 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) };
}