FloodTruth — AR inundation viewer

AR inundation viewer

See Hurricane Helene's peak, right here

Real recorded flood: the French Broad at Asheville reached 24.67 ft on 2024-09-27 (USGS gauge 03451500) — rendered over real USGS 3DEP terrain.

Runs in this browser. No camera, GPS, or permissions.

Bathtub-modeled at the recorded peak water-surface elevation — illustrative, not a HEC-RAS 2D analysis.

Other scenarios

Inundation AR prototype

Stand at a known location, point your phone at the area of interest. The app overlays a floodplain or inundation polygon (FEMA SFHA, dam-breach inundation, modeled depth grid) on the live camera using GPS, compass, and tilt from the phone's sensors. Real WGS84 math, real DeviceOrientation/Geolocation feeds — no shortcuts.

Best for an EAP exercise, a property visit, or council on the riverbank. Requires HTTPS (or localhost) for camera + orientation access.

1 · Inundation polygon (GeoJSON)

no NFHL fetched
no GeoJSON loaded
ft m
Works at any GPS position. Bathtub is the most realistic (floods actual low terrain). Set absolute WSEL for gauge/model matching.

Accepts FeatureCollection, Feature, or bare Polygon/MultiPolygon. Coordinates assumed WGS84 (lon, lat) per RFC 7946. Ring elevation, if present (z in coordinate triples), is taken as ground elevation in meters; otherwise the ground elevation field below is used for every vertex.

For storm + sensitivity filtering, tag features with properties.return_period_yr (e.g., 10, 100, 500, 1000) and properties.depth_offset_ft (e.g., −1, 0, +1). Each combination must be its own pre-modeled HEC-RAS run.

Fetch FEMA NFHL queries the live FEMA National Flood Hazard Layer (layer 28, S_Fld_Haz_Ar) within the radius of the observer GPS and ingests the polygons with zone-aware coloring (Zone A/AE blue, V/VE red-orange, X-shaded yellow, X-unshaded grey, D grey). Free public data, no auth. Informational use only — does not replace an official FIRM determination. See FEMA flood zone definitions.

2 · Observer position

no fix

Phone GPS vertical accuracy is ±10 m (often worse) — do not trust it for floodplain work. Enter ground elevation from a topo / known datum, or fetch from USGS 3DEP (1-m DEM where available, free, no auth). Add eye height ($\approx 1.65$ m) to get camera elevation.

2b · Terrain occluder (optional)

Builds a real USGS 3DEP bare-earth DEM grid around the observer, or — for sites where buildings & vegetation matter — accepts a user-supplied DSM GeoTIFF (drone survey, project lidar). Per-frame, every polygon vertex's line of sight is tested against the active grid; polygons fully behind a ridge are skipped, partially-occluded polygons are now clipped to the silhouette (v0.4) so they hug the ridge crest instead of disappearing. Recommended for hilly sites or anywhere a building / tree row would block the overlay.

no DEM — occluder off

3DEP bare-earth path: a 600 m × 600 m grid at 15 m spacing is 1,600 3DEP point queries — fired in batches of 12 with an 80 ms gap to respect the rate limit. Expect ~60–90 s on a typical phone connection. 3DEP coverage is contiguous US + Hawaii + most of Alaska.
DSM GeoTIFF path: upload a digital surface model (top-of-canopy / top-of-roof) covering the observer and the polygon footprints. WGS84 lat/lon (EPSG 4326) and any UTM zone (EPSG 32601–32660 / 32701–32760) are reprojected on load. The grid extent / spacing controls above define how the GeoTIFF is resampled into the local occluder grid; once loaded, the occluder treats every elevation as terrain — buildings, trees, walls all participate. Held in memory; use 2c · Offline staging below to persist it for field use.

2c · Offline staging field

