From 582004e75305c92050baaa8eb06ea48f0bb46049 Mon Sep 17 00:00:00 2001 From: Peyton Lee Date: Fri, 19 Jan 2024 15:37:10 -0800 Subject: [PATCH 1/4] feat: Saving categorical palettes to URL --- src/App.tsx | 5 ++ src/colorizer/colors/categorical_palettes.ts | 21 +++++++ src/colorizer/utils/url_utils.ts | 42 +++++++++++++ tests/url_utils.test.ts | 66 +++++++++++++++++++- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 060cd158c..1db5c8147 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -201,6 +201,7 @@ function App(): ReactElement { range: rangeParam, colorRampKey: colorRampKey, colorRampReversed: colorRampReversed, + palette: 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.palette) { + setCategoricalPalette(initialUrlParams.palette); + } }, []); // 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..9da1f65d0 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[]; @@ -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..f1a4cdfaa 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; + palette: 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. + * - `palette`: 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.palette) { + const key = getKeyFromPalette(state.palette); + if (key !== null) { + includedParameters.push(`${URL_PARAM_PALETTE_KEY}=${key}`); + } else { + // Save the hex color stops directly. + // TODO: Change only edited colors...? + const stops = state.palette.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,22 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial "#" + hex) as ColorRepresentation[]; + while (hexColors.length < MAX_FEATURE_CATEGORIES) { + // backfill extra colors to meet max length using default palette + hexColors.push(defaultPalette.colorStops[hexColors.length]); + } + palette = hexColors.map((hex) => new Color(hex)); + } + // Remove undefined entries from the object for a cleaner return value return removeUndefinedProperties({ collection: collectionParam, @@ -391,5 +432,6 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial { range: [21.433, 89.4], colorRampKey: "myMap-1", colorRampReversed: true, + palette: 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,64 @@ describe("Loading + saving from URL query strings", () => { expect(parsedParams).deep.equals(params); } }); + + it("Uses keys for all palettes", () => { + for (const data of DEFAULT_CATEGORICAL_PALETTES.values()) { + const params: Partial = { palette: data.colors }; + let queryString = paramsToUrlQueryString(params); + + expect(queryString).to.equal(`?palette-key=${data.key}`); + + let parsedParams = loadParamsFromUrlQueryString(queryString); + expect(parsedParams).deep.equals(params); + } + }); + + 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 = { palette: colors }; + let queryString = paramsToUrlQueryString(params); + expect(queryString).equals( + "?palette=000000-000010-000020-000030-000040-000050-000060-000070-000080-000090-0000a0-0000b0" + ); + let 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 = { + palette: 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 = { palette: colors }; + let queryString = paramsToUrlQueryString(params); + expect(queryString).equals("?palette=000000-000010-000020-000030"); + let parsedParams = loadParamsFromUrlQueryString(queryString); + + const defaultColors = DEFAULT_CATEGORICAL_PALETTES.get("adobe")!.colors; + const expectedColors = [...colors, ...defaultColors.slice(4)]; + expect(parsedParams).deep.equals({ palette: expectedColors }); + }); }); From 65ea4e0d46a3b5e0ded3e429869043c922341874 Mon Sep 17 00:00:00 2001 From: Peyton Lee Date: Mon, 22 Jan 2024 14:04:32 -0800 Subject: [PATCH 2/4] refactor: Code cleanup --- src/App.tsx | 6 +++--- src/colorizer/utils/url_utils.ts | 36 ++++++++++++++++++-------------- tests/url_utils.test.ts | 27 ++++++++++++------------ 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1db5c8147..5a7ec8819 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -201,7 +201,7 @@ function App(): ReactElement { range: rangeParam, colorRampKey: colorRampKey, colorRampReversed: colorRampReversed, - palette: categoricalPalette, + categoricalPalette: categoricalPalette, }); }, [ getDatasetAndCollectionParam, @@ -268,8 +268,8 @@ function App(): ReactElement { if (initialUrlParams.colorRampReversed) { setColorRampReversed(initialUrlParams.colorRampReversed); } - if (initialUrlParams.palette) { - setCategoricalPalette(initialUrlParams.palette); + if (initialUrlParams.categoricalPalette) { + setCategoricalPalette(initialUrlParams.categoricalPalette); } }, []); diff --git a/src/colorizer/utils/url_utils.ts b/src/colorizer/utils/url_utils.ts index f1a4cdfaa..01079d500 100644 --- a/src/colorizer/utils/url_utils.ts +++ b/src/colorizer/utils/url_utils.ts @@ -39,7 +39,7 @@ export type UrlParams = { range: [number, number]; colorRampKey: string | null; colorRampReversed: boolean | null; - palette: Color[]; + categoricalPalette: Color[]; }; export const DEFAULT_FETCH_TIMEOUT_MS = 2000; @@ -231,14 +231,14 @@ export function paramsToUrlQueryString(state: Partial): string { includedParameters.push(`${URL_PARAM_COLOR_RAMP}=${encodeURIComponent(state.colorRampKey)}`); } } - if (state.palette) { - const key = getKeyFromPalette(state.palette); + if (state.categoricalPalette) { + const key = getKeyFromPalette(state.categoricalPalette); if (key !== null) { includedParameters.push(`${URL_PARAM_PALETTE_KEY}=${key}`); } else { // Save the hex color stops directly. - // TODO: Change only edited colors...? - const stops = state.palette.map((color: Color) => { + // 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("-")}`); @@ -404,20 +404,25 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial "#" + hex) as ColorRepresentation[]; - while (hexColors.length < MAX_FEATURE_CATEGORIES) { + const hexColors: ColorRepresentation[] = paletteStringParam + .split("-") + .map((hex) => "#" + hex) as ColorRepresentation[]; + if (hexColors.length < MAX_FEATURE_CATEGORIES) { // backfill extra colors to meet max length using default palette - hexColors.push(defaultPalette.colorStops[hexColors.length]); + hexColors.push(...defaultPalette.colorStops.slice(hexColors.length)); } - palette = hexColors.map((hex) => new Color(hex)); + categoricalPalette = hexColors.map((hex) => new Color(hex)); } // Remove undefined entries from the object for a cleaner return value @@ -429,9 +434,8 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial { range: [21.433, 89.4], colorRampKey: "myMap-1", colorRampReversed: true, - palette: DEFAULT_CATEGORICAL_PALETTES.get(DEFAULT_CATEGORICAL_PALETTE_ID)!.colors, + categoricalPalette: DEFAULT_CATEGORICAL_PALETTES.get(DEFAULT_CATEGORICAL_PALETTE_ID)!.colors, }; const queryString = paramsToUrlQueryString(originalParams); const expectedQueryString = @@ -278,15 +278,16 @@ describe("Loading + saving from URL query strings", () => { } }); - it("Uses keys for all palettes", () => { + it("Accepts keys for all palettes", () => { for (const data of DEFAULT_CATEGORICAL_PALETTES.values()) { - const params: Partial = { palette: data.colors }; - let queryString = paramsToUrlQueryString(params); + const params: Partial = { categoricalPalette: data.colors }; + const queryString = paramsToUrlQueryString(params); expect(queryString).to.equal(`?palette-key=${data.key}`); - let parsedParams = loadParamsFromUrlQueryString(queryString); + const parsedParams = loadParamsFromUrlQueryString(queryString); expect(parsedParams).deep.equals(params); + expect(parsedParams.categoricalPalette).deep.equals(data.colors); } }); @@ -306,12 +307,12 @@ describe("Loading + saving from URL query strings", () => { "#0000b0", ]; const colors = hexColors.map((color) => new Color(color)); - const params: Partial = { palette: colors }; - let queryString = paramsToUrlQueryString(params); + 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" ); - let parsedParams = loadParamsFromUrlQueryString(queryString); + const parsedParams = loadParamsFromUrlQueryString(queryString); expect(parsedParams).deep.equals(params); }); @@ -319,7 +320,7 @@ describe("Loading + saving from URL query strings", () => { const queryString = "?palette-key=adobe&palette=000000-ff0000-00ff00-0000ff-000000-ff0000-00ff00-0000ff-000000-ff0000-00ff00-0000ff"; const expectedParams = { - palette: DEFAULT_CATEGORICAL_PALETTES.get("adobe")?.colors, + categoricalPalette: DEFAULT_CATEGORICAL_PALETTES.get("adobe")?.colors, }; expect(loadParamsFromUrlQueryString(queryString)).deep.equals(expectedParams); }); @@ -328,13 +329,13 @@ describe("Loading + saving from URL query strings", () => { const hexColors: ColorRepresentation[] = ["#000000", "#000010", "#000020", "#000030"]; const colors = hexColors.map((color) => new Color(color)); - const params: Partial = { palette: colors }; - let queryString = paramsToUrlQueryString(params); + const params: Partial = { categoricalPalette: colors }; + const queryString = paramsToUrlQueryString(params); expect(queryString).equals("?palette=000000-000010-000020-000030"); - let parsedParams = loadParamsFromUrlQueryString(queryString); + const parsedParams = loadParamsFromUrlQueryString(queryString); const defaultColors = DEFAULT_CATEGORICAL_PALETTES.get("adobe")!.colors; const expectedColors = [...colors, ...defaultColors.slice(4)]; - expect(parsedParams).deep.equals({ palette: expectedColors }); + expect(parsedParams).deep.equals({ categoricalPalette: expectedColors }); }); }); From 8859aed62c765014532e67278627423a26e879e0 Mon Sep 17 00:00:00 2001 From: Peyton Lee Date: Mon, 22 Jan 2024 14:13:11 -0800 Subject: [PATCH 3/4] refactor: Sanitized categorical palette keys --- src/colorizer/colors/categorical_palettes.ts | 22 ++++++++++---------- tests/colors.test.ts | 6 ++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/colorizer/colors/categorical_palettes.ts b/src/colorizer/colors/categorical_palettes.ts index 9da1f65d0..c1ad140aa 100644 --- a/src/colorizer/colors/categorical_palettes.ts +++ b/src/colorizer/colors/categorical_palettes.ts @@ -28,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", @@ -46,7 +46,7 @@ const rawPaletteData: RawColorData[] = [ ], }, { - key: "matplotlib-paired", + key: "matplotlib_paired", name: "Paired", colorStops: [ "#A8CEE2", @@ -65,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", @@ -84,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", @@ -104,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", @@ -124,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", @@ -161,7 +161,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://medialab.github.io/iwanthue/ - key: "iwanthue-dark", + key: "iwanthue_dark", name: "Dark", colorStops: [ "#44C098", @@ -180,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", @@ -199,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", @@ -218,7 +218,7 @@ const rawPaletteData: RawColorData[] = [ }, { // https://medialab.github.io/iwanthue/ - key: "iwanthue-pastel_3", + key: "iwanthue_pastel_3", name: "Pastel 3", colorStops: [ "#9CD2B8", @@ -237,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", diff --git a/tests/colors.test.ts b/tests/colors.test.ts index 206e7b6f1..e11a400da 100644 --- a/tests/colors.test.ts +++ b/tests/colors.test.ts @@ -36,4 +36,10 @@ describe("Categorical Palettes", () => { 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; + } + }); }); From 4c4b9bec3ed7431e8c885354f8dc2cf273ab328d Mon Sep 17 00:00:00 2001 From: Peyton Lee Date: Mon, 22 Jan 2024 14:25:37 -0800 Subject: [PATCH 4/4] doc: Updated comments --- src/colorizer/utils/url_utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/colorizer/utils/url_utils.ts b/src/colorizer/utils/url_utils.ts index 01079d500..a2e76d849 100644 --- a/src/colorizer/utils/url_utils.ts +++ b/src/colorizer/utils/url_utils.ts @@ -190,7 +190,7 @@ function deserializeThresholds(thresholds: string | null): FeatureThreshold[] | * - `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. - * - `palette`: an array of (three.js) Color objects representing the current color palette to use. + * - `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. @@ -236,7 +236,7 @@ export function paramsToUrlQueryString(state: Partial): string { if (key !== null) { includedParameters.push(`${URL_PARAM_PALETTE_KEY}=${key}`); } else { - // Save the hex color stops directly. + // 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();