Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
--color-brand: var(--brand);
--color-accent-solid: var(--accent-solid);
--color-accent-bright: var(--accent-bright);
--color-editor-bg-depth: var(--editor-bg-depth);
--color-editor-bg-base: var(--editor-bg-base);
--color-editor-bg-primary: var(--editor-bg-primary);
--color-editor-bg-secondary: var(--editor-bg-secondary);
Expand Down
284 changes: 284 additions & 0 deletions src/components/data/DataView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
"use client";

import { useMemo, useRef, useSyncExternalStore } from "react";
import { Globe } from "lucide-react";
import { formatLatitude, formatLongitude } from "@/lib/map/geogrid";
import { formatIsoDate } from "@/lib/map/fingerprintScale";
import { formatSeriesValue } from "@/lib/plots/chartTheme";
import { dailyMeanSeries } from "@/lib/zarr/series";
import {
formatSelectedYearsLabel,
getSelectedYearsDayMapping,
ZARR_TIME,
} from "@/lib/zarr/timeRange";
import type { TimeBasis } from "@/lib/zarr/localTime";
import type { CellSeries } from "@/hooks/useCellSeries";
import { useSeriesDisplay } from "@/hooks/useSeriesDisplay";
import { CellExport } from "@/components/panels/CellExport";
import { PlotArea, PlotControls } from "@/components/panels/CellView";
import { YearPicker } from "@/components/panels/YearPicker";
import {
MainViewSwitch,
type MainView,
} from "@/components/data/MainViewSwitch";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";

const FADE_DISTANCE = 48;

function subscribeResize(onChange: () => void) {
window.addEventListener("resize", onChange);
return () => window.removeEventListener("resize", onChange);
}

function usePlotHeight() {
return useSyncExternalStore(
subscribeResize,
() => Math.round(Math.min(560, Math.max(260, window.innerHeight * 0.5))),
() => 360,
);
}

type DayStat = { value: number; date: string };

type Summary = {
mean: number;
lowest: DayStat;
highest: DayStat;
days: number;
};

function summarize(
values: Float32Array,
selectedYears: number[],
): Summary | null {
const hoursPerDay = ZARR_TIME.hoursPerDay;
const daily = dailyMeanSeries(values, hoursPerDay);
if (daily.length === 0) return null;
const { absoluteDays } = getSelectedYearsDayMapping(
selectedYears,
undefined,
Math.floor(values.length / hoursPerDay),
);
const dateOf = (day: number) =>
formatIsoDate(absoluteDays[day - 1] ?? day - 1);

let sum = 0;
let lowest = daily[0];
let highest = daily[0];
for (const point of daily) {
sum += point.value;
if (point.value < lowest.value) lowest = point;
if (point.value > highest.value) highest = point;
}
return {
mean: sum / daily.length,
lowest: { value: lowest.value, date: dateOf(lowest.day) },
highest: { value: highest.value, date: dateOf(highest.day) },
days: daily.length,
};
}

function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-6 items-center rounded-md border bg-editor-bg-depth px-2 text-xs text-muted-foreground tabular-nums">
{children}
</span>
);
}

function StatCard({
label,
value,
units,
note,
}: {
label: string;
value: string;
units?: string | null;
note?: string;
}) {
return (
<Card className="gap-0">
<CardContent className="grid min-h-28 content-between gap-6">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="grid gap-0.5">
<span className="text-2xl font-medium tabular-nums">
{value}
{units ? (
<span className="ml-1 text-xs font-normal text-muted-foreground">
{units}
</span>
) : null}
</span>
{note ? (
<span className="text-xs text-muted-foreground tabular-nums">
{note}
</span>
) : null}
</div>
</CardContent>
</Card>
);
}

type DataViewProps = {
series: CellSeries;
timeBasis: TimeBasis;
onMainViewChange: (view: MainView) => void;
};

