Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .agents/rules/earthprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ npm run test # confirm all 215 tests still pass
### Colour scale calls
Every call to `fingerprintColorScale(id)` returns a function that takes
`(value, negMax, posMax)` — three arguments, not two.
For symmetric Science maps pass `absMax` as **both** `negMax` and `posMax`.
For Flux pass separate values from `asymmetricExtents(values)`.
Always pass both extents from `asymmetricExtents(values)`. Every palette is
centred on zero with each half scaled to its own extent; none of them collapse
to a symmetric `absMax` any more. Passing `absMax` as both arguments is still
valid (it just makes the ramp symmetric) but is not the default any more.

### Colorbar gradients
Never build a colorbar from 3 endpoint stops. Always sample the actual
Expand Down
23 changes: 16 additions & 7 deletions .agents/skills/earthprints-fingerprint-plot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ description: >-
| File | Role |
|---|---|
| `src/lib/map/fingerprintScale.ts` | Colour scale functions, CET palette data, axis helpers |
| `src/lib/map/colormapTables.ts` | Crameri + ColorBrewer diverging lookup tables |
| `src/components/map/FingerprintPlot.tsx` | Canvas heatmap component |
| `src/components/map/ColormapPicker.tsx` | Palette selector UI |
| `src/lib/settings/colormap.ts` | localStorage persistence |
| `src/lib/map/fingerprintScale.test.ts` | 20 unit tests |
| `src/lib/map/fingerprintScale.test.ts` | unit tests for extents, palettes, colorbar |

---

Expand All @@ -43,23 +44,26 @@ const css = scale(value, negMax, posMax);
// returns "transparent" for non-finite values.

// Extent helpers:
const absMax = symmetricAbsMax(values); // symmetric Science maps
const { negMax, posMax } = asymmetricExtents(values); // Flux only
const { negMax, posMax } = asymmetricExtents(values); // every palette
```

### Palette routing

```
colormapId === "science-light" | "science-dark"
→ lerpRgb(mid, endpoint, |t|) where t = value / absMax ∈ [−1, 1]
→ pass absMax as BOTH negMax and posMax
colormapId === a diverging table (vik, berlin, broc, cork, roma, vanimo, rdbu)
→ sampleTable(DIVERGING_TABLES[id], u)
where u = value < 0 ? 0.5·(1 − |value|/negMax) : 0.5 + 0.5·(value/posMax)
→ u = 0.5 is the table's neutral centre stop, so zero is always neutral
→ tables live in src/lib/map/colormapTables.ts

