fuzzyworld

shadow-zone / files / plan.md

plan.md

18.6 KB · history · blame

Seismic Shadow Zone — App Plan

Overview

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.

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.


Physics Background

Wave types

Wave Travels through Notes
P-wave (compressional) solid and liquid Primary wave, faster
S-wave (shear) solid only Cannot propagate in liquid

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.

Snell's Law in a spherical Earth (Bouguer's Law)

In a radially symmetric medium with P-wave velocity v(r), the ray parameter p is conserved along any ray:

p = r · sin(i) / v(r)

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).

At a boundary between two layers with velocities v₁ and v₂:

sin(i₂) = sin(i₁) · v₂ / v₁

If |sin(i₂)| > 1, total internal reflection occurs (the ray cannot enter the new medium).

Ray turning point

A ray turns around (stops going deeper) at radius r_min where:

r_min / v(r_min) = p   ⟹   i = 90°

Ray path differential equations

In polar coordinates (r, θ) with the earthquake source at θ = 0 on the surface:

dθ/ds = p · v(r) / r²
dr/ds = ±√(1 − (p · v(r) / r)²)   [+ going up, − going down]

where s is arc length along the ray. The sign flips at the turning point.

At each integration step δs:

sin_i  = p · v(r) / r
cos_i  = √(1 − sin_i²)            [clamp to 0 if sin_i ≥ 1]

Δθ = sin_i / r · δs               [= p·v/r² · δs]
Δr = ±cos_i · δs

This is the simplest stable form. Use RK4 for accuracy.

Velocity profiles

Default model (simplified PREM, all values in km/s, radii in km, R_earth = 6371):

Layer Radius range v_P (km/s) v_S (km/s) State
Inner core 0–1220 11.3 (flat) 3.5 Solid
Outer core 1220–3480 8.0 + 2.5·(r/3480) 0 (liquid) Liquid
Lower mantle 3480–5701 13.7 − 3.0·(r−3480)/(5701−3480) 7.3 Solid
Upper mantle 5701–6371 10.5 − 3.0·(r−5701)/670 5.5 Solid

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.

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.

Why the P-wave shadow zone forms

  1. Mantle rays with large ray parameter p (shallow takeoff) turn around inside the mantle and reach the surface at <~105°.
  2. Rays with smaller p reach the CMB and enter the outer core.
  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.
  4. After passing through the core and re-emerging, these rays surface at >~140°.
  5. The gap 105°–140° is the shadow zone: no ray reaches it directly.

User-controllable parameters and their physical effect

Parameter Physical meaning Effect on shadow zone
Outer core radius Size of liquid region Larger core → larger shadow zone, moved closer to 0°
Core P-velocity top How much the wave slows at CMB Slower → stronger refraction → bigger gap
Core velocity profile Gradient through core Affects which rays can turn inside core
Inner core radius Size of solid inner core PKiKP, PKIKP branches; subtle effect on shadow zone edges
Core material preset Water (1.5), magma (6), iron (8), hypothetical fast (14) Precomputes velocity from preset
Solid earth mode No velocity discontinuity at CMB Eliminates shadow zone
S-waves toggle Show S-wave propagation S-waves blocked entirely by liquid core

Architecture

Tech stack

Project structure

shadow-zone/
├── src/
│   ├── main.ts            # Entry: wires DOM to modules, starts render loop
│   ├── earth.ts           # EarthModel: velocity v(r), layer boundaries, presets
│   ├── rayTracer.ts       # RayTracer: integrates ray equations, returns path points
│   ├── shadowZone.ts      # ShadowZone: finds angular gap by sampling many rays
│   ├── renderer.ts        # Renderer: draws Earth cross-section, rays, labels
│   └── controls.ts        # Controls: reads/writes DOM sliders and fires callbacks
├── public/
│   └── index.html         # Minimal HTML: canvas + controls sidebar
├── package.json
├── tsconfig.json
├── vite.config.ts
└── .gitlab-ci.yml

Data types

// earth.ts
interface EarthLayer {
  rMin: number;          // km, inner radius of layer
  rMax: number;          // km, outer radius of layer
  vP: (r: number) => number;   // P-wave velocity at radius r (km/s)
  vS: (r: number) => number;   // S-wave velocity (0 = liquid)
  name: string;
  color: string;         // CSS color for renderer
}

interface EarthModel {
  radius: number;        // km, total earth radius (6371)
  layers: EarthLayer[];  // ordered innermost → outermost
  vAt(r: number, wave: 'P' | 'S'): number;  // velocity at radius r
  layerAt(r: number): EarthLayer;
}

