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.
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)
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
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.
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.
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.
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
Point list
Add point manually
| # | label | type | lat, lon | brg | note | |
|---|---|---|---|---|---|---|
| no points — upload CSV / GeoJSON or add manually | ||||||
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
webkitCompassHeadingon 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:
depth_ft— explicit depth (preferred)depth_mordepth— converted to feetwsel_ftminus 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:
- 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.
- Floor outline — faint white stroke showing where water meets land.
- 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.
DeviceOrientationevents 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.
DeviceOrientationon 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 reportswebkitCompassHeadingin 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.