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
19 changes: 19 additions & 0 deletions src/components/layout/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@ import { EditorViewTabs } from "@/components/layout/EditorViewTabs";
import { NavActions } from "@/components/nav/NavActions";
import { IconButton } from "@/components/ui/IconButton";
import { CrosshairIcon } from "@/icons/CrosshairIcon";
import { PatchIcon } from "@/icons/PatchIcon";
import type { MapViewMode } from "@/types/map";

type NavProps = {
viewMode?: MapViewMode;
onViewModeChange?: (mode: MapViewMode) => void;
hasSelection?: boolean;
onZoomToSelection?: () => void;
showPatch?: boolean;
onTogglePatch?: () => void;
};

export function Nav({
viewMode = "2d",
onViewModeChange,
hasSelection = false,
onZoomToSelection,
showPatch = false,
onTogglePatch,
}: NavProps) {
return (
<nav className="relative z-[100] flex w-full flex-col gap-0 overflow-visible">
Expand All @@ -40,6 +45,20 @@ export function Nav({
<CrosshairIcon />
</IconButton>
) : null}
{hasSelection && onTogglePatch ? (
<IconButton
className="animate-[zoomToSelectionIn_0.18s_cubic-bezier(0.16,1,0.3,1)]"
aria-label={
showPatch
? "Hide downloaded patch extent"
: "Show downloaded patch extent"
}
aria-pressed={showPatch}
onClick={onTogglePatch}
>
<PatchIcon />
</IconButton>
) : null}
</div>
<NavActions />
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/components/map/EarthMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function EarthMap() {
const [viewMode, setViewMode] = useState<MapViewMode>("2d");
const [mapSize, setMapSize] = useState({ width: 0, height: 0 });
const [selection, setSelection] = useState<MapSelection | null>(null);
const [showPatch, setShowPatch] = useState(true);
const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS);
const [loadingSeries, setLoadingSeries] = useState(false);
const [seriesError, setSeriesError] = useState<string | null>(null);
Expand Down Expand Up @@ -184,6 +185,10 @@ export function EarthMap() {
flyToView(focused, SELECTION_FOCUS_TRANSITION_MS);
}, [flyToView, selection, viewMode, viewState]);

const handleTogglePatch = useCallback(() => {
setShowPatch((previous) => !previous);
}, []);

const handleMove = useCallback(
(event: ViewStateChangeEvent) => {
setViewState(toMapViewState(event.viewState, viewMode));
Expand Down Expand Up @@ -226,6 +231,8 @@ export function EarthMap() {
onViewModeChange={handleViewModeChange}
hasSelection={selection !== null}
onZoomToSelection={handleZoomToSelection}
showPatch={showPatch}
onTogglePatch={handleTogglePatch}
/>
}
sidebar={
Expand Down Expand Up @@ -276,6 +283,7 @@ export function EarthMap() {
mapSize={mapSize}
isLight={isLight}
isSphere={isSphere}
showPatch={showPatch}
/>
) : null}
</Map>
Expand Down
41 changes: 38 additions & 3 deletions src/components/map/GlobeSelectionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
import { useMemo } from "react";
import { Layer, Source } from "react-map-gl/maplibre";
import {
chunkPatchBounds,
gridCellToBounds,
gridCellToGuidePaths,
selectionGuideGeoJson,
} from "@/lib/map/geogrid";
import { rgba, SELECTION_CELL_COLOR, SELECTION_GUIDE_COLOR } from "@/lib/map/selectionStyle";
import { ZARR_STORE } from "@/lib/constants/store";
import {
rgba,
SELECTION_CELL_COLOR,
SELECTION_GUIDE_COLOR,
SELECTION_PATCH_COLOR,
} from "@/lib/map/selectionStyle";
import { viewportToGeoBounds } from "@/lib/map/viewportBounds";
import type { GridCell, MapViewState } from "@/types/map";

Expand All @@ -17,6 +24,7 @@ type GlobeSelectionOverlayProps = {
mapSize: { width: number; height: number };
isLight: boolean;
isSphere?: boolean;
showPatch?: boolean;
};

export function GlobeSelectionOverlay({
Expand All @@ -25,19 +33,28 @@ export function GlobeSelectionOverlay({
mapSize,
isLight,
isSphere = false,
showPatch = false,
}: GlobeSelectionOverlayProps) {
const data = useMemo(() => {
const guidePaths = gridCellToGuidePaths(
gridCellToBounds(cell),
viewportToGeoBounds(viewState, mapSize.width, mapSize.height),
);
return selectionGuideGeoJson(cell, guidePaths, { densifyGuides: isSphere });
}, [cell, isSphere, mapSize.height, mapSize.width, viewState]);
return selectionGuideGeoJson(cell, guidePaths, {
densifyGuides: isSphere,
patchBounds: showPatch
? chunkPatchBounds(cell, ZARR_STORE.nativeChunks.lat, ZARR_STORE.nativeChunks.lon)
: null,
});
}, [cell, isSphere, mapSize.height, mapSize.width, showPatch, viewState]);

const guideColor = rgba(
isLight ? SELECTION_GUIDE_COLOR.light : SELECTION_GUIDE_COLOR.dark,
);
const cellColor = isLight ? SELECTION_CELL_COLOR.light : SELECTION_CELL_COLOR.dark;
const patchColor = isLight
? SELECTION_PATCH_COLOR.light
: SELECTION_PATCH_COLOR.dark;

return (
<Source id="map-selection" type="geojson" data={data}>
Expand All @@ -51,6 +68,24 @@ export function GlobeSelectionOverlay({
"line-dasharray": [6, 5],
}}
/>
<Layer
id="map-selection-patch-fill"
type="fill"
filter={["==", ["get", "kind"], "patch"]}
paint={{
"fill-color": rgba(patchColor.fill),
}}
/>
<Layer
id="map-selection-patch-line"
type="line"
filter={["==", ["get", "kind"], "patch"]}
paint={{
"line-color": rgba(patchColor.line),
"line-width": 1.5,
"line-dasharray": [3, 3],
}}
/>
<Layer
id="map-selection-cell-fill"
type="fill"
Expand Down
8 changes: 8 additions & 0 deletions src/components/map/MapReadout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export function MapReadout({
const historyLabel =
historyYears === 1 ? "Last 1 year" : `Last ${historyYears} years`;

const patchCells = ZARR_STORE.nativeChunks.lon;
const patchDeg = patchCells * ZARR_STORE.spatialResolutionDeg;

return (
<>
<section className={PANEL_CLASS}>
Expand All @@ -48,6 +51,11 @@ export function MapReadout({
<p className={HINT_CLASS}>
Click the map to sample a {ZARR_STORE.spatialResolutionDeg}° cell.
</p>
<p className={HINT_CLASS}>
Each click downloads a {patchCells}×{patchCells} patch ({patchDeg}° ×{" "}
{patchDeg}°), drawn as the dashed box. Toggle it with the patch button
in the top bar.
</p>
</section>

<section className={PANEL_CLASS}>
Expand Down
18 changes: 17 additions & 1 deletion src/components/ui/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type IconButtonProps = {
"aria-label": string;
"aria-expanded"?: boolean;
"aria-controls"?: string;
"aria-pressed"?: boolean;
};

// Every IconButton renders inside the editor nav, so the editor sizing/colour
Expand All @@ -14,22 +15,37 @@ const ICON_BUTTON_CLASS =
"bg-editor-bg-primary text-editor-fg-secondary transition-all duration-200 " +
"hover:border-editor-border hover:bg-editor-bg-primary hover:text-editor-fg-primary hover:shadow-editor";

// Toggle buttons highlight with the accent when pressed.
const ICON_BUTTON_PRESSED_CLASS =
"border-[color-mix(in_srgb,var(--accent)_45%,transparent)] " +
"bg-[color-mix(in_srgb,var(--accent)_16%,transparent)] text-accent " +
"hover:text-accent hover:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] " +
"hover:bg-[color-mix(in_srgb,var(--accent)_16%,transparent)]";

export function IconButton({
children,
onClick,
className,
"aria-label": ariaLabel,
"aria-expanded": ariaExpanded,
"aria-controls": ariaControls,
"aria-pressed": ariaPressed,
}: IconButtonProps) {
return (
<button
type="button"
className={[ICON_BUTTON_CLASS, className].filter(Boolean).join(" ")}
className={[
ICON_BUTTON_CLASS,
ariaPressed ? ICON_BUTTON_PRESSED_CLASS : null,
className,
]
.filter(Boolean)
.join(" ")}
onClick={onClick}
aria-label={ariaLabel}
aria-expanded={ariaExpanded}
aria-controls={ariaControls}
aria-pressed={ariaPressed}
>
{children}
</button>
Expand Down
21 changes: 21 additions & 0 deletions src/icons/PatchIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function PatchIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="M15 3v18" />
<path d="M3 9h18" />
<path d="M3 15h18" />
</svg>
);
}
9 changes: 9 additions & 0 deletions src/lib/constants/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export const ZARR_STORE = {
latStart: 89.975,
latStep: -0.05,
},
/**
* On-disk chunk footprint along lat/lon. A click downloads this whole
* patch (40 x 40 cells = 2° x 2°), not a single pixel. Matches the store's
* `chunks` metadata `[1461, 24, 40, 40]`.
*/
nativeChunks: {
lat: 40,
lon: 40,
},
defaultVariable: "NEE",
} as const;

Expand Down
20 changes: 20 additions & 0 deletions src/lib/map/geogrid.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
chunkPatchBounds,
geoPointToZarrGrid,
gridCellToBounds,
gridCellToGuidePaths,
Expand Down Expand Up @@ -39,6 +40,25 @@ describe("gridCellToPolygon", () => {
});
});

describe("chunkPatchBounds", () => {
it("spans the 40x40 native chunk that contains the cell", () => {
const { lat, lon } = ZARR_STORE.nativeChunks;
const cell = geoPointToZarrGrid({ lon: 10.012, lat: 51.998 });
const bounds = chunkPatchBounds(cell, lat, lon);

// 40 cells of 0.05° = 2° on each side.
expect(bounds.east - bounds.west).toBeCloseTo(lon * ZARR_STORE.spatialResolutionDeg);
expect(bounds.north - bounds.south).toBeCloseTo(lat * ZARR_STORE.spatialResolutionDeg);

// The clicked cell sits inside the patch.
const cellBounds = gridCellToBounds(cell);
expect(bounds.west).toBeLessThanOrEqual(cellBounds.west);
expect(bounds.east).toBeGreaterThanOrEqual(cellBounds.east);
expect(bounds.south).toBeLessThanOrEqual(cellBounds.south);
expect(bounds.north).toBeGreaterThanOrEqual(cellBounds.north);
});
});

describe("gridCellToGuidePaths", () => {
it("extends a line from each cell edge to the viewport border", () => {
const cell = {
Expand Down
Loading
Loading