File size: 27,191 Bytes
5b7eb93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
// The map, v2: MapLibre GL (CARTO Positron vector basemap, self-hosted library), every layer native. MapLibre's line and
// circle shaders antialias their own edges, so thin lines stay crisp at DPR 1 without MSAA (deck.gl's paths relied on
// MSAA and looked worse on a GT 710). Lines: a neutral grey base under every line (3:1 on Positron), data lines on the
// Vicomtech cyan ramp floored at a visible step (2:1), styled from their properties, overloaded lines red over a soft
// blurred glow, outages dashed. Each is a GeoJSON source holding only its own lines, so styles are computed in the
// worker when the data change; no feature-state (re-applying it on every tile switch stalled zooming by ~120 ms).
// Points, the storm track and the heat field are sources of their own. The field, the network and the generators sit under the basemap's labels, the markers above.

import { anomalyField, fieldAnomaly } from "./weather.js";

// the right arrow (U+2192) as an inline SVG: Mulish has none in any weight, and the fallback font's arrow sits low and
// small. This one takes the text's colour and centres on the digits' height (.arr in styles_v2.css), on every OS
export const ARROW = '<svg class="arr" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 8h10.5M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';

const STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";
// with no basemap (CARTO unreachable) the grid still draws, on blank
const BLANK_STYLE = { version: 8, sources: {}, layers: [] };

const hex = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
export const rgb = (a, alpha = 1) => `rgba(${a[0]}, ${a[1]}, ${a[2]}, ${alpha})`;
export const INK = "#231F20", CRITICAL = "#D03B3B", SERIOUS = "#EC835A", YELLOW = "#FFE600";
// "Nothing happens" is a visible neutral grey: 3.0:1 on Positron's land (#FAFAF8), WCAG 1.4.11 for graphics. The data
// ramps start at a visible step (light end 2.2:1 for cyan, 2.4:1 for the heat dots); both pass the dataviz validator's
// ordinal checks (monotone lightness, step gaps, light-end contrast, one hue).
export const BASE = "#8A9398";
export const LOAD_STOPS = [[0, "#45bad6"], [50, "#1399b3"], [72, "#018299"], [86, "#076c7f"], [94, "#045766"], [100, "#023742"]];
export const HEAT_STOPS = [[0, "#FFE600"], [2.5, "#ECB427"], [5, "#CD8813"], [7.5, "#A95F05"], [10, "#7F4407"]];   // the heat field
export const DOT_STOPS = [[0, "#D9971A"], [5, "#A95F05"], [10, "#7F4407"]];                                     // ΔT and load dots
export const OVER = 1.01;
export const BAND_LIMIT = 0.99;          // a line "may be at its limit" when its 90% band reaches 99% (docs/trust_plan.md)
// bottlenecks: the share of the what-ifs (exact optima, assets/data/insights.json) with a line at its limit, in %; the
// brand heat scale without its lightest yellows, which vanish on the basemap as thin marks
export const BOTT_STOPS = [[0, "#F2A900"], [33, "#C77B04"], [66, "#9A5604"], [100, "#5E3100"]];
// a branch's tooltip on the bottleneck view: its share of S.bottLabel's what-ifs at the limit (S.bott[j])
const bottTip = (j, kv, S, xfo = false) => {
  const s = S.bott && S.bott[j] > 0 ? S.bott[j] : 0;
  return `<b>${xfo ? "Transformer" : "Line"} ${j}</b> · ${kv}<br>`
    + (s ? `at its limit in ${(s * 100).toFixed(s < 0.1 ? 1 : 0)}% of ${S.bottLabel || "the what-ifs"}` : `never at its limit in ${S.bottLabel || "these what-ifs"}`);
};
// what counts as a change: the domes' field has long tails (0.1-0.5 degC hundreds of km out), which the floored ramps
// would paint as loudly as real changes. Under 0.5 degC (+1.25% load, a 0.6% rating cut) is "no change".
export const DT_MIN = 0.5;
const LOAD_W = [[0, 1.0], [0.4, 1.0], [0.6, 1.25], [0.75, 1.5], [0.85, 1.8], [0.93, 2.1], [1.0, 2.5]];
// generators: opaque discs of ink at about 25% on Positron, drawn under the lines. Opaque, overlapping sites merge
// instead of stacking into dark blobs (Houston has 20), and the lines stay on top.
export const GEN_FILL = "#C8C7C6";
const GROUPS = [["wind", "#00A7C4"], ["solar", "#00A7C4"], ["ng", "#CD8813"], ["coal", "#CD8813"], ["hydro", "#B2B2B2"], ["nuclear", "#B2B2B2"], ["unknown", "#B2B2B2"]];
const GROUP_COLOUR = Object.fromEntries(GROUPS.map(([f, c]) => [f, hex(c)]));
const FUEL_NAME = { ng: "gas", coal: "coal", nuclear: "nuclear", wind: "wind", solar: "solar", hydro: "hydro", unknown: "other" };
const EMPTY = { type: "FeatureCollection", features: [] };
const POINT_LAYERS = ["domes", "scan", "xfo", "bott", "vout", "cap", "load", "dt", "gens"];       // tooltip priority, top first
export const SHORT_KM = 0.5;                                      // a branch whose ends are closer: a transformer, drawn as a point
const km = (la1, lo1, la2, lo2) => {                              // great-circle distance (haversine), km
  const r = Math.PI / 180, a = Math.sin((la2 - la1) * r / 2) ** 2 + Math.cos(la1 * r) * Math.cos(la2 * r) * Math.sin((lo2 - lo1) * r / 2) ** 2;
  return 12742 * Math.asin(Math.sqrt(a));
};
// the branches too short to be seen as lines (transformers between co-located buses): overloaded, they get a marker
export function shortBranches(G) {
  const B = G.bus, R = G.branch, out = new Set();
  for (let j = 0; j < G.nBranch; j++) if (km(B.lat[R.from[j]], B.lon[R.from[j]], B.lat[R.to[j]], B.lon[R.to[j]]) < SHORT_KM) out.add(j);
  return out;
}

