From 3826c0dd7e647792ac01fae47a9b057deefad337 Mon Sep 17 00:00:00 2001 From: acshef Date: Thu, 29 Apr 2021 13:02:40 -0600 Subject: [PATCH] feat: Interpolate color threshold stops (#596) It's no longer necessary to specify all the stop points in color thresholds (gradients). This paves the way for two more improvements (which are not featured in this PR): Implicitly assuming the first and last stops are the min/max values in the graph (or some other reasonable logic), thereby making all stop points completely optional Allowing for named color patterns (hardcoded into the project), like "rainbow", "cubehelix", "rocket", "mako", etc. (adopting common color patterns already established in other FOSS data visualization projects) --- README.md | 40 ++++++++++++++++++++- src/buildConfig.js | 86 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0c3dd85..0b75a99 100755 --- a/README.md +++ b/README.md @@ -169,9 +169,47 @@ See [dynamic line color](#dynamic-line-color) for example usage. | Name | Type | Default | Description | |------|:----:|:-------:|-------------| -| value ***(required)*** | number | | The threshold for the color stop. +| value ***(required [except in interpolation (see below)](#line-color-interpolation-of-stop-values))*** | number | | The threshold for the color stop. | color ***(required)*** | string | | Color in 6 digit hex format (e.g. `#008080`). +##### Line color interpolation of stop values +As long as the first and last threshold stops have `value` properties, intermediate stops can exclude `value`; they will be interpolated linearly. For example, given stops like: + +```yaml +color_thresholds: + - value: 0 + color: "#ff0000" + - color: "#ffff00" + - color: "#00ff00" + - value: 4 + color: "#0000ff" +``` + +The values will be interpolated as: + +```yaml +color_thresholds: + - value: 0 + color: "#ff0000" + - value: 1.333333 + color: "#ffff00" + - value: 2.666667 + color: "#00ff00" + - value: 4 + color: "#0000ff" +``` + +As a shorthand, you can just use a color string for the stops that you want interpolated: + +```yaml + - value: 0 + color: "#ff0000" + - "#ffff00" + - "#00ff00" + - value: 4 + color: "#0000ff" +``` + #### Action object options | Name | Type | Default | Options | Description | |------|:----:|:-------:|:-----------:|-------------| diff --git a/src/buildConfig.js b/src/buildConfig.js index b717fc6..73fe792 100755 --- a/src/buildConfig.js +++ b/src/buildConfig.js @@ -8,15 +8,93 @@ import { DEFAULT_SHOW, } from './const'; +/** + * Starting from the given index, increment the index until an array element with a + * "value" property is found + * + * @param {Array} stops + * @param {number} startIndex + * @returns {number} + */ +const findFirstValuedIndex = (stops, startIndex) => { + for (let i = startIndex, l = stops.length; i < l; i += 1) { + if (stops[i].value != null) { + return i; + } + } + throw new Error( + 'Error in threshold interpolation: could not find right-nearest valued stop. ' + + 'Do the first and last thresholds have a set "value"?', + ); +}; + +/** + * Interpolates the "value" of each stop. Each stop can be a color string or an object of type + * ``` + * { + * color: string + * value?: number | null + * } + * ``` + * And the values will be interpolated by the nearest valued stops. + * + * For example, given values `[ 0, null, null, 4, null, 3]`, + * the interpolation will output `[ 0, 1.3333, 2.6667, 4, 3.5, 3 ]` + * + * Note that values will be interpolated ascending and descending. + * All that's necessary is that the first and the last elements have values. + * + * @param {Array} stops + * @returns {Array<{ color: string, value: number }>} + */ +const interpolateStops = (stops) => { + if (!stops || !stops.length) { + return stops; + } + if (stops[0].value == null || stops[stops.length - 1].value == null) { + throw new Error(`The first and last thresholds must have a set "value".\n See ${URL_DOCS}`); + } + + let leftValuedIndex = 0; + let rightValuedIndex = null; + + return stops.map((stop, stopIndex) => { + if (stop.value != null) { + return { ...stop }; + } + + if (rightValuedIndex == null) { + rightValuedIndex = findFirstValuedIndex(stops, stopIndex); + } else if (stopIndex > rightValuedIndex) { + leftValuedIndex = rightValuedIndex; + rightValuedIndex = findFirstValuedIndex(stops, stopIndex); + } + + // y = mx + b + // m = dY/dX + // x = index in question + // b = left value + + const leftValue = stops[leftValuedIndex].value; + const rightValue = stops[rightValuedIndex].value; + const m = (rightValue - leftValue) / (rightValuedIndex - leftValuedIndex); + return { + color: typeof stop === 'string' ? stop : stop.color, + value: m * stopIndex + leftValue, + }; + }); +}; + const computeThresholds = (stops, type) => { - stops.sort((a, b) => b.value - a.value); + const valuedStops = interpolateStops(stops); + valuedStops.sort((a, b) => b.value - a.value); if (type === 'smooth') { - return stops; + return valuedStops; } else { - const rect = [].concat(...stops.map((stop, i) => ([stop, { + const rect = [].concat(...valuedStops.map((stop, i) => ([stop, { value: stop.value - 0.0001, - color: stops[i + 1] ? stops[i + 1].color : stop.color, + color: valuedStops[i + 1] ? valuedStops[i + 1].color : stop.color, }]))); return rect; }