colormapId === "flux"
→ negative values: sampleCet(CET_KBC, 1 − |value|/negMax)
(kbc[255] = near-zero cyan, kbc[0] = darkest blue)
→ positive values: sampleCet(CET_KRYW, 1 − value/posMax)
(kryw[255] = near-zero white, kryw[0] = darkest)
→ pass separate negMax / posMax from asymmetricExtents()
→ note: the two halves do not meet at zero (kbc ends pale cyan, kryw white),
so Flux has a small seam at the neutral point that the table palettes
do not — see the "documented discontinuity" test
```

---
Expand All @@ -78,6 +82,11 @@ const zeroFrac =
// Science maps: negMax === posMax → zeroFrac === 0.5 (50%).
// Flux asymmetric: e.g. negMax=1, posMax=3 → zeroFrac === 0.25.

// Both are available as shared helpers — prefer them over re-deriving:
// zeroFrac(negMax, posMax)
// fingerprintRampSamples(colormapId, negMax, posMax, steps)
// fingerprintRampGradient(colormapId, negMax, posMax, steps) // CSS

// 2. Sample 32 stops, using zeroFrac as the pivot:
const stops = Array.from({ length: 32 }, (_, i) => {
const frac = i / 31;
Expand Down
145 changes: 91 additions & 54 deletions src/components/map/ColormapPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,121 @@
"use client";

import { useEffect, useRef, useState } from "react";
import {
COLORMAPS,
fingerprintColorScale,
type ColormapId,
fingerprintRampGradient,
} from "@/lib/map/fingerprintScale";

type ColormapPickerProps = {
value: ColormapId;
onChange: (id: ColormapId) => void;
};

const COLORMAP_IDS: ColormapId[] = ["science-light", "science-dark", "flux"];
const COLORMAP_IDS = Object.keys(COLORMAPS) as ColormapId[];

/**
* Build a CSS linear-gradient preview for a given colormap ID.
* Uses 16 samples from the actual scale function (same as the colorbar) so
* the swatch faithfully represents every palette, including Flux.
* Swatch gradient for one palette, sampled from the actual scale function (the
* same helper the colorbar uses) so a swatch never misrepresents its map.
*
* Extents are symmetric (negMax = posMax = 1, zero at 50%) because the swatch
* is a palette preview, not tied to any real dataset range.
* Extents are symmetric here (negMax = posMax = 1, zero at 50%) because the
* swatch is a palette preview, not tied to any real dataset range.
*/
function swatchGradient(id: ColormapId): string {
const scale = fingerprintColorScale(id);
const N = 16;
const stops = Array.from({ length: N }, (_, i) => {
const frac = i / (N - 1); // 0 → 1
// Map 0→0.5 to -1→0 and 0.5→1 to 0→1 (symmetric, negMax=posMax=1).
const value = frac <= 0.5 ? -(1 - frac * 2) : frac * 2 - 1;
return `${scale(value, 1, 1)} ${(frac * 100).toFixed(0)}%`;
});
return `linear-gradient(to right, ${stops.join(", ")})`;
}

// Pre-compute once — palette swatches never change at runtime.
const SWATCH_GRADIENTS = Object.fromEntries(
COLORMAP_IDS.map((id) => [id, swatchGradient(id)]),
COLORMAP_IDS.map((id) => [id, fingerprintRampGradient(id, 1, 1, 16)]),
) as Record<ColormapId, string>;

/**
* A row of swatch buttons for selecting the fingerprint heatmap's colour
* palette. Each button shows a small gradient preview sampled from the
* actual colour scale so the swatch matches the rendered colorbar exactly.
* Palette selector for the fingerprint heatmap. This is a dropdown rather than
* a row of buttons because the list outgrew the sidebar's width; the swatches
* are the point, since the maps differ in ways their names do not convey.
*/
export function ColormapPicker({ value, onChange }: ColormapPickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);

return (
<div
className="flex flex-wrap items-center gap-2"
role="group"
aria-label="Heatmap colour palette"
>
<div className="flex items-center gap-2">
<span className="shrink-0 text-[11.5px] font-semibold text-editor-fg-tertiary">
Palette
</span>
{COLORMAP_IDS.map((id) => {
const active = id === value;
const { label } = COLORMAPS[id];
return (
<button
key={id}
type="button"
aria-pressed={active}
onClick={() => onChange(id)}
title={COLORMAPS[id].description}
className={`flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11.5px] font-semibold transition-colors ${
active
? "border-accent bg-accent/10 text-accent"
: "border-editor-border text-editor-fg-tertiary hover:border-editor-border-strong hover:text-editor-fg-secondary"
}`}
<div ref={containerRef} className="relative inline-flex items-center">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-haspopup="menu"
title={COLORMAPS[value].description}
className="flex items-center gap-1.5 rounded-md border border-editor-border px-2 py-0.5 text-[11.5px] font-semibold text-editor-fg-secondary transition-colors hover:border-editor-border-strong hover:text-editor-fg-primary"
>
<span
className="block h-2.5 w-9 shrink-0 rounded-sm"
style={{ background: SWATCH_GRADIENTS[value] }}
aria-hidden="true"
/>
<span>{COLORMAPS[value].label}</span>
</button>

{open ? (
<div
role="menu"
className="absolute left-0 top-full z-50 mt-1.5 w-56 rounded-lg border border-editor-border bg-editor-bg-primary p-1 shadow-lg backdrop-blur-md"
>
{/* Gradient swatch — mirrors the colorbar */}
<span
className="block h-2.5 w-9 flex-shrink-0 rounded-sm"
style={{ background: SWATCH_GRADIENTS[id] }}
aria-hidden="true"
/>
<span>{label}</span>
</button>
);
})}
{COLORMAP_IDS.map((id) => {
const active = id === value;
return (
<button
key={id}
type="button"
role="menuitemradio"
aria-checked={active}
title={COLORMAPS[id].description}
onClick={() => {
onChange(id);
setOpen(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12px] font-medium hover:bg-editor-bg-secondary hover:text-editor-fg-primary ${
active
? "text-editor-fg-primary"
: "text-editor-fg-secondary"
}`}
>
<span
className="block h-2.5 w-14 shrink-0 rounded-sm"
style={{ background: SWATCH_GRADIENTS[id] }}
aria-hidden="true"
/>
<span className="flex-1">{COLORMAPS[id].label}</span>
{active ? (
<span className="font-mono text-[10px] text-accent">●</span>
) : null}
</button>
);
})}
</div>
) : null}
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions src/components/map/DownloadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export function DownloadButton({
selectedYear,
selectedYears,
timeBasisLabel,
colormapId,
}),
]);

