PageSourceSearch

https://solar.openmonitor.org/lab/app/widget_dst.js

js openmonitor.org collected 2026-09-29 19:39:11 UTC 21,172 bytes, 544 lines download raw bytes

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.