// rayTracer.ts
interface RayPoint {
  r: number;    // km, radius
  theta: number; // radians, epicentral angle from source
}

interface RayPath {
  points: RayPoint[];
  rayParameter: number;    // p value
  takeoffAngle: number;    // degrees from vertical at source
  epicentralDistance: number | null;  // degrees at surface, null if never resurfaces
  wave: 'P' | 'S';
  blocked: boolean;  // true if S-wave blocked by liquid core
}

// controls.ts
interface AppState {
  outerCoreRadius: number;   // km (default 3480)
  outerCoreVTop: number;     // km/s at top of outer core (default 8.0)
  outerCoreVBottom: number;  // km/s at bottom of outer core (default 10.4)
  innerCoreRadius: number;   // km (default 1220)
  innerCoreV: number;        // km/s (default 11.3)
  waveType: 'P' | 'S' | 'both';
  numRays: number;           // how many rays to show (default 60)
  preset: 'earth' | 'solid' | 'water-core' | 'giant-core' | 'tiny-core' | 'fast-core';
}

Component specifications

earth.ts — EarthModel

Exports: - buildEarthModel(state: AppState): EarthModel - PRESETS: Record<string, AppState> — named parameter sets

Behavior: - Constructs layer array from AppState - vAt(r, 'P') walks layers from outside in, finds the layer containing r, calls its vP function - vAt(r, 'S') same for S-waves; returns 0 in liquid layers - Layer boundaries must be at the exact radii specified in state (CMB = outerCoreRadius, ICB = innerCoreRadius)

Presets to implement:

Name Description Key params
earth PREM-like default as per table above
solid No liquid core; mantle velocity profile continues to center vS > 0 everywhere
water-core Outer core has water velocity (~1.5 km/s) extreme refraction
giant-core Outer core radius = 5000 km huge shadow zone
tiny-core Outer core radius = 500 km tiny shadow zone
fast-core Core faster than mantle (~15 km/s) rays refract outward, no shadow zone

rayTracer.ts — RayTracer

Exports: - traceRay(model: EarthModel, rayParameter: number, wave: 'P' | 'S'): RayPath - traceAllRays(model: EarthModel, numRays: number, wave: 'P' | 'S'): RayPath[]

traceRay algorithm:

