UNPKG

terriajs

Version:

Geospatial data visualization platform.

741 lines (650 loc) 24.7 kB
import * as d3Scale from "d3-scale-chromatic"; import { computed, makeObservable } from "mobx"; import Color from "terriajs-cesium/Source/Core/Color"; import createColorForIdTransformer from "../Core/createColorForIdTransformer"; import filterOutUndefined from "../Core/filterOutUndefined"; import isDefined from "../Core/isDefined"; import runLater from "../Core/runLater"; import StandardCssColors from "../Core/StandardCssColors"; import TerriaError from "../Core/TerriaError"; import ColorMap from "../Map/ColorMap/ColorMap"; import ConstantColorMap from "../Map/ColorMap/ConstantColorMap"; import ContinuousColorMap from "../Map/ColorMap/ContinuousColorMap"; import DiscreteColorMap from "../Map/ColorMap/DiscreteColorMap"; import EnumColorMap from "../Map/ColorMap/EnumColorMap"; import Model from "../Models/Definition/Model"; import ModelPropertiesFromTraits from "../Models/Definition/ModelPropertiesFromTraits"; import TableColorStyleTraits, { EnumColorTraits } from "../Traits/TraitsClasses/Table/ColorStyleTraits"; import TableColumn from "./TableColumn"; import TableColumnType from "./TableColumnType"; import { StyleMapType } from "./TableStyleMap"; const getColorForId = createColorForIdTransformer(); const DEFAULT_COLOR = "yellow"; /** Diverging scales (can be used for continuous and discrete). * Discrete scales support a size n ranging from 3 to 11 */ export const DIVERGING_SCALES = [ "BrBG", "PRGn", "PiYG", "PuOr", "RdBu", "RdGy", "RdYlBu", "RdYlGn", "Spectral" ]; export const DEFAULT_DIVERGING = "PuOr"; /** Sequential scales D3 color scales (can be used for continuous and discrete). * Discrete scales support a size n ranging from 3 to 9 */ export const SEQUENTIAL_SCALES = [ "Blues", "Greens", "Greys", "Oranges", "Purples", "Reds", "BuGn", "BuPu", "GnBu", "OrRd", "PuBuGn", "PuBu", "PuRd", "RdPu", "YlGnBu", "YlGn", "YlOrBr", "YlOrRd" ]; export const DEFAULT_SEQUENTIAL = "Reds"; /** Sequential continuous D3 color scales (continuous only - not discrete) */ export const SEQUENTIAL_CONTINUOUS_SCALES = [ "Turbo", "Viridis", "Inferno", "Magma", "Plasma", "Cividis", "Warm", "Cool", "CubehelixDefault" ]; export const QUALITATIVE_SCALES = [ // Note HighContrast is custom - see StandardCssColors.highContrast "HighContrast", "Category10", "Accent", "Dark2", "Paired", "Pastel1", "Pastel2", "Set1", "Set2", "Set3", "Tableau10" ]; export const DEFAULT_QUALITATIVE = "HighContrast"; export default class TableColorMap { constructor( /** Title used for ConstantColorMaps - and to create a unique color for a particular Table-based CatalogItem */ readonly title: string | undefined, readonly colorColumn: TableColumn | undefined, readonly colorTraits: Model<TableColorStyleTraits> ) { makeObservable(this); } @computed get type(): StyleMapType { return this.colorMap instanceof DiscreteColorMap ? "bin" : this.colorMap instanceof ContinuousColorMap ? "continuous" : this.colorMap instanceof EnumColorMap ? "enum" : "constant"; } /** * Gets an object used to map values in {@link #colorColumn} to colors * for this style. * * Will try to create most appropriate colorMap given colorColumn: * TODO: Move all of these default values/behaviours to TableAutomaticStylesStratum - ideally no use of colorColumn is needed here * - `TableAutomaticStylesStratum should also set default values for colorTraits.mapType, colors, etc * * - If column type is `scalar` * - and we have binMaximums - use DiscreteColorMap * - and we have a valid minValue and maxValue - use ContinuousColorMap * - and only a single value - use EnumColorMap * * - If column type is `enum` or `region` * - and we have enough binColors to represent uniqueValues - use EnumColorMap * * - If none of the above conditions are met - use ConstantColorMap */ @computed get colorMap(): ColorMap { const colorColumn = this.colorColumn; const colorTraits = this.colorTraits; // If column type is `scalar` - use DiscreteColorMap or ContinuousColorMap if ( (colorColumn && !colorTraits.mapType && colorColumn.type === TableColumnType.scalar) || colorTraits.mapType === "continuous" || colorTraits.mapType === "bin" ) { // If column type is `scalar` and we have binMaximums - use DiscreteColorMap if (colorTraits.mapType !== "continuous" && this.binMaximums.length > 0) { return new DiscreteColorMap({ bins: this.binColors.map((color, i) => { return { color: Color.fromCssColorString(color), maximum: this.binMaximums[i], includeMinimumInThisBin: false }; }), nullColor: this.nullColor }); } // If column type is `scalar` and we have a valid minValue and maxValue - use ContinuousColorMap if ( colorTraits.mapType !== "bin" && isDefined(this.minimumValue) && isDefined(this.maximumValue) && this.minimumValue < this.maximumValue ) { // Get colorScale from `d3-scale-chromatic` library - all continuous color schemes start with "interpolate" // See https://github.com/d3/d3-scale-chromatic#diverging // d3 continuous color schemes are represented as a function which map a value [0,1] to a color] const colorScale = this.colorScaleContinuous(); return new ContinuousColorMap({ colorScale, minValue: this.minimumValue, maxValue: this.maximumValue, nullColor: this.nullColor, outlierColor: this.outlierColor, isDiverging: this.isDiverging }); // Edge case: if we only have one value, create color map with single value // This is because ContinuousColorMap can't handle minimumValue === maximumValue } else if (this.colorColumn?.uniqueValues.values.length === 1) { return new EnumColorMap({ enumColors: [ { color: Color.fromCssColorString(this.colorScaleContinuous()(1)), value: this.colorColumn.uniqueValues.values[0] } ], nullColor: this.nullColor }); } // If no useful ColorMap could be found for the scalar column - we will create a ConstantColorMap at the end of the function } // If column type is `enum` or `region` - use EnumColorMap else if ( (colorColumn && !colorTraits.mapType && (colorColumn.type === TableColumnType.enum || colorColumn.type === TableColumnType.region) && this.enumColors.length > 0) || colorTraits.mapType === "enum" ) { return new EnumColorMap({ enumColors: filterOutUndefined( this.enumColors.map((e) => { if (e.value === undefined || e.color === undefined) { return undefined; } return { value: e.value, color: colorColumn?.type !== TableColumnType.region ? (Color.fromCssColorString(e.color) ?? Color.TRANSPARENT) : this.regionColor }; }) ), nullColor: this.nullColor }); } // No useful colorMap can be generated - so create a ConstantColorMap (the same color for everything. // Try to find a useful color to use in this order // - If colorColumn is of type region - use regionColor // - If binColors trait it set - use it // - If we have a title, use it to generate a unique color for this style // - Or use DEFAULT_COLOR let color: Color | undefined; if (colorColumn?.type === TableColumnType.region && this.regionColor) { color = this.regionColor; } else if (colorTraits.nullColor) { color = Color.fromCssColorString(colorTraits.nullColor); } else if (colorTraits.binColors && colorTraits.binColors.length > 0) { color = Color.fromCssColorString(colorTraits.binColors[0]); } else if (this.title) { color = Color.fromCssColorString(getColorForId(this.title)); } if (!color) { color = Color.fromCssColorString(DEFAULT_COLOR); } return new ConstantColorMap({ color, title: this.title, // Use nullColor if colorColumn is of type `region` // This is so we only see regions which rows exist for (everything else will use nullColor) nullColor: colorColumn?.type === TableColumnType.region ? this.nullColor : undefined }); } /** * Bin colors used to represent `scalar` TableColumns in a DiscreteColorMap */ @computed get binColors(): readonly Readonly<string>[] { const numberOfBins = this.binMaximums.length; // Pick a color for every bin. const binColors = this.colorTraits.binColors || []; const colorScale = this.colorScaleCategorical(this.binMaximums.length); const result: string[] = []; for (let i = 0; i < numberOfBins; ++i) { if (i < binColors.length) { result.push(binColors[i] ?? Color.TRANSPARENT.toCssHexString()); } else { result.push(colorScale[i % colorScale.length]); } } return result; } /** * Bin maximums used to represent `scalar` TableColumns in a DiscreteColorMap * These map directly to `this.binColors` */ @computed get binMaximums(): readonly number[] { const colorColumn = this.colorColumn; if (colorColumn === undefined) { return this.colorTraits.binMaximums || []; } const binMaximums = this.colorTraits.binMaximums; if (binMaximums !== undefined) { if ( colorColumn.type === TableColumnType.scalar && this.maximumValue !== undefined && (binMaximums.length === 0 || this.maximumValue > binMaximums[binMaximums.length - 1]) ) { // Add an extra bin to accommodate the maximum value of the dataset. return binMaximums.concat([this.maximumValue]); } return binMaximums; } else if (this.colorTraits.numberOfBins === 0) { return []; } else { // TODO: compute maximums according to ckmeans, quantile, etc. if (this.minimumValue === undefined || this.maximumValue === undefined) { return []; } const numberOfBins = colorColumn.uniqueValues.values.length < this.colorTraits.numberOfBins ? colorColumn.uniqueValues.values.length : this.colorTraits.numberOfBins; let next = this.minimumValue; const step = (this.maximumValue - this.minimumValue) / numberOfBins; const result: number[] = []; for (let i = 0; i < numberOfBins - 1; ++i) { next += step; result.push(next); } result.push(this.maximumValue); return result; } } /** * Enum bin colors used to represent `enum` or `region` TableColumns in a EnumColorMap * If no enumColor traits are provided, then try to create colors from uniqueValues */ @computed get enumColors(): readonly ModelPropertiesFromTraits<EnumColorTraits>[] { if (this.colorTraits.enumColors?.length ?? 0 > 0) { return this.colorTraits.enumColors!; } const colorColumn = this.colorColumn; if (colorColumn === undefined) { return []; } // No enumColors traits provided - so create a color for each unique value const uniqueValues = colorColumn.uniqueValues.values; const colorScale = this.colorScaleCategorical(uniqueValues.length); return colorScale .map((color, i) => { return { value: uniqueValues[i], color }; }) .filter((color) => isDefined(color.value)); } /** This color is used to color values outside minimumValue and maximumValue - it is only used for ContinuousColorMaps * If undefined, values outside min/max values will be clamped * If zScoreFilter is enabled, this will return a default color (AQUAMARINE) **/ @computed get outlierColor() { // Only return outlier if there are actually values outside min/max if ( !isDefined(this.minimumValue) || !isDefined(this.validValuesMin) || !isDefined(this.maximumValue) || !isDefined(this.validValuesMax) || (this.minimumValue <= this.validValuesMin && this.maximumValue >= this.validValuesMax) ) return; if (isDefined(this.colorTraits.outlierColor)) return Color.fromCssColorString(this.colorTraits.outlierColor); if (!this.zScoreFilterValues || !this.colorTraits.zScoreFilterEnabled) return; return Color.AQUAMARINE; } @computed get nullColor() { return this.colorTraits.nullColor ? (Color.fromCssColorString(this.colorTraits.nullColor) ?? Color.TRANSPARENT) : Color.TRANSPARENT; } @computed get regionColor() { return Color.fromCssColorString(this.colorTraits.regionColor); } /** We treat color map as "diverging" if the range cross 0 - (the color scale has positive and negative values) * We also check to make sure colorPalette ColorTrait is set to a diverging color palette (see https://github.com/d3/d3-scale-chromatic#diverging) */ @computed get isDiverging() { return ( (this.minimumValue || 0.0) < 0.0 && (this.maximumValue || 0.0) > 0.0 && [ // If colorPalette is undefined, defaultColorPaletteName will return a diverging color scale undefined, ...DIVERGING_SCALES ].includes(this.colorTraits.colorPalette) ); } /** Get default colorPalette name. * Follows https://github.com/d3/d3-scale-chromatic#api-reference * If Enum or Region - use custom HighContrast (See StandardCssColors.highContrast) * If scalar and not diverging - use Reds palette * If scalar and diverging - use Purple to Orange palette * * NOTE: it is **very** important that these values are valid color palettes. * If they are not, Terria will crash */ @computed get defaultColorPaletteName() { const colorColumn = this.colorColumn; if (colorColumn === undefined) { // This shouldn't get used - as if there is no colorColumn - there is nothing to visualize! return DEFAULT_SEQUENTIAL; } if ( colorColumn.type === TableColumnType.enum || colorColumn.type === TableColumnType.region ) { // Enumerated values, so use a large, high contrast palette. return DEFAULT_QUALITATIVE; } else if (colorColumn.type === TableColumnType.scalar) { const valuesAsNumbers = colorColumn.valuesAsNumbers; if (valuesAsNumbers !== undefined && this.isDiverging) { // Values cross zero, so use a diverging palette return DEFAULT_DIVERGING; } else { // Values do not cross zero so use a sequential palette. return DEFAULT_SEQUENTIAL; } } return DEFAULT_SEQUENTIAL; } /** Minimum value - with filters if applicable * This will apply to ContinuousColorMaps and DiscreteColorMaps */ @computed get minimumValue() { if (isDefined(this.colorTraits.minimumValue)) return this.colorTraits.minimumValue; if (this.zScoreFilterValues && this.colorTraits.zScoreFilterEnabled) return this.zScoreFilterValues.min; if (isDefined(this.validValuesMin)) return this.validValuesMin; } /** Maximum value - with filters if applicable * This will apply to ContinuousColorMaps and DiscreteColorMaps */ @computed get maximumValue() { if (isDefined(this.colorTraits.maximumValue)) return this.colorTraits.maximumValue; if (this.zScoreFilterValues && this.colorTraits.zScoreFilterEnabled) return this.zScoreFilterValues.max; if (isDefined(this.validValuesMax)) return this.validValuesMax; } /** Get values of colorColumn with valid regions if: * - colorColumn is scalar and the activeStyle has a regionColumn */ @computed get regionValues() { const regionColumn = this.colorColumn?.tableModel.activeTableStyle.regionColumn; if (this.colorColumn?.type !== TableColumnType.scalar || !regionColumn) return; return regionColumn.valuesAsRegions.regionIds.map((region, rowIndex) => { // Only return values which have a valid region in the same row if (region !== null) { return this.colorColumn?.valuesAsNumbers.values[rowIndex] ?? null; } return null; }); } /** Filter out null values from color column */ @computed get validValues() { const values = this.regionValues ?? this.colorColumn?.valuesAsNumbers.values; if (values) { return values.filter((val) => val !== null) as number[]; } } @computed get validValuesMax() { return this.validValues ? getMax(this.validValues) : undefined; } @computed get validValuesMin() { return this.validValues ? getMin(this.validValues) : undefined; } /** Filter by z-score if applicable * It requires: * - `colorTraits.zScoreFilter` to be defined, * - colorTraits.minimumValue and colorTraits.maximumValue to be UNDEFINED * * This will treat values outside of specified z-score as outliers, and therefore will not include in color scale. This value is magnitude of z-score - it will apply to positive and negative z-scores. For example a value of `2` will treat all values that are 2 or more standard deviations from the mean as outliers. * This will only apply to ContinuousColorMaps * */ @computed get zScoreFilterValues(): { max: number; min: number } | undefined { if ( !isDefined(this.colorTraits.zScoreFilter) || isDefined(this.colorTraits.minimumValue) || isDefined(this.colorTraits.maximumValue) || !this.colorColumn || !this.validValues || !isDefined(this.validValuesMax) || !isDefined(this.validValuesMin) || this.validValues.length === 0 ) return; const values = this.regionValues ?? this.colorColumn?.valuesAsNumbers.values; const rowGroups = this.colorColumn.tableModel.activeTableStyle.rowGroups; // Array of row group values const rowGroupValues = rowGroups.map( (group) => group[1] .map((row) => values[row]) .filter((val) => val !== null) as number[] ); // Get average value for each row group const rowGroupAverages = rowGroupValues.map((val) => getMean(val)); const definedRowGroupAverages = filterOutUndefined(rowGroupAverages); const std = getStandardDeviation(definedRowGroupAverages); const mean = getMean(definedRowGroupAverages); // No std or mean - so return unfiltered values if (!isDefined(std) && !isDefined(mean)) return; let filteredMax = -Infinity; let filteredMin = Infinity; rowGroupAverages.forEach((rowGroupMean, idx) => { if ( isDefined(rowGroupMean) && Math.abs((rowGroupMean - mean!) / std!) <= this.colorTraits.zScoreFilter! ) { // If mean is within zscore filter, update min/max const rowGroupMin = getMin(rowGroupValues[idx]); filteredMin = filteredMin > rowGroupMin ? rowGroupMin : filteredMin; const rowGroupMax = getMax(rowGroupValues[idx]); filteredMax = filteredMax < rowGroupMax ? rowGroupMax : filteredMax; } }); const actualRange = this.validValuesMax - this.validValuesMin; // Only apply filtered min/max if it reduces range by factor of `rangeFilter` (eg if `rangeFilter = 0.1`, then the filter must reduce the range by at least 10% to be applied) // This applies to min and max independently if ( filteredMin < this.validValuesMin + actualRange * this.colorTraits.rangeFilter ) { filteredMin = this.validValuesMin; } if ( filteredMax > this.validValuesMax - actualRange * this.colorTraits.rangeFilter ) { filteredMax = this.validValuesMax; } if ( filteredMin < filteredMax && (filteredMin !== this.validValuesMin || filteredMax !== this.validValuesMax) ) return { max: filteredMax, min: filteredMin }; } /** * Get colorScale from `d3-scale-chromatic` library - all continuous color schemes start with "interpolate" * See https://github.com/d3/d3-scale-chromatic#diverging */ colorScaleContinuous(): (value: number) => string { // d3 continuous color schemes are represented as a function which map a value [0,1] to a color] let colorScale: ((value: number) => string) | undefined; // If colorPalette trait is defined - try to resolve it if (isDefined(this.colorTraits.colorPalette)) { colorScale = (d3Scale as any)[ `interpolate${this.colorTraits.colorPalette}` ]; } // If no colorScaleScheme found - use `defaultColorPaletteName` to find one if (!isDefined(colorScale)) { if (isDefined(this.colorTraits.colorPalette)) { this.invalidColorPaletteWarning(); } colorScale = (d3Scale as any)[ `interpolate${this.defaultColorPaletteName}` ] as (value: number) => string; } return colorScale; } /** * Get categorical colorScale from `d3-scale-chromatic` library - all categorical color schemes start with "scheme" * See https://github.com/d3/d3-scale-chromatic#categorical * @param numberOfBins */ colorScaleCategorical(numberOfBins: number): string[] { // d3 categorical color schemes are represented as either: // Two dimensional arrays // - First array represents number of bins in the given color scale (eg 3 = [#ff0000, #ffaa00, #ffff00]) // - Second array contains color values // One dimensional array // - Just an array of color values // - For example schemeCategory10 (https://github.com/d3/d3-scale-chromatic#schemeCategory10) is a fixed color scheme with 10 values let colorScaleScheme: any; // If colorPalette trait is defined - try to resolve it if (isDefined(this.colorTraits.colorPalette)) { // "HighContrast" is a custom additional palette if (this.colorTraits.colorPalette === "HighContrast") { colorScaleScheme = StandardCssColors.highContrast; } else { colorScaleScheme = (d3Scale as any)[ `scheme${this.colorTraits.colorPalette}` ]; } } // If no colorScaleScheme found - use `defaultColorPaletteName` to find one if (!colorScaleScheme) { if (isDefined(this.colorTraits.colorPalette)) { this.invalidColorPaletteWarning(); } if (this.defaultColorPaletteName === "HighContrast") { colorScaleScheme = StandardCssColors.highContrast; } else { colorScaleScheme = (d3Scale as any)[ `scheme${this.defaultColorPaletteName}` ]; } } let colorScale: string[]; // If color scheme is one dimensional array (eg schemeCategory10 or HighContrast) if (typeof colorScaleScheme[0] === "string") { colorScale = colorScaleScheme; // Color scheme is two dimensional - so find appropriate set } else { colorScale = colorScaleScheme[numberOfBins]; // If invalid numberOfBins - use largest set provided by d3 if (!Array.isArray(colorScale)) { colorScale = colorScaleScheme[colorScaleScheme.length - 1]; } } return colorScale; } // TODO: Make TableColorMap use Result to pass warnings up model layer invalidColorPaletteWarning(): void { if ( this.colorColumn?.name && this.colorColumn?.tableModel.activeStyle === this.colorColumn?.name ) { runLater(() => this.colorColumn?.tableModel.terria.raiseErrorToUser( new TerriaError({ title: "Invalid colorPalette", message: `Column ${this.colorColumn?.name} has an invalid color palette - \`"${this.colorTraits.colorPalette}"\`. Will use default color palette \`"${this.defaultColorPaletteName}"\` instead` }) ) ); } } } function getMin(array: number[]) { return array.reduce((a, b) => (b < a ? b : a), Infinity); } function getMax(array: number[]) { return array.reduce((a, b) => (a < b ? b : a), -Infinity); } function getMean(array: number[]) { return array.length === 0 ? undefined : array.reduce((a, b) => a + b) / array.length; } // https://stackoverflow.com/a/53577159 function getStandardDeviation(array: number[]) { const n = array.length; const mean = getMean(array); return isDefined(mean) ? Math.sqrt( array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n ) : undefined; }