shadow-zone / files / src / earth.ts
earth.ts
| 1 | import type { AppState } from './controls'; |
| 2 | |
| 3 | // Map a P-wave velocity (km/s) to a fill colour. |
| 4 | // Range 1–15.5 km/s → very dark brown (slow) to bright yellow (fast). |
| 5 | // Same velocity always produces the same colour, regardless of which layer it |
| 6 | // comes from — so equal-speed regions look identical on screen. |
| 7 | function velocityToColor(v: number): string { |
| 8 | const t = Math.max(0, Math.min(1, (v - 1.0) / 14.5)); |
| 9 | const hue = Math.round(22 + t * 26); // 22° (red-orange) → 48° (golden yellow) |
| 10 | const sat = Math.round(65 + t * 30); // 65% → 95% |
| 11 | const lit = Math.round(14 + t * 48); // 14% → 62% |
| 12 | return `hsl(${hue}, ${sat}%, ${lit}%)`; |
| 13 | } |
| 14 | |
| 15 | export interface EarthLayer { |
| 16 | rMin: number; |
| 17 | rMax: number; |
| 18 | vP: (r: number) => number; |
| 19 | vS: (r: number) => number; |
| 20 | name: string; |
| 21 | color: string; |
| 22 | } |
| 23 | |
| 24 | export interface EarthModel { |
| 25 | radius: number; |
| 26 | layers: EarthLayer[]; |
| 27 | coreModifier: string; // '' | 'negative-refraction' | 'zero-index' | 'absorber' |
| 28 | cmbRadius: number; // outer core radius (km), used by ray tracer for modifier checks |
| 29 | vAt(r: number, wave: 'P' | 'S'): number; |
| 30 | layerAt(r: number): EarthLayer; |
| 31 | } |
| 32 | |
| 33 | export const R_EARTH = 6371; |
| 34 | |
| 35 | export function buildEarthModel(state: AppState): EarthModel { |
| 36 | const { outerCoreRadius, outerCoreVTop, outerCoreVBottom, innerCoreRadius, innerCoreV } = state; |
| 37 | |
| 38 | // Clamp inner core so it fits inside outer core |
| 39 | const icr = Math.min(innerCoreRadius, outerCoreRadius - 1); |
| 40 | |
| 41 | // Mantle: linear velocity with depth (increases going down, i.e. as r decreases). |
| 42 | // Lower mantle: 3480–5701 km v_P = 13.7 → 10.7 (with r) |
| 43 | // Upper mantle: 5701–6371 km v_P = 10.5 → 7.5 (with r) |
| 44 | // We parameterise generically so the CMB velocity matches outerCoreRadius. |
| 45 | const CMB = outerCoreRadius; |
| 46 | |
| 47 | // Fixed mantle model (not user-controlled) referenced from plan.md PREM table. |
| 48 | // Lower mantle base at CMB: 13.7 km/s, top at 5701: 10.7 km/s |
| 49 | // Upper mantle base at 5701: 10.5 km/s, top at surface: 7.5 km/s |
| 50 | const LM_RMIN = CMB; |
| 51 | const LM_RMAX = 5701; |
| 52 | |
| 53 | const layers: EarthLayer[] = []; |
| 54 | |
| 55 | // ── Inner core ─────────────────────────────────────────────────────────── |
| 56 | if (icr > 0) { |
| 57 | layers.push({ |
| 58 | rMin: 0, |
| 59 | rMax: icr, |
| 60 | vP: () => innerCoreV, |
| 61 | vS: () => 3.5, |
| 62 | name: 'Inner Core', |
| 63 | color: velocityToColor(innerCoreV), |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | // ── Outer core ─────────────────────────────────────────────────────────── |
| 68 | // Velocity varies linearly from outerCoreVBottom at r=icr to outerCoreVTop at r=CMB. |
| 69 | // Treated as liquid (vS=0) when outerCoreVTop < 10 km/s; solid otherwise. |
| 70 | // Exception: spiral-trap uses v(r) = outerCoreVTop * (r/CMB) — zero at center, |
| 71 | // producing constant sinI = p*v₀/CMB so rays spiral inward without turning. |
| 72 | const ocRange = CMB - icr; |
| 73 | const ocMidV = ocRange <= 0 ? outerCoreVTop |
| 74 | : (outerCoreVBottom + outerCoreVTop) / 2; |
| 75 | const ocIsSolid = outerCoreVTop >= 10; |
| 76 | const isSpiralTrap = state.coreModifier === 'spiral-trap'; |
| 77 | layers.push({ |
| 78 | rMin: icr, |
| 79 | rMax: CMB, |
| 80 | vP: isSpiralTrap |
| 81 | ? (r) => Math.max(0.001, outerCoreVTop * (r / CMB)) |
| 82 | : (r) => { |
| 83 | if (ocRange <= 0) return outerCoreVTop; |
| 84 | const t = (r - icr) / ocRange; // 0 at inner boundary, 1 at CMB |
| 85 | return outerCoreVBottom + t * (outerCoreVTop - outerCoreVBottom); |
| 86 | }, |
| 87 | vS: ocIsSolid |
| 88 | ? (r) => { |
| 89 | if (ocRange <= 0) return outerCoreVTop * 0.6; |
| 90 | const t = (r - icr) / ocRange; |
| 91 | return (outerCoreVBottom + t * (outerCoreVTop - outerCoreVBottom)) * 0.6; |
| 92 | } |
| 93 | : () => 0, |
| 94 | name: 'Outer Core', |
| 95 | color: velocityToColor(ocMidV), |
| 96 | }); |
| 97 | |
| 98 | // ── Mantle (lower + upper) ─────────────────────────────────────────────── |
| 99 | // For presets that change outerCoreRadius, the lower mantle simply starts at CMB. |
| 100 | // If CMB > 5701 there is no lower mantle, only upper. |
| 101 | if (CMB < LM_RMAX) { |
| 102 | const lmRmin = CMB; |
| 103 | const lmRmax = Math.min(LM_RMAX, R_EARTH); |
| 104 | const vLmBot = 13.7; |
| 105 | const vLmTop = 10.7; |
| 106 | layers.push({ |
| 107 | rMin: lmRmin, |
| 108 | rMax: lmRmax, |
| 109 | vP: (r) => { |
| 110 | const t = (r - lmRmin) / (lmRmax - lmRmin); |
| 111 | return vLmBot + t * (vLmTop - vLmBot); |
| 112 | }, |
| 113 | vS: (r) => { |
| 114 | const t = (r - lmRmin) / (lmRmax - lmRmin); |
| 115 | return 7.3 + t * (6.5 - 7.3); |
| 116 | }, |
| 117 | name: 'Lower Mantle', |
| 118 | color: velocityToColor((vLmBot + vLmTop) / 2), |
| 119 | }); |
| 120 | } |
| 121 | |
| 122 | // Upper mantle (only if CMB < R_EARTH) |
| 123 | const umRmin = Math.max(CMB, LM_RMAX); |
| 124 | if (umRmin < R_EARTH) { |
| 125 | const umRmax = R_EARTH; |
| 126 | const vUmBot = CMB >= LM_RMAX ? 13.7 - 3.0 * (CMB - LM_RMIN) / (LM_RMAX - LM_RMIN) : 10.5; |
| 127 | const vUmTop = 7.5; |
| 128 | layers.push({ |
| 129 | rMin: umRmin, |
| 130 | rMax: umRmax, |
| 131 | vP: (r) => { |
| 132 | const t = (r - umRmin) / (umRmax - umRmin); |
| 133 | return vUmBot + t * (vUmTop - vUmBot); |
| 134 | }, |
| 135 | vS: (r) => { |
| 136 | const t = (r - umRmin) / (umRmax - umRmin); |
| 137 | return 4.5 + t * (5.5 - 4.5); |
| 138 | }, |
| 139 | name: 'Upper Mantle', |
| 140 | color: velocityToColor((vUmBot + vUmTop) / 2), |
| 141 | }); |
| 142 | } |
| 143 | |
| 144 | // Sort layers innermost → outermost |
| 145 | layers.sort((a, b) => a.rMin - b.rMin); |
| 146 | |
| 147 | const model: EarthModel = { |
| 148 | radius: R_EARTH, |
| 149 | layers, |
| 150 | coreModifier: state.coreModifier, |
| 151 | cmbRadius: CMB, |
| 152 | vAt(r: number, wave: 'P' | 'S'): number { |
| 153 | const layer = this.layerAt(r); |
| 154 | return wave === 'P' ? layer.vP(r) : layer.vS(r); |
| 155 | }, |
| 156 | layerAt(r: number): EarthLayer { |
| 157 | // Walk from inner to outer |
| 158 | for (const l of this.layers) { |
| 159 | if (r <= l.rMax) return l; |
| 160 | } |
| 161 | return this.layers[this.layers.length - 1]; |
| 162 | }, |
| 163 | }; |
| 164 | |
| 165 | return model; |
| 166 | } |
| 167 | |
| 168 | // ── Presets ─────────────────────────────────────────────────────────────────── |
| 169 | |
| 170 | const BASE: AppState = { |
| 171 | outerCoreRadius: 3480, |
| 172 | outerCoreVTop: 8.0, |
| 173 | outerCoreVBottom: 10.4, |
| 174 | innerCoreRadius: 1220, |
| 175 | innerCoreV: 11.3, |
| 176 | waveType: 'P', |
| 177 | numRays: 60, |
| 178 | preset: 'earth', |
| 179 | earthquakeId: '', |
| 180 | coreModifier: '', |
| 181 | }; |
| 182 | |
| 183 | export const PRESETS: Record<string, AppState> = { |
| 184 | earth: { ...BASE, preset: 'earth' }, |
| 185 | solid: { |
| 186 | // Solid (no liquid outer core): velocities increase with depth continuously. |
| 187 | // outerCoreVTop matches the lower mantle at the CMB (13.7) — no P-wave discontinuity. |
| 188 | // outerCoreVTop >= 10 triggers solid vS behaviour in buildEarthModel. |
| 189 | ...BASE, |
| 190 | outerCoreVTop: 13.7, |
| 191 | outerCoreVBottom: 14.5, |
| 192 | innerCoreV: 14.5, |
| 193 | preset: 'solid', |
| 194 | }, |
| 195 | 'water-core': { ...BASE, outerCoreVTop: 1.5, outerCoreVBottom: 1.8, preset: 'water-core' }, |
| 196 | 'giant-core': { ...BASE, outerCoreRadius: 5000, innerCoreRadius: 1220, preset: 'giant-core' }, |
| 197 | 'tiny-core': { ...BASE, outerCoreRadius: 500, innerCoreRadius: 200, preset: 'tiny-core' }, |
| 198 | 'fast-core': { ...BASE, outerCoreVTop: 15.0, outerCoreVBottom: 14.0, innerCoreV: 14.5, preset: 'fast-core' }, |
| 199 | |
| 200 | // ── Impossible / metamaterial cores ───────────────────────────────────── |
| 201 | // Ray tracer interprets model.coreModifier to apply non-standard boundary physics. |
| 202 | |
| 203 | // Acoustic metamaterial with negative effective bulk modulus and density. |
| 204 | // Snell's law is negated at the CMB: transmitted ray bends to the same side |
| 205 | // of the normal as the incident ray. Angular direction reverses inside the core. |
| 206 | 'neg-refraction': { ...BASE, coreModifier: 'negative-refraction', preset: 'neg-refraction' }, |
| 207 | |
| 208 | // v_core → ∞ (zero-index medium): phase velocity is infinite, so sin(θ_t) → 0 |
| 209 | // for all incident angles. Every transmitted ray is forced radial inside the core |
| 210 | // with no angular accumulation. The core acts as a perfect converging lens. |
| 211 | 'zero-index': { ...BASE, coreModifier: 'zero-index', preset: 'zero-index' }, |
| 212 | |
| 213 | // v(r) = v₀·(r/R_CMB): velocity → 0 at centre. Snell parameter sinI = p·v₀/R_CMB |
| 214 | // is constant, so rays spiral inward indefinitely — a seismic black hole. |
| 215 | // Inner core removed (innerCoreRadius: 0) so the v∝r profile extends to r=0. |
| 216 | 'spiral-trap': { ...BASE, innerCoreRadius: 0, coreModifier: 'spiral-trap', preset: 'spiral-trap' }, |
| 217 | |
| 218 | // Retrograde velocity gradient: v decreases with depth (opposite real Earth). |
| 219 | // outerCoreVTop (at CMB) > outerCoreVBottom (at ICB) → v/r falls going inward, |
| 220 | // so rays never reach a turning point and are TIR'd at the ICB velocity jump. |
| 221 | // The core acts as a concave mirror; the shadow zone expands. |
| 222 | 'retrograde': { ...BASE, outerCoreVTop: 8.0, outerCoreVBottom: 2.0, coreModifier: '', preset: 'retrograde' }, |
| 223 | |
| 224 | // All waves absorbed at the CMB — nothing penetrates the core. |
| 225 | // Shadow zone is the pure geometric shadow of the core disk. |
| 226 | 'absorber': { ...BASE, coreModifier: 'absorber', preset: 'absorber' }, |
| 227 | }; |