1. Start at r = R_earth - epsilon, theta = 0, direction = downward (sign = -1)
2. Compute sin_i = p * v(r) / r
3. If sin_i > 1 at start: this p value is invalid (ray goes horizontally or beyond)
4. Integration loop (RK4, step size δs = 5 km):
   a. Compute v = model.vAt(r, wave)
   b. If wave = 'S' and v = 0: mark blocked = true, return
   c. sin_i = p * v / r; if sin_i > 1: flip sign (turning point)
   d. cos_i = sqrt(1 - sin_i²)
   e. dr/ds = sign * cos_i; dθ/ds = p * v / r²
   f. Check if crossing a layer boundary:
      - If yes: find exact boundary crossing by bisection (or linear interp)
      - Apply Snell's law at boundary: sin_i_new = sin_i * v_new / v_old
      - If sin_i_new > 1: total internal reflection (flip radial sign, keep tangential)
   g. Append (r, θ) to points[]
   h. Stop when r ≥ R_earth (ray resurfaces) or r < 1 (hit center, shouldn't happen) or loop > 50000
5. Record epicentral distance in degrees (θ * 180/π) at resurfacing

traceAllRays: - Samples numRays values of takeoff angle from 1° to 89° (angle from vertical) - Converts takeoff angle to ray parameter: p = R * sin(takeoff_angle_rad) / v(R) - Returns array of RayPath objects

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.

shadowZone.ts — Shadow zone finder

Exports: - findShadowZone(model: EarthModel, wave: 'P' | 'S'): { start: number; end: number } | null

Algorithm: - Trace 360 rays uniformly - Record epicentral distances (sorted by ray parameter) - Find angular gap: consecutive rays where the gap > 5° indicates shadow zone

renderer.ts — Canvas renderer

Exports: - class Renderer - constructor(canvas: HTMLCanvasElement) - render(model: EarthModel, rays: RayPath[], shadowZone: ...): void

Rendering layers:

  1. Background: dark space color
  2. Earth layers (filled concentric arcs, inner to outer): - Inner core: #f5e642 (bright yellow) - Outer core: #e8a020 (amber) - Mantle: #c87820 (orange-brown) - Crust: #888888 (gray, very thin)
  3. Ray paths: draw each RayPath.points[] as a polyline - Convert (r, θ) to canvas Cartesian: x = cx + (r/R_earth)*scale*sin(θ), y = cy - (r/R_earth)*scale*cos(θ) - P-rays that resurface: semi-transparent blue rgba(100,150,255,0.4) - S-rays that resurface: semi-transparent green rgba(100,255,150,0.4) - Blocked S-rays: red rgba(255,100,100,0.3), only drawn to blocking point
  4. Shadow zone arc: on the Earth's outer rim, highlight the shadow zone angular range in red/dark
  5. Angle labels: 0°, 30°, 60°, 90°, 120°, 150°, 180° around the rim
  6. Earthquake marker: ⚡ or a red star at top (θ = 0)
  7. Layer labels: text inside each layer (inner core, outer core, mantle)
  8. Shadow zone label: "Shadow Zone" with leader line

Coordinate transform (important): - Canvas origin (0,0) = top-left - Earth center = (canvas.width/2, canvas.height/2) - θ = 0 at top (North), increases clockwise - x = cx + (r/R) * scale * sin(θ) - y = cy - (r/R) * scale * cos(θ) - scale = canvas.height * 0.45 (so Earth fills ~90% of canvas height)

Show a full circle (360°), not a half-circle. The shadow zone will appear on both sides symmetrically.

controls.ts — UI controls

HTML elements expected (by id): - #canvas — the canvas element - #outer-core-radius — range input, 100–6000 km - #outer-core-radius-val — display span - #outer-core-v-top — range input, 1.0–15.0 km/s - #outer-core-v-top-val — display span - #inner-core-radius — range input, 0–3000 km - #inner-core-v — range input, 1.0–15.0 km/s - #wave-type — select (P / S / Both) - #preset — select (earth / solid / water-core / giant-core / tiny-core / fast-core) - #num-rays — range input, 10–120 - #shadow-zone-info — text display showing current shadow zone angles

controls.ts exports: - initControls(onChange: (state: AppState) => void): AppState - Reads initial values, returns initial state - Attaches all input event listeners - Each listener updates state and calls onChange(state)


main.ts — Wiring

const state = initControls((newState) => {
  const model = buildEarthModel(newState);
  const rays = traceAllRays(model, newState.numRays, newState.waveType);
  const shadow = findShadowZone(model, newState.waveType);
  renderer.render(model, rays, shadow);
  updateShadowInfo(shadow);
});

const model = buildEarthModel(state);
const rays = traceAllRays(model, state.numRays, state.waveType);
const shadow = findShadowZone(model, state.waveType);
const renderer = new Renderer(document.getElementById('canvas') as HTMLCanvasElement);
renderer.render(model, rays, shadow);

No render loop needed (not animated). Re-render on every control change.


UI Layout

┌─────────────────────────────────────────────────────┐
│  Seismic Shadow Zone                                │
├──────────────────────┬──────────────────────────────┤
│                      │  CONTROLS                    │
│                      │  ─────────────────────────── │
│                      │  Preset: [Earth ▼]           │
│      CANVAS          │  Outer core radius: 3480 km  │
│   (Earth cross-      │  ████████░░░░░░  3480        │
│    section)          │  Core P-vel (top): 8.0 km/s  │
│                      │  ████░░░░░░░░░░  8.0         │
│                      │  Inner core radius: 1220 km  │
│                      │  ████░░░░░░░░░░  1220        │
│                      │  Wave type: [P-waves ▼]      │
│                      │  Rays: 60                    │
│                      │  ─────────────────────────── │
│                      │  Shadow zone: 105° – 143°    │
└──────────────────────┴──────────────────────────────┘

Build & Deploy

vite.config.ts

import { defineConfig } from 'vite';

export default defineConfig({
  base: '/shadow-zone/',   // GitLab Pages serves at /repo-name/
  build: {
    outDir: 'dist',
  },
});

package.json scripts

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  }
}

.gitlab-ci.yml

image: node:20

pages:
  stage: deploy
  script:
    - npm ci
    - npm run build
    - mv dist public
  artifacts:
    paths:
      - public
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "lib": ["ES2020", "DOM"]
  },
  "include": ["src"]
}

