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