fuzzyworld

shadow-zone / files / src / main.ts

main.ts

6.3 KB · history · blame

1import { initControls } from './controls';
2import { buildEarthModel } from './earth';
3import type { EarthModel } from './earth';
4import { traceAllRays } from './rayTracer';
5import type { RayPath } from './rayTracer';
6import { findShadowZone } from './shadowZone';
7import type { ShadowZoneResult } from './shadowZone';
8import { Renderer } from './renderer';
9import { getDataset } from './earthquakeData';
10import { validateModel, fitnessLabel, countShadowZoneMismatches } from './validator';
11
12const canvas = document.getElementById('canvas') as HTMLCanvasElement;
13const canvasWrap = canvas.parentElement as HTMLDivElement;
14const renderer = new Renderer(canvas);
15const shadowInfoEl = document.getElementById('shadow-zone-info') as HTMLDivElement;
16const validationEl = document.getElementById('validation-info') as HTMLDivElement;
17
18// Restore collapsed/expanded state of the About section
19const descDetails = document.getElementById('description-details') as HTMLDetailsElement;
20descDetails.open = localStorage.getItem('descOpen') !== '0'; // default: open
21descDetails.addEventListener('toggle', () => {
22 localStorage.setItem('descOpen', descDetails.open ? '1' : '0');
23});
24
25// Cached render state — updated on every physics change, reused on resize
26let cachedModel: EarthModel | null = null;
27let cachedRays: RayPath[] = [];
28let cachedShadow: ShadowZoneResult[] = [];
29
30function 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}
38new ResizeObserver(resizeCanvas).observe(canvasWrap);
39
40function 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
60function 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
106function 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
123const tooltipEl = document.createElement('div');
124tooltipEl.id = 'shadow-tooltip';
125document.body.appendChild(tooltipEl);
126
127function 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
143canvas.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
160canvas.addEventListener('mouseleave', () => { tooltipEl.style.display = 'none'; });
161
162// ── Init ──────────────────────────────────────────────────────────────────────
163
164const state = initControls(doRender);
165doRender(state);