diff --git a/src/components/map/FingerprintPlot.tsx b/src/components/map/FingerprintPlot.tsx new file mode 100644 index 0000000..a1d0511 --- /dev/null +++ b/src/components/map/FingerprintPlot.tsx @@ -0,0 +1,369 @@ +"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, + formatDayTick, + formatIsoDate, + symmetricAbsMax, + yearRangesInWindow, +} from "@/lib/map/fingerprintScale"; +import { ZARR_TIME } from "@/lib/zarr/timeRange"; +import { useTheme } from "@/providers/ThemeProvider"; + +type FingerprintPlotProps = { + values: Float32Array; + units?: string | null; + hoursPerDay?: number; +}; + +/** + * Canvas gutters (CSS px). The left gutter widens when transposed, since date + * labels ("Mon 'yy") sit on the left then, versus 2-digit hours by default. + */ +const AXIS_LEFT_DEFAULT = 34; +const AXIS_LEFT_TRANSPOSED = 54; +const AXIS_BOTTOM = 18; +const AXIS_TOP = 4; +const AXIS_RIGHT = 6; + +const axisLeftFor = (transposed: boolean) => + transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT; + +type HoverCell = { + left: number; + top: number; + day: number; + hour: number; + value: number; + absoluteDay: number; +}; + +/** + * Fingerprint plot: an hour-of-day by day 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. + * + * Controls: flip the axes (hour<->day), single out one calendar year of the + * loaded window, and hover a cell to read its date/hour/value. + */ +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 [transposed, setTransposed] = useState(false); + const [selectedYear, setSelectedYear] = useState(null); + const [hover, setHover] = useState(null); + + const nDays = Math.floor(values.length / hoursPerDay); + const absMax = useMemo(() => symmetricAbsMax(values), [values]); + + // The window always ends at the last day of the archive, so its first day sits + // `totalDays - nDays` into the absolute time axis. + const baseDay = ZARR_TIME.totalDays - nDays; + const years = useMemo( + () => yearRangesInWindow(baseDay, nDays), + [baseDay, nDays], + ); + + // Local day range currently in view: the whole window, or one selected year. + // Derived defensively, so a selection that falls outside the current window + // simply reads as "All" without needing an effect to reset the stored value. + const activeYear = years.find((range) => range.year === selectedYear) ?? null; + const dayLo = activeYear ? activeYear.startDay : 0; + const dayHi = activeYear ? activeYear.endDay : nDays - 1; + const nSel = Math.max(1, dayHi - dayLo + 1); + + useEffect(() => { + const node = wrapperRef.current; + if (!node) return; + const observer = new ResizeObserver((entries) => { + setWidth(Math.floor(entries[0]?.contentRect.width ?? 0)); + }); + 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 axisLeft = axisLeftFor(transposed); + const plotW = Math.max(1, width - axisLeft - AXIS_RIGHT); + const plotH = Math.max(1, height - AXIS_TOP - AXIS_BOTTOM); + const scale = fingerprintColorScale(isLight); + + // Map a local day index (dayLo..dayHi) to a fill color at the given hour. + const cellColor = (dayLocal: number, hour: number) => + scale(values[dayLocal * hoursPerDay + hour] as number, absMax); + + if (!transposed) { + // x = day, y = hour (hour 0 at the bottom). + const rowY = (fromTop: number) => + AXIS_TOP + Math.floor((fromTop * plotH) / hoursPerDay); + for (let px = 0; px < plotW; px++) { + const dayLocal = Math.min(dayHi, dayLo + Math.floor((px / plotW) * nSel)); + for (let hour = 0; hour < hoursPerDay; hour++) { + const color = cellColor(dayLocal, 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)); + } + } + } else { + // x = hour (0 at the left), y = day (first day at the top). + const colX = (hour: number) => + axisLeft + Math.floor((hour * plotW) / hoursPerDay); + for (let py = 0; py < plotH; py++) { + const dayLocal = Math.min(dayHi, dayLo + Math.floor((py / plotH) * nSel)); + for (let hour = 0; hour < hoursPerDay; hour++) { + const color = cellColor(dayLocal, 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); + } + } + } + + // Axis labels. + const { tick } = timeSeriesChartTheme(isLight); + ctx.fillStyle = tick; + ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + + const dayTicks = dayIndexTicks(nSel).map((offset) => dayLo + offset); + const dayFrac = (dayLocal: number) => + nSel <= 1 ? 0 : (dayLocal - dayLo) / (nSel - 1); + + if (!transposed) { + // Hours down the left, dates along the bottom. + const rowY = (fromTop: number) => + AXIS_TOP + (fromTop * plotH) / hoursPerDay; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const hour of FINGERPRINT_HOUR_TICKS) { + const fromTop = hoursPerDay - 1 - hour; + ctx.fillText(String(hour), axisLeft - 6, (rowY(fromTop) + rowY(fromTop + 1)) / 2); + } + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + for (const dayLocal of dayTicks) { + const x = clampLabelX(axisLeft + dayFrac(dayLocal) * plotW, plotW, axisLeft); + ctx.fillText(formatDayTick(baseDay + dayLocal), x, AXIS_TOP + plotH + 4); + } + } else { + // Dates down the left, hours along the bottom. + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + for (const dayLocal of dayTicks) { + const y = AXIS_TOP + dayFrac(dayLocal) * plotH; + ctx.fillText( + formatDayTick(baseDay + dayLocal), + axisLeft - 6, + Math.max(AXIS_TOP + 6, Math.min(AXIS_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); + } + } + }, [ + values, + absMax, + nDays, + nSel, + dayLo, + dayHi, + baseDay, + hoursPerDay, + isLight, + width, + transposed, + ]); + + if (nDays === 0) return null; + + const legend = fingerprintLegendStops(isLight); + + const handleMove = (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const axisLeft = axisLeftFor(transposed); + const plotW = Math.max(1, width - axisLeft - AXIS_RIGHT); + const plotH = Math.max(1, TIME_SERIES_PLOT_HEIGHT - AXIS_TOP - AXIS_BOTTOM); + const inX = x - axisLeft; + const inY = y - AXIS_TOP; + if (inX < 0 || inX >= plotW || inY < 0 || inY >= plotH) { + setHover(null); + return; + } + + let dayLocal: number; + let hour: number; + if (!transposed) { + dayLocal = Math.min(dayHi, dayLo + 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, dayLo + Math.floor((inY / plotH) * nSel)); + } + + setHover({ + left: x, + top: y, + day: dayLocal, + hour, + value: values[dayLocal * hoursPerDay + hour] as number, + absoluteDay: baseDay + dayLocal, + }); + }; + + return ( +
+
+ + {years.length > 1 ? ( +
+ setSelectedYear(null)} + /> + {years.map((range) => ( + setSelectedYear(range.year)} + /> + ))} +
+ ) : null} +
+ +
+ setHover(null)} + /> + {hover ? ( +
+
+ {formatIsoDate(hover.absoluteDay)} · {String(hover.hour).padStart(2, "0")}:00 +
+
+ {Number.isFinite(hover.value) + ? `${formatSeriesValue(hover.value)}${units ? ` ${units}` : ""}` + : "no data"} +
+
+ ) : null} +
+ +
+ + {formatSeriesValue(-absMax)} + +
+

+ {nSel.toLocaleString()} days × {hoursPerDay} hours + {units ? ` · ${units}` : ""} +

+
+ ); +} + +/** Keep a bottom-axis date label inside the plot width. */ +function clampLabelX(x: number, plotW: number, axisLeft: number): number { + return Math.max(axisLeft + 12, Math.min(axisLeft + plotW - 12, x)); +} + +function YearChip({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} 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 ( +