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;