diff --git a/.agents/rules/earthprints.md b/.agents/rules/earthprints.md index 54bc720..49cf15b 100644 --- a/.agents/rules/earthprints.md +++ b/.agents/rules/earthprints.md @@ -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 diff --git a/.agents/skills/earthprints-fingerprint-plot/SKILL.md b/.agents/skills/earthprints-fingerprint-plot/SKILL.md index 6efd961..8afe085 100644 --- a/.agents/skills/earthprints-fingerprint-plot/SKILL.md +++ b/.agents/skills/earthprints-fingerprint-plot/SKILL.md @@ -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 | --- @@ -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 ``` --- @@ -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; diff --git a/src/components/map/ColormapPicker.tsx b/src/components/map/ColormapPicker.tsx index 439551e..38668bb 100644 --- a/src/components/map/ColormapPicker.tsx +++ b/src/components/map/ColormapPicker.tsx @@ -1,9 +1,10 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import { COLORMAPS, - fingerprintColorScale, type ColormapId, + fingerprintRampGradient, } from "@/lib/map/fingerprintScale"; type ColormapPickerProps = { @@ -11,74 +12,110 @@ type ColormapPickerProps = { 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; /** - * 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(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 ( -
+
Palette - {COLORMAP_IDS.map((id) => { - const active = id === value; - const { label } = COLORMAPS[id]; - return ( - + + {open ? ( +
- {/* Gradient swatch — mirrors the colorbar */} -
+ ) : null} +
); } diff --git a/src/components/map/DownloadButton.tsx b/src/components/map/DownloadButton.tsx index eee0122..ab6188b 100644 --- a/src/components/map/DownloadButton.tsx +++ b/src/components/map/DownloadButton.tsx @@ -115,6 +115,7 @@ export function DownloadButton({ selectedYear, selectedYears, timeBasisLabel, + colormapId, }), ]); @@ -324,6 +325,7 @@ export function DownloadButton({ selectedYear, selectedYears, timeBasisLabel, + colormapId, }), ]); const assets: ReportAssets = { @@ -347,6 +349,7 @@ export function DownloadButton({ setBusy(false); } }, [ + colormapId, displayValues, gridSpec, historyYears, diff --git a/src/components/map/ExportStage.tsx b/src/components/map/ExportStage.tsx index 2c4f80b..9ce6c3d 100644 --- a/src/components/map/ExportStage.tsx +++ b/src/components/map/ExportStage.tsx @@ -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"; /** @@ -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; }; /** @@ -60,6 +70,7 @@ function ExportStage({ values, units, hoursPerDay, + colormapId, selectedYear, selectedYears, timeBasisLabel, @@ -85,6 +96,7 @@ function ExportStage({ selectedYear={selectedYear} selectedYears={selectedYears} timeBasisLabel={timeBasisLabel} + colormapId={colormapId} /> @@ -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, }), diff --git a/src/components/map/FingerprintPlot.tsx b/src/components/map/FingerprintPlot.tsx index 6f1eee8..cf3b074 100644 --- a/src/components/map/FingerprintPlot.tsx +++ b/src/components/map/FingerprintPlot.tsx @@ -8,6 +8,8 @@ import { } from "@/components/map/timeSeriesChartConfig"; import { asymmetricExtents, + defaultColormapId, + fingerprintRampGradient, COLORMAPS, type ColormapId, dayIndexTicks, @@ -15,7 +17,7 @@ import { fingerprintColorScale, formatDayTick, formatIsoDate, - symmetricAbsMax, + zeroFrac, } from "@/lib/map/fingerprintScale"; import { ZARR_TIME, @@ -97,9 +99,8 @@ export function FingerprintPlot({ colormapId: colormapIdProp, }: FingerprintPlotProps) { const { isLight } = useTheme(); - // Default to the theme-appropriate Science palette when no explicit choice. - const colormapId: ColormapId = - colormapIdProp ?? (isLight ? "science-light" : "science-dark"); + // Default to the theme-appropriate palette when no explicit choice. + const colormapId: ColormapId = colormapIdProp ?? defaultColormapId(isLight); const wrapperRef = useRef(null); const canvasRef = useRef(null); const [containerSize, setContainerSize] = useState<{ @@ -122,15 +123,9 @@ export function FingerprintPlot({ const [hover, setHover] = useState(null); const nDays = Math.floor(values.length / hoursPerDay); - const absMax = useMemo(() => symmetricAbsMax(values), [values]); - // Flux uses asymmetric extents; Science maps use symmetric absMax. - const extents = useMemo( - () => - colormapId === "flux" - ? asymmetricExtents(values) - : { negMax: absMax, posMax: absMax }, - [colormapId, values, absMax], - ); + // Every palette gets the true extents: zero stays the neutral colour, and + // each half spans its own range so the weaker side is not washed out. + const extents = useMemo(() => asymmetricExtents(values), [values]); const years = selectedYears && selectedYears.length > 0 @@ -221,7 +216,10 @@ export function FingerprintPlot({ 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)); + 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; @@ -257,7 +255,10 @@ export function FingerprintPlot({ 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)); + 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; @@ -305,12 +306,20 @@ export function FingerprintPlot({ 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.fillText( + String(hour), + axisLeft - 6, + (rowY(fromTop) + rowY(fromTop + 1)) / 2, + ); } ctx.textAlign = "center"; ctx.textBaseline = "top"; for (const { dayLocal, label } of dayAxisTicks) { - const x = clampLabelX(axisLeft + dayFrac(dayLocal) * plotW, plotW, axisLeft); + const x = clampLabelX( + axisLeft + dayFrac(dayLocal) * plotW, + plotW, + axisLeft, + ); ctx.fillText(label, x, AXIS_TOP + plotH + 4); } } else { @@ -334,7 +343,6 @@ export function FingerprintPlot({ } }, [ values, - absMax, extents, colormapId, nDays, @@ -352,36 +360,17 @@ export function FingerprintPlot({ if (nDays === 0) return null; - // Zero-crossing position as a fraction of the total bar width. - // For symmetric Science maps negMax === posMax so this is always 0.5. - // For asymmetric Flux it is skewed: e.g. negMax=1, posMax=3 → 0.25 (25%). - const zeroFrac = - extents.negMax + extents.posMax > 0 - ? extents.negMax / (extents.negMax + extents.posMax) - : 0.5; - - // Build the colorbar gradient by sampling the actual scale function. - // The split between the two ramps is at zeroFrac, so the gradient's colour - // boundary matches the label position exactly. - const LEGEND_STOPS = 32; - const colorScale = fingerprintColorScale(colormapId); - const legendCssStops = Array.from({ length: LEGEND_STOPS }, (_, i) => { - const frac = i / (LEGEND_STOPS - 1); // 0 → 1 left to right - // Map bar position to a data value using zeroFrac as the pivot. - const value = - frac <= zeroFrac - ? zeroFrac > 0 - ? -extents.negMax * (1 - frac / zeroFrac) // -negMax → 0 - : 0 - : 1 - zeroFrac > 0 - ? extents.posMax * ((frac - zeroFrac) / (1 - zeroFrac)) // 0 → posMax - : 0; - return `${colorScale(value, extents.negMax, extents.posMax)} ${(frac * 100).toFixed(1)}%`; - }).join(", "); - const legendGradient = `linear-gradient(to right, ${legendCssStops})`; + // The bar is built by sampling the scale in value space, pivoting where zero + // falls, so the gradient's neutral point and the "0" label cannot drift apart. + const barZeroFrac = zeroFrac(extents.negMax, extents.posMax); + const legendGradient = fingerprintRampGradient( + colormapId, + extents.negMax, + extents.posMax, + ); // Convert zeroFrac to a CSS percentage for the label. - const zeroFracPct = zeroFrac * 100; + const zeroFracPct = barZeroFrac * 100; const handleMove = (event: React.MouseEvent) => { const canvas = canvasRef.current; @@ -403,14 +392,16 @@ export function FingerprintPlot({ 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)); + 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)); } - const absoluteDay = - dayMapping.absoluteDays[dayLocal] ?? dayLocal; + const absoluteDay = dayMapping.absoluteDays[dayLocal] ?? dayLocal; setHover({ left: x, @@ -430,9 +421,7 @@ export function FingerprintPlot({ transposed && !heightProp ? "flex-1 min-h-[380px]" : "" }`} style={ - transposed && !heightProp - ? undefined - : { height: `${height}px` } + transposed && !heightProp ? undefined : { height: `${height}px` } } > ("local"); const [colormapId, setColormapId] = useState( - () => loadColormap() ?? (isLight ? "science-light" : "science-dark"), + () => loadColormap() ?? defaultColormapId(isLight), ); const handleColormapChange = (id: ColormapId) => { diff --git a/src/lib/export/fingerprintImage.ts b/src/lib/export/fingerprintImage.ts index 5de4779..14547a5 100644 --- a/src/lib/export/fingerprintImage.ts +++ b/src/lib/export/fingerprintImage.ts @@ -1,5 +1,7 @@ import { - fingerprintLegendStops, + type ColormapId, + fingerprintRampSamples, + zeroFrac, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue, @@ -20,10 +22,14 @@ const INSET = 4; const BLOCK_H = GAP_TOP + BAR_H + GAP_BOTTOM; /** Same as the axis labels the canvas draws for itself. */ -const LABEL_FONT = "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; +const LABEL_FONT = + "11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; type LegendOptions = { - absMax: number; + /** Extents from `asymmetricExtents`, both non-negative magnitudes. */ + negMax: number; + posMax: number; + colormapId: ColormapId; units?: string | null; /** Backing-store pixels per CSS pixel in the source canvas. */ pixelRatio: number; @@ -33,20 +39,21 @@ function drawLegend( ctx: CanvasRenderingContext2D, width: number, top: number, - { absMax, units }: Pick, + { + negMax, + posMax, + colormapId, + units, + }: Pick, ) { - // The export always renders light, like the report, whatever theme the app - // is in. - const stops = fingerprintLegendStops(true); - ctx.font = LABEL_FONT; ctx.fillStyle = timeSeriesChartTheme(true).tick; ctx.textBaseline = "middle"; // Units ride on the upper end. The on-screen legend leaves them to the // caption below it, which a standalone image does not have. - const min = formatSeriesValue(-absMax); - const max = `${formatSeriesValue(absMax)}${units ? ` ${units}` : ""}`; + const min = formatSeriesValue(-negMax); + const max = `${formatSeriesValue(posMax)}${units ? ` ${units}` : ""}`; const minW = ctx.measureText(min).width; const maxW = ctx.measureText(max).width; const middle = top + BAR_H / 2; @@ -59,15 +66,25 @@ function drawLegend( const barX = INSET + minW + LABEL_GAP; const barW = Math.max(1, width - INSET - maxW - LABEL_GAP - barX); + // Sampled from the real scale rather than from three endpoint swatches, so + // the bar matches the heatmap for every palette. const ramp = ctx.createLinearGradient(barX, 0, barX + barW, 0); - ramp.addColorStop(0, stops.uptake); - ramp.addColorStop(0.5, stops.mid); - ramp.addColorStop(1, stops.release); + for (const sample of fingerprintRampSamples(colormapId, negMax, posMax)) { + ramp.addColorStop(sample.frac, sample.color); + } ctx.fillStyle = ramp; ctx.beginPath(); ctx.roundRect(barX, top, barW, BAR_H, BAR_H / 2); ctx.fill(); + + // With the halves scaled independently, zero is rarely at the middle, so the + // bar carries a tick where the neutral colour actually falls. + const pivot = zeroFrac(negMax, posMax); + if (pivot > 0.02 && pivot < 0.98) { + ctx.fillStyle = timeSeriesChartTheme(true).tick; + ctx.fillRect(barX + pivot * barW - 0.5, top - 1, 1, BAR_H + 2); + } } /** @@ -80,7 +97,7 @@ function drawLegend( */ export function fingerprintPngWithLegend( canvas: HTMLCanvasElement, - { absMax, units, pixelRatio }: LegendOptions, + { negMax, posMax, colormapId, units, pixelRatio }: LegendOptions, ): CapturedImage { if (canvas.width === 0 || canvas.height === 0) { throw new Error("Canvas has no size to capture"); @@ -104,7 +121,12 @@ export function fingerprintPngWithLegend( ctx.fillRect(0, 0, plotW, height); ctx.drawImage(canvas, 0, 0, plotW, plotH); - drawLegend(ctx, plotW, plotH + GAP_TOP, { absMax, units }); + drawLegend(ctx, plotW, plotH + GAP_TOP, { + negMax, + posMax, + colormapId, + units, + }); return { dataUrl: target.toDataURL("image/png"), diff --git a/src/lib/export/fingerprintSquareLogo.ts b/src/lib/export/fingerprintSquareLogo.ts index 690343e..fdecaba 100644 --- a/src/lib/export/fingerprintSquareLogo.ts +++ b/src/lib/export/fingerprintSquareLogo.ts @@ -1,7 +1,6 @@ import { fingerprintColorScale, - fingerprintLegendStops, - symmetricAbsMax, + defaultColormapId, asymmetricExtents, type ColormapId, dayIndexTicks, @@ -37,7 +36,6 @@ export type SquareFingerprintOptions = { values: Float32Array; prov: ExportProvenance; units?: string | null; - absMax?: number; hoursPerDay?: number; size?: number; isLight?: boolean; @@ -76,21 +74,18 @@ export function buildSquareFingerprintCanvas( values, prov, units = prov.units, - absMax = symmetricAbsMax(values), hoursPerDay = prov.hoursPerDay || 24, size = DEFAULT_SQUARE_LOGO_SIZE, isLight = false, watermarkText = "Generated by EarthPrints", selectedYear = null, selectedYears = null, - colormapId = "science-light", + colormapId = defaultColormapId(isLight), } = options; - // Compute extents: Flux uses separate negMax/posMax; Science maps use symmetric absMax. - const extents = - colormapId === "flux" - ? asymmetricExtents(values) - : { negMax: absMax, posMax: absMax }; + // Every palette uses the true extents, so zero is the neutral colour and + // neither half of the ramp is wasted. + const extents = asymmetricExtents(values); const canvas = document.createElement("canvas"); canvas.width = size; @@ -341,11 +336,7 @@ export function buildSquareFingerprintCanvas( ctx.fillStyle = textPrimary; ctx.textAlign = "left"; ctx.textBaseline = "middle"; - ctx.fillText( - provText, - leftInsetX + provPadX, - topBadgeY + provBadgeH / 2, - ); + ctx.fillText(provText, leftInsetX + provPadX, topBadgeY + provBadgeH / 2); ctx.restore(); // B. Top-Right Corner: Watermark ("Generated by EarthPrints") @@ -369,11 +360,7 @@ export function buildSquareFingerprintCanvas( ctx.fillStyle = textPrimary; ctx.textAlign = "left"; ctx.textBaseline = "middle"; - ctx.fillText( - watermarkText, - wmBadgeLeft + wmPadX, - topBadgeY + wmBadgeH / 2, - ); + ctx.fillText(watermarkText, wmBadgeLeft + wmPadX, topBadgeY + wmBadgeH / 2); ctx.restore(); // C. Bottom-Left Corner: Origin Pixel Location Overlay @@ -415,7 +402,8 @@ export function buildSquareFingerprintCanvas( // D. Right Side: Vertical Diverging Colorbar Legend const legendNumFontSize = standardFontSize; - // For asymmetric Flux: show independent negMax/posMax; for Science: show symmetric absMax. + // Both extents are real data values, so the two ends of the bar carry + // different magnitudes whenever uptake and release do. const maxVal = extents.posMax; const minVal = -extents.negMax; const maxLabel = `+${formatSeriesValue(maxVal)}`; diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 256a130..633ea5a 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -1,7 +1,9 @@ import type { jsPDF } from "jspdf"; import { fingerprintColorScale, - symmetricAbsMax, + asymmetricExtents, + defaultColormapId, + zeroFrac, } from "@/lib/map/fingerprintScale"; import { formatSeriesValue } from "@/components/map/timeSeriesChartConfig"; import { formatSelectedYearsLabel } from "@/lib/zarr/timeRange"; @@ -201,9 +203,15 @@ function drawPlot( * canvas rather than inside it. jsPDF has no gradient primitive, so the ramp is * sampled into thin bars from the same colour scale the plot uses. */ -function drawLegend(doc: jsPDF, absMax: number, y: number): number { - // PDF legend always renders in Science Light palette, matching the light export theme. - const scale = fingerprintColorScale("science-light"); +function drawLegend( + doc: jsPDF, + { negMax, posMax }: { negMax: number; posMax: number }, + y: number, +): number { + // The report always renders light whatever theme the app is in, so it keeps a + // fixed light-surface palette rather than following the visitor's pick. + const scale = fingerprintColorScale(defaultColormapId(true)); + const pivot = zeroFrac(negMax, posMax); const steps = 96; const barW = 70; const barH = 2.4; @@ -211,7 +219,17 @@ function drawLegend(doc: jsPDF, absMax: number, y: number): number { for (let i = 0; i < steps; i += 1) { const t = i / (steps - 1); - doc.setFillColor(...parseRgb(scale((t * 2 - 1) * absMax, absMax, absMax))); + // Walk the bar in value space, pivoting at zero, so the ramp's neutral + // point lands exactly where the tick below is drawn. + const value = + t <= pivot + ? pivot > 0 + ? -negMax * (1 - t / pivot) + : 0 + : pivot < 1 + ? posMax * ((t - pivot) / (1 - pivot)) + : 0; + doc.setFillColor(...parseRgb(scale(value, negMax, posMax))); // Overlap by a hair so no seams show between bars. doc.rect(barX + (i * barW) / steps, y, barW / steps + 0.1, barH, "F"); } @@ -219,8 +237,16 @@ function drawLegend(doc: jsPDF, absMax: number, y: number): number { doc.setFont("courier", "normal"); doc.setFontSize(7.5); doc.setTextColor(...INK_SOFT); - doc.text(formatSeriesValue(-absMax), MARGIN, y + barH - 0.3); - doc.text(formatSeriesValue(absMax), barX + barW + 3, y + barH - 0.3); + if (pivot > 0.02 && pivot < 0.98) { + doc.setFillColor(...INK_SOFT); + doc.rect(barX + pivot * barW - 0.15, y - 0.4, 0.3, barH + 0.8, "F"); + } + + doc.setFont("courier", "normal"); + doc.setFontSize(7.5); + doc.setTextColor(...INK_SOFT); + doc.text(formatSeriesValue(-negMax), MARGIN, y + barH - 0.3); + doc.text(formatSeriesValue(posMax), barX + barW + 3, y + barH - 0.3); return y + barH + 4; } @@ -314,7 +340,7 @@ export async function buildReportPdf({ cursor, ); - drawLegend(doc, symmetricAbsMax(values), cursor); + drawLegend(doc, asymmetricExtents(values), cursor); drawFooter(doc, prov, attribution); return doc.output("blob"); diff --git a/src/lib/map/colormapTables.ts b/src/lib/map/colormapTables.ts new file mode 100644 index 0000000..a3cd2cf --- /dev/null +++ b/src/lib/map/colormapTables.ts @@ -0,0 +1,287 @@ +/** + * Lookup tables for the diverging colour maps offered by the fingerprint plot. + * + * These are Fabio Crameri's perceptually uniform, colourblind-safe scientific + * colour maps, downsampled from the official 256-entry tables to 33 stops + * (indistinguishable once interpolated, small enough to read in a diff). Cite + * as: Crameri, F. (2023), Scientific colour maps, Zenodo, + * doi:10.5281/zenodo.1243862 (MIT). RdBu is the familiar ColorBrewer ramp, kept + * as a non-uniform reference point. + * + * Every table here is diverging with a neutral centre, so stop 16 of 33 is the + * zero colour. `vik`, `broc`, `cork` and `roma` are light-centred; `berlin` and + * `vanimo` are dark-centred, so a near-zero cell recedes into a dark panel + * instead of glowing. The Flux palette is not here: it is two sequential CET + * ramps glued at zero rather than a single diverging table, and lives in + * `fingerprintScale.ts` with the rest of the CET data. + */ + +import type { Rgb } from "@/lib/map/fingerprintScale"; + +const VIK: readonly Rgb[] = [ + [0, 18, 97], + [2, 31, 105], + [2, 43, 113], + [2, 55, 121], + [3, 68, 129], + [5, 81, 137], + [12, 94, 146], + [28, 109, 156], + [48, 125, 166], + [72, 141, 178], + [97, 158, 189], + [122, 174, 200], + [148, 190, 210], + [173, 205, 221], + [198, 219, 230], + [222, 230, 233], + [236, 229, 224], + [238, 219, 208], + [233, 204, 186], + [227, 188, 165], + [220, 172, 144], + [214, 157, 124], + [207, 142, 104], + [201, 128, 86], + [195, 114, 67], + [189, 100, 49], + [179, 83, 31], + [165, 64, 15], + [148, 47, 6], + [131, 33, 6], + [116, 21, 6], + [103, 10, 7], + [89, 0, 8], +]; + +const BERLIN: readonly Rgb[] = [ + [158, 176, 255], + [140, 174, 246], + [121, 171, 237], + [101, 167, 226], + [81, 159, 211], + [65, 148, 193], + [54, 133, 173], + [46, 118, 153], + [40, 104, 134], + [34, 89, 115], + [29, 75, 97], + [24, 61, 79], + [20, 48, 62], + [17, 36, 46], + [17, 26, 32], + [18, 18, 20], + [25, 12, 9], + [33, 11, 3], + [42, 14, 1], + [52, 15, 0], + [63, 18, 1], + [75, 22, 2], + [89, 28, 7], + [106, 37, 16], + [123, 50, 28], + [140, 64, 44], + [156, 79, 61], + [172, 94, 79], + [188, 109, 97], + [204, 125, 115], + [221, 141, 134], + [238, 157, 154], + [255, 173, 173], +]; + +const BROC: readonly Rgb[] = [ + [44, 26, 76], + [43, 38, 88], + [42, 49, 100], + [41, 62, 113], + [41, 75, 125], + [46, 88, 137], + [57, 102, 149], + [73, 116, 159], + [91, 130, 169], + [109, 144, 178], + [127, 158, 188], + [146, 172, 198], + [165, 187, 208], + [184, 201, 218], + [203, 216, 228], + [222, 229, 236], + [235, 238, 236], + [237, 238, 225], + [231, 231, 207], + [222, 222, 189], + [212, 212, 170], + [201, 201, 150], + [188, 188, 131], + [172, 172, 113], + [155, 155, 98], + [139, 139, 84], + [123, 123, 71], + [108, 108, 58], + [93, 93, 45], + [78, 78, 33], + [64, 64, 22], + [50, 51, 12], + [38, 38, 0], +]; + +const CORK: readonly Rgb[] = [ + [44, 25, 76], + [43, 38, 89], + [42, 50, 101], + [40, 63, 114], + [40, 75, 126], + [45, 89, 138], + [56, 102, 149], + [71, 115, 158], + [86, 127, 166], + [102, 139, 175], + [119, 152, 184], + [138, 166, 194], + [158, 181, 204], + [178, 197, 215], + [200, 213, 226], + [220, 229, 235], + [230, 237, 236], + [224, 234, 225], + [207, 223, 207], + [188, 211, 188], + [169, 197, 168], + [149, 184, 149], + [130, 171, 129], + [111, 159, 111], + [93, 147, 93], + [77, 136, 76], + [61, 125, 60], + [45, 112, 44], + [32, 99, 30], + [24, 84, 20], + [20, 69, 14], + [17, 55, 9], + [15, 41, 3], +]; + +const ROMA: readonly Rgb[] = [ + [126, 23, 0], + [134, 44, 6], + [143, 60, 12], + [150, 75, 18], + [157, 88, 24], + [163, 101, 30], + [169, 114, 35], + [176, 127, 42], + [182, 140, 50], + [189, 155, 60], + [196, 170, 74], + [203, 186, 93], + [208, 202, 114], + [210, 215, 138], + [209, 226, 161], + [203, 232, 180], + [192, 234, 195], + [179, 233, 205], + [162, 229, 212], + [142, 221, 215], + [121, 210, 215], + [100, 198, 213], + [83, 184, 209], + [68, 171, 204], + [57, 157, 199], + [49, 144, 193], + [43, 132, 188], + [38, 119, 183], + [34, 106, 177], + [30, 93, 171], + [25, 79, 165], + [17, 64, 159], + [3, 49, 152], +]; + +const VANIMO: readonly Rgb[] = [ + [255, 205, 253], + [243, 182, 236], + [230, 160, 220], + [218, 139, 204], + [205, 120, 189], + [192, 103, 174], + [178, 88, 159], + [163, 75, 144], + [146, 62, 128], + [126, 51, 110], + [105, 42, 91], + [84, 33, 72], + [64, 27, 55], + [47, 23, 40], + [36, 20, 30], + [29, 20, 23], + [26, 21, 19], + [25, 24, 17], + [28, 31, 17], + [33, 41, 19], + [41, 53, 22], + [50, 68, 25], + [60, 83, 29], + [71, 98, 33], + [81, 112, 38], + [91, 126, 43], + [101, 140, 49], + [113, 155, 57], + [126, 172, 69], + [140, 190, 85], + [156, 210, 107], + [173, 231, 134], + [190, 253, 165], +]; + +/** ColorBrewer RdBu, reversed so blue is negative and red positive like the rest. */ +const RDBU: readonly Rgb[] = [ + [5, 48, 97], + [33, 102, 172], + [67, 147, 195], + [146, 197, 222], + [209, 229, 240], + [247, 247, 247], + [253, 219, 199], + [244, 165, 130], + [214, 96, 77], + [178, 24, 43], + [103, 0, 31], +]; + +/** Every table that `fingerprintColorScale` can sample, keyed by colormap id. */ +export const DIVERGING_TABLES = { + vik: VIK, + berlin: BERLIN, + broc: BROC, + cork: CORK, + roma: ROMA, + vanimo: VANIMO, + rdbu: RDBU, +} as const; + +export type DivergingTableId = keyof typeof DIVERGING_TABLES; + +function lerpChannel(a: number, b: number, t: number): number { + return Math.round(a + (b - a) * t); +} + +/** + * Colour at position `u` along a table, interpolating between the two + * neighbouring stops. `u` is clamped to [0, 1], so 0.5 is always the map's + * neutral centre. + */ +export function sampleTable(lut: readonly Rgb[], u: number): Rgb { + const t = Math.max(0, Math.min(1, u)); + const pos = t * (lut.length - 1); + const i = Math.floor(pos); + if (i >= lut.length - 1) return lut[lut.length - 1]; + const f = pos - i; + const a = lut[i]; + const b = lut[i + 1]; + return [ + lerpChannel(a[0], b[0], f), + lerpChannel(a[1], b[1], f), + lerpChannel(a[2], b[2], f), + ]; +} diff --git a/src/lib/map/fingerprintScale.test.ts b/src/lib/map/fingerprintScale.test.ts index 258891c..0659c94 100644 --- a/src/lib/map/fingerprintScale.test.ts +++ b/src/lib/map/fingerprintScale.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from "vitest"; +import { DIVERGING_TABLES, sampleTable } from "@/lib/map/colormapTables"; import { asymmetricExtents, + COLORMAPS, + type ColormapId, dayIndexTicks, + defaultColormapId, fingerprintColorScale, + fingerprintRampSamples, formatIsoDate, symmetricAbsMax, yearRangesInWindow, + zeroFrac, } from "@/lib/map/fingerprintScale"; describe("symmetricAbsMax", () => { @@ -45,47 +51,93 @@ describe("asymmetricExtents", () => { }); }); -describe("fingerprintColorScale — science-light (symmetric)", () => { - const scale = fingerprintColorScale("science-light"); +describe("fingerprintColorScale — every palette is centred on zero", () => { + const ids = Object.keys(COLORMAPS) as ColormapId[]; it("maps non-finite values to transparent", () => { - expect(scale(NaN, 5, 5)).toBe("transparent"); - expect(scale(Infinity, 5, 5)).toBe("transparent"); - }); - - it("gives negative and positive extremes distinct hues", () => { - const uptake = scale(-5, 5, 5); - const release = scale(5, 5, 5); - expect(uptake).not.toBe(release); - expect(uptake).toMatch(/^rgb\(/); - expect(release).toMatch(/^rgb\(/); - }); - - it("collapses to the neutral midpoint when absMax is zero", () => { - // With no spread every value is neutral, not an endpoint. - expect(scale(3, 0, 0)).toBe(scale(-3, 0, 0)); + for (const id of ids) { + const scale = fingerprintColorScale(id); + expect(scale(NaN, 5, 5)).toBe("transparent"); + expect(scale(Infinity, 5, 5)).toBe("transparent"); + } + }); + + it("gives the two extremes distinct colours", () => { + for (const id of ids) { + const scale = fingerprintColorScale(id); + expect(scale(-5, 5, 5)).not.toBe(scale(5, 5, 5)); + expect(scale(-5, 5, 5)).toMatch(/^rgb\(/); + } + }); + + it("clamps values beyond either extent to the poles", () => { + for (const id of ids) { + const scale = fingerprintColorScale(id); + expect(scale(-99, 2, 2)).toBe(scale(-2, 2, 2)); + expect(scale(99, 2, 2)).toBe(scale(2, 2, 2)); + } + }); + + it("holds zero at the same colour however lopsided the extents are", () => { + for (const id of ids) { + if (id === "flux") continue; // see the seam test below + const scale = fingerprintColorScale(id); + const atZero = scale(0, 30, 8); + expect(scale(0, 1, 1000)).toBe(atZero); + expect(scale(0, 5, 5)).toBe(atZero); + } + }); + + it("scales each half against its own extent, not the larger one", () => { + for (const id of ids) { + const scale = fingerprintColorScale(id); + // The most-negative value hits the cool pole whether or not the positive + // side reaches as far. Before this, the short side was never reached. + expect(scale(-1, 1, 3)).toBe(scale(-5, 5, 5)); + expect(scale(3, 1, 3)).toBe(scale(5, 5, 5)); + // Equal magnitudes of opposite sign are deliberately not equally intense. + expect(scale(-1, 1, 3)).not.toBe(scale(1, 1, 3)); + } + }); + + it("leaves an unused half neutral rather than dividing by zero", () => { + for (const id of ids) { + if (id === "flux") continue; // see the seam test below + const scale = fingerprintColorScale(id); + expect(scale(3, 0, 0)).toBe(scale(-3, 0, 0)); + } }); }); -describe("fingerprintColorScale — science-dark (symmetric)", () => { - const scale = fingerprintColorScale("science-dark"); - - it("maps non-finite values to transparent", () => { - expect(scale(NaN, 5, 5)).toBe("transparent"); - }); - - it("gives different colours to different values", () => { - expect(scale(-5, 5, 5)).not.toBe(scale(5, 5, 5)); - }); - - it("produces different colours than science-light", () => { - const light = fingerprintColorScale("science-light")(-5, 5, 5); - const dark = fingerprintColorScale("science-dark")(-5, 5, 5); - expect(light).not.toBe(dark); +describe("fingerprintColorScale — table palettes", () => { + it("walks the real lookup table, centre stop at zero", () => { + const scale = fingerprintColorScale("vik"); + const table = DIVERGING_TABLES.vik; + const at = (u: number) => { + const [r, g, b] = sampleTable(table, u); + return `rgb(${r}, ${g}, ${b})`; + }; + expect(scale(0, 30, 8)).toBe(at(0.5)); + expect(scale(-30, 30, 8)).toBe(at(0)); + expect(scale(8, 30, 8)).toBe(at(1)); + // Half of each arm sits a quarter in from its own end. + expect(scale(-15, 30, 8)).toBe(at(0.25)); + expect(scale(4, 30, 8)).toBe(at(0.75)); + }); + + it("gives light and dark defaults different neutrals", () => { + const luma = (css: string) => { + const [r, g, b] = css.match(/\d+/g)!.map(Number); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const light = fingerprintColorScale(defaultColormapId(true))(0, 5, 5); + const dark = fingerprintColorScale(defaultColormapId(false))(0, 5, 5); + expect(luma(light)).toBeGreaterThan(180); + expect(luma(dark)).toBeLessThan(60); }); }); -describe("fingerprintColorScale — flux (asymmetric)", () => { +describe("fingerprintColorScale — flux", () => { const scale = fingerprintColorScale("flux"); it("maps non-finite values to transparent", () => { @@ -98,19 +150,50 @@ describe("fingerprintColorScale — flux (asymmetric)", () => { expect(scale(0, 1, 3)).toMatch(/^rgb\(/); }); - it("zero maps to the kbc end (near cyan)", () => { - // At value=0 on the negative half (t=0 → kbc index 255), we expect - // approximately [179, 255, 246] from CET_KBC. - const color = scale(0, 1, 3); - expect(color).toMatch(/^rgb\(/); + /* + * Flux is two sequential CET ramps glued at zero rather than one diverging + * table, and the two ends do not meet: approaching zero from below lands on + * kbc\'s pale cyan, from above on kryw\'s white. So the neutral colour depends + * on the sign of the value, and a cell at exactly 0 takes the white side. + * Pinned here so the seam is visible rather than surprising; the single-table + * palettes are continuous through zero by construction. + */ + it("has a documented discontinuity at zero", () => { + const fromBelow = scale(-1e-12, 1, 3); + const atZero = scale(0, 1, 3); + expect(fromBelow).toBe("rgb(179, 255, 246)"); + expect(atZero).toBe("rgb(255, 255, 255)"); + expect(fromBelow).not.toBe(atZero); }); +}); - it("uses full negMax span independently of posMax", () => { - // With asymmetric extents (-1 vs 3), the colour at -1 should be - // the same as at -5 with negMax=5 (both hit the darkest kbc entry). - const colorA = scale(-1, 1, 3); - const colorB = scale(-5, 5, 5); - expect(colorA).toBe(colorB); +describe("colorbar geometry", () => { + it("places zero by the ratio of the two extents", () => { + expect(zeroFrac(30, 10)).toBeCloseTo(0.75, 10); + expect(zeroFrac(5, 5)).toBeCloseTo(0.5, 10); + expect(zeroFrac(0, 0)).toBe(0.5); + }); + + it("samples a bar running from -negMax to +posMax, neutral at the pivot", () => { + const samples = fingerprintRampSamples("vik", 30, 10, 5); + const scale = fingerprintColorScale("vik"); + expect(samples[0].value).toBeCloseTo(-30, 10); + expect(samples[0].color).toBe(scale(-30, 30, 10)); + expect(samples[4].value).toBeCloseTo(10, 10); + expect(samples[4].color).toBe(scale(10, 30, 10)); + // zeroFrac is 0.75, which is sample 3 of 0..4. + expect(samples[3].value).toBeCloseTo(0, 10); + expect(samples[3].color).toBe(scale(0, 30, 10)); + }); + + it("covers every palette without gaps", () => { + for (const id of Object.keys(COLORMAPS) as ColormapId[]) { + const samples = fingerprintRampSamples(id, 2, 7); + expect(samples).toHaveLength(32); + expect(samples[0].frac).toBe(0); + expect(samples[31].frac).toBe(1); + for (const sample of samples) expect(sample.color).toMatch(/^rgb\(/); + } }); }); diff --git a/src/lib/map/fingerprintScale.ts b/src/lib/map/fingerprintScale.ts index 576c3e6..cfd5e89 100644 --- a/src/lib/map/fingerprintScale.ts +++ b/src/lib/map/fingerprintScale.ts @@ -2,37 +2,74 @@ * Color scale and axis helpers for the fingerprint plot (hour-of-day x day * heatmap of NEE flux). NEE is signed: negative means uptake (the ecosystem is a * sink), positive means release (a source). We therefore use a diverging ramp - * that is symmetric around zero, so the sign reads at a glance and midday uptake - * separates cleanly from nighttime respiration. + * pinned at zero, so the sign reads at a glance and midday uptake separates + * cleanly from nighttime respiration. * - * Three colormaps are available: - * - "science-light": blue→grey→red diverging, tuned for light backgrounds. - * - "science-dark": lighter poles for dark backgrounds. - * - "flux": Crameri/Kovesi CET perceptually-uniform palette: - * negative half = linear_kbc_5_95_c73 (dark-blue → cyan), - * positive half = Reverse(linear_kryw_5_100_c67) (white → dark). - * Supports true asymmetric extent: negMax and posMax may differ. + * The ramp is centred on zero but not symmetric. Uptake and release rarely have + * the same magnitude (midday uptake can be several times the nighttime source), + * and a symmetric range spends most of one half of the ramp on values the cell + * never reaches. Each half is instead stretched to its own extreme, so both ends + * of every palette are in use and weak-side structure stays visible. The cost is + * that two values of equal magnitude and opposite sign do not read as equally + * intense, which is why every colorbar labels both real extremes and marks zero. + * + * Palettes: + * - vik, berlin, broc, cork, roma, vanimo: Crameri's perceptually uniform + * scientific colour maps, as real lookup tables (see `colormapTables.ts`). + * vik and berlin are the defaults for light and dark surfaces. + * - rdbu: ColorBrewer RdBu, a familiar non-uniform reference point. + * - flux: Crameri/Kovesi CET pair, negative half = linear_kbc_5_95_c73 + * (dark-blue -> cyan), positive half = Reverse(linear_kryw_5_100_c67) + * (white -> dark). Two sequential ramps glued at zero rather than one + * diverging table, so it keeps its own branch in the scale. */ +import { + DIVERGING_TABLES, + type DivergingTableId, + sampleTable, +} from "@/lib/map/colormapTables"; import { dayIndexToUTCDate } from "@/lib/zarr/timeRange"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- -export type ColormapId = "science-light" | "science-dark" | "flux"; +export type ColormapId = DivergingTableId | "flux"; export const COLORMAPS: Record< ColormapId, { label: string; description: string } > = { - "science-light": { - label: "Science", - description: "Diverging blue–red, tuned for light backgrounds", + vik: { + label: "vik", + description: "Crameri vik: blue to white to red, tuned for light surfaces", + }, + berlin: { + label: "berlin", + description: + "Crameri berlin: blue to black to red, tuned for dark surfaces", + }, + broc: { + label: "broc", + description: "Crameri broc: blue to white to olive", + }, + cork: { + label: "cork", + description: "Crameri cork: blue to white to green", + }, + roma: { + label: "roma", + description: "Crameri roma: red to yellow to blue", }, - "science-dark": { - label: "Science Dark", - description: "Diverging blue–red, lifted for dark backgrounds", + vanimo: { + label: "vanimo", + description: "Crameri vanimo: pink to black to green, for dark surfaces", + }, + rdbu: { + label: "RdBu", + description: + "ColorBrewer RdBu: blue to white to red, not perceptually uniform", }, flux: { label: "Flux", @@ -41,34 +78,12 @@ export const COLORMAPS: Record< }, }; -export type Rgb = readonly [number, number, number]; - -// --------------------------------------------------------------------------- -// Science colormap poles -// --------------------------------------------------------------------------- - -/* - * Poles follow the geoscience-standard "vik" diverging convention (Crameri's - * perceptually uniform, colourblind-safe scientific colour maps): a cool blue - * for uptake and a warm red for release, so warm reading as CO2 emission matches - * how signed anomaly fields are shown in the flux/climate literature. Dark-mode - * poles are lifted so they stay legible on the dark surface. - */ - -/** Uptake (negative) end: blue. */ -const UPTAKE_LIGHT: Rgb = [33, 102, 172]; // #2166ac -const UPTAKE_DARK: Rgb = [106, 168, 224]; // #6aa8e0 - -/** Release (positive) end: red. */ -const RELEASE_LIGHT: Rgb = [178, 24, 43]; // #b2182b -const RELEASE_DARK: Rgb = [232, 114, 76]; // #e8724c +/** Palette to use when the visitor has not picked one, per surface theme. */ +export function defaultColormapId(isLight: boolean): ColormapId { + return isLight ? "vik" : "berlin"; +} -/** - * Zero end is a neutral gray kept distinct from both the panel background and - * from transparent gaps, so a near-zero cell never looks like missing data. - */ -const MID_LIGHT: Rgb = [235, 235, 231]; -const MID_DARK: Rgb = [66, 66, 64]; +export type Rgb = readonly [number, number, number]; // --------------------------------------------------------------------------- // Flux colormap: CET linear_kbc_5_95_c73 and Reverse(linear_kryw_5_100_c67) @@ -79,138 +94,96 @@ const MID_DARK: Rgb = [66, 66, 64]; // linear_kbc_5_95_c73 (256 entries, [r,g,b,...]) const CET_KBC = new Uint8Array([ - 0, 1, 78, 0, 1, 80, 0, 2, 82, 0, 2, 84, - 0, 2, 86, 0, 3, 88, 0, 3, 90, 0, 3, 92, - 0, 3, 94, 0, 3, 96, 1, 3, 98, 2, 3, 100, - 2, 3, 102, 3, 3, 104, 4, 2, 106, 5, 2, 108, - 5, 2, 110, 6, 2, 112, 7, 2, 114, 8, 2, 116, - 8, 2, 118, 9, 2, 120, 10, 2, 123, 11, 2, 125, - 11, 2, 127, 12, 2, 129, 13, 2, 131, 13, 2, 133, - 14, 2, 136, 14, 1, 138, 15, 1, 140, 15, 1, 142, - 15, 1, 144, 16, 1, 147, 16, 1, 149, 16, 1, 151, - 16, 1, 153, 16, 2, 156, 16, 2, 158, 16, 2, 160, - 16, 2, 162, 15, 2, 165, 15, 2, 167, 14, 3, 170, - 13, 3, 172, 13, 3, 174, 12, 4, 177, 11, 4, 179, - 10, 4, 181, 10, 5, 183, 9, 5, 185, 9, 6, 188, - 8, 7, 190, 8, 7, 192, 8, 8, 194, 8, 9, 196, - 8, 10, 198, 8, 10, 200, 8, 11, 202, 8, 12, 204, - 9, 13, 206, 9, 14, 208, 10, 15, 210, 11, 16, 212, - 12, 17, 214, 13, 18, 216, 14, 18, 217, 16, 19, 219, - 17, 20, 221, 18, 21, 223, 19, 22, 224, 21, 23, 226, - 22, 24, 227, 24, 25, 229, 25, 26, 231, 26, 28, 232, - 27, 29, 233, 28, 30, 235, 29, 31, 236, 31, 33, 237, - 32, 34, 238, 33, 36, 240, 34, 37, 241, 35, 38, 242, - 35, 40, 243, 36, 41, 244, 37, 43, 244, 38, 45, 245, - 39, 46, 246, 40, 48, 247, 40, 49, 247, 41, 51, 248, - 42, 53, 248, 42, 54, 249, 43, 56, 249, 43, 58, 250, - 44, 59, 250, 44, 61, 250, 45, 63, 250, 45, 65, 251, - 45, 66, 251, 46, 68, 251, 46, 70, 251, 46, 71, 251, - 47, 73, 251, 47, 75, 251, 47, 76, 251, 47, 78, 251, - 47, 79, 251, 47, 81, 251, 47, 82, 252, 48, 84, 252, - 48, 86, 252, 48, 87, 252, 48, 89, 252, 48, 90, 252, - 48, 92, 252, 48, 93, 252, 47, 94, 252, 47, 96, 252, - 47, 97, 252, 47, 99, 253, 47, 100, 253, 47, 102, 253, - 46, 103, 253, 46, 105, 253, 46, 106, 253, 45, 107, 253, - 45, 109, 253, 45, 110, 253, 45, 112, 253, 44, 113, 253, - 44, 114, 253, 44, 116, 253, 44, 117, 254, 44, 118, 254, - 43, 120, 254, 43, 121, 254, 43, 123, 254, 43, 124, 254, - 43, 125, 254, 44, 126, 254, 44, 128, 254, 44, 129, 254, - 44, 130, 254, 44, 132, 254, 45, 133, 254, 45, 134, 254, - 45, 135, 254, 46, 137, 254, 46, 138, 254, 47, 139, 254, - 47, 141, 254, 48, 142, 254, 48, 143, 254, 49, 144, 253, - 50, 146, 253, 50, 147, 253, 51, 148, 253, 51, 149, 253, - 52, 150, 253, 52, 152, 253, 53, 153, 253, 53, 154, 253, - 53, 155, 253, 53, 157, 253, 54, 158, 253, 54, 159, 253, - 54, 160, 253, 54, 162, 253, 54, 163, 253, 54, 164, 253, - 54, 166, 253, 54, 167, 253, 53, 168, 253, 53, 169, 252, - 53, 171, 252, 53, 172, 252, 52, 173, 252, 52, 174, 252, - 51, 176, 252, 51, 177, 252, 50, 178, 252, 49, 180, 252, - 49, 181, 252, 48, 182, 252, 47, 183, 252, 46, 185, 252, - 46, 186, 252, 45, 187, 252, 44, 188, 252, 44, 190, 252, - 43, 191, 252, 43, 192, 252, 42, 194, 252, 42, 195, 252, - 42, 196, 252, 41, 197, 252, 41, 199, 252, 41, 200, 252, - 41, 201, 251, 41, 202, 251, 41, 203, 251, 41, 205, 251, - 41, 206, 251, 41, 207, 251, 41, 208, 251, 42, 210, 251, - 42, 211, 251, 43, 212, 251, 43, 213, 251, 44, 214, 251, - 44, 216, 250, 45, 217, 250, 47, 218, 250, 48, 219, 250, - 51, 220, 250, 53, 221, 250, 56, 222, 250, 60, 223, 250, - 63, 225, 250, 66, 226, 249, 70, 227, 249, 73, 228, 249, - 77, 229, 249, 80, 229, 249, 84, 230, 249, 87, 231, 249, - 91, 232, 249, 94, 233, 249, 98, 234, 248, 101, 235, 248, - 105, 236, 248, 108, 237, 248, 112, 238, 248, 115, 239, 248, - 118, 240, 248, 122, 240, 248, 125, 241, 248, 128, 242, 247, - 132, 243, 247, 135, 244, 247, 138, 245, 247, 142, 245, 247, - 145, 246, 247, 148, 247, 247, 151, 248, 247, 154, 249, 246, - 157, 249, 246, 161, 250, 246, 164, 251, 246, 167, 252, 246, - 170, 253, 246, 173, 253, 246, 176, 254, 246, 179, 255, 246, + 0, 1, 78, 0, 1, 80, 0, 2, 82, 0, 2, 84, 0, 2, 86, 0, 3, 88, 0, 3, 90, 0, 3, + 92, 0, 3, 94, 0, 3, 96, 1, 3, 98, 2, 3, 100, 2, 3, 102, 3, 3, 104, 4, 2, 106, + 5, 2, 108, 5, 2, 110, 6, 2, 112, 7, 2, 114, 8, 2, 116, 8, 2, 118, 9, 2, 120, + 10, 2, 123, 11, 2, 125, 11, 2, 127, 12, 2, 129, 13, 2, 131, 13, 2, 133, 14, 2, + 136, 14, 1, 138, 15, 1, 140, 15, 1, 142, 15, 1, 144, 16, 1, 147, 16, 1, 149, + 16, 1, 151, 16, 1, 153, 16, 2, 156, 16, 2, 158, 16, 2, 160, 16, 2, 162, 15, 2, + 165, 15, 2, 167, 14, 3, 170, 13, 3, 172, 13, 3, 174, 12, 4, 177, 11, 4, 179, + 10, 4, 181, 10, 5, 183, 9, 5, 185, 9, 6, 188, 8, 7, 190, 8, 7, 192, 8, 8, 194, + 8, 9, 196, 8, 10, 198, 8, 10, 200, 8, 11, 202, 8, 12, 204, 9, 13, 206, 9, 14, + 208, 10, 15, 210, 11, 16, 212, 12, 17, 214, 13, 18, 216, 14, 18, 217, 16, 19, + 219, 17, 20, 221, 18, 21, 223, 19, 22, 224, 21, 23, 226, 22, 24, 227, 24, 25, + 229, 25, 26, 231, 26, 28, 232, 27, 29, 233, 28, 30, 235, 29, 31, 236, 31, 33, + 237, 32, 34, 238, 33, 36, 240, 34, 37, 241, 35, 38, 242, 35, 40, 243, 36, 41, + 244, 37, 43, 244, 38, 45, 245, 39, 46, 246, 40, 48, 247, 40, 49, 247, 41, 51, + 248, 42, 53, 248, 42, 54, 249, 43, 56, 249, 43, 58, 250, 44, 59, 250, 44, 61, + 250, 45, 63, 250, 45, 65, 251, 45, 66, 251, 46, 68, 251, 46, 70, 251, 46, 71, + 251, 47, 73, 251, 47, 75, 251, 47, 76, 251, 47, 78, 251, 47, 79, 251, 47, 81, + 251, 47, 82, 252, 48, 84, 252, 48, 86, 252, 48, 87, 252, 48, 89, 252, 48, 90, + 252, 48, 92, 252, 48, 93, 252, 47, 94, 252, 47, 96, 252, 47, 97, 252, 47, 99, + 253, 47, 100, 253, 47, 102, 253, 46, 103, 253, 46, 105, 253, 46, 106, 253, 45, + 107, 253, 45, 109, 253, 45, 110, 253, 45, 112, 253, 44, 113, 253, 44, 114, + 253, 44, 116, 253, 44, 117, 254, 44, 118, 254, 43, 120, 254, 43, 121, 254, 43, + 123, 254, 43, 124, 254, 43, 125, 254, 44, 126, 254, 44, 128, 254, 44, 129, + 254, 44, 130, 254, 44, 132, 254, 45, 133, 254, 45, 134, 254, 45, 135, 254, 46, + 137, 254, 46, 138, 254, 47, 139, 254, 47, 141, 254, 48, 142, 254, 48, 143, + 254, 49, 144, 253, 50, 146, 253, 50, 147, 253, 51, 148, 253, 51, 149, 253, 52, + 150, 253, 52, 152, 253, 53, 153, 253, 53, 154, 253, 53, 155, 253, 53, 157, + 253, 54, 158, 253, 54, 159, 253, 54, 160, 253, 54, 162, 253, 54, 163, 253, 54, + 164, 253, 54, 166, 253, 54, 167, 253, 53, 168, 253, 53, 169, 252, 53, 171, + 252, 53, 172, 252, 52, 173, 252, 52, 174, 252, 51, 176, 252, 51, 177, 252, 50, + 178, 252, 49, 180, 252, 49, 181, 252, 48, 182, 252, 47, 183, 252, 46, 185, + 252, 46, 186, 252, 45, 187, 252, 44, 188, 252, 44, 190, 252, 43, 191, 252, 43, + 192, 252, 42, 194, 252, 42, 195, 252, 42, 196, 252, 41, 197, 252, 41, 199, + 252, 41, 200, 252, 41, 201, 251, 41, 202, 251, 41, 203, 251, 41, 205, 251, 41, + 206, 251, 41, 207, 251, 41, 208, 251, 42, 210, 251, 42, 211, 251, 43, 212, + 251, 43, 213, 251, 44, 214, 251, 44, 216, 250, 45, 217, 250, 47, 218, 250, 48, + 219, 250, 51, 220, 250, 53, 221, 250, 56, 222, 250, 60, 223, 250, 63, 225, + 250, 66, 226, 249, 70, 227, 249, 73, 228, 249, 77, 229, 249, 80, 229, 249, 84, + 230, 249, 87, 231, 249, 91, 232, 249, 94, 233, 249, 98, 234, 248, 101, 235, + 248, 105, 236, 248, 108, 237, 248, 112, 238, 248, 115, 239, 248, 118, 240, + 248, 122, 240, 248, 125, 241, 248, 128, 242, 247, 132, 243, 247, 135, 244, + 247, 138, 245, 247, 142, 245, 247, 145, 246, 247, 148, 247, 247, 151, 248, + 247, 154, 249, 246, 157, 249, 246, 161, 250, 246, 164, 251, 246, 167, 252, + 246, 170, 253, 246, 173, 253, 246, 176, 254, 246, 179, 255, 246, ]); // linear_kryw_5_100_c67 (256 entries, [r,g,b,...]) const CET_KRYW = new Uint8Array([ - 17, 17, 17, 21, 17, 16, 24, 17, 16, 27, 17, 16, - 29, 17, 15, 32, 18, 15, 34, 18, 15, 37, 18, 14, - 39, 18, 14, 41, 17, 14, 44, 17, 13, 46, 17, 13, - 48, 17, 13, 51, 16, 12, 53, 16, 12, 55, 16, 12, - 58, 15, 11, 60, 15, 11, 62, 14, 11, 64, 13, 10, - 66, 13, 10, 69, 12, 9, 71, 11, 9, 73, 10, 9, - 75, 10, 8, 77, 9, 8, 79, 8, 8, 81, 7, 7, - 83, 6, 7, 85, 5, 7, 87, 4, 7, 89, 3, 6, - 91, 2, 6, 93, 1, 6, 95, 0, 5, 97, 0, 5, - 99, 0, 5, 100, 0, 5, 102, 0, 5, 104, 0, 4, - 105, 0, 4, 107, 0, 4, 109, 0, 4, 110, 0, 4, - 112, 0, 4, 113, 0, 3, 115, 0, 3, 117, 0, 3, - 118, 0, 3, 120, 0, 3, 121, 0, 3, 123, 0, 2, - 124, 0, 2, 126, 0, 2, 128, 0, 2, 129, 0, 2, - 131, 0, 2, 132, 0, 2, 134, 0, 2, 136, 0, 1, - 137, 0, 1, 139, 0, 1, 140, 0, 1, 142, 0, 1, - 144, 0, 1, 145, 0, 1, 147, 0, 1, 149, 0, 1, - 150, 0, 1, 152, 0, 0, 153, 0, 0, 155, 0, 0, - 157, 0, 0, 158, 0, 0, 160, 0, 0, 162, 0, 0, - 163, 0, 0, 165, 0, 0, 167, 0, 0, 168, 0, 0, - 170, 0, 0, 172, 0, 0, 173, 0, 0, 175, 0, 0, - 177, 0, 0, 178, 0, 0, 180, 0, 0, 182, 0, 0, - 183, 0, 0, 185, 0, 0, 187, 0, 0, 189, 0, 0, - 190, 0, 0, 192, 0, 0, 194, 0, 0, 196, 0, 0, - 197, 0, 0, 199, 0, 0, 201, 0, 0, 202, 0, 0, - 204, 0, 0, 206, 0, 0, 208, 0, 0, 209, 0, 0, - 211, 0, 0, 213, 0, 0, 214, 0, 0, 216, 0, 0, - 218, 0, 0, 219, 0, 0, 221, 0, 0, 223, 0, 0, - 224, 0, 0, 226, 0, 0, 228, 0, 0, 229, 0, 0, - 231, 0, 0, 233, 0, 0, 234, 0, 0, 236, 1, 0, - 238, 2, 0, 239, 4, 0, 241, 5, 0, 242, 7, 0, - 244, 10, 0, 245, 13, 0, 247, 16, 0, 248, 18, 0, - 249, 21, 0, 251, 24, 0, 252, 27, 0, 253, 30, 0, - 254, 33, 0, 255, 35, 0, 255, 38, 0, 255, 41, 0, - 255, 44, 0, 255, 47, 0, 255, 49, 0, 255, 52, 0, - 255, 55, 0, 255, 58, 0, 255, 61, 0, 255, 63, 0, - 255, 66, 0, 255, 69, 0, 255, 72, 0, 255, 74, 0, - 255, 77, 0, 255, 80, 0, 255, 82, 0, 255, 85, 0, - 255, 88, 0, 255, 90, 0, 255, 92, 0, 255, 95, 0, - 255, 97, 1, 255, 99, 1, 255, 102, 1, 255, 104, 2, - 255, 106, 2, 255, 108, 3, 255, 110, 3, 255, 112, 3, - 255, 114, 4, 255, 117, 4, 255, 119, 5, 255, 121, 5, - 255, 123, 5, 255, 125, 6, 255, 127, 6, 255, 129, 7, - 255, 130, 7, 255, 132, 7, 255, 134, 8, 255, 136, 8, - 255, 138, 8, 255, 140, 9, 255, 142, 9, 255, 144, 10, - 255, 145, 10, 255, 147, 10, 255, 149, 11, 255, 151, 11, - 255, 153, 12, 255, 154, 12, 255, 156, 12, 255, 158, 13, - 255, 160, 13, 255, 161, 13, 255, 163, 14, 255, 165, 14, - 255, 166, 14, 255, 168, 15, 255, 170, 15, 255, 171, 15, - 255, 173, 16, 255, 175, 16, 255, 176, 16, 255, 178, 17, - 255, 179, 17, 255, 181, 17, 255, 183, 18, 255, 184, 20, - 255, 186, 23, 255, 187, 27, 255, 189, 31, 255, 190, 35, - 255, 192, 39, 255, 193, 44, 255, 194, 48, 255, 196, 52, - 255, 197, 57, 255, 199, 61, 255, 200, 65, 255, 201, 70, - 255, 203, 74, 255, 204, 78, 255, 205, 83, 255, 207, 87, - 255, 208, 91, 255, 209, 96, 255, 211, 100, 255, 212, 104, - 255, 213, 109, 255, 215, 113, 255, 216, 117, 255, 217, 122, - 255, 219, 126, 255, 220, 131, 255, 221, 135, 255, 223, 140, - 255, 224, 144, 255, 225, 149, 255, 227, 153, 255, 228, 158, - 255, 229, 163, 255, 231, 167, 255, 232, 172, 255, 233, 177, - 255, 235, 181, 255, 236, 186, 255, 237, 191, 255, 239, 196, - 255, 240, 200, 255, 241, 205, 255, 243, 210, 255, 244, 215, - 255, 245, 220, 255, 247, 225, 255, 248, 230, 255, 250, 235, - 255, 251, 240, 255, 252, 245, 255, 254, 250, 255, 255, 255, + 17, 17, 17, 21, 17, 16, 24, 17, 16, 27, 17, 16, 29, 17, 15, 32, 18, 15, 34, + 18, 15, 37, 18, 14, 39, 18, 14, 41, 17, 14, 44, 17, 13, 46, 17, 13, 48, 17, + 13, 51, 16, 12, 53, 16, 12, 55, 16, 12, 58, 15, 11, 60, 15, 11, 62, 14, 11, + 64, 13, 10, 66, 13, 10, 69, 12, 9, 71, 11, 9, 73, 10, 9, 75, 10, 8, 77, 9, 8, + 79, 8, 8, 81, 7, 7, 83, 6, 7, 85, 5, 7, 87, 4, 7, 89, 3, 6, 91, 2, 6, 93, 1, + 6, 95, 0, 5, 97, 0, 5, 99, 0, 5, 100, 0, 5, 102, 0, 5, 104, 0, 4, 105, 0, 4, + 107, 0, 4, 109, 0, 4, 110, 0, 4, 112, 0, 4, 113, 0, 3, 115, 0, 3, 117, 0, 3, + 118, 0, 3, 120, 0, 3, 121, 0, 3, 123, 0, 2, 124, 0, 2, 126, 0, 2, 128, 0, 2, + 129, 0, 2, 131, 0, 2, 132, 0, 2, 134, 0, 2, 136, 0, 1, 137, 0, 1, 139, 0, 1, + 140, 0, 1, 142, 0, 1, 144, 0, 1, 145, 0, 1, 147, 0, 1, 149, 0, 1, 150, 0, 1, + 152, 0, 0, 153, 0, 0, 155, 0, 0, 157, 0, 0, 158, 0, 0, 160, 0, 0, 162, 0, 0, + 163, 0, 0, 165, 0, 0, 167, 0, 0, 168, 0, 0, 170, 0, 0, 172, 0, 0, 173, 0, 0, + 175, 0, 0, 177, 0, 0, 178, 0, 0, 180, 0, 0, 182, 0, 0, 183, 0, 0, 185, 0, 0, + 187, 0, 0, 189, 0, 0, 190, 0, 0, 192, 0, 0, 194, 0, 0, 196, 0, 0, 197, 0, 0, + 199, 0, 0, 201, 0, 0, 202, 0, 0, 204, 0, 0, 206, 0, 0, 208, 0, 0, 209, 0, 0, + 211, 0, 0, 213, 0, 0, 214, 0, 0, 216, 0, 0, 218, 0, 0, 219, 0, 0, 221, 0, 0, + 223, 0, 0, 224, 0, 0, 226, 0, 0, 228, 0, 0, 229, 0, 0, 231, 0, 0, 233, 0, 0, + 234, 0, 0, 236, 1, 0, 238, 2, 0, 239, 4, 0, 241, 5, 0, 242, 7, 0, 244, 10, 0, + 245, 13, 0, 247, 16, 0, 248, 18, 0, 249, 21, 0, 251, 24, 0, 252, 27, 0, 253, + 30, 0, 254, 33, 0, 255, 35, 0, 255, 38, 0, 255, 41, 0, 255, 44, 0, 255, 47, 0, + 255, 49, 0, 255, 52, 0, 255, 55, 0, 255, 58, 0, 255, 61, 0, 255, 63, 0, 255, + 66, 0, 255, 69, 0, 255, 72, 0, 255, 74, 0, 255, 77, 0, 255, 80, 0, 255, 82, 0, + 255, 85, 0, 255, 88, 0, 255, 90, 0, 255, 92, 0, 255, 95, 0, 255, 97, 1, 255, + 99, 1, 255, 102, 1, 255, 104, 2, 255, 106, 2, 255, 108, 3, 255, 110, 3, 255, + 112, 3, 255, 114, 4, 255, 117, 4, 255, 119, 5, 255, 121, 5, 255, 123, 5, 255, + 125, 6, 255, 127, 6, 255, 129, 7, 255, 130, 7, 255, 132, 7, 255, 134, 8, 255, + 136, 8, 255, 138, 8, 255, 140, 9, 255, 142, 9, 255, 144, 10, 255, 145, 10, + 255, 147, 10, 255, 149, 11, 255, 151, 11, 255, 153, 12, 255, 154, 12, 255, + 156, 12, 255, 158, 13, 255, 160, 13, 255, 161, 13, 255, 163, 14, 255, 165, 14, + 255, 166, 14, 255, 168, 15, 255, 170, 15, 255, 171, 15, 255, 173, 16, 255, + 175, 16, 255, 176, 16, 255, 178, 17, 255, 179, 17, 255, 181, 17, 255, 183, 18, + 255, 184, 20, 255, 186, 23, 255, 187, 27, 255, 189, 31, 255, 190, 35, 255, + 192, 39, 255, 193, 44, 255, 194, 48, 255, 196, 52, 255, 197, 57, 255, 199, 61, + 255, 200, 65, 255, 201, 70, 255, 203, 74, 255, 204, 78, 255, 205, 83, 255, + 207, 87, 255, 208, 91, 255, 209, 96, 255, 211, 100, 255, 212, 104, 255, 213, + 109, 255, 215, 113, 255, 216, 117, 255, 217, 122, 255, 219, 126, 255, 220, + 131, 255, 221, 135, 255, 223, 140, 255, 224, 144, 255, 225, 149, 255, 227, + 153, 255, 228, 158, 255, 229, 163, 255, 231, 167, 255, 232, 172, 255, 233, + 177, 255, 235, 181, 255, 236, 186, 255, 237, 191, 255, 239, 196, 255, 240, + 200, 255, 241, 205, 255, 243, 210, 255, 244, 215, 255, 245, 220, 255, 247, + 225, 255, 248, 230, 255, 250, 235, 255, 251, 240, 255, 252, 245, 255, 254, + 250, 255, 255, 255, ]); /** Sample a CET Uint8Array table at a normalised position t ∈ [0, 1]. */ @@ -222,9 +195,9 @@ function sampleCet(table: Uint8Array, t: number): Rgb { const i0 = lo * 3; const i1 = hi * 3; return [ - Math.round((table[i0]! + (table[i1]! - table[i0]!) * frac)), - Math.round((table[i0 + 1]! + (table[i1 + 1]! - table[i0 + 1]!) * frac)), - Math.round((table[i0 + 2]! + (table[i1 + 2]! - table[i0 + 2]!) * frac)), + Math.round(table[i0]! + (table[i1]! - table[i0]!) * frac), + Math.round(table[i0 + 1]! + (table[i1 + 1]! - table[i0 + 1]!) * frac), + Math.round(table[i0 + 2]! + (table[i1 + 2]! - table[i0 + 2]!) * frac), ]; } @@ -295,54 +268,64 @@ export function asymmetricExtents(values: ArrayLike): { } // --------------------------------------------------------------------------- -// Legend stops +// Colorbar geometry // --------------------------------------------------------------------------- /** - * Endpoint swatches for a legend bar, matching what each scale produces at - * its extreme values. Used both in the in-app legend and in the export. + * Where zero sits along a colorbar drawn from `-negMax` to `+posMax`. With the + * two halves scaled independently this is rarely 0.5, and it must drive both + * the gradient's split point and the "0" label, or the two drift apart. */ -export function fingerprintLegendStopsForColormap(colormapId: ColormapId): { - uptake: string; - mid: string; - release: string; -} { - switch (colormapId) { - case "science-light": - return { - uptake: rgbString(UPTAKE_LIGHT), - mid: rgbString(MID_LIGHT), - release: rgbString(RELEASE_LIGHT), - }; - case "science-dark": - return { - uptake: rgbString(UPTAKE_DARK), - mid: rgbString(MID_DARK), - release: rgbString(RELEASE_DARK), - }; - case "flux": - // kbc[0] = most-negative (dark blue), kbc[255] = zero (light cyan) - // Reversed kryw: kryw[255] = zero (white), kryw[0] = most-positive (dark) - return { - uptake: rgbString(sampleCet(CET_KBC, 0)), // [0, 1, 78] - mid: rgbString(sampleCet(CET_KBC, 1)), // [179, 255, 246] — cyan at zero - release: rgbString(sampleCet(CET_KRYW, 0)), // [17, 17, 17] — dark at posMax - }; - } +export function zeroFrac(negMax: number, posMax: number): number { + const span = negMax + posMax; + if (!(span > 0)) return 0.5; + return negMax / span; } +/** One sample along a colorbar: its position, the value there, and its colour. */ +export type RampSample = { frac: number; value: number; color: string }; + /** - * Convenience overload for the export path, which always renders in light mode. - * @deprecated Prefer {@link fingerprintLegendStopsForColormap}. + * Sample a colorbar by walking the bar in *value* space, pivoting at + * {@link zeroFrac}. Sampling the real scale (rather than interpolating between + * endpoint swatches) is what keeps the bar honest for every palette, Flux + * included, and puts the neutral colour exactly where the "0" label goes. + * + * `steps` must be at least 16; the in-app bar and every export use this. */ -export function fingerprintLegendStops(isLight: boolean): { - uptake: string; - mid: string; - release: string; -} { - return fingerprintLegendStopsForColormap( - isLight ? "science-light" : "science-dark", +export function fingerprintRampSamples( + colormapId: ColormapId, + negMax: number, + posMax: number, + steps = 32, +): RampSample[] { + const scale = fingerprintColorScale(colormapId); + const pivot = zeroFrac(negMax, posMax); + const samples: RampSample[] = []; + for (let i = 0; i < steps; i++) { + const frac = i / (steps - 1); + let value: number; + if (frac <= pivot) { + value = pivot > 0 ? -negMax * (1 - frac / pivot) : 0; + } else { + value = pivot < 1 ? posMax * ((frac - pivot) / (1 - pivot)) : 0; + } + samples.push({ frac, value, color: scale(value, negMax, posMax) }); + } + return samples; +} + +/** The same ramp as a CSS gradient, for the in-app bar and picker swatches. */ +export function fingerprintRampGradient( + colormapId: ColormapId, + negMax: number, + posMax: number, + steps = 32, +): string { + const stops = fingerprintRampSamples(colormapId, negMax, posMax, steps).map( + (s) => `${s.color} ${(s.frac * 100).toFixed(1)}%`, ); + return `linear-gradient(to right, ${stops.join(", ")})`; } // --------------------------------------------------------------------------- @@ -350,57 +333,55 @@ export function fingerprintLegendStops(isLight: boolean): { // --------------------------------------------------------------------------- /** - * Build a diverging color function for the given colormap. - * - * For "science-light" and "science-dark": uses symmetric `absMax` — pass - * `absMax` as both `negMax` and `posMax`. - * - * For "flux": pass separate `negMax` (the magnitude of the most-negative - * finite value) and `posMax` (the most-positive finite value) to get the - * true asymmetric mapping where each ramp half spans the full colour table. - * - * Non-finite values (NaN / ocean / missing) are returned as "transparent". + * Position of `value` along a diverging palette, in [0, 1] with 0.5 at zero. + * Each half is scaled independently, so `-negMax` lands on 0 and `+posMax` on 1 + * however lopsided the two are. Values beyond either extreme clamp; a half with + * no data collapses to the neutral centre rather than dividing by zero. */ -export function fingerprintColorScale(colormapId: ColormapId): ( +function divergingPosition( value: number, negMax: number, posMax: number, -) => string { - switch (colormapId) { - case "science-light": - case "science-dark": { - const uptake = colormapId === "science-light" ? UPTAKE_LIGHT : UPTAKE_DARK; - const release = - colormapId === "science-light" ? RELEASE_LIGHT : RELEASE_DARK; - const mid = colormapId === "science-light" ? MID_LIGHT : MID_DARK; - - return (value, negMax, posMax) => { - if (!Number.isFinite(value)) return "transparent"; - // Science maps use symmetric absMax — whichever extent the caller - // computed, we take the larger one so zero stays centred. - const absMax = Math.max(negMax, posMax); - if (absMax <= 0) return rgbString(mid); - const t = Math.max(-1, Math.min(1, value / absMax)); - const end = t < 0 ? uptake : release; - return rgbString(lerpRgb(mid, end, Math.abs(t))); - }; - } +): number { + if (value < 0) { + if (!(negMax > 0)) return 0.5; + return 0.5 * (1 - Math.min(1, -value / negMax)); + } + if (!(posMax > 0)) return 0.5; + return 0.5 + 0.5 * Math.min(1, value / posMax); +} - case "flux": { - return (value, negMax, posMax) => { - if (!Number.isFinite(value)) return "transparent"; - if (value < 0) { - // Negative half: kbc traversed from index 255 (zero) → 0 (−negMax). - const t = Math.max(0, Math.min(1, -value / negMax)); - return rgbString(sampleCet(CET_KBC, 1 - t)); - } else { - // Positive half: reversed kryw traversed from index 255 (zero) → 0 (+posMax). - const t = Math.max(0, Math.min(1, value / posMax)); - return rgbString(sampleCet(CET_KRYW, 1 - t)); - } - }; - } +/** + * Build the colour function for a palette. The returned function takes the + * cell value plus both extents from {@link asymmetricExtents}; non-finite + * values (NaN over ocean or missing pixels) are transparent so gaps read as + * gaps rather than as zero. + */ +export function fingerprintColorScale( + colormapId: ColormapId, +): (value: number, negMax: number, posMax: number) => string { + if (colormapId === "flux") { + // Two sequential ramps meeting at zero, each spanning its own half. + return (value, negMax, posMax) => { + if (!Number.isFinite(value)) return "transparent"; + if (value < 0) { + // kbc traversed from index 255 (zero) to 0 (-negMax). + const t = negMax > 0 ? Math.max(0, Math.min(1, -value / negMax)) : 0; + return rgbString(sampleCet(CET_KBC, 1 - t)); + } + // Reversed kryw traversed from index 255 (zero) to 0 (+posMax). + const t = posMax > 0 ? Math.max(0, Math.min(1, value / posMax)) : 0; + return rgbString(sampleCet(CET_KRYW, 1 - t)); + }; } + + const table = DIVERGING_TABLES[colormapId]; + return (value, negMax, posMax) => { + if (!Number.isFinite(value)) return "transparent"; + return rgbString( + sampleTable(table, divergingPosition(value, negMax, posMax)), + ); + }; } // --------------------------------------------------------------------------- diff --git a/src/lib/settings/colormap.ts b/src/lib/settings/colormap.ts index e7bcd92..e3c25a2 100644 --- a/src/lib/settings/colormap.ts +++ b/src/lib/settings/colormap.ts @@ -1,7 +1,24 @@ -import type { ColormapId } from "@/lib/map/fingerprintScale"; +import { COLORMAPS, type ColormapId } from "@/lib/map/fingerprintScale"; export const COLORMAP_STORAGE_KEY = "earthprints:colormap"; +/** + * Palette ids that shipped before the Crameri lookup tables landed. The two + * "science" maps were hand-rolled three-stop approximations of vik and berlin, + * so a stored preference carries forward to the real table rather than being + * dropped back to the default. + */ +const RENAMED: Record = { + "science-light": "vik", + "science-dark": "berlin", +}; + +function asColormapId(stored: string | null): ColormapId | null { + if (!stored) return null; + if (stored in RENAMED) return RENAMED[stored]; + return stored in COLORMAPS ? (stored as ColormapId) : null; +} + /** * Read the stored colormap preference. Falls back to `null` so callers can * decide their own default (typically the active theme's built-in palette). @@ -9,18 +26,11 @@ export const COLORMAP_STORAGE_KEY = "earthprints:colormap"; export function loadColormap(): ColormapId | null { if (typeof window === "undefined") return null; try { - const stored = localStorage.getItem(COLORMAP_STORAGE_KEY); - if ( - stored === "science-light" || - stored === "science-dark" || - stored === "flux" - ) { - return stored; - } + return asColormapId(localStorage.getItem(COLORMAP_STORAGE_KEY)); } catch { // Private-browsing or quota errors; ignore. + return null; } - return null; } /** Persist the selected colormap. */