Implementation order

  1. Scaffold: npm create vite@latest . -- --template vanilla-ts, add .gitlab-ci.yml, vite.config.ts
  2. earth.ts: implement EarthLayer, EarthModel, buildEarthModel, all presets
  3. rayTracer.ts: implement traceRay (no boundaries first, just mantle), verify rays curve correctly
  4. Add boundary handling to traceRay: CMB and ICB crossings, Snell's law, blocked S-waves
  5. shadowZone.ts: implement gap finder, test against known ~105°–140° for default Earth
  6. renderer.ts: draw Earth layers first, then rays, then labels and shadow zone overlay
  7. controls.ts + main.ts: wire everything together
  8. public/index.html: full layout with sidebar
  9. Polish: colors, labels, responsive CSS, preset buttons
  10. Deploy: push to GitLab, verify Pages build succeeds

Known tricky parts

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.

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.

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.

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.

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.

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.

Source
1# Seismic Shadow Zone — App Plan
2
3## Overview
4
5A 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
7The 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
20The 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
24In a radially symmetric medium with P-wave velocity `v(r)`, the **ray parameter** `p` is conserved along any ray:
25
26```
27p = r · sin(i) / v(r)
28```
29
30where `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
32At a boundary between two layers with velocities v₁ and v₂:
33
34```
35sin(i₂) = sin(i₁) · v₂ / v₁
36```
37
38If `|sin(i₂)| > 1`, total internal reflection occurs (the ray cannot enter the new medium).
39
40### Ray turning point
41
42A ray turns around (stops going deeper) at radius `r_min` where:
43
44```
45r_min / v(r_min) = p ⟹ i = 90°
46```
47
48### Ray path differential equations
49
50In polar coordinates (r, θ) with the earthquake source at θ = 0 on the surface:
51
52```
53dθ/ds = p · v(r) / r²
54dr/ds = ±√(1 − (p · v(r) / r)²) [+ going up, − going down]
55```
56
57where `s` is arc length along the ray. The sign flips at the turning point.
58
59**At each integration step δs:**
60
61```
62sin_i = p · v(r) / r
63cos_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
69This is the simplest stable form. Use RK4 for accuracy.
70
71### Velocity profiles
72
73Default 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
82Note: 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
84The 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
881. Mantle rays with large ray parameter p (shallow takeoff) turn around inside the mantle and reach the surface at <~105°.
892. Rays with smaller p reach the CMB and enter the outer core.
903. 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.
914. After passing through the core and re-emerging, these rays surface at >~140°.
925. 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```
122shadow-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
144interface 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
153interface 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
161interface RayPoint {
162 r: number; // km, radius
163 theta: number; // radians, epicentral angle from source
164}
165
166interface 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
176interface 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```
2241. Start at r = R_earth - epsilon, theta = 0, direction = downward (sign = -1)
2252. Compute sin_i = p * v(r) / r
2263. If sin_i > 1 at start: this p value is invalid (ray goes horizontally or beyond)
2274. 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
2395. 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
2681. **Background**: dark space color
2692. **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)
2743. **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
2794. **Shadow zone arc**: on the Earth's outer rim, highlight the shadow zone angular range in red/dark
2805. **Angle labels**: 0°, 30°, 60°, 90°, 120°, 150°, 180° around the rim
2816. **Earthquake marker**: ⚡ or a red star at top (θ = 0)
2827. **Layer labels**: text inside each layer (inner core, outer core, mantle)
2838. **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
293Show 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
321const 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
329const model = buildEarthModel(state);
330const rays = traceAllRays(model, state.numRays, state.waveType);
331const shadow = findShadowZone(model, state.waveType);
332const renderer = new Renderer(document.getElementById('canvas') as HTMLCanvasElement);
333renderer.render(model, rays, shadow);
334```
335
336No 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
374import { defineConfig } from 'vite';
375
376export 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
399image: node:20
400
401pages:
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
4351. **Scaffold**: `npm create vite@latest . -- --template vanilla-ts`, add `.gitlab-ci.yml`, `vite.config.ts`
4362. **`earth.ts`**: implement `EarthLayer`, `EarthModel`, `buildEarthModel`, all presets
4373. **`rayTracer.ts`**: implement `traceRay` (no boundaries first, just mantle), verify rays curve correctly
4384. **Add boundary handling** to `traceRay`: CMB and ICB crossings, Snell's law, blocked S-waves
4395. **`shadowZone.ts`**: implement gap finder, test against known ~105°–140° for default Earth
4406. **`renderer.ts`**: draw Earth layers first, then rays, then labels and shadow zone overlay
4417. **`controls.ts` + `main.ts`**: wire everything together
4428. **`public/index.html`**: full layout with sidebar
4439. **Polish**: colors, labels, responsive CSS, preset buttons
44410. **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.