diff --git a/README.md b/README.md index 38734c5..13875c5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 `. - `src/styles/theme.css` – design tokens. The brand ramp is the contour brown; grays use Tailwind's warm `stone` scale. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index db7d332..2db3650 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"; @@ -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 . 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 ( - + // The script rewrites the class before hydration, so React's "light-mode" is only a fallback. + + + + {children} diff --git a/src/components/contours/app-header.tsx b/src/components/contours/app-header.tsx index b103646..76f27fa 100644 --- a/src/components/contours/app-header.tsx +++ b/src/components/contours/app-header.tsx @@ -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) => (
-
+
-

Contours

+

Contours

+ {/* The credit sits with the wordmark; on phones it moves to the Style panel's footer. */} +
-
+
- +
); diff --git a/src/components/contours/color-field.tsx b/src/components/contours/color-field.tsx index 8484718..7ecfa3c 100644 --- a/src/components/contours/color-field.tsx +++ b/src/components/contours/color-field.tsx @@ -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) => { diff --git a/src/components/contours/contours-app.tsx b/src/components/contours/contours-app.tsx index f775bcc..efc0586 100644 --- a/src/components/contours/contours-app.tsx +++ b/src/components/contours/contours-app.tsx @@ -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(DEFAULT_SETTINGS); - const [panel, setPanel] = useState(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(null); const map = useContourMap(settings); const updateSettings = useCallback((patch: Partial) => 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) => { @@ -34,20 +65,35 @@ export const ContoursApp = () => { return (
- + -
- +
+ + +
- - - - - - - - + + {!styleOpen && ( + + )}
diff --git a/src/components/contours/download-menu.tsx b/src/components/contours/download-menu.tsx new file mode 100644 index 0000000..e6bbe7a --- /dev/null +++ b/src/components/contours/download-menu.tsx @@ -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 ( + + + + {!canExport && ( +

+ {isDrawing ? "Downloads will be ready once the contours finish drawing." : "Nothing to download yet. Move to a view with contours."} +

+ )} + f.id)} + onAction={(key) => void download(key as Format)} + > + {FORMATS.map(({ id, label, description, icon: Icon }) => ( + + 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", + ) + } + > + + ))} + +

The hillshade, roads and labels are never included.

+
+
+ ); +}; diff --git a/src/components/contours/download-panel.tsx b/src/components/contours/download-panel.tsx deleted file mode 100644 index 3d51fbe..0000000 --- a/src/components/contours/download-panel.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Download01, InfoCircle } from "@untitledui/icons"; -import { Button } from "@/components/base/buttons/button"; -import type { ContourMapActions } from "@/hooks/use-contour-map"; -import { PanelSection } from "./panel-section"; - -const Code = ({ children }: { children: string }) => {children}; - -export const DownloadPanel = ({ actions }: { actions: ContourMapActions }) => { - const [savingPng, setSavingPng] = useState(false); - - return ( - <> -
-
- - - Contours for GIS or mapping software, as overlapping MultiPolygon features with an elevation property. Geodata - only, no styles. - - } - > - - - - - - - - - - - - ); -}; diff --git a/src/components/contours/map-view.tsx b/src/components/contours/map-view.tsx index 21eefac..a364fad 100644 --- a/src/components/contours/map-view.tsx +++ b/src/components/contours/map-view.tsx @@ -2,40 +2,49 @@ import type { RefObject } from "react"; import { LoadingIndicator } from "@/components/application/loading-indicator/loading-indicator"; -import type { ContourProblem } from "@/lib/contours/types"; +import { suggestInterval } from "@/lib/contours/thresholds"; +import type { ContourProblem, Unit } from "@/lib/contours/types"; interface MapViewProps { containerRef: RefObject; isDrawing: boolean; problem: ContourProblem | null; + /** The interval the problem was computed at, for the suggestion in the message. */ + interval: number; + unit: Unit; } -const describeProblem = (problem: ContourProblem): string => { +const describeProblem = (problem: ContourProblem, interval: number, unit: Unit): string => { switch (problem.kind) { - case "too-many-thresholds": - return `Too many contour lines for this view (${problem.count.toLocaleString()}; the limit is ${problem.limit.toLocaleString()}). Try a larger interval.`; + case "too-many-thresholds": { + const suggestion = suggestInterval(problem.count, problem.limit, interval); + return `Too many contour lines for this view: ${problem.count.toLocaleString()} at ${interval.toLocaleString()} ${unit}, and the limit is ${problem.limit.toLocaleString()}. Try an interval of ${suggestion.toLocaleString()} ${unit} or more.`; + } } }; const cardClass = - "flex items-center gap-3 rounded-xl bg-primary py-2.5 pr-4 pl-3 shadow-lg ring-1 ring-secondary_alt duration-150 animate-in fade-in slide-in-from-top-1"; + "flex items-center gap-3 rounded-xl bg-primary py-2.5 pr-4 pl-3 shadow-lg ring-1 ring-secondary_alt duration-150 animate-in fade-in slide-in-from-top-1 motion-reduce:animate-none"; /** The Leaflet container plus floating status cards: "Drawing contours" and why a view has none. */ -export const MapView = ({ containerRef, isDrawing, problem }: MapViewProps) => ( +export const MapView = ({ containerRef, isDrawing, problem, interval, unit }: MapViewProps) => ( <> -
-
+
+
{isDrawing && (
Drawing contours…
)} - {problem && !isDrawing && ( -
- {describeProblem(problem)} -
- )} + {/* Only problems are announced; the busy card would otherwise be read out on every pan. */} +
+ {problem && !isDrawing && ( +
+ {describeProblem(problem, interval, unit)} +
+ )} +
); diff --git a/src/components/contours/number-field.tsx b/src/components/contours/number-field.tsx index f8eaf60..177185e 100644 --- a/src/components/contours/number-field.tsx +++ b/src/components/contours/number-field.tsx @@ -1,26 +1,34 @@ "use client"; -import { useState } from "react"; +import { type KeyboardEvent, useState } from "react"; import { Input, InputBase } from "@/components/base/input/input"; import { InputGroup } from "@/components/base/input/input-group"; interface NumberFieldProps { label: string; + hint?: string; value: number; onChange: (value: number) => void; /** Smallest value that will be committed. */ min?: number; /** Unit shown after the field, such as "px". */ suffix?: string; + /** + * When the draft reaches `onChange`: on every valid keystroke, or only on + * Enter and blur. Use "blur" where intermediate values are costly, since + * typing "100" passes through "1". + */ + commitOn?: "change" | "blur"; isDisabled?: boolean; className?: string; } /** - * A decimal field. The draft is committed as the user types whenever it - * parses to a valid number, and snaps back to the last good value on blur. + * A decimal field. The draft is committed whenever it parses to a valid number + * (as the user types, or on Enter and blur) and snaps back to the last good + * value on blur otherwise. */ -export const NumberField = ({ label, value, onChange, min = 0, suffix, isDisabled, className }: NumberFieldProps) => { +export const NumberField = ({ label, hint, value, onChange, min = 0, suffix, commitOn = "change", isDisabled, className }: NumberFieldProps) => { const [draft, setDraft] = useState(String(value)); const [lastValue, setLastValue] = useState(value); @@ -35,16 +43,27 @@ export const NumberField = ({ label, value, onChange, min = 0, suffix, isDisable return text.trim() !== "" && Number.isFinite(n) && n >= min ? n : null; }; + /** Commit the draft if it is valid; otherwise snap back to the current value. */ + const commit = () => { + const n = parse(draft); + if (n === null) setDraft(String(value)); + else if (n !== value) onChange(n); + }; + const fieldProps = { value: draft, isDisabled, onChange: (text: string) => { setDraft(text); + if (commitOn !== "change") return; const n = parse(text); if (n !== null && n !== value) onChange(n); }, - onBlur: () => { - if (parse(draft) === null) setDraft(String(value)); + onBlur: commit, + onKeyDown: (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + event.preventDefault(); + commit(); }, }; @@ -53,6 +72,7 @@ export const NumberField = ({ label, value, onChange, min = 0, suffix, isDisable {suffix}} @@ -63,5 +83,5 @@ export const NumberField = ({ label, value, onChange, min = 0, suffix, isDisable ); } - return ; + return ; }; diff --git a/src/components/contours/place-search.tsx b/src/components/contours/place-search.tsx index 27b812b..1381a6e 100644 --- a/src/components/contours/place-search.tsx +++ b/src/components/contours/place-search.tsx @@ -13,10 +13,14 @@ interface PlaceSearchProps { onSelect: (place: PlaceResult) => void; } +/** What the last search produced, so the popover can say "nothing" or "broken" instead of staying shut. */ +type SearchStatus = "idle" | "found" | "empty" | "error"; + /** Debounced Mapbox place search. Enter picks the first result when none is highlighted. */ export const PlaceSearch = ({ onSelect }: PlaceSearchProps) => { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); + const [status, setStatus] = useState("idle"); const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef(null); @@ -24,6 +28,7 @@ export const PlaceSearch = ({ onSelect }: PlaceSearchProps) => { setQuery(text); if (text.trim().length < 2) { setResults([]); + setStatus("idle"); setIsOpen(false); } }; @@ -37,12 +42,14 @@ export const PlaceSearch = ({ onSelect }: PlaceSearchProps) => { const found = await geocodePlace(trimmed, MAPBOX_TOKEN, controller.signal); if (controller.signal.aborted) return; setResults(found); - setIsOpen(found.length > 0); + setStatus(found.length > 0 ? "found" : "empty"); + setIsOpen(true); } catch (error) { if (!controller.signal.aborted) { console.error(error); setResults([]); - setIsOpen(false); + setStatus("error"); + setIsOpen(true); } } }, SEARCH_DELAY_MS); @@ -57,6 +64,7 @@ export const PlaceSearch = ({ onSelect }: PlaceSearchProps) => { onSelect(place); setQuery(""); setResults([]); + setStatus("idle"); setIsOpen(false); (document.activeElement as HTMLElement | null)?.blur(); }; @@ -88,7 +96,7 @@ export const PlaceSearch = ({ onSelect }: PlaceSearchProps) => {