Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,523 changes: 5,298 additions & 1,225 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
"lint": "eslint"
},
"dependencies": {
"@deck.gl/react": "^9.3.3",
"deck.gl": "^9.3.3",
"maplibre-gl": "^5.24.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-map-gl": "^8.1.1",
"zarrita": "^0.7.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Expand Down
153 changes: 134 additions & 19 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* ============================================================
EarthPrints design system
black / white / single teal accent (#006C66)
black / white / teal accent (#006C66 light, #52D4C8 dark)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the teal contrast now in dark mode is too bright. Maybe tone it down a little bit. Best to try any color contrast checker online to see what could work best.

============================================================ */
:root {
--bg: #0a0a0a;
Expand All @@ -13,8 +13,11 @@
--text-dim: rgba(255, 255, 255, 0.34);
--border: rgba(255, 255, 255, 0.1);
--border-strong: rgba(255, 255, 255, 0.18);
--accent: #006c66;
--accent-bright: #0a8a82;
/** Brand dark teal — icons on white chips, light-mode accent. */
--accent-solid: #006c66;
--accent: #52d4c8;
--accent-bright: #6eeadc;
--accent-on-dark: #52d4c8;
--scrim: rgba(10, 10, 10, 0.92);
--scrim-soft: rgba(10, 10, 10, 0.55);
--grid-line: rgba(255, 255, 255, 0.05);
Expand All @@ -32,8 +35,10 @@
--text-dim: rgba(10, 10, 10, 0.36);
--border: rgba(10, 10, 10, 0.1);
--border-strong: rgba(10, 10, 10, 0.16);
--accent-solid: #006c66;
--accent: #006c66;
--accent-bright: #00837b;
--accent-on-dark: #006c66;
--scrim: rgba(255, 255, 255, 0.92);
--scrim-soft: rgba(255, 255, 255, 0.55);
--grid-line: rgba(10, 10, 10, 0.05);
Expand Down Expand Up @@ -308,7 +313,7 @@ button {
height: 32px;
border-radius: 50%;
background: #fff;
color: var(--accent);
color: var(--accent-solid);
display: grid;
place-items: center;
flex-shrink: 0;
Expand Down Expand Up @@ -376,7 +381,7 @@ button {
height: 32px;
border-radius: 50%;
background: #fff;
color: var(--accent);
color: var(--accent-solid);
display: grid;
place-items: center;
flex-shrink: 0;
Expand Down Expand Up @@ -524,6 +529,11 @@ button {
animation: rise 1s cubic-bezier(0.16, 1, 0.3, 1) 0.25s forwards;
}

:root:not(.light) .btn-primary,
:root:not(.light) .nav-dropdown-cta {
color: var(--bg-deep) !important;
}

.hero h1 .accent {
color: var(--accent);
}
Expand Down Expand Up @@ -634,32 +644,137 @@ button {
}

/* ============================================================
MAP STUB
MAP
============================================================ */
.map-stub {
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
.map-shell {
position: relative;
height: 100svh;
background: var(--bg-deep);
}

.map-stage {
position: absolute;
inset: 0;
}

.map-stage .maplibregl-map,
.map-stage canvas {
outline: none;
}

.map-loading {
height: 100svh;
display: grid;
place-items: center;
color: var(--text-muted);
background: var(--bg-deep);
text-align: center;
}

.map-stub h1 {
font-size: clamp(28px, 5vw, 42px);
.map-readout {
position: absolute;
top: 92px;
left: clamp(16px, 3vw, 28px);
z-index: 2;
width: min(360px, calc(100vw - 32px));
padding: 18px 18px 16px;
border: 1px solid var(--border-strong);
border-radius: 18px;
color: var(--text);
background: color-mix(in srgb, var(--surface) 92%, var(--bg-deep));
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}

.map-readout-kicker {
display: inline-flex;
align-items: center;
width: fit-content;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 12px;
padding: 7px 12px;
border-radius: 100px;
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}

.map-readout-title {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.03em;
margin-bottom: 8px;
color: var(--text);
}

.map-stub p {
.map-readout-hint,
.map-readout-empty {
color: var(--text-muted);
max-width: 420px;
font-size: 13px;
line-height: 1.55;
}

.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;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 500;
}

.map-readout-grid dd {
font-size: 14px;
line-height: 1.45;
color: var(--text);
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);
}

:root:not(.light) .map-readout-title {
color: #ffffff;
}

: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);
}

:root:not(.light) .map-readout-grid dt {
color: rgba(255, 255, 255, 0.62);
}

:root:not(.light) .map-readout-grid dd {
color: #ffffff;
}

/* ============================================================
FOOTER
============================================================ */
Expand Down
13 changes: 2 additions & 11 deletions src/app/map/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Nav } from "@/components/layout/Nav";
import { MapExperience } from "@/components/map/MapExperience";

export const metadata: Metadata = {
title: "Map",
Expand All @@ -10,16 +10,7 @@ export default function MapPage() {
return (
<>
<Nav />
<main className="map-stub">
<h1>Map</h1>
<p>
Interactive map view coming soon. This route replaces map.html from
the original prototype.
</p>
<Link href="/" className="btn-outline">
Back to overview
</Link>
</main>
<MapExperience />
</>
);
}
132 changes: 132 additions & 0 deletions src/components/map/EarthMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useCallback, 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 Map from "react-map-gl/maplibre";
import "maplibre-gl/dist/maplibre-gl.css";
import { geoPointToEsdcGrid } 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 { fetchEsdcTimeSeries, openEsdcStore } from "@/lib/zarr/esdc";
import type { MapSelection } from "@/types/map";
import { useTheme } from "@/providers/ThemeProvider";
import { MapReadout } from "@/components/map/MapReadout";

type EarthMapProps = {
className?: string;
};

export function EarthMap({ className }: EarthMapProps) {
const { isLight } = useTheme();
const esdcRef = useRef<Awaited<ReturnType<typeof openEsdcStore>> | null>(
null,
);
const requestIdRef = useRef(0);

const [selection, setSelection] = useState<MapSelection | null>(null);
const [loadingSeries, setLoadingSeries] = useState(false);
const [seriesError, setSeriesError] = useState<string | null>(null);
const [seriesPreview, setSeriesPreview] = useState<number[] | null>(null);
const [seriesUnits, setSeriesUnits] = useState<string | null>(null);

const loadTimeSeries = useCallback(async (nextSelection: MapSelection) => {
const requestId = ++requestIdRef.current;
setLoadingSeries(true);
setSeriesError(null);
setSeriesPreview(null);
setSeriesUnits(null);

try {
if (!esdcRef.current) {
esdcRef.current = await openEsdcStore();
}

const { values, units } = await fetchEsdcTimeSeries(
esdcRef.current,
nextSelection.grid,
);

if (requestId !== requestIdRef.current) return;

setSeriesPreview(Array.from(values));
setSeriesUnits(units ?? null);
} catch (error) {
if (requestId !== requestIdRef.current) return;
setSeriesError(
error instanceof Error
? error.message
: "Could not load the Zarr time series.",
);
} finally {
if (requestId === requestIdRef.current) {
setLoadingSeries(false);
}
}
}, []);

const handleClick = useCallback(
(info: PickingInfo) => {
if (!info.coordinate) return;

const [lon, lat] = info.coordinate;
const nextSelection: MapSelection = {
click: { lon, lat },
grid: geoPointToEsdcGrid({ lon, lat }),
};

setSelection(nextSelection);
void loadTimeSeries(nextSelection);
},
[loadTimeSeries],
);

const layers = useMemo(() => {
if (!selection) return [];

return [
new ScatterplotLayer({
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,
stroked: true,
radiusUnits: "pixels",
getRadius: 8,
pickable: false,
}),
];
}, [selection]);

return (
<div className={className ?? "map-shell"}>
<div className="map-stage">
<DeckGL
initialViewState={DEFAULT_MAP_VIEW}
controller
layers={layers}
onClick={handleClick}
getCursor={() => "crosshair"}
>
<Map
mapStyle={isLight ? MAP_BASE_STYLES.light : MAP_BASE_STYLES.dark}
attributionControl={false}
reuseMaps
/>
</DeckGL>
</div>

<MapReadout
selection={selection}
loadingSeries={loadingSeries}
seriesError={seriesError}
seriesPreview={seriesPreview}
seriesUnits={seriesUnits}
/>
</div>
);
}
Loading
Loading