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