aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/overlays.js
blob: b4eef86ad25b6a0440979582537b19997896a4b3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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) };
}