Hurricane Helene knocked out cell + power across western NC for weeks. Stage the built DEM, inundation polygons, and any fetched FEMA NFHL zones to this device while you still have signal; in the field with no connectivity, Load the area and the AR viewer renders fully offline (don't press Build DEM / Fetch NFHL / 3DEP while dark — those are the only calls that touch the network). Stored on-device via IndexedDB.

no area staged

2d · Water rendering

In 3D mode, each polygon is drawn as a translucent water column extruded from the ground plane up to its WSEL ($z_{\rm ground} + \text{depth}$). The column is colored by depth (pale → deep navy) and the surface gets a subtle wave shimmer. Only fires when the feature carries a depth_ft property (or depth_m / wsel_ft per the depth-readout fallback chain). Flat mode is the v0.2 behavior — a single translucent polygon at the ground plane.

3 · Camera intrinsics

Browsers do not expose camera FOV via getUserMedia. Default 70° along the sensor's long side ≈ typical phone main camera (iPhone 13/14 wide ≈ 70–73°, Pixel 7/8 main ≈ 70°). The FOV you actually see is smaller: the browser rotates the frame with the device and object-fit: cover crops it to the screen, so a portrait phone shows ~36° across and a landscape one the full 70°. The viewer derives the visible FOV from the live frame size and the viewport each frame (HUD fov readout), so rotating the phone keeps the overlay registered. You can calibrate by panning until a known landmark just enters/exits the frame and adjusting the slider until it reports the correct bearing. The orthogonal FOV follows from the viewport aspect: $\;\tan(\text{VFOV}/2) = \tan(\text{HFOV}/2) \cdot (H/W)$.

4 · Start AR

Tapping Start AR view requests camera + orientation permission (iOS shows a prompt on the gesture). On older iPads or Safari, sensor access only works over HTTPS.

ready

5 · EAP exhibit batch v0.7

Generate every AR exhibit required by an EAP in one session — depth at notification points, evacuation-route crossings, critical-facility exposure — as labeled, georeferenced PNGs with a metadata footer. Output bundles into a single ZIP with a CSV + GeoJSON manifest and a README. Depth values come from the active inundation polygon, NFHL zones from the active NFHL layer — no synthesized values.

Dam reference (optional)

Used for the distance_from_dam_ft column in the manifest and (when no per-point bearing is supplied) the default look-toward-dam camera bearing in simulated mode.

Capture mode

Field mode: for each point, the AR view stays open and the tool prompts you to walk to the point's GPS and tap Capture & next. Real camera + AR overlay are captured at each. Suggested for a real EAP exercise.

Point list

Add point manually

#labeltypelat, lonbrgnote
no points — upload CSV / GeoJSON or add manually
no points loaded

Real-data only. Depth at each point is sampled from the active inundation polygon (point-in-polygon against state.activeFeatures); NFHL zone is read from the active NFHL layer if loaded; timestamp is the moment of capture. For EAP authoring use only — depth values are model output, validate against engineering ground truth.

Scientific basis & method transparency

1 · What this does

The app projects each polygon vertex from geodetic (lat, lon, elev) coordinates into screen pixel coordinates so it can be drawn over the live camera frame. Three real coordinate transforms run on every animation frame: (a) WGS84 → local ENU (East-North-Up) using a small-area approximation, (b) ENU → camera frame using the ZXY Euler rotation from compass / pitch / roll, (c) camera frame → image pixels via the pinhole projection.

2 · Geodetic to local ENU

Let observer position $O = (\phi_O, \lambda_O, z_O)$ and a polygon vertex $P = (\phi_P, \lambda_P, z_P)$, with $\phi$ in radians. For displacements small relative to the Earth's radius ($R_E = 6{,}378{,}137$ m), the planar approximation is

$\Delta E \approx (\lambda_P - \lambda_O) \, R_E \cos\phi_O, \quad \Delta N \approx (\phi_P - \phi_O) \, R_E, \quad \Delta U = z_P - z_O.$

Error from neglecting Earth curvature is below 0.1% out to ~5 km horizontal — well inside line-of-sight from a phone camera. ENU axes: East ($+x$), North ($+y$), Up ($+z$).

3 · ENU to camera frame (rotation)

The phone's DeviceOrientation event gives three angles: $\alpha$ (rotation about world Up, CCW from above per W3C spec), $\beta$ (front-back tilt; $\beta=90°$ ⇒ device upright in portrait), $\gamma$ (lateral tilt about device's top-edge axis). Rather than build $R$ from a ZXY Euler product (which has subtle sign and order pitfalls), we go through the unambiguous physical quantities:

  • Heading $h$ — compass bearing of camera-back, CW from True North. Use webkitCompassHeading on iOS, otherwise convert $h = (360° - \alpha) \bmod 360°$.
  • Pitch $p = \beta - 90°$ — angle of camera-forward above horizon.
  • Roll $r = \gamma$ — image rotation about camera-forward.

