From 990b2578a1909d4733d78672ea21582cba06fb85 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Thu, 15 Oct 2026 22:01:58 +0300 Subject: [PATCH 1/8] Enhance PlotControls and FingerprintPlot components with scale selection and improved data. --- src/components/data/DataView.tsx | 8 +- src/components/panels/CellView.tsx | 13 +- src/components/panels/ScaleField.tsx | 58 ++++++ src/components/plots/FingerprintPlot.tsx | 120 +++++++----- src/components/plots/MapSideTooltip.tsx | 12 +- src/components/plots/TimeSeriesPlot.tsx | 105 +++++++--- src/components/ui/segmented-tabs.tsx | 16 +- src/hooks/useCellExport.ts | 7 +- src/hooks/useSeriesDisplay.ts | 12 ++ src/lib/constants/tour.ts | 2 +- src/lib/export/captureFingerprint.ts | 15 +- src/lib/export/captureTimeSeries.tsx | 3 + src/lib/plots/chartTheme.ts | 2 +- src/lib/plots/fingerprint.ts | 239 ++++++++++++++--------- src/lib/plots/lineSeries.test.ts | 63 ++++++ src/lib/plots/lineSeries.ts | 142 ++++++++++++++ src/lib/plots/timeBins.test.ts | 146 ++++++++++++++ src/lib/plots/timeBins.ts | 214 ++++++++++++++++++++ src/lib/zarr/localTime.ts | 5 + 19 files changed, 980 insertions(+), 202 deletions(-) create mode 100644 src/components/panels/ScaleField.tsx create mode 100644 src/lib/plots/lineSeries.test.ts create mode 100644 src/lib/plots/lineSeries.ts create mode 100644 src/lib/plots/timeBins.test.ts create mode 100644 src/lib/plots/timeBins.ts 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..17d7209 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,6 +205,18 @@ 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 +256,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 +266,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 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..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 From b0d947969f5e365f42d28f9323e69657571c8855 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sat, 17 Oct 2026 14:21:44 +0300 Subject: [PATCH 2/8] Draw the hourly line thinner and translucent At year scale the hourly points sit a few per pixel, so a 1.5px line merges into a solid block. A 0.5px line at 70% opacity lets the overlap show. --- src/components/plots/TimeSeriesPlot.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/plots/TimeSeriesPlot.tsx b/src/components/plots/TimeSeriesPlot.tsx index 17d7209..778e1f8 100644 --- a/src/components/plots/TimeSeriesPlot.tsx +++ b/src/components/plots/TimeSeriesPlot.tsx @@ -221,7 +221,8 @@ export function TimeSeriesPlot({ type="linear" dataKey="value" stroke="var(--color-value)" - strokeWidth={1.5} + strokeWidth={resolution === "hour" ? 0.5 : 1.5} + strokeOpacity={resolution === "hour" ? 0.7 : 1} dot={false} isAnimationActive={false} /> From 04ed6a76412893f952ec6097ee865715af3b3f92 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sat, 17 Oct 2026 13:50:24 +0300 Subject: [PATCH 3/8] Add margins support to DataView, CellView, and FingerprintPlot components --- src/components/data/DataView.tsx | 1 + src/components/panels/CellView.tsx | 10 +- src/components/plots/FingerprintPlot.tsx | 87 +++++++- src/hooks/useCellExport.ts | 29 ++- src/lib/export/captureFingerprint.ts | 1 + src/lib/export/csv.ts | 16 ++ src/lib/export/margins.test.ts | 75 +++++++ src/lib/export/margins.ts | 58 +++++ src/lib/export/pdf.ts | 2 +- src/lib/export/xlsx.ts | 36 +++ src/lib/plots/fingerprint.ts | 269 +++++++++++++++++++---- src/lib/plots/fingerprintMargins.test.ts | 56 +++++ src/lib/plots/fingerprintMargins.ts | 77 +++++++ src/lib/plots/timeBins.ts | 25 ++- 14 files changed, 674 insertions(+), 68 deletions(-) create mode 100644 src/lib/export/margins.test.ts create mode 100644 src/lib/export/margins.ts create mode 100644 src/lib/plots/fingerprintMargins.test.ts create mode 100644 src/lib/plots/fingerprintMargins.ts diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index fd1380d..5c90888 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -138,6 +138,7 @@ export function DataView({ display={display} height={plotHeight} pinLimit={2} + margins /> diff --git a/src/components/panels/CellView.tsx b/src/components/panels/CellView.tsx index 36fd14b..3f9c82f 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -166,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 (
@@ -220,6 +227,7 @@ export function PlotArea({ series, display, height, pinLimit }: PlotAreaProps) { transposed={display.transposed} colormapId={display.colormapId} scale={display.fingerprintScale} + margins={margins} height={height} pinLimit={pinLimit} /> diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index b990711..c6ca509 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -13,10 +13,10 @@ import { } from "@/lib/plots/chartTheme"; import { drawFingerprint, - type FingerprintCell, - fingerprintCellAt, fingerprintGrid, type FingerprintLayout, + FINGERPRINT_STRIP_HEIGHT, + fingerprintTargetAt, } from "@/lib/plots/fingerprint"; import { binCountLabel, @@ -49,10 +49,19 @@ type FingerprintPlotProps = { transposed: boolean; colormapId: ColormapId; scale?: BinUnit; + margins?: boolean; height?: number; pinLimit?: number; }; +type FingerprintCell = { col: number; hour: number }; + +type StripHover = { + kind: "column" | "hour"; + index: number; + anchor: TooltipAnchor; +}; + type Hover = FingerprintCell & { left: number; top: number; @@ -71,6 +80,7 @@ export function FingerprintPlot({ transposed, colormapId, scale = "day", + margins = false, height: heightOverride, pinLimit = 1, }: FingerprintPlotProps) { @@ -79,6 +89,7 @@ export function FingerprintPlot({ const canvasRef = useRef(null); const [width, setWidth] = useState(0); const [hover, setHover] = useState(null); + const [stripHover, setStripHover] = useState(null); const [pins, setPins] = useState([]); const grid = useMemo( () => fingerprintGrid(values, hoursPerDay, selectedYears, scale), @@ -102,11 +113,12 @@ export function FingerprintPlot({ }, []); const height = - heightOverride ?? - (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT); + (heightOverride ?? + (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT)) + + (margins ? FINGERPRINT_STRIP_HEIGHT : 0); const layout: FingerprintLayout = useMemo( - () => ({ grid, width, height, transposed }), - [grid, width, height, transposed], + () => ({ grid, width, height, transposed, margins }), + [grid, width, height, transposed, margins], ); const extents = useMemo(() => asymmetricExtents(grid.mean), [grid]); const nCols = grid.bins.length; @@ -149,7 +161,7 @@ export function FingerprintPlot({ 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, @@ -160,7 +172,52 @@ export function FingerprintPlot({ event.clientX, event.clientY, ); - setHover(cell ? { ...cell, left, top, flip, anchor } : null); + setHover( + target?.kind === "cell" + ? { col: target.col, hour: target.hour, left, top, flip, anchor } + : null, + ); + setStripHover( + anchor && target && target.kind !== "cell" + ? { + kind: target.kind, + index: target.kind === "column" ? target.col : target.hour, + anchor, + } + : null, + ); + }; + + const stripPointAt = ( + kind: StripHover["kind"], + 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 = ( @@ -216,7 +273,10 @@ export function FingerprintPlot({ role="img" aria-label="Diurnal fingerprint heatmap" onMouseMove={handleMove} - onMouseLeave={() => setHover(null)} + onMouseLeave={() => { + setHover(null); + setStripHover(null); + }} onClick={() => { if (desktop && hover?.anchor) { setPins((current) => @@ -286,6 +346,14 @@ export function FingerprintPlot({ units={units} /> ) : null} + {desktop && stripHover ? ( + + ) : null} {desktop && !separate && shown?.anchor ? (
); diff --git a/src/hooks/useCellExport.ts b/src/hooks/useCellExport.ts index 6c7e5b1..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"; @@ -120,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, @@ -127,7 +130,7 @@ export function useCellExport({ values: displayValues ?? values, attribution: mapCapture.attribution, }), - buildSeriesWorkbook(rows, prov), + buildSeriesWorkbook(rows, prov, margins), ]); const archive = await buildZip([ @@ -141,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/lib/export/captureFingerprint.ts b/src/lib/export/captureFingerprint.ts index a9c4607..b51799c 100644 --- a/src/lib/export/captureFingerprint.ts +++ b/src/lib/export/captureFingerprint.ts @@ -38,6 +38,7 @@ export function captureFingerprint({ width: EXPORT_PLOT_WIDTH, height: EXPORT_PLOT_HEIGHT, transposed: false, + margins: true, }, { colormapId, 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/fingerprint.ts b/src/lib/plots/fingerprint.ts index 56c30bc..f9f23c4 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -6,6 +6,12 @@ import { } from "@/lib/map/fingerprintScale"; 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, @@ -20,6 +26,9 @@ const AXIS_LEFT_TRANSPOSED = 54; const AXIS_BOTTOM = 18; const AXIS_TOP = 4; const AXIS_RIGHT = 6; +const STRIP = 56; +const STRIP_GAP = 10; +const STRIP_LABELS = 44; const LABEL_FONT = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; @@ -31,6 +40,7 @@ export type FingerprintGrid = BinStats & { unit: BinUnit; bins: TimeBin[]; hoursPerDay: number; + margins: FingerprintMargins; }; export function fingerprintGrid( @@ -46,7 +56,14 @@ export function fingerprintGrid( nDays, ); const bins = timeBins(absoluteDays.slice(0, nDays), unit); - return { ...binHourGrid(values, hoursPerDay, bins), unit, bins, hoursPerDay }; + const stats = binHourGrid(values, hoursPerDay, bins); + return { + ...stats, + unit, + bins, + hoursPerDay, + margins: fingerprintMargins(stats.mean, bins.length, hoursPerDay), + }; } export type FingerprintLayout = { @@ -54,14 +71,24 @@ export type FingerprintLayout = { width: number; height: number; transposed: boolean; + /** Draw mean ± std strips along both axes. */ + margins?: boolean; }; -function plotBox({ width, height, transposed }: FingerprintLayout) { - const axisLeft = transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT; +/** Extra height the strips need, so the cells keep their size. */ +export const FINGERPRINT_STRIP_HEIGHT = STRIP + STRIP_GAP; + +function plotBox({ width, height, transposed, margins }: FingerprintLayout) { + const labels = transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT; + const axisLeft = margins + ? STRIP + STRIP_GAP + Math.max(labels, STRIP_LABELS) + : labels; + const top = margins ? AXIS_TOP + STRIP + STRIP_GAP : AXIS_TOP; 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), }; } @@ -117,6 +144,99 @@ function pixelRuns(length: number, nSel: number, colHi: number) { return runs; } +type StripBox = { x: number; y: number; w: number; h: number }; + +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 = (value: number) => + alongX + ? box.y + box.h - ((value - lo) / (hi - lo)) * box.h + : box.x + ((value - lo) / (hi - lo)) * box.w; + 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 { return Math.max(axisLeft + 12, Math.min(axisLeft + plotW - 12, x)); } @@ -129,11 +249,14 @@ 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 { grid, width, height, transposed } = layout; @@ -149,7 +272,7 @@ export function drawFingerprint( ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.clearRect(0, 0, width, height); - const { axisLeft, plotW, plotH } = plotBox(layout); + 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); @@ -164,8 +287,8 @@ export function drawFingerprint( 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 upper = snap(y0); + ctx.fillRect(left, upper, snap(x1) - left, snap(y1) - upper); }; const dividerColor = isLight ? "rgba(0, 0, 0, 0.25)" @@ -173,7 +296,7 @@ export function drawFingerprint( if (!transposed) { const rowY = (fromTop: number) => - AXIS_TOP + Math.floor((fromTop * plotH) / hoursPerDay); + 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(col, hour); @@ -198,8 +321,8 @@ export function drawFingerprint( 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.moveTo(bx, top); + ctx.lineTo(bx, top + plotH); ctx.stroke(); } ctx.restore(); @@ -216,9 +339,9 @@ export function drawFingerprint( ctx.fillStyle = color; fillCell( xLeft, - AXIS_TOP + start, + top + start, Math.max(xLeft + 1, xRight), - AXIS_TOP + start + size, + top + start + size, ); } } @@ -228,7 +351,7 @@ export function drawFingerprint( ctx.strokeStyle = dividerColor; ctx.lineWidth = 1; for (const start of breaks) { - const by = AXIS_TOP + Math.floor((start / nSel) * plotH); + const by = top + Math.floor((start / nSel) * plotH); ctx.beginPath(); ctx.moveTo(axisLeft, by); ctx.lineTo(axisLeft + plotW, by); @@ -241,12 +364,56 @@ export function drawFingerprint( ctx.fillStyle = chartTickColor(isLight); ctx.font = LABEL_FONT; + if (layout.margins) { + const colors = { + accent, + grid: isLight ? "rgba(0, 0, 0, 0.08)" : "rgba(255, 255, 255, 0.08)", + }; + const topBox = { x: axisLeft, y: AXIS_TOP, w: plotW, h: STRIP }; + const leftBox = { x: 0, y: top, w: STRIP, h: 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 = drawStrip( + ctx, + transposed ? grid.margins.byHour : grid.margins.byColumn, + transposed ? hourCentre : colCentre(axisLeft, plotW), + topBox, + true, + colors, + ); + const leftTicks = drawStrip( + ctx, + transposed ? grid.margins.byColumn : grid.margins.byHour, + transposed ? colCentre(top, plotH) : hourCentre, + leftBox, + false, + colors, + ); + + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const { label, at } of topTicks) { + ctx.fillText(label, axisLeft - 6, at); + } + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const { label, at } of 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) { @@ -265,59 +432,75 @@ export function drawFingerprint( 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 { at, label } of ticks) { - const y = AXIS_TOP + colFrac(at) * plotH; + 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 = { - col: number; - hour: 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 { grid, transposed } = layout; +): FingerprintTarget | null { + const { grid, transposed, margins } = layout; const { hoursPerDay } = grid; - const { axisLeft, plotW, plotH } = plotBox(layout); + 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 nCols = grid.bins.length; - const nSel = Math.max(1, nCols); - const colHi = Math.max(0, nCols - 1); - if (!transposed) { - return { - col: Math.min(colHi, Math.floor((inX / plotW) * nSel)), - hour: - hoursPerDay - - 1 - - Math.min(hoursPerDay - 1, Math.floor((inY / plotH) * hoursPerDay)), - }; + 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), + }; } - return { - col: Math.min(colHi, Math.floor((inY / plotH) * nSel)), - hour: Math.min(hoursPerDay - 1, Math.floor((inX / plotW) * hoursPerDay)), - }; + if (!margins) return null; + + const inTopStrip = inCellsX && y >= AXIS_TOP && y < AXIS_TOP + STRIP; + const inLeftStrip = inCellsY && x >= 0 && x < STRIP; + 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; } 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/timeBins.ts b/src/lib/plots/timeBins.ts index 067c9a7..bfa65cc 100644 --- a/src/lib/plots/timeBins.ts +++ b/src/lib/plots/timeBins.ts @@ -77,7 +77,16 @@ export type BinStats = { count: Uint32Array; }; -class Accumulator { +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; @@ -103,14 +112,6 @@ class Accumulator { } } -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. @@ -123,7 +124,7 @@ export function binHourGrid( const stats = emptyStats(bins.length * hoursPerDay); bins.forEach((bin, b) => { for (let hour = 0; hour < hoursPerDay; hour++) { - const acc = new Accumulator(); + const acc = new RunningStats(); for (let day = bin.startLocal; day <= bin.endLocal; day++) { acc.add(values[day * hoursPerDay + hour]); } @@ -146,7 +147,7 @@ export function binLineStats( ): BinStats { const stats = emptyStats(bins.length); const dayMean = (day: number) => { - const acc = new Accumulator(); + const acc = new RunningStats(); for (let hour = 0; hour < hoursPerDay; hour++) { acc.add(values[day * hoursPerDay + hour]); } @@ -154,7 +155,7 @@ export function binLineStats( }; bins.forEach((bin, b) => { - const acc = new Accumulator(); + const acc = new RunningStats(); for (let day = bin.startLocal; day <= bin.endLocal; day++) { if (unit === "day") { for (let hour = 0; hour < hoursPerDay; hour++) { From c8f444ab390e399e2a4a417023578bd47dce5e53 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sat, 17 Oct 2026 14:02:42 +0300 Subject: [PATCH 4/8] Refactor FingerprintPlot component to improve hover handling and target identification. --- src/components/plots/FingerprintPlot.tsx | 111 +++++++++-------------- 1 file changed, 45 insertions(+), 66 deletions(-) diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index c6ca509..a88b9b6 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -16,6 +16,7 @@ import { fingerprintGrid, type FingerprintLayout, FINGERPRINT_STRIP_HEIGHT, + type FingerprintTarget, fingerprintTargetAt, } from "@/lib/plots/fingerprint"; import { @@ -54,22 +55,22 @@ type FingerprintPlotProps = { pinLimit?: number; }; -type FingerprintCell = { col: number; hour: number }; - -type StripHover = { - kind: "column" | "hour"; - index: number; - anchor: TooltipAnchor; -}; - -type Hover = FingerprintCell & { +type Hover = { + target: FingerprintTarget; left: number; top: number; flip: boolean; anchor: TooltipAnchor | null; }; -const sameCell = (a: Hover, b: Hover) => a.col === b.col && 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); export function FingerprintPlot({ values, @@ -89,7 +90,6 @@ export function FingerprintPlot({ const canvasRef = useRef(null); const [width, setWidth] = useState(0); const [hover, setHover] = useState(null); - const [stripHover, setStripHover] = useState(null); const [pins, setPins] = useState([]); const grid = useMemo( () => fingerprintGrid(values, hoursPerDay, selectedYears, scale), @@ -172,24 +172,11 @@ export function FingerprintPlot({ event.clientX, event.clientY, ); - setHover( - target?.kind === "cell" - ? { col: target.col, hour: target.hour, left, top, flip, anchor } - : null, - ); - setStripHover( - anchor && target && target.kind !== "cell" - ? { - kind: target.kind, - index: target.kind === "column" ? target.col : target.hour, - anchor, - } - : null, - ); + setHover(target ? { target, left, top, flip, anchor } : null); }; const stripPointAt = ( - kind: StripHover["kind"], + kind: "column" | "hour", index: number, ): TooltipPoint | undefined => { const stats = @@ -242,10 +229,21 @@ export function FingerprintPlot({ : undefined, }; }; - const previousOf = (cell: FingerprintCell) => - cell.hour > 0 - ? pointAt(cell.col, cell.hour - 1, "time") - : pointAt(cell.col - 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; const separate = pinLimit > 1; const pinned = separate ? null : (pins[0] ?? null); @@ -253,16 +251,10 @@ export function FingerprintPlot({ const shown = pinned ?? hover; const hoverPoint = hover && !desktop - ? { - ...pointAt(hover.col, hover.hour, "full")!, - left: hover.left, - top: hover.top, - } + ? { ...pointFor(hover), left: hover.left, top: hover.top } : null; const comparing = - pinned && - hover && - (hover.col !== pinned.col || hover.hour !== pinned.hour); + pinned && hover && !sameTarget(hover, pinned) && comparable(hover, pinned); return (
@@ -273,14 +265,11 @@ export function FingerprintPlot({ role="img" aria-label="Diurnal fingerprint heatmap" onMouseMove={handleMove} - onMouseLeave={() => { - setHover(null); - setStripHover(null); - }} + onMouseLeave={() => setHover(null)} onClick={() => { if (desktop && hover?.anchor) { setPins((current) => - togglePin(current, hover, pinLimit, sameCell), + togglePin(current, hover, pinLimit, sameTarget), ); } }} @@ -288,14 +277,14 @@ export function FingerprintPlot({ {desktop && separate ? pins.map((pin) => ( setPins((current) => - current.filter((other) => !sameCell(other, pin)), + current.filter((other) => !sameTarget(other, pin)), ) } /> @@ -304,7 +293,7 @@ export function FingerprintPlot({ {desktop && separate ? pins.map((pin) => (
- {desktop && - separate && - hover?.anchor && - !pins.some((pin) => sameTarget(pin, hover)) ? ( - - ) : null} {desktop && !separate && shown?.anchor ? ( void; + /** Without it the tooltip is a hover readout and lets the pointer through. */ + onClose?: () => void; }) { return (
sameDay(pin, hover)) ? ( - ) : null} diff --git a/src/lib/export/captureFingerprint.ts b/src/lib/export/captureFingerprint.ts index b51799c..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, fingerprintGrid } 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 { @@ -38,7 +42,7 @@ export function captureFingerprint({ width: EXPORT_PLOT_WIDTH, height: EXPORT_PLOT_HEIGHT, transposed: false, - margins: true, + margins: { top: FINGERPRINT_STRIP, left: FINGERPRINT_STRIP }, }, { colormapId, diff --git a/src/lib/plots/fingerprint.ts b/src/lib/plots/fingerprint.ts index f9f23c4..968513f 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -26,9 +26,14 @@ const AXIS_LEFT_TRANSPOSED = 54; const AXIS_BOTTOM = 18; const AXIS_TOP = 4; const AXIS_RIGHT = 6; -const STRIP = 56; 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"; @@ -71,19 +76,26 @@ export type FingerprintLayout = { width: number; height: number; transposed: boolean; - /** Draw mean ± std strips along both axes. */ - margins?: 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 const FINGERPRINT_STRIP_HEIGHT = STRIP + STRIP_GAP; +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 - ? STRIP + STRIP_GAP + Math.max(labels, STRIP_LABELS) + ? stripSpace(margins.left) + + Math.max(labels, margins.top > 0 ? STRIP_LABELS : 0) : labels; - const top = margins ? AXIS_TOP + STRIP + STRIP_GAP : AXIS_TOP; + const top = AXIS_TOP + (margins ? stripSpace(margins.top) : 0); return { axisLeft, top, @@ -92,6 +104,19 @@ function plotBox({ width, height, transposed, margins }: FingerprintLayout) { }; } +/** 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 }, + }; +} + /** Column index where each year starts, after the first. */ function yearBreaks(bins: readonly TimeBin[]): number[] { const breaks: number[] = []; @@ -364,13 +389,19 @@ export function drawFingerprint( ctx.fillStyle = chartTickColor(isLight); ctx.font = LABEL_FONT; - if (layout.margins) { + const { margins } = layout; + if (margins) { const colors = { accent, grid: isLight ? "rgba(0, 0, 0, 0.08)" : "rgba(255, 255, 255, 0.08)", }; - const topBox = { x: axisLeft, y: AXIS_TOP, w: plotW, h: STRIP }; - const leftBox = { x: 0, y: top, w: STRIP, h: plotH }; + const topBox = { x: axisLeft, y: AXIS_TOP, w: plotW, h: margins.top }; + const leftBox = { + x: STRIP_INSET, + y: top, + w: Math.max(0, margins.left - STRIP_INSET), + h: plotH, + }; const colCentre = (along: number, length: number) => (col: number) => along + ((col + 0.5) / nSel) * length; const hourCentre = (hour: number) => @@ -378,31 +409,39 @@ export function drawFingerprint( ? axisLeft + ((hour + 0.5) * plotW) / hoursPerDay : top + ((hoursPerDay - 0.5 - hour) * plotH) / hoursPerDay; - const topTicks = drawStrip( - ctx, - transposed ? grid.margins.byHour : grid.margins.byColumn, - transposed ? hourCentre : colCentre(axisLeft, plotW), - topBox, - true, - colors, - ); - const leftTicks = drawStrip( - ctx, - transposed ? grid.margins.byColumn : grid.margins.byHour, - transposed ? colCentre(top, plotH) : hourCentre, - leftBox, - false, - colors, - ); + 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 topTicks) { + 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 leftTicks) { + 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); } @@ -490,8 +529,8 @@ export function fingerprintTargetAt( } if (!margins) return null; - const inTopStrip = inCellsX && y >= AXIS_TOP && y < AXIS_TOP + STRIP; - const inLeftStrip = inCellsY && x >= 0 && x < STRIP; + 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) } From 61e437acf60ff030199ed89e9968ad572d384c66 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Wed, 21 Oct 2026 16:39:28 +0300 Subject: [PATCH 6/8] Keep fingerprint pins on their target when the axes flip Pins stored the pixel position of the click, so after flipping the axes the dot and card stayed put and pointed at a different cell. Place each pin from its target in the current layout instead. --- src/components/plots/FingerprintPlot.tsx | 13 +++- src/components/plots/MapSideTooltip.tsx | 6 +- src/lib/plots/fingerprint.test.ts | 56 ++++++++++++++ src/lib/plots/fingerprint.ts | 94 +++++++++++++++++++++--- 4 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 src/lib/plots/fingerprint.test.ts diff --git a/src/components/plots/FingerprintPlot.tsx b/src/components/plots/FingerprintPlot.tsx index 811c311..f4a3c77 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -22,6 +22,7 @@ import { type FingerprintStrips, type FingerprintTarget, fingerprintTargetAt, + fingerprintTargetPoint, } from "@/lib/plots/fingerprint"; import { binCountLabel, @@ -35,6 +36,7 @@ import { anchorBesidePanel, MapSideTooltip, PinnedTooltip, + pinFlips, pinPosition, togglePin, useDesktop, @@ -343,6 +345,13 @@ export function FingerprintPlot({ }; // 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); @@ -393,7 +402,7 @@ export function FingerprintPlot({ )) : null} {desktop && separate - ? pins.map((pin) => ( + ? placedPins.map((pin) => ( ( + ? placedPins.map((pin) => (