function interp(stops, x) {
  if (!(x > stops[0][0])) return stops[0][1];
  for (let i = 1; i < stops.length; i++) if (x <= stops[i][0]) {
    const [x0, y0] = stops[i - 1], [x1, y1] = stops[i], w = (x - x0) / (x1 - x0);
    return y0 + w * (y1 - y0);
  }
  return stops[stops.length - 1][1];
}
export function ramp(stops, x) {                                          // colour stops [[x, "#hex"], ...] -> [r, g, b]
  const cs = stops.map(([v, c]) => [v, hex(c)]);
  if (!(x > cs[0][0])) return cs[0][1];
  for (let i = 1; i < cs.length; i++) if (x <= cs[i][0]) {
    const w = (x - cs[i - 1][0]) / (cs[i][0] - cs[i - 1][0]);
    return cs[i][1].map((v, k) => Math.round(cs[i - 1][1][k] + w * (v - cs[i - 1][1][k])));
  }
  return cs[cs.length - 1][1];
}
export const cssRamp = (stops, unit = 1) => stops.map(([v, c]) => `${c} ${(v / stops[stops.length - 1][0]) * 100 * unit}%`).join(", ");

const mercY = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2));
// the heat anomaly (domes plus any real-weather field) as an image whose rows are uniform in Web Mercator y (so it sits
// right on the map): transparent at 0 degC, rising through yellow to brown on the heat scale
function heatImage(domes, weather, box, cap, alphaMax, n = 180) {
  const [lat0, lat1, lon0, lon1] = box, y0 = mercY(lat1), y1 = mercY(lat0);
  const lat = new Float64Array(n * n), lon = new Float64Array(n * n);
  for (let r = 0; r < n; r++) {
    const la = (2 * Math.atan(Math.exp(y0 + (y1 - y0) * r / (n - 1))) - Math.PI / 2) * 180 / Math.PI;
    for (let c = 0; c < n; c++) { lat[r * n + c] = la; lon[r * n + c] = lon0 + (lon1 - lon0) * c / (n - 1); }
  }
  const dT = anomalyField(domes, lat, lon, cap);
  let fade = null;
  if (weather && weather.length) {
    const f = fieldAnomaly(weather, lat, lon);
    for (let i = 0; i < dT.length; i++) dT[i] = Math.min(dT[i] + f[i], cap);
    // no data far from the points (Mexico, the Gulf): fade the field out between 60 and 150 km from the nearest point
    fade = new Float64Array(dT.length);
    const R = Math.PI / 180;
    for (let i = 0; i < dT.length; i++) {
      let dmin = Infinity;
      const la = lat[i] * R, cl = Math.cos(la);
      for (const p of weather) {
        const s1 = Math.sin((p[0] * R - la) / 2), s2 = Math.sin((p[1] - lon[i]) * R / 2);
        dmin = Math.min(dmin, 2 * 6371 * Math.asin(Math.sqrt(Math.min(1, s1 * s1 + cl * Math.cos(p[0] * R) * s2 * s2))));
      }
      fade[i] = Math.min(1, Math.max(0, (150 - dmin) / 90));
    }
  }
  const cv = document.createElement("canvas");
  cv.width = n; cv.height = n;
  const ctx = cv.getContext("2d"), img = ctx.createImageData(n, n);
  for (let i = 0; i < n * n; i++) {
    const [r, g, b] = ramp(HEAT_STOPS, dT[i]);
    img.data[4 * i] = r; img.data[4 * i + 1] = g; img.data[4 * i + 2] = b;
    img.data[4 * i + 3] = Math.round(alphaMax * 255 * Math.pow(Math.min(Math.max(dT[i] / 10, 0), 1), 0.75) * (fade ? fade[i] : 1));
  }
  ctx.putImageData(img, 0, 0);
  return cv;
}

