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 ( - <> - - - +