diff --git a/src/components/data/DataView.tsx b/src/components/data/DataView.tsx index 5c90888..fd1380d 100644 --- a/src/components/data/DataView.tsx +++ b/src/components/data/DataView.tsx @@ -138,7 +138,6 @@ 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 3f9c82f..36fd14b 100644 --- a/src/components/panels/CellView.tsx +++ b/src/components/panels/CellView.tsx @@ -166,16 +166,9 @@ type PlotAreaProps = { display: SeriesDisplay; height?: number; pinLimit?: number; - margins?: boolean; }; -export function PlotArea({ - series, - display, - height, - pinLimit, - margins, -}: PlotAreaProps) { +export function PlotArea({ series, display, height, pinLimit }: PlotAreaProps) { if (series.loading) { return (
@@ -227,7 +220,6 @@ export function PlotArea({ 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 a88b9b6..b990711 100644 --- a/src/components/plots/FingerprintPlot.tsx +++ b/src/components/plots/FingerprintPlot.tsx @@ -13,11 +13,10 @@ import { } from "@/lib/plots/chartTheme"; import { drawFingerprint, + type FingerprintCell, + fingerprintCellAt, fingerprintGrid, type FingerprintLayout, - FINGERPRINT_STRIP_HEIGHT, - type FingerprintTarget, - fingerprintTargetAt, } from "@/lib/plots/fingerprint"; import { binCountLabel, @@ -50,27 +49,18 @@ type FingerprintPlotProps = { transposed: boolean; colormapId: ColormapId; scale?: BinUnit; - margins?: boolean; height?: number; pinLimit?: number; }; -type Hover = { - target: FingerprintTarget; +type Hover = FingerprintCell & { left: number; top: number; flip: boolean; anchor: TooltipAnchor | null; }; -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); +const sameCell = (a: Hover, b: Hover) => a.col === b.col && a.hour === b.hour; export function FingerprintPlot({ values, @@ -81,7 +71,6 @@ export function FingerprintPlot({ transposed, colormapId, scale = "day", - margins = false, height: heightOverride, pinLimit = 1, }: FingerprintPlotProps) { @@ -113,12 +102,11 @@ export function FingerprintPlot({ }, []); const height = - (heightOverride ?? - (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT)) + - (margins ? FINGERPRINT_STRIP_HEIGHT : 0); + heightOverride ?? + (transposed ? TRANSPOSED_MIN_HEIGHT : TIME_SERIES_PLOT_HEIGHT); const layout: FingerprintLayout = useMemo( - () => ({ grid, width, height, transposed, margins }), - [grid, width, height, transposed, margins], + () => ({ grid, width, height, transposed }), + [grid, width, height, transposed], ); const extents = useMemo(() => asymmetricExtents(grid.mean), [grid]); const nCols = grid.bins.length; @@ -161,7 +149,7 @@ export function FingerprintPlot({ const rect = event.currentTarget.getBoundingClientRect(); const left = event.clientX - rect.left; const top = event.clientY - rect.top; - const target = fingerprintTargetAt(layout, left, top); + const cell = fingerprintCellAt(layout, left, top); const anchor = anchorBesidePanel( wrapperRef.current, event.clientX, @@ -172,39 +160,7 @@ export function FingerprintPlot({ event.clientX, event.clientY, ); - setHover(target ? { target, left, top, flip, anchor } : null); - }; - - const stripPointAt = ( - kind: "column" | "hour", - index: number, - ): TooltipPoint | undefined => { - const stats = - kind === "column" ? grid.margins.byColumn : grid.margins.byHour; - if (index < 0 || index >= stats.mean.length) return undefined; - const count = stats.count[index] ?? 0; - const std = formatSeriesValue(stats.std[index] as number); - if (kind === "column") { - const bin = grid.bins[index]!; - return { - label: formatBinLabel(bin, scale), - shortLabel: formatBinShortLabel(bin, scale), - value: stats.mean[index] as number, - spread: - count > 0 - ? `Std ${std} across ${count} hour${count === 1 ? "" : "s"}` - : undefined, - }; - } - const time = `${String(index).padStart(2, "0")}:00`; - const suffix = timeBasisLabel ? ` ${shortTimeBasis(timeBasisLabel)}` : ""; - return { - label: `${time}${suffix}`, - shortLabel: time, - value: stats.mean[index] as number, - spread: - count > 0 ? `Std ${std} across ${binCountLabel(count, scale)}` : undefined, - }; + setHover(cell ? { ...cell, left, top, flip, anchor } : null); }; const pointAt = ( @@ -229,21 +185,10 @@ export function FingerprintPlot({ : undefined, }; }; - 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 previousOf = (cell: FingerprintCell) => + cell.hour > 0 + ? pointAt(cell.col, cell.hour - 1, "time") + : pointAt(cell.col - 1, hoursPerDay - 1, "full"); const separate = pinLimit > 1; const pinned = separate ? null : (pins[0] ?? null); @@ -251,10 +196,16 @@ export function FingerprintPlot({ const shown = pinned ?? hover; const hoverPoint = hover && !desktop - ? { ...pointFor(hover), left: hover.left, top: hover.top } + ? { + ...pointAt(hover.col, hover.hour, "full")!, + left: hover.left, + top: hover.top, + } : null; const comparing = - pinned && hover && !sameTarget(hover, pinned) && comparable(hover, pinned); + pinned && + hover && + (hover.col !== pinned.col || hover.hour !== pinned.hour); return (
@@ -269,7 +220,7 @@ export function FingerprintPlot({ onClick={() => { if (desktop && hover?.anchor) { setPins((current) => - togglePin(current, hover, pinLimit, sameTarget), + togglePin(current, hover, pinLimit, sameCell), ); } }} @@ -277,14 +228,14 @@ export function FingerprintPlot({ {desktop && separate ? pins.map((pin) => ( setPins((current) => - current.filter((other) => !sameTarget(other, pin)), + current.filter((other) => !sameCell(other, pin)), ) } /> @@ -293,7 +244,7 @@ export function FingerprintPlot({ {desktop && separate ? pins.map((pin) => (
); diff --git a/src/hooks/useCellExport.ts b/src/hooks/useCellExport.ts index e2a3db4..6c7e5b1 100644 --- a/src/hooks/useCellExport.ts +++ b/src/hooks/useCellExport.ts @@ -4,8 +4,7 @@ import { useCallback, useState } from "react"; import { captureFingerprint } from "@/lib/export/captureFingerprint"; import { captureMap } from "@/lib/export/captureMap"; import { captureTimeSeries } from "@/lib/export/captureTimeSeries"; -import { buildMarginCsv, buildSeriesCsv } from "@/lib/export/csv"; -import { buildMarginTables, marginColumns } from "@/lib/export/margins"; +import { buildSeriesCsv } from "@/lib/export/csv"; import { downloadBlob } from "@/lib/export/download"; import { buildSquareFingerprintCanvas } from "@/lib/export/fingerprintSquareLogo"; import { buildReportPdf, type ReportAssets } from "@/lib/export/pdf"; @@ -121,8 +120,6 @@ 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, @@ -130,7 +127,7 @@ export function useCellExport({ values: displayValues ?? values, attribution: mapCapture.attribution, }), - buildSeriesWorkbook(rows, prov, margins), + buildSeriesWorkbook(rows, prov), ]); const archive = await buildZip([ @@ -144,28 +141,6 @@ 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 b51799c..a9c4607 100644 --- a/src/lib/export/captureFingerprint.ts +++ b/src/lib/export/captureFingerprint.ts @@ -38,7 +38,6 @@ 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 6987346..f331a91 100644 --- a/src/lib/export/csv.ts +++ b/src/lib/export/csv.ts @@ -1,6 +1,5 @@ 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 = @@ -75,18 +74,3 @@ 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 deleted file mode 100644 index 118a61c..0000000 --- a/src/lib/export/margins.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index c562402..0000000 --- a/src/lib/export/margins.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 13a01ce..7225b6c 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}` : ""} - strips: mean +/-1 std per row and column`, + )}${prov.units ? ` - ${prov.units}` : ""}`, cursor, ); diff --git a/src/lib/export/xlsx.ts b/src/lib/export/xlsx.ts index 0526a22..4a19ab9 100644 --- a/src/lib/export/xlsx.ts +++ b/src/lib/export/xlsx.ts @@ -1,13 +1,10 @@ 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; @@ -86,32 +83,13 @@ 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([ { @@ -130,20 +108,6 @@ 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 f9f23c4..56c30bc 100644 --- a/src/lib/plots/fingerprint.ts +++ b/src/lib/plots/fingerprint.ts @@ -6,12 +6,6 @@ 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, @@ -26,9 +20,6 @@ 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"; @@ -40,7 +31,6 @@ export type FingerprintGrid = BinStats & { unit: BinUnit; bins: TimeBin[]; hoursPerDay: number; - margins: FingerprintMargins; }; export function fingerprintGrid( @@ -56,14 +46,7 @@ export function fingerprintGrid( nDays, ); const bins = timeBins(absoluteDays.slice(0, nDays), unit); - const stats = binHourGrid(values, hoursPerDay, bins); - return { - ...stats, - unit, - bins, - hoursPerDay, - margins: fingerprintMargins(stats.mean, bins.length, hoursPerDay), - }; + return { ...binHourGrid(values, hoursPerDay, bins), unit, bins, hoursPerDay }; } export type FingerprintLayout = { @@ -71,24 +54,14 @@ export type FingerprintLayout = { width: number; height: number; transposed: boolean; - /** Draw mean ± std strips along both axes. */ - margins?: boolean; }; -/** 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; +function plotBox({ width, height, transposed }: FingerprintLayout) { + const axisLeft = transposed ? AXIS_LEFT_TRANSPOSED : AXIS_LEFT_DEFAULT; return { axisLeft, - top, plotW: Math.max(1, width - axisLeft - AXIS_RIGHT), - plotH: Math.max(1, height - top - AXIS_BOTTOM), + plotH: Math.max(1, height - AXIS_TOP - AXIS_BOTTOM), }; } @@ -144,99 +117,6 @@ 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)); } @@ -249,14 +129,11 @@ 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; @@ -272,7 +149,7 @@ export function drawFingerprint( ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.clearRect(0, 0, width, height); - const { axisLeft, top, plotW, plotH } = plotBox(layout); + const { axisLeft, plotW, plotH } = plotBox(layout); const nSel = Math.max(1, nCols); const colHi = Math.max(0, nCols - 1); const breaks = yearBreaks(grid.bins); @@ -287,8 +164,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 upper = snap(y0); - ctx.fillRect(left, upper, snap(x1) - left, snap(y1) - upper); + const top = snap(y0); + ctx.fillRect(left, top, snap(x1) - left, snap(y1) - top); }; const dividerColor = isLight ? "rgba(0, 0, 0, 0.25)" @@ -296,7 +173,7 @@ export function drawFingerprint( if (!transposed) { const rowY = (fromTop: number) => - top + Math.floor((fromTop * plotH) / hoursPerDay); + AXIS_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); @@ -321,8 +198,8 @@ export function drawFingerprint( for (const start of breaks) { const bx = axisLeft + Math.floor((start / nSel) * plotW); ctx.beginPath(); - ctx.moveTo(bx, top); - ctx.lineTo(bx, top + plotH); + ctx.moveTo(bx, AXIS_TOP); + ctx.lineTo(bx, AXIS_TOP + plotH); ctx.stroke(); } ctx.restore(); @@ -339,9 +216,9 @@ export function drawFingerprint( ctx.fillStyle = color; fillCell( xLeft, - top + start, + AXIS_TOP + start, Math.max(xLeft + 1, xRight), - top + start + size, + AXIS_TOP + start + size, ); } } @@ -351,7 +228,7 @@ export function drawFingerprint( ctx.strokeStyle = dividerColor; ctx.lineWidth = 1; for (const start of breaks) { - const by = top + Math.floor((start / nSel) * plotH); + const by = AXIS_TOP + Math.floor((start / nSel) * plotH); ctx.beginPath(); ctx.moveTo(axisLeft, by); ctx.lineTo(axisLeft + plotW, by); @@ -364,56 +241,12 @@ 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) => - top + (fromTop * plotH) / hoursPerDay; + AXIS_TOP + (fromTop * plotH) / hoursPerDay; ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (const hour of FINGERPRINT_HOUR_TICKS) { @@ -432,75 +265,59 @@ export function drawFingerprint( plotW, axisLeft, ); - ctx.fillText(label, x, top + plotH + 4); + ctx.fillText(label, x, AXIS_TOP + plotH + 4); } } else { ctx.textAlign = "right"; ctx.textBaseline = "middle"; for (const { at, label } of ticks) { - const y = top + colFrac(at) * plotH; + const y = AXIS_TOP + colFrac(at) * plotH; ctx.fillText( label, axisLeft - 6, - Math.max(top + 6, Math.min(top + plotH - 6, y)), + 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, top + plotH + 4); + ctx.fillText(String(hour), x, AXIS_TOP + plotH + 4); } } } -/** 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 type FingerprintCell = { + col: number; + hour: number; +}; -export function fingerprintTargetAt( +export function fingerprintCellAt( layout: FingerprintLayout, x: number, y: number, -): FingerprintTarget | null { - const { grid, transposed, margins } = layout; +): FingerprintCell | null { + const { grid, transposed } = layout; const { hoursPerDay } = grid; - const { axisLeft, top, plotW, plotH } = plotBox(layout); + const { axisLeft, plotW, plotH } = plotBox(layout); const inX = x - axisLeft; - 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; - - if (inCellsX && inCellsY) { - return transposed - ? { kind: "cell", col: colAt(inY, plotH), hour: hourAt(inX, plotW) } - : { - kind: "cell", - col: colAt(inX, plotW), - hour: hoursPerDay - 1 - hourAt(inY, plotH), - }; - } - if (!margins) return null; + const inY = y - AXIS_TOP; + if (inX < 0 || inX >= plotW || inY < 0 || inY >= plotH) 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) }; + 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)), + }; } - return null; + return { + 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/fingerprintMargins.test.ts b/src/lib/plots/fingerprintMargins.test.ts deleted file mode 100644 index d1b9dc9..0000000 --- a/src/lib/plots/fingerprintMargins.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 78d6cd6..0000000 --- a/src/lib/plots/fingerprintMargins.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 bfa65cc..067c9a7 100644 --- a/src/lib/plots/timeBins.ts +++ b/src/lib/plots/timeBins.ts @@ -77,16 +77,7 @@ export type BinStats = { count: Uint32Array; }; -export function emptyStats(length: number): BinStats { - return { - mean: new Float32Array(length), - std: new Float32Array(length), - count: new Uint32Array(length), - }; -} - -/** Running NaN-aware mean and population std. */ -export class RunningStats { +class Accumulator { sum = 0; sumSq = 0; count = 0; @@ -112,6 +103,14 @@ export class RunningStats { } } +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. @@ -124,7 +123,7 @@ export function binHourGrid( const stats = emptyStats(bins.length * hoursPerDay); bins.forEach((bin, b) => { for (let hour = 0; hour < hoursPerDay; hour++) { - const acc = new RunningStats(); + const acc = new Accumulator(); for (let day = bin.startLocal; day <= bin.endLocal; day++) { acc.add(values[day * hoursPerDay + hour]); } @@ -147,7 +146,7 @@ export function binLineStats( ): BinStats { const stats = emptyStats(bins.length); const dayMean = (day: number) => { - const acc = new RunningStats(); + const acc = new Accumulator(); for (let hour = 0; hour < hoursPerDay; hour++) { acc.add(values[day * hoursPerDay + hour]); } @@ -155,7 +154,7 @@ export function binLineStats( }; bins.forEach((bin, b) => { - const acc = new RunningStats(); + const acc = new Accumulator(); for (let day = bin.startLocal; day <= bin.endLocal; day++) { if (unit === "day") { for (let hour = 0; hour < hoursPerDay; hour++) {