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
49 changes: 38 additions & 11 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,24 @@
--surface-2: #1f1f1f;
--nav-bg: #121212;
--text: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.80); /* 5.7:1 — AA body text, subtitles */
--text-muted: rgba(255, 255, 255, 0.56); /* 3.8:1 — AA large text only (18px+) */
--text-dim: rgba(255, 255, 255, 0.34); /* 2.3:1 — decorative only, never body */
--text-secondary: rgba(
255,
255,
255,
0.8
); /* 5.7:1 — AA body text, subtitles */
--text-muted: rgba(
255,
255,
255,
0.56
); /* 3.8:1 — AA large text only (18px+) */
--text-dim: rgba(
255,
255,
255,
0.34
); /* 2.3:1 — decorative only, never body */
--border: rgba(220, 219, 214, 0.12);
--border-strong: rgba(220, 219, 214, 0.18);
/** Brand dark teal — icons on white chips, light-mode accent. */
Expand Down Expand Up @@ -49,9 +64,14 @@
--surface-2: #f6f6f4;
--nav-bg: #ffffff;
--text: #0a0a0a;
--text-secondary: rgba(10, 10, 10, 0.72); /* 5.0:1 — AA body text, subtitles */
--text-muted: rgba(10, 10, 10, 0.56); /* 3.8:1 — AA large text only (18px+) */
--text-dim: rgba(10, 10, 10, 0.36); /* 2.4:1 — decorative only, never body */
--text-secondary: rgba(
10,
10,
10,
0.72
); /* 5.0:1 — AA body text, subtitles */
--text-muted: rgba(10, 10, 10, 0.56); /* 3.8:1 — AA large text only (18px+) */
--text-dim: rgba(10, 10, 10, 0.36); /* 2.4:1 — decorative only, never body */
--border: rgba(45, 45, 42, 0.12);
--border-strong: rgba(45, 45, 42, 0.17);
--accent-solid: #006c66;
Expand Down Expand Up @@ -98,10 +118,12 @@

--shadow-editor: var(--editor-shadow);

--font-sans: var(--font-geist-sans), "Geist", "Geist Fallback", -apple-system,
system-ui, sans-serif;
--font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback",
ui-monospace, monospace;
--font-sans:
var(--font-geist-sans), "Geist", "Geist Fallback", -apple-system, system-ui,
sans-serif;
--font-mono:
var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", ui-monospace,
monospace;
}

@layer base {
Expand All @@ -117,12 +139,17 @@
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
transition: background 0.5s ease, color 0.5s ease;
transition:
background 0.5s ease,
color 0.5s ease;
}

/* The app only ever renders the editor shell, which owns the viewport. */
html:has(.editor-shell),
body:has(.editor-shell) {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}

/* The footer is rendered by the root layout on every route, but the app
Expand Down
114 changes: 108 additions & 6 deletions src/components/layout/EditorShell.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,127 @@
import type { ReactNode } from "react";
"use client";

import { useRef, useState, type PointerEvent, type ReactNode } from "react";

/** Id of the mobile controls drawer, for aria-controls on its triggers. */
export const EDITOR_CONTROLS_ID = "editor-controls";

/** Drag distance (px) or flick speed (px/ms) that dismisses the sheet. */
const DISMISS_DISTANCE = 80;
const DISMISS_VELOCITY = 0.5;

type EditorShellProps = {
header: ReactNode;
sidebar: ReactNode;
preview: ReactNode;
/** Whether the mobile controls drawer is open (<=900px only). */
controlsOpen: boolean;
onCloseControls: () => void;
};

