diff --git a/.gitignore b/.gitignore index e23c933..0f25159 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# Lovable design reference exports (local visual reference only, never commit) -*Lovable*.html -*Lovable_files/ # Claude Code local per-user settings .claude/settings.local.json diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index d60cde7..e2ebe5c 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -1,7 +1,8 @@ "use client"; -import { useRef, useSyncExternalStore } from "react"; -import { ArrowLeft } from "lucide-react"; +import { useRef, useState, useSyncExternalStore } from "react"; +import { Collapsible } from "@base-ui/react/collapsible"; +import { ArrowLeft, ChevronDown } from "lucide-react"; import { formatLatitude, formatLongitude } from "@/lib/map/geogrid"; import { formatSelectedYearsLabel } from "@/lib/zarr/timeRange"; import type { TimeBasis } from "@/lib/zarr/localTime"; @@ -9,6 +10,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"; @@ -35,6 +37,16 @@ function usePlotHeight() { ); } +const SIDEBAR_QUERY = "(width >= 64rem)"; + +function useSidebarLayout() { + return useSyncExternalStore( + subscribeResize, + () => matchMedia(SIDEBAR_QUERY).matches, + () => true, + ); +} + function Chip({ children }: { children: React.ReactNode }) { return ( @@ -59,6 +71,8 @@ export function DataView({ const display = useSeriesDisplay(series, timeBasis); const fadeRef = useRef(null); const plotHeight = usePlotHeight(); + const wide = useSidebarLayout(); + const [detailsOpen, setDetailsOpen] = useState(false); const { selection, selectedYears, units } = series; return ( @@ -79,33 +93,39 @@ export function DataView({ />
{selection ? ( -
- -
+
+ + + + {wide ? null : ( + + Details · Years {formatSelectedYearsLabel(selectedYears)} + + + )} + +
+ + + +
+
+
+
+
+
@@ -140,6 +160,7 @@ export function DataView({ display={display} height={plotHeight} pinLimit={2} + margins /> @@ -151,6 +172,11 @@ export function DataView({ className="grid grid-cols-2 gap-3 xl:grid-cols-4" />
+ + + + +
) : null}
diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index b2e7038..3f9c82f 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -6,7 +6,7 @@ import { ArrowLeftRight, ChevronDown } from "lucide-react"; import { formatLatitude, formatLongitude } from "@/lib/map/geogrid"; import { formatSelectedYearsLabel, ZARR_TIME } from "@/lib/zarr/timeRange"; import { TIME_SERIES_PLOT_HEIGHT } from "@/lib/plots/chartTheme"; -import type { TimeBasis } from "@/lib/zarr/localTime"; +import { shortTimeBasis, type TimeBasis } from "@/lib/zarr/localTime"; import type { CellSeries, SeriesProgress } from "@/hooks/useCellSeries"; import type { PlotView, SeriesDisplay } from "@/hooks/useSeriesDisplay"; import { TimeSeriesPlot } from "@/components/plots/TimeSeriesPlot"; @@ -108,6 +108,9 @@ export function CellView({ ); } +const activeTriggerClass = + "data-active:bg-brand/15 data-active:text-brand dark:data-active:bg-brand/15 dark:data-active:text-brand"; + export function PlotControls({ display }: { display: SeriesDisplay }) { const disabled = display.isEmptyCell; @@ -121,14 +124,14 @@ export function PlotControls({ display }: { display: SeriesDisplay }) { Line Fingerprint @@ -163,9 +166,16 @@ type PlotAreaProps = { display: SeriesDisplay; height?: number; pinLimit?: number; + margins?: boolean; }; -export function PlotArea({ series, display, height, pinLimit }: PlotAreaProps) { +export function PlotArea({ + series, + display, + height, + pinLimit, + margins, +}: PlotAreaProps) { if (series.loading) { return (
@@ -201,6 +211,9 @@ export function PlotArea({ series, display, height, pinLimit }: PlotAreaProps) { values={display.displayValues} units={series.units} hoursPerDay={ZARR_TIME.hoursPerDay} + selectedYears={series.selectedYears} + resolution={display.lineResolution} + timeBasisSuffix={shortTimeBasis(display.timeBasisLabel)} height={height} pinLimit={pinLimit} /> @@ -213,6 +226,8 @@ export function PlotArea({ series, display, height, pinLimit }: PlotAreaProps) { timeBasisLabel={display.timeBasisLabel} transposed={display.transposed} colormapId={display.colormapId} + scale={display.fingerprintScale} + margins={margins} height={height} pinLimit={pinLimit} /> diff --git a/src/components/panels/ScaleField.tsx b/src/components/panels/ScaleField.tsx new file mode 100644 index 0000000..3796ddc --- /dev/null +++ b/src/components/panels/ScaleField.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { BinUnit } from "@/lib/plots/timeBins"; +import type { LineResolution } from "@/lib/plots/lineSeries"; +import type { SeriesDisplay } from "@/hooks/useSeriesDisplay"; +import { FieldLegend, FieldSet } from "@/components/ui/field"; +import { SegmentedTabs } from "@/components/ui/segmented-tabs"; + +const LINE_RESOLUTIONS = [ + { value: "hour", label: "Hourly" }, + { value: "day", label: "Daily" }, + { value: "week", label: "Weekly" }, + { value: "month", label: "Monthly" }, +] as const; + +export function ScaleField({ display }: { display: SeriesDisplay }) { + const disabled = display.isEmptyCell; + + if (display.plotView === "line") { + return ( +
+ Line resolution + + value={display.lineResolution} + onChange={display.setLineResolution} + options={LINE_RESOLUTIONS} + disabled={disabled} + aria-label="Line resolution" + /> +
+ ); + } + + const fingerprintScales = [ + { value: "day", label: "Daily" }, + { value: "week", label: "Weekly" }, + { value: "month", label: "Monthly" }, + { + value: "year", + label: "Yearly", + disabled: !display.canShowYearly, + title: display.canShowYearly ? undefined : "Pick two or more years", + }, + ] as const; + + return ( +
+ Fingerprint scale + + value={display.fingerprintScale} + onChange={display.setFingerprintScale} + options={fingerprintScales} + disabled={disabled} + aria-label="Fingerprint scale" + /> +
+ ); +} diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index 4de97ba..a0dbe0a 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -6,7 +6,6 @@ import { COLORMAPS, type ColormapId, fingerprintColorScale, - formatIsoDate, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue, @@ -14,15 +13,30 @@ import { } from "@/lib/plots/chartTheme"; import { drawFingerprint, - type FingerprintCell, - fingerprintCellAt, - fingerprintLayout, + fingerprintGrid, + type FingerprintLayout, + FINGERPRINT_STRIP, + FINGERPRINT_STRIP_MAX, + fingerprintStripDividers, + fingerprintStripHeight, + type FingerprintStrips, + type FingerprintTarget, + fingerprintTargetAt, + fingerprintTargetPoint, } from "@/lib/plots/fingerprint"; +import { + binCountLabel, + type BinUnit, + formatBinLabel, + formatBinShortLabel, +} from "@/lib/plots/timeBins"; +import { shortTimeBasis } from "@/lib/zarr/localTime"; import { useTheme } from "@/providers/ThemeProvider"; import { anchorBesidePanel, MapSideTooltip, PinnedTooltip, + pinFlips, pinPosition, togglePin, useDesktop, @@ -32,6 +46,12 @@ import { const LEGEND_STOPS = 32; const TRANSPOSED_MIN_HEIGHT = 380; +// A strip dropped shorter than this is too small to read, so it closes. +const STRIP_SNAP = 24; +const STRIP_KEY_STEP = 8; +const DRAG_START = 3; +// The left strip takes its width from the cells, so it may only use part of the plot. +const LEFT_STRIP_SHARE = 0.4; type FingerprintPlotProps = { values: Float32Array; @@ -41,19 +61,107 @@ type FingerprintPlotProps = { timeBasisLabel?: string; transposed: boolean; colormapId: ColormapId; + scale?: BinUnit; + margins?: boolean; height?: number; pinLimit?: number; }; -type Hover = FingerprintCell & { +type Hover = { + target: FingerprintTarget; left: number; top: number; flip: boolean; anchor: TooltipAnchor | null; }; -const sameCell = (a: Hover, b: Hover) => - a.dayLocal === b.dayLocal && a.hour === b.hour; +function targetKey(target: FingerprintTarget): string { + if (target.kind === "cell") return `cell-${target.col}-${target.hour}`; + if (target.kind === "column") return `column-${target.col}`; + return `hour-${target.hour}`; +} + +const sameTarget = (a: Hover, b: Hover) => + targetKey(a.target) === targetKey(b.target); + +type StripSide = keyof FingerprintStrips; + +/** The full-length edge between a strip and the cells: drag to resize, drag to the edge or tap to hide. */ +function StripDivider({ + side, + size, + max, + box, + onResize, +}: { + side: StripSide; + size: number; + max: number; + box: { x: number; y: number; w: number; h: number }; + onResize: (size: number) => void; +}) { + const drag = useRef<{ from: number; size: number; moved: boolean } | null>( + null, + ); + const vertical = side === "left"; + const coord = (event: React.PointerEvent) => + vertical ? event.clientX : event.clientY; + const clamp = (value: number) => + Math.max(0, Math.min(max, Math.round(value))); + const toggle = () => onResize(size > 0 ? 0 : FINGERPRINT_STRIP); + + return ( +
0 ? "Drag to resize, tap to hide" : "Drag or tap to show"} + className={`group absolute flex touch-none items-center justify-center outline-none select-none ${vertical ? "cursor-col-resize" : "cursor-row-resize"}`} + style={{ left: box.x, top: box.y, width: box.w, height: box.h }} + onPointerDown={(event) => { + event.preventDefault(); + event.currentTarget.focus(); + drag.current = { from: coord(event), size, moved: false }; + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + const current = drag.current; + if (!current) return; + const delta = coord(event) - current.from; + if (!current.moved && Math.abs(delta) < DRAG_START) return; + current.moved = true; + onResize(clamp(current.size + delta)); + }} + onPointerUp={() => { + const current = drag.current; + drag.current = null; + if (!current) return; + if (!current.moved) toggle(); + else if (size < STRIP_SNAP) onResize(0); + }} + onPointerCancel={() => { + drag.current = null; + }} + onKeyDown={(event) => { + const grow = vertical ? "ArrowRight" : "ArrowDown"; + const shrink = vertical ? "ArrowLeft" : "ArrowUp"; + if (event.key === grow) onResize(clamp(size + STRIP_KEY_STEP)); + else if (event.key === shrink) onResize(clamp(size - STRIP_KEY_STEP)); + else if (event.key === "Enter" || event.key === " ") toggle(); + else return; + event.preventDefault(); + }} + > + +
+ ); +} export function FingerprintPlot({ values, @@ -63,6 +171,8 @@ export function FingerprintPlot({ timeBasisLabel, transposed, colormapId, + scale = "day", + margins = false, height: heightOverride, pinLimit = 1, }: FingerprintPlotProps) { @@ -72,9 +182,17 @@ export function FingerprintPlot({ const [width, setWidth] = useState(0); const [hover, setHover] = useState(null); const [pins, setPins] = useState([]); - const [pinnedValues, setPinnedValues] = useState(values); - if (pinnedValues !== values) { - setPinnedValues(values); + const [strips, setStrips] = useState({ + top: FINGERPRINT_STRIP, + left: FINGERPRINT_STRIP, + }); + const grid = useMemo( + () => fingerprintGrid(values, hoursPerDay, selectedYears, scale), + [values, hoursPerDay, selectedYears, scale], + ); + const [pinnedGrid, setPinnedGrid] = useState(grid); + if (pinnedGrid !== grid) { + setPinnedGrid(grid); setPins([]); } const desktop = useDesktop(); @@ -89,21 +207,24 @@ export function FingerprintPlot({ return () => observer.disconnect(); }, []); + const shownStrips = margins ? strips : undefined; const height = - heightOverride ?? - (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT); - const layout = useMemo( - () => - fingerprintLayout( - values, - hoursPerDay, - selectedYears, - { width, height }, - transposed, - ), - [values, hoursPerDay, selectedYears, width, height, transposed], + (heightOverride ?? + (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT)) + + fingerprintStripHeight(shownStrips); + const layout: FingerprintLayout = useMemo( + () => ({ grid, width, height, transposed, margins: shownStrips }), + [grid, width, height, transposed, shownStrips], ); - const extents = useMemo(() => asymmetricExtents(values), [values]); + const dividers = width > 0 ? fingerprintStripDividers(layout) : null; + const resizeStrip = (side: StripSide, size: number) => { + if (strips[side] === size) return; + setStrips((current) => ({ ...current, [side]: size })); + setPins([]); + setHover(null); + }; + const extents = useMemo(() => asymmetricExtents(grid.mean), [grid]); + const nCols = grid.bins.length; useEffect(() => { if (!canvasRef.current) return; @@ -137,13 +258,13 @@ export function FingerprintPlot({ }; }, [colormapId, extents]); - if (layout.nDays === 0) return null; + if (nCols === 0) return null; const handleMove = (event: React.MouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const left = event.clientX - rect.left; const top = event.clientY - rect.top; - const cell = fingerprintCellAt(layout, left, top); + const target = fingerprintTargetAt(layout, left, top); const anchor = anchorBesidePanel( wrapperRef.current, event.clientX, @@ -154,44 +275,103 @@ export function FingerprintPlot({ event.clientX, event.clientY, ); - setHover(cell ? { ...cell, left, top, flip, anchor } : null); + setHover(target ? { target, left, top, flip, anchor } : null); + }; + + const stripPointAt = ( + kind: "column" | "hour", + index: number, + ): TooltipPoint | undefined => { + const stats = + kind === "column" ? grid.margins.byColumn : grid.margins.byHour; + if (index < 0 || index >= stats.mean.length) return undefined; + const count = stats.count[index] ?? 0; + const std = formatSeriesValue(stats.std[index] as number); + if (kind === "column") { + const bin = grid.bins[index]!; + return { + label: formatBinLabel(bin, scale), + shortLabel: formatBinShortLabel(bin, scale), + value: stats.mean[index] as number, + spread: + count > 0 + ? `Std ${std} across ${count} hour${count === 1 ? "" : "s"}` + : undefined, + }; + } + const time = `${String(index).padStart(2, "0")}:00`; + const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; + return { + label: `${time}${suffix}`, + shortLabel: time, + value: stats.mean[index] as number, + spread: + count > 0 ? `Std ${std} across ${binCountLabel(count, scale)}` : undefined, + }; }; const pointAt = ( - dayLocal: number, + col: number, hour: number, short: "time" | "full", ): TooltipPoint | undefined => { - if (dayLocal < 0 || hour < 0) return undefined; - const day = layout.mapping.absoluteDays[dayLocal] ?? dayLocal; + const bin = grid.bins[col]; + if (!bin || hour < 0) return undefined; + const at = col * hoursPerDay + hour; + const count = grid.count[at] ?? 0; const time = `${String(hour).padStart(2, "0")}:00`; - const suffix = timeBasisLabel ? ` ${hoverBasisSuffix(timeBasisLabel)}` : ""; + const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; return { - label: `${formatIsoDate(day)} · ${time}${suffix}`, - shortLabel: short === "time" ? time : `${formatIsoDate(day)} ${time}`, - value: layout.values[dayLocal * hoursPerDay + hour] as number, + label: `${formatBinLabel(bin, scale)} · ${time}${suffix}`, + shortLabel: + short === "time" ? time : `${formatBinShortLabel(bin, scale)} ${time}`, + value: grid.mean[at] as number, + spread: + scale !== "day" && count > 0 + ? `Std ${formatSeriesValue(grid.std[at] as number)} across ${binCountLabel(count, "day")}` + : undefined, }; }; - const previousOf = (cell: FingerprintCell) => - cell.hour > 0 - ? pointAt(cell.dayLocal, cell.hour - 1, "time") - : pointAt(cell.dayLocal - 1, hoursPerDay - 1, "full"); + const pointFor = ({ target }: Hover) => + target.kind === "cell" + ? pointAt(target.col, target.hour, "full")! + : target.kind === "column" + ? stripPointAt("column", target.col)! + : stripPointAt("hour", target.hour)!; + const previousFor = ({ target }: Hover) => { + if (target.kind === "column") return stripPointAt("column", target.col - 1); + if (target.kind === "hour") return stripPointAt("hour", target.hour - 1); + return target.hour > 0 + ? pointAt(target.col, target.hour - 1, "time") + : pointAt(target.col - 1, hoursPerDay - 1, "full"); + }; + // A change against a pin only reads sensibly between like targets. + const comparable = (a: Hover, b: Hover) => a.target.kind === b.target.kind; + // Pins store their target, not the click, so they follow it when the layout changes. + const placedPins = pins.flatMap((pin) => { + const at = fingerprintTargetPoint(layout, pin.target); + return at + ? [{ ...pin, left: at.x, top: at.y, flip: pinFlips(at.x, width) }] + : []; + }); const separate = pinLimit > 1; const pinned = separate ? null : (pins[0] ?? null); const lastPin = pins.at(-1); const shown = pinned ?? hover; + const hoverPoint = + hover && !desktop + ? { ...pointFor(hover), left: hover.left, top: hover.top } + : null; const comparing = - pinned && - hover && - (hover.dayLocal !== pinned.dayLocal || hover.hour !== pinned.hour); + pinned && hover && !sameTarget(hover, pinned) && comparable(hover, pinned); return (
{ if (desktop && hover?.anchor) { setPins((current) => - togglePin(current, hover, pinLimit, sameCell), + togglePin(current, hover, pinLimit, sameTarget), ); } }} /> + {dividers + ? (["top", "left"] as const).map((side) => ( + resizeStrip(side, size)} + /> + )) + : null} {desktop && separate - ? pins.map((pin) => ( + ? placedPins.map((pin) => ( setPins((current) => - current.filter((other) => !sameCell(other, pin)), + current.filter((other) => !sameTarget(other, pin)), ) } /> )) : null} {desktop && separate - ? pins.map((pin) => ( + ? placedPins.map((pin) => (
- {desktop && - separate && - hover?.anchor && - !pins.some((pin) => sameCell(pin, hover)) ? ( - - ) : null} {desktop && !separate && shown?.anchor ? ( setPins([]) : undefined} @@ -302,14 +498,11 @@ export function FingerprintPlot({

- {Math.max(1, layout.nDays).toLocaleString()} days × {hoursPerDay} hours + {binCountLabel(nCols, scale)} × {hoursPerDay} hours {timeBasisLabel ? ` · ${timeBasisLabel}` : ""} {units ? ` · ${units}` : ""} + {margins ? " · strips show the mean ±1 std of each row and column" : ""}

); } - -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..2c217fb 100644 --- a/src/components/plots/MapSideTooltip.tsx +++ b/src/components/plots/MapSideTooltip.tsx @@ -44,6 +44,10 @@ export function anchorBesidePanel( export type PinPosition = { x: number; y: number; flip: boolean }; +export function pinFlips(x: number, plotWidth: number): boolean { + return x + TOOLTIP_GAP + TOOLTIP_ROOM > plotWidth; +} + export function pinPosition( plot: Element, clientX: number, @@ -54,7 +58,7 @@ export function pinPosition( return { x, y: clientY - rect.top, - flip: x + TOOLTIP_GAP + TOOLTIP_ROOM > rect.width, + flip: pinFlips(x, rect.width), }; } @@ -73,7 +77,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 +108,11 @@ function PointRows({ ? `${formatSeriesValue(point.value)}${units ? ` ${units}` : ""}` : "no data"} + {point.spread ? ( + + {point.spread} + + ) : null} {change ? {change} : null} ); @@ -140,7 +154,8 @@ export function PinnedTooltip({ previous?: TooltipPoint; position: PinPosition; units?: string | null; - onClose: () => void; + /** Without it the tooltip is a hover readout and lets the pointer through. */ + onClose?: () => void; }) { return (
, 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 ( @@ -209,24 +253,24 @@ export function TimeSeriesPlot({ hover && shownPoint && !pins.some((pin) => sameDay(pin, hover)) ? ( - ) : null} {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..e2a3db4 100644 --- a/src/hooks/useCellExport.ts +++ b/src/hooks/useCellExport.ts @@ -4,7 +4,8 @@ import { useCallback, useState } from "react"; import { captureFingerprint } from "@/lib/export/captureFingerprint"; import { captureMap } from "@/lib/export/captureMap"; import { captureTimeSeries } from "@/lib/export/captureTimeSeries"; -import { buildSeriesCsv } from "@/lib/export/csv"; +import { buildMarginCsv, buildSeriesCsv } from "@/lib/export/csv"; +import { buildMarginTables, marginColumns } from "@/lib/export/margins"; import { downloadBlob } from "@/lib/export/download"; import { buildSquareFingerprintCanvas } from "@/lib/export/fingerprintSquareLogo"; import { buildReportPdf, type ReportAssets } from "@/lib/export/pdf"; @@ -66,7 +67,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, @@ -115,6 +121,8 @@ export function useCellExport({ }).toDataURL("image/png"); const rows = buildSeriesRows(values, prov); + const margins = buildMarginTables(displayValues ?? values, prov); + const columns = marginColumns(prov); const [pdf, workbook] = await Promise.all([ buildReportPdf({ prov, @@ -122,7 +130,7 @@ export function useCellExport({ values: displayValues ?? values, attribution: mapCapture.attribution, }), - buildSeriesWorkbook(rows, prov), + buildSeriesWorkbook(rows, prov, margins), ]); const archive = await buildZip([ @@ -136,6 +144,28 @@ export function useCellExport({ name: `${base}.csv`, data: new TextEncoder().encode(buildSeriesCsv(rows, prov)), }, + { + name: `${base}_daily_cycle.csv`, + data: new TextEncoder().encode( + buildMarginCsv( + margins.dailyCycle, + columns.dailyCycle, + "mean daily cycle, each hour across all days", + prov, + ), + ), + }, + { + name: `${base}_seasonal_course.csv`, + data: new TextEncoder().encode( + buildMarginCsv( + margins.seasonalCourse, + columns.seasonalCourse, + "seasonal course, each day across its hours", + prov, + ), + ), + }, { name: `${base}_badge.png`, data: dataUrlToBytes(squareDataUrl), diff --git a/src/hooks/useSeriesDisplay.ts b/src/hooks/useSeriesDisplay.ts index f63f606..2a295c6 100644 --- a/src/hooks/useSeriesDisplay.ts +++ b/src/hooks/useSeriesDisplay.ts @@ -16,6 +16,8 @@ import { } 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"; @@ -32,6 +34,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() ?? DEFAULT_PALETTE, ); @@ -71,6 +78,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..0e1b139 100644 --- a/src/lib/export/captureFingerprint.ts +++ b/src/lib/export/captureFingerprint.ts @@ -2,7 +2,11 @@ import { asymmetricExtents, defaultColormapId, } from "@/lib/map/fingerprintScale"; -import { drawFingerprint, fingerprintLayout } from "@/lib/plots/fingerprint"; +import { + drawFingerprint, + FINGERPRINT_STRIP, + fingerprintGrid, +} from "@/lib/plots/fingerprint"; import { canvasToPng, type CapturedImage } from "@/lib/export/capture"; import { fingerprintPngWithLegend } from "@/lib/export/fingerprintImage"; import { @@ -33,13 +37,13 @@ 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, + margins: { top: FINGERPRINT_STRIP, left: FINGERPRINT_STRIP }, + }, { 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/export/csv.ts b/src/lib/export/csv.ts index f331a91..6987346 100644 --- a/src/lib/export/csv.ts +++ b/src/lib/export/csv.ts @@ -1,5 +1,6 @@ import { formatUtcOffset } from "@/lib/zarr/localTime"; import { isoDate, type ExportProvenance } from "./provenance"; +import type { MarginRow } from "./margins"; import type { SeriesRow } from "./rows"; export const CSV_COLUMNS = @@ -74,3 +75,18 @@ export function buildSeriesCsv( return `${lines.join("\n")}\n`; } + +/** One of the fingerprint's strips: mean, std and count along one axis. */ +export function buildMarginCsv( + rows: MarginRow[], + columns: string[], + description: string, + prov: ExportProvenance, +): string { + const lines = buildHeader(rows.length, prov); + lines.push(`# table: ${description}`, columns.join(",")); + for (const row of rows) { + lines.push([row.key, row.mean ?? "", row.std ?? "", row.count].join(",")); + } + return `${lines.join("\n")}\n`; +} diff --git a/src/lib/export/margins.test.ts b/src/lib/export/margins.test.ts new file mode 100644 index 0000000..118a61c --- /dev/null +++ b/src/lib/export/margins.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { buildMarginCsv } from "@/lib/export/csv"; +import { buildMarginTables, marginColumns } from "@/lib/export/margins"; +import { buildProvenance } from "@/lib/export/provenance"; +import { buildMarginSheets } from "@/lib/export/xlsx"; +import { ZARR_TIME } from "@/lib/zarr/timeRange"; +import type { MapSelection } from "@/types/map"; + +const SELECTION: MapSelection = { + click: { lon: 11.5669, lat: 50.9128 }, + grid: { lon: 11.575, lat: 50.925, lonIndex: 3831, latIndex: 780 }, +}; + +function tablesForDays(days: number) { + const hours = ZARR_TIME.hoursPerDay; + const prov = buildProvenance({ + selection: SELECTION, + historyYears: 1, + selectedYears: [2019], + valueCount: days * hours, + units: "gC m-2 d-1", + timeBasis: "utc", + }); + // Each hour's value is the hour itself, plus the day index. + const values = Float32Array.from( + { length: days * hours }, + (_, i) => (i % hours) + Math.floor(i / hours), + ); + values[0] = Number.NaN; + return { prov, tables: buildMarginTables(values, prov) }; +} + +describe("buildMarginTables", () => { + const { prov, tables } = tablesForDays(3); + + it("gives one row per hour across the days", () => { + expect(tables.dailyCycle).toHaveLength(ZARR_TIME.hoursPerDay); + expect(tables.dailyCycle[0]).toEqual({ key: 0, mean: 1.5, std: 0.5, count: 2 }); + expect(tables.dailyCycle[5]).toMatchObject({ key: 5, mean: 6, count: 3 }); + }); + + it("gives one dated row per day across its hours", () => { + expect(tables.seasonalCourse.map((row) => row.key)).toEqual([ + "2019-01-01", + "2019-01-02", + "2019-01-03", + ]); + expect(tables.seasonalCourse[1]).toMatchObject({ mean: 12.5, count: 24 }); + expect(tables.seasonalCourse[0]!.count).toBe(23); + }); + + it("names the clock in the first column", () => { + expect(marginColumns(prov).dailyCycle[0]).toBe("hour_utc"); + expect(marginColumns(prov).seasonalCourse[0]).toBe("date_utc"); + }); + + it("writes CSV with provenance comments and empty cells for gaps", () => { + const text = buildMarginCsv( + [{ key: 0, mean: null, std: null, count: 0 }], + marginColumns(prov).dailyCycle, + "mean daily cycle", + prov, + ); + const lines = text.trim().split("\n"); + expect(lines.at(-3)).toBe("# table: mean daily cycle"); + expect(lines.at(-2)).toBe("hour_utc,mean,std,days"); + expect(lines.at(-1)).toBe("0,,,0"); + }); + + it("lays out a sheet per axis with a bold header", () => { + const sheets = buildMarginSheets(tables, prov); + expect(sheets.dailyCycle).toHaveLength(ZARR_TIME.hoursPerDay + 1); + expect(sheets.seasonalCourse[1]).toEqual(["2019-01-01", 12, expect.any(Number), 23]); + }); +}); diff --git a/src/lib/export/margins.ts b/src/lib/export/margins.ts new file mode 100644 index 0000000..c562402 --- /dev/null +++ b/src/lib/export/margins.ts @@ -0,0 +1,58 @@ +import { formatIsoDate } from "@/lib/map/fingerprintScale"; +import { fingerprintGrid } from "@/lib/plots/fingerprint"; +import type { BinStats } from "@/lib/plots/timeBins"; +import type { ExportProvenance } from "./provenance"; + +export type MarginRow = { + /** Hour of day on the plot's clock, or the date of the column. */ + key: number | string; + mean: number | null; + std: number | null; + /** Days behind an hour's value, or hours behind a day's. */ + count: number; +}; + +export type MarginTables = { + dailyCycle: MarginRow[]; + seasonalCourse: MarginRow[]; +}; + +function finite(value: number | undefined): number | null { + return value !== undefined && Number.isFinite(value) + ? Number(value.toPrecision(7)) + : null; +} + +function rowsOf(stats: BinStats, keyOf: (i: number) => number | string) { + return Array.from(stats.mean, (_, i) => ({ + key: keyOf(i), + mean: finite(stats.mean[i]), + std: finite(stats.std[i]), + count: stats.count[i] ?? 0, + })); +} + +/** + * The fingerprint's strips as tables. `values` is the series as plotted, so + * on the local clock when that is the plot's time basis. + */ +export function buildMarginTables( + values: Float32Array, + prov: ExportProvenance, +): MarginTables { + const grid = fingerprintGrid(values, prov.hoursPerDay, prov.selectedYears); + return { + dailyCycle: rowsOf(grid.margins.byHour, (hour) => hour), + seasonalCourse: rowsOf(grid.margins.byColumn, (col) => + formatIsoDate(grid.bins[col]!.firstDay), + ), + }; +} + +export function marginColumns(prov: ExportProvenance) { + const clock = prov.timeBasis === "utc" ? "utc" : "local"; + return { + dailyCycle: [`hour_${clock}`, "mean", "std", "days"], + seasonalCourse: [`date_${clock}`, "mean", "std", "hours"], + }; +} diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 7225b6c..13a01ce 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -336,7 +336,7 @@ export async function buildReportPdf({ `${prov.dayCount.toLocaleString()} days x ${prov.hoursPerDay} hours - ${formatTimeBasis( prov.timeBasis, prov.utcOffsetHours, - )}${prov.units ? ` - ${prov.units}` : ""}`, + )}${prov.units ? ` - ${prov.units}` : ""} - strips: mean +/-1 std per row and column`, cursor, ); diff --git a/src/lib/export/xlsx.ts b/src/lib/export/xlsx.ts index 4a19ab9..0526a22 100644 --- a/src/lib/export/xlsx.ts +++ b/src/lib/export/xlsx.ts @@ -1,10 +1,13 @@ import type { SheetData } from "write-excel-file/browser"; import { formatUtcOffset } from "@/lib/zarr/localTime"; import { isoDate, type ExportProvenance } from "./provenance"; +import { marginColumns, type MarginTables } from "./margins"; import type { SeriesRow } from "./rows"; export const XLSX_DATA_SHEET = "data"; export const XLSX_METADATA_SHEET = "metadata"; +export const XLSX_DAILY_CYCLE_SHEET = "daily_cycle"; +export const XLSX_SEASONAL_COURSE_SHEET = "seasonal_course"; const BOLD = { fontWeight: "bold" } as const; @@ -83,13 +86,32 @@ export function buildWorkbookSheets( return { data, metadata }; } +/** The fingerprint's strips, one sheet per axis. */ +export function buildMarginSheets( + margins: MarginTables, + prov: ExportProvenance, +): { dailyCycle: SheetData; seasonalCourse: SheetData } { + const columns = marginColumns(prov); + const sheet = (header: string[], rows: MarginTables["dailyCycle"]) => [ + header.map((value) => ({ value, ...BOLD })), + ...rows.map((row) => [row.key, row.mean, row.std, row.count]), + ]; + return { + dailyCycle: sheet(columns.dailyCycle, margins.dailyCycle), + seasonalCourse: sheet(columns.seasonalCourse, margins.seasonalCourse), + }; +} + /** Build the workbook. Imports the xlsx writer lazily, so it stays off the map route's first load. */ export async function buildSeriesWorkbook( rows: SeriesRow[], prov: ExportProvenance, + margins?: MarginTables, ): Promise { const { default: writeXlsxFile } = await import("write-excel-file/browser"); const { data, metadata } = buildWorkbookSheets(rows, prov); + const marginSheets = margins ? buildMarginSheets(margins, prov) : null; + const statColumns = [{ width: 14 }, { width: 14 }, { width: 14 }, { width: 8 }]; const result = await writeXlsxFile([ { @@ -108,6 +130,20 @@ export async function buildSeriesWorkbook( { width: 16 }, ], }, + ...(marginSheets + ? [ + { + data: marginSheets.dailyCycle, + sheet: XLSX_DAILY_CYCLE_SHEET, + columns: statColumns, + }, + { + data: marginSheets.seasonalCourse, + sheet: XLSX_SEASONAL_COURSE_SHEET, + columns: statColumns, + }, + ] + : []), { data: metadata, sheet: XLSX_METADATA_SHEET, 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.test.ts b/src/lib/plots/fingerprint.test.ts new file mode 100644 index 0000000..b1f4057 --- /dev/null +++ b/src/lib/plots/fingerprint.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + fingerprintGrid, + type FingerprintLayout, + type FingerprintTarget, + fingerprintTargetAt, + fingerprintTargetPoint, +} from "@/lib/plots/fingerprint"; + +const HOURS = 24; +const DAYS = 10; +const values = Float32Array.from({ length: DAYS * HOURS }, (_, i) => + Math.sin(i / 5) + (i % HOURS) / 10, +); +const grid = fingerprintGrid(values, HOURS, null); + +const layoutFor = (transposed: boolean): FingerprintLayout => ({ + grid, + width: 640, + height: 400, + transposed, + margins: { top: 56, left: 56 }, +}); + +const targets: FingerprintTarget[] = [ + { kind: "cell", col: 0, hour: 0 }, + { kind: "cell", col: 3, hour: 17 }, + { kind: "cell", col: DAYS - 1, hour: HOURS - 1 }, + { kind: "column", col: 4 }, + { kind: "hour", hour: 9 }, +]; + +describe("fingerprintTargetPoint", () => { + for (const transposed of [false, true]) { + it(`maps back to the same target${transposed ? " when transposed" : ""}`, () => { + const layout = layoutFor(transposed); + for (const target of targets) { + const at = fingerprintTargetPoint(layout, target); + expect(at).not.toBeNull(); + expect(fingerprintTargetAt(layout, at!.x, at!.y)).toEqual(target); + } + }); + } + + it("moves a pinned cell when the plot is transposed", () => { + const target: FingerprintTarget = { kind: "cell", col: 3, hour: 17 }; + expect(fingerprintTargetPoint(layoutFor(true), target)).not.toEqual( + fingerprintTargetPoint(layoutFor(false), target), + ); + }); + + it("has no point on a hidden strip", () => { + const layout = { ...layoutFor(false), margins: { top: 0, left: 56 } }; + expect(fingerprintTargetPoint(layout, { kind: "column", col: 4 })).toBeNull(); + }); +}); diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index 0296353..9d037a2 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -1,76 +1,285 @@ 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 { + bandRange, + type FingerprintMargins, + fingerprintMargins, + niceTicks, +} from "@/lib/plots/fingerprintMargins"; +import { + binHourGrid, + type BinStats, + monthStartTicks, + type BinUnit, + type TimeBin, + timeBins, +} from "@/lib/plots/timeBins"; const AXIS_LEFT_DEFAULT = 34; const AXIS_LEFT_TRANSPOSED = 54; const AXIS_BOTTOM = 18; const AXIS_TOP = 4; const AXIS_RIGHT = 6; +const STRIP_GAP = 10; +const STRIP_LABELS = 44; +// Shorter strips would print their value labels on top of each other. +const STRIP_LABELS_MIN = 32; +const STRIP_INSET = 8; + +export const FINGERPRINT_STRIP = 56; +export const FINGERPRINT_STRIP_MAX = 280; const LABEL_FONT = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; 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; + margins: FingerprintMargins; }; -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); + const { absoluteDays } = getSelectedYearsDayMapping( + selectedYears, + undefined, + nDays, + ); + const bins = timeBins(absoluteDays.slice(0, nDays), unit); + const stats = binHourGrid(values, hoursPerDay, bins); return { - values, + ...stats, + unit, + bins, hoursPerDay, - nDays, - mapping: getSelectedYearsDayMapping(selectedYears, undefined, nDays), - width: size.width, - height: size.height, - transposed, + margins: fingerprintMargins(stats.mean, bins.length, hoursPerDay), }; } -function plotBox({ width, height, transposed }: FingerprintLayout) { - const axisLeft = transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT; +export type FingerprintLayout = { + grid: FingerprintGrid; + width: number; + height: number; + transposed: boolean; + margins?: FingerprintStrips; +}; + +/** Size of each strip in pixels; 0 hides it but keeps its divider. */ +export type FingerprintStrips = { top: number; left: number }; + +const stripSpace = (size: number) => size + STRIP_GAP; + +/** Extra height the strips need, so the cells keep their size. */ +export function fingerprintStripHeight(margins?: FingerprintStrips): number { + return margins ? stripSpace(margins.top) : 0; +} + +function plotBox({ width, height, transposed, margins }: FingerprintLayout) { + const labels = transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT; + const axisLeft = margins + ? stripSpace(margins.left) + + Math.max(labels, margins.top > 0 ? STRIP_LABELS : 0) + : labels; + const top = AXIS_TOP + (margins ? stripSpace(margins.top) : 0); return { axisLeft, + top, plotW: Math.max(1, width - axisLeft - AXIS_RIGHT), - plotH: Math.max(1, height - AXIS_TOP - AXIS_BOTTOM), + plotH: Math.max(1, height - top - AXIS_BOTTOM), + }; +} + +/** The gap between each strip and the cells, where its divider sits. */ +export function fingerprintStripDividers( + layout: FingerprintLayout, +): { top: StripBox; left: StripBox } | null { + const { margins } = layout; + if (!margins) return null; + const { axisLeft, top, plotW, plotH } = plotBox(layout); + return { + top: { x: axisLeft, y: AXIS_TOP + margins.top, w: plotW, h: STRIP_GAP }, + left: { x: margins.left, y: top, w: STRIP_GAP, h: plotH }, }; } -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; +} + +type StripBox = { x: number; y: number; w: number; h: number }; + +function stripBoxes( + margins: FingerprintStrips, + { axisLeft, top, plotW, plotH }: ReturnType, +): { top: StripBox; left: StripBox } { + return { + top: { x: axisLeft, y: AXIS_TOP, w: plotW, h: margins.top }, + left: { + x: STRIP_INSET, + y: top, + w: Math.max(0, margins.left - STRIP_INSET), + h: plotH, + }, + }; +} + +function stripValuePos(stats: BinStats, box: StripBox, alongX: boolean) { + const [lo, hi] = bandRange(stats); + return (value: number) => + alongX + ? box.y + box.h - ((value - lo) / (hi - lo)) * box.h + : box.x + ((value - lo) / (hi - lo)) * box.w; +} + +function formatTick(value: number): string { + return String(Number(value.toFixed(2))); +} + +/** + * One mean line with a ±std band. `alongX` strips run with the columns and + * grow upwards; the others run with the rows and grow to the right. + */ +function drawStrip( + ctx: CanvasRenderingContext2D, + stats: BinStats, + centre: (i: number) => number, + box: StripBox, + alongX: boolean, + colors: { accent: string; grid: string }, +) { + const [lo, hi] = bandRange(stats); + const valuePos = stripValuePos(stats, box, alongX); + const point = (i: number, value: number): [number, number] => + alongX ? [centre(i), valuePos(value)] : [valuePos(value), centre(i)]; + const ticks = niceTicks(lo, hi); + + ctx.save(); + ctx.strokeStyle = colors.grid; + ctx.lineWidth = 1; + for (const tick of ticks) { + const at = Math.round(valuePos(tick)) + 0.5; + ctx.beginPath(); + if (alongX) { + ctx.moveTo(box.x, at); + ctx.lineTo(box.x + box.w, at); + } else { + ctx.moveTo(at, box.y); + ctx.lineTo(at, box.y + box.h); + } + ctx.stroke(); + } + + const runs: number[][] = []; + let run: number[] = []; + for (let i = 0; i < stats.mean.length; i++) { + if (Number.isFinite(stats.mean[i])) { + run.push(i); + } else if (run.length > 0) { + runs.push(run); + run = []; + } + } + if (run.length > 0) runs.push(run); + + ctx.fillStyle = colors.accent; + ctx.globalAlpha = 0.2; + for (const indices of runs) { + ctx.beginPath(); + indices.forEach((i, k) => { + const [x, y] = point(i, stats.mean[i]! + stats.std[i]!); + if (k === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + for (const i of [...indices].reverse()) { + ctx.lineTo(...point(i, stats.mean[i]! - stats.std[i]!)); + } + ctx.closePath(); + ctx.fill(); + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = colors.accent; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + for (const indices of runs) { + ctx.beginPath(); + indices.forEach((i, k) => { + const [x, y] = point(i, stats.mean[i]!); + if (k === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + if (indices.length === 1) { + const [x, y] = point(indices[0]!, stats.mean[indices[0]!]!); + ctx.lineTo(alongX ? x + 1 : x, alongX ? y : y + 1); + } + ctx.stroke(); + } + ctx.restore(); + + return ticks.map((tick) => ({ label: formatTick(tick), at: valuePos(tick) })); } function clampLabelX(x: number, plotW: number, axisLeft: number): number { @@ -85,16 +294,20 @@ export function drawFingerprint( extents, isLight, pixelRatio, + accent = isLight ? "#006c66" : "#52d4c8", }: { colormapId: ColormapId; extents: FingerprintExtents; isLight: boolean; pixelRatio: number; + /** Colour of the strips' mean line and band. */ + accent?: string; }, ): 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); @@ -104,80 +317,90 @@ export function drawFingerprint( ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); 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 { axisLeft, top, plotW, plotH } = plotBox(layout); + 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 upper = snap(y0); + ctx.fillRect(left, upper, snap(x1) - left, snap(y1) - upper); + }; const dividerColor = isLight ? "rgba(0, 0, 0, 0.25)" : "rgba(255, 255, 255, 0.25)"; 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)); + top + Math.floor((fromTop * plotH) / hoursPerDay); + 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, top); + ctx.lineTo(bx, 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, + top + start, + Math.max(xLeft + 1, xRight), + 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 = top + Math.floor((start / nSel) * plotH); + ctx.beginPath(); + ctx.moveTo(axisLeft, by); + ctx.lineTo(axisLeft + plotW, by); + ctx.stroke(); } ctx.restore(); } @@ -186,12 +409,69 @@ 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 { margins } = layout; + if (margins) { + const colors = { + accent, + grid: isLight ? "rgba(0, 0, 0, 0.08)" : "rgba(255, 255, 255, 0.08)", + }; + const { top: topBox, left: leftBox } = stripBoxes(margins, { + axisLeft, + top, + plotW, + plotH, + }); + const colCentre = (along: number, length: number) => (col: number) => + along + ((col + 0.5) / nSel) * length; + const hourCentre = (hour: number) => + transposed + ? axisLeft + ((hour + 0.5) * plotW) / hoursPerDay + : top + ((hoursPerDay - 0.5 - hour) * plotH) / hoursPerDay; + + const topTicks = + margins.top > 0 + ? drawStrip( + ctx, + transposed ? grid.margins.byHour : grid.margins.byColumn, + transposed ? hourCentre : colCentre(axisLeft, plotW), + topBox, + true, + colors, + ) + : []; + const leftTicks = + margins.left > 0 + ? drawStrip( + ctx, + transposed ? grid.margins.byColumn : grid.margins.byHour, + transposed ? colCentre(top, plotH) : hourCentre, + leftBox, + false, + colors, + ) + : []; + const labelled = (size: number, ticks: typeof topTicks) => + size >= STRIP_LABELS_MIN ? ticks : []; + + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const { label, at } of labelled(margins.top, topTicks)) { + ctx.fillText(label, axisLeft - 6, at); + } + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const { label, at } of labelled(margins.left, leftTicks)) { + const x = Math.max(leftBox.x + 10, Math.min(leftBox.x + leftBox.w - 10, at)); + ctx.fillText(label, x, top + plotH + 4); + } + } + + const ticks = columnAxisTicks(layout); + const colFrac = (at: number) => at / nSel; if (!transposed) { const rowY = (fromTop: number) => - AXIS_TOP + (fromTop * plotH) / hoursPerDay; + top + (fromTop * plotH) / hoursPerDay; ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (const hour of FINGERPRINT_HOUR_TICKS) { @@ -204,71 +484,134 @@ 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, ); - ctx.fillText(label, x, AXIS_TOP + plotH + 4); + ctx.fillText(label, x, top + plotH + 4); } } 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 = top + colFrac(at) * plotH; ctx.fillText( label, axisLeft - 6, - Math.max(AXIS_TOP + 6, Math.min(AXIS_TOP + plotH - 6, y)), + Math.max(top + 6, Math.min(top + plotH - 6, y)), ); } ctx.textAlign = "center"; ctx.textBaseline = "top"; for (const hour of FINGERPRINT_HOUR_TICKS) { const x = axisLeft + ((hour + 0.5) * plotW) / hoursPerDay; - ctx.fillText(String(hour), x, AXIS_TOP + plotH + 4); + ctx.fillText(String(hour), x, top + plotH + 4); } } } -export type FingerprintCell = { - dayLocal: number; - hour: number; - absoluteDay: number; - value: number; -}; +/** What sits under the pointer: a cell, or a point on one of the strips. */ +export type FingerprintTarget = + | { kind: "cell"; col: number; hour: number } + | { kind: "column"; col: number } + | { kind: "hour"; hour: number }; -export function fingerprintCellAt( +export function fingerprintTargetAt( layout: FingerprintLayout, x: number, y: number, -): FingerprintCell | null { - const { values, hoursPerDay, nDays, mapping, transposed } = layout; - const { axisLeft, plotW, plotH } = plotBox(layout); +): FingerprintTarget | null { + const { grid, transposed, margins } = layout; + const { hoursPerDay } = grid; + const { axisLeft, top, 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 inY = y - top; + const nSel = Math.max(1, grid.bins.length); + const colHi = Math.max(0, grid.bins.length - 1); + const colAt = (along: number, length: number) => + Math.min(colHi, Math.floor((along / length) * nSel)); + const hourAt = (along: number, length: number) => + Math.min(hoursPerDay - 1, Math.floor((along / length) * hoursPerDay)); + const inCellsX = inX >= 0 && inX < plotW; + const inCellsY = inY >= 0 && inY < plotH; - const nSel = Math.max(1, nDays); - const dayHi = Math.max(0, nDays - 1); - let dayLocal: number; - let hour: number; - 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)); + if (inCellsX && inCellsY) { + return transposed + ? { kind: "cell", col: colAt(inY, plotH), hour: hourAt(inX, plotW) } + : { + kind: "cell", + col: colAt(inX, plotW), + hour: hoursPerDay - 1 - hourAt(inY, plotH), + }; } + if (!margins) return null; - return { - dayLocal, - hour, - absoluteDay: mapping.absoluteDays[dayLocal] ?? dayLocal, - value: values[dayLocal * hoursPerDay + hour] as number, - }; + const inTopStrip = inCellsX && y >= AXIS_TOP && y < AXIS_TOP + margins.top; + const inLeftStrip = inCellsY && x >= 0 && x < margins.left; + if (inTopStrip) { + return transposed + ? { kind: "hour", hour: hourAt(inX, plotW) } + : { kind: "column", col: colAt(inX, plotW) }; + } + if (inLeftStrip) { + return transposed + ? { kind: "column", col: colAt(inY, plotH) } + : { kind: "hour", hour: hoursPerDay - 1 - hourAt(inY, plotH) }; + } + return null; +} + +/** The inverse of `fingerprintTargetAt`; null when the target's strip is hidden. */ +export function fingerprintTargetPoint( + layout: FingerprintLayout, + target: FingerprintTarget, +): { x: number; y: number } | null { + const { grid, transposed, margins } = layout; + const { hoursPerDay } = grid; + const box = plotBox(layout); + const { axisLeft, top, plotW, plotH } = box; + const nSel = Math.max(1, grid.bins.length); + const colAlong = (col: number, length: number) => + ((col + 0.5) / nSel) * length; + const hourAlong = (hour: number, length: number) => + ((hour + 0.5) / hoursPerDay) * length; + + if (target.kind === "cell") { + return transposed + ? { + x: axisLeft + hourAlong(target.hour, plotW), + y: top + colAlong(target.col, plotH), + } + : { + x: axisLeft + colAlong(target.col, plotW), + y: top + hourAlong(hoursPerDay - 1 - target.hour, plotH), + }; + } + if (!margins) return null; + + const strips = stripBoxes(margins, box); + const byColumn = target.kind === "column"; + const index = byColumn ? target.col : target.hour; + const stats = byColumn ? grid.margins.byColumn : grid.margins.byHour; + const onTop = byColumn !== transposed; + const strip = onTop ? strips.top : strips.left; + if (strip.w <= 0 || strip.h <= 0) return null; + + const mean = stats.mean[index] as number; + const across = Number.isFinite(mean) + ? stripValuePos(stats, strip, onTop)(mean) + : onTop + ? strip.y + strip.h / 2 + : strip.x + strip.w / 2; + const along = byColumn + ? colAlong(index, onTop ? plotW : plotH) + : onTop + ? hourAlong(index, plotW) + : hourAlong(hoursPerDay - 1 - index, plotH); + + return onTop + ? { x: axisLeft + along, y: across } + : { x: across, y: top + along }; } diff --git a/src/lib/plots/fingerprintMargins.test.ts b/src/lib/plots/fingerprintMargins.test.ts new file mode 100644 index 0000000..d1b9dc9 --- /dev/null +++ b/src/lib/plots/fingerprintMargins.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + bandRange, + fingerprintMargins, + niceTicks, +} from "@/lib/plots/fingerprintMargins"; + +describe("fingerprintMargins", () => { + // Three columns of two hours: [1, 3], [5, NaN], [NaN, NaN] + const cells = new Float32Array([1, 3, 5, NaN, NaN, NaN]); + const { byColumn, byHour } = fingerprintMargins(cells, 3, 2); + + it("summarises each column over its hours", () => { + expect(Array.from(byColumn.mean.slice(0, 2))).toEqual([2, 5]); + expect(Array.from(byColumn.std.slice(0, 2))).toEqual([1, 0]); + expect(Array.from(byColumn.count)).toEqual([2, 1, 0]); + expect(byColumn.mean[2]).toBeNaN(); + }); + + it("summarises each hour over the columns, skipping gaps", () => { + expect(Array.from(byHour.mean)).toEqual([3, 3]); + expect(Array.from(byHour.std)).toEqual([2, 0]); + expect(Array.from(byHour.count)).toEqual([2, 1]); + }); +}); + +describe("bandRange", () => { + it("covers the band and zero", () => { + const stats = { + mean: new Float32Array([2, 4, NaN]), + std: new Float32Array([1, 1, NaN]), + count: new Uint32Array([1, 1, 0]), + }; + expect(bandRange(stats)).toEqual([0, 5]); + }); + + it("opens up a flat range", () => { + const stats = { + mean: new Float32Array([0]), + std: new Float32Array([0]), + count: new Uint32Array([1]), + }; + expect(bandRange(stats)).toEqual([-1, 1]); + }); +}); + +describe("niceTicks", () => { + it("picks round steps that include zero", () => { + expect(niceTicks(-12.3, 4.1)).toEqual([-10, 0]); + expect(niceTicks(-12.3, 4.1, 5)).toEqual([-10, -5, 0]); + expect(niceTicks(0, 1)).toEqual([0, 0.5, 1]); + expect(niceTicks(-0.3, 0.2)).toEqual([-0.25, 0]); + expect(niceTicks(-9, 1)).toEqual([-5, 0]); + expect(niceTicks(-17, 6)).toEqual([-10, 0]); + }); +}); diff --git a/src/lib/plots/fingerprintMargins.ts b/src/lib/plots/fingerprintMargins.ts new file mode 100644 index 0000000..78d6cd6 --- /dev/null +++ b/src/lib/plots/fingerprintMargins.ts @@ -0,0 +1,77 @@ +import { + type BinStats, + emptyStats, + RunningStats, +} from "@/lib/plots/timeBins"; + +export type FingerprintMargins = { + /** One entry per column, over its hours: the seasonal course. */ + byColumn: BinStats; + /** One entry per hour, over the columns: the mean daily cycle. */ + byHour: BinStats; +}; + +/** Mean and std along each axis of the cells as drawn. */ +export function fingerprintMargins( + cells: ArrayLike, + nCols: number, + hoursPerDay: number, +): FingerprintMargins { + const byColumn = emptyStats(nCols); + for (let col = 0; col < nCols; col++) { + const acc = new RunningStats(); + for (let hour = 0; hour < hoursPerDay; hour++) { + acc.add(cells[col * hoursPerDay + hour]); + } + acc.write(byColumn, col); + } + + const byHour = emptyStats(hoursPerDay); + for (let hour = 0; hour < hoursPerDay; hour++) { + const acc = new RunningStats(); + for (let col = 0; col < nCols; col++) { + acc.add(cells[col * hoursPerDay + hour]); + } + acc.write(byHour, hour); + } + + return { byColumn, byHour }; +} + +/** Range covering mean ± std, widened to include zero. */ +export function bandRange(stats: BinStats): [number, number] { + let lo = 0; + let hi = 0; + for (let i = 0; i < stats.mean.length; i++) { + const mean = stats.mean[i]!; + const std = stats.std[i]!; + if (!Number.isFinite(mean)) continue; + lo = Math.min(lo, mean - std); + hi = Math.max(hi, mean + std); + } + return lo === hi ? [lo - 1, hi + 1] : [lo, hi]; +} + +/** Round tick values inside `[lo, hi]`, at least two, always with zero. */ +export function niceTicks(lo: number, hi: number, count = 3): number[] { + const rough = (hi - lo) / Math.max(1, count - 1); + const exponent = Math.floor(Math.log10(rough)); + const steps: number[] = []; + for (let e = exponent + 1; e >= exponent - 2; e--) { + for (const m of [5, 2.5, 2, 1]) steps.push(m * 10 ** e); + } + const first = steps.findLastIndex((step) => step >= rough); + for (const step of steps.slice(Math.max(0, first))) { + const ticks = ticksEvery(lo, hi, step); + if (ticks.length >= 2) return ticks; + } + return [0]; +} + +function ticksEvery(lo: number, hi: number, step: number): number[] { + const ticks: number[] = []; + for (let t = Math.ceil(lo / step) * step; t <= hi + step * 1e-9; t += step) { + ticks.push(Math.abs(t) < step * 1e-9 ? 0 : Number(t.toPrecision(12))); + } + return ticks; +} 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..bfa65cc --- /dev/null +++ b/src/lib/plots/timeBins.ts @@ -0,0 +1,215 @@ +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; +}; + +export function emptyStats(length: number): BinStats { + return { + mean: new Float32Array(length), + std: new Float32Array(length), + count: new Uint32Array(length), + }; +} + +/** Running NaN-aware mean and population std. */ +export class RunningStats { + 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)); + } +} + +/** + * 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 RunningStats(); + 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 RunningStats(); + 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 RunningStats(); + 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