diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx
index d60cde7..fd1380d 100644
--- a/src/components/data/DataView.tsx
+++ b/src/components/data/DataView.tsx
@@ -9,6 +9,7 @@ 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 { ScaleField } from "@/components/panels/ScaleField";
import { TimeBasisField } from "@/components/panels/TimeBasisField";
import { YearPicker } from "@/components/panels/YearPicker";
import { SeriesSummary } from "@/components/data/SeriesSummary";
@@ -82,17 +83,14 @@ export function DataView({
- {Math.max(1, layout.nDays).toLocaleString()} days × {hoursPerDay} hours
+ {binCountLabel(nCols, scale)} × {hoursPerDay} hours
{timeBasisLabel ? ` · ${timeBasisLabel}` : ""}
{units ? ` · ${units}` : ""}
);
}
-
-function hoverBasisSuffix(timeBasisLabel: string): string {
- return timeBasisLabel.startsWith("local") ? "local" : timeBasisLabel;
-}
diff --git a/src/components/plots/MapSideTooltip.tsx b/src/components/plots/MapSideTooltip.tsx
index 1f7c6cc..4e04a4c 100644
--- a/src/components/plots/MapSideTooltip.tsx
+++ b/src/components/plots/MapSideTooltip.tsx
@@ -73,7 +73,12 @@ export function togglePin(
const tooltipBoxClass =
"z-40 grid min-w-40 -translate-y-4 gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs whitespace-nowrap shadow-xl";
-export type TooltipPoint = { label: string; shortLabel: string; value: number };
+export type TooltipPoint = {
+ label: string;
+ shortLabel: string;
+ value: number;
+ spread?: string;
+};
function formatChange(point: TooltipPoint, base?: TooltipPoint) {
if (!base || base.value === 0 || !Number.isFinite(base.value)) return null;
@@ -99,6 +104,11 @@ function PointRows({
? `${formatSeriesValue(point.value)}${units ? ` ${units}` : ""}`
: "no data"}
+ {point.spread ? (
+
+ {point.spread}
+
+ ) : null}
{change ? {change} : null}
>
);
diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx
index d0f2923..778e1f8 100644
--- a/src/components/plots/TimeSeriesPlot.tsx
+++ b/src/components/plots/TimeSeriesPlot.tsx
@@ -2,9 +2,10 @@
import { useMemo, useRef, useState, type Ref } from "react";
import {
+ Area,
CartesianGrid,
+ ComposedChart,
Line,
- LineChart,
ReferenceLine,
XAxis,
YAxis,
@@ -14,7 +15,11 @@ import {
TIME_SERIES_CHART_MARGIN,
TIME_SERIES_PLOT_HEIGHT,
} from "@/lib/plots/chartTheme";
-import { dailyMeanSeries, type DailyMeanPoint } from "@/lib/zarr/series";
+import {
+ lineSeries,
+ type LinePoint,
+ type LineResolution,
+} from "@/lib/plots/lineSeries";
import {
anchorBesidePanel,
MapSideTooltip,
@@ -34,23 +39,32 @@ import {
} from "@/components/ui/chart";
const CHART_CONFIG = {
- value: { label: "Daily mean", color: "var(--brand)" },
+ value: { label: "Mean", color: "var(--brand)" },
} satisfies ChartConfig;
+const MEANS_LABEL: Record, string> = {
+ day: "daily",
+ week: "weekly",
+ month: "monthly",
+};
+
type Readout = TooltipAnchor & PinPosition & { index: number };
const sameDay = (a: Readout, b: Readout) => a.index === b.index;
-function dayPoint(point: DailyMeanPoint | undefined): TooltipPoint | undefined {
+function tooltipPoint(point: LinePoint | undefined): TooltipPoint | undefined {
if (!point) return undefined;
- const label = `Day ${point.day}`;
- return { label, shortLabel: label.toLowerCase(), value: point.value };
+ const { label, shortLabel, value, spread } = point;
+ return { label, shortLabel, value, spread };
}
type TimeSeriesPlotProps = {
values: Float32Array;
units?: string | null;
hoursPerDay?: number;
+ selectedYears: number[];
+ resolution?: LineResolution;
+ timeBasisSuffix?: string;
height?: number;
width?: number;
pinLimit?: number;
@@ -61,15 +75,26 @@ export function TimeSeriesPlot({
values,
units,
hoursPerDay = 24,
+ selectedYears,
+ resolution = "day",
+ timeBasisSuffix,
height = TIME_SERIES_PLOT_HEIGHT,
width,
pinLimit = 1,
ref,
}: TimeSeriesPlotProps) {
- const data = useMemo(
- () => dailyMeanSeries(values, hoursPerDay),
- [values, hoursPerDay],
+ const series = useMemo(
+ () =>
+ lineSeries(
+ values,
+ hoursPerDay,
+ selectedYears,
+ resolution,
+ timeBasisSuffix,
+ ),
+ [values, hoursPerDay, selectedYears, resolution, timeBasisSuffix],
);
+ const data = series.points;
const desktop = useDesktop();
const wrapperRef = useRef(null);
@@ -101,7 +126,7 @@ export function TimeSeriesPlot({
const pinned = separate ? null : (pins[0] ?? null);
const lastPin = pins.at(-1);
const shown = pinned ?? hover;
- const shownPoint = shown ? dayPoint(data[shown.index]) : undefined;
+ const shownPoint = shown ? tooltipPoint(data[shown.index]) : undefined;
return (
@@ -112,7 +137,7 @@ export function TimeSeriesPlot({
className="aspect-auto w-full"
style={{ height, width }}
>
-
@@ -131,16 +156,15 @@ export function TimeSeriesPlot({
tick.x)}
+ tickFormatter={(x: number) =>
+ series.ticks.find((tick) => tick.x === x)?.label ?? ""
+ }
+ interval="preserveStartEnd"
tickLine={false}
axisLine={false}
- tickCount={6}
- label={{
- value: "Day in window",
- position: "insideBottom",
- offset: -12,
- }}
/>
+ {series.yearBreaks.map((x) => (
+
+ ))}
{desktop
? pins.map((pin) => (
@@ -165,7 +196,7 @@ export function TimeSeriesPlot({
) : (
- `Day ${payload[0]?.payload.day ?? ""}`
+ payload[0]?.payload.label ?? ""
}
formatter={(value) =>
`${formatSeriesValue(Number(value))}${units ? ` ${units}` : ""}`
@@ -174,25 +205,38 @@ export function TimeSeriesPlot({
)
}
/>
+ {resolution === "hour" ? null : (
+
+ )}
-
+
{desktop && separate
? pins.map((pin) => {
- const point = dayPoint(data[pin.index]);
+ const point = tooltipPoint(data[pin.index]);
if (!point) return null;
return (
@@ -213,8 +257,8 @@ export function TimeSeriesPlot({
point={shownPoint}
previous={
lastPin
- ? dayPoint(data[lastPin.index])
- : dayPoint(data[hover.index - 1])
+ ? tooltipPoint(data[lastPin.index])
+ : tooltipPoint(data[hover.index - 1])
}
anchor={hover}
units={units}
@@ -223,10 +267,10 @@ export function TimeSeriesPlot({
{desktop && !separate && shown && shownPoint ? (
) : null}
- {values.length.toLocaleString()} hourly steps · {data.length} daily
- means
+ {values.length.toLocaleString()} hourly steps
+ {resolution === "hour"
+ ? ""
+ : ` · ${series.binCount.toLocaleString()} ${MEANS_LABEL[resolution]} means, band ±1 std`}
{units ? ` · ${units}` : ""}
diff --git a/src/components/ui/segmented-tabs.tsx b/src/components/ui/segmented-tabs.tsx
index 764d778..82fcab6 100644
--- a/src/components/ui/segmented-tabs.tsx
+++ b/src/components/ui/segmented-tabs.tsx
@@ -13,6 +13,8 @@ type SegmentedOption = {
value: T;
label: string;
icon?: LucideIcon;
+ disabled?: boolean;
+ title?: string;
};
type SegmentedTabsProps = {
@@ -21,6 +23,7 @@ type SegmentedTabsProps = {
options: readonly SegmentedOption[];
"aria-label": string;
className?: string;
+ disabled?: boolean;
};
export function SegmentedTabs({
@@ -29,6 +32,7 @@ export function SegmentedTabs({
options,
"aria-label": ariaLabel,
className,
+ disabled = false,
}: SegmentedTabsProps) {
return (
({
className={className}
>
- {options.map(({ value: option, label, icon: Icon }) => (
+ {options.map((option) => (
- {Icon ? : null}
- {label}
+ {option.icon ? : null}
+ {option.label}
))}
diff --git a/src/hooks/useCellExport.ts b/src/hooks/useCellExport.ts
index d701a4c..6c7e5b1 100644
--- a/src/hooks/useCellExport.ts
+++ b/src/hooks/useCellExport.ts
@@ -66,7 +66,12 @@ export function useCellExport({
});
const [mapCapture, timeSeries] = await Promise.all([
captureMap({ cell: selection.grid, gridSpec }),
- captureTimeSeries({ values: plotValues, units, hoursPerDay }),
+ captureTimeSeries({
+ values: plotValues,
+ units,
+ hoursPerDay,
+ selectedYears,
+ }),
]);
const assets: ReportAssets = {
map: mapCapture.image,
diff --git a/src/hooks/useSeriesDisplay.ts b/src/hooks/useSeriesDisplay.ts
index c189b3f..451448b 100644
--- a/src/hooks/useSeriesDisplay.ts
+++ b/src/hooks/useSeriesDisplay.ts
@@ -12,6 +12,8 @@ import { hasFiniteValues } from "@/lib/zarr/series";
import { colormapFor, type Palette } from "@/lib/map/fingerprintScale";
import { loadPalette, savePalette } from "@/lib/settings/colormap";
import { useTheme } from "@/providers/ThemeProvider";
+import type { BinUnit } from "@/lib/plots/timeBins";
+import type { LineResolution } from "@/lib/plots/lineSeries";
import type { CellSeries } from "@/hooks/useCellSeries";
export type PlotView = "line" | "fingerprint";
@@ -28,6 +30,11 @@ export function useSeriesDisplay(
const { isLight } = useTheme();
const [plotView, setPlotView] = useState("line");
const [transposed, setTransposed] = useState(false);
+ const [lineResolution, setLineResolution] = useState("day");
+ const [scaleChoice, setFingerprintScale] = useState("day");
+ const canShowYearly = selectedYears.length > 1;
+ const fingerprintScale =
+ scaleChoice === "year" && !canShowYearly ? "day" : scaleChoice;
const [palette, setPaletteState] = useState(
() => loadPalette() ?? "science",
);
@@ -67,6 +74,11 @@ export function useSeriesDisplay(
setPlotView,
transposed,
setTransposed,
+ lineResolution,
+ setLineResolution,
+ fingerprintScale,
+ setFingerprintScale,
+ canShowYearly,
timeBasis,
utcOffsetHours,
timeBasisLabel,
diff --git a/src/lib/constants/tour.ts b/src/lib/constants/tour.ts
index 174c587..66a72d6 100644
--- a/src/lib/constants/tour.ts
+++ b/src/lib/constants/tour.ts
@@ -92,7 +92,7 @@ export const TOUR_STEPS: TourStepSpec[] = [
target: '[data-tour="plot"]',
title: "Two views of the same numbers",
body: [
- "Line is the daily mean, one point per day. Fingerprint keeps all 24 hours: columns are days, rows are hours in local time (or UTC), blue for uptake and red for release.",
+ "Line is the daily mean, one point per day, and the shaded band shows how much the hours vary. Fingerprint keeps all 24 hours: columns are days, rows are hours in local time (or UTC), blue for uptake and red for release.",
"The Export tab downloads a zip: a PDF report, the hourly numbers as XLSX and CSV, and both plots as images.",
],
gate: "series",
diff --git a/src/lib/export/captureFingerprint.ts b/src/lib/export/captureFingerprint.ts
index 034a49d..a9c4607 100644
--- a/src/lib/export/captureFingerprint.ts
+++ b/src/lib/export/captureFingerprint.ts
@@ -2,7 +2,7 @@ import {
asymmetricExtents,
defaultColormapId,
} from "@/lib/map/fingerprintScale";
-import { drawFingerprint, fingerprintLayout } from "@/lib/plots/fingerprint";
+import { drawFingerprint, fingerprintGrid } from "@/lib/plots/fingerprint";
import { canvasToPng, type CapturedImage } from "@/lib/export/capture";
import { fingerprintPngWithLegend } from "@/lib/export/fingerprintImage";
import {
@@ -33,13 +33,12 @@ export function captureFingerprint({
const canvas = document.createElement("canvas");
drawFingerprint(
canvas,
- fingerprintLayout(
- values,
- hoursPerDay,
- selectedYears,
- { width: EXPORT_PLOT_WIDTH, height: EXPORT_PLOT_HEIGHT },
- false,
- ),
+ {
+ grid: fingerprintGrid(values, hoursPerDay, selectedYears),
+ width: EXPORT_PLOT_WIDTH,
+ height: EXPORT_PLOT_HEIGHT,
+ transposed: false,
+ },
{
colormapId,
extents,
diff --git a/src/lib/export/captureTimeSeries.tsx b/src/lib/export/captureTimeSeries.tsx
index e25244d..c775cf5 100644
--- a/src/lib/export/captureTimeSeries.tsx
+++ b/src/lib/export/captureTimeSeries.tsx
@@ -20,10 +20,12 @@ export async function captureTimeSeries({
values,
units,
hoursPerDay,
+ selectedYears,
}: {
values: Float32Array;
units: string | null;
hoursPerDay: number;
+ selectedYears: number[];
}): Promise {
await whenVisible();
@@ -41,6 +43,7 @@ export async function captureTimeSeries({
values={values}
units={units}
hoursPerDay={hoursPerDay}
+ selectedYears={selectedYears}
width={EXPORT_PLOT_WIDTH}
height={EXPORT_PLOT_HEIGHT}
/>,
diff --git a/src/lib/plots/chartTheme.ts b/src/lib/plots/chartTheme.ts
index 251844b..75cc997 100644
--- a/src/lib/plots/chartTheme.ts
+++ b/src/lib/plots/chartTheme.ts
@@ -4,7 +4,7 @@ export const TIME_SERIES_CHART_MARGIN = {
top: 12,
right: 8,
left: 0,
- bottom: 20,
+ bottom: 0,
} as const;
export function formatSeriesValue(value: number): string {
diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts
index 0296353..56c30bc 100644
--- a/src/lib/plots/fingerprint.ts
+++ b/src/lib/plots/fingerprint.ts
@@ -1,15 +1,19 @@
import {
type ColormapId,
- dayIndexTicks,
FINGERPRINT_HOUR_TICKS,
fingerprintColorScale,
formatDayTick,
} from "@/lib/map/fingerprintScale";
-import {
- getSelectedYearsDayMapping,
- type YearDayMapping,
-} from "@/lib/zarr/timeRange";
+import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange";
import { chartTickColor } from "@/lib/plots/chartTheme";
+import {
+ binHourGrid,
+ type BinStats,
+ monthStartTicks,
+ type BinUnit,
+ type TimeBin,
+ timeBins,
+} from "@/lib/plots/timeBins";
const AXIS_LEFT_DEFAULT = 34;
const AXIS_LEFT_TRANSPOSED = 54;
@@ -22,35 +26,36 @@ const LABEL_FONT =
export type FingerprintExtents = { negMax: number; posMax: number };
-export type FingerprintLayout = {
- values: Float32Array;
+/** Hourly values grouped into columns of days, weeks, months or years. */
+export type FingerprintGrid = BinStats & {
+ unit: BinUnit;
+ bins: TimeBin[];
hoursPerDay: number;
- nDays: number;
- mapping: YearDayMapping;
- width: number;
- height: number;
- transposed: boolean;
};
-export function fingerprintLayout(
+export function fingerprintGrid(
values: Float32Array,
hoursPerDay: number,
selectedYears: number[] | null,
- size: { width: number; height: number },
- transposed: boolean,
-): FingerprintLayout {
+ unit: BinUnit = "day",
+): FingerprintGrid {
const nDays = Math.floor(values.length / hoursPerDay);
- return {
- values,
- hoursPerDay,
+ const { absoluteDays } = getSelectedYearsDayMapping(
+ selectedYears,
+ undefined,
nDays,
- mapping: getSelectedYearsDayMapping(selectedYears, undefined, nDays),
- width: size.width,
- height: size.height,
- transposed,
- };
+ );
+ const bins = timeBins(absoluteDays.slice(0, nDays), unit);
+ return { ...binHourGrid(values, hoursPerDay, bins), unit, bins, hoursPerDay };
}
+export type FingerprintLayout = {
+ grid: FingerprintGrid;
+ width: number;
+ height: number;
+ transposed: boolean;
+};
+
function plotBox({ width, height, transposed }: FingerprintLayout) {
const axisLeft = transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT;
return {
@@ -60,17 +65,56 @@ function plotBox({ width, height, transposed }: FingerprintLayout) {
};
}
-function dayAxisTicks({ mapping, nDays }: FingerprintLayout) {
- if (mapping.yearIntervals.length > 1) {
- return mapping.yearIntervals.map((interval) => ({
- dayLocal: Math.round((interval.startDayLocal + interval.endDayLocal) / 2),
- label: `${interval.year}`,
+/** Column index where each year starts, after the first. */
+function yearBreaks(bins: readonly TimeBin[]): number[] {
+ const breaks: number[] = [];
+ for (let col = 1; col < bins.length; col++) {
+ if (bins[col]!.year !== bins[col - 1]!.year) breaks.push(col);
+ }
+ return breaks;
+}
+
+/** Tick positions in columns, measured from the plot's leading edge. */
+function columnAxisTicks({ grid }: FingerprintLayout) {
+ const { bins } = grid;
+ const breaks = yearBreaks(bins);
+ if (breaks.length > 0 || grid.unit === "year") {
+ const starts = [0, ...breaks];
+ return starts.map((start, i) => ({
+ at: (start + (starts[i + 1] ?? bins.length)) / 2,
+ label: `${bins[start]!.year}`,
}));
}
- return dayIndexTicks(Math.max(1, nDays)).map((offset) => ({
- dayLocal: offset,
- label: formatDayTick(mapping.absoluteDays[offset] ?? offset),
- }));
+ const days = bins.flatMap((bin) =>
+ Array.from(
+ { length: bin.lastDay - bin.firstDay + 1 },
+ (_, i) => bin.firstDay + i,
+ ),
+ );
+ return monthStartTicks(days).map((local) => {
+ const col = bins.findIndex((bin) => bin.endLocal >= local);
+ const bin = bins[col]!;
+ const span = bin.endLocal - bin.startLocal + 1;
+ return {
+ at: col + (local - bin.startLocal) / span,
+ label: formatDayTick(days[local] ?? 0),
+ };
+ });
+}
+
+/**
+ * Pixel strips along the time axis, one per column, so a wide column is one
+ * rectangle; strips of 1px leave seams at fractional pixel ratios.
+ */
+function pixelRuns(length: number, nSel: number, colHi: number) {
+ const runs: { col: number; start: number; size: number }[] = [];
+ for (let px = 0; px < length; px++) {
+ const col = Math.min(colHi, Math.floor((px / length) * nSel));
+ const last = runs.at(-1);
+ if (last && last.col === col) last.size++;
+ else runs.push({ col, start: px, size: 1 });
+ }
+ return runs;
}
function clampLabelX(x: number, plotW: number, axisLeft: number): number {
@@ -92,9 +136,10 @@ export function drawFingerprint(
pixelRatio: number;
},
): void {
- const { values, hoursPerDay, nDays, mapping, width, height, transposed } =
- layout;
- if (width === 0 || nDays === 0) return;
+ const { grid, width, height, transposed } = layout;
+ const { hoursPerDay } = grid;
+ const nCols = grid.bins.length;
+ if (width === 0 || nCols === 0) return;
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
@@ -105,15 +150,23 @@ export function drawFingerprint(
ctx.clearRect(0, 0, width, height);
const { axisLeft, plotW, plotH } = plotBox(layout);
- const nSel = Math.max(1, nDays);
- const dayHi = Math.max(0, nDays - 1);
+ const nSel = Math.max(1, nCols);
+ const colHi = Math.max(0, nCols - 1);
+ const breaks = yearBreaks(grid.bins);
const scale = fingerprintColorScale(colormapId);
- const cellColor = (dayLocal: number, hour: number) =>
+ const cellColor = (col: number, hour: number) =>
scale(
- values[dayLocal * hoursPerDay + hour] as number,
+ grid.mean[col * hoursPerDay + hour] as number,
extents.negMax,
extents.posMax,
);
+ // Edges on whole device pixels, or neighbouring cells leave a faint seam.
+ const snap = (v: number) => Math.round(v * pixelRatio) / pixelRatio;
+ const fillCell = (x0: number, y0: number, x1: number, y1: number) => {
+ const left = snap(x0);
+ const top = snap(y0);
+ ctx.fillRect(left, top, snap(x1) - left, snap(y1) - top);
+ };
const dividerColor = isLight
? "rgba(0, 0, 0, 0.25)"
: "rgba(255, 255, 255, 0.25)";
@@ -121,63 +174,65 @@ export function drawFingerprint(
if (!transposed) {
const rowY = (fromTop: number) =>
AXIS_TOP + Math.floor((fromTop * plotH) / hoursPerDay);
- for (let px = 0; px < plotW; px++) {
- const dayLocal = Math.min(dayHi, Math.floor((px / plotW) * nSel));
+ for (const { col, start, size } of pixelRuns(plotW, nSel, colHi)) {
for (let hour = 0; hour < hoursPerDay; hour++) {
- const color = cellColor(dayLocal, hour);
+ const color = cellColor(col, hour);
if (color === "transparent") continue;
const fromTop = hoursPerDay - 1 - hour;
const yTop = rowY(fromTop);
const yBot = rowY(fromTop + 1);
ctx.fillStyle = color;
- ctx.fillRect(axisLeft + px, yTop, 1, Math.max(1, yBot - yTop));
+ fillCell(
+ axisLeft + start,
+ yTop,
+ axisLeft + start + size,
+ Math.max(yTop + 1, yBot),
+ );
}
}
- if (mapping.yearIntervals.length > 1) {
+ if (breaks.length > 0) {
ctx.save();
ctx.strokeStyle = dividerColor;
ctx.lineWidth = 1;
- for (const interval of mapping.yearIntervals) {
- if (interval.startDayLocal > 0) {
- const bx =
- axisLeft + Math.floor((interval.startDayLocal / nSel) * plotW);
- ctx.beginPath();
- ctx.moveTo(bx, AXIS_TOP);
- ctx.lineTo(bx, AXIS_TOP + plotH);
- ctx.stroke();
- }
+ for (const start of breaks) {
+ const bx = axisLeft + Math.floor((start / nSel) * plotW);
+ ctx.beginPath();
+ ctx.moveTo(bx, AXIS_TOP);
+ ctx.lineTo(bx, AXIS_TOP + plotH);
+ ctx.stroke();
}
ctx.restore();
}
} else {
const colX = (hour: number) =>
axisLeft + Math.floor((hour * plotW) / hoursPerDay);
- for (let py = 0; py < plotH; py++) {
- const dayLocal = Math.min(dayHi, Math.floor((py / plotH) * nSel));
+ for (const { col, start, size } of pixelRuns(plotH, nSel, colHi)) {
for (let hour = 0; hour < hoursPerDay; hour++) {
- const color = cellColor(dayLocal, hour);
+ const color = cellColor(col, hour);
if (color === "transparent") continue;
const xLeft = colX(hour);
const xRight = colX(hour + 1);
ctx.fillStyle = color;
- ctx.fillRect(xLeft, AXIS_TOP + py, Math.max(1, xRight - xLeft), 1);
+ fillCell(
+ xLeft,
+ AXIS_TOP + start,
+ Math.max(xLeft + 1, xRight),
+ AXIS_TOP + start + size,
+ );
}
}
- if (mapping.yearIntervals.length > 1) {
+ if (breaks.length > 0) {
ctx.save();
ctx.strokeStyle = dividerColor;
ctx.lineWidth = 1;
- for (const interval of mapping.yearIntervals) {
- if (interval.startDayLocal > 0) {
- const by =
- AXIS_TOP + Math.floor((interval.startDayLocal / nSel) * plotH);
- ctx.beginPath();
- ctx.moveTo(axisLeft, by);
- ctx.lineTo(axisLeft + plotW, by);
- ctx.stroke();
- }
+ for (const start of breaks) {
+ const by = AXIS_TOP + Math.floor((start / nSel) * plotH);
+ ctx.beginPath();
+ ctx.moveTo(axisLeft, by);
+ ctx.lineTo(axisLeft + plotW, by);
+ ctx.stroke();
}
ctx.restore();
}
@@ -186,8 +241,8 @@ export function drawFingerprint(
ctx.fillStyle = chartTickColor(isLight);
ctx.font = LABEL_FONT;
- const ticks = dayAxisTicks(layout);
- const dayFrac = (dayLocal: number) => (nSel <= 1 ? 0 : dayLocal / (nSel - 1));
+ const ticks = columnAxisTicks(layout);
+ const colFrac = (at: number) => at / nSel;
if (!transposed) {
const rowY = (fromTop: number) =>
@@ -204,9 +259,9 @@ export function drawFingerprint(
}
ctx.textAlign = "center";
ctx.textBaseline = "top";
- for (const { dayLocal, label } of ticks) {
+ for (const { at, label } of ticks) {
const x = clampLabelX(
- axisLeft + dayFrac(dayLocal) * plotW,
+ axisLeft + colFrac(at) * plotW,
plotW,
axisLeft,
);
@@ -215,8 +270,8 @@ export function drawFingerprint(
} else {
ctx.textAlign = "right";
ctx.textBaseline = "middle";
- for (const { dayLocal, label } of ticks) {
- const y = AXIS_TOP + dayFrac(dayLocal) * plotH;
+ for (const { at, label } of ticks) {
+ const y = AXIS_TOP + colFrac(at) * plotH;
ctx.fillText(
label,
axisLeft - 6,
@@ -233,10 +288,8 @@ export function drawFingerprint(
}
export type FingerprintCell = {
- dayLocal: number;
+ col: number;
hour: number;
- absoluteDay: number;
- value: number;
};
export function fingerprintCellAt(
@@ -244,31 +297,27 @@ export function fingerprintCellAt(
x: number,
y: number,
): FingerprintCell | null {
- const { values, hoursPerDay, nDays, mapping, transposed } = layout;
+ const { grid, transposed } = layout;
+ const { hoursPerDay } = grid;
const { axisLeft, plotW, plotH } = plotBox(layout);
const inX = x - axisLeft;
const inY = y - AXIS_TOP;
if (inX < 0 || inX >= plotW || inY < 0 || inY >= plotH) return null;
- const nSel = Math.max(1, nDays);
- const dayHi = Math.max(0, nDays - 1);
- let dayLocal: number;
- let hour: number;
+ const nCols = grid.bins.length;
+ const nSel = Math.max(1, nCols);
+ const colHi = Math.max(0, nCols - 1);
if (!transposed) {
- dayLocal = Math.min(dayHi, Math.floor((inX / plotW) * nSel));
- hour =
- hoursPerDay -
- 1 -
- Math.min(hoursPerDay - 1, Math.floor((inY / plotH) * hoursPerDay));
- } else {
- hour = Math.min(hoursPerDay - 1, Math.floor((inX / plotW) * hoursPerDay));
- dayLocal = Math.min(dayHi, Math.floor((inY / plotH) * nSel));
+ return {
+ col: Math.min(colHi, Math.floor((inX / plotW) * nSel)),
+ hour:
+ hoursPerDay -
+ 1 -
+ Math.min(hoursPerDay - 1, Math.floor((inY / plotH) * hoursPerDay)),
+ };
}
-
return {
- dayLocal,
- hour,
- absoluteDay: mapping.absoluteDays[dayLocal] ?? dayLocal,
- value: values[dayLocal * hoursPerDay + hour] as number,
+ col: Math.min(colHi, Math.floor((inY / plotH) * nSel)),
+ hour: Math.min(hoursPerDay - 1, Math.floor((inX / plotW) * hoursPerDay)),
};
}
diff --git a/src/lib/plots/lineSeries.test.ts b/src/lib/plots/lineSeries.test.ts
new file mode 100644
index 0000000..da421ee
--- /dev/null
+++ b/src/lib/plots/lineSeries.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import {
+ keepExtremes,
+ lineSeries,
+ MAX_LINE_POINTS,
+} from "@/lib/plots/lineSeries";
+
+const hourly = (days: number, at: (day: number, hour: number) => number) =>
+ Float32Array.from({ length: days * 24 }, (_, i) =>
+ at(Math.floor(i / 24), i % 24),
+ );
+
+describe("keepExtremes", () => {
+ it("keeps every point when there are few enough", () => {
+ const points = [1, 2, 3].map((value) => ({ value }));
+ expect(keepExtremes(points, 10)).toEqual(points);
+ });
+
+ it("keeps each slice's lowest and highest point in time order", () => {
+ const points = [5, 1, 9, 3, 2, 8, 7, 4].map((value) => ({ value }));
+ expect(keepExtremes(points, 4).map((point) => point.value)).toEqual([
+ 1, 9, 2, 8,
+ ]);
+ });
+});
+
+describe("lineSeries", () => {
+ const year = hourly(365, (day, hour) => day + hour);
+
+ it("labels days with real dates and adds a std band", () => {
+ const { points } = lineSeries(year, 24, [2019], "day");
+ expect(points).toHaveLength(365);
+ expect(points[0]).toMatchObject({ label: "2019-01-01", x: 0.5 });
+ expect(points[0]!.value).toBeCloseTo(11.5);
+ expect(points[0]!.band![1] - points[0]!.value).toBeCloseTo(
+ Math.sqrt((24 * 24 - 1) / 12),
+ );
+ });
+
+ it("groups weeks and months on the calendar", () => {
+ expect(lineSeries(year, 24, [2019], "week").points).toHaveLength(52);
+ const months = lineSeries(year, 24, [2019], "month").points;
+ expect(months.map((point) => point.label).slice(0, 2)).toEqual([
+ "Jan 2019",
+ "Feb 2019",
+ ]);
+ });
+
+ it("thins a long hourly line without losing its peak", () => {
+ const peaked = hourly(365, (day, hour) => (day === 200 && hour === 13 ? 99 : 0));
+ const { points } = lineSeries(peaked, 24, [2019], "hour", "local");
+ expect(points.length).toBeLessThanOrEqual(MAX_LINE_POINTS);
+ expect(points.some((point) => point.value === 99)).toBe(true);
+ expect(points[0]!.label).toBe("2019-01-01 · 00:00 local");
+ });
+
+ it("marks year breaks and labels each year when several are picked", () => {
+ const twoYears = hourly(730, () => 1);
+ const { ticks, yearBreaks } = lineSeries(twoYears, 24, [2003, 2010], "day");
+ expect(yearBreaks).toEqual([365]);
+ expect(ticks.map((tick) => tick.label)).toEqual(["2003", "2010"]);
+ });
+});
diff --git a/src/lib/plots/lineSeries.ts b/src/lib/plots/lineSeries.ts
new file mode 100644
index 0000000..192131d
--- /dev/null
+++ b/src/lib/plots/lineSeries.ts
@@ -0,0 +1,142 @@
+import { formatDayTick, formatIsoDate } from "@/lib/map/fingerprintScale";
+import { formatSeriesValue } from "@/lib/plots/chartTheme";
+import {
+ binCountLabel,
+ binLineStats,
+ type BinUnit,
+ formatBinLabel,
+ formatBinShortLabel,
+ monthStartTicks,
+ timeBins,
+} from "@/lib/plots/timeBins";
+import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange";
+
+export type LineResolution = "hour" | Exclude;
+
+/** Above this many hourly points the line keeps only each slice's extremes. */
+export const MAX_LINE_POINTS = 4000;
+
+export type LinePoint = {
+ /** Position in days from the start of the selection, at the bin's centre. */
+ x: number;
+ value: number;
+ band?: [number, number];
+ label: string;
+ shortLabel: string;
+ spread?: string;
+};
+
+export type LineTick = { x: number; label: string };
+
+export type LineSeries = {
+ points: LinePoint[];
+ ticks: LineTick[];
+ /** Positions where a new year starts, after the first. */
+ yearBreaks: number[];
+ nDays: number;
+ binCount: number;
+};
+
+function hourLabel(hour: number) {
+ return `${String(hour).padStart(2, "0")}:00`;
+}
+
+/** Keep the lowest and highest point of each slice, in time order. */
+export function keepExtremes(
+ points: readonly T[],
+ maxPoints: number,
+): T[] {
+ if (points.length <= maxPoints) return [...points];
+ const slices = Math.max(1, Math.floor(maxPoints / 2));
+ const size = points.length / slices;
+ const kept: T[] = [];
+ for (let s = 0; s < slices; s++) {
+ const start = Math.floor(s * size);
+ const end = Math.min(points.length, Math.floor((s + 1) * size));
+ let lo = -1;
+ let hi = -1;
+ for (let i = start; i < end; i++) {
+ const value = points[i]!.value;
+ if (!Number.isFinite(value)) continue;
+ if (lo < 0 || value < points[lo]!.value) lo = i;
+ if (hi < 0 || value > points[hi]!.value) hi = i;
+ }
+ if (lo < 0) continue;
+ kept.push(points[Math.min(lo, hi)]!);
+ if (lo !== hi) kept.push(points[Math.max(lo, hi)]!);
+ }
+ return kept;
+}
+
+export function lineSeries(
+ values: Float32Array,
+ hoursPerDay: number,
+ selectedYears: number[] | null,
+ resolution: LineResolution,
+ timeBasisSuffix = "",
+): LineSeries {
+ const nDays = Math.floor(values.length / hoursPerDay);
+ const absoluteDays = getSelectedYearsDayMapping(
+ selectedYears,
+ undefined,
+ nDays,
+ ).absoluteDays.slice(0, nDays);
+ const years = timeBins(absoluteDays, "year");
+ const yearBreaks = years.slice(1).map((year) => year.startLocal);
+ const ticks: LineTick[] =
+ years.length > 1
+ ? years.map((year) => ({
+ x: (year.startLocal + year.endLocal + 1) / 2,
+ label: String(year.year),
+ }))
+ : monthStartTicks(absoluteDays).map((day) => ({
+ x: day,
+ label: formatDayTick(absoluteDays[day] ?? 0),
+ }));
+
+ let points: LinePoint[] = [];
+ let binCount = 0;
+
+ if (resolution === "hour") {
+ const suffix = timeBasisSuffix ? ` ${timeBasisSuffix}` : "";
+ for (let day = 0; day < nDays; day++) {
+ const date = formatIsoDate(absoluteDays[day] ?? 0);
+ for (let hour = 0; hour < hoursPerDay; hour++) {
+ const value = values[day * hoursPerDay + hour] as number;
+ if (!Number.isFinite(value)) continue;
+ points.push({
+ x: day + (hour + 0.5) / hoursPerDay,
+ value,
+ label: `${date} · ${hourLabel(hour)}${suffix}`,
+ shortLabel: `${date} ${hourLabel(hour)}`,
+ });
+ }
+ }
+ binCount = points.length;
+ points = keepExtremes(points, MAX_LINE_POINTS);
+ } else {
+ const bins = timeBins(absoluteDays, resolution);
+ const stats = binLineStats(values, hoursPerDay, bins, resolution);
+ binCount = bins.length;
+ bins.forEach((bin, b) => {
+ const count = stats.count[b] ?? 0;
+ if (count === 0) return;
+ const mean = stats.mean[b] as number;
+ const std = stats.std[b] as number;
+ const across =
+ resolution === "day"
+ ? `${count.toLocaleString()} hour${count === 1 ? "" : "s"}`
+ : binCountLabel(count, "day");
+ points.push({
+ x: (bin.startLocal + bin.endLocal + 1) / 2,
+ value: mean,
+ band: [mean - std, mean + std],
+ label: formatBinLabel(bin, resolution),
+ shortLabel: formatBinShortLabel(bin, resolution),
+ spread: `Std ${formatSeriesValue(std)} across ${across}`,
+ });
+ });
+ }
+
+ return { points, ticks, yearBreaks, nDays, binCount };
+}
diff --git a/src/lib/plots/timeBins.test.ts b/src/lib/plots/timeBins.test.ts
new file mode 100644
index 0000000..1c98c44
--- /dev/null
+++ b/src/lib/plots/timeBins.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+import {
+ binHourGrid,
+ binLineStats,
+ formatBinLabel,
+ monthStartTicks,
+ timeBins,
+ WEEKS_PER_YEAR,
+} from "@/lib/plots/timeBins";
+import { getSelectedYearsDayMapping } from "@/lib/zarr/timeRange";
+
+const daysOf = (years: number[]) =>
+ getSelectedYearsDayMapping(years).absoluteDays;
+
+describe("timeBins", () => {
+ it("makes 52 weeks a year, with the spare days in the last week", () => {
+ const common = timeBins(daysOf([2019]), "week");
+ expect(common).toHaveLength(WEEKS_PER_YEAR);
+ expect(common[0]).toMatchObject({ startLocal: 0, endLocal: 6 });
+ expect(common.at(-1)).toMatchObject({ startLocal: 357, endLocal: 364 });
+
+ const leap = timeBins(daysOf([2020]), "week");
+ expect(leap).toHaveLength(WEEKS_PER_YEAR);
+ expect(leap.at(-1)).toMatchObject({ startLocal: 357, endLocal: 365 });
+ });
+
+ it("follows calendar months, including a leap February", () => {
+ const months = timeBins(daysOf([2020]), "month");
+ expect(months).toHaveLength(12);
+ expect(months[1]).toMatchObject({ index: 1, startLocal: 31, endLocal: 59 });
+ });
+
+ it("keeps years with a gap between them apart", () => {
+ const years = timeBins(daysOf([2003, 2010]), "year");
+ expect(years.map((bin) => bin.year)).toEqual([2003, 2010]);
+ expect(years[1]).toMatchObject({ startLocal: 365, endLocal: 729 });
+
+ const weeks = timeBins(daysOf([2003, 2010]), "week");
+ expect(weeks).toHaveLength(2 * WEEKS_PER_YEAR);
+ expect(weeks[WEEKS_PER_YEAR]).toMatchObject({ year: 2010, index: 0 });
+ });
+
+ it("gives one bin per day at the daily scale", () => {
+ expect(timeBins(daysOf([2019]), "day")).toHaveLength(365);
+ });
+});
+
+describe("binHourGrid", () => {
+ it("averages each hour across the days of a bin, skipping gaps", () => {
+ const bins = [
+ { year: 2019, index: 0, startLocal: 0, endLocal: 2, firstDay: 0, lastDay: 2 },
+ ];
+ const values = new Float32Array([1, 10, 3, NaN, 5, 30]);
+ const grid = binHourGrid(values, 2, bins);
+
+ expect(Array.from(grid.mean)).toEqual([3, 20]);
+ expect(Array.from(grid.count)).toEqual([3, 2]);
+ expect(grid.std[0]).toBeCloseTo(Math.sqrt(8 / 3));
+ expect(grid.std[1]).toBeCloseTo(10);
+ });
+
+ it("returns the values unchanged for daily bins", () => {
+ const days = daysOf([2019]).slice(0, 3);
+ const values = new Float32Array([0.1, -2.7, NaN, 4.2, 5.5, -0.3]);
+ const grid = binHourGrid(values, 2, timeBins(days, "day"));
+
+ expect(Array.from(grid.mean)).toEqual(Array.from(values));
+ });
+
+ it("marks a bin with no data as missing", () => {
+ const bins = [
+ { year: 2019, index: 0, startLocal: 0, endLocal: 0, firstDay: 0, lastDay: 0 },
+ ];
+ const grid = binHourGrid(new Float32Array([NaN]), 1, bins);
+ expect(grid.mean[0]).toBeNaN();
+ expect(grid.count[0]).toBe(0);
+ });
+});
+
+describe("binLineStats", () => {
+ const values = new Float32Array([0, 2, 4, 6, 10, 10]);
+ const bins = [
+ { year: 2019, index: 0, startLocal: 0, endLocal: 2, firstDay: 0, lastDay: 2 },
+ ];
+
+ it("spreads a day across its hours", () => {
+ const daily = binLineStats(
+ values,
+ 2,
+ timeBins(daysOf([2019]).slice(0, 3), "day"),
+ "day",
+ );
+ expect(Array.from(daily.mean)).toEqual([1, 5, 10]);
+ expect(Array.from(daily.std)).toEqual([1, 1, 0]);
+ expect(Array.from(daily.count)).toEqual([2, 2, 2]);
+ });
+
+ it("spreads a week or month across its daily means", () => {
+ const weekly = binLineStats(values, 2, bins, "week");
+ expect(weekly.mean[0]).toBeCloseTo(16 / 3);
+ expect(weekly.std[0]).toBeCloseTo(Math.sqrt((1 + 25 + 100) / 3 - (16 / 3) ** 2));
+ expect(weekly.count[0]).toBe(3);
+ });
+
+ it("leaves days without data out of the count", () => {
+ const gappy = new Float32Array([NaN, NaN, 4, 6, 10, 10]);
+ const weekly = binLineStats(gappy, 2, bins, "week");
+ expect(weekly.count[0]).toBe(2);
+ expect(weekly.mean[0]).toBeCloseTo(7.5);
+ });
+});
+
+describe("monthStartTicks", () => {
+ it("puts ticks on the first of every other month in one year", () => {
+ const days = daysOf([2019]);
+ const ticks = monthStartTicks(days);
+ expect(ticks).toHaveLength(6);
+ expect(ticks.map((local) => formatBinLabel(timeBins([days[local]!], "day")[0]!, "day"))).toEqual([
+ "2019-01-01",
+ "2019-03-01",
+ "2019-05-01",
+ "2019-07-01",
+ "2019-09-01",
+ "2019-11-01",
+ ]);
+ });
+});
+
+describe("formatBinLabel", () => {
+ it("names each scale plainly", () => {
+ const [week] = timeBins(daysOf([2019]), "week").slice(11);
+ const [month] = timeBins(daysOf([2019]), "month").slice(2);
+ const [day] = timeBins(daysOf([2019]), "day");
+ expect(formatBinLabel(week!, "week")).toBe("19 to 25 Mar 2019");
+ expect(formatBinLabel(month!, "month")).toBe("Mar 2019");
+ expect(formatBinLabel(day!, "day")).toBe("2019-01-01");
+ expect(formatBinLabel(timeBins(daysOf([2019]), "year")[0]!, "year")).toBe(
+ "2019",
+ );
+ });
+
+ it("spells out both months when a week crosses one", () => {
+ const [week] = timeBins(daysOf([2019]), "week").slice(4);
+ expect(formatBinLabel(week!, "week")).toBe("29 Jan to 4 Feb 2019");
+ });
+});
diff --git a/src/lib/plots/timeBins.ts b/src/lib/plots/timeBins.ts
new file mode 100644
index 0000000..067c9a7
--- /dev/null
+++ b/src/lib/plots/timeBins.ts
@@ -0,0 +1,214 @@
+import { dayIndexToUTCDate } from "@/lib/zarr/timeRange";
+
+export type BinUnit = "day" | "week" | "month" | "year";
+
+export const WEEKS_PER_YEAR = 52;
+
+const MS_PER_DAY = 86_400_000;
+const SHORT_MONTHS = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+] as const;
+
+export type TimeBin = {
+ year: number;
+ /** Day of year, week (0 to 51) or month (0 to 11); 0 for a whole year. */
+ index: number;
+ /** First and last selected day in the bin, as positions in the series. */
+ startLocal: number;
+ endLocal: number;
+ /** The same two days as absolute day indices. */
+ firstDay: number;
+ lastDay: number;
+};
+
+function binKey(absoluteDay: number, unit: BinUnit) {
+ const date = dayIndexToUTCDate(absoluteDay);
+ const year = date.getUTCFullYear();
+ if (unit === "year") return { year, index: 0 };
+ if (unit === "month") return { year, index: date.getUTCMonth() };
+ const dayOfYear = Math.round(
+ (date.getTime() - Date.UTC(year, 0, 1)) / MS_PER_DAY,
+ );
+ if (unit === "day") return { year, index: dayOfYear };
+ // The last week also takes day 365 (and 366 in leap years).
+ return {
+ year,
+ index: Math.min(WEEKS_PER_YEAR - 1, Math.floor(dayOfYear / 7)),
+ };
+}
+
+/** Group consecutive selected days into calendar bins. */
+export function timeBins(absoluteDays: readonly number[], unit: BinUnit) {
+ const bins: TimeBin[] = [];
+ absoluteDays.forEach((absoluteDay, local) => {
+ const { year, index } = binKey(absoluteDay, unit);
+ const last = bins.at(-1);
+ if (last && last.year === year && last.index === index) {
+ last.endLocal = local;
+ last.lastDay = absoluteDay;
+ } else {
+ bins.push({
+ year,
+ index,
+ startLocal: local,
+ endLocal: local,
+ firstDay: absoluteDay,
+ lastDay: absoluteDay,
+ });
+ }
+ });
+ return bins;
+}
+
+export type BinStats = {
+ mean: Float32Array;
+ std: Float32Array;
+ count: Uint32Array;
+};
+
+class Accumulator {
+ sum = 0;
+ sumSq = 0;
+ count = 0;
+
+ add(value: number | undefined) {
+ if (value === undefined || !Number.isFinite(value)) return;
+ this.sum += value;
+ this.sumSq += value * value;
+ this.count++;
+ }
+
+ write(stats: BinStats, at: number) {
+ stats.count[at] = this.count;
+ if (this.count === 0) {
+ stats.mean[at] = NaN;
+ stats.std[at] = NaN;
+ return;
+ }
+ const mean = this.sum / this.count;
+ stats.mean[at] = mean;
+ // Population std; clamped because rounding can leave a tiny negative.
+ stats.std[at] = Math.sqrt(Math.max(0, this.sumSq / this.count - mean * mean));
+ }
+}
+
+function emptyStats(length: number): BinStats {
+ return {
+ mean: new Float32Array(length),
+ std: new Float32Array(length),
+ count: new Uint32Array(length),
+ };
+}
+
+/**
+ * One value per bin and hour of day, laid out bin by bin. The std and count
+ * describe how that hour varies across the days in the bin.
+ */
+export function binHourGrid(
+ values: ArrayLike,
+ hoursPerDay: number,
+ bins: readonly TimeBin[],
+): BinStats {
+ const stats = emptyStats(bins.length * hoursPerDay);
+ bins.forEach((bin, b) => {
+ for (let hour = 0; hour < hoursPerDay; hour++) {
+ const acc = new Accumulator();
+ for (let day = bin.startLocal; day <= bin.endLocal; day++) {
+ acc.add(values[day * hoursPerDay + hour]);
+ }
+ acc.write(stats, b * hoursPerDay + hour);
+ }
+ });
+ return stats;
+}
+
+/**
+ * One value per bin for a line. A day's spread is across its hours; a week's
+ * or month's is across its daily means, so the band shows how much days vary
+ * rather than the day and night swing.
+ */
+export function binLineStats(
+ values: ArrayLike,
+ hoursPerDay: number,
+ bins: readonly TimeBin[],
+ unit: BinUnit,
+): BinStats {
+ const stats = emptyStats(bins.length);
+ const dayMean = (day: number) => {
+ const acc = new Accumulator();
+ for (let hour = 0; hour < hoursPerDay; hour++) {
+ acc.add(values[day * hoursPerDay + hour]);
+ }
+ return acc.count > 0 ? acc.sum / acc.count : NaN;
+ };
+
+ bins.forEach((bin, b) => {
+ const acc = new Accumulator();
+ for (let day = bin.startLocal; day <= bin.endLocal; day++) {
+ if (unit === "day") {
+ for (let hour = 0; hour < hoursPerDay; hour++) {
+ acc.add(values[day * hoursPerDay + hour]);
+ }
+ } else {
+ acc.add(dayMean(day));
+ }
+ }
+ acc.write(stats, b);
+ });
+ return stats;
+}
+
+function shortDate(absoluteDay: number) {
+ const date = dayIndexToUTCDate(absoluteDay);
+ return `${date.getUTCDate()} ${SHORT_MONTHS[date.getUTCMonth()]}`;
+}
+
+/** Readout label for a bin, such as "19 to 25 Mar 2019" for a week. */
+export function formatBinLabel(bin: TimeBin, unit: BinUnit): string {
+ if (unit === "year") return String(bin.year);
+ if (unit === "month") return `${SHORT_MONTHS[bin.index]} ${bin.year}`;
+ if (unit === "day") return dayIndexToUTCDate(bin.firstDay).toISOString().slice(0, 10);
+ const first = dayIndexToUTCDate(bin.firstDay);
+ const last = dayIndexToUTCDate(bin.lastDay);
+ const range =
+ first.getUTCMonth() === last.getUTCMonth()
+ ? `${first.getUTCDate()} to ${shortDate(bin.lastDay)}`
+ : `${shortDate(bin.firstDay)} to ${shortDate(bin.lastDay)}`;
+ return `${range} ${bin.year}`;
+}
+
+/** Compact label for the short "vs ..." comparison line. */
+export function formatBinShortLabel(bin: TimeBin, unit: BinUnit): string {
+ if (unit === "week") return `week of ${shortDate(bin.firstDay)}`;
+ return formatBinLabel(bin, unit);
+}
+
+/** First day of every month, thinned to at most `count` ticks. */
+export function monthStartTicks(
+ absoluteDays: readonly number[],
+ count = 6,
+): number[] {
+ const starts = timeBins(absoluteDays, "month").map((bin) => bin.startLocal);
+ const step = Math.max(1, Math.ceil(starts.length / count));
+ return starts.filter((_, i) => i % step === 0);
+}
+
+export function binCountLabel(count: number, unit: BinUnit): string {
+ const plural = (n: number, word: string) =>
+ `${n.toLocaleString()} ${word}${n === 1 ? "" : "s"}`;
+ if (unit === "year") return plural(count, "year");
+ if (unit === "month") return plural(count, "month");
+ if (unit === "week") return plural(count, "week");
+ return plural(count, "day");
+}
diff --git a/src/lib/zarr/localTime.ts b/src/lib/zarr/localTime.ts
index da116ca..05094ee 100644
--- a/src/lib/zarr/localTime.ts
+++ b/src/lib/zarr/localTime.ts
@@ -57,6 +57,11 @@ export function formatTimeBasis(basis: TimeBasis, offsetHours: number): string {
return `local solar time (${formatUtcOffset(offsetHours)})`;
}
+/** The time basis as a short suffix for an hour readout: "local" or "UTC". */
+export function shortTimeBasis(timeBasisLabel: string): string {
+ return timeBasisLabel.startsWith("local") ? "local" : timeBasisLabel;
+}
+
/**
* Split the window into runs of consecutive days. `dayCount` covers the buffer
* even when the mapping is short, so the tail still rolls with the run it