1// solar/lab/app/widget_dst.js â Kyoto WDC Dst hourly geomagnetic index widget. 2// 3// Phase B of the Dst integration (Phase A = Codex `_1830` shipped the data 4// to /datos/dst/ on VPS). This widget renders the hourly Dst index as a 5// color-coded bar chart in a second Plotly canvas below the main 4-instrument 6// timeline. Per handoff `_1822` design: 7// 8// - Bar chart (Plotly type:'bar'), color by NOAA G-scale storm class. 9// - Y axis: linear, autorange:'reversed' â storms (negative nT) go DOWN 10// visually (geomagnetism convention, Sugiura's plots, NOAA dashboards). 11// - x axis: ISO-8601 strings with explicit `Z` â same TZ discipline as 12// widget_goes / widget_rhessi (see `feedback_plotly_tz_footguns` memo). 13// - Hover via `customdata` (HH:MM UT + G-scale label + status flag), NOT 14// `%{x|format}`. 15// - Background reference lines at G1 -30, G2 -50, G3 -100, G4 -200, G5 -350. 16// - Single-shot fetch on first plotDST(); the whole CSV.gz lives in memory 17// for subsequent windows. The bundle is ~3.5 MB compressed â ~13 MB raw 18// (â266k hourly rows 1996-2026). Acceptable for one-time load. 19// - Optional quicklook toggle fetches a second CSV with provisional+ + 20// quicklook rows for the last ~30 days, merged on top of the stable 21// final+provisional source. 22 23import { t } from './i18n.js'; 24import { 25 clickEventToXMs, 26 buildPinShapeAndAnnotation, 27 renderLoading as baseRenderLoading, 28 renderError as baseRenderError, 29 renderInfo as baseRenderInfo, 30 paint as basePaint, 31 relayoutShapesAnnotations, 32 attachRelayoutHandlerOnce, 33 setXAxisRange as baseSetXAxisRange, 34 downloadCSV, 35} from './_lib/widget_base.js'; 36 37// ============================================================= 38// Data URLs (Netlify rewrite /datos/* â rudimirz.space/datos/*) 39// ============================================================= 40const DST_BASE_URL = '/datos/dst'; 41const DST_FINAL_PROV_URL = `${DST_BASE_URL}/kyoto_dst_hourly_1996_2026_final_provisional.csv.gz`; 42const DST_WITH_QUICKLOOK_URL = `${DST_BASE_URL}/kyoto_dst_hourly_1996_2026_with_quicklook.csv.gz`; 43 44// ============================================================= 45// NOAA G-scale color thresholds (W/m^2-free, just Dst nT). 46// Reference: https://www.swpc.noaa.gov/noaa-scales-explanation 47// Dst is a proxy for Kp on these scale labels â boundaries below 48// match the conventional storm classification. 49// ============================================================= 50const G_SCALE = [ 51 { max: -350, key: 'G5', color: '#7c3aed' }, // extreme (⤠-350) 52 { max: -200, key: 'G4', color: '#dc2626' }, // severe (-200 â -350) 53 { max: -100, key: 'G3', color: '#ea580c' }, // strong (-100 â -200) 54 { max: -50, key: 'G2', color: '#f59e0b' }, // moderate (-50 â -100) 55 { max: -30, key: 'G1', color: '#eab308' }, // minor (-30 â -50) 56 { max: Infinity, key: 'q', color: '#64748b' }, // quiet (⥠-30; covers small negatives AND positive Dst â they all represent non-storm conditions per the NOAA scale) 57]; 58 59function dstToBucket(nT) { 60 for (const b of G_SCALE) if (nT <= b.max) return b; 61 return G_SCALE[G_SCALE.length - 1]; 62} 63 64function gScaleLabel(bucket) { 65 if (bucket.key === 'q') return 'quiet'; 66 return bucket.key; // G1..G5 67} 68 69// ============================================================= 70// Visual constants 71// ============================================================= 72const AXIS_INK = '#1a1a22'; 73const AXIS_STROKE = '#cccccc'; 74const GRID_INK = '#eaeaea'; 75const PIN_INK = '#7e57c2'; 76const REF_LINE_INK = '#94a3b8'; 77const REF_LINE_STROKE = 'rgba(148,163,184,0.45)'; 78const PIN_UNPIN_TOLERANCE_MS = 60 * 1000; 79 80// ============================================================= 81// State 82// ============================================================= 83let chartDiv = null; 84let onPinClickHook = null; 85let onRangeChangeHook = null; 86 87let includeQuicklook = false; 88let pinnedISO = null; 89 90let lastT0 = null; 91let lastT1 = null; 92let lastBundle = null; // { tMs[], value[], status[] } â full corpus in memory once 93let lastFullPromise = null; // dedup concurrent fetches 94let lastQuicklookPromise = null; 95 96let hasFirstRender = false;
97let clickHandlerAttached = false; 98 99// ============================================================= 100// Public API (uniform with widget_goes / widget_rstn / widget_rhessi / widget_poemas) 101// ============================================================= 102export function mountDST(el, opts) { 103 chartDiv = el; 104 if (opts && typeof opts.onPinClick === 'function') { 105 onPinClickHook = opts.onPinClick; 106 } 107 if (opts && typeof opts.onRangeChange === 'function') { 108 onRangeChangeHook = opts.onRangeChange; 109 } 110} 111 112export function setDSTPin(iso) { 113 pinnedISO = iso || null; 114 rerenderFromCache(); 115} 116 117export function setDSTXRange(t0, t1) { 118 baseSetXAxisRange(chartDiv, t0, t1); 119} 120 121export function setIncludeQuicklook(v) { 122 const next = !!v; 123 if (next === includeQuicklook) return; 124 includeQuicklook = next; 125 // Invalidate cache so the next plotDST refetches w/ the quicklook merged in. 126 lastBundle = null; 127 lastFullPromise = null; 128 rerenderFromCache(); 129} 130 131export function getDSTIncludeQuicklook() { return includeQuicklook; } 132 133// ============================================================= 134// Main plot entrypoint 135// ============================================================= 136export async function plotDST(t0, t1, opts) { 137 if (!chartDiv) throw new Error('widget_dst: mountDST not called'); 138 if (!window.Plotly) throw new Error('Plotly not loaded'); 139 140 lastT0 = t0; 141 lastT1 = t1; 142 hasFirstRender = false;
143 144 // Coverage gate â Dst hourly is 1996-01-01 â 2026-06-06. 145 const DST_MIN_MS = Date.UTC(1996, 0, 1); 146 const DST_MAX_MS = Date.UTC(2026, 5, 7); // +1 day buffer 147 if (t1.getTime() < DST_MIN_MS || t0.getTime() > DST_MAX_MS) { 148 renderOutOfCoverage(t0, t1); 149 return null; 150 } 151 // Window too narrow for an hourly bar to be meaningful. 152 if (t1.getTime() - t0.getTime() < 3600 * 1000 * 2) { 153 renderTooNarrow(); 154 return null; 155 } 156 157 renderLoading(); 158 159 let bundle; 160 try { 161 bundle = await loadFullBundle(); 162 } catch (e) { 163 renderError(e, opts && opts.onRetry); 164 throw e; 165 } 166 167 if (!bundle || !bundle.tMs.length) { 168 renderEmpty(t0, t1); 169 return null; 170 } 171 172 const slice = sliceWindow(bundle, t0, t1); 173 if (!slice.tMs.length) { 174 renderEmpty(t0, t1); 175 return null; 176 } 177 178 renderInto(t0, t1, slice); 179 attachClickHandlerOnce(); 180 return summarisePeak(slice); 181} 182 183// ============================================================= 184// CSV.gz fetch + parse (single-shot, module-cached) 185// ============================================================= 186async function loadFullBundle() { 187 if (lastBundle) return lastBundle; 188 if (lastFullPromise) return lastFullPromise; 189 lastFullPromise = (async () => { 190 const baseBundle = await fetchAndParse(DST_FINAL_PROV_URL, 'final_provisional'); 191 let merged = baseBundle; 192 if (includeQuicklook) { 193 try { 194 const qlBundle = await fetchAndParse(DST_WITH_QUICKLOOK_URL, 'quicklook_source'); 195 merged = mergeQuicklookOnTop(baseBundle, qlBundle); 196 } catch (e) { 197 // Quicklook fetch fail is non-fatal â surface in console + carry on 198 // with final_provisional only. 199 console.warn('[widget_dst] quicklook fetch failed, falling back to final_provisional', e); 200 } 201 } 202 lastBundle = merged; 203 return merged; 204 })(); 205 return lastFullPromise; 206} 207 208async function fetchAndParse(url, sourceLabel) { 209 const resp = await fetch(url, { cache: 'force-cache' }); 210 if (!resp.ok) throw new Error(`Dst fetch failed: ${resp.status} ${url}`); 211 const buf = await resp.arrayBuffer(); 212 const decompressed = window.fflate.decompressSync(new Uint8Array(buf)); 213 const text = new TextDecoder('utf-8').decode(decompressed); 214 return parseCSV(text); 215} 216 217function parseCSV(text) { 218 // Real header (confirmed against deployed bundle 2026-06-07): 219 // datetime_utc,date_utc,year,month,day,hour_utc,dst_nt,data_status, 220 // source_kind,source_month,source_url 221 // We only need cols 0 (datetime_utc), 6 (dst_nt), 7 (data_status). 222 const lines = text.split('\n'); 223 const tMs = []; 224 const value = []; 225 const status = []; 226 for (let i = 1; i < lines.length; i++) { 227 const ln = lines[i]; 228 if (!ln) continue; 229 const c = ln.split(','); 230 if (c.length < 8) continue; 231 // Normalize ISO-with-Z (Plotly TZ footgun â same discipline as widget_goes). 232 const iso = c[0].endsWith('Z') ? c[0] : `${c[0]}Z`; 233 const ms = new Date(iso).getTime(); 234 if (!Number.isFinite(ms)) continue; 235 const v = Number(c[6]); // explicit Number(), no Papa dynamicTyping 236 if (!Number.isFinite(v)) continue; 237 tMs.push(ms); 238 value.push(v); 239 status.push((c[7] || '').trim()); 240 } 241 return { tMs, value, status }; 242} 243 244function mergeQuicklookOnTop(base, ql) { 245 // Both are sorted ASC by time. Merge: keep all base rows, then append 246 // ql rows whose tMs > max(base.tMs). 247 if (!ql || !ql.tMs.length) return base; 248 const baseMax = base.tMs[base.tMs.length - 1]; 249 const out = { 250 tMs: base.tMs.slice(), 251 value: base.value.slice(), 252 status: base.status.slice(), 253 }; 254 for (let i = 0; i < ql.tMs.length; i++) { 255 if (ql.tMs[i] > baseMax) { 256 out.tMs.push(ql.tMs[i]); 257 out.value.push(ql.value[i]); 258 out.status.push(ql.status[i] || 'quicklook'); 259 } 260 } 261 return out; 262} 263 264function sliceWindow(bundle, t0, t1) { 265 // Binary search the lower bound; linear walk forward. Hourly cadence 266 // means at most window_hours iterations. 267 const t0ms = t0.getTime(); 268 const t1ms = t1.getTime(); 269 const arr = bundle.tMs; 270 // Lower bound via binary search. 271 let lo = 0, hi = arr.length; 272 while (lo < hi) { 273 const mid = (lo + hi) >>> 1; 274 if (arr[mid] < t0ms) lo = mid + 1; 275 else hi = mid; 276 } 277 const tMs = [], value = [], status = []; 278 for (let i = lo; i < arr.length && arr[i] <= t1ms; i++) { 279 tMs.push(arr[i]); 280 value.push(bundle.value[i]); 281 status.push(bundle.status[i]); 282 } 283 return { tMs, value, status }; 284} 285 286// =============================================================
287// Render â Plotly bar trace + reference lines + cursor 288// ============================================================= 289function buildTraces(slice) { 290 // One bar per hour. Colors per G-scale bucket. Customdata for hover. 291 const isoArr = slice.tMs.map((ms) => new Date(ms).toISOString()); 292 const colors = slice.value.map((v) => dstToBucket(v).color); 293 const customdata = slice.tMs.map((ms, i) => { 294 const d = new Date(ms); 295 const HH = String(d.getUTCHours()).padStart(2, '0'); 296 const MM = String(d.getUTCMinutes()).padStart(2, '0'); 297 const Y = d.getUTCFullYear(); 298 const M = String(d.getUTCMonth() + 1).padStart(2, '0'); 299 const D = String(d.getUTCDate()).padStart(2, '0'); 300 const utStamp = `${Y}-${M}-${D} ${HH}:${MM} UT`; 301 const bucket = dstToBucket(slice.value[i]); 302 const cls = gScaleLabel(bucket); 303 const st = slice.status[i] || ''; 304 return [utStamp, cls, st]; 305 }); 306 // Patterns for quicklook differentiation (diagonal hatch + lower opacity). 307 const patternShape = slice.status.map((s) => (s === 'quicklook' ? '/' : '')); 308 const opacities = slice.status.map((s) => (s === 'quicklook' ? 0.65 : 1.0)); 309 // Plotly bar doesn't accept per-bar opacity natively; we group by status 310 // into 2 traces if quicklook present. Simpler: use a single trace with 311 // marker.line color = same; per-bar pattern via marker.pattern.shape array. 312 return [{ 313 type: 'bar', 314 x: isoArr, 315 y: slice.value, 316 customdata, 317 name: 'Dst', 318 marker: { 319 color: colors, 320 line: { width: 0 }, 321 pattern: { shape: patternShape, fgcolor: '#1a1a22', size: 4 }, 322 }, 323 opacity: 1, 324 hovertemplate: '%{customdata[0]}<br><b>Dst: %{y} nT</b><br>%{customdata[1]} · <i>%{customdata[2]}</i><extra></extra>', 325 width: 3600 * 1000 * 0.9, // 90% of an hour wide 326 }]; 327} 328 329function buildShapesAndAnnotations(t0, t1, slice) { 330 // 5 G-scale boundary lines + the 0 nT baseline. 331 // Hotfix 4 (v0.8.3): only emit reference lines that fall WITHIN the 332 // adaptive Y range â otherwise Plotly leaves them off-screen but the 333 // annotation labels (G1..G5) still render at the chart edge, looking 334 // orphaned. 335 const yRange = slice ? computeAdaptiveYRange(slice) : [-350, 30]; 336 const yMin = yRange[0]; 337 const lines = [ 338 { y: 0, color: REF_LINE_INK, dash: 'solid', label: '0' }, 339 { y: -30, color: REF_LINE_STROKE, dash: 'dot', label: 'G1' }, 340 { y: -50, color: REF_LINE_STROKE, dash: 'dot', label: 'G2' }, 341 { y: -100, color: REF_LINE_STROKE, dash: 'dot', label: 'G3' }, 342 { y: -200, color: REF_LINE_STROKE, dash: 'dot', label: 'G4' }, 343 { y: -350, color: REF_LINE_STROKE, dash: 'dot', label: 'G5' }, 344 ].filter((l) => l.y >= yMin); 345 const shapes = lines.map((l) => ({ 346 type: 'line', 347 xref: 'paper', x0: 0, x1: 1, 348 yref: 'y', y0: l.y, y1: l.y, 349 line: { color: l.color, width: l.y === 0 ? 1 : 0.6, dash: l.dash }, 350 layer: 'below', 351 })); 352 const annotations = lines.filter((l) => l.label && l.y < 0).map((l) => ({ 353 xref: 'paper', x: 1, xanchor: 'left', 354 yref: 'y', y: l.y, yanchor: 'middle', 355 text: l.label, 356 showarrow: false, 357 font: { family: 'JetBrains Mono, monospace', size: 9, color: '#94a3b8' }, 358 })); 359 // Pin overlay (cross-instrument hover sync â pinnedISO comes from main.js). 360 if (pinnedISO) { 361 const pinShape = buildPinShapeAndAnnotation(pinnedISO, PIN_INK); 362 if (pinShape) { 363 shapes.push(...pinShape.shapes); 364 annotations.push(...(pinShape.annotations || [])); 365 } 366 } 367 return { shapes, annotations }; 368} 369 370function computeAdaptiveYRange(slice) { 371 // Hotfix 4 (v0.8.3): Plotly was auto-extending Y range to -350 because 372 // the G5 reference shape lives at y=-350, leaving 90% of the canvas 373 // empty on quiet days (e.g., X1.6 2014-10-22 â min Dst was ~-31 nT). 374 // 375 // New strategy: clamp Y range to a band that BRACKETS the actual data 376 // with one G-scale step of headroom beyond the most negative reading. 377 // The reference lines outside the clamped range simply don't render. 378 if (!slice || !slice.value || !slice.value.length) { 379 return [-50, 20]; // sane default â show G1 band 380 } 381 const yMin = Math.min(...slice.value); 382 const yMax = Math.max(...slice.value); 383 const STOPS = [-30, -50, -100, -200, -350, -500, -1000]; 384 // Pick the smallest STOP that is BELOW yMin (i.e., yMin - 10 nT padding).
385 let yMinSnap = -50; 386 for (const s of STOPS) { 387 if (s <= yMin - 5) { yMinSnap = s - 10; break; } 388 } 389 // Always show at least to G1 (-50) so quiet days still feel calibrated. 390 if (yMinSnap > -50) yMinSnap = -50; 391 // Top: at least +15 nT to give zero line some breathing room. 392 const yMaxSnap = Math.max(yMax + 8, 15); 393 return [yMinSnap, yMaxSnap]; 394} 395 396function buildLayout(t0, t1, slice) { 397 const { shapes, annotations } = buildShapesAndAnnotations(t0, t1, slice); 398 const yRange = computeAdaptiveYRange(slice); 399 return { 400 margin: { l: 56, r: 28, t: 6, b: 28 }, 401 // Height intentionally not set â let the CSS-controlled container 402 // (`.ev-widget[data-instrument="dst"] .ev-widget-body`) decide. We 403 // shrank that to 220px so the chart doesn't waste vertical space on 404 // quiet days. 405 paper_bgcolor: '#ffffff', 406 plot_bgcolor: '#ffffff', 407 showlegend: false, 408 bargap: 0, 409 autosize: true, 410 xaxis: { 411 type: 'date', 412 range: [t0.toISOString(), t1.toISOString()], 413 tickfont: { family: 'JetBrains Mono, monospace', size: 10, color: AXIS_INK }, 414 gridcolor: GRID_INK, 415 linecolor: AXIS_STROKE, 416 showspikes: false, 417 }, 418 yaxis: { 419 title: { text: 'Dst (nT)', font: { family: 'JetBrains Mono, monospace', size: 10, color: AXIS_INK } }, 420 // Hotfix 4 (v0.8.3): explicit Y range from computeAdaptiveYRange so 421 // quiet days don't render with a giant empty -50â-350 gulf below 422 // the actual data. 423 range: yRange, 424 zeroline: true, 425 zerolinecolor: '#94a3b8', 426 zerolinewidth: 1, 427 tickfont: { family: 'JetBrains Mono, monospace', size: 9, color: AXIS_INK }, 428 gridcolor: GRID_INK, 429 linecolor: AXIS_STROKE, 430 }, 431 shapes, 432 annotations, 433 hovermode: 'x', // per-trace tooltip (NOT 'x unified' â TZ footgun) 434 }; 435} 436 437function renderInto(t0, t1, slice) { 438 const traces = buildTraces(slice); 439 const layout = buildLayout(t0, t1, slice); 440 // Hotfix 2: basePaint signature is (chartDiv, traces, layout, hasFirstRender, 441 // attachClickFn). Passing an opts object as the 4th arg made the function 442 // treat the FIRST render as "already rendered" â Plotly.react instead of 443 // Plotly.newPlot â loading text never cleared. Pattern lifted from 444 // widget_poemas.js line 400. 445 // 446 // Hotfix 3 (v0.8.2): on first render, also explicitly clear chartDiv's 447 // innerHTML. Plotly.newPlot purges its own internal state but does NOT 448 // remove non-Plotly children â so renderLoading's <div class="ev-loading"> 449 // persisted as a sibling under the chart, producing the "Loading Dst 450 // (Kyoto WDC)â¦" residual the director saw below the rendered plot. 451 if (chartDiv && !hasFirstRender) chartDiv.innerHTML = ''; 452 hasFirstRender = basePaint(chartDiv, traces, layout, hasFirstRender, attachClickHandlerOnce); 453} 454 455function rerenderFromCache() { 456 if (!hasFirstRender || !lastT0 || !lastT1 || !lastBundle) return; 457 const slice = sliceWindow(lastBundle, lastT0, lastT1); 458 renderInto(lastT0, lastT1, slice); 459} 460 461function renderLoading() { baseRenderLoading(chartDiv, t('dst.loading') || 'Loading Dst (Kyoto WDC)â¦'); } 462function renderError(err, retry) { baseRenderError(chartDiv, err, retry); } 463function renderEmpty(t0, t1) { baseRenderInfo(chartDiv, t('dst.empty') || 'No Dst data in this window.'); } 464function renderOutOfCoverage(t0, t1) { 465 baseRenderInfo(chartDiv, t('dst.outOfCoverage') || 'Dst dataset: 1996-01 â 2026-06 (hourly, Kyoto WDC).'); 466} 467function renderTooNarrow() { 468 baseRenderInfo(chartDiv, t('dst.tooNarrow') || 'Window too narrow for hourly Dst â widen to â¥2 hours.'); 469} 470 471// ============================================================= 472// Hover / click handler (sync pin across instruments) 473// ============================================================= 474function attachClickHandlerOnce() { 475 if (clickHandlerAttached || !chartDiv) return; 476 clickHandlerAttached = true; 477 chartDiv.on('plotly_click', (ev) => { 478 const xMs = clickEventToXMs(ev); 479 if (xMs == null) return; 480 const iso = new Date(xMs).toISOString(); 481 if (onPinClickHook) { 482 // Toggle behaviour: clicking near existing pin un-pins. 483 if (pinnedISO && Math.abs(new Date(pinnedISO).getTime() - xMs) < PIN_UNPIN_TOLERANCE_MS) {
484 onPinClickHook(null); 485 } else { 486 onPinClickHook(iso); 487 } 488 } 489 }); 490 // v0.8.5 â Hotfix: attachRelayoutHandlerOnce expects a `ctx` OBJECT with 491 // an `.onRangeChange` method, NOT a bare callback. I was passing a 492 // callback fn â the early-return guard `if (!ctx || typeof 493 // ctx.onRangeChange !== 'function') return` rejected it, so the 494 // plotly_relayout listener was NEVER attached. That's why zooming 495 // INSIDE the Dst plot didn't propagate to siblings (the other widgets 496 // received their own relayout events but not Dst's). The reverse worked 497 // because setDSTXRange is a direct programmatic call, not event-driven. 498 // Pattern lifted from widget_poemas.js:403. 499 attachRelayoutHandlerOnce(chartDiv, { 500 onRangeChange: (t0, t1) => { 501 if (onRangeChangeHook) onRangeChangeHook(t0, t1); 502 }, 503 }); 504} 505 506// ============================================================= 507// Summarize (peak Dst in window) â surfaced in the meta header 508// ============================================================= 509function summarisePeak(slice) { 510 if (!slice.value.length) return null; 511 let minI = 0; 512 for (let i = 1; i < slice.value.length; i++) { 513 if (slice.value[i] < slice.value[minI]) minI = i; 514 } 515 const peakMs = slice.tMs[minI]; 516 const peakVal = slice.value[minI]; 517 const bucket = dstToBucket(peakVal); 518 return { 519 peakIso: new Date(peakMs).toISOString(), 520 peakNT: peakVal, 521 gScale: gScaleLabel(bucket), 522 color: bucket.color, 523 }; 524} 525 526// ============================================================= 527// CSV export of the visible window 528// ============================================================= 529export function exportDSTCSV(sd, ed) { 530 if (!lastBundle || !lastT0 || !lastT1) return false; 531 const slice = sliceWindow(lastBundle, lastT0, lastT1); 532 if (!slice.tMs.length) return false; 533 const header = ['iso_utc', 'dst_nT', 'status', 'g_scale']; 534 const rows = []; 535 for (let i = 0; i < slice.tMs.length; i++) { 536 const iso = new Date(slice.tMs[i]).toISOString(); 537 const cls = gScaleLabel(dstToBucket(slice.value[i])); 538 rows.push([iso, slice.value[i], slice.status[i] || '', cls]); 539 } 540 const safe = (s) => String(s || '').replace(/[^0-9-]/g, ''); 541 const filename = `openmonitor_events_dst_${safe(sd)}_to_${safe(ed)}.csv`; 542 downloadCSV(filename, header, rows); 543 return true; 544}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.