From 2171d7b57a9dfdfddf2f83c06162e5e35f4e8724 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Wed, 14 Oct 2026 18:38:37 +0300 Subject: [PATCH] Bring back the palette picker with light and dark pairs The picker had shrunk to Science and Flux. It now offers vik, broc, cork, bam, roma, RdBu and Flux, and each Crameri palette switches to its dark-centred counterpart (berlin, lisbon, tofino, vanimo, managua) in the dark theme. Adds lisbon, tofino, bam and managua from Crameri 8.0.0. On the dark maps, zero now draws in the dark panel colour, so near-zero hours blend into the panel instead of showing as a black band. Older stored choices still load. Sources and licences are in THIRD_PARTY_NOTICES.md. Closes #149 --- THIRD_PARTY_NOTICES.md | 54 +++++++ src/components/panels/ColormapSelect.tsx | 12 +- src/hooks/useSeriesDisplay.ts | 8 +- src/lib/map/colormapTables.ts | 192 +++++++++++++++++++++-- src/lib/map/fingerprintScale.test.ts | 72 ++++++++- src/lib/map/fingerprintScale.ts | 60 ++++++- src/lib/settings/colormap.ts | 9 +- 7 files changed, 374 insertions(+), 33 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..b0023c3 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,54 @@ +# Third-party notices + +EarthPrints is licensed under the Apache License 2.0 (see `LICENSE`). The colour maps used by the fingerprint plot come from the sources below, under their own licences. + +## Scientific colour maps (Fabio Crameri) + +Used for vik, berlin, broc, lisbon, cork, tofino, bam, vanimo, roma and managua, in `src/lib/map/colormapTables.ts`. The tables are the official 256-entry tables from version 8.0.0, downsampled to 33 stops. The dark-centred maps (berlin, lisbon, tofino, vanimo, managua) are modified: their centre is shifted onto the app's dark panel colour, and the shift fades out linearly toward both ends. + +- Source: Crameri, F. (2023). *Scientific colour maps* (8.0.0). Zenodo. https://doi.org/10.5281/zenodo.8035877 +- Website: https://www.fabiocrameri.ch/colourmaps/ +- Licence: MIT + +Further reading: Crameri, F., Shephard, G. E. and Heron, P. J. (2020). The misuse of colour in science communication. *Nature Communications*, 11, 5444. https://doi.org/10.1038/s41467-020-19160-7 + +``` +MIT License + +Copyright (c) Fabio Crameri + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## ColorBrewer (Cynthia A. Brewer) + +Used for RdBu, in `src/lib/map/colormapTables.ts`, reversed so blue is negative. + +- Source: Brewer, C. A. ColorBrewer 2.0. https://colorbrewer2.org +- Repository: https://github.com/axismaps/colorbrewer +- Licence: Apache License 2.0 (the same licence as EarthPrints, full text in `LICENSE`) + +## CET perceptually uniform colour maps (Peter Kovesi) + +Used for Flux, in `src/lib/map/fingerprintScale.ts`: `linear_kbc_5_95_c73` for uptake and reversed `linear_kryw_5_100_c67` for release, taken from the colorcet package. + +- Source: Kovesi, P. (2015). Good colour maps: how to design them. arXiv:1509.03700. https://arxiv.org/abs/1509.03700 +- Website: https://colorcet.com +- Package: colorcet (HoloViz), https://github.com/holoviz/colorcet +- Licence: Creative Commons Attribution 4.0 International (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/ diff --git a/src/components/panels/ColormapSelect.tsx b/src/components/panels/ColormapSelect.tsx index 28c685e..698511a 100644 --- a/src/components/panels/ColormapSelect.tsx +++ b/src/components/panels/ColormapSelect.tsx @@ -6,6 +6,7 @@ import { fingerprintRampGradient, type ColormapId, type Palette, + PALETTES, } from "@/lib/map/fingerprintScale"; import { Select, @@ -16,13 +17,6 @@ import { } from "@/components/ui/select"; import { useTheme } from "@/providers/ThemeProvider"; -const PALETTES: Palette[] = ["science", "flux"]; - -const PALETTE_LABELS: Record = { - science: "Science", - flux: "Flux", -}; - const SWATCHES = Object.fromEntries( (Object.keys(COLORMAPS) as ColormapId[]).map((id) => [ id, @@ -53,7 +47,7 @@ export function ColormapSelect({ value, onChange }: ColormapSelectProps) { - {PALETTE_LABELS[value]} + {COLORMAPS[selected].label} @@ -66,7 +60,7 @@ export function ColormapSelect({ value, onChange }: ColormapSelectProps) { title={COLORMAPS[id].description} > - {PALETTE_LABELS[palette]} + {COLORMAPS[id].label} ); })} diff --git a/src/hooks/useSeriesDisplay.ts b/src/hooks/useSeriesDisplay.ts index c189b3f..f63f606 100644 --- a/src/hooks/useSeriesDisplay.ts +++ b/src/hooks/useSeriesDisplay.ts @@ -9,7 +9,11 @@ import { type TimeBasis, } from "@/lib/zarr/localTime"; import { hasFiniteValues } from "@/lib/zarr/series"; -import { colormapFor, type Palette } from "@/lib/map/fingerprintScale"; +import { + colormapFor, + DEFAULT_PALETTE, + type Palette, +} from "@/lib/map/fingerprintScale"; import { loadPalette, savePalette } from "@/lib/settings/colormap"; import { useTheme } from "@/providers/ThemeProvider"; import type { CellSeries } from "@/hooks/useCellSeries"; @@ -29,7 +33,7 @@ export function useSeriesDisplay( const [plotView, setPlotView] = useState("line"); const [transposed, setTransposed] = useState(false); const [palette, setPaletteState] = useState( - () => loadPalette() ?? "science", + () => loadPalette() ?? DEFAULT_PALETTE, ); const colormapId = colormapFor(palette, isLight); diff --git a/src/lib/map/colormapTables.ts b/src/lib/map/colormapTables.ts index a3cd2cf..6500f8d 100644 --- a/src/lib/map/colormapTables.ts +++ b/src/lib/map/colormapTables.ts @@ -2,16 +2,17 @@ * 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. + * colour maps, downsampled from the official 256-entry tables (version 8.0.0) + * to 33 stops by taking entry round(i * 255 / 32) (indistinguishable once + * interpolated, small enough to read in a diff). RdBu is the familiar + * ColorBrewer ramp, kept as a non-uniform reference point. Sources and licences + * are in THIRD_PARTY_NOTICES.md. * * 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 + * zero colour. `vik`, `broc`, `cork`, `bam` and `roma` are light-centred; `berlin`, + * `lisbon`, `tofino`, `vanimo` and `managua` are their dark-centred + * counterparts, so a near-zero cell recedes into a dark panel instead of + * glowing (see `onDarkPanel`). 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. */ @@ -234,6 +235,150 @@ const VANIMO: readonly Rgb[] = [ [190, 253, 165], ]; +const LISBON: readonly Rgb[] = [ + [230, 229, 255], + [208, 214, 242], + [187, 198, 229], + [165, 183, 217], + [144, 167, 204], + [124, 152, 192], + [104, 137, 179], + [84, 121, 166], + [65, 106, 151], + [48, 90, 134], + [36, 76, 116], + [27, 63, 97], + [22, 51, 79], + [18, 40, 62], + [17, 31, 46], + [18, 25, 33], + [23, 25, 25], + [30, 29, 23], + [40, 38, 26], + [53, 50, 32], + [67, 63, 40], + [81, 76, 49], + [96, 90, 57], + [111, 105, 67], + [127, 120, 76], + [143, 135, 88], + [160, 152, 101], + [177, 169, 117], + [193, 186, 135], + [209, 203, 154], + [224, 220, 175], + [240, 238, 195], + [255, 255, 217], +]; + +const TOFINO: readonly Rgb[] = [ + [222, 217, 255], + [200, 202, 246], + [179, 187, 236], + [158, 172, 227], + [136, 157, 217], + [115, 141, 206], + [95, 125, 193], + [76, 109, 175], + [62, 94, 154], + [52, 80, 134], + [44, 68, 113], + [36, 56, 93], + [29, 45, 74], + [23, 35, 56], + [18, 26, 40], + [15, 21, 27], + [13, 22, 19], + [15, 27, 18], + [18, 35, 20], + [22, 47, 25], + [28, 59, 31], + [34, 72, 37], + [40, 86, 44], + [47, 100, 52], + [55, 115, 60], + [65, 131, 69], + [80, 147, 80], + [100, 164, 92], + [124, 179, 105], + [148, 192, 118], + [172, 205, 131], + [195, 217, 143], + [219, 230, 155], +]; + +const BAM: readonly Rgb[] = [ + [101, 2, 75], + [119, 23, 93], + [136, 39, 110], + [151, 53, 126], + [165, 67, 140], + [178, 80, 153], + [189, 94, 165], + [200, 110, 176], + [209, 127, 188], + [217, 145, 198], + [224, 163, 208], + [231, 181, 218], + [236, 198, 226], + [241, 213, 233], + [245, 226, 239], + [246, 235, 241], + [246, 241, 240], + [243, 243, 237], + [238, 242, 228], + [229, 239, 213], + [216, 232, 194], + [200, 222, 171], + [181, 210, 146], + [161, 196, 122], + [141, 181, 101], + [122, 167, 84], + [105, 153, 70], + [90, 141, 58], + [76, 129, 47], + [62, 117, 37], + [48, 104, 25], + [32, 90, 13], + [13, 76, 0], +]; + +const MANAGUA: readonly Rgb[] = [ + [255, 207, 103], + [245, 191, 98], + [235, 176, 93], + [225, 161, 88], + [216, 147, 83], + [206, 134, 79], + [197, 121, 74], + [187, 109, 70], + [177, 98, 67], + [166, 87, 63], + [154, 76, 61], + [142, 66, 58], + [129, 57, 57], + [116, 49, 57], + [105, 43, 60], + [95, 41, 65], + [87, 41, 73], + [82, 44, 83], + [78, 50, 96], + [76, 59, 111], + [76, 69, 127], + [77, 81, 142], + [80, 93, 156], + [83, 105, 168], + [87, 117, 179], + [92, 129, 189], + [96, 142, 199], + [102, 156, 208], + [107, 170, 218], + [112, 184, 227], + [118, 199, 236], + [123, 215, 245], + [129, 231, 255], +]; + /** ColorBrewer RdBu, reversed so blue is negative and red positive like the rest. */ const RDBU: readonly Rgb[] = [ [5, 48, 97], @@ -249,14 +394,41 @@ const RDBU: readonly Rgb[] = [ [103, 0, 31], ]; +/** The dark theme's panel colour, `--editor-bg-primary` in `globals.css`. */ +export const DARK_PANEL: Rgb = [33, 33, 32]; + +/** + * The dark-centred maps bottom out darker than the dark panel, so near-zero + * cells read as a black band instead of receding. Shift the centre stop onto + * the panel colour and fade the shift linearly to nothing at both ends; the + * shift changes more slowly than the maps brighten, so lightness still rises + * steadily away from zero. + */ +function onDarkPanel(lut: readonly Rgb[]): readonly Rgb[] { + const mid = (lut.length - 1) / 2; + const centre = lut[mid]; + return lut.map(([r, g, b], i) => { + const w = 1 - Math.abs(i - mid) / mid; + return [ + Math.round(r + (DARK_PANEL[0] - centre[0]) * w), + Math.round(g + (DARK_PANEL[1] - centre[1]) * w), + Math.round(b + (DARK_PANEL[2] - centre[2]) * w), + ]; + }); +} + /** Every table that `fingerprintColorScale` can sample, keyed by colormap id. */ export const DIVERGING_TABLES = { vik: VIK, - berlin: BERLIN, + berlin: onDarkPanel(BERLIN), broc: BROC, cork: CORK, roma: ROMA, - vanimo: VANIMO, + vanimo: onDarkPanel(VANIMO), + lisbon: onDarkPanel(LISBON), + tofino: onDarkPanel(TOFINO), + bam: BAM, + managua: onDarkPanel(MANAGUA), rdbu: RDBU, } as const; diff --git a/src/lib/map/fingerprintScale.test.ts b/src/lib/map/fingerprintScale.test.ts index 0659c94..23fa644 100644 --- a/src/lib/map/fingerprintScale.test.ts +++ b/src/lib/map/fingerprintScale.test.ts @@ -1,14 +1,22 @@ import { describe, expect, it } from "vitest"; -import { DIVERGING_TABLES, sampleTable } from "@/lib/map/colormapTables"; +import { + DARK_PANEL, + DIVERGING_TABLES, + sampleTable, +} from "@/lib/map/colormapTables"; import { asymmetricExtents, COLORMAPS, + colormapFor, type ColormapId, dayIndexTicks, defaultColormapId, fingerprintColorScale, fingerprintRampSamples, formatIsoDate, + PALETTE_COLORMAPS, + PALETTES, + paletteFromStored, symmetricAbsMax, yearRangesInWindow, zeroFrac, @@ -137,6 +145,68 @@ describe("fingerprintColorScale — table palettes", () => { }); }); +describe("palettes", () => { + const luma = (css: string) => { + const [r, g, b] = css.match(/\d+/g)!.map(Number); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + + it("pairs each Crameri light-centred map with a dark-centred one", () => { + for (const palette of PALETTES) { + const { light, dark } = PALETTE_COLORMAPS[palette]; + if (light === dark) continue; + expect(luma(fingerprintColorScale(light)(0, 5, 5))).toBeGreaterThan(180); + expect(luma(fingerprintColorScale(dark)(0, 5, 5))).toBeLessThan(60); + } + }); + + it("draws zero on dark maps in the dark panel colour", () => { + const panel = `rgb(${DARK_PANEL.join(", ")})`; + for (const palette of PALETTES) { + const { light, dark } = PALETTE_COLORMAPS[palette]; + if (light === dark) continue; + expect(fingerprintColorScale(dark)(0, 5, 5)).toBe(panel); + } + }); + + it("keeps dark maps brightening away from zero", () => { + // The published dark maps already dip a few units just off centre. + const dip = 1; + for (const palette of PALETTES) { + const { light, dark } = PALETTE_COLORMAPS[palette]; + if (light === dark) continue; + const scale = fingerprintColorScale(dark); + for (let v = 0; v < 5; v += 0.05) { + expect(luma(scale(v + 0.05, 5, 5)) + dip).toBeGreaterThanOrEqual( + luma(scale(v, 5, 5)), + ); + expect(luma(scale(-v - 0.05, 5, 5)) + dip).toBeGreaterThanOrEqual( + luma(scale(-v, 5, 5)), + ); + } + } + }); + + it("offers every colormap in some palette", () => { + const offered = new Set( + PALETTES.flatMap((palette) => [ + colormapFor(palette, true), + colormapFor(palette, false), + ]), + ); + expect([...offered].sort()).toEqual(Object.keys(COLORMAPS).sort()); + }); + + it("reads stored preferences from older builds", () => { + expect(paletteFromStored("science")).toBe("vik"); + expect(paletteFromStored("science-dark")).toBe("vik"); + expect(paletteFromStored("berlin")).toBe("vik"); + expect(paletteFromStored("tofino")).toBe("cork"); + expect(paletteFromStored("flux")).toBe("flux"); + expect(paletteFromStored("broc")).toBe("broc"); + }); +}); + describe("fingerprintColorScale — flux", () => { const scale = fingerprintColorScale("flux"); diff --git a/src/lib/map/fingerprintScale.ts b/src/lib/map/fingerprintScale.ts index 9baa91b..868c72d 100644 --- a/src/lib/map/fingerprintScale.ts +++ b/src/lib/map/fingerprintScale.ts @@ -14,9 +14,10 @@ * intense, which is why every colorbar labels both real extremes and marks zero. * * Palettes: - * - vik, berlin, broc, cork, roma, vanimo: Crameri's perceptually uniform + * - vik, broc, cork, bam, roma and their dark-centred counterparts berlin, + * lisbon, tofino, vanimo, managua: 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. + * The picker offers each pair as one palette that follows the theme. * - 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) @@ -66,6 +67,22 @@ export const COLORMAPS: Record< label: "vanimo", description: "Crameri vanimo: pink to black to green, for dark surfaces", }, + lisbon: { + label: "lisbon", + description: "Crameri lisbon: blue to black to olive, for dark surfaces", + }, + tofino: { + label: "tofino", + description: "Crameri tofino: blue to black to green, for dark surfaces", + }, + bam: { + label: "bam", + description: "Crameri bam: purple to white to green", + }, + managua: { + label: "managua", + description: "Crameri managua: orange to black to blue, for dark surfaces", + }, rdbu: { label: "RdBu", description: @@ -83,11 +100,44 @@ export function defaultColormapId(isLight: boolean): ColormapId { return isLight ? "vik" : "berlin"; } -/** What the user picks; Science resolves to the theme's default table. */ -export type Palette = "science" | "flux"; +/** + * What the user picks. Each palette names its light-surface table and resolves + * to a dark-centred counterpart on dark surfaces, where one exists. + */ +export const PALETTE_COLORMAPS = { + vik: { light: "vik", dark: "berlin" }, + broc: { light: "broc", dark: "lisbon" }, + cork: { light: "cork", dark: "tofino" }, + bam: { light: "bam", dark: "vanimo" }, + roma: { light: "roma", dark: "managua" }, + rdbu: { light: "rdbu", dark: "rdbu" }, + flux: { light: "flux", dark: "flux" }, +} as const satisfies Record; + +export type Palette = keyof typeof PALETTE_COLORMAPS; + +export const PALETTES = Object.keys(PALETTE_COLORMAPS) as Palette[]; + +export const DEFAULT_PALETTE: Palette = "vik"; export function colormapFor(palette: Palette, isLight: boolean): ColormapId { - return palette === "flux" ? "flux" : defaultColormapId(isLight); + const pair = PALETTE_COLORMAPS[palette]; + return isLight ? pair.light : pair.dark; +} + +/** + * The palette a stored preference stands for. Older builds stored "science" + * (or "science-light" / "science-dark") for the vik/berlin pair, and a dark + * table id maps back to the palette that contains it. + */ +export function paletteFromStored(stored: string): Palette { + for (const palette of PALETTES) { + const { light, dark } = PALETTE_COLORMAPS[palette]; + if (stored === palette || stored === light || stored === dark) { + return palette; + } + } + return DEFAULT_PALETTE; } export type Rgb = readonly [number, number, number]; diff --git a/src/lib/settings/colormap.ts b/src/lib/settings/colormap.ts index fe81d15..64dc43b 100644 --- a/src/lib/settings/colormap.ts +++ b/src/lib/settings/colormap.ts @@ -1,17 +1,14 @@ -import type { Palette } from "@/lib/map/fingerprintScale"; +import { type Palette, paletteFromStored } from "@/lib/map/fingerprintScale"; export const COLORMAP_STORAGE_KEY = "earthprints:colormap"; -/** - * Read the stored palette preference, or `null` if none. Any stored diverging - * table id, including the older "science-light" / "science-dark", means Science. - */ +/** Read the stored palette preference, or `null` if none. */ export function loadPalette(): Palette | null { if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(COLORMAP_STORAGE_KEY); if (!stored) return null; - return stored === "flux" ? "flux" : "science"; + return paletteFromStored(stored); } catch { // Private-browsing or quota errors; ignore. return null;