export function DataView({
series,
timeBasis,
onMainViewChange,
}: DataViewProps) {
const display = useSeriesDisplay(series, timeBasis);
const fadeRef = useRef<HTMLDivElement>(null);
const plotHeight = usePlotHeight();
const { selection, selectedYears, units } = series;
const values = display.displayValues;

const summary = useMemo(
() =>
values && !display.isEmptyCell ? summarize(values, selectedYears) : null,
[values, display.isEmptyCell, selectedYears],
);

return (
<div
className="absolute inset-0 z-20 overflow-y-auto bg-editor-bg-depth"
// Styled directly so scrolling does not re-render the charts.
onScroll={(event) => {
const fade = fadeRef.current;
if (!fade) return;
const progress = event.currentTarget.scrollTop / FADE_DISTANCE;
fade.style.opacity = String(Math.min(1, progress));
}}
>
<div
ref={fadeRef}
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 top-0 z-10 h-25 bg-linear-to-b from-editor-bg-depth from-55% to-transparent opacity-0 max-md:hidden"
/>
<div className="mx-auto grid max-w-7xl gap-6 px-3 pt-28 pb-40 md:px-6 md:pt-20 md:pb-60">
{!selection ? (
<Empty className="min-h-[60dvh]">
<EmptyHeader>
<EmptyMedia variant="icon">
<Globe />
</EmptyMedia>
<EmptyTitle>No point picked yet</EmptyTitle>
<EmptyDescription>
Pick a point on the map or search for a place to see its record
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" onClick={() => onMainViewChange("map")}>
Open the map
</Button>
</EmptyContent>
</Empty>
) : (
<div className="grid items-start gap-6 lg:grid-cols-[340px_minmax(0,1fr)]">
<aside className="grid content-start gap-3 max-lg:order-last">
<Card>
<CardContent>
<YearPicker
selectedYears={selectedYears}
cachedYears={series.cachedYears}
loading={series.loading}
onSelectYears={series.selectYears}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<CellExport series={series} display={display} />
</CardContent>
</Card>
</aside>
<div className="grid min-w-0 gap-6">
<Card>
<CardContent className="grid gap-6">
<header className="grid gap-4 grid-cols-[1fr_auto] items-start">
<div className="grid gap-1">
<CardTitle>
{formatLongitude(selection.grid.lon)},{" "}
{formatLatitude(selection.grid.lat)}
</CardTitle>
<CardDescription>
Net ecosystem exchange, 0.05° cell
</CardDescription>
<div className="flex flex-wrap gap-1.5 pt-2">
<Chip>
Years {formatSelectedYearsLabel(selectedYears)}
</Chip>
<Chip>{display.timeBasisLabel}</Chip>
{units ? <Chip>{units}</Chip> : null}
</div>
</div>
<MainViewSwitch value="data" onChange={onMainViewChange} />
</header>
<section aria-live="polite" className="grid gap-3">
<PlotControls display={display} />
<PlotArea
series={series}
display={display}
height={plotHeight}
pinLimit={2}
/>
</section>
</CardContent>
</Card>

{summary ? (
<section aria-label="Summary" className="grid gap-3">
<h2 className="text-xs text-muted-foreground">
Daily means over the selected years
</h2>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<StatCard
label="Mean"
value={formatSeriesValue(summary.mean)}
units={units}
/>
<StatCard
label="Lowest day"
value={formatSeriesValue(summary.lowest.value)}
units={units}
note={summary.lowest.date}
/>
<StatCard
label="Highest day"
value={formatSeriesValue(summary.highest.value)}
units={units}
note={summary.highest.date}
/>
<StatCard
label="Days with data"
value={summary.days.toLocaleString()}
/>
</div>
</section>
) : null}
</div>
</div>
)}
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions src/components/data/MainViewSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { ChartColumn, Globe } from "lucide-react";
import { SegmentedTabs } from "@/components/ui/segmented-tabs";

export type MainView = "map" | "data";

const MAIN_VIEWS = [
{ value: "map", label: "Map", icon: Globe },
{ value: "data", label: "Data", icon: ChartColumn },
] as const;

type MainViewSwitchProps = {
value: MainView;
onChange: (view: MainView) => void;
};

export function MainViewSwitch({ value, onChange }: MainViewSwitchProps) {
return (
<SegmentedTabs
value={value}
onChange={onChange}
options={MAIN_VIEWS}
aria-label="Main view"
className="shrink-0"
/>
);
}
8 changes: 7 additions & 1 deletion src/components/layout/MapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function MapActions({ onBeforeGuide }: { onBeforeGuide: () => void }) {
type MapToolbarProps = {
openPanel: ToolbarPanel | null;
onOpenPanelChange: (panel: ToolbarPanel | null) => void;
hideChart?: boolean;
hideOnMobile?: boolean;
};

Expand Down Expand Up @@ -122,8 +123,13 @@ export function SiteMark() {
export function MapToolbar({
openPanel,
onOpenPanelChange,
hideChart = false,
hideOnMobile = false,
}: MapToolbarProps) {
const panels = hideChart
? PANELS.filter(({ value }) => value !== "chart")
: PANELS;

return (
<div className="flex items-start gap-2">
<Card className="[--card-spacing:--spacing(1)] max-md:hidden">
Expand All @@ -145,7 +151,7 @@ export function MapToolbar({
onOpenPanelChange((value[0] as ToolbarPanel | undefined) ?? null)
}
>
{PANELS.map(({ value, label, Icon }) => (
{panels.map(({ value, label, Icon }) => (
<ToggleGroupItem
key={value}
value={value}
Expand Down
Loading
Loading