const pts = (list) => ({ type: "FeatureCollection", features: list.map(({ p, ...props }) => ({ type: "Feature", geometry: { type: "Point", coordinates: p }, properties: props })) });

export class GridMap {
  constructor(el, G) {
    this.G = G;
    el.gridMap = this;                       // the tests' read-only view: the last points drawn (this.points)
    const B = G.bus, R = G.branch, b = G.box;
    this.lines = [];
    for (let j = 0; j < G.nBranch; j++) if (R.status[j] === 1)
      this.lines.push({ j, path: [[B.lon[R.from[j]], B.lat[R.from[j]]], [B.lon[R.to[j]], B.lat[R.to[j]]]] });
    this.feat = (d, props = {}) => ({ type: "Feature", properties: { j: d.j, ...props }, geometry: { type: "LineString", coordinates: d.path } });
    this.short = shortBranches(G);
    this.box = [b.lat0 - 5, b.lat1 + 5, b.lon0 - 6, b.lon1 + 6];
    this.heatKey = null; this.lineKey = null; this.outKey = null; this.hoverJ = null;
    this.map = new window.maplibregl.Map({
      container: el, style: STYLE, bounds: [[b.lon0, b.lat0], [b.lon1, b.lat1]], fitBoundsOptions: { padding: 24 },
      attributionControl: { compact: true }, dragRotate: false, pitchWithRotate: false, renderWorldCopies: false,
      cooperativeGestures: true,             // the wheel and one finger scroll the page; ctrl + wheel and two fingers move the map
    });
    this.map.touchZoomRotate.disableRotation();
    // the basemap's style failed to load (offline, blocked): `load` would never fire, so fall back to a blank style
    let blank = false;
    this.map.on("error", (e) => { if (!blank && !e.sourceId && !e.tile && !this.map.isStyleLoaded()) { blank = true; this.map.setStyle(BLANK_STYLE); } });
    this.tip = Object.assign(document.createElement("div"), { className: "map-tip", hidden: true });
    el.appendChild(this.tip);
    this.ready = new Promise((res) => this.map.on("load", () => { this.map.resize(); this.fit(); this.setup(); res(); }));
    this.scene = null;
  }

