Terrain Viewer
Dev

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= and terrainUrl=/basemapUrl= for pointing straight at a custom source without registering it first.
Rendering diagram…

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 wiring
  • components/TerrainControlPanel/ — the big sidebar, one file per settings section
  • components/LayersAndSources/ — MapLibre layer/source composition
  • lib/ — the real domain logic: one *-protocol.ts file per terrain-derivative raster computation, historical-imagery source adapters, export pipelines, settings-atoms.ts

Architecture

  • State is split: nuqs for 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 titiler for 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

On this page