Skip to content
Merged
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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.
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, tinted by elevation by default, and can be exported as geodata or graphics. A faint Esri hillshade is multiplied over the canvas on screen for orientation.

## Stack

Expand All @@ -18,7 +18,7 @@ Elevation comes from Mapzen's Terrarium tiles, decoded in the browser: each tile
## 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.
- Hillshade, roads and place labels: Esri's keyless `World_Hillshade`, `World_Transportation` and `World_Light_Gray_Reference` tiles. They are guides for finding a place and never appear in downloads.
- Place search: the Mapbox Geocoding API, with the public token in `NEXT_PUBLIC_MAPBOX_TOKEN`.

## Running locally
Expand Down Expand Up @@ -75,7 +75,7 @@ The same code runs unchanged on Vercel: import the repository, set `NEXT_PUBLIC_

- `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/contours/` – the app shell: header with place search and the Download menu, the collapsible Style sidebar (a bottom sheet on phones), and the map view.
- `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.

Expand Down
30 changes: 27 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Analytics } from "@vercel/analytics/next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import Script from "next/script";
import { RouteProvider } from "@/providers/router-provider";
import "@/styles/globals.css";
import { cx } from "@/utils/cx";
Expand All @@ -17,14 +18,37 @@ export const metadata: Metadata = {
};

export const viewport: Viewport = {
themeColor: "#ffffff",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0c0a09" },
],
width: "device-width",
initialScale: 1,
};

/**
* Follow the OS colour scheme by toggling the theme classes on <html>. Runs before
* the first paint so dark-mode users never flash a white page.
*/
const colorSchemeScript = `(function () {
var dark = window.matchMedia("(prefers-color-scheme: dark)");
function apply() {
document.documentElement.classList.toggle("dark-mode", dark.matches);
document.documentElement.classList.toggle("light-mode", !dark.matches);
}
apply();
dark.addEventListener("change", apply);
})();`;

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className="light-mode">
// The script rewrites the class before hydration, so React's "light-mode" is only a fallback.
<html lang="en" className="light-mode" suppressHydrationWarning>
<head>
<Script id="color-scheme" strategy="beforeInteractive">
{colorSchemeScript}
</Script>
</head>
<body className={cx(inter.variable, "overflow-hidden bg-primary antialiased")}>
<RouteProvider>{children}</RouteProvider>
<Analytics />
Expand Down
49 changes: 31 additions & 18 deletions src/components/contours/app-header.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,48 @@
"use client";

import { Button } from "@/components/base/buttons/button";
import type { ContourMapActions } from "@/hooks/use-contour-map";
import { AXIS_MAPS_LOGO_URL, AXIS_MAPS_URL } from "@/lib/contours/constants";
import type { PlaceResult } from "@/lib/contours/geocode";
import { DownloadMenu } from "./download-menu";
import { ContoursLogo } from "./logo";
import { PlaceSearch } from "./place-search";

