From 28c47589a8d028a4c1d935e202332aac4f61fb41 Mon Sep 17 00:00:00 2001 From: Adam Midlik Date: Mon, 20 May 2024 12:28:49 +0100 Subject: [PATCH] Refactor ColorScale 2, docstrings --- demo/demo1.html | 2 +- demo/demo2.html | 2 +- demo/demo3.html | 2 +- src/heatmap-component/data/color-scale.ts | 150 +++++++++++++++----- src/heatmap-component/extensions/tooltip.ts | 2 +- src/main.ts | 2 +- 6 files changed, 118 insertions(+), 42 deletions(-) diff --git a/demo/demo1.html b/demo/demo1.html index 6ff167a..96a32a2 100644 --- a/demo/demo1.html +++ b/demo/demo1.html @@ -31,7 +31,7 @@ diff --git a/demo/demo2.html b/demo/demo2.html index 0d7ca73..86b2652 100644 --- a/demo/demo2.html +++ b/demo/demo2.html @@ -23,7 +23,7 @@ diff --git a/demo/demo3.html b/demo/demo3.html index 7c6e34d..9501bf3 100644 --- a/demo/demo3.html +++ b/demo/demo3.html @@ -19,7 +19,7 @@

Demo 3

diff --git a/src/heatmap-component/data/color-scale.ts b/src/heatmap-component/data/color-scale.ts index 586daef..6646764 100644 --- a/src/heatmap-component/data/color-scale.ts +++ b/src/heatmap-component/data/color-scale.ts @@ -1,4 +1,4 @@ -import { clamp, range } from 'lodash'; +import { clamp, isArray, range, uniq } from 'lodash'; import * as d3 from '../d3-modules'; import { Color } from './color'; import { Domain } from './domain'; @@ -11,16 +11,24 @@ type RemovePrefix

