Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Mapbox public token, used only for the place search (Geocoding API).
# Copy this file to .env.local; the value below is the token the original site shipped with.
NEXT_PUBLIC_MAPBOX_TOKEN=pk.eyJ1IjoiYXhpc21hcHMiLCJhIjoieUlmVFRmRSJ9.CpIxovz1TUWe_ecNLFuHNg
32 changes: 32 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Build

on:
push:
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- run: npm run build
env:
# Public Mapbox token for the place search. Without it the build still
# passes; the exported site just has search disabled.
NEXT_PUBLIC_MAPBOX_TOKEN: ${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}

# The static export, ready to copy to any file host. No deploy step yet:
# the hosting target isn't decided.
- uses: actions/upload-artifact@v4
with:
name: site
path: out/
if-no-files-found: error
47 changes: 46 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,46 @@
node_modules/*
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# env files (can opt-in for committing if needed)
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# local env files
.env*.local

# Claude Code local config and worktrees
.claude/
10 changes: 10 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"printWidth": 160,
"tabWidth": 4,
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
"tailwindFunctions": ["sortCx", "cx"],
"importOrder": ["^react$", "^react-dom$", "^(?!react$|react-dom$|@/|\\.).*", "^@/.*", "^\\.{1,2}/.*"],
"importOrderSeparation": false,
"importOrderSortSpecifiers": true,
"tailwindStylesheet": "./src/styles/globals.css"
}
85 changes: 82 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,84 @@
# contours
# Contours

A tool for drawing and exporting simple contour maps. https://contours.axismaps.com/
A tool for drawing and exporting simple contour maps of anywhere on Earth. Pick a place, tune the interval and style, then download GeoJSON, SVG or PNG. Live at https://contours.axismaps.com/.

Read more at https://www.axismaps.com/blog/2018/04/contours-in-browser/
Read more at https://www.axismaps.com/blog/contour-maps-in-a-web-browser

## How it works

Elevation comes from Mapzen's Terrarium tiles, decoded in the browser: each tile is drawn to an offscreen canvas, every pixel is turned back into metres, and [d3-contour](https://github.com/d3/d3-contour) traces contour polygons over the pixel grid at your chosen interval. The result is drawn to a canvas on top of a Leaflet map and can be exported as geodata or graphics.

## Stack

- [Next.js](https://nextjs.org/) (App Router) and TypeScript
- [Untitled UI React](https://www.untitledui.com/react) components on React Aria and Tailwind CSS v4
- [Leaflet](https://leafletjs.com/) 1.9 for the map, loaded client-side only
- d3-contour, d3-geo, d3-scale and d3-interpolate for the contouring and tints

## Data sources

- Elevation: [Terrain Tiles](https://registry.opendata.aws/terrain-tiles/) (Mapzen Terrarium PNGs on AWS), no key needed, max zoom 15.
- Roads and place labels: Esri's keyless `World_Transportation` and `World_Boundaries_and_Places` reference tiles.
- Place search: the Mapbox Geocoding API, with the public token in `NEXT_PUBLIC_MAPBOX_TOKEN`.

## Running locally

Requires Node 20 or newer.

```bash
cp .env.example .env.local
npm install
npm run dev
```

Then open http://localhost:3000/. Without `NEXT_PUBLIC_MAPBOX_TOKEN` the app still works, but the place search is disabled.

## Building

```bash
npm run build
```

The app is configured as a static export (`output: "export"` in `next.config.mjs`), so `next build` writes the whole site to `out/` as plain HTML, JS and CSS. There is no server to run: preview the build with any file server, for example

```bash
npm start
```

which runs `npx serve out`, or `python3 -m http.server 8787 --directory out`. `npm run typecheck` runs the TypeScript compiler on its own.

## Deploying

### Static hosting (contours.axismaps.com)

The site is entirely client-side (Leaflet, d3 and the Mapbox geocoder all run in the browser), so the exported `out/` folder is the deployable: copy its contents to the web root of any static host, exactly as the old `index.html` site was deployed.

1. Make sure `NEXT_PUBLIC_MAPBOX_TOKEN` is set when you build (in `.env.local` locally, or as an environment variable in CI). It is baked into the JavaScript at build time; without it the site works but search is disabled.
2. Run `npm run build`.
3. Upload the contents of `out/` to the host's document root.

Things the host needs to get right:

- **Serve from the domain root.** Asset URLs are root-absolute (`/_next/static/...`, `/icon.svg`). To host under a sub-path such as `example.com/contours/`, set `basePath: "/contours"` in `next.config.mjs` and rebuild; there is no `assetPrefix` or CDN setting otherwise.
- **`/` must serve `out/index.html`.** Every file server does this by default. `trailingSlash` is left at its default; the app has a single route, so it makes no difference here.
- **The map view lives in the URL hash** (`#zoom/lat/lng`), which never reaches the server, so no rewrite rules are needed for deep links.
- **Caching.** Everything under `out/_next/static/` has a content hash in its name and can be cached forever. `index.html` and `icon.svg` should be served with a short or no cache so a new deploy is picked up.
- **404s.** `out/404.html` is a styled not-found page; point the host's error document at it if it supports one. Nothing in the app depends on it.

The GitHub Actions workflow in `.github/workflows/build.yml` runs `npm ci` and `npm run build` on every push and pull request, reading the Mapbox token from the `NEXT_PUBLIC_MAPBOX_TOKEN` repository secret, and uploads `out/` as a workflow artifact named `site`. It does not deploy anywhere; download the artifact or add a deploy step for whichever host is chosen.

### Vercel

The same code runs unchanged on Vercel: import the repository, set `NEXT_PUBLIC_MAPBOX_TOKEN` in the project's environment variables, and Vercel detects Next.js and serves the static export.

## Project layout

- `src/lib/contours/` – the contouring pipeline, framework-free: Terrarium decoding (`elevation.ts`), threshold selection (`thresholds.ts`), d3 contouring (`compute.ts`), canvas and SVG rendering (`draw.ts`), exports (`export.ts`), the URL hash (`hash.ts`), geocoding (`geocode.ts`) and the hidden Leaflet elevation layer (`terrarium-layer.ts`).
- `src/hooks/use-contour-map.ts` – owns the Leaflet map, the canvases and the debounced redraw pipeline, and exposes the download actions.
- `src/components/contours/` – the app shell: header with place search, tool rail, Style and Download panels.
- `src/components/base/`, `src/components/application/` – Untitled UI components, added with `npx untitledui@latest add <component>`.
- `src/styles/theme.css` – design tokens. The brand ramp is the contour brown; grays use Tailwind's warm `stone` scale.

## URL

The view is kept in the hash as `#zoom/lat/lng`, so any view can be linked to. Without a hash the app opens at one of a few example mountains.
Loading
Loading