export const AppHeader = ({ onSelectPlace }: { onSelectPlace: (place: PlaceResult) => void }) => (
interface AppHeaderProps {
onSelectPlace: (place: PlaceResult) => void;
actions: ContourMapActions;
hasContours: boolean;
isDrawing: boolean;
}

export const AppHeader = ({ onSelectPlace, actions, hasContours, isDrawing }: AppHeaderProps) => (
<header className="flex h-16 shrink-0 items-center gap-3 border-b border-secondary bg-primary px-4 md:gap-6 md:px-6">
<div className="flex items-center gap-2.5">
<div className="flex shrink-0 items-center gap-2.5">
<ContoursLogo />
<h1 className="hidden text-md font-semibold text-primary sm:block">Contours</h1>
<h1 className="text-md font-semibold text-primary">Contours</h1>
{/* The credit sits with the wordmark; on phones it moves to the Style panel's footer. */}
<Button
color="link-gray"
size="sm"
href={AXIS_MAPS_URL}
target="_blank"
rel="noopener"
// At least a 40px target where only the logo shows.
className="hidden min-h-10 min-w-10 shrink-0 justify-center sm:flex"
iconLeading={
// eslint-disable-next-line @next/next/no-img-element
<img src={AXIS_MAPS_LOGO_URL} alt="" width={20} height={20} className="size-5 shrink-0 rounded" />
}
>
<span className="hidden md:inline">by Axis Maps</span>
<span className="sr-only md:hidden">by Axis Maps</span>
</Button>
</div>

<div className="mx-auto w-full max-w-md">
<div className="mx-auto w-full max-w-md min-w-0">
<PlaceSearch onSelect={onSelectPlace} />
</div>

<Button
color="link-gray"
size="sm"
href={AXIS_MAPS_URL}
target="_blank"
rel="noopener"
iconLeading={
// eslint-disable-next-line @next/next/no-img-element
<img src={AXIS_MAPS_LOGO_URL} alt="" width={20} height={20} className="size-5 shrink-0 rounded" />
}
>
<span className="hidden md:inline">by Axis Maps</span>
<span className="sr-only md:hidden">by Axis Maps</span>
</Button>
<DownloadMenu actions={actions} hasContours={hasContours} isDrawing={isDrawing} />
</header>
);
3 changes: 2 additions & 1 deletion src/components/contours/color-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export const ColorField = ({ label, value, onChange, className }: ColorFieldProp
size="sm"
label={label}
className={className}
inputClassName="lowercase tabular-nums"
// Tighter than the default so a seven-character hex fits in the half-width fields.
inputClassName="px-2 lowercase tabular-nums"
leadingAddon={swatch}
value={draft}
onChange={(text) => {
Expand Down
86 changes: 66 additions & 20 deletions src/components/contours/contours-app.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,59 @@
"use client";

import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Brush01 } from "@untitledui/icons";
import { Button } from "@/components/base/buttons/button";
import { useContourMap } from "@/hooks/use-contour-map";
import { DEFAULT_SETTINGS } from "@/lib/contours/constants";
import type { PlaceResult } from "@/lib/contours/geocode";
import type { ContourSettings } from "@/lib/contours/types";
import { AppHeader } from "./app-header";
import { DownloadPanel } from "./download-panel";
import { MapView } from "./map-view";
import { SidePanel } from "./side-panel";
import { StylePanel } from "./style-panel";
import { type PanelId, ToolRail } from "./tool-rail";
import { STYLE_CLOSE_BUTTON_ID, STYLE_OPEN_BUTTON_ID, STYLE_PANEL_ID, StyleSidebar } from "./style-sidebar";

/** Open the Style panel on first load where it won't cover the map. */
const initialPanel = (): PanelId | null => (typeof window !== "undefined" && window.matchMedia("(min-width: 720px)").matches ? "style" : null);
/** Start with the Style panel open where it sits beside the map rather than over it. */
const initialStyleOpen = (): boolean => typeof window !== "undefined" && window.matchMedia("(min-width: 720px)").matches;

export const ContoursApp = () => {
const [settings, setSettings] = useState<ContourSettings>(DEFAULT_SETTINGS);
const [panel, setPanel] = useState<PanelId | null>(initialPanel);
const [styleOpen, setStyleOpen] = useState(initialStyleOpen);
/** Which button to focus once the panel has opened or closed; the other one only exists then. */
const focusAfterToggle = useRef<string | null>(null);
const map = useContourMap(settings);

const updateSettings = useCallback((patch: Partial<ContourSettings>) => setSettings((current) => ({ ...current, ...patch })), []);
const togglePanel = useCallback((id: PanelId) => setPanel((current) => (current === id ? null : id)), []);
const closePanel = useCallback(() => setPanel(null), []);

const openStyle = useCallback(() => {
focusAfterToggle.current = STYLE_CLOSE_BUTTON_ID;
setStyleOpen(true);
}, []);
const closeStyle = useCallback(() => {
focusAfterToggle.current = STYLE_OPEN_BUTTON_ID;
setStyleOpen(false);
}, []);

useEffect(() => {
const id = focusAfterToggle.current;
focusAfterToggle.current = null;
if (!id) return;
// After the press that toggled the panel has finished, since the pressed button
// otherwise takes focus back. A timer rather than an animation frame, which
// never fires while the tab is in the background.
const timer = window.setTimeout(() => document.getElementById(id)?.focus(), 0);
return () => window.clearTimeout(timer);
}, [styleOpen]);

// Escape anywhere on the page closes the panel. Listening on the window catches it even
// when nothing is focused; popovers and menus handle their own Escape first and mark it handled.
useEffect(() => {
if (!styleOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) closeStyle();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [styleOpen, closeStyle]);

const goToPlace = useCallback(
(place: PlaceResult) => {
Expand All @@ -34,20 +65,35 @@ export const ContoursApp = () => {

return (
<div className="flex h-dvh w-full flex-col overflow-hidden bg-primary">
<AppHeader onSelectPlace={goToPlace} />
<AppHeader onSelectPlace={goToPlace} actions={map.actions} hasContours={map.hasContours} isDrawing={map.isDrawing} />

<div className="flex min-h-0 flex-1">
<ToolRail active={panel} onToggle={togglePanel} />
<div className="relative flex min-h-0 flex-1">
<StyleSidebar isOpen={styleOpen} onClose={closeStyle}>
<StylePanel settings={settings} onChange={updateSettings} hasBathymetry={map.hasBathymetry} />
</StyleSidebar>

<div className="relative min-w-0 flex-1">
<MapView containerRef={map.containerRef} isDrawing={map.isDrawing} problem={map.problem} />

<SidePanel id="style" title="Style" description="Interval, lines, tints and labels." isOpen={panel === "style"} onClose={closePanel}>
<StylePanel settings={settings} onChange={updateSettings} hasBathymetry={map.hasBathymetry} />
</SidePanel>
<SidePanel id="download" title="Download" description="Export what's on screen." isOpen={panel === "download"} onClose={closePanel}>
<DownloadPanel actions={map.actions} />
</SidePanel>
<MapView
containerRef={map.containerRef}
isDrawing={map.isDrawing}
problem={map.problem}
interval={settings.interval}
unit={settings.unit}
/>
{!styleOpen && (
<Button
id={STYLE_OPEN_BUTTON_ID}
color="secondary"
size="sm"
iconLeading={Brush01}
aria-controls={STYLE_PANEL_ID}
aria-expanded={false}
onPress={openStyle}
className="absolute top-4 left-4 z-10 shadow-lg"
>
Style
</Button>
)}
</div>
</div>
</div>
Expand Down
107 changes: 107 additions & 0 deletions src/components/contours/download-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"use client";

import { type FC, useState } from "react";
import { Download01, FileCode02, Globe02, Image01 } from "@untitledui/icons";
import { Menu as AriaMenu, MenuItem as AriaMenuItem, MenuTrigger as AriaMenuTrigger, Text as AriaText } from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { Popover } from "@/components/base/select/popover";
import type { ContourMapActions } from "@/hooks/use-contour-map";
import { cx } from "@/utils/cx";

interface DownloadMenuProps {
actions: ContourMapActions;
/** Whether contours for the current view are on screen. */
hasContours: boolean;
/** Whether a redraw is pending or in progress; the canvas may be blank meanwhile. */
isDrawing: boolean;
}

type Format = "geojson" | "svg" | "png";

const FORMATS: { id: Format; label: string; description: string; icon: FC<{ className?: string }> }[] = [
{
id: "geojson",
label: "GeoJSON",
description: "Overlapping MultiPolygon features with an elevation property, for GIS or mapping software. No styles.",
icon: Globe02,
},
{
id: "svg",
label: "SVG",
description: "One path per elevation with your styles. Illuminated contours export as plain polygons.",
icon: FileCode02,
},
{
id: "png",
label: "PNG image",
description: "Your styles at twice the on-screen size, or your display's pixel density if higher. Transparent where nothing is filled.",
icon: Image01,
},
];

/** The header's Download button: a menu of the three export formats, disabled while there is nothing to export. */
export const DownloadMenu = ({ actions, hasContours, isDrawing }: DownloadMenuProps) => {
const [isSaving, setIsSaving] = useState(false);
const canExport = hasContours && !isDrawing;

const download = async (format: Format) => {
setIsSaving(true);
try {
if (format === "geojson") actions.downloadGeoJson();
else if (format === "svg") actions.downloadSvg();
else await actions.downloadPng();
} catch (error) {
console.error("Download failed", error);
} finally {
setIsSaving(false);
}
};

return (
<AriaMenuTrigger>
<Button color="primary" size="sm" iconLeading={Download01} isLoading={isSaving} showTextWhileLoading aria-label="Download" className="shrink-0">
<span className="hidden sm:inline">Download</span>
</Button>
<Popover size="lg" placement="bottom end" className="max-h-none! w-80 p-2">
{!canExport && (
<p role="status" className="px-3 pt-2 pb-3 text-sm font-medium text-primary">
{isDrawing ? "Downloads will be ready once the contours finish drawing." : "Nothing to download yet. Move to a view with contours."}
</p>
)}
<AriaMenu
aria-label="Download formats"
className="outline-hidden"
disabledKeys={canExport ? [] : FORMATS.map((f) => f.id)}
onAction={(key) => void download(key as Format)}
>
{FORMATS.map(({ id, label, description, icon: Icon }) => (
<AriaMenuItem
key={id}
id={id}
textValue={label}
className={({ isFocused, isDisabled, isFocusVisible }) =>
cx(
"flex cursor-pointer items-start gap-3 rounded-lg p-3 outline-hidden select-none",
isFocused && "bg-primary_hover",
isDisabled && "cursor-not-allowed opacity-50",
isFocusVisible && "ring-2 ring-focus-ring ring-inset",
)
}
>
<Icon aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-fg-quaternary" />
<div className="flex min-w-0 flex-col">
<AriaText slot="label" className="text-sm font-medium text-primary">
{label}
</AriaText>
<AriaText slot="description" className="text-sm text-tertiary">
{description}
</AriaText>
</div>
</AriaMenuItem>
))}
</AriaMenu>
<p className="mt-2 border-t border-secondary px-3 pt-3 pb-1.5 text-xs text-tertiary">The hillshade, roads and labels are never included.</p>
</Popover>
</AriaMenuTrigger>
);
};
Loading
Loading