  fit() { const b = this.G.box; this.map.fitBounds([[b.lon0, b.lat0], [b.lon1, b.lat1]], { padding: 24, duration: 0 }); }

  setup() {
    const m = this.map, before = (m.getStyle().layers.find((l) => l.type === "symbol") || {}).id;   // under the labels
    const [lat0, lat1, lon0, lon1] = this.box;
    const blank = document.createElement("canvas"); blank.width = blank.height = 1;
    m.addSource("heat", { type: "image", url: blank.toDataURL(), coordinates: [[lon0, lat1], [lon1, lat1], [lon1, lat0], [lon0, lat0]] });
    // maxzoom 8: tiled up to z8 (about 20 m precision) and overzoomed beyond, so zooming in does not re-tile the grid
    for (const id of ["base", "lines", "uncert", "over", "out", "hover", "track", ...POINT_LAYERS, "cap0"])
      m.addSource(id, { type: "geojson", data: id === "base" ? { type: "FeatureCollection", features: this.lines.map((d) => this.feat(d)) } : EMPTY, tolerance: 0, maxzoom: 8 });
    const add = (spec) => m.addLayer(spec, before);              // the field, the network and the generators: under the labels
    const top = (spec) => m.addLayer(spec);                       // markers: above them
    add({ id: "heat", type: "raster", source: "heat", paint: { "raster-opacity": 1, "raster-fade-duration": 0, "raster-resampling": "linear" } });
    add({ id: "gens", type: "circle", source: "gens", paint: { "circle-color": GEN_FILL, "circle-radius": ["get", "r"], "circle-blur": 0.08 } });
    const round = { "line-cap": "round", "line-join": "round" };
    add({ id: "line-base", type: "line", source: "base", layout: round, paint: { "line-color": BASE, "line-width": 1 } });
    add({ id: "line-glow", type: "line", source: "over", layout: round, paint: { "line-color": "rgba(208, 59, 59, 0.30)", "line-width": 10, "line-blur": 4 } });
    add({ id: "lines", type: "line", source: "lines", layout: round, paint: { "line-color": ["get", "c"], "line-width": ["get", "w"] } });
    // lines whose 90% band of the optimal loading (gridfm-trustlayer) reaches 99%: dashed amber over the data line
    add({ id: "line-uncert", type: "line", source: "uncert", layout: { "line-cap": "butt", "line-join": "round" },
          paint: { "line-color": SERIOUS, "line-width": 2.4, "line-dasharray": [1.4, 1.1] } });
    add({ id: "line-over", type: "line", source: "over", layout: round, paint: { "line-color": CRITICAL, "line-width": 3 } });
    add({ id: "line-hover", type: "line", source: "hover", layout: round, paint: { "line-color": "rgba(0, 167, 196, 0.45)", "line-width": 5 } });
    // outages: dashed ink with butt caps (rounded caps would fill the gaps), the same for a heat wave and a hurricane: red is for overloaded
    add({ id: "line-out", type: "line", source: "out", layout: { "line-cap": "butt", "line-join": "round" },
          paint: { "line-color": rgb(hex(INK), 0.9), "line-width": 2.2, "line-dasharray": [2.5, 1.8] } });
    top({ id: "dt", type: "circle", source: "dt", paint: { "circle-color": ["get", "c"], "circle-opacity": 0.92, "circle-radius": 3 } });
    top({ id: "load", type: "circle", source: "load", paint: { "circle-color": ["get", "c"], "circle-opacity": 0.86, "circle-radius": ["get", "r"],
          "circle-stroke-color": "rgba(255, 255, 255, 0.8)", "circle-stroke-width": 0.5 } });
    top({ id: "cap0", type: "circle", source: "cap0", paint: { "circle-color": ["get", "c"], "circle-opacity": 0.18, "circle-radius": ["get", "r0"] } });
    top({ id: "cap", type: "circle", source: "cap", paint: { "circle-color": ["get", "c"], "circle-opacity": 0.88, "circle-radius": ["get", "r"],
          "circle-stroke-color": ["case", [">", ["get", "out"], 0], CRITICAL, "rgba(255, 255, 255, 0.7)"], "circle-stroke-width": ["case", [">", ["get", "out"], 0], 2, 0.5] } });
    // overloaded transformers: a small red marker in the overload red, a soft halo under it
    top({ id: "xfo-glow", type: "circle", source: "xfo", paint: { "circle-color": "rgba(208, 59, 59, 0.28)", "circle-radius": 10, "circle-blur": 0.6 } });
    top({ id: "xfo", type: "circle", source: "xfo", paint: { "circle-color": CRITICAL, "circle-radius": 4.5, "circle-stroke-color": "#FFFFFF", "circle-stroke-width": 1.2 } });
    top({ id: "vout", type: "circle", source: "vout", paint: { "circle-color": "#FFFFFF", "circle-radius": 4.5, "circle-stroke-color": SERIOUS, "circle-stroke-width": 2.5 } });
    // bottlenecks: a transformer has both ends at one place, so it is a ring sized and coloured by its share
    top({ id: "bott", type: "circle", source: "bott", paint: { "circle-color": "#FFFFFF", "circle-radius": ["get", "r"],
          "circle-stroke-color": ["get", "c"], "circle-stroke-width": ["get", "sw"] } });
    top({ id: "track", type: "line", source: "track", layout: round, paint: { "line-color": "rgba(99, 108, 114, 0.86)", "line-width": 2.5 } });
    // a live study's what-ifs (the dome scan): one disc per place, coloured by the outcome the page computed
    top({ id: "scan", type: "circle", source: "scan", paint: { "circle-color": ["get", "c"], "circle-radius": ["get", "r"], "circle-opacity": 0.92,
          "circle-stroke-color": ["get", "sc"], "circle-stroke-width": ["get", "sw"] } });
    top({ id: "domes", type: "circle", source: "domes", paint: { "circle-color": YELLOW, "circle-radius": 6.5, "circle-stroke-color": INK, "circle-stroke-width": 2 } });
    // tooltips: what is rendered within a few px of the pointer; points first, then lines
    const pick = (e) => {
      const r = 5, bb = [[e.point.x - r, e.point.y - r], [e.point.x + r, e.point.y + r]];
      const f = m.queryRenderedFeatures(bb, { layers: [...POINT_LAYERS, "line-out", "line-over", "line-base", "lines"] });
      return f.find((x) => POINT_LAYERS.includes(x.layer.id)) || f[0] || null;
    };
    m.on("mousemove", (e) => this.hover(pick(e), e.point));
    m.on("click", (e) => this.hover(pick(e), e.point));
    m.on("mouseout", () => this.hover(null));
    m.on("movestart", () => { this.tip.hidden = true; });
  }

