Download map_v2.js from cardef/grid-lab: direct link, hf CLI and curl.
- Browser
- Download file 27.2 kB
-
https://huggingface.co/spaces/cardef/grid-lab/resolve/main/map_v2.js
- Command line
-
hf download hf://spaces/cardef/grid-lab/map_v2.js
-
curl -L -o map_v2.js https://huggingface.co/spaces/cardef/grid-lab/resolve/main/map_v2.js
27.2 kB
| // 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); } | |
| } | |
| } | |