From ff5b7ddb7ab9bebd69e9b983715ccdcc28ec5737 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 21 Jun 2026 12:27:43 +0300 Subject: [PATCH] Show selected grid cell on the map and polish map UI. Draw the snapped cell footprint with dashed guide lines, fly to the cell on click, and align the readout, nav, and control panel styling. Co-authored-by: Cursor --- src/app/globals.css | 155 +++++++++++++------------- src/components/map/EarthMap.tsx | 176 ++++++++++++++++++++++++++---- src/components/map/MapReadout.tsx | 86 ++++++++------- src/components/nav/Brand.tsx | 2 +- src/icons/BrandMark.tsx | 25 +---- src/lib/map/geogrid.test.ts | 74 +++++++++++++ src/lib/map/geogrid.ts | 64 ++++++++++- src/lib/map/mapLabels.test.ts | 109 ++++++++++++++++++ src/lib/map/mapLabels.ts | 151 +++++++++++++++++++++++++ src/lib/map/viewState.test.ts | 45 ++++++++ src/lib/map/viewState.ts | 37 ++++++- src/lib/map/viewportBounds.ts | 30 +++++ src/types/map.ts | 4 + 13 files changed, 788 insertions(+), 170 deletions(-) create mode 100644 src/lib/map/geogrid.test.ts create mode 100644 src/lib/map/mapLabels.test.ts create mode 100644 src/lib/map/mapLabels.ts create mode 100644 src/lib/map/viewState.test.ts create mode 100644 src/lib/map/viewportBounds.ts diff --git a/src/app/globals.css b/src/app/globals.css index 5e205ea..f0a0725 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -23,6 +23,11 @@ --scrim-soft: rgba(10, 10, 10, 0.55); --grid-line: rgba(255, 255, 255, 0.05); --shadow: 0 18px 50px -24px rgba(0, 0, 0, 0.7); + --elevated-bg: rgba(36, 36, 36, 0.94); + --elevated-border: rgba(255, 255, 255, 0.24); + --elevated-shadow: + 0 10px 28px -14px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.08) inset; } :root.light { --bg: #ffffff; @@ -44,6 +49,9 @@ --scrim-soft: rgba(255, 255, 255, 0.55); --grid-line: rgba(10, 10, 10, 0.05); --shadow: 0 18px 50px -28px rgba(0, 0, 0, 0.22); + --elevated-bg: color-mix(in srgb, var(--surface) 92%, var(--bg-deep)); + --elevated-border: var(--border-strong); + --elevated-shadow: 0 8px 20px -16px rgba(0, 0, 0, 0.1); } * { @@ -111,10 +119,12 @@ button { margin: 0 auto; height: 62px; padding: 0 10px 0 22px; - background: var(--nav-bg); - border: 1px solid var(--border); + background: var(--elevated-bg); + border: 1px solid var(--elevated-border); border-radius: 100px; - box-shadow: var(--shadow); + box-shadow: var(--elevated-shadow); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); opacity: 0; animation: pillExpand 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards; } @@ -133,21 +143,16 @@ button { .brand-mark { display: block; - width: auto; - height: auto; - object-fit: contain; -} - -.brand-mark--light { - display: none; -} - -:root.light .brand-mark--dark { - display: none; -} - -:root.light .brand-mark--light { - display: block; + flex-shrink: 0; + background-color: var(--accent); + mask-image: url("/brand-mark-dark.png"); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-image: url("/brand-mark-dark.png"); + -webkit-mask-size: contain; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; } .brand .word { @@ -171,7 +176,7 @@ button { .nav-links a { font-size: 14.5px; - color: var(--text-muted); + color: var(--text-secondary); padding: 9px 14px; border-radius: 100px; letter-spacing: -0.01em; @@ -245,8 +250,11 @@ button { grid-template-rows: 1fr; margin-top: 4px; padding: 10px; - border-color: var(--border); - box-shadow: var(--shadow); + background: var(--elevated-bg); + border-color: var(--elevated-border); + box-shadow: var(--elevated-shadow); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); opacity: 1; visibility: visible; pointer-events: auto; @@ -265,7 +273,7 @@ button { align-items: center; gap: 7px; font-size: 15px; - color: var(--text-muted); + color: var(--text-secondary); padding: 12px 16px; border-radius: 100px; letter-spacing: -0.01em; @@ -537,6 +545,10 @@ button { color: var(--bg-deep) !important; } +:root:not(.light) body:has(.map-shell) .nav-inner { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset; +} + .hero h1 .accent { color: var(--accent-bright); text-shadow: @@ -581,8 +593,7 @@ button { font-weight: 500; color: var(--text); letter-spacing: -0.01em; - background: color-mix(in srgb, var(--surface) 30%, transparent); - backdrop-filter: blur(6px); + background: var(--surface); transition: border-color 0.2s, background 0.2s; } @@ -688,25 +699,35 @@ button { z-index: 2; width: min(360px, calc(100vw - 32px)); padding: 18px 18px 16px; - border: 1px solid var(--border-strong); + --map-readout-border: var(--elevated-border); + border: 1px solid var(--map-readout-border); border-radius: 18px; color: var(--text); - background: color-mix(in srgb, var(--surface) 92%, var(--bg-deep)); + background: var(--elevated-bg); backdrop-filter: blur(16px); - box-shadow: var(--shadow); + -webkit-backdrop-filter: blur(16px); + box-shadow: var(--elevated-shadow); +} + +.map-readout-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; } .map-readout-kicker { display: inline-flex; align-items: center; width: fit-content; - font-size: 12px; + flex-shrink: 0; + font-size: 11px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); - margin-bottom: 12px; - padding: 7px 12px; + padding: 5px 10px; border-radius: 100px; border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); background: color-mix(in srgb, var(--accent) 14%, transparent); @@ -716,13 +737,13 @@ button { font-size: 20px; font-weight: 600; letter-spacing: -0.03em; - margin-bottom: 8px; + margin-bottom: 0; color: var(--text); } .map-readout-hint, .map-readout-empty { - color: var(--text-muted); + color: var(--text-secondary); font-size: 13px; line-height: 1.55; } @@ -732,7 +753,7 @@ button { gap: 10px; margin-top: 16px; padding-top: 14px; - border-top: 1px solid var(--border-strong); + border-top: 1px solid var(--map-readout-border); } .map-readout-control-header { @@ -746,7 +767,7 @@ button { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--text-muted); + color: var(--text-secondary); font-weight: 500; } @@ -756,29 +777,6 @@ button { font-weight: 600; } -.map-readout-slider { - width: 100%; - accent-color: var(--accent); - cursor: pointer; -} - -.map-readout-control-hint { - color: var(--text-dim); - font-size: 12px; - line-height: 1.45; -} - -.map-readout-grid { - display: grid; - gap: 12px; - margin-top: 14px; -} - -.map-readout-grid div { - display: grid; - gap: 4px; -} - .map-readout-grid dt { font-size: 12px; letter-spacing: 0.06em; @@ -794,37 +792,34 @@ button { font-weight: 500; } -.map-readout-span { - padding-top: 8px; - border-top: 1px solid var(--border-strong); -} - -.map-readout-span dd { - color: var(--text-muted); - font-weight: 400; -} - -:root:not(.light) .map-readout { - background: rgba(18, 18, 18, 0.96); - border-color: rgba(255, 255, 255, 0.16); +.map-readout-slider { + width: 100%; + accent-color: var(--accent); + cursor: pointer; } -:root:not(.light) .map-readout-title { - color: #ffffff; +.map-readout-selection { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--map-readout-border); } -:root:not(.light) .map-readout-hint, -:root:not(.light) .map-readout-empty, -:root:not(.light) .map-readout-span dd { - color: rgba(255, 255, 255, 0.72); +.map-readout-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; } -:root:not(.light) .map-readout-grid dt { - color: rgba(255, 255, 255, 0.62); +.map-readout-grid div { + display: grid; + gap: 4px; + min-width: 0; } -:root:not(.light) .map-readout-grid dd { - color: #ffffff; +.map-readout-span { + grid-column: 1 / -1; + padding-top: 8px; + border-top: 1px solid var(--map-readout-border); } /* ============================================================ diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx index a8b5205..def3ee2 100644 --- a/src/components/map/EarthMap.tsx +++ b/src/components/map/EarthMap.tsx @@ -1,18 +1,28 @@ "use client"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import DeckGL from "@deck.gl/react"; -import { ScatterplotLayer } from "@deck.gl/layers"; -import type { PickingInfo } from "@deck.gl/core"; +import { PathLayer, PolygonLayer } from "@deck.gl/layers"; +import { PathStyleExtension } from "@deck.gl/extensions"; +import type { PickingInfo, ViewStateChangeParameters } from "@deck.gl/core"; import Map from "react-map-gl/maplibre"; +import type { StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; -import { geoPointToZarrGrid } from "@/lib/map/geogrid"; -import { TEAL_ON_DARK_RGB } from "@/lib/constants/theme"; -import { DEFAULT_MAP_VIEW, MAP_BASE_STYLES } from "@/lib/map/viewState"; +import { + geoPointToZarrGrid, + gridCellToBounds, + gridCellToGuidePaths, + gridCellToPolygon, +} from "@/lib/map/geogrid"; +import { loadDarkMapStyle, brightenDarkMapPlaceLabels } from "@/lib/map/mapLabels"; +import type { MapLibreEvent, MapStyleDataEvent } from "maplibre-gl"; +import { viewportToGeoBounds } from "@/lib/map/viewportBounds"; +import { TEAL_ON_DARK_RGB, TEAL_RGB } from "@/lib/constants/theme"; +import { DEFAULT_MAP_VIEW, MAP_BASE_STYLES, viewStateFocusedOnCell } from "@/lib/map/viewState"; import { openZarrStore } from "@/lib/zarr/store"; import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; import { DEFAULT_HISTORY_YEARS } from "@/lib/zarr/timeRange"; -import type { MapSelection } from "@/types/map"; +import type { MapSelection, MapViewState } from "@/types/map"; import { useTheme } from "@/providers/ThemeProvider"; import { MapReadout } from "@/components/map/MapReadout"; @@ -20,11 +30,32 @@ type EarthMapProps = { className?: string; }; +const dashedPathExtension = new PathStyleExtension({ dash: true }); + +const GUIDE_LINE_COLOR = { + light: [110, 110, 110, 170] as [number, number, number, number], + dark: [190, 190, 190, 150] as [number, number, number, number], +}; + +const SELECTION_CELL_COLOR = { + light: { + fill: [...TEAL_RGB, 36] as [number, number, number, number], + line: [...TEAL_RGB, 255] as [number, number, number, number], + }, + dark: { + fill: [255, 255, 255, 48] as [number, number, number, number], + line: [...TEAL_ON_DARK_RGB, 255] as [number, number, number, number], + }, +}; + export function EarthMap({ className }: EarthMapProps) { const { isLight } = useTheme(); const readerPromiseRef = useRef | null>(null); const requestIdRef = useRef(0); + const mapStageRef = useRef(null); + const [viewState, setViewState] = useState(DEFAULT_MAP_VIEW); + const [mapSize, setMapSize] = useState({ width: 0, height: 0 }); const [selection, setSelection] = useState(null); const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS); const [loadingSeries, setLoadingSeries] = useState(false); @@ -32,6 +63,38 @@ export function EarthMap({ className }: EarthMapProps) { const [seriesLength, setSeriesLength] = useState(null); const [seriesPreview, setSeriesPreview] = useState(null); const [seriesUnits, setSeriesUnits] = useState(null); + const [darkMapStyle, setDarkMapStyle] = useState( + null, + ); + + useEffect(() => { + let cancelled = false; + + void loadDarkMapStyle() + .then((style) => { + if (!cancelled) setDarkMapStyle(style); + }) + .catch(() => { + // Fall back to the remote style URL if patching fails. + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const node = mapStageRef.current; + if (!node) return; + + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + setMapSize({ width, height }); + }); + + observer.observe(node); + return () => observer.disconnect(); + }, []); const loadTimeSeries = useCallback( async (nextSelection: MapSelection, years: number) => { @@ -102,46 +165,111 @@ export function EarthMap({ className }: EarthMapProps) { }; setSelection(nextSelection); + setViewState((current) => + viewStateFocusedOnCell(current, nextSelection.grid), + ); void loadTimeSeries(nextSelection, historyYears); }, [historyYears, loadTimeSeries], ); + const handleViewStateChange = useCallback( + ({ viewState: nextViewState }: ViewStateChangeParameters) => { + setViewState(nextViewState as MapViewState); + }, + [], + ); + + const mapStyle = isLight + ? MAP_BASE_STYLES.light + : darkMapStyle; + + const applyDarkMapLabelColors = useCallback( + (event: MapLibreEvent | MapStyleDataEvent) => { + if (isLight) return; + brightenDarkMapPlaceLabels(event.target); + }, + [isLight], + ); + + const handleMapLoad = useCallback( + (event: MapLibreEvent) => { + applyDarkMapLabelColors(event); + }, + [applyDarkMapLabelColors], + ); + + const handleStyleData = useCallback( + (event: MapStyleDataEvent) => { + if (event.dataType !== "style") return; + applyDarkMapLabelColors(event); + }, + [applyDarkMapLabelColors], + ); + const layers = useMemo(() => { - if (!selection) return []; + if (!selection || mapSize.width === 0 || mapSize.height === 0) return []; + + const cellBounds = gridCellToBounds(selection.grid); + const viewportBounds = viewportToGeoBounds( + viewState, + mapSize.width, + mapSize.height, + ); + const guidePaths = gridCellToGuidePaths(cellBounds, viewportBounds); + const guideColor = isLight ? GUIDE_LINE_COLOR.light : GUIDE_LINE_COLOR.dark; + const cellColor = isLight ? SELECTION_CELL_COLOR.light : SELECTION_CELL_COLOR.dark; return [ - new ScatterplotLayer({ + new PathLayer({ + id: "selected-grid-cell-guides", + data: guidePaths, + getPath: (path) => path, + pickable: false, + widthUnits: "pixels", + getWidth: 1, + getColor: guideColor, + extensions: [dashedPathExtension], + getDashArray: [6, 5], + dashJustified: true, + }), + new PolygonLayer({ id: "selected-grid-cell", data: [selection.grid], - getPosition: (point: MapSelection["grid"]) => [point.lon, point.lat], - getFillColor: [255, 255, 255, 230], - getLineColor: [...TEAL_ON_DARK_RGB, 255], - lineWidthUnits: "pixels", - getLineWidth: 2, + getPolygon: (cell: MapSelection["grid"]) => [ + gridCellToPolygon(cell).map((point) => [point.lon, point.lat]), + ], + filled: true, stroked: true, - radiusUnits: "pixels", - getRadius: 8, pickable: false, + getFillColor: cellColor.fill, + getLineColor: cellColor.line, + getLineWidth: 2, + lineWidthUnits: "pixels", }), ]; - }, [selection]); + }, [isLight, mapSize.height, mapSize.width, selection, viewState]); return (
-
+
"crosshair"} > - + {mapStyle ? ( + + ) : null}
diff --git a/src/components/map/MapReadout.tsx b/src/components/map/MapReadout.tsx index 42412f7..680a994 100644 --- a/src/components/map/MapReadout.tsx +++ b/src/components/map/MapReadout.tsx @@ -31,8 +31,10 @@ export function MapReadout({ return (
- {selection ? ( -
-
-
Click
-
{formatGeoPoint(selection.click)}
-
-
-
Grid cell
-
{formatGeoPoint(selection.grid)}
-
-
-
Indices
-
- lon {selection.grid.lonIndex}, lat {selection.grid.latIndex} -
-
-
-
Variable
-
{ZARR_STORE.defaultVariable}
-
-
-
Time series
-
- {loadingSeries && "Fetching from Zarr…"} - {!loadingSeries && seriesError && seriesError} - {!loadingSeries && - !seriesError && - seriesPreview && - seriesLength !== null && - `${seriesLength} steps · first ${seriesPreview - .map((value) => value.toFixed(2)) - .join(", ")}${seriesUnits ? ` ${seriesUnits}` : ""}`} -
-
-
- ) : ( -

No pixel selected yet.

- )} +
+ {selection ? ( +
+
+
Click
+
{formatGeoPoint(selection.click)}
+
+
+
Grid cell
+
{formatGeoPoint(selection.grid)}
+
+
+
Indices
+
+ lon {selection.grid.lonIndex}, lat {selection.grid.latIndex} +
+
+
+
Variable
+
{ZARR_STORE.defaultVariable}
+
+
+
Time series
+
+ {loadingSeries && "Fetching from Zarr…"} + {!loadingSeries && seriesError && seriesError} + {!loadingSeries && + !seriesError && + seriesPreview && + seriesLength !== null && + `${seriesLength} steps · first ${seriesPreview + .map((value) => value.toFixed(2)) + .join(", ")}${seriesUnits ? ` ${seriesUnits}` : ""}`} +
+
+
+ ) : ( +

No pixel selected yet.

+ )} +
); } diff --git a/src/components/nav/Brand.tsx b/src/components/nav/Brand.tsx index bf5b71f..bcbc235 100644 --- a/src/components/nav/Brand.tsx +++ b/src/components/nav/Brand.tsx @@ -6,7 +6,7 @@ export function Brand() { return ( - + {SITE_NAME} diff --git a/src/icons/BrandMark.tsx b/src/icons/BrandMark.tsx index b3f631e..9bcdb8b 100644 --- a/src/icons/BrandMark.tsx +++ b/src/icons/BrandMark.tsx @@ -1,28 +1,13 @@ -import Image from "next/image"; - type BrandMarkProps = { size?: number; }; export function BrandMark({ size = 24 }: BrandMarkProps) { return ( - <> - - - +