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