= T extends `${P}${infer S}` ? S : never /** Set of continuous D3 color scheme names */ type D3ContinuousSchemeName = RemovePrefix<'interpolate', KeysWith string>> -// /** Set of categorical D3 color scheme names */ -// type D3CategoricalSchemeName = RemovePrefix<'scheme', KeysWith> +/** Set of categorical D3 color scheme names */ +type D3CategoricalSchemeName = RemovePrefix<'scheme', KeysWith> +/** Set of all D3 color scheme names */ +type D3SchemeName = D3ContinuousSchemeName | D3CategoricalSchemeName -/** List of names of available color schemes */ -const AvailableSchemes = Object.keys(d3).filter(k => k.indexOf('interpolate') === 0).map(k => k.replace(/^interpolate/, '')) as D3ContinuousSchemeName[]; -// const AllSchemes = Object.keys(d3).filter(k => k.indexOf('scheme') === 0).map(k => k.replace(/^scheme/, '')) as D3ContinuousSchemeName[];// TODO: type +/** List of names of available continuous color schemes */ +const D3ContinuousSchemes = Object.keys(d3).filter(k => k.indexOf('interpolate') === 0).map(k => k.replace(/^interpolate/, '')) as D3ContinuousSchemeName[]; +/** List of names of available categorical color schemes */ +const D3CategoricalSchemes = Object.keys(d3).filter(k => k.indexOf('scheme') === 0 && isStringArray((d3 as any)[k])).map(k => k.replace(/^scheme/, '')) as D3CategoricalSchemeName[]; +/** List of names of all available color schemes */ +const D3Schemes = uniq([...D3ContinuousSchemes, ...D3CategoricalSchemes]).sort() as D3SchemeName[]; -export type ColorScale = (x: number) => Color -function createScaleFromColors(values: number[], colors: (Color | string)[]): ColorScale { +export type ContinuousColorScale = (x: number) => Color +export type DiscreteColorScale = (x: T) => Color + + +function continuousScaleFromColors(values: number[], colors: (Color | string)[]): ContinuousColorScale { if (values.length !== colors.length) throw new Error('`values` and `colors` must have the same length'); const n = values.length; const theDomain = Domain.create(values); @@ -28,57 +36,117 @@ function createScaleFromColors(values: number[], colors: (Color | string)[]): Co if (!theDomain.isNumeric || theDomain.sortDirection === 'none') { throw new Error('Provided list of `values` is not numeric and monotonous'); } - return (x: number) => { + function colorScale(x: number): Color { const contIndex = clamp(Domain.interpolateIndex(theDomain, x)!, 0, n - 1); const index = Math.floor(contIndex); if (index === n) return theColors[n]; else return Color.mix(theColors[index], theColors[index + 1], contIndex - index); }; + return colorScale; } -function createScaleFromScheme(schemeName: D3ContinuousSchemeName, domain: [number, number] = [0, 1], range_: [number, number] = [0, 1]): ColorScale { - const colorInterpolator = d3[`interpolate${schemeName}`]; - if (!colorInterpolator) { - const schemes = Object.keys(d3).filter(k => k.indexOf('interpolate') === 0).map(k => k.replace(/^interpolate/, '')); - throw new Error(`Invalid color scheme name: "${schemeName}".\n(Available schemes: ${schemes})`); +function continuousScaleFromScheme(schemeName: D3ContinuousSchemeName, domain: [number, number] = [0, 1], range_: [number, number] = [0, 1]): ContinuousColorScale { + const colorInterpolator = d3[`interpolate${schemeName as D3ContinuousSchemeName}`]; + if (colorInterpolator !== undefined) { + const n = 101; + const values = linspace(domain, n); + const colors = colorsFromInterpolator(colorInterpolator, range_, n); + return continuousScaleFromColors(values, colors); } - const n = 101; - const domainScale = d3.scaleLinear([0, n - 1], domain); - const values = range(n).map(i => domainScale(i)); - const colors = colorsFromInterpolator(colorInterpolator, range_, n); - return createScaleFromColors(values, colors); -} -function colorsFromInterpolator(colorInterpolator: (t: number) => string, range_: [number, number], nColors: number): Color[] { - const rangeScale = d3.scaleLinear([0, nColors - 1], range_); - return range(nColors).map(i => Color.fromString(colorInterpolator(rangeScale(i)))); + throw new Error(`Invalid color scheme name: "${schemeName}".\n(Available schemes: ${ColorScale.ContinuousSchemes})`); } - - - /** Create a continuous color scale based on a named scheme, * e.g. `continuous('Magma', [0, 1], [0, 1])`. - * `schemeName` is the name of the used scheme (list available from `ColorScale.AvailableSchemes`). + * `schemeName` is the name of the used scheme (list available from `ColorScale.ContinuousSchemes`). * `domain` is the range of numbers that maps to the colors in the scheme: `domain[0]` maps to the first color in the scheme, `domain[1]` to the last color (default domain is [0, 1]). * `range` can be used to select only a section of the whole scheme, e.g. [0, 0.5] uses only the first half of the scheme, [1, 0] reverses the scheme direction. */ -function continuous(schemeName: D3ContinuousSchemeName, domain: [number, number], range?: [number, number]): ColorScale; - +function continuous(schemeName: D3ContinuousSchemeName, domain: [number, number], range?: [number, number]): ContinuousColorScale; /** Create a continuous color scale based on a list of numeric values and a list of colors mapped to these values, interpolating inbetween, * e.g. `continuous([0, 0.5, 1], ['white', 'orange', 'brown'])`. * `values` must be either ascending or descending. */ -function continuous(values: number[], colors: (Color | string)[]): ColorScale; +function continuous(values: number[], colors: (Color | string)[]): ContinuousColorScale; +function continuous(a: D3ContinuousSchemeName | number[], b?: any, c?: any): ContinuousColorScale { + if (typeof a === 'string') return continuousScaleFromScheme(a, b, c); + else return continuousScaleFromColors(a, b); +} + + +function discreteScaleFromColors(values: T[], colors: (Color | string)[], unknownColor: Color | string = '#888888'): DiscreteColorScale { + if (values.length !== colors.length) throw new Error('`values` and `colors` must have the same length'); + const n = values.length; + const map = new Map(); + for (let i = 0; i < n; i++) { + const color = colors[i]; + map.set(values[i], (typeof color === 'string') ? Color.fromString(color) : color); + } + const fallbackColor = (typeof unknownColor === 'string') ? Color.fromString(unknownColor) : unknownColor; + function discreteColorScale(x: T): Color { + return map.get(x) ?? fallbackColor; + }; + return discreteColorScale; +} + +function discreteScaleFromScheme(schemeName: D3SchemeName, values: T[], unknownColor?: Color | string): DiscreteColorScale { + const scheme = d3[`scheme${schemeName as D3CategoricalSchemeName}`]; + if (isStringArray(scheme)) { + const colorList = scheme.map(s => Color.fromString(s)); + return discreteScaleFromColors(values, cycle(colorList, values.length), unknownColor); + } + const colorInterpolator = d3[`interpolate${schemeName as D3ContinuousSchemeName}`]; + if (colorInterpolator !== undefined) { + const n = values.length; + const colors = colorsFromInterpolator(colorInterpolator, [0, 1], n); + return discreteScaleFromColors(values, colors, unknownColor); + } -function continuous(a: D3ContinuousSchemeName | number[], b?: any, c?: any): ColorScale { - if (typeof a === 'string') return createScaleFromScheme(a, b, c); - else return createScaleFromColors(a, b); + throw new Error(`Invalid color scheme name: "${schemeName}".\n(Available schemes: ${ColorScale.DiscreteSchemes})`); } +/** Create a discrete (categorical) color scale based on a named scheme, + * e.g. `discrete('Set1', ['dog', 'cat', 'fish'], 'gray')`. + * `schemeName` is the name of the used scheme (list available from `ColorScale.DiscreteSchemes`). + * `values` is the set of values (of any type) that map to the colors in the scheme. + * `unknownColor` parameter is the color that will be used for any value not present in `values`. */ +function discrete(schemeName: D3SchemeName, values: T[], unknownColor?: Color | string): DiscreteColorScale; +/** Create a discrete (categorical) color scale based on a list of values (of any type) and a list of colors mapped to these values, + * e.g. `discrete(['dog', 'cat', 'fish'], ['red', 'green', 'blue'], 'gray')`. + * `unknownColor` parameter is the color that will be used for any value not present in `values`. */ +function discrete(values: T[], colors: (Color | string)[], unknownColor?: Color | string): DiscreteColorScale; +function discrete(a: D3SchemeName | T[], b: any, c: any): DiscreteColorScale { + if (typeof a === 'string') return discreteScaleFromScheme(a, b, c); + else return discreteScaleFromColors(a, b, c); +} + +function linspace(range_: [number, number], n: number): number[] { + const scale = d3.scaleLinear([0, n - 1], range_); + return range(n).map(i => scale(i)); +} +function colorsFromInterpolator(colorInterpolator: (t: number) => string, range_: [number, number], nColors: number): Color[] { + return linspace(range_, nColors).map(x => Color.fromString(colorInterpolator(x))); +} +function isStringArray(value: any): value is string[] { + return isArray(value) && value.length > 0 && typeof (value[0]) === 'string'; +} +function cycle(source: T[], n: number): T[] { + const result = []; + while (result.length < n) { + result.push(...source); + } + result.length = n; + return result; +} + + +/** Functions for creating color scales to be used with heatmap. */ export const ColorScale = { - /** List of names of available color schemes */ - AvailableSchemes, - // AllSchemes, + /** List of available color schemes for `ColorScale.continuous()` */ + ContinuousSchemes: D3ContinuousSchemes, // continuous can only be used with continuous D3 schemes + + /** List of available color schemes for `ColorScale.discrete()` */ + DiscreteSchemes: D3Schemes, // discrete can be used with both categorical and continuous D3 schemes /** Create a continuous color scale, i.e. a mapping from real number to color. * Examples: @@ -86,5 +154,13 @@ export const ColorScale = { * continuous('Magma', [0, 1], [0, 1]) * continuous([0, 0.5, 1], ['white', 'orange', 'brown']) * ``` */ - continuous: continuous, + continuous, + + /** Create a discrete (categorical) color scale, i.e. a mapping from values of any type to colors. + * Examples: + * ``` + * discrete('Set1', ['dog', 'cat', 'fish'], 'gray') + * discrete(['dog', 'cat', 'fish'], ['red', 'green', 'blue'], 'gray') + * ``` */ + discrete, }; diff --git a/src/heatmap-component/extensions/tooltip.ts b/src/heatmap-component/extensions/tooltip.ts index 381188a..338599d 100644 --- a/src/heatmap-component/extensions/tooltip.ts +++ b/src/heatmap-component/extensions/tooltip.ts @@ -40,7 +40,7 @@ export class TooltipBehavior extends BehaviorBase this.updatePinnedTooltipPosition()); this.subscribe(this.state.events.resize, () => this.updatePinnedTooltipPosition()); } - + /** Add a div with tooltip or update position of existing tooltip, for the `pointed` grid cell. * Remove existing tooltip, if `pointed` is `undefined`. */ private drawTooltip(pointed: CellEventValue | undefined): void { diff --git a/src/main.ts b/src/main.ts index 023203a..589fc23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ /** Main file for importing `HeatmapComponent` as a dependency */ export { ColorScale } from './heatmap-component/data/color-scale'; -export { demo1, demo2, demo3 } from './heatmap-component/demo'; +export * as demos from './heatmap-component/demo'; export { Heatmap } from './heatmap-component/heatmap';