Camera-forward in ENU is then directly $\;\mathbf{f} = (\sin h \cos p, \, \cos h \cos p, \, \sin p)$. A horizontal "right" axis perpendicular to $\mathbf{f}$ is $\mathbf{r}_0 = (\cos h, -\sin h, 0)$, and $\mathbf{u}_0 = \mathbf{r}_0 \times \mathbf{f}$ is the "up" axis. Roll $r$ rotates $(\mathbf{r}_0, \mathbf{u}_0)$ about $\mathbf{f}$. The rows of $R$ are then $(\mathbf{r}, -\mathbf{u}, \mathbf{f})$ in ENU — i.e., camera-right, camera-down (= image-down), camera-forward. Pose verified against eight known cases (facing N/E/S; level/pitched; overhead point) before deploy. Add a user-set compass calibration offset $\delta$ to $h$ (magnetometers are routinely 5–20° off without calibration).

4 · Camera frame to image (pinhole)

Given camera HFOV and frame size $W \times H$ (CSS pixels of the canvas), the pixel coordinates of a 3-D point $(x, y, z)$ in camera space (right, down, forward) are

$u = \dfrac{W}{2} + \dfrac{x}{z} \cdot \dfrac{W/2}{\tan(\text{HFOV}/2)}, \quad v = \dfrac{H}{2} + \dfrac{y}{z} \cdot \dfrac{H/2}{\tan(\text{VFOV}/2)}.$

VFOV is derived from HFOV and the live video aspect ratio: $\tan(\text{VFOV}/2) = \tan(\text{HFOV}/2)\,(H/W)$.

5 · Near-plane clipping

Vertices behind the camera ($z \leq \epsilon$, $\epsilon = 0.5$ m) cannot be projected. Polygon edges crossing the near plane are clipped at the intersection point, computed by linear interpolation in 3-D camera space — the same Sutherland–Hodgman algorithm used by every real-time renderer. Without this step, distant polygon edges flip across the screen when the camera passes their plane.

6 · Compass calibration

Pointing the reticle at a landmark of known true bearing (a road run from a topo, a known building corner, a magnetic-declination-corrected map call) and tapping Calibrate compass stores the residual offset $\delta = \alpha_\text{measured} - \alpha_\text{true}$ and applies it on every subsequent frame. Without this step, the overlay can be off by 20° on cheap magnetometers — visibly wrong at any range.

6b · Magnetic declination (WMM)

The DeviceOrientation $\alpha$ value reports magnetic heading on most Android phones, while iOS exposes a separate webkitCompassHeading in true. Without correction, projected polygons drift ~5–20° east or west of their real positions (about 7° west in western North Carolina). On AR start we fetch the WMM-2025 magnetic declination $\varepsilon$ at the observer's GPS position via the British Geological Survey's public Geomagnetic Field Models JSON service (free, no auth) and add it to the effective heading:

$\;\alpha_{\rm true} = \alpha_{\rm magnetic} + \varepsilon \quad \text{(east declination positive)}$

Skipped on iOS, where webkitCompassHeading is already true. The HUD's decl field reports the correction applied. If the fetch fails (offline, BGS outage, or both 2025 and 2020 fallbacks unreachable), $\varepsilon$ defaults to 0 and the user is toasted to use the Calibrate compass button against a known landmark bearing.

