Setup
Running the app, its structure, and its architecture
Using the hosted app
The app is deployed at terrain-viewer.iconem.com — no install needed. Open it, pick a location, and toggle visualization modes from the sidebar.
A dedicated historical-imagery-only entry point is available at historical-satellite.iconem.com — same app, defaulting straight into Historical mode on a first visit (see Basemaps & Historical Imagery).
Running locally
git clone https://github.com/Iconem/terrain-viewer.git
cd terrain-viewer
pnpm install
pnpm run dev # http://localhost:5173
pnpm run build # bundles to dist/The URL as app state
Nearly every sidebar control — viewport (lat/lng/zoom/pitch/bearing), active terrain/basemap sources, every visualization toggle and its options, split-screen layout, historical dates — is mirrored to the URL query string via nuqs. That means:
- Copying the address bar URL always reproduces the exact current view for someone else.
- Bookmarks are really just a saved query string plus a thumbnail.
- Embedding a specific configuration (e.g. in an iframe) is just linking to the right URL — see
?project=andterrainUrl=/basemapUrl=for pointing straight at a custom source without registering it first.
Not everything is in the URL — API keys, custom color ramps, and collapsed-section preferences live in localStorage via jotai, since those are per-browser, not part of "the view" someone else would want when opening your link.
See Tech Stack for the libraries this is built on.
Structure
components/TerrainViewer.tsx(~3,200 lines) — central orchestrator, all state wiringcomponents/TerrainControlPanel/— the big sidebar, one file per settings sectioncomponents/LayersAndSources/— MapLibre layer/source compositionlib/— the real domain logic: one*-protocol.tsfile per terrain-derivative raster computation, historical-imagery source adapters, export pipelines,settings-atoms.ts
Architecture
- State is split:
nuqsfor shareable URL state (viz toggles, camera, ramps),jotai(atomWithStorage) for local persistence (API keys, UI collapse state) - No router — app "modes" are just state, not routes
- No real backend — everything streams from tile/COG providers client-side, with an optional self-hosted
titilerfor export/contours - Custom MapLibre protocols intercept tile requests to compute derived rasters (some GPU-accelerated) client-side — see below
Custom protocols
Each of these is a maplibregl.addProtocol() registration in TerrainViewer.tsx, backed by a lib/*-protocol.ts file:
- Terrain Analysis Rendering Pipeline — Slope, Aspect, Curvature, TRI, TPI, Roughness, Blobness, Sky-View Factor, Openness, Local Dominance: the shared "compute a scalar, smuggle it through raster-dem" mechanism
- LRM (Local Relief Model) — the one terrain-derivative mode that reads a coarser pyramid ancestor tile instead of a same-zoom neighborhood
- Lighting Effects — Matcap, Phong, Hard Shadows: real surface normals, GPU shading, and a live-WebGL fast path
- Equations & Formulas — every mode's exact math in one place
- WMS Float32 DEM Protocol — bridging a plain WMS elevation service into MapLibre's raster-dem pipeline
Historical imagery source adapters
Not custom protocols (ordinary raster/XYZ sources), but the other half of lib/'s domain logic:
- Google Earth Historical Protocol — the one exception that IS a custom protocol: a reverse-engineered, self-contained-per-tile Time Machine quadtree
- ESRI Wayback — a release-chained catalog of full global mosaics, resolved to a date via nearest-real-capture matching