shadow-zone / files / plan.md
plan.md
0a81e745 2026-06-09 Pavel Safronov | 1 | # Seismic Shadow Zone — App Plan |
| 2 | ||
| 3 | ## Overview | |
| 4 | ||
| 5 | A single-page TypeScript app hosted on GitLab Pages. The user sees a circular cross-section of the Earth with an earthquake source at the top. Seismic ray paths are traced through the interior and drawn on screen. The user can interactively manipulate the Earth's interior (core size, liquid vs solid, core material/velocity) and immediately see how the shadow zones change. | |
| 6 | ||
| 7 | The educational goal: make it viscerally clear *why* the shadow zone exists — it's not magic, it's refraction at the core–mantle boundary, the same physics as a lens. | |
| 8 | ||
| 9 | --- | |
| 10 | ||
| 11 | ## Physics Background | |
| 12 | ||
| 13 | ### Wave types | |
| 14 | ||
| 15 | | Wave | Travels through | Notes | | |
| 16 | |------|----------------|-------| | |
| 17 | | P-wave (compressional) | solid and liquid | Primary wave, faster | | |
| 18 | | S-wave (shear) | solid only | Cannot propagate in liquid | | |
| 19 | ||
| 20 | The P-wave shadow zone (~105°–140° from epicenter) exists because P-waves entering the liquid outer core are strongly refracted inward, skipping that angular band. The S-wave shadow zone (>~105°) exists because S-waves are entirely blocked by the liquid outer core. | |
| 21 | ||
| 22 | ### Snell's Law in a spherical Earth (Bouguer's Law) | |
| 23 | ||
| 24 | In a radially symmetric medium with P-wave velocity `v(r)`, the **ray parameter** `p` is conserved along any ray: | |
| 25 | ||
| 26 | ``` | |
| 27 | p = r · sin(i) / v(r) | |
| 28 | ``` | |
| 29 | ||
| 30 | where `r` is the current radius and `i` is the angle between the ray direction and the local radial direction (i = 0° means straight down, i = 90° means horizontal). | |
| 31 | ||
| 32 | At a boundary between two layers with velocities v₁ and v₂: | |
| 33 | ||
| 34 | ``` | |
| 35 | sin(i₂) = sin(i₁) · v₂ / v₁ | |
| 36 | ``` | |
| 37 | ||
| 38 | If `|sin(i₂)| > 1`, total internal reflection occurs (the ray cannot enter the new medium). | |
| 39 | ||
| 40 | ### Ray turning point | |
| 41 | ||
| 42 | A ray turns around (stops going deeper) at radius `r_min` where: | |
| 43 | ||
| 44 | ``` | |
| 45 | r_min / v(r_min) = p ⟹ i = 90° | |
| 46 | ``` | |
| 47 | ||
| 48 | ### Ray path differential equations | |
| 49 | ||
| 50 | In polar coordinates (r, θ) with the earthquake source at θ = 0 on the surface: | |
| 51 | ||
| 52 | ``` | |
| 53 | dθ/ds = p · v(r) / r² | |
| 54 | dr/ds = ±√(1 − (p · v(r) / r)²) [+ going up, − going down] | |
| 55 | ``` | |
| 56 | ||
| 57 | where `s` is arc length along the ray. The sign flips at the turning point. | |
| 58 | ||
| 59 | **At each integration step δs:** | |
| 60 | ||
| 61 | ``` | |
| 62 | sin_i = p · v(r) / r | |
| 63 | cos_i = √(1 − sin_i²) [clamp to 0 if sin_i ≥ 1] | |
| 64 | ||
| 65 | Δθ = sin_i / r · δs [= p·v/r² · δs] | |
| 66 | Δr = ±cos_i · δs | |
| 67 | ``` | |
| 68 | ||
| 69 | This is the simplest stable form. Use RK4 for accuracy. | |
| 70 | ||
| 71 | ### Velocity profiles | |
| 72 | ||
| 73 | Default model (simplified PREM, all values in km/s, radii in km, R_earth = 6371): | |
| 74 | ||
| 75 | | Layer | Radius range | v_P (km/s) | v_S (km/s) | State | | |
| 76 | |-------|-------------|------------|------------|-------| | |
| 77 | | Inner core | 0–1220 | 11.3 (flat) | 3.5 | Solid | | |
| 78 | | Outer core | 1220–3480 | 8.0 + 2.5·(r/3480) | 0 (liquid) | Liquid | | |
| 79 | | Lower mantle | 3480–5701 | 13.7 − 3.0·(r−3480)/(5701−3480) | 7.3 | Solid | | |
| 80 | | Upper mantle | 5701–6371 | 10.5 − 3.0·(r−5701)/670 | 5.5 | Solid | | |
| 81 | ||
| 82 | Note: velocities increase with depth in the mantle (pressure dominates). This is what causes mantle rays to curve *back up* toward the surface — a critical feature that must be implemented correctly. | |
| 83 | ||
| 84 | The velocity jump at the CMB (core–mantle boundary) is the primary cause of the shadow zone: the mantle velocity at CMB depth (~13.7 km/s) is much higher than the outer core velocity (~8.0 km/s), causing strong refraction inward. | |
| 85 | ||
| 86 | ### Why the P-wave shadow zone forms | |
| 87 | ||
| 88 | 1. Mantle rays with large ray parameter p (shallow takeoff) turn around inside the mantle and reach the surface at <~105°. | |
| 89 | 2. Rays with smaller p reach the CMB and enter the outer core. | |
| 90 | 3. At the CMB: v drops sharply (13.7 → 8.0), so `sin(i₂) = sin(i₁) · 8/13.7` — the ray bends strongly toward the radial direction. The ray is "sucked in" toward the center. | |
| 91 | 4. After passing through the core and re-emerging, these rays surface at >~140°. | |
| 92 | 5. The gap 105°–140° is the shadow zone: no ray reaches it directly. | |
| 93 | ||
| 94 | ### User-controllable parameters and their physical effect | |
| 95 | ||
| 96 | | Parameter | Physical meaning | Effect on shadow zone | | |
| 97 | |-----------|-----------------|----------------------| | |
| 98 | | Outer core radius | Size of liquid region | Larger core → larger shadow zone, moved closer to 0° | | |
| 99 | | Core P-velocity top | How much the wave slows at CMB | Slower → stronger refraction → bigger gap | | |
| 100 | | Core velocity profile | Gradient through core | Affects which rays can turn inside core | | |
| 101 | | Inner core radius | Size of solid inner core | PKiKP, PKIKP branches; subtle effect on shadow zone edges | | |
| 102 | | Core material preset | Water (1.5), magma (6), iron (8), hypothetical fast (14) | Precomputes velocity from preset | | |
| 103 | | Solid earth mode | No velocity discontinuity at CMB | Eliminates shadow zone | | |
| 104 | | S-waves toggle | Show S-wave propagation | S-waves blocked entirely by liquid core | | |
| 105 | ||
| 106 | --- | |
| 107 | ||
| 108 | ## Architecture | |
| 109 | ||
| 110 | ### Tech stack | |
| 111 | ||
| 112 | - **TypeScript** — strict mode | |
| 113 | - **Vite** — dev server and bundler | |
| 114 | - **npm** — package manager | |
| 115 | - **Canvas 2D API** — rendering (no WebGL needed; performance is adequate) | |
| 116 | - **Vanilla TS, no UI framework** — app is simple enough; React would be overkill | |
| 117 | - **No physics libraries** — all ray tracing implemented from scratch per this spec | |
| 118 | ||
| 119 | ### Project structure | |
| 120 | ||
| 121 | ``` | |
| 122 | shadow-zone/ | |
| 123 | ├── src/ | |
| 124 | │ ├── main.ts # Entry: wires DOM to modules, starts render loop | |
| 125 | │ ├── earth.ts # EarthModel: velocity v(r), layer boundaries, presets | |
| 126 | │ ├── rayTracer.ts # RayTracer: integrates ray equations, returns path points | |
| 127 | │ ├── shadowZone.ts # ShadowZone: finds angular gap by sampling many rays | |
| 128 | │ ├── renderer.ts # Renderer: draws Earth cross-section, rays, labels | |
| 129 | │ └── controls.ts # Controls: reads/writes DOM sliders and fires callbacks | |
| 130 | ├── public/ | |
| 131 | │ └── index.html # Minimal HTML: canvas + controls sidebar | |
| 132 | ├── package.json | |
| 133 | ├── tsconfig.json | |
| 134 | ├── vite.config.ts | |
| 135 | └── .gitlab-ci.yml | |
| 136 | ``` | |
| 137 | ||
| 138 | --- | |
| 139 | ||
| 140 | ## Data types | |
| 141 | ||
| 142 | ```typescript | |
| 143 | // earth.ts | |
| 144 | interface EarthLayer { | |
| 145 | rMin: number; // km, inner radius of layer | |
| 146 | rMax: number; // km, outer radius of layer | |
| 147 | vP: (r: number) => number; // P-wave velocity at radius r (km/s) | |
| 148 | vS: (r: number) => number; // S-wave velocity (0 = liquid) | |
| 149 | name: string; | |
| 150 | color: string; // CSS color for renderer | |
| 151 | } | |
| 152 | ||
| 153 | interface EarthModel { | |
| 154 | radius: number; // km, total earth radius (6371) | |
| 155 | layers: EarthLayer[]; // ordered innermost → outermost | |
| 156 | vAt(r: number, wave: 'P' | 'S'): number; // velocity at radius r | |
| 157 | layerAt(r: number): EarthLayer; | |
| 158 | } | |
| 159 | ||
| 160 | // rayTracer.ts | |
| 161 | interface RayPoint { | |
| 162 | r: number; // km, radius | |
| 163 | theta: number; // radians, epicentral angle from source | |
| 164 | } | |
| 165 | ||
| 166 | interface RayPath { | |
| 167 | points: RayPoint[]; | |
| 168 | rayParameter: number; // p value | |
| 169 | takeoffAngle: number; // degrees from vertical at source | |
| 170 | epicentralDistance: number | null; // degrees at surface, null if never resurfaces | |
| 171 | wave: 'P' | 'S'; | |
| 172 | blocked: boolean; // true if S-wave blocked by liquid core | |
| 173 | } | |
| 174 | ||
| 175 | // controls.ts | |
| 176 | interface AppState { | |
| 177 | outerCoreRadius: number; // km (default 3480) | |
| 178 | outerCoreVTop: number; // km/s at top of outer core (default 8.0) | |
| 179 | outerCoreVBottom: number; // km/s at bottom of outer core (default 10.4) | |
| 180 | innerCoreRadius: number; // km (default 1220) | |
| 181 | innerCoreV: number; // km/s (default 11.3) | |
| 182 | waveType: 'P' | 'S' | 'both'; | |
| 183 | numRays: number; // how many rays to show (default 60) | |
| 184 | preset: 'earth' | 'solid' | 'water-core' | 'giant-core' | 'tiny-core' | 'fast-core'; | |
| 185 | } | |
| 186 | ``` | |
| 187 | ||
| 188 | --- | |
| 189 | ||
| 190 | ## Component specifications | |
| 191 | ||
| 192 | ### `earth.ts` — EarthModel | |
| 193 | ||
| 194 | **Exports:** | |
| 195 | - `buildEarthModel(state: AppState): EarthModel` | |
| 196 | - `PRESETS: Record<string, AppState>` — named parameter sets | |
| 197 | ||
| 198 | **Behavior:** | |
| 199 | - Constructs layer array from `AppState` | |
| 200 | - `vAt(r, 'P')` walks layers from outside in, finds the layer containing r, calls its vP function | |
| 201 | - `vAt(r, 'S')` same for S-waves; returns 0 in liquid layers | |
| 202 | - Layer boundaries must be at the exact radii specified in state (CMB = outerCoreRadius, ICB = innerCoreRadius) | |
| 203 | ||
| 204 | **Presets to implement:** | |
| 205 | ||
| 206 | | Name | Description | Key params | | |
| 207 | |------|-------------|-----------| | |
| 208 | | `earth` | PREM-like default | as per table above | | |
| 209 | | `solid` | No liquid core; mantle velocity profile continues to center | vS > 0 everywhere | | |
| 210 | | `water-core` | Outer core has water velocity (~1.5 km/s) | extreme refraction | | |
| 211 | | `giant-core` | Outer core radius = 5000 km | huge shadow zone | | |
| 212 | | `tiny-core` | Outer core radius = 500 km | tiny shadow zone | | |
| 213 | | `fast-core` | Core faster than mantle (~15 km/s) | rays refract *outward*, no shadow zone | | |
| 214 | ||
| 215 | ### `rayTracer.ts` — RayTracer | |
| 216 | ||
| 217 | **Exports:** | |
| 218 | - `traceRay(model: EarthModel, rayParameter: number, wave: 'P' | 'S'): RayPath` | |
| 219 | - `traceAllRays(model: EarthModel, numRays: number, wave: 'P' | 'S'): RayPath[]` | |
| 220 | ||
| 221 | **`traceRay` algorithm:** | |
| 222 | ||
| 223 | ``` | |
| 224 | 1. Start at r = R_earth - epsilon, theta = 0, direction = downward (sign = -1) | |
| 225 | 2. Compute sin_i = p * v(r) / r | |
| 226 | 3. If sin_i > 1 at start: this p value is invalid (ray goes horizontally or beyond) | |
| 227 | 4. Integration loop (RK4, step size δs = 5 km): | |
| 228 | a. Compute v = model.vAt(r, wave) | |
| 229 | b. If wave = 'S' and v = 0: mark blocked = true, return | |
| 230 | c. sin_i = p * v / r; if sin_i > 1: flip sign (turning point) | |
| 231 | d. cos_i = sqrt(1 - sin_i²) | |
| 232 | e. dr/ds = sign * cos_i; dθ/ds = p * v / r² | |
| 233 | f. Check if crossing a layer boundary: | |
| 234 | - If yes: find exact boundary crossing by bisection (or linear interp) | |
| 235 | - Apply Snell's law at boundary: sin_i_new = sin_i * v_new / v_old | |
| 236 | - If sin_i_new > 1: total internal reflection (flip radial sign, keep tangential) | |
| 237 | g. Append (r, θ) to points[] | |
| 238 | h. Stop when r ≥ R_earth (ray resurfaces) or r < 1 (hit center, shouldn't happen) or loop > 50000 | |
| 239 | 5. Record epicentral distance in degrees (θ * 180/π) at resurfacing | |
| 240 | ``` | |
| 241 | ||
| 242 | **`traceAllRays`:** | |
| 243 | - Samples `numRays` values of takeoff angle from 1° to 89° (angle from vertical) | |
| 244 | - Converts takeoff angle to ray parameter: `p = R * sin(takeoff_angle_rad) / v(R)` | |
| 245 | - Returns array of RayPath objects | |
| 246 | ||
| 247 | **Performance note:** With numRays = 60 and stepSize = 5 km, each ray does ~1270 steps maximum. 60 rays × 1270 steps = 76,200 iterations. Fast in JS. Runs synchronously on each control change. | |
| 248 | ||
| 249 | ### `shadowZone.ts` — Shadow zone finder | |
| 250 | ||
| 251 | **Exports:** | |
| 252 | - `findShadowZone(model: EarthModel, wave: 'P' | 'S'): { start: number; end: number } | null` | |
| 253 | ||
| 254 | **Algorithm:** | |
| 255 | - Trace 360 rays uniformly | |
| 256 | - Record epicentral distances (sorted by ray parameter) | |
| 257 | - Find angular gap: consecutive rays where the gap > 5° indicates shadow zone | |
| 258 | ||
| 259 | ### `renderer.ts` — Canvas renderer | |
| 260 | ||
| 261 | **Exports:** | |
| 262 | - `class Renderer` | |
| 263 | - `constructor(canvas: HTMLCanvasElement)` | |
| 264 | - `render(model: EarthModel, rays: RayPath[], shadowZone: ...): void` | |
| 265 | ||
| 266 | **Rendering layers:** | |
| 267 | ||
| 268 | 1. **Background**: dark space color | |
| 269 | 2. **Earth layers** (filled concentric arcs, inner to outer): | |
| 270 | - Inner core: `#f5e642` (bright yellow) | |
| 271 | - Outer core: `#e8a020` (amber) | |
| 272 | - Mantle: `#c87820` (orange-brown) | |
| 273 | - Crust: `#888888` (gray, very thin) | |
| 274 | 3. **Ray paths**: draw each RayPath.points[] as a polyline | |
| 275 | - Convert (r, θ) to canvas Cartesian: `x = cx + (r/R_earth)*scale*sin(θ)`, `y = cy - (r/R_earth)*scale*cos(θ)` | |
| 276 | - P-rays that resurface: semi-transparent blue `rgba(100,150,255,0.4)` | |
| 277 | - S-rays that resurface: semi-transparent green `rgba(100,255,150,0.4)` | |
| 278 | - Blocked S-rays: red `rgba(255,100,100,0.3)`, only drawn to blocking point | |
| 279 | 4. **Shadow zone arc**: on the Earth's outer rim, highlight the shadow zone angular range in red/dark | |
| 280 | 5. **Angle labels**: 0°, 30°, 60°, 90°, 120°, 150°, 180° around the rim | |
| 281 | 6. **Earthquake marker**: ⚡ or a red star at top (θ = 0) | |
| 282 | 7. **Layer labels**: text inside each layer (inner core, outer core, mantle) | |
| 283 | 8. **Shadow zone label**: "Shadow Zone" with leader line | |
| 284 | ||
| 285 | **Coordinate transform (important):** | |
| 286 | - Canvas origin (0,0) = top-left | |
| 287 | - Earth center = (canvas.width/2, canvas.height/2) | |
| 288 | - θ = 0 at top (North), increases clockwise | |
| 289 | - `x = cx + (r/R) * scale * sin(θ)` | |
| 290 | - `y = cy - (r/R) * scale * cos(θ)` | |
| 291 | - `scale = canvas.height * 0.45` (so Earth fills ~90% of canvas height) | |
| 292 | ||
| 293 | Show a full circle (360°), not a half-circle. The shadow zone will appear on both sides symmetrically. | |
| 294 | ||
| 295 | ### `controls.ts` — UI controls | |
| 296 | ||
| 297 | **HTML elements expected (by id):** | |
| 298 | - `#canvas` — the canvas element | |
| 299 | - `#outer-core-radius` — range input, 100–6000 km | |
| 300 | - `#outer-core-radius-val` — display span | |
| 301 | - `#outer-core-v-top` — range input, 1.0–15.0 km/s | |
| 302 | - `#outer-core-v-top-val` — display span | |
| 303 | - `#inner-core-radius` — range input, 0–3000 km | |
| 304 | - `#inner-core-v` — range input, 1.0–15.0 km/s | |
| 305 | - `#wave-type` — select (P / S / Both) | |
| 306 | - `#preset` — select (earth / solid / water-core / giant-core / tiny-core / fast-core) | |
| 307 | - `#num-rays` — range input, 10–120 | |
| 308 | - `#shadow-zone-info` — text display showing current shadow zone angles | |
| 309 | ||
| 310 | **`controls.ts` exports:** | |
| 311 | - `initControls(onChange: (state: AppState) => void): AppState` | |
| 312 | - Reads initial values, returns initial state | |
| 313 | - Attaches all `input` event listeners | |
| 314 | - Each listener updates state and calls `onChange(state)` | |
| 315 | ||
| 316 | --- | |
| 317 | ||
| 318 | ## `main.ts` — Wiring | |
| 319 | ||
| 320 | ```typescript | |
| 321 | const state = initControls((newState) => { | |
| 322 | const model = buildEarthModel(newState); | |
| 323 | const rays = traceAllRays(model, newState.numRays, newState.waveType); | |
| 324 | const shadow = findShadowZone(model, newState.waveType); | |
| 325 | renderer.render(model, rays, shadow); | |
| 326 | updateShadowInfo(shadow); | |
| 327 | }); | |
| 328 | ||
| 329 | const model = buildEarthModel(state); | |
| 330 | const rays = traceAllRays(model, state.numRays, state.waveType); | |
| 331 | const shadow = findShadowZone(model, state.waveType); | |
| 332 | const renderer = new Renderer(document.getElementById('canvas') as HTMLCanvasElement); | |
| 333 | renderer.render(model, rays, shadow); | |
| 334 | ``` | |
| 335 | ||
| 336 | No render loop needed (not animated). Re-render on every control change. | |
| 337 | ||
| 338 | --- | |
| 339 | ||
| 340 | ## UI Layout | |
| 341 | ||
| 342 | ``` | |
| 343 | ┌─────────────────────────────────────────────────────┐ | |
| 344 | │ Seismic Shadow Zone │ | |
| 345 | ├──────────────────────┬──────────────────────────────┤ | |
| 346 | │ │ CONTROLS │ | |
| 347 | │ │ ─────────────────────────── │ | |
| 348 | │ │ Preset: [Earth ▼] │ | |
| 349 | │ CANVAS │ Outer core radius: 3480 km │ | |
| 350 | │ (Earth cross- │ ████████░░░░░░ 3480 │ | |
| 351 | │ section) │ Core P-vel (top): 8.0 km/s │ | |
| 352 | │ │ ████░░░░░░░░░░ 8.0 │ | |
| 353 | │ │ Inner core radius: 1220 km │ | |
| 354 | │ │ ████░░░░░░░░░░ 1220 │ | |
| 355 | │ │ Wave type: [P-waves ▼] │ | |
| 356 | │ │ Rays: 60 │ | |
| 357 | │ │ ─────────────────────────── │ | |
| 358 | │ │ Shadow zone: 105° – 143° │ | |
| 359 | └──────────────────────┴──────────────────────────────┘ | |
| 360 | ``` | |
| 361 | ||
| 362 | - Canvas: ~600×600px, responsive | |
| 363 | - Controls panel: right side, ~280px wide | |
| 364 | - Mobile: stack vertically (canvas on top, controls below) | |
| 365 | - Dark theme preferred (space background makes the Earth pop) | |
| 366 | ||
| 367 | --- | |
| 368 | ||
| 369 | ## Build & Deploy | |
| 370 | ||
| 371 | ### `vite.config.ts` | |
| 372 | ||
| 373 | ```typescript | |
| 374 | import { defineConfig } from 'vite'; | |
| 375 | ||
| 376 | export default defineConfig({ | |
| 377 | base: '/shadow-zone/', // GitLab Pages serves at /repo-name/ | |
| 378 | build: { | |
| 379 | outDir: 'dist', | |
| 380 | }, | |
| 381 | }); | |
| 382 | ``` | |
| 383 | ||
| 384 | ### `package.json` scripts | |
| 385 | ||
| 386 | ```json | |
| 387 | { | |
| 388 | "scripts": { | |
| 389 | "dev": "vite", | |
| 390 | "build": "tsc --noEmit && vite build", | |
| 391 | "preview": "vite preview" | |
| 392 | } | |
| 393 | } | |
| 394 | ``` | |
| 395 | ||
| 396 | ### `.gitlab-ci.yml` | |
| 397 | ||
| 398 | ```yaml | |
| 399 | image: node:20 | |
| 400 | ||
| 401 | pages: | |
| 402 | stage: deploy | |
| 403 | script: | |
| 404 | - npm ci | |
| 405 | - npm run build | |
| 406 | - mv dist public | |
| 407 | artifacts: | |
| 408 | paths: | |
| 409 | - public | |
| 410 | rules: | |
| 411 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH | |
| 412 | ``` | |
| 413 | ||
| 414 | ### `tsconfig.json` | |
| 415 | ||
| 416 | ```json | |
| 417 | { | |
| 418 | "compilerOptions": { | |
| 419 | "target": "ES2020", | |
| 420 | "module": "ESNext", | |
| 421 | "moduleResolution": "bundler", | |
| 422 | "strict": true, | |
| 423 | "noUnusedLocals": true, | |
| 424 | "noUnusedParameters": true, | |
| 425 | "lib": ["ES2020", "DOM"] | |
| 426 | }, | |
| 427 | "include": ["src"] | |
| 428 | } | |
| 429 | ``` | |
| 430 | ||
| 431 | --- | |
| 432 | ||
| 433 | ## Implementation order | |
| 434 | ||
| 435 | 1. **Scaffold**: `npm create vite@latest . -- --template vanilla-ts`, add `.gitlab-ci.yml`, `vite.config.ts` | |
| 436 | 2. **`earth.ts`**: implement `EarthLayer`, `EarthModel`, `buildEarthModel`, all presets | |
| 437 | 3. **`rayTracer.ts`**: implement `traceRay` (no boundaries first, just mantle), verify rays curve correctly | |
| 438 | 4. **Add boundary handling** to `traceRay`: CMB and ICB crossings, Snell's law, blocked S-waves | |
| 439 | 5. **`shadowZone.ts`**: implement gap finder, test against known ~105°–140° for default Earth | |
| 440 | 6. **`renderer.ts`**: draw Earth layers first, then rays, then labels and shadow zone overlay | |
| 441 | 7. **`controls.ts` + `main.ts`**: wire everything together | |
| 442 | 8. **`public/index.html`**: full layout with sidebar | |
| 443 | 9. **Polish**: colors, labels, responsive CSS, preset buttons | |
| 444 | 10. **Deploy**: push to GitLab, verify Pages build succeeds | |
| 445 | ||
| 446 | --- | |
| 447 | ||
| 448 | ## Known tricky parts | |
| 449 | ||
| 450 | **Velocity gradient in the mantle:** If implemented as a linear function of r, the rays will curve correctly. If the velocity is flat within a layer, rays will travel in straight lines within each layer and only bend at boundaries — this is less physically accurate and will not produce the smooth curves seen in the reference image. Use a continuous function. | |
| 451 | ||
| 452 | **Step size near turning point:** As the ray approaches its turning point, `cos_i → 0` and the integration can take tiny radial steps. This is fine — the ray just spends more steps near the turning point (physically correct). Don't add special-case logic here. | |
| 453 | ||
| 454 | **Boundary crossing detection:** Between two integration steps, check if the layer has changed. If it has, bisect (or linearly interpolate) to find the exact boundary crossing radius, apply Snell's law there, then continue with the new velocity. Don't just let the velocity jump mid-step. | |
| 455 | ||
| 456 | **Fast-core preset:** When the core velocity is *higher* than the mantle velocity at the CMB, Snell's law gives `sin(i₂) = sin(i₁) · v_core / v_mantle > sin(i₁)` — rays refract *away* from the radial direction. This means they spread out, and no shadow zone forms. Verify this behavior explicitly when testing presets. | |
| 457 | ||
| 458 | **Ray parameter sampling:** To get even coverage, sample by takeoff angle (not by p), since the mapping from p to Δ (epicentral distance) is non-linear and bunches up near critical values. | |
| 459 | ||
| 460 | **S-wave display:** When `waveType = 'S'`, show S-rays in the mantle and mark the point where they hit the CMB as a reflection/absorption point. Do not continue S-ray paths into the liquid outer core. |