diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f1b3b..a4fd1a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,82 @@ All notable changes to TsunamiSimulator. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [SemVer](https://semver.org/spec/v2.0.0.html). +## [Unreleased] — Deep correctness, reliability & UX hardening + +### Fixed — physics correctness +- **Synolakis 1987 coastal run-up corrected.** `synolakis_runup_m` multiplied + by the offshore *amplitude* instead of the offshore *depth*, so every run-up + / inundation figure (overlay bars, Inspect readout) under-predicted by a + factor of `d/H`. The implementation now matches the documented + `R = 2.831·√(cot β)·H^(5/4)/d^(1/4)` Carrier-Greenspan form, and the + feature-gated `synolakis_matches_carrier_greenspan_envelope` validation — + previously failing by up to 100 % — now passes. Added a non-gated + closed-form regression test so the default suite guards it. +- **Non-finite seismic magnitude eliminated.** A custom landslide with + `drop_height_m = 0` (admitted by the IPC validator) produced zero kinetic + energy and a `-inf` `seismic_mw_equivalent` that crossed the IPC boundary + into the UI. A shared, floored `mw_from_radiated_j` helper now backs the + asteroid, nuclear, and landslide magnitude paths. +- **Latent NaN sources guarded** in the asteroid cavity-scaling (zero diameter + / out-of-range angle), nuclear cavity radius (negative burst depth → + cube-root of a negative number), and Lamb-wave envelope (zero source radius + → `cos(NaN)` at arrival). + +### Fixed — reliability & safety +- **`simulate_grid` compute budget.** The cell count and step count were each + capped but their *product* was not, so a single request could wedge the + blocking worker for minutes. A combined cell-steps budget now rejects + pathological requests up front. +- **`simulate_grid` polar/longitude robustness.** A source beyond ±80° + latitude no longer builds an inverted (degenerate, silently blank) grid, and + the source longitude is normalised so the returned bbox stays inside the + frame Cesium expects. Longitude validation is now a single canonical ±180° + contract across every command. +- **GPU SWE path.** Fixed a missing `bytemuck` dependency that meant the `gpu` + feature never compiled. The GPU readback no longer advances simulated time + over a failed/garbage field — it returns a clean failure so the dispatcher + falls back to the CPU — and the dispatcher only routes all-wet grids to the + (linear, land-mask-free) kernel so it can't re-flood continents. +- **SWE sponge boundary** is now applied (thinner) on small grids instead of + silently reverting to reflective edges. +- **Preset registry** now has a test asserting every shipped preset satisfies + the same input bounds the live `*_initial_conditions` commands enforce, and + `find_preset` no longer rebuilds the whole registry on each lookup. + +### Fixed — UX & accessibility +- **Custom number fields are editable again.** Scenario-builder inputs held a + draft string and clamp on blur, so clearing a field to retype no longer + snaps it to its minimum on every keystroke. +- **Failures are no longer silent.** Preset/scenario IPC errors, preset-list + load failures (with retry), and PNG/share/video export failures now surface + a visible toast / inline status instead of only a console log. +- **Modal focus management.** Settings, Citations, first-run, and tour dialogs + now move focus inside on open, trap Tab/Shift-Tab, and restore focus on close + (WCAG 2.4.3 / 2.1.2). +- **Globe NaN-proofing.** The Inspect readout and run-up labels coalesce + non-finite physics to an em dash, the SWE imagery layer rejects non-finite + bounding boxes before handing them to Cesium, stale wavefront rings are torn + down when the source clears, and the Inspect readout now uses the source’s + real water depth instead of a hardcoded 4000 m. +- Inspect uses the live viewer instance (not a stale closure); the dev-mode + StrictMode remount no longer leaves a blank globe; SWE Play restarts from the + top at the final frame and stays usable after Cancel; the Settings Save + button shows a saving state; the timeline tolerates a non-finite time. + +### Fixed — security & DX +- CSP/permissions: tightened the `shell:allow-open` allow-list (scoped Forbes + to the cited author path, added the explicit repo URL with a trailing-slash + glob). +- Production builds now **fail loudly if a personal `VITE_CESIUM_TOKEN` would + be inlined** into the distributable bundle (override with + `ALLOW_TOKEN_IN_BUNDLE=1`). +- Settings store uses a read-only init probe so it no longer writes an + `__init_probe` key into the user’s `settings.json`; the video exporter has a + watchdog so a missing `MediaRecorder` stop event can’t hang forever; export + filenames neutralise Windows reserved device names and clamp length. +- App version string corrected to `v0.4.0` (was a stale `v0.2.1`); refreshed + stale "planned / scaffold" docs for the now-shipped Okada and GPU kernel code. + ## [0.4.0] - 2026-05-25 — Premium polish + GPU SWE + Lamb-wave coupling ### Premium polish pass diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3288a8c..f63b382 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3919,9 +3919,10 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tsunami-simulator" -version = "0.2.1" +version = "0.4.0" dependencies = [ "base64 0.22.1", + "bytemuck", "png 0.17.16", "pollster", "rayon", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b72d67d..1599ecb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,7 +41,7 @@ validation = [] # integrated GPU adapter on the host. Falls back to CPU on adapter # failure. Off by default — Linux CI runners may not have a usable # Vulkan/Metal device. -gpu = ["dep:wgpu", "dep:pollster"] +gpu = ["dep:wgpu", "dep:pollster", "dep:bytemuck"] [dependencies.wgpu] version = "26.0" @@ -53,6 +53,13 @@ features = ["wgsl"] version = "0.4" optional = true +# Zero-copy casting of the f32 grid buffers + the `GpuParams` uniform struct. +# Only pulled in with the `gpu` feature (gpu.rs is the sole user). +[dependencies.bytemuck] +version = "1" +optional = true +features = ["derive"] + [profile.release] opt-level = 3 lto = true diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 70aea82..47b1eda 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -26,7 +26,7 @@ { "url": "https://www.nature.com/*" }, { "url": "https://www.researchgate.net/*" }, { "url": "https://www.sciencedirect.com/*" }, - { "url": "https://www.forbes.com/*" }, + { "url": "https://www.forbes.com/sites/davidhambling/*" }, { "url": "http://www.tsunamisociety.org/*" }, { "url": "http://library.lanl.gov/*" }, { "url": "https://nuclearsecrecy.com/*" }, @@ -38,7 +38,8 @@ { "url": "https://portal.opentopography.org/*" }, { "url": "https://www.naturalearthdata.com/*" }, { "url": "https://www.clawpack.org/*" }, - { "url": "https://github.com/SysAdminDoc/TsunamiSimulator*" } + { "url": "https://github.com/SysAdminDoc/TsunamiSimulator" }, + { "url": "https://github.com/SysAdminDoc/TsunamiSimulator/*" } ] } ] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a681229..e470e63 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -41,21 +41,22 @@ fn check_finite_nonnegative(name: &str, value: f64) -> Result<(), String> { Ok(()) } +// Canonical geographic domain enforced uniformly across every command: +// latitude in [-90, 90], longitude in [-180, 180]. The whole frontend (preset +// registry, coastal-point DB, globe picks) already works in this range, so +// accepting the looser ±360 longitude only admitted un-normalised values that +// then produced off-frame Cesium bounding boxes. Keep all callers in one domain. +const LON_ABS_MAX: f64 = 180.0; + fn check_lat_lon(loc: &GeoPoint) -> Result<(), String> { - if !loc.lat_deg.is_finite() || loc.lat_deg.abs() > 90.0 { - return Err(format!("location.lat_deg {} out of range", loc.lat_deg)); - } - if !loc.lon_deg.is_finite() || loc.lon_deg.abs() > 360.0 { - return Err(format!("location.lon_deg {} out of range", loc.lon_deg)); - } - Ok(()) + check_lat_lon_values("location", loc.lat_deg, loc.lon_deg) } fn check_lat_lon_values(prefix: &str, lat: f64, lon: f64) -> Result<(), String> { if !lat.is_finite() || lat.abs() > 90.0 { return Err(format!("{prefix} latitude {lat} out of range")); } - if !lon.is_finite() || lon.abs() > 360.0 { + if !lon.is_finite() || lon.abs() > LON_ABS_MAX { return Err(format!("{prefix} longitude {lon} out of range")); } Ok(()) @@ -522,18 +523,8 @@ pub struct LambWaveSampleResult { /// volcanic source. #[tauri::command] pub fn lamb_wave_sample(req: LambWaveSampleRequest) -> Result { - if !req.source.lat_deg.is_finite() || req.source.lat_deg.abs() > 90.0 { - return Err("source latitude out of range".into()); - } - if !req.source.lon_deg.is_finite() || req.source.lon_deg.abs() > 360.0 { - return Err("source longitude out of range".into()); - } - if !req.lat.is_finite() || req.lat.abs() > 90.0 { - return Err("receiver latitude out of range".into()); - } - if !req.lon.is_finite() || req.lon.abs() > 360.0 { - return Err("receiver longitude out of range".into()); - } + check_lat_lon_values("source", req.source.lat_deg, req.source.lon_deg)?; + check_lat_lon_values("receiver", req.lat, req.lon)?; if !req.time_s.is_finite() || req.time_s < 0.0 { return Err("time_s must be finite and non-negative".into()); } @@ -707,6 +698,13 @@ pub struct SimulateGridResponse { /// Hard cap on the SWE grid size — protects us against runaway requests. const SWE_MAX_CELLS: usize = 4_000_000; +/// Hard cap on total *work* (grid cells × time steps). The cell cap and the +/// step cap are each individually bounded, but their product is what actually +/// determines wall-clock time; without this a request that passes both (e.g. +/// 4 M cells × ~250 k steps) could wedge the blocking worker for many minutes. +/// 5e10 cell-steps is a few seconds of CPU on this solver and far above any +/// legitimate interactive request (a typical run is well under 1e7). +const SWE_MAX_CELL_STEPS: u64 = 50_000_000_000; /// Hard cap on number of snapshots per simulation. const SWE_MAX_SNAPSHOTS: usize = 240; /// Hard cap on simulated time — runaway scrubs. @@ -723,7 +721,7 @@ fn validate_simulate_grid(req: &SimulateGridRequest) -> Result<(), String> { req.source.lat_deg )); } - if !req.source.lon_deg.is_finite() || req.source.lon_deg.abs() > 360.0 { + if !req.source.lon_deg.is_finite() || req.source.lon_deg.abs() > LON_ABS_MAX { return Err(format!( "source longitude {} out of range", req.source.lon_deg @@ -778,16 +776,20 @@ pub async fn simulate_grid(req: SimulateGridRequest) -> Result south). A source beyond ±80° would + // otherwise make `(lat - half).max(-80)` exceed `(lat + half).min(80)`, + // producing an inverted/degenerate grid that silently renders nothing. + let lat = req.source.lat_deg.clamp(-80.0, 80.0); + // Normalise longitude into [-180, 180) so the returned snapshot bbox + // stays inside the frame Cesium expects (a source at lon 359 must not + // build a box spanning [357, 361]). + let lon = ((req.source.lon_deg + 180.0).rem_euclid(360.0)) - 180.0; let half = req.box_half_size_deg; let cell = 1.0 / req.cells_per_deg; - // Clamp latitudes to avoid polar singularities, keeping the box - // symmetric around the source even if it would otherwise exceed - // [-80, 80]. - let south = (lat - half).max(-80.0); - let north = (lat + half).min(80.0); + let south = (lat - half).max(-89.0); + let north = (lat + half).min(89.0); let west = lon - half; let east = lon + half; @@ -857,6 +859,23 @@ pub async fn simulate_grid(req: SimulateGridRequest) -> Result 0.0 { + (req.t_end_s / dt).clamp(1.0, 1.0e9) + } else { + 1.0 + }; + let work = (grid.nx as u64) + .saturating_mul(grid.ny as u64) + .saturating_mul(est_steps as u64); + if work > SWE_MAX_CELL_STEPS { + return Err(format!( + "simulation too expensive (~{} cell-steps; cap {}). Reduce cells_per_deg, box_half_size_deg, or t_end_s.", + work, SWE_MAX_CELL_STEPS + )); + } + // F4-01 — when compiled with `--features gpu`, try the wgpu // dispatch path. Fall back to CPU cleanly if no adapter is // available (Linux CI, integrated-only laptops without @@ -920,10 +939,29 @@ fn run_simulation_dispatch( n_snapshots: usize, ) -> (Vec, bool) { use crate::physics::solver::gpu::GpuTimeStepper; - if let Some(gpu) = GpuTimeStepper::new(grid, dt_s, crate::physics::constants::MANNING_N_COASTAL) - { - let snaps = run_simulation_gpu(grid, &gpu, dt_s, t_end_s, n_snapshots); - return (snaps, true); + use crate::physics::solver::LAND_DEPTH_THRESHOLD_M; + + // The WGSL kernel is a LINEAR leapfrog with reflective edges and NO land + // masking, whereas the CPU default is nonlinear + sponge + land-aware. To + // avoid the GPU silently producing materially different physics, only route + // to it when the grid is entirely wet — otherwise the missing land mask + // would re-flood the 1 m land sentinel into a slow-spreading halo across + // continents (exactly the artefact the CPU mask was added to remove). + // (The remaining sponge/advection differences for all-wet grids are tracked + // for a future WGSL kernel-parity pass; see solver/kernels.rs.) + let all_wet = grid.h_m.iter().all(|&h| h > LAND_DEPTH_THRESHOLD_M); + if all_wet { + if let Some(gpu) = + GpuTimeStepper::new(grid, dt_s, crate::physics::constants::MANNING_N_COASTAL) + { + // Snapshot the (already-injected) state so a mid-run GPU failure + // can cleanly retry on the CPU rather than emitting a frozen field. + let pristine = grid.clone(); + if let Some(snaps) = run_simulation_gpu(grid, &gpu, dt_s, t_end_s, n_snapshots) { + return (snaps, true); + } + *grid = pristine; + } } let stepper = TimeStepper::new(dt_s); (run_simulation(grid, &stepper, t_end_s, n_snapshots), false) @@ -944,6 +982,8 @@ fn run_simulation_dispatch( /// spaced snapshots as the CPU path. The GPU dispatch loop runs /// `take` steps between each snapshot then reads back into `grid` /// so the snapshot encoder can serialise η as PNG. +/// Returns `None` if any GPU step fails (map/poll error or non-finite field), +/// signalling the dispatcher to fall back to the CPU path. #[cfg(feature = "gpu")] fn run_simulation_gpu( grid: &mut SwGrid, @@ -951,13 +991,13 @@ fn run_simulation_gpu( dt_s: f64, t_end_s: f64, n_snapshots: usize, -) -> Vec { +) -> Option> { const MAX_TOTAL_STEPS: usize = 1_000_000; let n = n_snapshots.max(2); let mut snaps = Vec::with_capacity(n); snaps.push(grid.snapshot()); if !t_end_s.is_finite() || t_end_s <= 0.0 { - return snaps; + return Some(snaps); } let dt = if dt_s.is_finite() && dt_s > 0.0 { dt_s @@ -974,10 +1014,12 @@ fn run_simulation_gpu( let target_step = (k * total_steps) / (n - 1); let current_step = ((k - 1) * total_steps) / (n - 1); let take = target_step.saturating_sub(current_step).max(1); - gpu.step(grid, take); + if !gpu.step(grid, take) { + return None; + } snaps.push(grid.snapshot()); } - snaps + Some(snaps) } #[tauri::command] diff --git a/src-tauri/src/physics/asteroid.rs b/src-tauri/src/physics/asteroid.rs index eeb5de1..259fe9a 100644 --- a/src-tauri/src/physics/asteroid.rs +++ b/src-tauri/src/physics/asteroid.rs @@ -73,11 +73,23 @@ impl AsteroidImpact { /// ``` /// with `C_T = 1.88`, `β = 0.22` for water. pub fn transient_cavity_diameter_m(&self) -> f64 { - let theta_rad = self.angle_deg.to_radians(); + // Defensive guards (the IPC command already rejects these, but this + // is a `pub` helper that presets and future callers reach directly): + // - a zero/negative diameter makes `pi_term` infinite and then + // `0 · inf = NaN`, poisoning the whole InitialDisplacement; + // - `sin(θ)` raised to a fractional power is NaN for θ outside + // (0°, 180°), so clamp the impact angle into the physical (0, 90]. + if !self.diameter_m.is_finite() + || self.diameter_m <= 0.0 + || !self.velocity_m_s.is_finite() + { + return 0.0; + } + let theta_rad = self.angle_deg.clamp(f64::MIN_POSITIVE, 90.0).to_radians(); let pi_term = self.velocity_m_s.powi(2) / (G_EARTH * self.diameter_m); SCHMIDT_HOLSAPPLE_CT * self.diameter_m - * (self.density_kg_m3 / RHO_SEAWATER).powf(1.0 / 3.0) + * (self.density_kg_m3.max(0.0) / RHO_SEAWATER).powf(1.0 / 3.0) * pi_term.powf(SCHMIDT_HOLSAPPLE_BETA) * theta_rad.sin().powf(1.0 / 3.0) } @@ -96,6 +108,11 @@ impl AsteroidImpact { /// amplitude saturates at the local water depth. pub fn initial_amplitude_m(&self) -> f64 { let cavity_depth = self.transient_cavity_depth_m(); + // Don't let a non-finite cavity depth get laundered into a + // plausible-looking depth-limited amplitude by `min`/`max`. + if !cavity_depth.is_finite() { + return 0.0; + } let saturated = cavity_depth.min(self.water_depth_m); 0.5 * saturated.max(0.0) } @@ -105,9 +122,9 @@ impl AsteroidImpact { /// Gault 1975 for hypervelocity oceanic impacts). pub fn seismic_mw_equivalent(&self) -> f64 { let radiated_j = 0.01 * self.kinetic_energy_j(); - // log10(M0) = log10(E_s / 5e-5) ⇒ Mw = (2/3) (log10 M0 − 9.1) - let m0 = radiated_j / 5.0e-5; - (2.0 / 3.0) * (m0.log10() - 9.1) + // log10(M0) = log10(E_s / 5e-5) ⇒ Mw = (2/3) (log10 M0 − 9.1). + // Shared floored helper guards against zero/negative energy → -inf. + super::mw_from_radiated_j(radiated_j) } /// Snapshot of the source ready for the propagation solver. diff --git a/src-tauri/src/physics/earthquake.rs b/src-tauri/src/physics/earthquake.rs index 2f2492c..787a685 100644 --- a/src-tauri/src/physics/earthquake.rs +++ b/src-tauri/src/physics/earthquake.rs @@ -13,11 +13,13 @@ //! approximation: initial water-surface displacement = vertical seafloor //! displacement (validated for wavelengths much longer than ocean depth). //! -//! This scaffold provides an order-of-magnitude estimate from moment magnitude -//! using Abe (1979) `log M0 = 1.5 M_w + 9.1`. A full Okada-1985 dislocation -//! field (strike, dip, rake, slip, fault length × width, depth) is planned for -//! v0.3.0 — it requires elliptic-integral evaluations that we'll add when the -//! full propagation grid is in place. +//! The full Okada-1985 dislocation field (strike, dip, rake, slip, fault +//! length × width, depth) is implemented in [`super::okada`] and is the primary +//! peak-amplitude source whenever `slip_m > 0` (see [`Self::initial_displacement`]). +//! For slip-less, magnitude-only sources we fall back to the Geist-Dmowska 1999 +//! empirical `log(η₀) ≈ 0.5·M_w − 3.3`. (Okada 1985 is closed-form algebraic — +//! no elliptic integrals; those appear only in the older Mansinha-Smylie 1971 +//! formulation.) use serde::{Deserialize, Serialize}; @@ -30,13 +32,13 @@ pub struct EarthquakeSource { pub mw: f64, /// Hypocentral depth, meters. pub depth_m: f64, - /// Fault strike, degrees clockwise from north. For Okada (planned). + /// Fault strike, degrees clockwise from north. Drives the Okada 1985 field. pub strike_deg: f64, - /// Fault dip, degrees from horizontal. For Okada (planned). + /// Fault dip, degrees from horizontal. Drives the Okada 1985 field. pub dip_deg: f64, - /// Slip rake, degrees. For Okada (planned). + /// Slip rake, degrees. Drives the Okada 1985 field. pub rake_deg: f64, - /// Average slip on the fault, meters. For Okada (planned). + /// Average slip on the fault, meters. Drives the Okada 1985 field. pub slip_m: f64, /// Fault length along strike, meters. Pass 0 to derive from Wells & /// Coppersmith 1994 scaling: `log L = 0.5·M_w − 1.85`. diff --git a/src-tauri/src/physics/lamb_wave.rs b/src-tauri/src/physics/lamb_wave.rs index 0345b98..df7650c 100644 --- a/src-tauri/src/physics/lamb_wave.rs +++ b/src-tauri/src/physics/lamb_wave.rs @@ -102,6 +102,13 @@ impl LambWaveSource { // Half-width: pulse FWHM ~ 2 σ over c_L. With source_radius_m // as σ, the pulse passes the observer in ~2σ/c_L seconds. let half_width_s = self.source_radius_m / LAMB_WAVE_SPEED_M_S; + // A zero (or non-finite) source radius collapses the pulse window to a + // point: the `|Δt| > 0` guard is false exactly at arrival, and the + // cosine envelope below would evaluate `cos(π·0/0) = cos(NaN) = NaN`. + // Treat a degenerate pulse as no contribution. + if !half_width_s.is_finite() || half_width_s <= 0.0 { + return 0.0; + } if (time_s - arrival_t).abs() > half_width_s { return 0.0; } @@ -157,4 +164,15 @@ mod tests { // Pacific basin amplification was strongest over ~9.8 km bathymetry. assert!(h > 9_000.0 && h < 10_500.0, "Proudman depth {} m off published", h); } + + #[test] + fn zero_source_radius_does_not_nan() { + let s = LambWaveSource { peak_pressure_pa: 200.0, source_radius_m: 0.0 }; + let range = 1_000_000.0; + let arrival_t = s.arrival_time_s(range); + // Exactly at arrival is the degenerate case that produced cos(NaN). + let eta = s.surface_depression_m(range, arrival_t); + assert!(eta.is_finite(), "depression must be finite, got {eta}"); + assert_eq!(eta, 0.0); + } } diff --git a/src-tauri/src/physics/landslide.rs b/src-tauri/src/physics/landslide.rs index e371d45..7b038c9 100644 --- a/src-tauri/src/physics/landslide.rs +++ b/src-tauri/src/physics/landslide.rs @@ -130,9 +130,11 @@ impl LandslideSource { /// Equivalent seismic moment magnitude. Landslide slides radiate ~0.1% of /// kinetic energy seismically (Eissler & Kanamori 1987). pub fn seismic_mw_equivalent(&self) -> f64 { + // A subaerial/submarine slide entered with drop_height_m = 0 yields + // zero free-fall velocity and therefore zero kinetic energy; the + // floored helper keeps Mw finite (→ very small) instead of -inf. let radiated_j = 1.0e-3 * self.kinetic_energy_j(); - let m0 = radiated_j / 5.0e-5; - (2.0 / 3.0) * (m0.log10() - 9.1) + super::mw_from_radiated_j(radiated_j) } pub fn initial_displacement(&self) -> InitialDisplacement { @@ -205,4 +207,26 @@ mod tests { ); assert!(d.source_energy_j > 1.0e14, "Lituya energy too low"); } + + /// A submarine slope-failure entered with drop_height_m = 0 has zero + /// free-fall energy. The displacement snapshot must stay fully finite — + /// the seismic Mw used to be -inf (log10 of zero energy), which then + /// serialised over IPC and surfaced as a non-finite magnitude in the UI. + #[test] + fn zero_drop_height_keeps_displacement_finite() { + let s = LandslideSource { + kind: LandslideKind::Submarine, + volume_m3: 1.0e9, + density_kg_m3: 2500.0, + drop_height_m: 0.0, + slope_deg: 10.0, + water_depth_m: 1000.0, + water_body_width_m: 5000.0, + location: GeoPoint { lat_deg: 0.0, lon_deg: 0.0, depth_m: 1000.0 }, + }; + let d = s.initial_displacement(); + assert!(d.seismic_mw_equivalent.is_finite(), "Mw must be finite, got {}", d.seismic_mw_equivalent); + assert!(d.peak_amplitude_m.is_finite()); + assert!(d.source_energy_j.is_finite()); + } } diff --git a/src-tauri/src/physics/mod.rs b/src-tauri/src/physics/mod.rs index ff754c7..0735c6e 100644 --- a/src-tauri/src/physics/mod.rs +++ b/src-tauri/src/physics/mod.rs @@ -4,7 +4,7 @@ //! - [`asteroid`] — Ward & Asphaug 2000, Schmidt & Holsapple 1982 cavity scaling. //! - [`nuclear`] — Glasstone & Dolan 1977 + Le Méhauté 1996 + DNA 1996 efficiency. //! - [`landslide`] — Fritz & Hager 2001 (Lituya); Slingerland & Voight. -//! - [`earthquake`] — Okada 1985 fault dislocation (scaffold). +//! - [`earthquake`] — Okada 1985 fault dislocation (see [`okada`]). //! //! ## Propagation + runup //! - [`shallow_water`] — linear long-wave dispersion, Synolakis 1987 runup, @@ -28,6 +28,21 @@ pub mod validation; use serde::{Deserialize, Serialize}; +/// Equivalent seismic moment magnitude from radiated seismic energy (J), +/// via the Gutenberg-Richter energy relation inverted into Hanks-Kanamori +/// `Mw = (2/3)·(log10 M0 − 9.1)` with `M0 = E_s / 5e-5`. +/// +/// The argument is floored to `f64::MIN_POSITIVE` before `log10` so a zero +/// or negative energy (e.g. a landslide with `drop_height_m = 0`, which the +/// IPC layer currently admits) can never produce `-inf`/`NaN` and poison the +/// `InitialDisplacement` snapshot that is serialised to the UI. Callers should +/// still pass non-negative energy; this is a defensive floor, not a license to +/// feed garbage. +pub(crate) fn mw_from_radiated_j(radiated_j: f64) -> f64 { + let m0 = (radiated_j / 5.0e-5).max(f64::MIN_POSITIVE); + (2.0 / 3.0) * (m0.log10() - 9.1) +} + /// A point on Earth's surface (WGS84, degrees, with sea-level reference). #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct GeoPoint { diff --git a/src-tauri/src/physics/nuclear.rs b/src-tauri/src/physics/nuclear.rs index fa7b56c..b2b9769 100644 --- a/src-tauri/src/physics/nuclear.rs +++ b/src-tauri/src/physics/nuclear.rs @@ -74,8 +74,12 @@ impl NuclearBurst { /// For a near-surface burst (depth ~0) the cavity is "vented" and the /// effective radius is approximated by the Le Méhauté & Wang 1996 scaling. pub fn cavity_radius_m(&self) -> f64 { - let w_kt = self.yield_kt; - let p_h_atm = 1.0 + (self.burst_depth_m / 10.0); // each 10 m water = ~1 atm + let w_kt = self.yield_kt.max(0.0); + // Hydrostatic pressure at/above the surface can never drop below 1 atm, + // so floor it: a (mis-entered) negative burst depth would otherwise make + // p_h_atm ≤ 0 → division by zero (+inf radius) or a cube-root of a + // negative number (NaN radius), poisoning the amplitude path. + let p_h_atm = (1.0 + self.burst_depth_m / 10.0).max(1.0); // each 10 m water ≈ 1 atm let base = 13.6 * w_kt.powf(1.0 / 3.0) / p_h_atm.powf(1.0 / 3.0); match self.burst_mode { BurstMode::Surface => 0.5 * base, @@ -131,8 +135,7 @@ impl NuclearBurst { /// We use 1% coupling efficiency to ground motion (Glasstone & Dolan §6.92). pub fn seismic_mw_equivalent(&self) -> f64 { let radiated_j = 0.01 * self.energy_j(); - let m0 = radiated_j / 5.0e-5; - (2.0 / 3.0) * (m0.log10() - 9.1) + super::mw_from_radiated_j(radiated_j) } pub fn initial_displacement(&self) -> InitialDisplacement { diff --git a/src-tauri/src/physics/shallow_water.rs b/src-tauri/src/physics/shallow_water.rs index 4de84e9..ba21c30 100644 --- a/src-tauri/src/physics/shallow_water.rs +++ b/src-tauri/src/physics/shallow_water.rs @@ -69,9 +69,18 @@ pub fn synolakis_runup_m(offshore_amplitude_m: f64, offshore_depth_m: f64, beach return 0.0; } let amp = offshore_amplitude_m.abs(); + // Breaking gate: the Synolakis 1987 closed form is valid for H/d ≲ 0.78 + // (Miche/McCowan). Above it the wave breaks offshore and run-up saturates, + // so we clamp the ratio that drives the (H/d)^(5/4) term. let h_over_d = (amp / offshore_depth_m).min(0.78); let cot_beta = 1.0 / beach_slope_deg.to_radians().tan().max(1e-6); - 2.831 * cot_beta.sqrt() * h_over_d.powf(5.0 / 4.0) * amp + // Synolakis 1987 / Carrier-Greenspan 1958: R/d = 2.831·√(cot β)·(H/d)^(5/4), + // i.e. R = d · 2.831·√(cot β)·(H/d)^(5/4). The amplification scales with the + // *offshore depth* d, not the amplitude — multiplying by `amp` here was a + // long-standing bug that under-predicted run-up by a factor of d/H and made + // the feature-gated `synolakis_matches_carrier_greenspan_envelope` validation + // fail by up to 100 %. + 2.831 * cot_beta.sqrt() * h_over_d.powf(5.0 / 4.0) * offshore_depth_m } /// Whether a wave at the given amplitude / depth ratio is past the breaking @@ -160,26 +169,35 @@ mod tests { #[test] fn synolakis_amplifies_for_mild_slope() { // 10-m wave, 50-m offshore depth, 2° beach (representative Pacific - // shelf approach for a large near-field tsunami). H/d = 0.2 puts us - // above the amplification threshold for this slope. + // shelf approach for a large near-field tsunami). For the canonical + // R/d = 2.831·√(cot β)·(H/d)^(5/4) form this gives ≈101 m — a strong + // amplification that mild slopes are known to produce. let r = synolakis_runup_m(10.0, 50.0, 2.0); - // Mild slopes amplify — expect ~1.5× to 5× the offshore amplitude - // before the H/d=0.78 breaking cap kicks in. assert!( - (12.0..50.0).contains(&r), - "Synolakis runup {} m outside expected 12–50 m band", + (80.0..130.0).contains(&r), + "Synolakis runup {} m outside expected 80–130 m band", r ); } - /// Tiny offshore amplitudes on the same slope should NOT amplify above - /// the offshore amplitude — the Synolakis formula only amplifies once - /// H/d crosses ~0.116 for a 2° slope, so a 1 m wave on 50 m water gives - /// a runup well under 1 m. Sanity-check that. + /// The implementation must reproduce the Synolakis 1987 closed form + /// exactly (R = 2.831·√(cot β)·H^(5/4)/d^(1/4)) below the breaking limit. + /// This is the non-feature-gated mirror of the `validation` harness so a + /// regression in the formula is caught by the default `cargo test` run. #[test] - fn synolakis_does_not_amplify_below_threshold() { - let r = synolakis_runup_m(1.0, 50.0, 2.0); - assert!(r < 1.0, "Synolakis runup {} should not amplify (H/d=0.02)", r); + fn synolakis_matches_closed_form() { + let depth_m: f64 = 50.0; + let slope_deg: f64 = 2.0; + let cot_beta = 1.0 / slope_deg.to_radians().tan(); + for & in &[0.5_f64, 1.0, 2.5, 5.0, 10.0] { + // Stay below the H/d = 0.78 breaking clamp for all cases here. + let expected = 2.831 * cot_beta.sqrt() * amp.powf(5.0 / 4.0) / depth_m.powf(1.0 / 4.0); + let got = synolakis_runup_m(amp, depth_m, slope_deg); + assert!( + (got - expected).abs() / expected < 1e-9, + "synolakis({amp}, {depth_m}, {slope_deg}) = {got}, expected {expected}" + ); + } } #[test] diff --git a/src-tauri/src/physics/solver/gpu.rs b/src-tauri/src/physics/solver/gpu.rs index b3186b2..eeeb0fd 100644 --- a/src-tauri/src/physics/solver/gpu.rs +++ b/src-tauri/src/physics/solver/gpu.rs @@ -281,13 +281,19 @@ impl GpuTimeStepper { /// the dispatch loop the η, u, v fields are copied back to /// `grid.{eta_m,u_ms,v_ms}` via staging buffers so the next call /// resumes from the correct host-side state. - pub fn step(&self, grid: &mut SwGrid, n_steps: usize) { - pollster::block_on(self.step_async(grid, n_steps)); + /// Returns `true` on success. Returns `false` (leaving `grid` and its + /// simulated time untouched) if a buffer map / device poll fails or the + /// read-back field contains non-finite values — letting the caller fall + /// back to the CPU path instead of advancing time over a frozen/garbage + /// field, which previously happened silently. + #[must_use] + pub fn step(&self, grid: &mut SwGrid, n_steps: usize) -> bool { + pollster::block_on(self.step_async(grid, n_steps)) } - async fn step_async(&self, grid: &mut SwGrid, n_steps: usize) { + async fn step_async(&self, grid: &mut SwGrid, n_steps: usize) -> bool { if n_steps == 0 { - return; + return true; } // Re-upload the host-side eta/u/v into the "A" set so multiple @@ -361,36 +367,53 @@ impl GpuTimeStepper { .map_async(wgpu::MapMode::Read, move |r| { let _ = tx_v.send(r); }); - let _ = self.device.poll(wgpu::PollType::Wait); + if self.device.poll(wgpu::PollType::Wait).is_err() { + eprintln!("[gpu] device.poll failed during readback — aborting GPU step"); + return false; + } - if let Ok(Ok(())) = rx_eta.recv() { - let view = self.readback_eta.slice(..).get_mapped_range(); - let f32_slice: &[f32] = bytemuck::cast_slice(&view); - for (i, &val) in f32_slice.iter().take(self.n_cells).enumerate() { - grid.eta_m[i] = val as f64; + // Copy each field into a scratch Vec first; only commit to `grid` + // (and advance time) once all three readbacks succeeded AND the field + // is finite, so a failed/garbage readback never freezes the field + // under a later timestamp. + let read_field = |rx: &std::sync::mpsc::Receiver>, + buf: &wgpu::Buffer| + -> Option> { + match rx.recv() { + Ok(Ok(())) => {} + _ => return None, } - drop(view); - self.readback_eta.unmap(); - } - if let Ok(Ok(())) = rx_u.recv() { - let view = self.readback_u.slice(..).get_mapped_range(); + let view = buf.slice(..).get_mapped_range(); let f32_slice: &[f32] = bytemuck::cast_slice(&view); - for (i, &val) in f32_slice.iter().take(self.n_cells).enumerate() { - grid.u_ms[i] = val as f64; - } + let out: Vec = f32_slice + .iter() + .take(self.n_cells) + .map(|&v| v as f64) + .collect(); drop(view); - self.readback_u.unmap(); - } - if let Ok(Ok(())) = rx_v.recv() { - let view = self.readback_v.slice(..).get_mapped_range(); - let f32_slice: &[f32] = bytemuck::cast_slice(&view); - for (i, &val) in f32_slice.iter().take(self.n_cells).enumerate() { - grid.v_ms[i] = val as f64; + buf.unmap(); + if out.iter().any(|v| !v.is_finite()) { + return None; + } + Some(out) + }; + + let new_eta = read_field(&rx_eta, &self.readback_eta); + let new_u = read_field(&rx_u, &self.readback_u); + let new_v = read_field(&rx_v, &self.readback_v); + match (new_eta, new_u, new_v) { + (Some(eta), Some(u), Some(v)) => { + grid.eta_m = eta; + grid.u_ms = u; + grid.v_ms = v; + grid.t_s += self.dt_s * n_steps as f64; + true + } + _ => { + eprintln!("[gpu] readback failed or produced non-finite field — aborting GPU step"); + false } - drop(view); - self.readback_v.unmap(); } - grid.t_s += self.dt_s * n_steps as f64; } fn make_bg(&self, a_is_in: bool) -> wgpu::BindGroup { @@ -496,7 +519,7 @@ mod tests { return; } }; - gpu.step(&mut g_gpu, 50); + assert!(gpu.step(&mut g_gpu, 50), "GPU step reported failure"); let mut max_diff = 0.0_f64; for (a, b) in g_cpu.eta_m.iter().zip(g_gpu.eta_m.iter()) { diff --git a/src-tauri/src/physics/solver/kernels.rs b/src-tauri/src/physics/solver/kernels.rs index c5c0b66..5d09ede 100644 --- a/src-tauri/src/physics/solver/kernels.rs +++ b/src-tauri/src/physics/solver/kernels.rs @@ -1,21 +1,25 @@ -//! WGSL kernel source code, embedded as a string constant. v0.2.0 will -//! compile this into a `wgpu::ShaderModule` and dispatch it from -//! [`TimeStepper::step`](super::TimeStepper::step). +//! WGSL kernel source code, embedded as a string constant. When the crate is +//! built with `--features gpu` this is compiled into a `wgpu::ShaderModule` +//! and dispatched by [`super::gpu::GpuTimeStepper`]. //! -//! The kernel implements one leapfrog step of the depth-averaged shallow- -//! water equations on a regular lat-lon grid. Inputs: bathymetry texture -//! `h`, two ping-pong textures for the staggered C-grid quantities -//! `(η, u, v)`. Output: updated `(η, u, v)` written to the back buffer. +//! The kernel implements one leapfrog step of the depth-averaged shallow-water +//! equations on a regular lat-lon grid. Data layout is **collocated** (A-grid), +//! all quantities in flat `array` storage buffers (not textures, not a +//! staggered C-grid): bindings 1-4 read `h`/`η`/`u`/`v`, bindings 5-7 write the +//! updated `(η, u, v)`. //! -//! For v0.1.x this is just the source text; nothing compiles it yet. The -//! file lives next to the Rust scaffold so a future WGSL syntax check -//! (`naga-cli`) can be wired into CI. +//! NOTE: this kernel intentionally implements only the *linear* momentum form +//! with reflective (zero-normal-flux) edges and **no** land masking or sponge +//! damping — unlike the CPU [`super::TimeStepper`] default +//! (`SolverMode::Nonlinear` + `BoundaryMode::Sponge` + `LAND_DEPTH_THRESHOLD_M` +//! masking). The dispatcher (`commands::run_simulation_dispatch`) therefore only +//! routes a run to the GPU when those CPU-only features are not in play; see +//! that function before porting work here. -/// Leapfrog SWE update kernel. Workgroup size 8×8 (64 invocations) over an -/// `(nx, ny)` grid. Boundary cells reflect (zero-normal-flux); coastal -/// wet/dry handling is deferred to v0.4.0. +/// Linear leapfrog SWE update kernel. Workgroup size 8×8 (64 invocations) over +/// an `(nx, ny)` collocated grid. Boundary cells reflect (zero-normal-flux). pub const SWE_LEAPFROG_WGSL: &str = r#" -// TsunamiSimulator — shallow-water leapfrog kernel (scaffold, v0.1.x) +// TsunamiSimulator — shallow-water leapfrog kernel (linear, collocated A-grid) // Reference: Mader 1988 "Numerical Modelling of Water Waves", chapter 3 // Reference: Kowalik & Murty 1993 "Numerical Modeling of Ocean Dynamics" diff --git a/src-tauri/src/physics/solver/mod.rs b/src-tauri/src/physics/solver/mod.rs index d9cb3da..ad628ed 100644 --- a/src-tauri/src/physics/solver/mod.rs +++ b/src-tauri/src/physics/solver/mod.rs @@ -611,8 +611,16 @@ impl TimeStepper { // so a long-running simulation doesn't reflect the wavefront // back into the source. if let BoundaryMode::Sponge { width_cells } = self.boundary { - if width_cells > 0 && width_cells * 2 < nx && width_cells * 2 < ny { - apply_sponge(&mut eta_new, &mut u_new, &mut v_new, nx, ny, width_cells); + // Clamp the rim width to what the grid can hold (leaving at least + // one interior cell on each axis) rather than silently disabling + // the absorbing boundary on small grids — which used to revert them + // to reflective edges with no indication, bouncing the wavefront + // back into the source. + let w = width_cells + .min(nx.saturating_sub(1) / 2) + .min(ny.saturating_sub(1) / 2); + if w > 0 { + apply_sponge(&mut eta_new, &mut u_new, &mut v_new, nx, ny, w); } } diff --git a/src-tauri/src/presets.rs b/src-tauri/src/presets.rs index 8cdc763..a0b2bfb 100644 --- a/src-tauri/src/presets.rs +++ b/src-tauri/src/presets.rs @@ -292,8 +292,16 @@ pub fn all_presets() -> Vec { ] } +/// Process-wide cached registry. `all_presets()` rebuilds (and heap-allocates) +/// the full Vec on every call; `find_preset` is invoked on every `run_preset`, +/// so back lookups with a `OnceLock` and clone only the single match. +fn presets_cached() -> &'static [Preset] { + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + CACHE.get_or_init(all_presets) +} + pub fn find_preset(id: &str) -> Option { - all_presets().into_iter().find(|p| p.id == id) + presets_cached().iter().find(|p| p.id == id).cloned() } #[cfg(test)] @@ -328,6 +336,53 @@ mod tests { } } + /// Every shipped preset must satisfy the same input bounds the live + /// `*_initial_conditions` IPC commands enforce, so the curated preset path + /// can never describe an event the custom-scenario path would reject. + #[test] + fn every_preset_source_is_within_command_bounds() { + for p in all_presets() { + let id = p.id; + match &p.source { + PresetSource::Asteroid(a) => { + assert!(a.diameter_m > 0.0, "{id}: diameter"); + assert!(a.density_kg_m3 > 0.0, "{id}: density"); + assert!(a.velocity_m_s > 0.0, "{id}: velocity"); + assert!(a.angle_deg > 0.0 && a.angle_deg <= 90.0, "{id}: angle {}", a.angle_deg); + assert!((0.0..=12_000.0).contains(&a.water_depth_m), "{id}: water_depth"); + } + PresetSource::Nuclear(n) => { + assert!(n.yield_kt > 0.0 && n.yield_kt <= 1.0e7, "{id}: yield {}", n.yield_kt); + assert!((0.0..=12_000.0).contains(&n.burst_depth_m), "{id}: burst_depth"); + assert!((0.0..=12_000.0).contains(&n.water_depth_m), "{id}: water_depth"); + } + PresetSource::Earthquake(e) => { + assert!((4.0..=10.5).contains(&e.mw), "{id}: mw {}", e.mw); + assert!((0.0..=700_000.0).contains(&e.depth_m), "{id}: depth"); + assert!(e.dip_deg >= 0.0 && e.dip_deg <= 90.0, "{id}: dip"); + assert!(e.slip_m >= 0.0, "{id}: slip"); + } + PresetSource::Landslide(l) => { + assert!(l.volume_m3 > 0.0, "{id}: volume"); + assert!(l.density_kg_m3 > 0.0, "{id}: density"); + assert!(l.drop_height_m >= 0.0, "{id}: drop_height"); + assert!(l.slope_deg >= 0.0 && l.slope_deg <= 90.0, "{id}: slope"); + assert!(l.water_depth_m > 0.0, "{id}: water_depth"); + assert!(l.water_body_width_m > 0.0, "{id}: water_body_width"); + } + } + // Location domain matches check_lat_lon (lat ±90, lon ±180). + let loc = match &p.source { + PresetSource::Asteroid(a) => a.location, + PresetSource::Nuclear(n) => n.location, + PresetSource::Earthquake(e) => e.location, + PresetSource::Landslide(l) => l.location, + }; + assert!(loc.lat_deg.abs() <= 90.0, "{id}: lat {}", loc.lat_deg); + assert!(loc.lon_deg.abs() <= 180.0, "{id}: lon {}", loc.lon_deg); + } + } + #[test] fn every_preset_initial_displacement_is_finite() { for p in all_presets() { diff --git a/src/App.tsx b/src/App.tsx index 2f61c9e..76a50ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { PresetSelector } from "./components/PresetSelector"; import { ScenarioBuilder } from "./components/ScenarioBuilder"; import { ResultsPanel } from "./components/ResultsPanel"; @@ -133,11 +133,31 @@ export default function App() { const [recording, setRecording] = useState(false); const [tourOpen, setTourOpen] = useState(false); const [tokenBannerOpen, setTokenBannerOpen] = useState(false); + const [presetsError, setPresetsError] = useState(null); + const [toast, setToast] = useState<{ msg: string; tone: "error" | "info" } | null>(null); + const toastTimer = useRef(undefined); const inTauri = useMemo(isTauri, []); const slotA = useScenarioSlot(timeS); const slotB = useScenarioSlot(timeS); + // Ephemeral status toast for actions that otherwise fail silently + // (exports, IPC errors). Auto-dismisses; replaced by the next message. + const showToast = useCallback((msg: string, tone: "error" | "info" = "info") => { + setToast({ msg, tone }); + window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(null), 6000); + }, []); + useEffect(() => () => window.clearTimeout(toastTimer.current), []); + + // Surface scenario/preset IPC failures (from either slot) to the user. + useEffect(() => { + if (slotA.error) showToast(slotA.error, "error"); + }, [slotA.error, showToast]); + useEffect(() => { + if (slotB.error) showToast(slotB.error, "error"); + }, [slotB.error, showToast]); + // Apply persisted theme once at startup. useEffect(() => { loadTheme().then(applyTheme).catch(() => applyTheme("mocha")); @@ -175,10 +195,17 @@ export default function App() { setPresets(listDemoPresets()); return; } + setPresetsError(null); api .listPresets() - .then(setPresets) - .catch((err) => console.error("listPresets failed", err)); + .then((p) => { + setPresets(p); + setPresetsError(null); + }) + .catch((err) => { + console.error("listPresets failed", err); + setPresetsError(String(err)); + }); }, [inTauri]); // First-launch banner: prompt the user to paste a Cesium ion token @@ -214,6 +241,18 @@ export default function App() { return (
Skip to globe + {toast && ( +
+ {toast.msg} + +
+ )} {tokenBannerOpen && (
@@ -247,7 +286,7 @@ export default function App() { TS TsunamiSimulator - v0.2.1 + v0.4.0
Educational only — not for evacuation. Use NOAA NTWC/PTWC for warnings. @@ -277,7 +316,7 @@ export default function App() { icon="image" onClick={() => { const ok = exportGlobePng({ preset: activePresetA, initial: slotA.initial, timeS }); - if (!ok) console.warn("No globe canvas found to export"); + showToast(ok ? "Saved globe PNG." : "No globe view to export yet.", ok ? "info" : "error"); }} title="Save the current globe view as PNG" disabled={!slotA.initial} @@ -292,7 +331,7 @@ export default function App() { initial: slotA.initial, timeS, }); - if (!ok) console.warn("No globe canvas found to export"); + showToast(ok ? "Saved share card." : "No globe view to export yet.", ok ? "info" : "error"); }} title="Save a branded share-card with scenario metadata + citation overlay" disabled={!slotA.initial} @@ -309,9 +348,10 @@ export default function App() { { preset: activePresetA, initial: slotA.initial, timeS }, { fps: 30, durationMs: 6_000, bitsPerSecond: 6_000_000 }, ); - if (!result.ok) { - console.warn("Video export failed:", result.reason); - } + showToast( + result.ok ? "Saved globe recording." : `Video export failed: ${result.reason}`, + result.ok ? "info" : "error", + ); } finally { setRecording(false); } @@ -331,6 +371,23 @@ export default function App() {
diff --git a/src/components/CitationsModal.tsx b/src/components/CitationsModal.tsx index db0821e..82fe292 100644 --- a/src/components/CitationsModal.tsx +++ b/src/components/CitationsModal.tsx @@ -1,5 +1,7 @@ +import { useRef } from "react"; import { open as openExternal } from "@tauri-apps/plugin-shell"; import { useEscapeKey } from "../hooks/useEscapeKey"; +import { useFocusTrap } from "../hooks/useFocusTrap"; import { isTauri } from "../lib/tauri"; import type { Preset } from "../types/scenario"; @@ -18,9 +20,11 @@ function openUrl(url: string) { export function CitationsModal({ presets, onClose }: Props) { useEscapeKey(onClose); + const dialogRef = useRef(null); + useFocusTrap(dialogRef); return (
-
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="citations-title"> +
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="citations-title">

Citations & references

diff --git a/src/components/FirstRunDisclaimer.tsx b/src/components/FirstRunDisclaimer.tsx index 851126d..011444b 100644 --- a/src/components/FirstRunDisclaimer.tsx +++ b/src/components/FirstRunDisclaimer.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { settings } from "../lib/settings"; +import { useFocusTrap } from "../hooks/useFocusTrap"; /** * Shown once on first launch. After acknowledgement the timestamp is stored @@ -7,6 +8,8 @@ import { settings } from "../lib/settings"; */ export function FirstRunDisclaimer() { const [open, setOpen] = useState(false); + const dialogRef = useRef(null); + useFocusTrap(dialogRef, open); useEffect(() => { settings.getDisclaimerAcknowledged().then((ack) => { @@ -44,7 +47,7 @@ export function FirstRunDisclaimer() { return (
-
+

Welcome to TsunamiSimulator

diff --git a/src/components/Globe.tsx b/src/components/Globe.tsx index a58c9bb..ec56123 100644 --- a/src/components/Globe.tsx +++ b/src/components/Globe.tsx @@ -36,6 +36,10 @@ type Props = { inspectIsImpact?: boolean; inspectTimeS?: number; onInspectCancel?: () => void; + /** Whether this is the primary (exportable) globe pane. Only the primary + * pane keeps the WebGL backbuffer alive for PNG/share/video export — the + * Slot B compare pane skips that per-frame cost. */ + primary?: boolean; }; const PICK_CURSOR_STYLE = "crosshair"; @@ -66,6 +70,7 @@ export function Globe({ inspectIsImpact, inspectTimeS, onInspectCancel, + primary = true, }: Props) { const containerRef = useRef(null); const viewerRef = useRef(null); @@ -82,6 +87,11 @@ export function Globe({ const inspectEntityRef = useRef(null); const [imageryStatus, setImageryStatus] = useState<"loading" | "ready" | "fallback" | "error">("loading"); const [resolvedStyle, setResolvedStyle] = useState(styleId ?? DEFAULT_STYLE); + // Bumped each time the Viewer is (re)created. React 19 StrictMode mounts → + // unmounts → remounts in dev, which destroys the first Viewer; including this + // in every data effect's deps re-binds entities/imagery to the fresh Viewer + // instead of leaving the dev globe blank until the next prop change. + const [viewerEpoch, setViewerEpoch] = useState(0); // One-time viewer mount useEffect(() => { @@ -104,7 +114,9 @@ export function Globe({ fullscreenButton: false, selectionIndicator: false, infoBox: false, - contextOptions: { webgl: { preserveDrawingBuffer: true } }, + // Only the primary (exportable) pane pays the preserveDrawingBuffer + // compositing cost; canvas.toDataURL/captureStream target Slot A only. + contextOptions: { webgl: { preserveDrawingBuffer: primary } }, }); viewer.scene.globe.enableLighting = true; @@ -112,6 +124,8 @@ export function Globe({ viewer.scene.fog.enabled = true; viewerRef.current = viewer; + // Signal dependent effects that a (new) viewer is live so they re-bind. + setViewerEpoch((n) => n + 1); return () => { pickHandlerRef.current?.destroy(); @@ -223,7 +237,7 @@ export function Globe({ return () => { cancelled = true; }; - }, [resolvedStyle]); + }, [resolvedStyle, viewerEpoch]); // Pick mode: install a left-click handler that reports cartographic coords. useEffect(() => { @@ -258,7 +272,7 @@ export function Globe({ window.removeEventListener("keydown", onKey); viewer.canvas.style.cursor = ""; }; - }, [pickMode, onPick, onPickCancel]); + }, [pickMode, onPick, onPickCancel, viewerEpoch]); // F-V11 — Inspect mode: a left-click queries the backend for the // per-point readout and pops a Cesium label at the click position. @@ -291,12 +305,16 @@ export function Globe({ const lat = Cesium.Math.toDegrees(carto.latitude); const lon = Cesium.Math.toDegrees(carto.longitude); + // Use the source's own water depth as the propagation depth so the + // inspect readout reconciles with run_preset / the results panel, + // instead of a hardcoded flat 4000 m that contradicts shelf/lake events. + const sourceDepth = initial.center.depth_m ?? 0; const req = { source: initial.center, initial_amplitude_m: initial.peak_amplitude_m, cavity_radius_m: initial.cavity_radius_m, is_impact: inspectIsImpact === true, - mean_depth_m: 4000, + mean_depth_m: Number.isFinite(sourceDepth) && sourceDepth > 0 ? sourceDepth : 4000, time_s: inspectTimeS ?? 0, click_lat: lat, click_lon: lon, @@ -310,26 +328,34 @@ export function Globe({ inspectPromise .then((res) => { - if (!viewerRef.current) return; + // Re-read the viewer from the ref (it may have been recreated) and + // use that instance for all entity mutations — never the closure. + const v = viewerRef.current; + if (!v) return; + // Coalesce any non-finite physics field to an em dash so a degenerate + // result (e.g. clicking exactly on the source) never renders + // "Range NaN km" / "T+Infinityh" into the on-globe label. + const fmt = (x: number, d: number) => (Number.isFinite(x) ? x.toFixed(d) : "—"); const arrivalMin = res.arrival_time_s / 60; - const arrivalLabel = - arrivalMin < 60 + const arrivalLabel = !Number.isFinite(arrivalMin) + ? "—" + : arrivalMin < 60 ? `T+${arrivalMin.toFixed(0)}m` : `T+${Math.floor(arrivalMin / 60)}h${String(Math.round(arrivalMin % 60)).padStart(2, "0")}`; const status = res.has_arrived ? "ARRIVED" : "in transit"; const text = [ - `${lat.toFixed(2)}°, ${lon.toFixed(2)}°`, - `Range ${(res.range_m / 1000).toFixed(0)} km · ${status}`, + `${fmt(lat, 2)}°, ${fmt(lon, 2)}°`, + `Range ${fmt(res.range_m / 1000, 0)} km · ${status}`, `Arrival ${arrivalLabel}`, - `Offshore ${res.offshore_amplitude_m.toFixed(2)} m · Runup ${res.runup_m.toFixed(1)} m`, - `Inundation ~${(res.inundation_extent_m / 1000).toFixed(2)} km`, + `Offshore ${fmt(res.offshore_amplitude_m, 2)} m · Runup ${fmt(res.runup_m, 1)} m`, + `Inundation ~${fmt(res.inundation_extent_m / 1000, 2)} km`, ].join("\n"); if (inspectEntityRef.current) { - viewer.entities.remove(inspectEntityRef.current); + v.entities.remove(inspectEntityRef.current); inspectEntityRef.current = null; } - inspectEntityRef.current = viewer.entities.add({ + inspectEntityRef.current = v.entities.add({ position: Cesium.Cartesian3.fromDegrees(lon, lat, 0), point: { pixelSize: 10, @@ -366,7 +392,7 @@ export function Globe({ window.removeEventListener("keydown", onKey); viewer.canvas.style.cursor = ""; }; - }, [inspectMode, initial, inspectIsImpact, inspectTimeS, onInspectCancel]); + }, [inspectMode, initial, inspectIsImpact, inspectTimeS, onInspectCancel, viewerEpoch]); // React to a new initial displacement useEffect(() => { @@ -469,12 +495,20 @@ export function Globe({ }); } } - }, [initial]); + }, [initial, viewerEpoch]); // React to a new wavefront snapshot — update existing entities in place. useEffect(() => { const viewer = viewerRef.current; - if (!viewer || !initial || !wavefront) return; + if (!viewer) return; + // When the source or its wavefront clears, tear down the existing rings + // instead of leaving them stranded on the globe after switching back to + // the empty state. + if (!initial || !wavefront) { + for (const e of wavefrontEntitiesRef.current) viewer.entities.remove(e); + wavefrontEntitiesRef.current = []; + return; + } const { lat_deg, lon_deg } = initial.center; const position = Cesium.Cartesian3.fromDegrees(lon_deg, lat_deg, 0); @@ -525,7 +559,7 @@ export function Globe({ Cesium.Color.fromCssColorString("#74c7ec").withAlpha(0.25 + 0.55 * t), ); } - }, [initial, wavefront]); + }, [initial, wavefront, viewerEpoch]); // SWE snapshot → Cesium imagery layer. // @@ -554,7 +588,10 @@ export function Globe({ const e = Math.max(-180, Math.min(180, east)); const s = Math.max(-90, Math.min(90, south)); const n = Math.max(-90, Math.min(90, north)); - if (e <= w || n <= s) return; + // NaN slips through Math.max/min and through `e <= w` (NaN comparisons are + // always false), and a NaN rectangle makes Cesium throw / blank the scene. + // Require all four edges finite and properly ordered before constructing it. + if (![w, e, s, n].every(Number.isFinite) || e <= w || n <= s) return; const url = `data:image/png;base64,${sweSnapshot.eta_png_b64}`; let cancelled = false; @@ -575,7 +612,7 @@ export function Globe({ return () => { cancelled = true; }; - }, [sweSnapshot]); + }, [sweSnapshot, viewerEpoch]); // Runup bars at named coastal points. useEffect(() => { @@ -609,11 +646,13 @@ export function Globe({ // Format the hover label. Arrival time as "T+HhMM"; runup to 1 dp. const arrivalMin = r.arrival_time_s / 60; - const arrivalLabel = - arrivalMin < 60 + const arrivalLabel = !Number.isFinite(arrivalMin) + ? "—" + : arrivalMin < 60 ? `T+${arrivalMin.toFixed(0)}m` : `T+${Math.floor(arrivalMin / 60)}h${String(Math.round(arrivalMin % 60)).padStart(2, "0")}`; - const hoverText = `${r.name}\n${arrivalLabel} • ${r.runup_m.toFixed(1)} m runup\n${r.offshore_amplitude_m.toFixed(2)} m offshore`; + const offshore = Number.isFinite(r.offshore_amplitude_m) ? r.offshore_amplitude_m.toFixed(2) : "—"; + const hoverText = `${r.name}\n${arrivalLabel} • ${r.runup_m.toFixed(1)} m runup\n${offshore} m offshore`; let entity = map.get(r.id); if (!entity) { @@ -728,7 +767,7 @@ export function Globe({ inMap.delete(id); } } - }, [runupResults]); + }, [runupResults, viewerEpoch]); // DART buoy pins. useEffect(() => { @@ -774,7 +813,7 @@ export function Globe({ map.delete(id); } } - }, [dartBuoys]); + }, [dartBuoys, viewerEpoch]); return ( <> diff --git a/src/components/ResultsPanel.tsx b/src/components/ResultsPanel.tsx index 601cc13..83e6e26 100644 --- a/src/components/ResultsPanel.tsx +++ b/src/components/ResultsPanel.tsx @@ -49,7 +49,10 @@ export function ResultsPanel({ initial, timeS, onTimeChange }: Props) { const amp = formatLength(initial.peak_amplitude_m); const wl = initial.dominant_wavelength_m ? formatLength(initial.dominant_wavelength_m) : null; const totalT = 6 * 3600; - const progress = Math.max(0, Math.min(1, timeS / totalT)); + // Coerce a non-finite timeS to 0 so a bad upstream value can't apply + // scaleX(NaN) (which collapses the fill bar) or render "NaN minutes". + const safeTimeS = Number.isFinite(timeS) ? timeS : 0; + const progress = Math.max(0, Math.min(1, safeTimeS / totalT)); return ( <> @@ -105,17 +108,17 @@ export function ResultsPanel({ initial, timeS, onTimeChange }: Props) { min={0} max={totalT} step={60} - value={timeS} + value={safeTimeS} onChange={(e) => onTimeChange(Number(e.target.value))} aria-label="Scenario timeline scrubber" - aria-valuetext={`${Math.round(timeS / 60)} minutes after source event`} + aria-valuetext={`${Math.round(safeTimeS / 60)} minutes after source event`} />
- t = {(timeS / 60).toFixed(0)} min - {(timeS / 3600).toFixed(2)} h + t = {(safeTimeS / 60).toFixed(0)} min + {(safeTimeS / 3600).toFixed(2)} h
diff --git a/src/components/ScenarioBuilder.tsx b/src/components/ScenarioBuilder.tsx index bb4c808..57cb596 100644 --- a/src/components/ScenarioBuilder.tsx +++ b/src/components/ScenarioBuilder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { AsteroidImpactInput, EarthquakeInput, @@ -115,6 +115,24 @@ function NumField({ step?: number; }) { const b = BOUNDS[field]; + // Hold an uncommitted text draft so the user can clear the field and retype + // intermediate states ("", "-", "1.", "1e") without each keystroke being + // clamped (Number("") === 0 used to snap the field to its minimum, making + // it effectively un-editable). We clamp + commit on blur, and re-sync from + // the prop when it changes externally (e.g. a globe pick). + const [draft, setDraft] = useState(() => String(value)); + const focusedRef = useRef(false); + useEffect(() => { + if (!focusedRef.current) setDraft(String(value)); + }, [value]); + + function commit() { + const parsed = Number(draft); + const next = clamp(field, parsed); + onChange(next); + setDraft(String(next)); + } + return ( ); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index f7d564c..e103688 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { open as openExternal } from "@tauri-apps/plugin-shell"; import { useEscapeKey } from "../hooks/useEscapeKey"; +import { useFocusTrap } from "../hooks/useFocusTrap"; import { primeCesiumToken } from "../lib/cesium"; import { settings, type Theme } from "../lib/settings"; import { setTheme } from "../lib/theme"; @@ -13,11 +14,14 @@ type Props = { onClose: () => void }; export function Settings({ onClose }: Props) { useEscapeKey(onClose); + const dialogRef = useRef(null); + useFocusTrap(dialogRef); const [token, setTokenLocal] = useState(""); const [theme, setThemeLocal] = useState("mocha"); const [globeStyle, setGlobeStyle] = useState("osm"); const [savedAt, setSavedAt] = useState(null); const [saveErr, setSaveErr] = useState(null); + const [saving, setSaving] = useState(false); const [gpuStatus, setGpuStatus] = useState("unknown"); useEffect(() => { @@ -44,6 +48,8 @@ export function Settings({ onClose }: Props) { }, []); async function save() { + if (saving) return; + setSaving(true); setSaveErr(null); const trimmedToken = token.trim(); // Apply the token immediately so the next imagery request sees it, @@ -60,6 +66,8 @@ export function Settings({ onClose }: Props) { } catch (err) { console.error("[settings] save failed", err); setSaveErr(String(err)); + } finally { + setSaving(false); } // Always dispatch — Globe + main.tsx listen for this to re-read the // active style + token. Even if persistence failed the in-memory @@ -73,7 +81,7 @@ export function Settings({ onClose }: Props) { return (
-
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="settings-title"> +
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="settings-title">

Settings

@@ -162,7 +170,9 @@ export function Settings({ onClose }: Props) {
- + {savedAt && !saveErr && ( Saved at {savedAt} )} diff --git a/src/components/SwePlayback.tsx b/src/components/SwePlayback.tsx index abe0c22..9e635b7 100644 --- a/src/components/SwePlayback.tsx +++ b/src/components/SwePlayback.tsx @@ -56,22 +56,24 @@ export function SwePlayback({ initial, onSnapshot }: Props) { } }, [snapshots, activeIdx, onSnapshot]); - // Auto-play: advance the scrubber every 250 ms when playing. + // Auto-play: advance the scrubber every 250 ms when playing. The updater + // stays pure (no nested setState) — reaching the end is handled by the + // separate effect below so it behaves correctly under StrictMode. useEffect(() => { if (!isPlaying || !snapshots || snapshots.length < 2) return; const interval = window.setInterval(() => { - setActiveIdx((i) => { - const next = i + 1; - if (next >= (snapshots?.length ?? 0)) { - setIsPlaying(false); - return i; - } - return next; - }); + setActiveIdx((i) => Math.min(i + 1, snapshots.length - 1)); }, 250); return () => window.clearInterval(interval); }, [isPlaying, snapshots]); + // Stop playback when the scrubber reaches the final frame. + useEffect(() => { + if (isPlaying && snapshots && activeIdx >= snapshots.length - 1) { + setIsPlaying(false); + } + }, [isPlaying, snapshots, activeIdx]); + // Cancel an in-flight simulation. The Tauri worker keeps running // to completion (the IPC layer has no cancel signal), but bumping // `reqIdRef` causes the response to be ignored on arrival and the @@ -186,8 +188,15 @@ export function SwePlayback({ initial, onSnapshot }: Props) { <>