// 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 = '';
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 `${xfo ? "Transformer" : "Line"} ${j} · ${kv}
`
+ (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 `Line ${j} · ${kv}
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 ? `
${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 `Line ${j} · ${kv}
loading ${(S.loading[j] * 100).toFixed(0)}% of ${rate.toFixed(0)} MVA${band}`;
}
return `Line ${j} · ${kv}
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: `Transformer, branch ${j} · ${kv}
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]]}
ΔT +${S.A.dT[i].toFixed(1)} °C above normal`
+ (hf > 1 ? `
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]}
${a.p0 ? Math.round(a.p / a.p0 * 100) : 0}% of nameplate: ${a.p0.toFixed(0)} ${ARROW} ${a.p.toFixed(0)} MW${a.out ? `
${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]}`}
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); }
}
}