Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f3acce2
Open any Zarr store, not only FLUXCOM NEE (#132)
aivanchenk Oct 30, 2026
acb6568
Readable numbers and fitted axes for any variable
aivanchenk Oct 30, 2026
bafe4f6
Place coarse and model-calendar steps on the right days
aivanchenk Oct 30, 2026
0774c6d
Read finely chunked coordinates and honour the CF valid range
aivanchenk Oct 30, 2026
bf54cfc
Print an empty colour bar half as zero
aivanchenk Oct 30, 2026
49afe17
Show daily and coarser stores as a years by season fingerprint
aivanchenk Oct 30, 2026
bf97b8d
Fit the controls and copy to the store's time step
aivanchenk Oct 30, 2026
ef1476d
Recover cleanly from stores that fail to open
aivanchenk Oct 30, 2026
2c546de
Keep years before 100 and give CMIP stores a title
aivanchenk Oct 30, 2026
a041565
Budget a series load in bytes as well as downloads
aivanchenk Oct 30, 2026
daf6969
Keep the data view clear of the corner map
aivanchenk Oct 30, 2026
f94af32
Thin crowded axis labels and keep unlisted variables editable
aivanchenk Oct 30, 2026
a4ce54c
Show changes in the variable's units and fix sub-daily export tables
aivanchenk Oct 30, 2026
ffc11f5
Frame coarse cells after a pick instead of flying to street zoom
aivanchenk Oct 30, 2026
edb06f9
Keep gappy daily values on their own day and open stores on their las…
aivanchenk Oct 30, 2026
4d36cef
Frame regional stores, show coverage, and avoid empty default years
aivanchenk Oct 30, 2026
7d815ec
Restore FLUXCOM-X names, read regular time axes quickly, guard the UR…
aivanchenk Oct 30, 2026
b35d325
Break the line over missing data instead of bridging it
aivanchenk Oct 30, 2026
50cb69a
Pick a store's variable by searching its name
aivanchenk Oct 30, 2026
f5153f3
Start a new store's variable field empty and spell out long year runs
aivanchenk Oct 30, 2026
1556884
Open stores in their own Zarr format instead of probing both
aivanchenk Oct 30, 2026
65f20e2
Count only filled bins and keep strip and half-hour labels apart
aivanchenk Oct 30, 2026
ebfb04b
Word the empty-cell message for any store's mask
aivanchenk Oct 30, 2026
83aa92d
Count only the loaded steps after a partial load
aivanchenk Oct 30, 2026
22c54ea
Show a failed store link's error instead of the default store's chrome
aivanchenk Nov 1, 2026
4fafb62
Time out only silent hosts and keep empty cells off oversized earlier…
aivanchenk Nov 1, 2026
991991e
Print whole values without decimals
aivanchenk Nov 1, 2026
4a6a075
End the line axis where a partial load stops
aivanchenk Nov 1, 2026
ac19c5e
Leave half-filled local-time edge days out of the summary
aivanchenk Nov 1, 2026
1e886a6
Count hourly daily-mean tooltips in hours
aivanchenk Nov 1, 2026
4b9ab36
Show temperatures in kelvin, Celsius or Fahrenheit and plainer captio…
aivanchenk Nov 2, 2026
f83e4f7
Name private stores in the reach error and space the line's x labels
aivanchenk Nov 2, 2026
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
27 changes: 17 additions & 10 deletions src/components/data/DataView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ 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 {
describeRecord,
PlotArea,
PlotControls,
} from "@/components/panels/CellView";
import { ScaleField } from "@/components/panels/ScaleField";
import { TemperatureUnitField } from "@/components/panels/TemperatureUnitField";
import { TimeBasisField } from "@/components/panels/TimeBasisField";
import { YearPicker } from "@/components/panels/YearPicker";
import { SeriesSummary } from "@/components/data/SeriesSummary";
Expand Down Expand Up @@ -73,7 +78,8 @@ export function DataView({
const plotHeight = usePlotHeight();
const wide = useSidebarLayout();
const [detailsOpen, setDetailsOpen] = useState(false);
const { selection, selectedYears, units } = series;
const { selection, selectedYears } = series;
const { units } = display;

return (
<div
Expand All @@ -91,7 +97,7 @@ export function DataView({
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">
<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 lg:pr-[max(1.5rem,calc(14rem_-_max(0px,(100vw_-_80rem)/2)))]">
{selection ? (
<div className="grid items-start gap-6 lg:grid-cols-[340px_minmax(0,1fr)] lg:grid-rows-[auto_1fr] lg:gap-y-3">
<Card className="lg:col-start-1 lg:row-start-1">
Expand All @@ -116,10 +122,13 @@ export function DataView({
onSelectYears={series.selectYears}
/>
<ScaleField display={display} />
<TimeBasisField
value={timeBasis}
onChange={onTimeBasisChange}
/>
<TemperatureUnitField display={display} />
{display.canUseLocalTime ? (
<TimeBasisField
value={timeBasis}
onChange={onTimeBasisChange}
/>
) : null}
</div>
</Collapsible.Panel>
</Collapsible.Root>
Expand All @@ -134,9 +143,7 @@ export function DataView({
{formatLongitude(selection.grid.lon)},{" "}
{formatLatitude(selection.grid.lat)}
</CardTitle>
<CardDescription>
Net ecosystem exchange, 0.05° cell
</CardDescription>
<CardDescription>{describeRecord(series)}</CardDescription>
<div className="flex flex-wrap gap-1.5 pt-2">
<Chip>
Years {formatSelectedYearsLabel(selectedYears)}
Expand Down
117 changes: 92 additions & 25 deletions src/components/data/SeriesSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import { useMemo } from "react";
import { formatIsoDate } from "@/lib/map/fingerprintScale";
import { formatSeriesValue } from "@/lib/plots/chartTheme";
import { contiguousDayBlocks } from "@/lib/zarr/localTime";
import { dailyMeanSeries } from "@/lib/zarr/series";
import { getSelectedYearsDayMapping, ZARR_TIME } from "@/lib/zarr/timeRange";
import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange";
import type { CellSeries } from "@/hooks/useCellSeries";
import type { SeriesDisplay } from "@/hooks/useSeriesDisplay";
import { Card, CardContent } from "@/components/ui/card";
Expand All @@ -18,43 +19,87 @@ type Summary = {
days: number;
};

type Period = "day" | "month";

/**
* Days the local-time shift leaves only partly filled: the first day of each
* run east of Greenwich, the last one west of it. Their means would stand for
* a few hours, not a day.
*/
function partialLocalDays(
values: Float32Array,
selectedYears: number[],
hoursPerDay: number,
utcOffsetHours: number,
): Set<number> {
if (utcOffsetHours === 0) return new Set();
const dayCount = Math.floor(values.length / hoursPerDay);
const { absoluteDays } = getSelectedYearsDayMapping(
selectedYears,
undefined,
dayCount,
);
return new Set(
contiguousDayBlocks(absoluteDays, dayCount).map((block) =>
utcOffsetHours > 0 ? block.start : block.start + block.dayCount - 1,
),
);
}

function summarize(
values: Float32Array,
selectedYears: number[],
hoursPerDay: number,
period: Period,
skipped: Set<number> = new Set(),
firstDay = 0,
): 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);
// A monthly store fills every day of its month with one value, so its
// summary counts and dates months rather than those repeated days.
const labelOf = (day: number) => {
const date = formatIsoDate(absoluteDays[day - 1] ?? day - 1);
return period === "month" ? date.slice(0, 7) : date;
};
const points: { value: number; label: string }[] = [];
for (const point of daily) {
if (skipped.has(firstDay + point.day - 1)) continue;
const label = labelOf(point.day);
if (period === "month" && points.at(-1)?.label === label) continue;
points.push({ value: point.value, label });
}

if (points.length === 0) return null;
let sum = 0;
let lowest = daily[0];
let highest = daily[0];
for (const point of daily) {
let lowest = points[0]!;
let highest = points[0]!;
for (const point of points) {
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,
mean: sum / points.length,
lowest: { value: lowest.value, date: lowest.label },
highest: { value: highest.value, date: highest.label },
days: points.length,
};
}

/** One summary per selected year, read from that year's slice of the series. */
function summarizeByYear(
values: Float32Array,
selectedYears: number[],
hoursPerDay: number,
period: Period,
skipped: Set<number>,
): { year: number; summary: Summary }[] {
const hoursPerDay = ZARR_TIME.hoursPerDay;
const { yearIntervals } = getSelectedYearsDayMapping(
selectedYears,
undefined,
Expand All @@ -64,6 +109,10 @@ function summarizeByYear(
const summary = summarize(
values.subarray(startDayLocal * hoursPerDay, (endDayLocal + 1) * hoursPerDay),
[year],
hoursPerDay,
period,
skipped,
startDayLocal,
);
return summary ? [{ year, summary }] : [];
});
Expand Down Expand Up @@ -115,29 +164,45 @@ export function SeriesSummary({
display,
className,
}: SeriesSummaryProps) {
const { selectedYears, units } = series;
const { selectedYears, slotsPerDay } = series;
const { units } = display;
const values = display.displayValues;
const period: Period =
series.timeSpan === "month" || series.timeSpan === "year" ? "month" : "day";
const word = period === "month" ? "month" : "day";
const Word = period === "month" ? "Month" : "Day";

const offset = display.timeBasis === "local" ? display.utcOffsetHours : 0;
const skipped = useMemo(
() =>
values
? partialLocalDays(values, selectedYears, slotsPerDay, offset)
: new Set<number>(),
[values, selectedYears, slotsPerDay, offset],
);

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

const byYear = useMemo(
() =>
values && !display.isEmptyCell && selectedYears.length > 1
? summarizeByYear(values, selectedYears)
? summarizeByYear(values, selectedYears, slotsPerDay, period, skipped)
: [],
[values, display.isEmptyCell, selectedYears],
[values, display.isEmptyCell, selectedYears, slotsPerDay, period, skipped],
);

if (!summary) return null;

return (
<section aria-label="Summary" className="grid gap-3">
<h2 className="text-xs text-muted-foreground">
Daily means over the selected years
{period === "month" ? "Monthly" : "Daily"} means over the selected years
</h2>
<div className={className ?? "grid grid-cols-2 gap-3"}>
<StatCard
Expand All @@ -146,24 +211,24 @@ export function SeriesSummary({
units={units}
/>
<StatCard
label="Lowest day"
label={`Lowest ${word}`}
value={formatSeriesValue(summary.lowest.value)}
units={units}
note={summary.lowest.date}
/>
<StatCard
label="Highest day"
label={`Highest ${word}`}
value={formatSeriesValue(summary.highest.value)}
units={units}
note={summary.highest.date}
/>
<StatCard
label="Days with data"
label={`${Word}s with data`}
value={summary.days.toLocaleString()}
/>
</div>
{byYear.length > 0 ? (
<YearTable rows={byYear} total={summary} units={units} />
<YearTable rows={byYear} total={summary} units={units} word={word} />
) : null}
</section>
);
Expand All @@ -182,10 +247,12 @@ function YearTable({
rows,
total,
units,
word,
}: {
rows: { year: number; summary: Summary }[];
total: Summary;
units?: string | null;
word: string;
}) {
const row = (label: string, summary: Summary, isTotal = false) => (
<tr
Expand Down Expand Up @@ -222,13 +289,13 @@ function YearTable({
Mean
</th>
<th scope="col" className="px-3 py-2 text-right font-normal">
Lowest day
Lowest {word}
</th>
<th scope="col" className="px-3 py-2 text-right font-normal">
Highest day
Highest {word}
</th>
<th scope="col" className="px-3 py-2 text-right font-normal">
Days with data
{word === "month" ? "Months" : "Days"} with data
</th>
</tr>
</thead>
Expand Down
4 changes: 3 additions & 1 deletion src/components/layout/MapToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ChartLine,
CircleQuestionMark,
Compass,
Database,
type LucideIcon,
Moon,
Settings,
Expand All @@ -17,9 +18,10 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

export type ToolbarPanel = "settings" | "chart" | "help";
export type ToolbarPanel = "data" | "settings" | "chart" | "help";

const PANELS = [
{ value: "data", label: "Data", Icon: Database },
{ value: "settings", label: "Settings", Icon: Settings },
{ value: "chart", label: "Chart", Icon: ChartLine },
{ value: "help", label: "Help", Icon: CircleQuestionMark },
Expand Down
Loading
Loading