Expand Down Expand Up @@ -324,6 +325,7 @@ export function DownloadButton({
selectedYear,
selectedYears,
timeBasisLabel,
colormapId,
}),
]);
const assets: ReportAssets = {
Expand All @@ -347,6 +349,7 @@ export function DownloadButton({
setBusy(false);
}
}, [
colormapId,
displayValues,
gridSpec,
historyYears,
Expand Down
19 changes: 17 additions & 2 deletions src/components/map/ExportStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
type CapturedImage,
} from "@/lib/export/capture";
import { fingerprintPngWithLegend } from "@/lib/export/fingerprintImage";
import { symmetricAbsMax } from "@/lib/map/fingerprintScale";
import {
asymmetricExtents,
type ColormapId,
defaultColormapId,
} from "@/lib/map/fingerprintScale";
import { FixedThemeProvider } from "@/providers/ThemeProvider";

/**
Expand Down Expand Up @@ -49,6 +53,12 @@ type StageProps = {
selectedYears?: number[] | null;
/** Names the clock `values` is already on, for the fingerprint's caption. */
timeBasisLabel?: string;
/**
* The visitor's explicit palette pick, or undefined when they have not made
* one. Left unresolved so the light export stage applies its own default
* rather than inheriting a dark-surface palette from the live theme.
*/
colormapId?: ColormapId;
};

/**
Expand All @@ -60,6 +70,7 @@ function ExportStage({
values,
units,
hoursPerDay,
colormapId,
selectedYear,
selectedYears,
timeBasisLabel,
Expand All @@ -85,6 +96,7 @@ function ExportStage({
selectedYear={selectedYear}
selectedYears={selectedYears}
timeBasisLabel={timeBasisLabel}
colormapId={colormapId}
/>
</div>
</div>
Expand Down Expand Up @@ -153,7 +165,10 @@ export async function capturePlotsForExport(
timeSeries: await svgToPng(svg, { scale: EXPORT_PIXEL_RATIO }),
fingerprint: canvasToPng(canvas),
fingerprintStandalone: fingerprintPngWithLegend(canvas, {
absMax: symmetricAbsMax(props.values),
...asymmetricExtents(props.values),
// The stage renders light whatever theme the app is in, so an unpicked
// palette resolves against the light surface, not the live one.
colormapId: props.colormapId ?? defaultColormapId(true),
units: props.units,
pixelRatio: EXPORT_PIXEL_RATIO,
}),
Expand Down
Loading
Loading