From 05be41eef05cc370241c4f6726b2c9b2769abd8d Mon Sep 17 00:00:00 2001 From: David Heyman Date: Thu, 5 Nov 2026 14:57:20 +0000 Subject: [PATCH 1/7] Enable scroll zoom and open with tints, hillshade and lighter labels Scroll-wheel zoom was off. Turn it on, pinned to whole zoom levels because the elevation layer only rasterizes tiles at the map's exact zoom, with a short wheel debounce so one long scroll is one zoom. The first view was hairline brown contours on a cream page under Esri's heavily haloed place labels. Default the background to hypsometric tints, multiply a faint Esri World Hillshade over the contour canvas for orientation (on screen only; exports still read the canvas alone), and swap the label tiles for Esri's Light Gray Reference layer. The hillshade gets its own toggle in the Style panel's Reference section. Make the SVG index-line test set its background explicitly instead of relying on the old default. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +-- src/components/contours/download-panel.tsx | 2 +- src/components/contours/style-panel.tsx | 11 +++++-- src/hooks/use-contour-map.ts | 38 +++++++++++++++++++--- src/lib/contours/__tests__/draw.test.ts | 2 +- src/lib/contours/constants.ts | 16 ++++++--- src/lib/contours/types.ts | 3 ++ 7 files changed, 62 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 38734c5..865bce2 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 diff --git a/src/components/contours/download-panel.tsx b/src/components/contours/download-panel.tsx index 3d51fbe..0040e4a 100644 --- a/src/components/contours/download-panel.tsx +++ b/src/components/contours/download-panel.tsx @@ -15,7 +15,7 @@ export const DownloadPanel = ({ actions }: { actions: ContourMapActions }) => { <>
- + + onChange({ showHillshade })} + /> onChange({ showLabels })} /> diff --git a/src/hooks/use-contour-map.ts b/src/hooks/use-contour-map.ts index 31ee528..c185254 100644 --- a/src/hooks/use-contour-map.ts +++ b/src/hooks/use-contour-map.ts @@ -2,7 +2,18 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import L from "leaflet"; -import { CANVAS_BUFFER, ESRI_ATTRIBUTION, ESRI_PLACES_URL, ESRI_ROADS_URL, MAX_ZOOM, REDRAW_DELAY_MS, randomExampleLocation } from "@/lib/contours/constants"; +import { + CANVAS_BUFFER, + ESRI_ATTRIBUTION, + ESRI_HILLSHADE_URL, + ESRI_LABELS_URL, + ESRI_ROADS_URL, + HILLSHADE_OPACITY, + MAX_ZOOM, + REDRAW_DELAY_MS, + WHEEL_DEBOUNCE_MS, + randomExampleLocation, +} from "@/lib/contours/constants"; import { ContourWorkerClient } from "@/lib/contours/contour-client"; import { contoursToSvg, drawContours, hasBathymetry } from "@/lib/contours/draw"; import { canvasToPngBlob, contoursToGeoJson, downloadBlob, downloadText } from "@/lib/contours/export"; @@ -51,6 +62,7 @@ export function useContourMap(settings: ContourSettings): ContourMap { const containerRef = useRef(null); const mapRef = useRef(null); const demLayerRef = useRef(null); + const hillshadeLayerRef = useRef(null); const referenceLayerRef = useRef(null); const contourCanvasRef = useRef(null); const demCanvasRef = useRef(null); @@ -194,7 +206,8 @@ export function useContourMap(settings: ContourSettings): ContourMap { const container = containerRef.current; if (!container || mapRef.current) return; - const map = L.map(container, { scrollWheelZoom: false, zoomControl: false, maxZoom: MAX_ZOOM }); + // Whole zoom levels only: the elevation layer rasterizes the tiles of the map's exact zoom, so a fractional zoom would yield no contours. + const map = L.map(container, { zoomControl: false, maxZoom: MAX_ZOOM, zoomSnap: 1, wheelDebounceTime: WHEEL_DEBOUNCE_MS }); mapRef.current = map; workerRef.current = new ContourWorkerClient(); L.control.zoom({ position: "topright" }).addTo(map); @@ -214,12 +227,23 @@ export function useContourMap(settings: ContourSettings): ContourMap { contourCanvasRef.current = contourCanvas; demCanvasRef.current = document.createElement("canvas"); + // Shaded relief sits above the contour canvas and is multiplied into it, so it + // shades the fills instead of being hidden by them. Blending happens on screen + // only; the exports read the contour canvas alone. + const hillshadePane = map.createPane("hillshade"); + hillshadePane.style.zIndex = "420"; + hillshadePane.style.pointerEvents = "none"; + hillshadePane.style.mixBlendMode = "multiply"; + const hillshade = L.tileLayer(ESRI_HILLSHADE_URL, { maxZoom: MAX_ZOOM, pane: "hillshade", opacity: HILLSHADE_OPACITY, attribution: ESRI_ATTRIBUTION }); + hillshadeLayerRef.current = hillshade; + if (settingsRef.current.showHillshade) hillshade.addTo(map); + const labelPane = map.createPane("labels"); labelPane.style.zIndex = "450"; labelPane.style.pointerEvents = "none"; const roads = L.tileLayer(ESRI_ROADS_URL, { maxZoom: MAX_ZOOM, pane: "labels", opacity: 0.5, attribution: ESRI_ATTRIBUTION }); - const places = L.tileLayer(ESRI_PLACES_URL, { maxZoom: MAX_ZOOM, pane: "labels" }); - const referenceLayer = L.layerGroup([roads, places]); + const labels = L.tileLayer(ESRI_LABELS_URL, { maxZoom: MAX_ZOOM, pane: "labels" }); + const referenceLayer = L.layerGroup([roads, labels]); referenceLayerRef.current = referenceLayer; if (settingsRef.current.showLabels) referenceLayer.addTo(map); @@ -274,6 +298,7 @@ export function useContourMap(settings: ContourSettings): ContourMap { map.remove(); mapRef.current = null; demLayerRef.current = null; + hillshadeLayerRef.current = null; referenceLayerRef.current = null; contourCanvasRef.current = null; demCanvasRef.current = null; @@ -290,6 +315,11 @@ export function useContourMap(settings: ContourSettings): ContourMap { if (!previous) return; const map = mapRef.current; + const hillshadeLayer = hillshadeLayerRef.current; + if (map && hillshadeLayer && previous.showHillshade !== settings.showHillshade) { + if (settings.showHillshade) hillshadeLayer.addTo(map); + else hillshadeLayer.removeFrom(map); + } const referenceLayer = referenceLayerRef.current; if (map && referenceLayer && previous.showLabels !== settings.showLabels) { if (settings.showLabels) referenceLayer.addTo(map); diff --git a/src/lib/contours/__tests__/draw.test.ts b/src/lib/contours/__tests__/draw.test.ts index fe13350..7b9d94e 100644 --- a/src/lib/contours/__tests__/draw.test.ts +++ b/src/lib/contours/__tests__/draw.test.ts @@ -120,7 +120,7 @@ describe("contoursToSvg", () => { it("uses the index line width on index contours in lines mode", () => { const svg = contoursToSvg( land, - makeSettings({ mapType: "lines", indexEvery: 2, interval: 100, lineWidth: 0.75, indexLineWidth: 1.5, lineColor: "#8c7556" }), + makeSettings({ mapType: "lines", background: "none", indexEvery: 2, interval: 100, lineWidth: 0.75, indexLineWidth: 1.5, lineColor: "#8c7556" }), 5, ); const widths = Object.fromEntries(paths(svg).map((p) => [attr(p, "id"), attr(p, "stroke-width")])); diff --git a/src/lib/contours/constants.ts b/src/lib/contours/constants.ts index b422611..ed64787 100644 --- a/src/lib/contours/constants.ts +++ b/src/lib/contours/constants.ts @@ -4,10 +4,15 @@ import type { ContourSettings, MapPosition } from "./types"; export const TERRARIUM_TILE_URL = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"; export const TERRARIUM_ATTRIBUTION = 'Elevation: Terrain Tiles (Mapzen / AWS)'; -/** Esri's keyless reference overlays: roads, then boundaries and place labels. */ +/** Esri's keyless reference overlays: shaded relief under the contours, roads and light gray place labels on top. */ +export const ESRI_HILLSHADE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}"; export const ESRI_ROADS_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}"; -export const ESRI_PLACES_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}"; -export const ESRI_ATTRIBUTION = 'Roads & labels © Esri, HERE, Garmin, OpenStreetMap contributors'; +export const ESRI_LABELS_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Reference/MapServer/tile/{z}/{y}/{x}"; +export const ESRI_ATTRIBUTION = + 'Hillshade, roads & labels © Esri, HERE, Garmin, USGS, NASA, OpenStreetMap contributors'; + +/** The hillshade is multiplied over the contours, so it only needs to be faint. */ +export const HILLSHADE_OPACITY = 0.65; export const MAX_ZOOM = 15; @@ -22,6 +27,8 @@ export const MAX_THRESHOLDS = 2000; /** Wait this long after the last move, tile load or setting change before recomputing. */ export const REDRAW_DELAY_MS = 500; +/** Leaflet waits this long after the last wheel tick before zooming, so one long scroll is one zoom. */ +export const WHEEL_DEBOUNCE_MS = 80; export const SEARCH_DELAY_MS = 250; export const METERS_TO_FEET = 3.28084; @@ -44,6 +51,7 @@ export const EXAMPLE_LOCATIONS: ReadonlyArray<{ name: string } & MapPosition> = export const randomExampleLocation = (): MapPosition => EXAMPLE_LOCATIONS[Math.floor(Math.random() * EXAMPLE_LOCATIONS.length)]; export const DEFAULT_SETTINGS: ContourSettings = { + showHillshade: true, showLabels: true, mapType: "lines", interval: 500, @@ -58,7 +66,7 @@ export const DEFAULT_SETTINGS: ContourSettings = { shadowColor: "#5b5143", shadowWidth: 2, - background: "none", + background: "hypso", solidColor: "#fffcfa", hypsoLowColor: "#79a483", hypsoHighColor: "#ffdecb", diff --git a/src/lib/contours/types.ts b/src/lib/contours/types.ts index 5bc1a03..839a879 100644 --- a/src/lib/contours/types.ts +++ b/src/lib/contours/types.ts @@ -8,6 +8,9 @@ export type IndexEvery = 0 | 2 | 5 | 10; /** Everything the user can change in the Style panel. */ export interface ContourSettings { + /** Shaded relief under the contours, as a guide; never exported. */ + showHillshade: boolean; + /** Roads and place labels over the contours, as a guide; never exported. */ showLabels: boolean; mapType: MapType; /** Contour interval in `unit`. */ From 2b0147ee8b1082d8816deca7fb3b06821e2eea95 Mon Sep 17 00:00:00 2001 From: David Heyman Date: Thu, 5 Nov 2026 15:04:54 +0000 Subject: [PATCH 2/7] Fix the status card, interval field and download feedback loops Three ways a first-time visitor could get confused, found in a UI review: - The status cards were centred over the whole map container, so with a side panel open the error card sat half under it. Shift the status layer right by the panel width above 720px, where the panel leaves map visible beside it. - The interval field committed on every keystroke, so typing "100" passed through "1", tripped the line cap and wiped the map. Give NumberField a commitOn option and use "blur" for the interval, so it applies on Enter or when leaving the field. A failed recompute of the same view now keeps the last good contours drawn, and the message names the interval and suggests a round one that would fit. - The download buttons never disabled. GeoJSON and SVG silently did nothing without contours, and PNG saved a blank image after a pan because the canvas is cleared on moveend. Expose hasContours from the hook, drop the data as soon as the view moves or resizes (which also stops GeoJSON exporting old contours at the new view's coordinates), and disable the buttons with an explanation while nothing can be exported. Co-Authored-By: Claude Fable 5.1 --- src/components/contours/contours-app.tsx | 11 ++++-- src/components/contours/download-panel.tsx | 25 +++++++++++--- src/components/contours/map-view.tsx | 34 ++++++++++++++----- src/components/contours/number-field.tsx | 34 +++++++++++++++---- src/components/contours/style-panel.tsx | 1 + src/hooks/use-contour-map.ts | 26 +++++++++++--- src/lib/contours/__tests__/thresholds.test.ts | 23 ++++++++++++- src/lib/contours/thresholds.ts | 15 ++++++++ 8 files changed, 142 insertions(+), 27 deletions(-) diff --git a/src/components/contours/contours-app.tsx b/src/components/contours/contours-app.tsx index f775bcc..6289b27 100644 --- a/src/components/contours/contours-app.tsx +++ b/src/components/contours/contours-app.tsx @@ -40,13 +40,20 @@ export const ContoursApp = () => {
- + - +
diff --git a/src/components/contours/download-panel.tsx b/src/components/contours/download-panel.tsx index 0040e4a..a0b4233 100644 --- a/src/components/contours/download-panel.tsx +++ b/src/components/contours/download-panel.tsx @@ -8,14 +8,30 @@ import { PanelSection } from "./panel-section"; const Code = ({ children }: { children: string }) => {children}; -export const DownloadPanel = ({ actions }: { actions: ContourMapActions }) => { +interface DownloadPanelProps { + 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; +} + +export const DownloadPanel = ({ actions, hasContours, isDrawing }: DownloadPanelProps) => { const [savingPng, setSavingPng] = useState(false); + const canExport = hasContours && !isDrawing; return ( <>
{ } > - @@ -37,6 +53,7 @@ export const DownloadPanel = ({ actions }: { actions: ContourMapActions }) => { color="secondary" size="md" iconLeading={Download01} + isDisabled={!canExport} isLoading={savingPng} showTextWhileLoading onClick={async () => { @@ -56,7 +73,7 @@ export const DownloadPanel = ({ actions }: { actions: ContourMapActions }) => { title="SVG" description="Contours as vector graphics, one path per elevation. Illuminated contours export as plain polygons; recreate the lighting in your graphics editor." > -
diff --git a/src/components/contours/map-view.tsx b/src/components/contours/map-view.tsx index 21eefac..0bbfb11 100644 --- a/src/components/contours/map-view.tsx +++ b/src/components/contours/map-view.tsx @@ -2,29 +2,47 @@ 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"; +import { cx } from "@/utils/cx"; 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; + /** Whether a side panel is open; the status cards then centre over the visible map. */ + panelOpen: boolean; } -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, panelOpen }: MapViewProps) => ( <>
-
+
{isDrawing && (
@@ -33,7 +51,7 @@ export const MapView = ({ containerRef, isDrawing, problem }: MapViewProps) => ( )} {problem && !isDrawing && (
- {describeProblem(problem)} + {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/style-panel.tsx b/src/components/contours/style-panel.tsx index 5006574..3e4496a 100644 --- a/src/components/contours/style-panel.tsx +++ b/src/components/contours/style-panel.tsx @@ -57,6 +57,7 @@ export const StylePanel = ({ settings, onChange, hasBathymetry }: StylePanelProp value={settings.interval} onChange={(interval) => onChange({ interval })} min={Number.MIN_VALUE} + commitOn="blur" className="flex-1" /> (null); const gridSize = () => { @@ -112,9 +115,19 @@ export function useContourMap(settings: ContourSettings): ContourMap { if (timerRef.current === null && inFlightRef.current === 0) setIsDrawing(false); }; - const applyResult = (result: ContourResult) => { - dataRef.current = result.status === "ok" ? result.data : null; + /** The contours on screen no longer match the view; drop them so they can't be exported. */ + const invalidateData = () => { + dataRef.current = null; + setHasContours(false); + }; + + const applyResult = (result: ContourResult, viewChanged: boolean) => { + if (result.status === "ok") dataRef.current = result.data; + // A failed recompute of the same view (say, an interval that is too fine) keeps + // the last good contours on screen; a failure after a move has nothing to keep. + else if (result.status === "empty" || viewChanged) dataRef.current = null; setProblem(result.status === "failed" ? result.problem : null); + setHasContours(dataRef.current !== null); setBathymetry(dataRef.current ? hasBathymetry(dataRef.current) : false); draw(); }; @@ -140,6 +153,7 @@ export function useContourMap(settings: ContourSettings): ContourMap { const worker = workerRef.current; if (!worker) return; const generation = generationRef.current; + const viewChanged = grid !== null; const { interval, unit } = settingsRef.current; inFlightRef.current++; worker @@ -148,12 +162,12 @@ export function useContourMap(settings: ContourSettings): ContourMap { (result) => { // The map moved (or unmounted) while this was computing; its result belongs to the old view. if (generation !== generationRef.current || !mapRef.current) return; - applyResult(result); + applyResult(result, viewChanged); }, (error: unknown) => { if (generation !== generationRef.current || !mapRef.current) return; console.error("Contour computation failed", error); - applyResult({ status: "empty" }); + applyResult({ status: "empty" }, viewChanged); }, ) .finally(() => { @@ -274,11 +288,13 @@ export function useContourMap(settings: ContourSettings): ContourMap { map.on("moveend", () => { // Clear right away; stale contours sliding around are worse than a blank map. contourCanvas.getContext("2d")?.clearRect(0, 0, contourCanvas.width, contourCanvas.height); + invalidateData(); syncHash(); schedule(Work.Relief); }); map.on("resize", () => { resizeCanvases(); + invalidateData(); schedule(Work.Relief); }); window.addEventListener("hashchange", onHashChange); @@ -370,5 +386,5 @@ export function useContourMap(settings: ContourSettings): ContourMap { [setView, fitBounds, downloadGeoJson, downloadPng, downloadSvg], ); - return { containerRef, isDrawing, problem, hasBathymetry: bathymetry, actions }; + return { containerRef, isDrawing, problem, hasBathymetry: bathymetry, hasContours, actions }; } diff --git a/src/lib/contours/__tests__/thresholds.test.ts b/src/lib/contours/__tests__/thresholds.test.ts index eb775c4..91c415b 100644 --- a/src/lib/contours/__tests__/thresholds.test.ts +++ b/src/lib/contours/__tests__/thresholds.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { MAX_THRESHOLDS, MIN_PIXELS_PER_BAND } from "../constants"; import { NO_DATA } from "../elevation"; -import { computeThresholds } from "../thresholds"; +import { computeThresholds, suggestInterval } from "../thresholds"; /** Repeat each value `count` times, so a "band" can be given an exact pixel count. */ const fill = (...bands: Array<[value: number, count: number]>): number[] => bands.flatMap(([value, count]) => Array(count).fill(value)); @@ -81,3 +81,24 @@ describe("computeThresholds", () => { if (result.status === "failed") expect(result.problem).toEqual({ kind: "too-many-thresholds", count: 1_000_001, limit: 10 }); }); }); + +describe("suggestInterval", () => { + it("rounds the interval that would fit under the limit up to 1, 2 or 5 times a power of ten", () => { + expect(suggestInterval(3494, 2000, 1)).toBe(2); + expect(suggestInterval(5000, 2000, 500)).toBe(2000); + expect(suggestInterval(2001, 2000, 10)).toBe(20); + expect(suggestInterval(60_000, 2000, 1)).toBe(50); + expect(suggestInterval(4000, 2000, 0.25)).toBe(0.5); + }); + + it("never suggests less than what is needed", () => { + for (const [count, interval] of [ + [2500, 1], + [19_999, 3], + [1_000_001, 10], + ] as const) { + const suggestion = suggestInterval(count, 2000, interval); + expect((count * interval) / suggestion).toBeLessThanOrEqual(2000); + } + }); +}); diff --git a/src/lib/contours/thresholds.ts b/src/lib/contours/thresholds.ts index 2f80d1d..450c73b 100644 --- a/src/lib/contours/thresholds.ts +++ b/src/lib/contours/thresholds.ts @@ -68,3 +68,18 @@ export function computeThresholds( return { status: "ok", min: thresholds[0], max: thresholds[thresholds.length - 1], thresholds }; } + +/** + * The smallest round interval (1, 2 or 5 times a power of ten) that would bring + * `count` lines at `interval` down to `limit` or fewer. For the message shown + * when a view has too many lines. + */ +export function suggestInterval(count: number, limit: number, interval: number): number { + const needed = (count / limit) * interval; + const magnitude = 10 ** Math.floor(Math.log10(needed)); + for (const step of [1, 2, 5, 10]) { + const candidate = step * magnitude; + if (candidate >= needed) return candidate; + } + return 10 * magnitude; +} From 69e42c9e4a83602c48423901beb16857cd749db1 Mon Sep 17 00:00:00 2001 From: David Heyman Date: Thu, 5 Nov 2026 15:20:34 +0000 Subject: [PATCH 3/7] Address the medium-severity UI review findings - Escape closes the open panel from anywhere on the page and returns focus to its rail button. The panels now come before the map in the DOM, so Tab reaches their controls right after the rail instead of after the map, zoom buttons and attribution links. - Below 720px the panel is a bottom sheet over the lower half of the map instead of covering it entirely, so style changes stay visible. - The tool rail buttons show captions under their icons and announce the panel they control with aria-expanded; the hover-only tooltips are gone. - Fields render at 16px on narrow touch screens so iOS Safari doesn't zoom the fixed-height page when one is focused. - Place search keeps its popover open to say "No places found" or "Search is unavailable right now" instead of silently showing nothing, and the no-token placeholder no longer talks to the deployer. - The PNG export is rendered from the contour data at twice the on-screen size (or the display's pixel density if higher) rather than copied from the soft 1x canvas, and its description says it is transparent wherever nothing is filled. Illuminated shadows scale with the pixel ratio. - Switching ft/m converts the interval to a nice round number in the new unit (500 ft -> 150 m) instead of keeping the digits and tripling the line density. Co-Authored-By: Claude Fable 5.1 --- src/components/contours/contours-app.tsx | 33 ++++++++++---- src/components/contours/download-panel.tsx | 5 ++- src/components/contours/place-search.tsx | 25 ++++++++--- src/components/contours/side-panel.tsx | 16 ++++--- src/components/contours/style-panel.tsx | 5 ++- src/components/contours/tool-rail.tsx | 48 +++++++++++---------- src/hooks/use-contour-map.ts | 10 +++-- src/lib/contours/__tests__/interval.test.ts | 36 ++++++++++++++++ src/lib/contours/draw.ts | 14 +++--- src/lib/contours/export.ts | 24 +++++++---- src/lib/contours/interval.ts | 27 ++++++++++++ src/styles/globals.css | 13 ++++++ 12 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 src/lib/contours/__tests__/interval.test.ts create mode 100644 src/lib/contours/interval.ts diff --git a/src/components/contours/contours-app.tsx b/src/components/contours/contours-app.tsx index 6289b27..2beafe0 100644 --- a/src/components/contours/contours-app.tsx +++ b/src/components/contours/contours-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useContourMap } from "@/hooks/use-contour-map"; import { DEFAULT_SETTINGS } from "@/lib/contours/constants"; import type { PlaceResult } from "@/lib/contours/geocode"; @@ -10,7 +10,7 @@ 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 { type PanelId, ToolRail, railButtonId } from "./tool-rail"; /** 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); @@ -24,6 +24,20 @@ export const ContoursApp = () => { const togglePanel = useCallback((id: PanelId) => setPanel((current) => (current === id ? null : id)), []); const closePanel = useCallback(() => setPanel(null), []); + // Escape anywhere on the page closes the open panel and hands focus back to its rail + // button. 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 (!panel) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + setPanel(null); + document.getElementById(railButtonId(panel))?.focus(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [panel]); + const goToPlace = useCallback( (place: PlaceResult) => { if (place.bbox) map.actions.fitBounds(place.bbox); @@ -40,6 +54,14 @@ export const ContoursApp = () => {
+ {/* Panels come before the map in the DOM so Tab reaches their controls right after the rail. */} + + + + + + + { unit={settings.unit} panelOpen={panel !== null} /> - - - - - - -
diff --git a/src/components/contours/download-panel.tsx b/src/components/contours/download-panel.tsx index a0b4233..c322634 100644 --- a/src/components/contours/download-panel.tsx +++ b/src/components/contours/download-panel.tsx @@ -48,7 +48,10 @@ export const DownloadPanel = ({ actions, hasContours, isDrawing }: DownloadPanel - +
- + ); diff --git a/src/components/contours/contours-app.tsx b/src/components/contours/contours-app.tsx index 2beafe0..efc0586 100644 --- a/src/components/contours/contours-app.tsx +++ b/src/components/contours/contours-app.tsx @@ -1,42 +1,59 @@ "use client"; -import { useCallback, useEffect, 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, railButtonId } 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), []); - // Escape anywhere on the page closes the open panel and hands focus back to its rail - // button. Listening on the window catches it even when nothing is focused; popovers - // and menus handle their own Escape first and mark it handled. + 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 (!panel) return; + if (!styleOpen) return; const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape" || event.defaultPrevented) return; - setPanel(null); - document.getElementById(railButtonId(panel))?.focus(); + if (event.key === "Escape" && !event.defaultPrevented) closeStyle(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [panel]); + }, [styleOpen, closeStyle]); const goToPlace = useCallback( (place: PlaceResult) => { @@ -48,28 +65,35 @@ export const ContoursApp = () => { return (
- + -
- +
+ + +
- {/* Panels come before the map in the DOM so Tab reaches their controls right after the rail. */} - - - - - - - + {!styleOpen && ( + + )}
diff --git a/src/components/contours/download-menu.tsx b/src/components/contours/download-menu.tsx new file mode 100644 index 0000000..9abd13f --- /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-2 rounded-md p-2 pr-2.5 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 c322634..0000000 --- a/src/components/contours/download-panel.tsx +++ /dev/null @@ -1,85 +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}; - -interface DownloadPanelProps { - 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; -} - -export const DownloadPanel = ({ actions, hasContours, isDrawing }: DownloadPanelProps) => { - const [savingPng, setSavingPng] = useState(false); - const canExport = hasContours && !isDrawing; - - 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 e9fc320..a364fad 100644 --- a/src/components/contours/map-view.tsx +++ b/src/components/contours/map-view.tsx @@ -4,7 +4,6 @@ import type { RefObject } from "react"; import { LoadingIndicator } from "@/components/application/loading-indicator/loading-indicator"; import { suggestInterval } from "@/lib/contours/thresholds"; import type { ContourProblem, Unit } from "@/lib/contours/types"; -import { cx } from "@/utils/cx"; interface MapViewProps { containerRef: RefObject; @@ -13,8 +12,6 @@ interface MapViewProps { /** The interval the problem was computed at, for the suggestion in the message. */ interval: number; unit: Unit; - /** Whether a side panel is open; the status cards then centre over the visible map. */ - panelOpen: boolean; } const describeProblem = (problem: ContourProblem, interval: number, unit: Unit): string => { @@ -30,17 +27,10 @@ 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 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, interval, unit, panelOpen }: MapViewProps) => ( +export const MapView = ({ containerRef, isDrawing, problem, interval, unit }: MapViewProps) => ( <>
-
+
{isDrawing && (
diff --git a/src/components/contours/side-panel.tsx b/src/components/contours/side-panel.tsx deleted file mode 100644 index d90205f..0000000 --- a/src/components/contours/side-panel.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { CloseButton } from "@/components/base/buttons/close-button"; -import { cx } from "@/utils/cx"; -import type { PanelId } from "./tool-rail"; - -interface SidePanelProps { - id: PanelId; - title: string; - description: string; - isOpen: boolean; - onClose: () => void; - children: ReactNode; -} - -/** - * A panel for the Style and Download tools. From 720px up it slides out - * beside the rail and leaves the map visible next to it; below that it is a - * bottom sheet over the lower half of the map, so changes stay visible. - * Closed, it is off-screen and inert. - */ -export const SidePanel = ({ id, title, description, isOpen, onClose, children }: SidePanelProps) => ( -
-
-); diff --git a/src/components/contours/style-panel.tsx b/src/components/contours/style-panel.tsx index a3a6923..ad6bc1a 100644 --- a/src/components/contours/style-panel.tsx +++ b/src/components/contours/style-panel.tsx @@ -4,7 +4,7 @@ import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/but import { RadioButton, RadioGroup } from "@/components/base/radio-buttons/radio-buttons"; import { Select } from "@/components/base/select/select"; import { Toggle } from "@/components/base/toggle/toggle"; -import { BLOG_POST_URL } from "@/lib/contours/constants"; +import { AXIS_MAPS_URL, BLOG_POST_URL } from "@/lib/contours/constants"; import { convertInterval } from "@/lib/contours/interval"; import type { BackgroundType, BathymetryType, ContourSettings, IndexEvery, MapType, Unit } from "@/lib/contours/types"; import { ColorField } from "./color-field"; @@ -26,24 +26,6 @@ export const StylePanel = ({ settings, onChange, hasBathymetry }: StylePanelProp return ( <> - - onChange({ showHillshade })} - /> - onChange({ showLabels })} - /> - - onChange({ mapType: mapType as MapType })}> @@ -168,11 +150,36 @@ export const StylePanel = ({ settings, onChange, hasBathymetry }: StylePanelProp )} + + onChange({ showHillshade })} + /> + onChange({ showLabels })} + /> + +

Elevation comes from Mapzen's open terrain tiles, so contours cover the whole world.{" "} How it works + + Made by{" "} + + Axis Maps + + . +

); diff --git a/src/components/contours/style-sidebar.tsx b/src/components/contours/style-sidebar.tsx new file mode 100644 index 0000000..dca0806 --- /dev/null +++ b/src/components/contours/style-sidebar.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { ReactNode } from "react"; +import { CloseButton } from "@/components/base/buttons/close-button"; +import { cx } from "@/utils/cx"; + +export const STYLE_PANEL_ID = "style-panel"; +export const STYLE_OPEN_BUTTON_ID = "style-open-button"; +export const STYLE_CLOSE_BUTTON_ID = "style-close-button"; + +interface StyleSidebarProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} + +/** + * The Style panel. From 720px up it is a column beside the map that collapses + * to nothing; below that it is a bottom sheet over the lower half of the map, + * so changes stay visible. Closed, it is off-screen and inert. + */ +export const StyleSidebar = ({ isOpen, onClose, children }: StyleSidebarProps) => ( + +); diff --git a/src/components/contours/tool-rail.tsx b/src/components/contours/tool-rail.tsx deleted file mode 100644 index 5a814b5..0000000 --- a/src/components/contours/tool-rail.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import type { FC } from "react"; -import { Brush01, Download01 } from "@untitledui/icons"; -import { ToggleButton as AriaToggleButton } from "react-aria-components"; -import { cx } from "@/utils/cx"; - -export type PanelId = "style" | "download"; - -/** The id of the rail button that opens a panel, so closing the panel can hand focus back to it. */ -export const railButtonId = (id: PanelId) => `${id}-rail-button`; - -interface ToolRailProps { - active: PanelId | null; - onToggle: (panel: PanelId) => void; -} - -/** An icon with a caption, as a real toggle button that announces the panel it controls. */ -const RailButton = ({ id, label, icon: Icon, active, onToggle }: { id: PanelId; label: string; icon: FC<{ className?: string }> } & ToolRailProps) => { - const isSelected = active === id; - return ( - onToggle(id)} - className={cx( - "group/item relative flex w-14 cursor-pointer flex-col items-center gap-1 rounded-md bg-primary py-2 outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2", - isSelected && "bg-secondary hover:bg-secondary_hover", - )} - > - - ); -}; - -/** The slim rail on the left with the panel toggles. */ -export const ToolRail = (props: ToolRailProps) => ( - -); diff --git a/src/hooks/use-contour-map.ts b/src/hooks/use-contour-map.ts index 584438a..48e5909 100644 --- a/src/hooks/use-contour-map.ts +++ b/src/hooks/use-contour-map.ts @@ -300,12 +300,18 @@ export function useContourMap(settings: ContourSettings): ContourMap { }); window.addEventListener("hashchange", onHashChange); + // Leaflet only watches the window for size changes; the Style sidebar opening and + // closing changes the container's size without one. + const resizeObserver = new ResizeObserver(() => map.invalidateSize({ animate: false })); + resizeObserver.observe(container); + const start = parseHash(window.location.hash) ?? randomExampleLocation(); map.setView([start.lat, start.lng], start.zoom); resetCanvasPosition(); return () => { cancelPending(); + resizeObserver.disconnect(); // A counter, not a DOM node: bumping it here is what drops results that arrive after unmount. // eslint-disable-next-line react-hooks/exhaustive-deps generationRef.current++; From be57b8c55057ff9a1b92fb143f45bd596166ca99 Mon Sep 17 00:00:00 2001 From: David Heyman Date: Thu, 5 Nov 2026 15:56:33 +0000 Subject: [PATCH 6/7] Give the Download menu more room More padding around and inside the items, larger gaps, and no height cap on its popover, which the extra padding had pushed it past. Co-Authored-By: Claude Fable 5.1 --- src/components/contours/download-menu.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/contours/download-menu.tsx b/src/components/contours/download-menu.tsx index 9abd13f..e6bbe7a 100644 --- a/src/components/contours/download-menu.tsx +++ b/src/components/contours/download-menu.tsx @@ -62,9 +62,9 @@ export const DownloadMenu = ({ actions, hasContours, isDrawing }: DownloadMenuPr - + {!canExport && ( -

+

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

)} @@ -81,7 +81,7 @@ export const DownloadMenu = ({ actions, hasContours, isDrawing }: DownloadMenuPr textValue={label} className={({ isFocused, isDisabled, isFocusVisible }) => cx( - "flex cursor-pointer items-start gap-2 rounded-md p-2 pr-2.5 outline-hidden select-none", + "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", @@ -100,7 +100,7 @@ export const DownloadMenu = ({ actions, hasContours, isDrawing }: DownloadMenuPr ))} -

The hillshade, roads and labels are never included.

+

The hillshade, roads and labels are never included.

); From 56b48ddb9964d73e3a0990d11a4320f866e336bf Mon Sep 17 00:00:00 2001 From: David Heyman Date: Thu, 5 Nov 2026 15:59:30 +0000 Subject: [PATCH 7/7] Fit the full hex value in the half-width color fields The Low/High and Deep/Shallow fields share a row, and at 12px side padding the last character of a seven-character hex was clipped. Use 8px in the color fields. Co-Authored-By: Claude Fable 5.1 --- src/components/contours/color-field.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) => {