diff --git a/src/App.tsx b/src/App.tsx index 060cd158c..5a7ec8819 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -201,6 +201,7 @@ function App(): ReactElement { range: rangeParam, colorRampKey: colorRampKey, colorRampReversed: colorRampReversed, + categoricalPalette: categoricalPalette, }); }, [ getDatasetAndCollectionParam, @@ -211,6 +212,7 @@ function App(): ReactElement { featureThresholds, colorRampKey, colorRampReversed, + categoricalPalette, ]); // Update url whenever the viewer settings change @@ -266,6 +268,9 @@ function App(): ReactElement { if (initialUrlParams.colorRampReversed) { setColorRampReversed(initialUrlParams.colorRampReversed); } + if (initialUrlParams.categoricalPalette) { + setCategoricalPalette(initialUrlParams.categoricalPalette); + } }, []); // Attempt to load database and collections data from the URL. diff --git a/src/colorizer/colors/categorical_palettes.ts b/src/colorizer/colors/categorical_palettes.ts index 486fe9230..c1ad140aa 100644 --- a/src/colorizer/colors/categorical_palettes.ts +++ b/src/colorizer/colors/categorical_palettes.ts @@ -1,5 +1,6 @@ import { Color } from "three"; import { RawColorData } from "./color_ramps"; +import { MAX_FEATURE_CATEGORIES } from "../../constants"; export type PaletteData = RawColorData & { colors: Color[]; @@ -27,7 +28,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://spectrum.adobe.com/page/color-for-data-visualization/ - key: "adobe-light", + key: "adobe_light", name: "Adobe Categorical 50%", colorStops: [ "#93D9D7", @@ -45,7 +46,7 @@ const rawPaletteData: RawColorData[] = [ ], }, { - key: "matplotlib-paired", + key: "matplotlib_paired", name: "Paired", colorStops: [ "#A8CEE2", @@ -64,7 +65,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://matplotlib.org/stable/gallery/color/colormap_reference.html - key: "matplotlib-accent", + key: "matplotlib_accent", name: "Accent", colorStops: [ "#7FC97F", @@ -83,7 +84,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://matplotlib.org/stable/gallery/color/colormap_reference.html - key: "matplotlib-tab10", + key: "matplotlib_tab10", name: "Matplotlib - Tab 10", colorStops: [ "#2677B0", @@ -103,7 +104,7 @@ const rawPaletteData: RawColorData[] = [ { // https://medialab.github.io/iwanthue/ // TODO: Potentially remove or rename - key: "iwanthue-set2", + key: "iwanthue_set2", name: "Random - Tea Party", colorStops: [ "#E085FB", @@ -123,7 +124,7 @@ const rawPaletteData: RawColorData[] = [ { // https://medialab.github.io/iwanthue/ // TODO: Potentially remove or rename - key: "iwanthue-set3", + key: "iwanthue_set3", name: "Random - Chiclets", colorStops: [ "#F769CD", @@ -160,7 +161,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://medialab.github.io/iwanthue/ - key: "iwanthue-dark", + key: "iwanthue_dark", name: "Dark", colorStops: [ "#44C098", @@ -179,7 +180,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://matplotlib.org/stable/gallery/color/colormap_reference.html - key: "matplotlib-pastel1", + key: "matplotlib_pastel1", name: "Pastel 1", colorStops: [ "#90D3C8", @@ -198,7 +199,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://matplotlib.org/stable/gallery/color/colormap_reference.html - key: "matplotlib-pastel2", + key: "matplotlib_pastel2", name: "Pastel 2", colorStops: [ "#F9B5B0", @@ -217,7 +218,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://medialab.github.io/iwanthue/ - key: "iwanthue-pastel_3", + key: "iwanthue_pastel_3", name: "Pastel 3", colorStops: [ "#9CD2B8", @@ -236,7 +237,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://matplotlib.org/stable/gallery/color/colormap_reference.html - key: "matplotlib-paired", + key: "matplotlib_paired", name: "Paired", colorStops: [ "#A8CEE2", @@ -262,5 +263,25 @@ const keyedPaletteData: [string, PaletteData][] = rawPaletteData.map((value) => }); const paletteMap: Map = new Map(keyedPaletteData); +/** + * Returns the string key of a palette if it matches an existing palette; otherwise, returns null. + */ +export const getKeyFromPalette = (palette: Color[]): string | null => { + for (const data of paletteMap.values()) { + let matches = true; + for (let i = 0; i < MAX_FEATURE_CATEGORIES; i++) { + const paletteColor = palette[i].getHexString().toLowerCase(); + if (paletteColor !== data.colorStops[i].toLowerCase().slice(1)) { + matches = false; + break; + } + } + if (matches) { + return data.key; + } + } + return null; +}; + export const DEFAULT_CATEGORICAL_PALETTES = paletteMap; export const DEFAULT_CATEGORICAL_PALETTE_ID = "adobe"; diff --git a/src/colorizer/utils/url_utils.ts b/src/colorizer/utils/url_utils.ts index 06490b696..a2e76d849 100644 --- a/src/colorizer/utils/url_utils.ts +++ b/src/colorizer/utils/url_utils.ts @@ -1,9 +1,15 @@ // Typescript doesn't recognize RequestInit /* global RequestInit */ +import { Color, ColorRepresentation } from "three"; import { MAX_FEATURE_CATEGORIES } from "../../constants"; import { FeatureThreshold, ThresholdType, isThresholdCategorical } from "../types"; import { numberToStringDecimal } from "./math_utils"; +import { + DEFAULT_CATEGORICAL_PALETTES, + DEFAULT_CATEGORICAL_PALETTE_ID, + getKeyFromPalette, +} from "../colors/categorical_palettes"; const URL_PARAM_TRACK = "track"; const URL_PARAM_DATASET = "dataset"; @@ -14,6 +20,8 @@ const URL_PARAM_THRESHOLDS = "filters"; const URL_PARAM_RANGE = "range"; const URL_PARAM_COLOR_RAMP = "color"; const URL_COLOR_RAMP_REVERSED_SUFFIX = "!"; +const URL_PARAM_PALETTE = "palette"; +const URL_PARAM_PALETTE_KEY = "palette-key"; const ALLEN_FILE_PREFIX = "/allen/"; const ALLEN_PREFIX_TO_HTTPS: Record = { @@ -31,6 +39,7 @@ export type UrlParams = { range: [number, number]; colorRampKey: string | null; colorRampReversed: boolean | null; + categoricalPalette: Color[]; }; export const DEFAULT_FETCH_TIMEOUT_MS = 2000; @@ -179,6 +188,9 @@ function deserializeThresholds(thresholds: string | null): FeatureThreshold[] | * - `time`: integer frame number. * - `thresholds`: array of feature threshold. * - `range`: array of two numbers, representing the min and max of the color map range. + * - `colorRampKey`: the key of the current color map. + * - `colorRampReversed`: boolean, whether the color map is reversed. + * - `categoricalPalette`: an array of (three.js) Color objects representing the current color palette to use. * * @returns * - If no parameters are present or valid, returns an empty string. @@ -219,6 +231,19 @@ export function paramsToUrlQueryString(state: Partial): string { includedParameters.push(`${URL_PARAM_COLOR_RAMP}=${encodeURIComponent(state.colorRampKey)}`); } } + if (state.categoricalPalette) { + const key = getKeyFromPalette(state.categoricalPalette); + if (key !== null) { + includedParameters.push(`${URL_PARAM_PALETTE_KEY}=${key}`); + } else { + // Save the hex color stops as a string separated by dashes. + // TODO: Save only the edited colors to shorten URL. + const stops = state.categoricalPalette.map((color: Color) => { + return color.getHexString(); + }); + includedParameters.push(`${URL_PARAM_PALETTE}=${stops.join("-")}`); + } + } // If parameters present, join with URL syntax and push into the URL return includedParameters.length > 0 ? "?" + includedParameters.join("&") : ""; @@ -379,6 +404,27 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial "#" + hex) as ColorRepresentation[]; + if (hexColors.length < MAX_FEATURE_CATEGORIES) { + // backfill extra colors to meet max length using default palette + hexColors.push(...defaultPalette.colorStops.slice(hexColors.length)); + } + categoricalPalette = hexColors.map((hex) => new Color(hex)); + } + // Remove undefined entries from the object for a cleaner return value return removeUndefinedProperties({ collection: collectionParam, @@ -388,8 +434,8 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial { expect(palette.colorStops.length).to.equal(uniqueStops.size); } }); + + it("has sanitized keys", () => { + for (const palette of DEFAULT_CATEGORICAL_PALETTES.values()) { + expect(/^[a-z0-9_]+$/g.test(palette.key)).to.be.true; + } + }); }); diff --git a/tests/url_utils.test.ts b/tests/url_utils.test.ts index c904aa318..c4dea5267 100644 --- a/tests/url_utils.test.ts +++ b/tests/url_utils.test.ts @@ -10,7 +10,8 @@ import { } from "../src/colorizer/utils/url_utils"; import { MAX_FEATURE_CATEGORIES } from "../src/constants"; import { ThresholdType } from "../src/colorizer/types"; -import { DEFAULT_COLOR_RAMPS } from "../src/colorizer"; +import { DEFAULT_CATEGORICAL_PALETTES, DEFAULT_CATEGORICAL_PALETTE_ID, DEFAULT_COLOR_RAMPS } from "../src/colorizer"; +import { Color, ColorRepresentation } from "three"; function padCategories(categories: boolean[]): boolean[] { const result = [...categories]; @@ -136,10 +137,11 @@ describe("Loading + saving from URL query strings", () => { range: [21.433, 89.4], colorRampKey: "myMap-1", colorRampReversed: true, + categoricalPalette: DEFAULT_CATEGORICAL_PALETTES.get(DEFAULT_CATEGORICAL_PALETTE_ID)!.colors, }; const queryString = paramsToUrlQueryString(originalParams); const expectedQueryString = - "?collection=collection&dataset=dataset&feature=feature&track=25&t=14&filters=f1%3Am%3A0%3A0%2Cf2%3Aum%3ANaN%3ANaN%2Cf3%3Akm%3A0%3A1%2Cf4%3Amm%3A0.501%3A1000.485%2Cf5%3A%3Afff%2Cf6%3A%3A11&range=21.433%2C89.400&color=myMap-1!"; + "?collection=collection&dataset=dataset&feature=feature&track=25&t=14&filters=f1%3Am%3A0%3A0%2Cf2%3Aum%3ANaN%3ANaN%2Cf3%3Akm%3A0%3A1%2Cf4%3Amm%3A0.501%3A1000.485%2Cf5%3A%3Afff%2Cf6%3A%3A11&range=21.433%2C89.400&color=myMap-1!&palette-key=adobe"; expect(queryString).equals(expectedQueryString); const parsedParams = loadParamsFromUrlQueryString(queryString); @@ -275,4 +277,65 @@ describe("Loading + saving from URL query strings", () => { expect(parsedParams).deep.equals(params); } }); + + it("Accepts keys for all palettes", () => { + for (const data of DEFAULT_CATEGORICAL_PALETTES.values()) { + const params: Partial = { categoricalPalette: data.colors }; + const queryString = paramsToUrlQueryString(params); + + expect(queryString).to.equal(`?palette-key=${data.key}`); + + const parsedParams = loadParamsFromUrlQueryString(queryString); + expect(parsedParams).deep.equals(params); + expect(parsedParams.categoricalPalette).deep.equals(data.colors); + } + }); + + it("Handles palette colors", () => { + const hexColors: ColorRepresentation[] = [ + "#000000", + "#000010", + "#000020", + "#000030", + "#000040", + "#000050", + "#000060", + "#000070", + "#000080", + "#000090", + "#0000a0", + "#0000b0", + ]; + const colors = hexColors.map((color) => new Color(color)); + const params: Partial = { categoricalPalette: colors }; + const queryString = paramsToUrlQueryString(params); + expect(queryString).equals( + "?palette=000000-000010-000020-000030-000040-000050-000060-000070-000080-000090-0000a0-0000b0" + ); + const parsedParams = loadParamsFromUrlQueryString(queryString); + expect(parsedParams).deep.equals(params); + }); + + it("Uses palette key instead of palette array when both are provided", () => { + const queryString = + "?palette-key=adobe&palette=000000-ff0000-00ff00-0000ff-000000-ff0000-00ff00-0000ff-000000-ff0000-00ff00-0000ff"; + const expectedParams = { + categoricalPalette: DEFAULT_CATEGORICAL_PALETTES.get("adobe")?.colors, + }; + expect(loadParamsFromUrlQueryString(queryString)).deep.equals(expectedParams); + }); + + it("Backfills missing palette colors", () => { + const hexColors: ColorRepresentation[] = ["#000000", "#000010", "#000020", "#000030"]; + const colors = hexColors.map((color) => new Color(color)); + + const params: Partial = { categoricalPalette: colors }; + const queryString = paramsToUrlQueryString(params); + expect(queryString).equals("?palette=000000-000010-000020-000030"); + const parsedParams = loadParamsFromUrlQueryString(queryString); + + const defaultColors = DEFAULT_CATEGORICAL_PALETTES.get("adobe")!.colors; + const expectedColors = [...colors, ...defaultColors.slice(4)]; + expect(parsedParams).deep.equals({ categoricalPalette: expectedColors }); + }); });