  hover(f, pt) {
    const html = f ? this.tooltip(f) : null, j = f && f.layer.type === "line" && f.properties.j !== undefined ? f.properties.j : null;
    if (j !== this.hoverJ) {
      this.hoverJ = j;
      const d = j == null ? null : this.lines.find((x) => x.j === j);
      this.map.getSource("hover").setData({ type: "FeatureCollection", features: d ? [this.feat(d)] : [] });
    }
    this.map.getCanvas().style.cursor = html ? "pointer" : "";
    if (!html) { this.tip.hidden = true; return; }
    this.tip.innerHTML = html;
    this.tip.hidden = false;
    const box = this.tip.parentElement, w = this.tip.offsetWidth, h = this.tip.offsetHeight;
    const x = pt.x + 14 + w > box.clientWidth ? Math.max(4, pt.x - 14 - w) : pt.x + 14;
    const y = Math.min(Math.max(4, pt.y + 14), box.clientHeight - h - 4);
    this.tip.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
  }

  tooltip(f) {
    const S = this.scene, G = this.G;
    if (!S) return null;
    if (f.properties.tip) return f.properties.tip;
    const j = f.properties.j;
    if (j === undefined) return null;
    const kv = `${G.bus.kv[G.branch.from[j]].toFixed(0)}/${G.bus.kv[G.branch.to[j]].toFixed(0)} kV`, rate = S.rate ? S.rate[j] : G.branch.rate[j];
    if (S.out && S.out.has(j)) return `<b>Line ${j}</b> · ${kv}<br>out of service`;
    if (S.view === "bottleneck") return bottTip(j, kv, S);
    if (S.view === "loading" && S.loading && Number.isFinite(S.loading[j])) {
      const band = S.bands ? `<br>${S.bandLabel || "90% band"} at the optimum: ${Math.max(0, S.bands.lo[j] * 100).toFixed(0)}-${(S.bands.hi[j] * 100).toFixed(0)}%` : "";
      return `<b>Line ${j}</b> · ${kv}<br>loading ${(S.loading[j] * 100).toFixed(0)}% of ${rate.toFixed(0)} MVA${band}`;
    }
    return `<b>Line ${j}</b> · ${kv}<br>rating ${rate.toFixed(0)} MVA${S.A && S.A.rate_factor[j] < 0.995 ? ` (−${((1 - S.A.rate_factor[j]) * 100).toFixed(1)}% from ${G.branch.rate[j].toFixed(0)}, the heat)` : ""}`;
  }

