shadow-zone / files / src / main.ts
main.ts
0ed88387 2026-06-12 Pavel Safronov | 1 | import { initControls } from './controls'; |
| 2 | import { buildEarthModel } from './earth'; | |
| 3 | import type { EarthModel } from './earth'; | |
| 4 | import { traceAllRays } from './rayTracer'; | |
| 5 | import type { RayPath } from './rayTracer'; | |
| 6 | import { findShadowZone } from './shadowZone'; | |
| 7 | import type { ShadowZoneResult } from './shadowZone'; | |
| 8 | import { Renderer } from './renderer'; | |
| 9 | import { getDataset } from './earthquakeData'; | |
| 10 | import { validateModel, fitnessLabel, countShadowZoneMismatches } from './validator'; | |
| 11 | ||
| 12 | const canvas = document.getElementById('canvas') as HTMLCanvasElement; | |
| 13 | const canvasWrap = canvas.parentElement as HTMLDivElement; | |
| 14 | const renderer = new Renderer(canvas); | |
| 15 | const shadowInfoEl = document.getElementById('shadow-zone-info') as HTMLDivElement; | |
| 16 | const validationEl = document.getElementById('validation-info') as HTMLDivElement; | |
| 17 | ||
| 18 | // Restore collapsed/expanded state of the About section | |
| 19 | const descDetails = document.getElementById('description-details') as HTMLDetailsElement; | |
| 20 | descDetails.open = localStorage.getItem('descOpen') !== '0'; // default: open | |
| 21 | descDetails.addEventListener('toggle', () => { | |
| 22 | localStorage.setItem('descOpen', descDetails.open ? '1' : '0'); | |
| 23 | }); | |
| 24 | ||
| 25 | // Cached render state — updated on every physics change, reused on resize | |
| 26 | let cachedModel: EarthModel | null = null; | |
| 27 | let cachedRays: RayPath[] = []; | |
| 28 | let cachedShadow: ShadowZoneResult[] = []; | |
| 29 | ||
| 30 | function resizeCanvas(): void { | |
| 31 | const size = Math.min(canvasWrap.clientWidth - 24, canvasWrap.clientHeight - 24); | |
| 32 | if (size < 100) return; | |
| 33 | if (canvas.width === size && canvas.height === size) return; | |
| 34 | canvas.width = size; | |
| 35 | canvas.height = size; | |
| 36 | if (cachedModel) renderer.render(cachedModel, cachedRays, cachedShadow); | |
| 37 | } | |
| 38 | new ResizeObserver(resizeCanvas).observe(canvasWrap); | |
| 39 | ||
| 40 | function updateShadowInfo( | |
| 41 | pShadows: ShadowZoneResult[], | |
| 42 | sShadows: ShadowZoneResult[], | |
| 43 | waveType: string, | |
| 44 | ): void { | |
| 45 | const summary = (label: string, zones: ShadowZoneResult[]) => | |
| 46 | zones.length === 0 | |
| 47 | ? `No ${label} shadow zone` | |
| 48 | : `${zones.length} ${label} shadow zone${zones.length > 1 ? 's' : ''}`; | |
| 49 | ||
| 50 | if (waveType === 'P') { | |
| 51 | shadowInfoEl.textContent = summary('P-wave', pShadows); | |
| 52 | } else if (waveType === 'S') { | |
| 53 | shadowInfoEl.textContent = summary('S-wave', sShadows); | |
| 54 | } else { | |
| 55 | shadowInfoEl.textContent = | |
| 56 | summary('P-wave', pShadows) + ' · ' + summary('S-wave', sShadows); | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | function updateValidation( | |
| 61 | model: ReturnType<typeof buildEarthModel>, | |
| 62 | earthquakeId: string, | |
| 63 | ): void { | |
| 64 | if (!earthquakeId) { | |
| 65 | validationEl.style.display = 'none'; | |
| 66 | return; | |
| 67 | } | |
| 68 | ||
| 69 | const dataset = getDataset(earthquakeId); | |
| 70 | if (!dataset) { | |
| 71 | validationEl.style.display = 'none'; | |
| 72 | return; | |
| 73 | } | |
| 74 | ||
| 75 | validationEl.style.display = ''; | |
| 76 | const result = validateModel(model, dataset); | |
| 77 | const mismatch = countShadowZoneMismatches(model, dataset); | |
| 78 | ||
| 79 | if (result.noData) { | |
| 80 | validationEl.innerHTML = | |
| 81 | `<b>📡 ${dataset.name}</b><br/>` + | |
| 82 | `<span style="color:#f0a040">No data yet. Run:</span><br/>` + | |
| 83 | `<code>npm run fetch-data</code>`; | |
| 84 | return; | |
| 85 | } | |
| 86 | ||
| 87 | if (result.stationCount === 0) { | |
| 88 | validationEl.innerHTML = | |
| 89 | `<b>📡 ${dataset.name}</b><br/>No matched P arrivals.`; | |
| 90 | return; | |
| 91 | } | |
| 92 | ||
| 93 | const label = fitnessLabel(result); | |
| 94 | validationEl.innerHTML = | |
| 95 | `<b>📡 ${dataset.name}</b><br/>` + | |
| 96 | `${label}<br/>` + | |
| 97 | `RMS residual: <b>${result.rmsSec.toFixed(1)} s</b><br/>` + | |
| 98 | `Mean residual: ${result.meanResidualSec.toFixed(1)} s<br/>` + | |
| 99 | `Stations matched: ${result.stationCount}<br/>` + | |
| 100 | `Hit rate ±5s: ${(result.hitRate5s * 100).toFixed(0)}%` + | |
| 101 | (mismatch.total > 0 | |
| 102 | ? `<br/>Shadow zone mismatches: ${mismatch.inShadow}/${mismatch.total}` | |
| 103 | : ''); | |
| 104 | } | |
| 105 | ||
| 106 | function doRender(newState: Parameters<typeof initControls>[0] extends (s: infer S) => void ? S : never): void { | |
| 107 | const model = buildEarthModel(newState); | |
| 108 | const rays = traceAllRays(model, newState.numRays, newState.waveType); | |
| 109 | const pShadows = findShadowZone(model, 'P'); | |
| 110 | const sShadows = newState.waveType !== 'P' ? findShadowZone(model, 'S') : []; | |
| 111 | // Canvas shows the wave type that's currently displayed; 'both' defaults to P zones | |
| 112 | const displayShadows = newState.waveType === 'S' ? sShadows : pShadows; | |
| 113 | cachedModel = model; | |
| 114 | cachedRays = rays; | |
| 115 | cachedShadow = displayShadows; | |
| 116 | renderer.render(model, rays, displayShadows); | |
| 117 | updateShadowInfo(pShadows, sShadows, newState.waveType); | |
| 118 | updateValidation(model, newState.earthquakeId); | |
| 119 | } | |
| 120 | ||
| 121 | // ── Tooltip on shadow zone hover ────────────────────────────────────────────── | |
| 122 | ||
| 123 | const tooltipEl = document.createElement('div'); | |
| 124 | tooltipEl.id = 'shadow-tooltip'; | |
| 125 | document.body.appendChild(tooltipEl); | |
| 126 | ||
| 127 | function hitTestShadow( | |
| 128 | mx: number, my: number, | |
| 129 | shadows: ShadowZoneResult[], | |
| 130 | cx: number, cy: number, | |
| 131 | canvasScale: number, | |
| 132 | ): ShadowZoneResult | null { | |
| 133 | const arcW = canvasScale * 0.05; | |
| 134 | const dist = Math.sqrt((mx - cx) ** 2 + (my - cy) ** 2); | |
| 135 | if (Math.abs(dist - canvasScale) > arcW * 1.5) return null; | |
| 136 | // Angle from top (clockwise), normalised to [−π, π] | |
| 137 | let topAngle = Math.atan2(my - cy, mx - cx) + Math.PI / 2; | |
| 138 | if (topAngle > Math.PI) topAngle -= 2 * Math.PI; | |
| 139 | const epicDist = Math.abs(topAngle) * 180 / Math.PI; | |
| 140 | return shadows.find(z => epicDist >= z.start && epicDist <= z.end) ?? null; | |
| 141 | } | |
| 142 | ||
| 143 | canvas.addEventListener('mousemove', (e) => { | |
| 144 | const rect = canvas.getBoundingClientRect(); | |
| 145 | const mx = (e.clientX - rect.left) * (canvas.width / rect.width); | |
| 146 | const my = (e.clientY - rect.top) * (canvas.height / rect.height); | |
| 147 | const canvasScale = Math.min(canvas.width, canvas.height) * 0.40; | |
| 148 | const hit = hitTestShadow(mx, my, cachedShadow, canvas.width / 2, canvas.height / 2, canvasScale); | |
| 149 | if (hit) { | |
| 150 | tooltipEl.style.display = 'block'; | |
| 151 | tooltipEl.style.left = (e.clientX + 14) + 'px'; | |
| 152 | tooltipEl.style.top = (e.clientY - 10) + 'px'; | |
| 153 | tooltipEl.textContent = | |
| 154 | `Shadow zone: ${hit.start.toFixed(1)}°–${hit.end.toFixed(1)}° (${(hit.end - hit.start).toFixed(1)}° gap)`; | |
| 155 | } else { | |
| 156 | tooltipEl.style.display = 'none'; | |
| 157 | } | |
| 158 | }); | |
| 159 | ||
| 160 | canvas.addEventListener('mouseleave', () => { tooltipEl.style.display = 'none'; }); | |
| 161 | ||
| 162 | // ── Init ────────────────────────────────────────────────────────────────────── | |
| 163 | ||
| 164 | const state = initControls(doRender); | |
| 165 | doRender(state); |