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
- Mantle rays with large ray parameter p (shallow takeoff) turn around inside the mantle and reach the surface at <~105°.
- Rays with smaller p reach the CMB and enter the outer core.
- 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. - After passing through the core and re-emerging, these rays surface at >~140°.
- 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
- TypeScript — strict mode
- Vite — dev server and bundler
- npm — package manager
- Canvas 2D API — rendering (no WebGL needed; performance is adequate)
- Vanilla TS, no UI framework — app is simple enough; React would be overkill
- No physics libraries — all ray tracing implemented from scratch per this spec
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:
- Background: dark space color
- 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) - 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 bluergba(100,150,255,0.4)- S-rays that resurface: semi-transparent greenrgba(100,255,150,0.4)- Blocked S-rays: redrgba(255,100,100,0.3), only drawn to blocking point - Shadow zone arc: on the Earth's outer rim, highlight the shadow zone angular range in red/dark
- Angle labels: 0°, 30°, 60°, 90°, 120°, 150°, 180° around the rim
- Earthquake marker: ⚡ or a red star at top (θ = 0)
- Layer labels: text inside each layer (inner core, outer core, mantle)
- 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° │
└──────────────────────┴──────────────────────────────┘
- Canvas: ~600×600px, responsive
- Controls panel: right side, ~280px wide
- Mobile: stack vertically (canvas on top, controls below)
- Dark theme preferred (space background makes the Earth pop)
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
- Scaffold:
npm create vite@latest . -- --template vanilla-ts, add.gitlab-ci.yml,vite.config.ts earth.ts: implementEarthLayer,EarthModel,buildEarthModel, all presetsrayTracer.ts: implementtraceRay(no boundaries first, just mantle), verify rays curve correctly- Add boundary handling to
traceRay: CMB and ICB crossings, Snell's law, blocked S-waves shadowZone.ts: implement gap finder, test against known ~105°–140° for default Earthrenderer.ts: draw Earth layers first, then rays, then labels and shadow zone overlaycontrols.ts+main.ts: wire everything togetherpublic/index.html: full layout with sidebar- Polish: colors, labels, responsive CSS, preset buttons
- 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.