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) => (
sameTarget(pin, hover)) ? (
+ !pins.some((pin) => sameCell(pin, hover)) ? (
setPins([]) : undefined}
@@ -372,7 +325,6 @@ export function FingerprintPlot({
{binCountLabel(nCols, scale)} × {hoursPerDay} hours
{timeBasisLabel ? ` · ${timeBasisLabel}` : ""}
{units ? ` · ${units}` : ""}
- {margins ? " · strips show the mean ±1 std of each row and column" : ""}
);
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++) {