Endpoint: https://geomag.bgs.ac.uk/web_service/GMModels/wmm/2025/. NOAA's equivalent geomag-web/calculators endpoint requires a free registered API key as of late 2025, which we deliberately avoid for a frictionless field experience.

7 · Scenario filter (storm × WSEL offset)

Each polygon in the loaded GeoJSON may carry a properties.return_period_yr and a properties.depth_offset_ft. The viewer collects unique values from the loaded data, builds a discrete-step slider for the storm event (10 / 100 / 500 / 1000-yr or whatever you have), and a ± button pair for the offset (a sensitivity nudge usually run as separate HEC-RAS scenarios at WSEL ± 1 ft).

Filtering is strict: the viewer shows only features whose (return_period_yr, depth_offset_ft) matches the current slider/offset selection. Each selectable combination must be a real, separately-modeled run in your hydraulics tool. The viewer never inflates or translates a polygon to fake an offset — that would misrepresent inundation extent (which depends on terrain downstream of obstacles, not on a uniform vertical translation).

Polygons with no return_period_yr or no depth_offset_ft are treated as "applies to any selection" so legacy single-scenario GeoJSON still works. Recognized property aliases: return_period, recurrence_yr, rp_yr, RP (storm); offset_ft, wsel_offset_ft, offset (offset).

8 · Depth at look-center (ray-cast)

The reticle in screen center always corresponds to the camera-frame look direction $\mathbf{d}_{\rm cam} = (0, 0, 1)^T$. Transformed back to ENU: $\;\mathbf{d}_{\rm ENU} = \mathbf{R}^T \mathbf{d}_{\rm cam} = (R_{20}, R_{21}, R_{22})^T$ (the third row of $\mathbf{R}$). The look-at point on the ground is the ray–plane intersection at the polygon's elevation $z_{\rm poly}$:

$\displaystyle t = \frac{z_{\rm poly} - z_{\rm cam}}{R_{22}}, \qquad (E_{\rm at}, N_{\rm at}) = t \cdot (R_{20}, R_{21})$

Solving for $t > 0$ requires $R_{22} < 0$ (camera tilted down) — if not, the look-at is in the sky and depth is undefined. The intersection is converted back to $(\lambda_{\rm at}, \phi_{\rm at})$ by the inverse of the ENU equations and tested against the active feature rings via standard even-odd ray-cast point-in-polygon. When the look-at falls inside a feature, depth is read from the feature's properties in this order of preference:

  1. depth_ft — explicit depth (preferred)
  2. depth_m or depth — converted to feet
  3. wsel_ft minus observer ground (planar approx, accurate near observer)

Caveat: the planar approximation $z_{\rm poly} \approx z_{\rm obs,ground}$ holds well near the observer but degrades as range increases over varied terrain. For accurate long-range readings, attach explicit ground/WSEL elevations per feature, or wait for the v0.3 DEM-tile lookup that uses the actual $z(\lambda, \phi)$ at the look-at point.

9 · Terrain occluder (vertex-level)

When the optional DEM is built, each polygon vertex $P=(\lambda_P, \phi_P, z_P)$ that survives near-plane clipping is tested against terrain along the line of sight from the camera $C$ at $(\lambda_O, \phi_O, z_{\rm cam})$. The ray is sampled at $N$ stations (default 20) evenly spaced in ENU camera-to-vertex distance $L = \|P - C\|_{\rm ENU}$. At sample fraction $s/L \in [0,1]$, the ray's instantaneous elevation is

$z_{\rm ray}(s) = z_C + (s/L)\,(z_P - z_C)$, $\quad (E,N)(s) = (s/L)\,(E_P, N_P)$.

The terrain elevation $z_{\rm terrain}(E, N)$ is read from the cached 2-D DEM grid by bilinear interpolation. With grid origin at the observer and cell size $\Delta$, let $i = \lfloor E/\Delta + n/2\rfloor$, $j = \lfloor N/\Delta + n/2\rfloor$ and the fractional offsets $a = E/\Delta + n/2 - i$, $b = N/\Delta + n/2 - j$. Then

