Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Save/load categorical palettes via URL #190

Merged
merged 4 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ function App(): ReactElement {
range: rangeParam,
colorRampKey: colorRampKey,
colorRampReversed: colorRampReversed,
categoricalPalette: categoricalPalette,
});
}, [
getDatasetAndCollectionParam,
Expand All @@ -211,6 +212,7 @@ function App(): ReactElement {
featureThresholds,
colorRampKey,
colorRampReversed,
categoricalPalette,
]);

// Update url whenever the viewer settings change
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 32 additions & 11 deletions src/colorizer/colors/categorical_palettes.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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",
Expand All @@ -45,7 +46,7 @@ const rawPaletteData: RawColorData[] = [
],
},
{
key: "matplotlib-paired",
key: "matplotlib_paired",
name: "Paired",
colorStops: [
"#A8CEE2",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -160,7 +161,7 @@ const rawPaletteData: RawColorData[] = [
},
{
// https://medialab.github.io/iwanthue/
key: "iwanthue-dark",
key: "iwanthue_dark",
name: "Dark",
colorStops: [
"#44C098",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -262,5 +263,25 @@ const keyedPaletteData: [string, PaletteData][] = rawPaletteData.map((value) =>
});
const paletteMap: Map<string, PaletteData> = 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";
48 changes: 47 additions & 1 deletion src/colorizer/utils/url_utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, string> = {
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -219,6 +231,19 @@ export function paramsToUrlQueryString(state: Partial<UrlParams>): 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("&") : "";
Expand Down Expand Up @@ -379,6 +404,27 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial<UrlPa
colorRampParam = colorRampRawParam.slice(0, -1);
}

// Parse palette data
const paletteKeyParam = urlParams.get(URL_PARAM_PALETTE_KEY);
const paletteStringParam = urlParams.get(URL_PARAM_PALETTE);
const defaultPalette = DEFAULT_CATEGORICAL_PALETTES.get(DEFAULT_CATEGORICAL_PALETTE_ID)!;

let categoricalPalette: Color[] | undefined = undefined;
if (paletteKeyParam) {
// Use key if provided
categoricalPalette = DEFAULT_CATEGORICAL_PALETTES.get(paletteKeyParam)?.colors || defaultPalette.colors;
} else if (paletteStringParam) {
// Parse into color objects
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.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,
Expand All @@ -388,8 +434,8 @@ export function loadParamsFromUrlQueryString(queryString: string): Partial<UrlPa
time: timeParam,
thresholds: thresholdsParam,
range: rangeParam,

colorRampKey: colorRampParam,
colorRampReversed: colorRampReversedParam,
categoricalPalette,
});
}
6 changes: 6 additions & 0 deletions tests/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
67 changes: 65 additions & 2 deletions tests/url_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<UrlParams> = { 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<UrlParams> = { 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<UrlParams> = { 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 });
});
});
Loading