da html/jscript
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sun Activity ↔ Earthquakes (Browser Demo)</title>
<style>
:root {
--bg: #0b1020;
--panel: #121a33;
--ink: #e9eeff;
--muted: #a9b3d6;
--grid: rgba(233,238,255,0.12);
--accent: #7aa2ff;
--warn: #ffb86b;
--ok: #43f6a5;
--bad: #ff5c7a;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
body {
margin: 0;
font-family: var(--sans);
background: radial-gradient(1200px 800px at 20% 10%, rgba(122,162,255,0.18), transparent 55%),
radial-gradient(900px 700px at 70% 20%, rgba(67,246,165,0.12), transparent 50%),
radial-gradient(900px 700px at 70% 80%, rgba(255,92,122,0.10), transparent 55%),
var(--bg);
color: var(--ink);
}
header {
padding: 16px 18px;
border-bottom: 1px solid rgba(233,238,255,0.10);
background: rgba(10,14,30,0.55);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
header h1 {
margin: 0;
font-size: 16px;
letter-spacing: 0.2px;
}
main {
display: grid;
grid-template-columns: 360px 1fr;
gap: 16px;
padding: 16px;
max-width: 1280px;
margin: 0 auto;
}
@media (max-width: 980px) {
main { grid-template-columns: 1fr; }
}
.card {
background: rgba(18,26,51,0.78);
border: 1px solid rgba(233,238,255,0.10);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,0.30);
overflow: hidden;
}
.card h2 {
margin: 0;
padding: 14px 14px 10px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.2px;
border-bottom: 1px solid rgba(233,238,255,0.08);
}
.card .body {
padding: 12px 14px 14px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
label {
display: block;
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
input[type="number"], select {
width: 100%;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid rgba(233,238,255,0.14);
background: rgba(10,14,30,0.55);
color: var(--ink);
outline: none;
}
.btns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
button {
cursor: pointer;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid rgba(233,238,255,0.16);
background: rgba(122,162,255,0.16);
color: var(--ink);
font-weight: 650;
letter-spacing: 0.2px;
}
button.secondary {
background: rgba(233,238,255,0.06);
}
button:active { transform: translateY(1px); }
.statgrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 12px;
}
.stat {
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(233,238,255,0.10);
background: rgba(10,14,30,0.45);
}
.stat .k { font-size: 11px; color: var(--muted); }
.stat .v { font-size: 16px; font-family: var(--mono); margin-top: 6px; }
.charts {
display: grid;
grid-template-rows: auto auto;
gap: 16px;
}
.canvasWrap { padding: 10px; }
canvas {
width: 100%;
height: 280px;
display: block;
border-radius: 14px;
border: 1px solid rgba(233,238,255,0.10);
background: rgba(10,14,30,0.45);
}
.mono {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid rgba(233,238,255,0.16);
background: rgba(233,238,255,0.06);
margin-left: 6px;
}
.pill.ok { border-color: rgba(67,246,165,0.40); color: rgba(67,246,165,0.95); }
.pill.bad { border-color: rgba(255,92,122,0.40); color: rgba(255,92,122,0.95); }
.pill.warn { border-color: rgba(255,184,107,0.45); color: rgba(255,184,107,0.98); }
.reality-check-inline {
margin-top: 10px;
padding: 8px 10px;
border-radius: 10px;
border: 1px dashed rgba(255,184,107,0.45);
background: rgba(10,14,30,0.55);
color: rgba(255,184,107,0.95);
font-size: 11px;
line-height: 1.35;
}
</style>
</head>
<body>
<header>
<h1>Sun Activity ↔ Earthquakes <span class="pill warn">real data</span></h1>
</header>
<main>
<section class="card">
<h2>Controls</h2>
<div class="body">
<div class="row">
<div>
<label>Days (lookback window)</label>
<input id="days" type="number" min="30" max="3650" step="1" value="365" />
</div>
<div>
<label>Solar data source</label>
<select id="solarSource">
<option value="f107" selected>NOAA SWPC — F10.7 (daily; observed solar-cycle indices)</option>
<option value="kp1m">NOAA SWPC — Kp (1-minute; recent only)</option>
</select>
</div>
</div>
<div class="row">
<div>
<label>EQ metric</label>
<select id="eqMetric">
<option value="sumMag" selected>Daily sum of magnitudes</option>
<option value="count">Daily event count</option>
</select>
</div>
<div>
<label>EQ minimum magnitude</label>
<input id="magMin" type="number" min="0" max="8" step="0.1" value="4.0" />
</div>
</div>
<div class="row">
<div>
<label>Rolling smooth (days)</label>
<input id="smooth" type="number" min="1" max="60" step="1" value="7" />
</div>
<div>
<label>Max lag to scan (days)</label>
<input id="maxLag" type="number" min="1" max="180" step="1" value="45" />
</div>
</div>
<div class="row">
<div>
<label>Normalize series</label>
<select id="norm">
<option value="z" selected>Z-score</option>
<option value="minmax">Min-Max</option>
<option value="none">None</option>
</select>
</div>
<div>
<label>Status</label>
<div class="mono" id="status">ready</div>
</div>
</div>
<div class="btns">
<button id="regen">Fetch data</button>
<button id="export" class="secondary">Export CSV</button>
</div>
<div class="statgrid">
<div class="stat">
<div class="k">Best lag (days)</div>
<div class="v" id="bestLag">—</div>
</div>
<div class="stat">
<div class="k">Best correlation (r)</div>
<div class="v" id="bestR">—</div>
</div>
<div class="stat">
<div class="k">Pearson r @ lag 0</div>
<div class="v" id="r0">—</div>
</div>
<div class="stat">
<div class="k">Events ≥ magMin</div>
<div class="v" id="nEvents">—</div>
</div>
</div>
<div class="reality-check-inline">⚠ Correlation ≠ causation. Lag relationships may arise from noise, seasonality, reporting artifacts, or shared external drivers. Treat results as exploratory.</div>
</div>
</section>
<section class="charts">
<section class="card">
<h2>Time series (Solar + EQ)</h2>
<div class="canvasWrap">
<canvas id="ts" width="1100" height="320"></canvas>
<div class="mono" id="tsMeta"></div>
</div>
</section>
<section class="card">
<h2>Lag scan (cross-correlation)</h2>
<div class="canvasWrap">
<canvas id="cc" width="1100" height="320"></canvas>
<div class="mono" id="ccMeta"></div>
</div>
</section>
</section>
</main>
<script>
// =========================
// Utilities
// =========================
function mean(arr) {
let s = 0;
for (const x of arr) s += x;
return s / (arr.length || 1);
}
function std(arr) {
const m = mean(arr);
let s2 = 0;
for (const x of arr) { const d = x - m; s2 += d * d; }
return Math.sqrt(s2 / (arr.length - 1 || 1));
}
function zscore(arr) {
const m = mean(arr);
const sd = std(arr) || 1;
return arr.map(x => (x - m) / sd);
}
function minmax(arr) {
let lo = Infinity, hi = -Infinity;
for (const x of arr) { if (x < lo) lo = x; if (x > hi) hi = x; }
const span = (hi - lo) || 1;
return arr.map(x => (x - lo) / span);
}
function rollingMean(arr, win) {
win = Math.max(1, Math.floor(win));
const out = new Array(arr.length).fill(0);
let s = 0;
for (let i = 0; i < arr.length; i++) {
s += arr[i];
if (i >= win) s -= arr[i - win];
const denom = (i + 1 < win) ? (i + 1) : win;
out[i] = s / denom;
}
return out;
}
function pearson(x, y) {
const n = Math.min(x.length, y.length);
if (n < 3) return 0;
let sx = 0, sy = 0;
for (let i = 0; i < n; i++) { sx += x[i]; sy += y[i]; }
const mx = sx / n, my = sy / n;
let num = 0, dx = 0, dy = 0;
for (let i = 0; i < n; i++) {
const a = x[i] - mx;
const b = y[i] - my;
num += a * b;
dx += a * a;
dy += b * b;
}
const den = Math.sqrt(dx * dy) || 1;
return num / den;
}
// Shift y relative to x by lag (positive lag means y occurs AFTER x)
function corrAtLag(x, y, lag) {
const n = Math.min(x.length, y.length);
if (n < 3) return 0;
if (lag === 0) return pearson(x, y);
if (lag > 0) {
const a = x.slice(0, n - lag);
const b = y.slice(lag, n);
return pearson(a, b);
}
const k = -lag;
const a = x.slice(k, n);
const b = y.slice(0, n - k);
return pearson(a, b);
}
// =========================
// REAL DATA INGEST (NOAA + USGS)
// =========================
async function fetchNOAAKp(startISO, endISO) {
const url = 'https://services.swpc.noaa.gov/json/planetary_k_index_1m.json';
const res = await fetch(url);
if (!res.ok) throw new Error('NOAA Kp fetch failed: ' + res.status);
const data = await res.json();
const byDay = {};
for (const row of data) {
const t = new Date(row.time_tag || row.time || row.datetime || row.date);
if (isNaN(t)) continue;
const day = t.toISOString().slice(0, 10);
if (day < startISO || day > endISO) continue;
const v = Number(row.kp_index ?? row.kp ?? row.value);
if (!Number.isFinite(v)) continue;
if (!byDay[day]) byDay[day] = [];
byDay[day].push(v);
}
const days = Object.keys(byDay).sort();
return days.map(d => ({ day: d, value: mean(byDay[d]) }));
}
async function fetchNOAAF107(startISO, endISO) {
// Best long daily series from SWPC is the observed solar-cycle indices JSON (large file).
// Directory listing shows this file is sizable (~499K), i.e., far more than a handful of points.
// https://services.swpc.noaa.gov/json/solar-cycle/
const urls = [
'https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json',
// fallbacks (may be monthly/limited depending on product)
'https://services.swpc.noaa.gov/json/solar-cycle/f10-7cm-flux.json',
'https://services.swpc.noaa.gov/json/solar-radio-flux.json'
];
let data = null;
let lastErr = null;
for (const u of urls) {
try {
const res = await fetch(u, { cache: 'no-store' });
if (!res.ok) throw new Error(String(res.status));
data = await res.json();
if (data) break;
} catch (e) {
lastErr = e;
data = null;
}
}
if (!data) throw new Error('NOAA F10.7 fetch failed: ' + String(lastErr?.message || lastErr));
const pickDay = (row) => {
const candidates = ['time_tag', 'time-tag', 'date', 'day', 'timestamp', 'time', 'datetime', 'dt'];
for (const k of candidates) {
const raw = row?.[k];
if (!raw) continue;
if (typeof raw === 'string' && raw.length >= 10) {
const d = raw.slice(0, 10);
// YYYY-MM-DD check
if (d[4] === '-' && d[7] === '-') return d;
}
const t = new Date(raw);
if (!isNaN(t)) return t.toISOString().slice(0, 10);
}
return null;
};
const pickVal = (row) => {
// try explicit candidates first
const candidates = ['f10_7', 'f10.7', 'f107', 'f107_cm_flux', 'flux', 'observed', 'value', 'radio_flux'];
for (const k of candidates) {
if (row && row[k] != null) {
const v = Number(row[k]);
if (Number.isFinite(v)) return v;
}
}
// then try any key that smells like f10.7
for (const k of Object.keys(row || {})) {
const kl = k.toLowerCase();
if (kl.includes('f10') || kl.includes('f107') || (kl.includes('radio') && kl.includes('flux'))) {
const v = Number(row[k]);
if (Number.isFinite(v)) return v;
}
}
return null;
};
const rows = Array.isArray(data)
? data
: (Array.isArray(data?.data) ? data.data
: (Array.isArray(data?.values) ? data.values
: (Array.isArray(data?.observations) ? data.observations : [])));
const map = {};
for (const row of rows) {
const day = pickDay(row);
if (!day) continue;
if (day < startISO || day > endISO) continue;
const val = pickVal(row);
if (!Number.isFinite(val)) continue;
map[day] = val;
}
return Object.keys(map).sort().map(d => ({ day: d, value: map[d] }));
}
async function fetchUSGSEarthquakes(startISO, endISO, magMin) {
const url = `https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson&starttime=${startISO}&endtime=${endISO}&minmagnitude=${magMin}`;
const res = await fetch(url);
if (!res.ok) throw new Error('USGS EQ fetch failed: ' + res.status);
const data = await res.json();
const byDay = {};
for (const f of (data.features || [])) {
const t = new Date(f?.properties?.time);
if (isNaN(t)) continue;
const day = t.toISOString().slice(0, 10);
const mag = Number(f?.properties?.mag);
if (!Number.isFinite(mag)) continue;
if (!byDay[day]) byDay[day] = [];
byDay[day].push(mag);
}
const days = Object.keys(byDay).sort();
return days.map(d => ({
day: d,
count: byDay[d].length,
activity: byDay[d].reduce((a, b) => a + b, 0)
}));
}
// =========================
// Drawing (Canvas)
// =========================
function hiDpi(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width * dpr));
const h = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return ctx;
}
function drawAxes(ctx, w, h, pad) {
ctx.save();
ctx.strokeStyle = 'rgba(233,238,255,0.12)';
ctx.lineWidth = 1;
const gx = 10, gy = 6;
for (let i = 0; i <= gx; i++) {
const x = pad + (w - 2 * pad) * (i / gx);
ctx.beginPath();
ctx.moveTo(x, pad);
ctx.lineTo(x, h - pad);
ctx.stroke();
}
for (let j = 0; j <= gy; j++) {
const y = pad + (h - 2 * pad) * (j / gy);
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(w - pad, y);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(233,238,255,0.18)';
ctx.beginPath();
ctx.rect(pad, pad, w - 2 * pad, h - 2 * pad);
ctx.stroke();
ctx.restore();
}
function polyline(ctx, w, h, pad, series, color, yMin, yMax) {
const n = series.length;
const x0 = pad, x1 = w - pad;
const y0 = h - pad, y1 = pad;
const span = (yMax - yMin) || 1;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const x = x0 + (x1 - x0) * (i / (n - 1));
const y = y0 - (y0 - y1) * ((series[i] - yMin) / span);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
function drawZeroLine(ctx, w, h, pad, yMin, yMax) {
if (yMin > 0 || yMax < 0) return;
const y0 = h - pad, y1 = pad;
const y = y0 - (y0 - y1) * ((0 - yMin) / (yMax - yMin));
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.22)';
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(w - pad, y);
ctx.stroke();
ctx.restore();
}
function drawBars(ctx, w, h, pad, values, color, yMin, yMax) {
const n = values.length;
const x0 = pad, x1 = w - pad;
const y0 = h - pad, y1 = pad;
const span = (yMax - yMin) || 1;
const bw = (x1 - x0) / n;
ctx.save();
ctx.fillStyle = color;
for (let i = 0; i < n; i++) {
const v = values[i];
const x = x0 + i * bw;
const y = y0 - (y0 - y1) * ((v - yMin) / span);
const top = Math.min(y, y0);
const height = Math.abs(y0 - y);
ctx.fillRect(x, top, Math.max(1, bw - 1), height);
}
ctx.restore();
}
function label(ctx, text, x, y, color) {
ctx.save();
ctx.fillStyle = color;
ctx.font = '12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
ctx.fillText(text, x, y);
ctx.restore();
}
// =========================
// App
// =========================
const el = (id) => document.getElementById(id);
const state = {
days: 365,
solarSource: 'f107',
eqMetric: 'sumMag',
maxLag: 45,
magMin: 4.0,
smooth: 7,
norm: 'z',
dates: [],
sunRaw: [],
eqRaw: [],
eqCountRaw: [],
sunSeries: [],
eqSeries: [],
lags: [],
cors: [],
nEvents: 0,
solarLabel: 'F10.7'
};
function readControls() {
state.days = parseInt(el('days').value, 10);
state.solarSource = el('solarSource').value;
state.eqMetric = el('eqMetric').value;
state.maxLag = parseInt(el('maxLag').value, 10);
state.magMin = parseFloat(el('magMin').value);
state.smooth = parseInt(el('smooth').value, 10);
state.norm = el('norm').value;
}
function normalize(arr) {
if (state.norm === 'z') return zscore(arr);
if (state.norm === 'minmax') return minmax(arr);
return arr.slice();
}
async function compute() {
readControls();
el('status').textContent = 'fetching…';
try {
const end = new Date();
const start = new Date(end.getTime() - state.days * 86400000);
const startISO = start.toISOString().slice(0, 10);
const endISO = end.toISOString().slice(0, 10);
let sunDaily = [];
if (state.solarSource === 'kp1m') {
state.solarLabel = 'Kp';
sunDaily = await fetchNOAAKp(startISO, endISO);
} else {
state.solarLabel = 'F10.7';
sunDaily = await fetchNOAAF107(startISO, endISO);
}
const eqDaily = await fetchUSGSEarthquakes(startISO, endISO, state.magMin);
const mapSun = Object.fromEntries(sunDaily.map(d => [d.day, d.value]));
const mapEqActivity = Object.fromEntries(eqDaily.map(d => [d.day, d.activity]));
const mapEqCount = Object.fromEntries(eqDaily.map(d => [d.day, d.count]));
// Build continuous UTC day list for the requested window.
const dates = [];
for (let t = new Date(startISO + 'T00:00:00Z'); t <= new Date(endISO + 'T00:00:00Z'); t = new Date(t.getTime() + 86400000)) {
dates.push(t.toISOString().slice(0, 10));
}
// Fill to full window length (same number of solar + EQ points):
// - EQ: missing day => 0 (no events meeting threshold)
// - Solar: forward-fill last known value; if none yet, back-fill from first known value
const alignedDates = [];
const sunRaw = [];
const eqRaw = [];
const eqCountRaw = [];
// precompute first known solar for backfill
let firstSolar = null;
for (const d of dates) {
const sv = mapSun[d];
if (sv != null) { firstSolar = Number(sv); break; }
}
let lastSolar = firstSolar;
let missingSolar = 0;
let missingEq = 0;
for (const d of dates) {
let sv = mapSun[d];
if (sv == null) {
sv = lastSolar;
missingSolar++;
} else {
lastSolar = Number(sv);
sv = lastSolar;
}
const av = mapEqActivity[d];
const cv = mapEqCount[d];
let eqVal;
let eqCount;
if (av == null || cv == null) {
// treat missing as no events
eqVal = 0;
eqCount = 0;
missingEq++;
} else {
eqVal = state.eqMetric === 'count' ? Number(cv) : Number(av);
eqCount = Number(cv);
}
// If we *still* don't have solar (no firstSolar and no lastSolar), skip this day.
if (!Number.isFinite(sv)) continue;
alignedDates.push(d);
sunRaw.push(Number(sv));
eqRaw.push(Number(eqVal));
eqCountRaw.push(Number(eqCount));
}
// stash counters for status
state._missingSolar = missingSolar;
state._missingEq = missingEq;
// alignedDates length should match requested window unless solar was entirely unavailable.
// (We skip days only if solar couldn't be back/forward-filled at all.)
state.dates = alignedDates;
state.sunRaw = sunRaw;
state.eqRaw = eqRaw;
state.eqCountRaw = eqCountRaw;
state.nEvents = eqDaily.reduce((s, d) => s + (d.count || 0), 0);
if (state.dates.length < 10) {
el('status').textContent = `not enough overlapping days (solar=${sunDaily.length}, eq=${eqDaily.length}, overlap=${state.dates.length})`;
state.sunSeries = [];
state.eqSeries = [];
state.lags = [];
state.cors = [];
updateStats();
draw();
return;
}
const sunSm = rollingMean(state.sunRaw, state.smooth);
const eqSm = rollingMean(state.eqRaw, state.smooth);
state.sunSeries = normalize(sunSm);
state.eqSeries = normalize(eqSm);
const maxLag = Math.max(1, state.maxLag);
state.lags = [];
state.cors = [];
for (let L = -maxLag; L <= maxLag; L++) {
state.lags.push(L);
state.cors.push(corrAtLag(state.sunSeries, state.eqSeries, L));
}
el('status').textContent = `ok (window=${state.days}d, solarPts=${sunDaily.length}, eqPts=${eqDaily.length}, used=${state.dates.length}, fillSolar=${state._missingSolar||0}, fillEQ=${state._missingEq||0})`;
updateStats();
draw();
} catch (err) {
console.error(err);
el('status').textContent = 'error: ' + String(err?.message || err);
}
}
function updateStats() {
if (!state.cors.length || !state.lags.length) {
el('bestLag').textContent = '—';
el('bestR').textContent = '—';
el('r0').textContent = '—';
el('nEvents').textContent = String(state.nEvents || 0);
return;
}
let bestIdx = 0;
let bestAbs = -Infinity;
for (let i = 0; i < state.cors.length; i++) {
const a = Math.abs(state.cors[i]);
if (a > bestAbs) { bestAbs = a; bestIdx = i; }
}
const bestLag = state.lags[bestIdx];
const bestR = state.cors[bestIdx];
const r0 = corrAtLag(state.sunSeries, state.eqSeries, 0);
el('bestLag').textContent = String(bestLag);
el('bestR').textContent = Number(bestR).toFixed(3);
el('r0').textContent = Number(r0).toFixed(3);
el('nEvents').textContent = String(state.nEvents);
const badge = (Math.abs(bestR) >= 0.4) ? 'ok' : (Math.abs(bestR) >= 0.2 ? 'warn' : 'bad');
const existing = el('bestR').parentElement.querySelector('.pill');
if (existing) existing.remove();
const pill = document.createElement('span');
pill.className = `pill ${badge}`;
pill.textContent = badge === 'ok' ? 'strong-ish' : (badge === 'warn' ? 'meh' : 'weak');
el('bestR').parentElement.querySelector('.k').appendChild(pill);
}
function draw() {
drawTimeSeries();
drawCrossCorr();
}
function drawTimeSeries() {
const canvas = el('ts');
const ctx = hiDpi(canvas);
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const pad = 22;
ctx.clearRect(0, 0, w, h);
drawAxes(ctx, w, h, pad);
if (!state.sunSeries.length || !state.eqSeries.length) {
label(ctx, 'no data (check Status)', pad + 8, pad + 16, 'rgba(233,238,255,0.92)');
el('tsMeta').textContent = `window=${state.days}d overlap=${state.dates.length}d solar=${state.solarLabel} eqMetric=${state.eqMetric}`;
return;
}
let yMin = Infinity, yMax = -Infinity;
for (const v of state.sunSeries) { if (v < yMin) yMin = v; if (v > yMax) yMax = v; }
for (const v of state.eqSeries) { if (v < yMin) yMin = v; if (v > yMax) yMax = v; }
yMin = Math.min(yMin, -1.5);
yMax = Math.max(yMax, 1.5);
drawZeroLine(ctx, w, h, pad, yMin, yMax);
polyline(ctx, w, h, pad, state.sunSeries, 'rgba(122,162,255,0.95)', yMin, yMax);
polyline(ctx, w, h, pad, state.eqSeries, 'rgba(255,184,107,0.95)', yMin, yMax);
label(ctx, `${state.solarLabel} (normalized)`, pad + 8, pad + 16, 'rgba(122,162,255,0.95)');
label(ctx, `EQ ${state.eqMetric === 'count' ? 'count' : 'activity'} (normalized)`, pad + 8, pad + 34, 'rgba(255,184,107,0.95)');
el('tsMeta').textContent = `window=${state.days}d overlap=${state.dates.length}d solar=${state.solarLabel} eqMetric=${state.eqMetric} magMin>=${state.magMin.toFixed(1)} smooth=${state.smooth} norm=${state.norm}`;
}
function drawCrossCorr() {
const canvas = el('cc');
const ctx = hiDpi(canvas);
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const pad = 22;
ctx.clearRect(0, 0, w, h);
drawAxes(ctx, w, h, pad);
if (!state.cors.length || !state.lags.length) {
drawZeroLine(ctx, w, h, pad, -1, 1);
label(ctx, 'no lag-scan (check Status)', pad + 8, pad + 16, 'rgba(233,238,255,0.92)');
el('ccMeta').textContent = '';
return;
}
const yMin = -1, yMax = 1;
drawZeroLine(ctx, w, h, pad, yMin, yMax);
drawBars(ctx, w, h, pad, state.cors, 'rgba(67,246,165,0.80)', yMin, yMax);
let bestIdx = 0;
let bestAbs = -Infinity;
for (let i = 0; i < state.cors.length; i++) {
const a = Math.abs(state.cors[i]);
if (a > bestAbs) { bestAbs = a; bestIdx = i; }
}
const n = state.cors.length;
const x0 = pad, x1 = w - pad;
const bw = (x1 - x0) / n;
const x = x0 + bestIdx * bw;
ctx.save();
ctx.fillStyle = 'rgba(255,92,122,0.60)';
ctx.fillRect(x, pad, Math.max(2, bw - 1), h - 2 * pad);
ctx.restore();
drawBars(ctx, w, h, pad, state.cors, 'rgba(67,246,165,0.80)', yMin, yMax);
const bestLag = state.lags[bestIdx];
const bestR = state.cors[bestIdx];
label(ctx, `best lag = ${bestLag}d r=${bestR.toFixed(3)} (red band)`, pad + 8, pad + 16, 'rgba(233,238,255,0.92)');
el('ccMeta').textContent = `scanned lags: ${-state.maxLag}..${state.maxLag} (positive lag means EQ follows solar)`;
}
function exportCSV() {
if (!state.dates.length) return;
const rows = [];
rows.push(['date','solar_raw','solar_norm','eq_value_raw','eq_norm','eq_count_raw'].join(','));
const sunSm = rollingMean(state.sunRaw, state.smooth);
const eqSm = rollingMean(state.eqRaw, state.smooth);
const sunN = state.sunSeries.length ? state.sunSeries : normalize(sunSm);
const eqN = state.eqSeries.length ? state.eqSeries : normalize(eqSm);
for (let i = 0; i < state.dates.length; i++) {
rows.push([
state.dates[i],
Number(state.sunRaw[i]).toFixed(6),
Number(sunN[i]).toFixed(6),
Number(state.eqRaw[i]).toFixed(6),
Number(eqN[i]).toFixed(6),
Number(state.eqCountRaw[i]).toFixed(0)
].join(','));
}
const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sun_eq_realdata.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// =========================
// Self-tests
// =========================
function assert(name, cond) {
if (!cond) throw new Error('Test failed: ' + name);
}
function runTests() {
assert('pearson identical ~ 1', Math.abs(pearson([1,2,3,4],[1,2,3,4]) - 1) < 1e-9);
assert('pearson inverse ~ -1', Math.abs(pearson([1,2,3,4],[4,3,2,1]) + 1) < 1e-9);
const x = [0, 1, 0, 0, 0];
const y = [0, 0, 1, 0, 0];
const rLag1 = corrAtLag(x, y, 1);
assert('corrAtLag detects shift', rLag1 > 0.9);
// ensure CSV join is escaped and contains newline
const demo = ['a,b', '1,2', '3,4'].join('\n');
assert('join newline contains \\n', demo.includes('\n'));
// sanity: compute returns promise
assert('compute is async', typeof compute().then === 'function');
console.log('All tests passed');
}
// wire up
el('regen').addEventListener('click', () => compute());
el('export').addEventListener('click', () => exportCSV());
window.addEventListener('resize', () => { draw(); });
// initial
try {
runTests();
compute();
} catch (e) {
console.error(e);
el('status').textContent = 'error: ' + String(e?.message || e);
}
</script>
</body>
</html>