shadow-zone / files / src / main.ts
main.ts
| 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); |