$z_{\rm terrain} = (1-a)(1-b) z_{i,j} + a(1-b) z_{i+1,j} + (1-a)b z_{i,j+1} + ab z_{i+1,j+1}$.

If at any sample $z_{\rm terrain} > z_{\rm ray}$, the vertex is marked occluded. After classifying every vertex of a ring:

  • All visible → draw normally (solid stroke, full fill).
  • All occluded → skip the ring entirely.
  • Mixed → draw with a dashed stroke and ~40% fill alpha so the user still sees the polygon's outline shape but knows it is not directly visible.

The DEM is built once on user request: a square grid (default 600 m, 15 m spacing → 1,600 cells) of USGS 3DEP point queries at the same endpoint used for the observer ground-elevation lookup, fired in batches of ~12 with an 80 ms inter-batch gap to respect the public rate limit. Per-frame occlusion runs entirely from the cached Float32Array — no network in the hot path. If the observer drifts more than ~150 m from the build center (per the GPS watch), the HUD warns terrain: stale, rebuild?.

Limitations.

  • Edge-silhouette clip (v0.4). When a polygon edge straddles a ridge — one endpoint visible, the other occluded — the renderer now binary-searches the line of sight along that edge to locate the exact silhouette crossing (where the ray just grazes the ridge crest), inserts a synthetic vertex at that point, and drops every fully-occluded vertex. The polygon is rebuilt as a closed ring against the silhouette, so the visible water boundary actually follows the terrain crest instead of collapsing to a dashed outline. 10-iteration bisection in ENU+absolute-elevation gives sub-metre accuracy for typical 100–300 m sight-lines.
  • Finite grid. Default 600 m × 600 m — far-field polygons may be occluded by ridges outside the grid that are not modeled. Increase the grid extent for long sight-lines or use a coarser spacing.
  • Sub-grid features. 15 m grid spacing won't capture small berms, walls, individual buildings, or sub-grid ridge crests.
  • Bare-earth vs DSM. The 3DEP path is bare-earth: buildings, vegetation, and engineered structures are not in the terrain model. The v0.4 DSM GeoTIFF path accepts a user-supplied surface model (drone survey, project lidar) in WGS84 lat/lon or any UTM zone — once loaded, the same per-vertex occluder + silhouette clip treat each elevation as terrain, so trees and roofs can occlude the overlay.
  • 3DEP coverage. Contiguous US + Hawaii + most of Alaska. Outside that the build will fail and the toggle warns the user.

10 · 3D water rendering

When a feature carries a positive depth_ft (or the equivalent fallbacks from §8) and the user keeps the polygon-style toggle on 3D, the renderer extrudes the polygon into a translucent water column. For each ring vertex $(\lambda, \phi, z_g)$ we project two camera-space points:

$\;P_{\rm floor} = R \cdot \mathrm{ENU}(\lambda, \phi, z_g) , \qquad P_{\rm surface} = R \cdot \mathrm{ENU}(\lambda, \phi,\, z_g + d + a\sin(\omega t + i\,\varphi))$

where $d$ is the water depth in metres, $a \approx 0.04$ m and $\omega \approx 2\pi/0.65\,{\rm s}^{-1}$ give a subtle wave shimmer, and $\varphi$ is a per-vertex phase that prevents the surface from oscillating uniformly.

The renderer then draws three layers per ring:

  1. Side walls — quads connecting consecutive floor and surface vertices. Translucent fill only, no stroke. Color is depth-keyed: pale blue at $< 2$ ft, mid-blue at $2\!-\!5$ ft, deeper at $5\!-\!10$ ft, navy at $\ge 10$ ft.
  2. Floor outline — faint white stroke showing where water meets land.
  3. Top surface — translucent fill plus a crisp stroke (the visible water-line).

