Skip to content

Rewrite as a Next.js + TypeScript app on Untitled UI - #4

Merged
davidheyman merged 10 commits into
masterfrom
claude/great-goldwasser-880c74
Nov 5, 2026
Merged

davidheyman merged 10 commits into
masterfrom
claude/great-goldwasser-880c74

Conversation

@davidheyman

@davidheyman davidheyman commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Ground-up rewrite of the Contours app as a Next.js 16 (App Router) + TypeScript application using Untitled UI React components, replacing the static index.html / contours.js page. Branched from claude/map-tiles-ui-modernize-517f09 (#3) so it carries the Esri reference tiles and Leaflet 1.9 fixes.

What changed

  • Stack: Next.js 16, TypeScript strict, Tailwind v4, Untitled UI installed via its CLI (unused kit components and dependencies pruned). Leaflet is loaded client-only.
  • Pipeline: the contouring logic is split into typed, framework-free modules under src/lib/contours (Terrarium decoding, junk-band threshold selection, d3-contour, canvas + SVG rendering, GeoJSON/PNG/SVG export, URL hash, Mapbox geocoding). src/hooks/use-contour-map.ts owns the map, canvases and the debounced redraw, with work levels so a style tweak can't cancel a pending re-rasterization.
  • UI: rebuilt on Untitled UI's own patterns rather than the old olive chrome: header with place search, slim icon rail with tooltips, slideout-style panels with label-above fields, React Aria Select, radio groups with hints, toggle, button group for units, and the Untitled UI color picker in an input group for every color. Brand ramp is the contour brown; grays use the warm stone scale. Reduced motion and visible keyboard focus respected.
  • Config: Mapbox token moves to NEXT_PUBLIC_MAPBOX_TOKEN (.env.example provided). README rewritten with run/build instructions.

Behaviour changes

  • GeoJSON and SVG exports no longer carry the 5px canvas-buffer offset the old code had.
  • Bathymetry settings are treated as "none" when the view has no sub-zero elevations (matching what the panel shows).

Test plan

  • npm run build passes with no type errors
  • Contours draw at a start location and after panning (Everest, Fuji, Rainier, Grand Canyon)
  • #zoom/lat/lng hash read on load and kept in sync; hashchange navigates
  • Place search returns results; Enter picks the first; selection fits the bbox
  • Roads/labels toggle on and off
  • Illuminated + hypsometric modes render
  • Bathymetry options appear at a coastal view (Hilo) and bathymetric tints render
  • Color picker popover and hex field stay in sync
  • GeoJSON, PNG and SVG downloads all produce files
  • Phone layout: panel takes the remaining width beside the rail

Follow-ups merged into this PR

  • Contouring in a Web Worker. Decoding and d3-contour run off the main thread; results for a view that has since moved are dropped. Fully transparent pixels (tiles not loaded, failed, or outside the data) are treated as no-data, and a view that would need more than 2,000 contour lines shows a notice instead of freezing the page.
  • Static export. next build writes a self-contained out/ folder that any file server can host; npm start serves it. A GitHub Actions workflow builds on every push and PR. README has a Deploying section.
  • Tests and lint. 58 Vitest unit tests over the pure modules (elevation decode, thresholds, colors, drawing, exports, hash) and an ESLint 9 flat config. npm run typecheck, npm run lint, npm test and npm run build all pass.

🤖 Generated with Claude Code

davidheyman and others added 10 commits November 4, 2026 22:32
The label layer pointed at tile.stamen.com, which shut down in 2023, so
the map showed no roads or labels. Replace it with Esri's keyless
World_Transportation and World_Boundaries_and_Places overlays, which
also restores the roads the checkbox always promised.

Swap Mapbox.js 3.1.1 for plain Leaflet 1.9.4 (the Mapbox token is kept
for place search only), skip failed elevation tiles instead of stalling
the redraw, and position tiles relative to the map container.

Rebuild the page chrome: top bar with search, left tool rail, sliding
Style and Download panels, a phone layout, keyboard focus states and
reduced-motion support. Styles move to css/contours.css. Fix the Axis
Maps link and logo, update the blog link, and drop the dead Universal
Analytics tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replaces the static page (index.html, contours.js, jscolor, leaflet-hash)
with a Next.js 16 App Router app, TypeScript strict, Tailwind v4 and
Untitled UI React components installed with its CLI.

The contouring pipeline is unchanged in behaviour but split into typed
modules under src/lib/contours: Terrarium decoding, threshold selection
with the junk-band filter, d3-contour, canvas and SVG rendering, GeoJSON,
PNG and SVG exports, the URL hash and Mapbox geocoding. A hidden Leaflet
GridLayer still fetches the elevation tiles, and use-contour-map owns the
map, the canvases and the debounced redraw. The GeoJSON and SVG exports
no longer carry the 5px canvas-buffer offset the old code had.

The interface is rebuilt on Untitled UI's own patterns: a header with the
place search, a slim icon rail with tooltips, slideout-style panels with
label-above fields, React Aria Select, radio groups with hints, toggle,
button group for units, and the Untitled UI color picker in an input
group for every color. Brand ramp is the contour brown; grays use the
warm stone scale. Reduced motion and keyboard focus are respected.

The Mapbox token moves to NEXT_PUBLIC_MAPBOX_TOKEN (see .env.example).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Set output: "export" (with images.unoptimized) in next.config.mjs so
`next build` writes a self-contained out/ folder that the existing static
host for contours.axismaps.com can serve; the same config still runs
unchanged on Vercel. Verified the served export end to end with a plain
file server: #zoom/lat/lng hash routing, icon.svg, the dynamically
imported Leaflet chunk, contour drawing and the Mapbox place search.

Point `npm start` at `npx serve out`, since `next start` refuses a
static export.

Add .github/workflows/build.yml, which runs npm ci and npm run build on
pushes and pull requests with NEXT_PUBLIC_MAPBOX_TOKEN from a repository
secret and uploads out/ as an artifact. No deploy step: the hosting
target isn't decided.

Document the export, the host requirements (serve from the domain root,
basePath for sub-paths, caching, 404 page) and Vercel in the README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Tests (npm test, Vitest, node environment) cover the pure modules in
src/lib/contours: Terrarium decoding and unit rounding, threshold
selection and the junk-band filter, hex normalisation and alpha, hash
parsing and formatting, style resolution and fills (hypsometric domain
shift, bathymetry only below sea level), SVG serialisation and the
GeoJSON builder, all on small synthetic grids.

Lint (npm run lint) uses eslint-config-next core-web-vitals + typescript
with eslint-config-prettier on top. Fixes for what it reported:
- color-field, number-field: adopt a changed prop during render instead
  of setState in an effect (react-hooks/set-state-in-effect).
- place-search: clear results in the input handler, not the effect.
- use-contour-map: mirror settings into the ref from an effect instead
  of during render (react-hooks/refs).
- input.tsx: empty interface -> type alias.
- avatar img tags: explicit no-img-element disables with a reason.
- postcss.config.mjs: named export object.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Decoding, threshold selection and d3-contour now run in
src/lib/contours/contour.worker.ts, a thin wrapper around the unchanged
pure computeContours. The hook rasterizes the tiles on the main thread,
transfers the pixel buffer to the worker and draws the result when it
arrives; the worker keeps the last grid so an interval or unit change
only sends the new parameters. A generation counter drops results that
belong to a view the map has already left, and the "Drawing contours"
card stays up from the moment work is scheduled until the result is
drawn. The double requestAnimationFrame is gone since nothing blocks.

Pixels with alpha 0 (a tile that failed to load or is outside the data)
decoded to -32768 m, which stretched the threshold range over tens of
thousands of steps and froze the tab. They are now NaN, which d3-contour
v4 treats as missing, and computeThresholds skips them when picking the
range and counting bands. Threshold counts above MAX_THRESHOLDS (2000)
compute nothing and come back as a ContourResult failure that the hook
exposes as `problem`, shown as a notice in the map's status card area.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The threshold and elevation tests were written before contouring moved to
a Web Worker: computeThresholds now returns a status result instead of
null, and decodeElevationGrid returns a Float32Array with NaN as no-data.
Rewrite the assertions for those shapes and add cases for no-data values
and the threshold cap. Silence the one lint warning on the generation
counter bumped in the map effect cleanup, which is not a DOM ref.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Turbopack copies the contour worker's source into out/_next/static/media
as an asset, so the second build on any machine, and the first one on
Vercel, failed when tsc swept up out/. Exclude out, .next and .vercel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@davidheyman
davidheyman merged commit 05ffa9a into master Nov 5, 2026
2 checks passed
@davidheyman
davidheyman deleted the claude/great-goldwasser-880c74 branch November 5, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant