From eb1605d97740d200466950028a0960198291f588 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sat, 25 Jul 2026 12:42:47 +0300 Subject: [PATCH 1/4] Add diurnal fingerprint plot to the readout panel (#28) Add a fingerprint view: an hour-of-day (0 at the bottom) by day heatmap of the pixel's flux, colored by a diverging scale centered on zero so midday uptake separates from nighttime respiration. It sits behind a Line | Fingerprint toggle in the readout and reuses the same series, history slider, and loading/error states as the line chart. The plot is a pure consumer of the existing time series: the reader already returns the full flat [day x 24] Float32Array per pixel, so no data-layer change is needed. Rendering is a canvas cell grid (no new dependency), with NaN cells left transparent for ocean/missing pixels. --- src/components/map/FingerprintPlot.tsx | 156 ++++++++++++++++++ src/components/map/FingerprintPlotLoading.tsx | 19 +++ src/components/map/MapReadout.tsx | 78 +++++++-- src/lib/map/fingerprintScale.test.ts | 58 +++++++ src/lib/map/fingerprintScale.ts | 108 ++++++++++++ 5 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 src/components/map/FingerprintPlot.tsx create mode 100644 src/components/map/FingerprintPlotLoading.tsx create mode 100644 src/lib/map/fingerprintScale.test.ts create mode 100644 src/lib/map/fingerprintScale.ts diff --git a/src/components/map/FingerprintPlot.tsx b/src/components/map/FingerprintPlot.tsx new file mode 100644 index 0000000..b37b60e --- /dev/null +++ b/src/components/map/FingerprintPlot.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + formatSeriesValue, + TIME_SERIES_PLOT_HEIGHT, + timeSeriesChartTheme, +} from "@/components/map/timeSeriesChartConfig"; +import { + dayIndexTicks, + FINGERPRINT_HOUR_TICKS, + fingerprintColorScale, + fingerprintLegendStops, + symmetricAbsMax, +} from "@/lib/map/fingerprintScale"; +import { useTheme } from "@/providers/ThemeProvider"; + +type FingerprintPlotProps = { + values: Float32Array; + units?: string | null; + hoursPerDay?: number; +}; + +/** Canvas gutters (CSS px): hour labels on the left, day labels along the bottom. */ +const AXIS_LEFT = 30; +const AXIS_BOTTOM = 18; +const AXIS_TOP = 4; +const AXIS_RIGHT = 4; + +/** + * Fingerprint plot: an hour-of-day (y, 0 at the bottom) by day (x) heatmap of the + * pixel's flux, colored by a diverging scale centered on zero. Consumes the same + * flat `[day x hoursPerDay]` `Float32Array` the line chart receives and indexes + * `values[day * hoursPerDay + hour]` instead of averaging over the hour axis. + */ +export function FingerprintPlot({ + values, + units, + hoursPerDay = 24, +}: FingerprintPlotProps) { + const { isLight } = useTheme(); + const wrapperRef = useRef(null); + const canvasRef = useRef(null); + const [width, setWidth] = useState(0); + + const nDays = Math.floor(values.length / hoursPerDay); + const absMax = useMemo(() => symmetricAbsMax(values), [values]); + + useEffect(() => { + const node = wrapperRef.current; + if (!node) return; + const observer = new ResizeObserver((entries) => { + const next = entries[0]?.contentRect.width ?? 0; + setWidth(Math.floor(next)); + }); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || width === 0 || nDays === 0) return; + + const height = TIME_SERIES_PLOT_HEIGHT; + const dpr = + typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const plotW = Math.max(1, width - AXIS_LEFT - AXIS_RIGHT); + const plotH = Math.max(1, height - AXIS_TOP - AXIS_BOTTOM); + const rowY = (hourFromTop: number) => + AXIS_TOP + Math.floor((hourFromTop * plotH) / hoursPerDay); + + const scale = fingerprintColorScale(isLight); + + // Walk destination pixel columns and nearest-sample the day axis, so the draw + // cost is bounded by plotW (not nDays) whether we up- or down-scale. + for (let px = 0; px < plotW; px++) { + const day = Math.min(nDays - 1, Math.floor((px / plotW) * nDays)); + const base = day * hoursPerDay; + for (let hour = 0; hour < hoursPerDay; hour++) { + const color = scale(values[base + hour] as number, absMax); + if (color === "transparent") continue; + // Hour 0 sits at the bottom, so it occupies the last row from the top. + const fromTop = hoursPerDay - 1 - hour; + const yTop = rowY(fromTop); + const yBot = rowY(fromTop + 1); + ctx.fillStyle = color; + ctx.fillRect(AXIS_LEFT + px, yTop, 1, Math.max(1, yBot - yTop)); + } + } + + // Axis labels. + const { tick } = timeSeriesChartTheme(isLight); + ctx.fillStyle = tick; + ctx.font = + "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const hour of FINGERPRINT_HOUR_TICKS) { + const fromTop = hoursPerDay - 1 - hour; + const yMid = (rowY(fromTop) + rowY(fromTop + 1)) / 2; + ctx.fillText(String(hour), AXIS_LEFT - 6, yMid); + } + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const labelY = AXIS_TOP + plotH + 4; + for (const day of dayIndexTicks(nDays)) { + const px = nDays <= 1 ? 0 : (day / (nDays - 1)) * plotW; + const x = Math.max( + AXIS_LEFT + 8, + Math.min(AXIS_LEFT + plotW - 8, AXIS_LEFT + px), + ); + ctx.fillText(String(day + 1), x, labelY); + } + }, [values, absMax, nDays, hoursPerDay, isLight, width]); + + if (nDays === 0) return null; + + const legend = fingerprintLegendStops(isLight); + + return ( +
+ +
+ + {formatSeriesValue(-absMax)} + +
+

+ {nDays.toLocaleString()} days × {hoursPerDay} hours · hour of day (0 at + bottom){units ? ` · ${units}` : ""} +

+
+ ); +} diff --git a/src/components/map/FingerprintPlotLoading.tsx b/src/components/map/FingerprintPlotLoading.tsx new file mode 100644 index 0000000..e5c260c --- /dev/null +++ b/src/components/map/FingerprintPlotLoading.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { TIME_SERIES_PLOT_HEIGHT } from "@/components/map/timeSeriesChartConfig"; + +/** + * Placeholder matching the fingerprint canvas footprint so the loading branch + * keeps the same height as the rendered plot, mirroring TimeSeriesPlotLoading. + */ +export function FingerprintPlotLoading() { + return ( +