  // scene: {view: "loading" | "dT" | "load" | "capacity" | "bottleneck", A (weather arrays), loading, rate, pg, vOut, domes,
  //         bott ({branch: share}), bottLabel, scan ([{p, c, r, sc, sw, tip}]: a live study's places), bandLabel,
  //         showGen, out (Set of branch indices out), track, dark (a hurricane's damaged-zone buses)}
  render(scene) {
    this.scene = scene;
    return this.ready.then(() => { if (this.scene === scene) this.apply(scene); });
  }

  apply(S) {
    const m = this.map, G = this.G, B = G.bus, out = S.out || new Set(), outKey = [...out].sort((a, b) => a - b).join(",");
    // the heat field: an image whose rows are uniform in Web Mercator y, so it sits right on the map
    const wx = S.weather || [], hasHeat = !!((S.domes && S.domes.length) || wx.length), alpha = S.view === "dT" ? 0.55 : 0.28;
    const hk = hasHeat ? JSON.stringify([S.domes, wx, alpha]) : "";
    if (hk !== this.heatKey) {
      this.heatKey = hk;
      const cv = hasHeat ? heatImage(S.domes || [], wx, this.box, G.C.CAP_C, alpha, wx.length ? 120 : 180)
        : Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
      const [lat0, lat1, lon0, lon1] = this.box;
      m.getSource("heat").updateImage({ url: cv.toDataURL(), coordinates: [[lon0, lat1], [lon1, lat1], [lon1, lat0], [lon0, lat0]] });
    }
    // lines: the data lines (colour and width from their properties), the overloaded ones and the outages, each a source
    // of its own; rebuilt only when what they show has changed
    const ld = S.loading, lk = [S.view, S.view === "loading" ? ld : S.view === "bottleneck" ? S.bott : null, outKey, S.bands || null];
    if (!this.lineKey || lk.some((v, i) => v !== this.lineKey[i])) {
      this.lineKey = lk;
      const data = [], over = [], xfo = [], unc = [], G = this.G, bd = S.view === "loading" ? S.bands : null;
      for (const d of this.lines) {
        const j = d.j;
        if (out.has(j)) continue;
        if (S.view === "loading" && ld && Number.isFinite(ld[j])) {
          data.push(this.feat(d, { c: rgb(ramp(LOAD_STOPS, Math.min(ld[j], 1) * 100)), w: interp(LOAD_W, ld[j]) }));
          if (bd && ld[j] < OVER && bd.lo[j] < BAND_LIMIT && bd.hi[j] >= BAND_LIMIT) unc.push(this.feat(d));
          if (ld[j] >= OVER && this.short.has(j)) {
            const rate = S.rate ? S.rate[j] : G.branch.rate[j], kv = `${G.bus.kv[G.branch.from[j]].toFixed(0)}/${G.bus.kv[G.branch.to[j]].toFixed(0)} kV`;
            xfo.push({ p: d.path[0], tip: `<b>Transformer, branch ${j}</b> · ${kv}<br>loading ${(ld[j] * 100).toFixed(0)}% of ${rate.toFixed(0)} MVA` });
          } else if (ld[j] >= OVER) over.push(this.feat(d));
        } else if (S.view === "bottleneck" && S.bott && S.bott[j] > 0 && !this.short.has(j)) {
          const sh = S.bott[j];                 // a soft halo under the lines at their limit in most what-ifs
          if (sh >= 0.5) data.push(this.feat(d, { c: "rgba(154, 86, 4, 0.22)", w: 16 }));
          data.push(this.feat(d, { c: rgb(ramp(BOTT_STOPS, sh * 100)), w: 1.2 + 6 * sh }));
        }
      }
      m.getSource("lines").setData({ type: "FeatureCollection", features: data });
      m.getSource("uncert").setData({ type: "FeatureCollection", features: unc });
      m.getSource("over").setData({ type: "FeatureCollection", features: over });
      m.getSource("xfo").setData(pts(xfo));
    }
    if (outKey !== this.outKey) {
      this.outKey = outKey;
      m.getSource("out").setData({ type: "FeatureCollection", features: this.lines.filter((d) => out.has(d.j)).map((d) => this.feat(d)) });
      m.getSource("base").setData({ type: "FeatureCollection", features: this.lines.filter((d) => !out.has(d.j)).map((d) => this.feat(d)) });
    }
    // the bottleneck view is the same for every what-if: the network fades so the lines at their limit stand out
    const dim = S.view === "bottleneck";
    if (dim !== this.dim) { this.dim = dim; m.setPaintProperty("line-base", "line-opacity", dim ? 0.35 : 1); }
    // points
    let dt = [], load = [], cap = [], gens = [], vout = [];
    if (S.view === "dT") {
      for (let i = 0; i < G.nBus; i++) if (S.A.dT[i] >= DT_MIN) {
        const hf = S.A.load_heat[i], Pd = S.A.Pd[i];          // the load the heat adds: domes only (a real day's load is measured)
        dt.push({ p: [B.lon[i], B.lat[i]], c: rgb(ramp(DOT_STOPS, S.A.dT[i])),
                  tip: `Bus ${B.number[i]} · ${G.areas[B.area[i]]}<br>ΔT +${S.A.dT[i].toFixed(1)} °C above normal`
                    + (hf > 1 ? `<br>load +${((hf - 1) * 100).toFixed(1)}% (+${(Pd - Pd / hf).toFixed(1)} MW)` : "") });
      }
    } else if (S.view === "load") {
      // a hurricane's damaged zone: one dot per bus that keeps only part of its load, sized by that load. weather.js draws
      // the mean share, 70%; the engine draws each bus's own share in 40-100% (the stored draw's)
      for (const i of S.dark || []) if (B.Pd[i] !== 0) load.push({ p: [B.lon[i], B.lat[i]], r: Math.min(16, Math.max(3, Math.sqrt(Math.abs(S.A.Pd[i])) * 0.8)), c: SERIOUS,
        tip: `Bus ${B.number[i]} · damaged zone: ${S.A.Pd[i].toFixed(1)} of ${(S.A.Pd[i] / 0.7).toFixed(1)} MW (70% kept here; the engine draws each bus's share in 40-100%)` });
    } else if (S.view === "capacity") {
      const agg = new Map();
      for (let g = 0; g < G.nGen; g++) {
        if (G.gen.status[g] !== 1) continue;
        const key = `${G.gen.bus[g]}|${G.gen.fuel[g]}`, isOn = S.A.in_service[g] === 1;
        const a = agg.get(key) || { bus: G.gen.bus[g], fuel: G.gen.fuel[g], p0: 0, p: 0, out: 0 };
        a.p0 += G.gen.Pmax[g]; if (isOn) a.p += S.A.pmax[g]; else a.out += 1; agg.set(key, a);
      }
      cap = [...agg.values()].map((a) => ({ p: [B.lon[a.bus], B.lat[a.bus]], r0: Math.min(28, Math.max(4, Math.sqrt(a.p0) * 0.6)),
        r: Math.min(28, Math.max(2.5, Math.sqrt(a.p) * 0.6)), c: rgb(GROUP_COLOUR[a.fuel] || GROUP_COLOUR.unknown), out: a.out,
        tip: `${(FUEL_NAME[a.fuel] || a.fuel).replace(/^./, (x) => x.toUpperCase())} at bus ${B.number[a.bus]}<br>${a.p0 ? Math.round(a.p / a.p0 * 100) : 0}% of nameplate: ${a.p0.toFixed(0)} ${ARROW} ${a.p.toFixed(0)} MW${a.out ? `<br>${a.out} unit(s) out` : ""}` }));
    }
    if (S.view === "loading" && S.showGen && S.pg) {
      const pb = new Float64Array(G.nBus), pm = new Float64Array(G.nBus);
      for (let g = 0; g < G.nGen; g++) { pb[G.gen.bus[g]] += Math.max(0, S.pg[g]); pm[G.gen.bus[g]] += G.gen.Pmax[g]; }
      // one circle per site: a plant's units sit on separate buses at the same coordinates
      const site = new Map();
      for (let i = 0; i < G.nBus; i++) if (pb[i] > 0.5) {
        const key = `${B.lon[i].toFixed(3)},${B.lat[i].toFixed(3)}`, a = site.get(key) || { p: [B.lon[i], B.lat[i]], pb: 0, pm: 0, buses: [] };
        a.pb += pb[i]; a.pm += pm[i]; a.buses.push(B.number[i]); site.set(key, a);
      }
      gens = [...site.values()].map((a) => ({ p: a.p, r: Math.min(10, Math.max(2.5, Math.sqrt(a.pb) * 0.32)),
        tip: `${a.buses.length > 1 ? `Buses ${a.buses.join(", ")}` : `Bus ${a.buses[0]}`}<br>dispatch ${a.pb.toFixed(0)} of ${a.pm.toFixed(0)} MW` }));
    }
    if (S.view === "loading" && S.vOut && S.vOut.length)
      vout = Array.from(S.vOut, (i) => ({ p: [B.lon[i], B.lat[i]], tip: `Bus ${B.number[i]}: voltage out of the 0.90-1.10 p.u. band (not certified)` }));
    const bott = [];
    if (S.view === "bottleneck" && S.bott)
      for (const [k, s] of Object.entries(S.bott)) {
        const j = +k, f = G.branch.from[j];
        if (!(s > 0) || !this.short.has(j) || out.has(j)) continue;
        const kv = `${G.bus.kv[f].toFixed(0)}/${G.bus.kv[G.branch.to[j]].toFixed(0)} kV`;
        bott.push({ p: [B.lon[f], B.lat[f]], r: 3 + 8 * s, sw: 1.3 + 2.2 * s, c: rgb(ramp(BOTT_STOPS, s * 100)), tip: bottTip(j, kv, S, true) });
      }
    const domes = (S.domes || []).length ? S.domes.map((d, j) => ({ p: [d[1], d[0]], tip: `Dome ${j + 1}: +${d[3].toFixed(1)} °C at the centre, radius ${d[2].toFixed(0)} km` })) : [];
    // setData only when a layer's content changed (it re-tiles in a worker)
    const put = (id, list) => { const key = JSON.stringify(list); if (this[`k_${id}`] !== key) { this[`k_${id}`] = key; m.getSource(id).setData(pts(list)); } };
    put("dt", dt); put("load", load); put("cap", cap); put("cap0", cap); put("gens", gens); put("vout", vout); put("domes", domes);
    this.points = { dt, load, cap };
    put("bott", bott); put("scan", S.scan || []);
    const track = S.track ? { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: S.track.map((q) => [q[1], q[0]]) } }] } : EMPTY;
    const tk = S.track ? JSON.stringify(S.track) : "";
    if (tk !== this.trackKey) { this.trackKey = tk; m.getSource("track").setData(track); }
  }
}