export function EditorShell({ header, sidebar, preview }: EditorShellProps) {
export function EditorShell({
header,
sidebar,
preview,
controlsOpen,
onCloseControls,
}: EditorShellProps) {
const drag = useRef<{
startY: number;
lastY: number;
lastT: number;
v: number;
} | null>(null);
const moved = useRef(false);
const [dragY, setDragY] = useState<number | null>(null);

const onHandleDown = (event: PointerEvent<HTMLButtonElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = {
startY: event.clientY,
lastY: event.clientY,
lastT: event.timeStamp,
v: 0,
};
moved.current = false;
setDragY(0);
};

const onHandleMove = (event: PointerEvent<HTMLButtonElement>) => {
const state = drag.current;
if (!state) return;
const dy = event.clientY - state.startY;
if (Math.abs(dy) > 4) moved.current = true;
const dt = event.timeStamp - state.lastT;
if (dt > 0) state.v = (event.clientY - state.lastY) / dt;
state.lastY = event.clientY;
state.lastT = event.timeStamp;
// There is nothing above the open position to reveal, so up only rubber-bands.
setDragY(dy > 0 ? dy : Math.max(-24, dy / 3));
};

const onHandleUp = (event: PointerEvent<HTMLButtonElement>) => {
const state = drag.current;
drag.current = null;
setDragY(null);
if (!state) return;
const dy = event.clientY - state.startY;
if (dy > DISMISS_DISTANCE || state.v > DISMISS_VELOCITY) onCloseControls();
};

const onHandleClick = () => {
if (moved.current) return;
onCloseControls();
};

return (
<div className="editor-shell flex h-dvh flex-col bg-editor-bg-base text-editor-fg-primary [--editor-sidebar-width:min(400px,36vw)]">
<div className="editor-shell flex h-svh flex-col overflow-hidden bg-editor-bg-base text-editor-fg-primary [--editor-sidebar-width:min(400px,36vw)]">
<header className="relative z-[100] flex-shrink-0 overflow-visible bg-editor-bg-base">
{header}
</header>
<div className="flex min-h-0 flex-1 max-[900px]:flex-col">
<div className="flex min-h-0 flex-1">
{/* Backdrop behind the mobile drawer. */}
<button
type="button"
aria-hidden="true"
tabIndex={-1}
onClick={onCloseControls}
className={`fixed inset-0 z-[105] bg-black/40 backdrop-blur-[2px] transition-opacity duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] min-[901px]:hidden ${
controlsOpen ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
<aside
className="flex w-[var(--editor-sidebar-width)] flex-shrink-0 flex-col gap-3 overflow-y-auto bg-editor-bg-base p-4 max-[900px]:max-h-[42vh] max-[900px]:w-full max-[900px]:border-b max-[900px]:border-editor-border"
id={EDITOR_CONTROLS_ID}
className={`flex flex-shrink-0 flex-col gap-3 overflow-y-auto bg-editor-bg-base p-4 min-[901px]:w-[var(--editor-sidebar-width)] max-[900px]:fixed max-[900px]:inset-x-0 max-[900px]:bottom-0 max-[900px]:top-auto max-[900px]:z-[110] max-[900px]:min-w-0 max-[900px]:overflow-x-hidden max-[900px]:overscroll-contain max-[900px]:rounded-t-editor-lg max-[900px]:border-t max-[900px]:border-editor-border max-[900px]:pt-4 max-[900px]:shadow-[0_-12px_40px_rgba(0,0,0,0.28)] max-[900px]:max-h-[80svh] max-[900px]:transition-[translate,opacity,visibility] max-[900px]:duration-[380ms] max-[900px]:ease-[cubic-bezier(0.22,1,0.36,1)] max-[900px]:will-change-[translate] max-[900px]:motion-reduce:transition-none ${
controlsOpen
? "max-[900px]:visible max-[900px]:translate-y-0 max-[900px]:opacity-100"
: "max-[900px]:invisible max-[900px]:translate-y-full max-[900px]:opacity-0"
}`}
style={
dragY === null
? undefined
: { translate: `0 ${dragY}px`, transition: "none" }
}
aria-label="Map controls"
>
Comment thread
aivanchenk marked this conversation as resolved.
<button
type="button"
onClick={onHandleClick}
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={onHandleUp}
aria-label="Close time series"
className="hidden flex-shrink-0 touch-none select-none flex-col items-center gap-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-editor-fg-tertiary transition-colors hover:text-editor-fg-primary active:cursor-grabbing max-[900px]:flex"
>
<span
aria-hidden="true"
className="h-1 w-9 rounded-full bg-editor-border"
/>
Time series
</button>
{sidebar}
</aside>
<div className="flex min-w-0 flex-1 bg-editor-bg-base pb-2 pr-2 max-[900px]:pl-2">
<div className="flex min-w-0 flex-1 bg-editor-bg-base pb-2 pr-2 max-[900px]:px-2 max-[900px]:pb-2">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-editor-lg border border-editor-border bg-editor-bg-primary">
<div className="relative isolate min-h-0 flex-1 overflow-hidden rounded-[inherit]">
{preview}
Expand Down
58 changes: 32 additions & 26 deletions src/components/layout/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,38 +27,44 @@ export function Nav({
}: NavProps) {
return (
<nav className="relative z-[100] flex w-full flex-col gap-0 overflow-visible">
<div className="relative z-[2] box-border grid h-12 w-full grid-cols-[var(--editor-sidebar-width)_1fr] items-center gap-0 overflow-visible rounded-none border-0 bg-transparent p-0">
<div className="box-border flex min-w-0 items-center px-4 py-2">
<div className="relative z-[2] box-border grid h-12 w-full grid-cols-[var(--editor-sidebar-width)_1fr] items-center max-[900px]:grid-cols-[auto_1fr] gap-0 overflow-visible rounded-none border-0 bg-transparent p-0">
<div className="box-border flex min-w-0 items-center gap-2 px-4 py-2">
<Brand />
</div>
<div className="box-border flex min-w-0 items-center bg-editor-bg-base py-2 pl-0 pr-2">
<div className="mr-auto flex flex-shrink-0 items-center gap-1.5">
{/* On mobile (<=900px) the view toggle and these controls move to
the map's floating side panels. */}
{onViewModeChange ? (
<EditorViewTabs value={viewMode} onChange={onViewModeChange} />
) : null}
{hasSelection && onZoomToSelection ? (
<IconButton
className="animate-[zoomToSelectionIn_0.18s_cubic-bezier(0.16,1,0.3,1)]"
aria-label="Zoom to selection"
onClick={onZoomToSelection}
>
<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>
<div className="max-[900px]:hidden">
<EditorViewTabs value={viewMode} onChange={onViewModeChange} />
</div>
) : null}
<div className="flex items-center gap-1.5 max-[900px]:hidden">
{hasSelection && onZoomToSelection ? (
<IconButton
className="animate-[zoomToSelectionIn_0.18s_cubic-bezier(0.16,1,0.3,1)]"
aria-label="Zoom to selection"
onClick={onZoomToSelection}
>
<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>
</div>
<NavActions />
</div>
Expand Down
33 changes: 31 additions & 2 deletions src/components/map/EarthMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader";
import { DEFAULT_HISTORY_YEARS } from "@/lib/zarr/timeRange";
import type { MapSelection, MapViewMode, MapViewState } from "@/types/map";
import { useTheme } from "@/providers/ThemeProvider";
import { EditorShell } from "@/components/layout/EditorShell";
import {
EditorShell,
EDITOR_CONTROLS_ID,
} from "@/components/layout/EditorShell";
import { Nav } from "@/components/layout/Nav";
import { MapSideControls } from "@/components/map/MapSideControls";
import { MapSearch } from "@/components/map/MapSearch";
import { MapReadout } from "@/components/map/MapReadout";
import { GlobeSelectionOverlay } from "@/components/map/GlobeSelectionOverlay";

Expand Down Expand Up @@ -58,6 +63,7 @@ export function EarthMap() {
const [mapSize, setMapSize] = useState({ width: 0, height: 0 });
const [selection, setSelection] = useState<MapSelection | null>(null);
const [showPatch, setShowPatch] = useState(true);
const [controlsOpen, setControlsOpen] = useState(false);
const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS);
const [loadingSeries, setLoadingSeries] = useState(false);
const [seriesProgress, setSeriesProgress] = useState<{
Expand Down Expand Up @@ -166,7 +172,11 @@ export function EarthMap() {
};

setSelection(nextSelection);
const focused = viewStateFocusedOnCell(viewState, nextSelection.grid, viewMode);
const focused = viewStateFocusedOnCell(
viewState,
nextSelection.grid,
viewMode,
);
flyToView(focused, SELECTION_FOCUS_TRANSITION_MS);
void loadTimeSeries(nextSelection, historyYears);
},
Expand Down Expand Up @@ -235,6 +245,8 @@ export function EarthMap() {

return (
<EditorShell
controlsOpen={controlsOpen}
onCloseControls={() => setControlsOpen(false)}
header={
<Nav
viewMode={viewMode}
Expand Down Expand Up @@ -298,6 +310,23 @@ export function EarthMap() {
/>
) : null}
</Map>
<div className="pointer-events-none absolute inset-x-0 top-2 z-30 flex justify-center px-2">
<MapSearch
onSelect={handlePick}
className="pointer-events-auto w-full min-[901px]:max-w-[400px]"
/>
</div>
<MapSideControls
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
hasSelection={selection !== null}
onZoomToSelection={handleZoomToSelection}
showPatch={showPatch}
onTogglePatch={handleTogglePatch}
controlsOpen={controlsOpen}
onToggleControls={() => setControlsOpen((open) => !open)}
controlsId={EDITOR_CONTROLS_ID}
/>
</div>
}
/>
Expand Down
24 changes: 11 additions & 13 deletions src/components/map/MapReadout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,19 @@ export function MapReadout({
return (
<div className="flex flex-col divide-y divide-editor-border">
<section className="pb-4">
<div className="flex items-baseline justify-between gap-3">
<h2 className="font-mono text-[20px] leading-none tracking-tight tabular-nums text-editor-fg-primary">
{formatCoordinate(selection.click.lat)}
<span className="text-editor-fg-tertiary">, </span>
{formatCoordinate(selection.click.lon)}
</h2>
<span className={`flex-shrink-0 ${META}`}>{ZARR_STORE.kicker}</span>
</div>
<h2 className="font-mono text-[20px] leading-none tracking-tight tabular-nums text-editor-fg-primary">
{formatCoordinate(selection.click.lat)}
<span className="text-editor-fg-tertiary">, </span>
{formatCoordinate(selection.click.lon)}
</h2>
<p className="mt-2 text-[12.5px] leading-[1.5] text-editor-fg-tertiary">
Snapped to the nearest {ZARR_STORE.spatialResolutionDeg}° cell ·
variable {ZARR_STORE.defaultVariable}
{ZARR_STORE.kicker} · snapped to the nearest{" "}
{ZARR_STORE.spatialResolutionDeg}° cell
</p>
<p className="mt-2 text-[12.5px] leading-[1.5] text-editor-fg-tertiary">
Each click downloads a {patchCells}×{patchCells} patch ({patchDeg}° ×{" "}
{patchDeg}°), drawn as the dashed box. Toggle it with the patch button
in the top bar.
{patchDeg}°), drawn as the dashed box. Toggle it with the patch
button.
</p>
</section>

Expand Down Expand Up @@ -173,7 +170,8 @@ function formatBytes(bytes: number): string {
function SeriesLoader({ progress }: { progress: SeriesProgress | null }) {
const hasBytes = progress !== null && progress.total > 0;
const value = hasBytes ? progress.loaded / progress.total : undefined;
const pct = value === undefined ? null : Math.min(100, Math.round(value * 100));
const pct =
value === undefined ? null : Math.min(100, Math.round(value * 100));

return (
<div className="grid gap-2">
Expand Down
Loading
Loading