Extrusion only fires when (a) the polygon-style toggle is 3D, (b) the feature has a positive depth, (c) all floor and surface vertices are in front of the near plane, and (d) the occluder verdict is visible (full extrusion would mislead in mixed occlusion). Anything else falls back to the v0.2 flat-polygon rendering, still using the depth-aware color.

Limitations: walls aren't z-sorted (back-face culling is a v0.4 polish); for some camera angles the back wall can draw in front of the front wall. With translucent fill the artifact reads as "more water," not as a hard glitch — but it's there. For a polygon where one or more vertices fall behind the near plane, the tool quietly degrades to flat.

11 · Known biases & limitations

  • Compass error. Magnetometers are sensitive to nearby ferrous metal (vehicles, rebar, pipes) and indoor environments. Always calibrate against a known landmark before relying on the overlay.
  • GPS horizontal accuracy. Phones report ±3–10 m on open sky, much worse under canopy or in canyons. Affects polygon position by the same amount.
  • GPS vertical accuracy. ±10 m is typical, ±20 m possible. Must be replaced with a ground-truth elevation (DEM lookup, topo, or known benchmark) for any work that depends on depth.
  • FOV assumption. Browsers cannot read sensor pixel pitch / focal length from getUserMedia. The 70° HFOV default is correct to within ~3° on most modern phones; calibrate with a known landmark for precision work.
  • Planar-Earth approximation. The local ENU conversion neglects curvature. Within ~5 km this is below 0.1%; beyond that, drop in a real ECEF / WGS84 conversion.
  • Sensor latency. DeviceOrientation events can lag the live video frame by 30–80 ms on Android Chrome and ~16 ms on iOS Safari. Visible as overlay "swimming" during fast pans. Holding the phone still produces the correct overlay.
  • DEM gaps. USGS 3DEP coverage is ~95% of CONUS at 1 m, near-100% at 10 m. Private parcels with no public LIDAR may return null; fall back to a manually entered ground elevation.
  • Vertical datum mismatch. FEMA NFHL inundation polygons are typically NAVD88; older FIRMs may be NGVD29 ($\approx -0.3$ m offset in NC). USGS 3DEP returns NAVD88. Mixing datums introduces an apparent vertical offset to the overlay.
  • Wrap-around hills (no full occlusion). When the optional terrain occluder (§9) is off, the overlay draws polygons even when the line of sight is blocked by terrain — you'd see flooding "behind a hill." Turning the occluder on resolves this at vertex granularity; full per-fragment occlusion (and occlusion by buildings or vegetation) remains a v0.4+ item.
  • Browser sensor scope. DeviceOrientation on Chromium reports the magnetic heading, not true. Apply a magnetic-declination correction (e.g., ~6.7° W in western NC, 2026) for survey-grade work. iOS Safari reports webkitCompassHeading in true when present.

12 · References

  • NIMA (2000). Department of Defense World Geodetic System 1984 (WGS84). NIMA TR8350.2.
  • Butler, H. et al. (2016). The GeoJSON Format. IETF RFC 7946.
  • USGS (2023). 3D Elevation Program (3DEP) — Elevation Point Query Service. nationalmap.gov.
  • FEMA (current). National Flood Hazard Layer (NFHL). msc.fema.gov.
  • W3C (2023). DeviceOrientation Event Specification. w3c.github.io/deviceorientation.
  • W3C (2024). Media Capture and Streams (getUserMedia). w3c.github.io/mediacapture-main.
  • Sutherland, I.E., Hodgman, G.W. (1974). Reentrant polygon clipping. Comm. ACM 17(1), 32–42.
  • NOAA NCEI. World Magnetic Model 2025 — magnetic declination tables. ncei.noaa.gov/products/world-magnetic-model.
DEMO MODE — synthetic illustrative data, not engineering analysis. Real USGS 3DEP terrain · real WGS84→ENU→pinhole math · procedural downstream polygon.
depth—
WSEL—
range—
lat—, lon— —
elev— m
hdg—°
pitch—°
roll—°
offset δ0.0°  decl—  vis verts0/0
terrainoff  fov—°  screenportrait
Exhibit — of —
—
—