// Copyright (c) 2022 Uber Technologies, Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import {console as Console} from 'global/window'; import keymirror from 'keymirror'; import {CollisionFilterExtension, DataFilterExtension} from '@deck.gl/extensions'; import {COORDINATE_SYSTEM, Layer as DeckLayer} from '@deck.gl/core'; import {PointLabelLayer} from '@deck.gl/carto'; import DefaultLayerIcon from './default-layer-icon'; import {diffUpdateTriggers} from './layer-update'; import {rgb} from 'd3-color'; import { ALL_FIELD_TYPES, NO_VALUE_COLOR, SCALE_TYPES, CHANNEL_SCALES, FIELD_OPTS, SCALE_FUNC, CHANNEL_SCALE_SUPPORTED_FIELDS, MAX_GPU_FILTERS, UNKNOWN_COLOR, ZOOM_LEVEL_LIMITS } from 'constants/default-settings'; import {ColorRange, COLOR_RANGES, hexColor} from 'constants/color-ranges'; import {DataVizColors} from 'constants/custom-color-ranges'; import { LAYER_VIS_CONFIGS, DEFAULT_TEXT_LABEL, DEFAULT_COLOR_UI, DEFAULT_HIGHLIGHT_COLOR, DEFAULT_LAYER_LABEL, LayerVisConfig, LayerVisConfigSettings } from './layer-factory'; import {generateHashId, isPlainObject} from 'utils/utils'; import {getLatLngBounds, notNullorUndefined} from 'utils/data-utils'; import {getSampleData} from 'utils/table-utils/data-container-utils'; import { hexToRgb, getColorGroupByName, reverseColorRange, checkColorOrFallback } from 'utils/color-utils'; import {getMaskExtensionProps} from 'utils/layer-utils'; import {scaleOrdinal} from 'd3-scale'; import {RGBColor, RGBAColor, MapState, Filter, Datasets, ValueOf, NestedPartial} from 'reducers'; import {LayerTextLabel, ColorUI} from './layer-factory'; import {KeplerTable} from '../utils'; import {DataContainerInterface} from 'utils/table-utils/data-container-interface'; import {Field, GpuFilter} from 'utils/table-utils/kepler-table'; import React from 'react'; export type LayerColumn = {value: string | null; fieldIdx: number; optional?: boolean}; export type LayerColumns = { [key: string]: LayerColumn; }; export type VisualChannelDomain = number[] | string[]; export type VisualChannelField = Field | null; export type VisualChannelScale = keyof typeof SCALE_TYPES; export type LayerBaseConfig = { dataId: string | null; label: string; color: RGBColor; columns: LayerColumns; isVisible: boolean; isConfigActive: boolean; highlightColor: RGBColor | RGBAColor; hidden: boolean; visConfig: LayerVisConfig; textLabel: LayerTextLabel[]; colorUI: { color: ColorUI; colorRange: ColorUI; }; animation: { enabled: boolean; domain?: null; }; }; export type LayerColorConfig = { colorField: VisualChannelField; colorDomain: VisualChannelDomain; colorScale: VisualChannelScale; }; export type LayerSizeConfig = { // color by size, domain is set by filters, field, scale type sizeDomain: VisualChannelDomain; sizeScale: VisualChannelScale; sizeField: VisualChannelField; }; export type LayerHeightConfig = { heightField: VisualChannelField; heightDomain: VisualChannelDomain; heightScale: VisualChannelScale; }; export type LayerStrokeColorConfig = { strokeColorField: VisualChannelField; strokeColorDomain: VisualChannelDomain; strokeColorScale: VisualChannelScale; }; export type LayerCoverageConfig = { coverageField: VisualChannelField; coverageDomain: VisualChannelDomain; coverageScale: VisualChannelScale; }; export type LayerRadiusConfig = { radiusField: VisualChannelField; radiusDomain: VisualChannelDomain; radiusScale: VisualChannelScale; }; export type LayerWeightConfig = { weightField: VisualChannelField; }; export type VisualChannels = {[key: string]: VisualChannel}; export type VisualChannelAggregation = 'colorAggregation' | 'sizeAggregation'; export type VisualChannel = { property: string; field: string; scale: string; domain: string; range: string; key: string; channelScaleType: string; nullValue?: any; defaultMeasure?: any; accessor?: string; condition?: (config: any) => boolean; getAttributeValue?: (config: any) => (d: any) => any; // TODO: define defaultValue defaultValue?: any; // TODO: define fixed fixed?: any; supportedFieldTypes?: Array; aggregation?: VisualChannelAggregation; }; export type VisualChannelDescription = { label: string; measure: string; }; export type ColumnPairs = {[key: string]: {pair: string; fieldPairKey: string}}; type ColumnValidator = (column: LayerColumn, columns: LayerColumns, allFields: Field[]) => boolean; export type UpdateTriggers = { [key: string]: UpdateTrigger; }; export type UpdateTrigger = { [key: string]: {}; }; export type LayerBounds = [number, number, number, number]; export type FindDefaultLayerPropsReturnValue = {props: any[]; foundLayers?: any[]}; /** * Approx. number of points to sample in a large data set */ export const LAYER_ID_LENGTH = 6; // Note, this is is SVG of solid circle encoded as data-uri const FALLBACK_ICON = 'data:image/svg+xml;charset=utf-8;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTAwIDEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCiAgPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iNTAiLz4NCjwvc3ZnPg=='; const MAX_SAMPLE_SIZE = 5000; const defaultDomain: [number, number] = [0, 1]; const dataFilterExtension = new DataFilterExtension({filterSize: MAX_GPU_FILTERS}); const collisionFilterExtension = new CollisionFilterExtension(); const defaultDataAccessor = dc => d => d; const defaultGetFieldValue = (field, d) => field.valueAccessor(d); const scaleIdentity = v => v; export const OVERLAY_TYPE = keymirror({ deckgl: null, mapboxgl: null }); export const layerColors = Object.values(DataVizColors).map(hexToRgb); function* generateColor(): Generator { let index = 0; while (index < layerColors.length + 1) { if (index === layerColors.length) { index = 0; } yield layerColors[index++]; } } export const colorMaker = generateColor(); class Layer { id: string; meta: {}; visConfigSettings: { [key: string]: ValueOf; }; config: LayerBaseConfig; // TODO: define _oldDataUpdateTriggers _oldDataUpdateTriggers: any; legendSettings: Record; _interactivitySettings: Record; constructor( props: { id?: string; } & Partial = {} ) { this.id = props.id || generateHashId(LAYER_ID_LENGTH); // meta this.meta = {}; // legendSettings this.legendSettings = {}; // visConfigSettings this.visConfigSettings = {}; // interactivitySettings this._interactivitySettings = {}; this.config = this.getDefaultLayerConfig({ columns: this.getLayerColumns(), ...props }); } get layerIcon(): React.ElementType { return DefaultLayerIcon; } get overlayType(): keyof typeof OVERLAY_TYPE { return OVERLAY_TYPE.deckgl; } get type(): string | null { return null; } get name() { return this.type; } get isAggregated() { return false; } get requiredLayerColumns(): string[] { return []; } get optionalColumns(): string[] { return []; } get noneLayerDataAffectingProps() { return ['label', 'opacity', 'thickness', 'isVisible', 'hidden']; } get visualChannels(): VisualChannels { return { color: { property: 'color', field: 'colorField', scale: 'colorScale', domain: 'colorDomain', range: 'colorRange', key: 'color', channelScaleType: CHANNEL_SCALES.color, nullValue: NO_VALUE_COLOR, defaultValue: config => config.color }, size: { property: 'size', field: 'sizeField', scale: 'sizeScale', domain: 'sizeDomain', range: 'sizeRange', key: 'size', channelScaleType: CHANNEL_SCALES.size, nullValue: 0, defaultValue: 1 } }; } get columnValidators(): {[key: string]: ColumnValidator} { return {}; } /* * Column pairs maps layer column to a specific field pairs, * By default, it is set to null */ get columnPairs(): ColumnPairs | null { return null; } /* * Default point column pairs, can be used for point based layers: point, icon etc. */ get defaultPointColumnPairs(): ColumnPairs { return { lat: {pair: 'lng', fieldPairKey: 'lat'}, lng: {pair: 'lat', fieldPairKey: 'lng'} }; } /* * Default link column pairs, can be used for link based layers: arc, line etc */ get defaultLinkColumnPairs(): ColumnPairs { return { lat0: {pair: 'lng0', fieldPairKey: 'lat'}, lng0: {pair: 'lat0', fieldPairKey: 'lng'}, lat1: {pair: 'lng1', fieldPairKey: 'lat'}, lng1: {pair: 'lat1', fieldPairKey: 'lng'} }; } /** * Return a React component for to render layer instructions in a modal * @returns {object} - an object * @example * return { * id: 'iconInfo', * template: IconInfoModal, * modalProps: { * title: 'How to draw icons' * }; * } */ get layerInfoModal(): any { return null; } get interactivitySettings() { return (this as any)._interactivitySettings; } set interactivitySettings(interactivitySettings) { (this as any)._interactivitySettings = interactivitySettings; } get hasTooltip() { return Boolean( this.interactivitySettings?.hover?.fields.length || this.interactivitySettings?.click?.fields.length ); } get hasInteractivity() { return Boolean(this.hasTooltip && this.interactivitySettings?.enabled); } /* * Given a dataset, automatically find props to create layer based on it * and return the props and previous found layers. * By default, no layers will be found */ static findDefaultLayerProps( dataset: KeplerTable, foundLayers?: any[] ): FindDefaultLayerPropsReturnValue { return {props: [], foundLayers}; } /** * Given a array of preset required column names * found field that has the same name to set as layer column * * @param {object} defaultFields * @param {object[]} allFields * @returns {object[] | null} all possible required layer column pairs */ static findDefaultColumnField(defaultFields, allFields) { // find all matched fields for each required col const requiredColumns = Object.keys(defaultFields).reduce((prev, key) => { const requiredFields = allFields.filter( f => f.name === defaultFields[key] || defaultFields[key].includes(f.name) ); prev[key] = requiredFields.length ? requiredFields.map(f => ({ value: f.name, fieldIdx: f.fieldIdx })) : null; return prev; }, {}); if (!Object.values(requiredColumns).every(Boolean)) { // if any field missing, return null return null; } return this.getAllPossibleColumnParis(requiredColumns); } static getAllPossibleColumnParis(requiredColumns) { // for multiple matched field for one required column, return multiple // combinations, e. g. if column a has 2 matched, column b has 3 matched // 6 possible column pairs will be returned const allKeys = Object.keys(requiredColumns); const pointers = allKeys.map((k, i) => (i === allKeys.length - 1 ? -1 : 0)); const countPerKey = allKeys.map(k => requiredColumns[k].length); // TODO: Better typings const pairs: any[] = []; /* eslint-disable no-loop-func */ while (incrementPointers(pointers, countPerKey, pointers.length - 1)) { const newPair = pointers.reduce((prev, cuur, i) => { prev[allKeys[i]] = requiredColumns[allKeys[i]][cuur]; return prev; }, {}); pairs.push(newPair); } /* eslint-enable no-loop-func */ // recursively increment pointers function incrementPointers(pts, counts, index) { if (index === 0 && pts[0] === counts[0] - 1) { // nothing to increment return false; } if (pts[index] + 1 < counts[index]) { pts[index] = pts[index] + 1; return true; } pts[index] = 0; return incrementPointers(pts, counts, index - 1); } return pairs; } static hexToRgb(c) { return hexToRgb(c); } getDefaultLayerConfig( props: Partial = {} ): LayerBaseConfig & Partial { return { dataId: props.dataId || null, ...{ visibilityByZoom: { min: (props as any).visibilityByZoom?.min || ZOOM_LEVEL_LIMITS.min, max: (props as any).visibilityByZoom?.max || ZOOM_LEVEL_LIMITS.max } }, // TODO: Clean up label: props.label || DEFAULT_LAYER_LABEL, color: props.color || colorMaker.next().value, columns: props.columns || {}, isVisible: props.isVisible || false, isConfigActive: props.isConfigActive || false, highlightColor: props.highlightColor || DEFAULT_HIGHLIGHT_COLOR, hidden: props.hidden || false, // TODO: refactor this into separate visual Channel config // color by field, domain is set by filters, field, scale type colorField: null, colorDomain: [0, 1], colorScale: SCALE_TYPES.quantile, // color by size, domain is set by filters, field, scale type sizeDomain: [0, 1], sizeScale: SCALE_TYPES.linear, sizeField: null, ...{radiusField: null}, // TODO: Clean up visConfig: {}, textLabel: [DEFAULT_TEXT_LABEL], colorUI: { color: DEFAULT_COLOR_UI, colorRange: DEFAULT_COLOR_UI }, animation: {enabled: false} }; } /** * Get the description of a visualChannel config * @param key * @returns */ getVisualChannelDescription(key: string): VisualChannelDescription { // e.g. label: Color, measure: Vehicle Type const channel = this.visualChannels[key]; if (!channel) return {label: '', measure: ''}; const rangeSettings = this.visConfigSettings[channel.range]; const fieldSettings = this.config[channel.field]; const label = rangeSettings?.label; return { label: typeof label === 'function' ? label(this.config) : label, measure: fieldSettings ? fieldSettings.displayName || fieldSettings.name : channel.defaultMeasure }; } /** * Assign a field to layer column, return column config * @param key - Column Key * @param field - Selected field * @returns {{}} - Column config */ assignColumn(key: string, field: Field): LayerColumns { // field value could be null for optional columns const update = field ? { value: field.name, fieldIdx: field.fieldIdx } : {value: null, fieldIdx: -1}; return { ...this.config.columns, [key]: { ...this.config.columns[key], ...update } }; } /** * Assign a field pair to column config, return column config * @param key - Column Key * @param pair - field Pair * @returns {object} - Column config */ assignColumnPairs(key, pair) { if (!this.columnPairs || !this.columnPairs?.[key]) { // should not end in this state return this.config.columns; } const {pair: partnerKey, fieldPairKey} = this.columnPairs?.[key]; const {fieldPairKey: partnerFieldPairKey} = this.columnPairs?.[partnerKey]; return { ...this.config.columns, [key]: pair[fieldPairKey], [partnerKey]: pair[partnerFieldPairKey] }; } /** * Calculate a radius zoom multiplier to render points, so they are visible in all zoom level * @param {object} mapState * @param {number} mapState.zoom - actual zoom * @param {number | void} mapState.zoomOffset - zoomOffset when render in the plot container for export image * @returns {number} */ getZoomFactor({zoom, zoomOffset = 0}) { return Math.pow(2, Math.max(14 - zoom + zoomOffset, 0)); } /** * Calculate a elevation zoom multiplier to render points, so they are visible in all zoom level * @param {object} mapState * @param {number} mapState.zoom - actual zoom * @param {number | void} mapState.zoomOffset - zoomOffset when render in the plot container for export image * @returns {number} */ getElevationZoomFactor({zoom, zoomOffset = 0}: {zoom: number; zoomOffset?: number}) { return this.config.visConfig.enableElevationZoomFactor ? Math.pow(2, Math.max(8 - zoom + zoomOffset, 0)) : 1; } formatLayerData(datasets: Datasets, oldLayerData?: any) { return {}; } renderLayer(...args: any[]): any[] { return []; } getHoverData(object, dataContainer: DataContainerInterface, fields: Field[]) { if (!object) { return null; } // By default, each entry of layerData should have an index of a row in the original data container. // Each layer can implement its own getHoverData method return dataContainer.row(object.index); } /** * When change layer type, try to copy over layer configs as much as possible * @param configToCopy - config to copy over * @param visConfigSettings - visConfig settings of config to copy */ assignConfigToLayer(configToCopy, visConfigSettings) { // don't deep merge visualChannel field // don't deep merge color range, reversed: is not a key by default const shallowCopy = ['colorRange', 'strokeColorRange'].concat( Object.values(this.visualChannels).map(v => v.field) ); // don't copy over domain and animation const notToCopy = ['animation'].concat(Object.values(this.visualChannels).map(v => v.domain)); // if range is for the same property group copy it, otherwise, not to copy Object.values(this.visualChannels).forEach(v => { if ( configToCopy.visConfig[v.range] && this.visConfigSettings[v.range] && visConfigSettings[v.range].group !== this.visConfigSettings[v.range].group ) { notToCopy.push(v.range); } }); // don't copy over visualChannel range const currentConfig = this.config; const copied = this.copyLayerConfig(currentConfig, configToCopy, { shallowCopy, notToCopy }); this.updateLayerConfig(copied); // validate visualChannel field type and scale types Object.keys(this.visualChannels).forEach(channel => { this.validateVisualChannel(channel); }); } /* * Recursively copy config over to an empty layer * when received saved config, or copy config over from a different layer type * make sure to only copy over value to existing keys * @param {object} currentConfig - existing config to be override * @param {object} configToCopy - new Config to copy over * @param {string[]} shallowCopy - array of properties to not to be deep copied * @param {string[]} notToCopy - array of properties not to copy * @returns {object} - copied config */ copyLayerConfig( currentConfig, configToCopy, {shallowCopy = [], notToCopy = []}: {shallowCopy?: string[]; notToCopy?: string[]} = {} ) { const copied = {}; Object.keys(currentConfig).forEach(key => { if ( isPlainObject(currentConfig[key]) && isPlainObject(configToCopy[key]) && !shallowCopy.includes(key) && !notToCopy.includes(key) ) { // recursively assign object value copied[key] = this.copyLayerConfig(currentConfig[key], configToCopy[key], { shallowCopy, notToCopy }); } else if (notNullorUndefined(configToCopy[key]) && !notToCopy.includes(key)) { // copy copied[key] = configToCopy[key]; } else { // keep existing copied[key] = currentConfig[key]; } }); return copied; } registerVisConfig(layerVisConfigs: { [key: string]: keyof LayerVisConfigSettings | ValueOf; }) { Object.keys(layerVisConfigs).forEach(item => { const configItem = layerVisConfigs[item]; if (typeof configItem === 'string' && LAYER_VIS_CONFIGS[configItem]) { // if assigned one of default LAYER_CONFIGS this.config.visConfig[item] = LAYER_VIS_CONFIGS[configItem].defaultValue; this.visConfigSettings[item] = LAYER_VIS_CONFIGS[configItem]; } else if ( typeof configItem === 'object' && ['type', 'defaultValue'].every(p => configItem.hasOwnProperty(p)) ) { // if provided customized visConfig, and has type && defaultValue // TODO: further check if customized visConfig is valid this.config.visConfig[item] = configItem.defaultValue; this.visConfigSettings[item] = configItem; } }); } getLayerColumns() { const columnValidators = this.columnValidators; const required = this.requiredLayerColumns.reduce( (accu, key) => ({ ...accu, [key]: columnValidators[key] ? {value: null, fieldIdx: -1, validator: columnValidators[key]} : {value: null, fieldIdx: -1} }), {} ); const optional = this.optionalColumns.reduce( (accu, key) => ({ ...accu, [key]: {value: null, fieldIdx: -1, optional: true} }), {} ); return {...required, ...optional}; } updateLayerConfig( newConfig: Partial ): Layer { this.config = {...this.config, ...newConfig}; return this; } updateLayerVisConfig(newVisConfig) { this.config.visConfig = {...this.config.visConfig, ...newVisConfig}; return this; } updateLayerColorUI(prop: string, newConfig: NestedPartial): Layer { const {colorUI: previous, visConfig} = this.config; if (!isPlainObject(newConfig) || typeof prop !== 'string') { return this; } const colorUIProp = Object.entries(newConfig).reduce((accu, [key, value]) => { return { ...accu, [key]: isPlainObject(accu[key]) && isPlainObject(value) ? {...accu[key], ...value} : value }; }, previous[prop] || DEFAULT_COLOR_UI); const colorUI = { ...previous, [prop]: colorUIProp }; this.updateLayerConfig({colorUI}); // if colorUI[prop] is colorRange. If type is hexColor then it's also a range const isColorRange = visConfig[prop] && (visConfig[prop].colors || visConfig[prop].type === hexColor); if (isColorRange) { this.updateColorUIByColorRange(newConfig, prop); this.updateColorRangeByColorUI(newConfig, previous, prop); this.updateCustomPalette(newConfig, previous, prop); } return this; } updateCustomPalette(newConfig, previous, prop) { if (!newConfig.colorRangeConfig || !newConfig.colorRangeConfig.custom) { return; } const {colorUI, visConfig} = this.config; if (!visConfig[prop]) return; const {colors} = visConfig[prop]; const customPalette = { ...colorUI[prop].customPalette, name: 'Custom Palette', colors: [...colors] }; this.updateLayerConfig({ colorUI: { ...colorUI, [prop]: { ...colorUI[prop], customPalette } } }); } /** * if open dropdown and prop is color range * Automatically set colorRangeConfig's step, reversed and type * @param {*} newConfig * @param {*} prop */ updateColorUIByColorRange(newConfig, prop) { if (typeof newConfig.showDropdown !== 'number') return; const {colorUI, visConfig} = this.config; this.updateLayerConfig({ colorUI: { ...colorUI, [prop]: { ...colorUI[prop], colorRangeConfig: { ...colorUI[prop].colorRangeConfig, type: visConfig[prop].type, steps: visConfig[prop].colors?.length, reversed: Boolean(visConfig[prop].reversed), hexColumn: this.getHexColumn(prop), isHexColumn: this.isColoredByColorColumn(prop) } } } }); } // eslint-disable-next-line complexity updateColorRangeByColorUI(newConfig, previous, prop) { // only update colorRange if changes in UI is made to 'reversed', 'steps' or steps const shouldUpdate = newConfig.colorRangeConfig && ['reversed', 'steps'].some( key => newConfig.colorRangeConfig.hasOwnProperty(key) && newConfig.colorRangeConfig[key] !== (previous[prop] || DEFAULT_COLOR_UI).colorRangeConfig[key] ); if (!shouldUpdate) return; const {colorUI, visConfig} = this.config; const {steps, reversed} = colorUI[prop].colorRangeConfig; const colorRange = visConfig[prop]; // find based on step or reversed let update; if (newConfig.colorRangeConfig.hasOwnProperty('steps')) { const group = getColorGroupByName(colorRange); if (group) { const sameGroup = COLOR_RANGES.filter(cr => getColorGroupByName(cr) === group); update = sameGroup.find(cr => cr.colors.length === steps); if (update && colorRange.reversed) { update = reverseColorRange(true, update); } if (update && colorRange.colorMap) { update = this.updateColorMap(prop, colorRange.colorMap, update); } } } if (newConfig.colorRangeConfig.hasOwnProperty('reversed')) { update = reverseColorRange(reversed, update || colorRange); } if (update) { this.updateLayerVisConfig({[prop]: update}); } } updateColorMap(prop, colorMap, colorRange) { // @ts-ignore const {field, domain} = Object.values(this.visualChannels).find(value => value.range === prop); const type = this.config[field]?.type; if (type === 'string' || type === 'boolean' || type === 'date') { return this.updateCategoryColorMap(colorMap, this.config[domain], colorRange); } // If no type or type is not string, boolean or date return this.updateNumericColorMap(colorMap, this.config[domain], colorRange); } updateCategoryColorMap(colorMap, domain, colorRange) { const colors = colorRange.colors; const assignedCategories = []; if (colorRange.type === hexColor) { return { ...colorRange, colorMap: [] }; } const newColorMap = colorMap.slice(0, colors.length).map((cMap, index) => { assignedCategories.push(cMap[0]); return [cMap[0], colors[index]]; }); const unassignedColors = colors.slice(newColorMap.length, colors.length); for (const color of unassignedColors) { const category = domain.find(d => !assignedCategories.includes(d)); if (!category) { break; } assignedCategories.push(category); newColorMap.push([category, color]); } return { ...colorRange, colorMap: newColorMap }; } updateNumericColorMap(colorMap, domain, update) { const colors = update.colors; let newColorMap = colorMap.slice(0, colors.length); newColorMap = newColorMap.map((cMap, index) => { const value = index === newColorMap.length - 1 ? null : cMap[0]; return [value, colors[index]]; }); const unassignedColors = colors.slice(newColorMap.length, colors.length); if (unassignedColors.length > 0) { const colorMapValues = colorMap.map(c => c[0]).filter(c => typeof c === 'number'); const maxValue = Math.max(...colorMapValues, domain[domain.length - 1]); const prevValue = newColorMap[newColorMap.length - 2][0]; const step = (maxValue - prevValue) / (unassignedColors.length + 1); newColorMap[newColorMap.length - 1][0] = prevValue + step; for (const [i, color] of unassignedColors.entries()) { const value = i === unassignedColors.length - 1 ? null : prevValue + step * (i + 2); newColorMap.push([value, color]); } } return { ...update, colorMap: newColorMap }; } /** * Check whether layer has all columns * @returns yes or no */ hasAllColumns(): boolean { const {columns} = this.config; return ( columns && Object.values(columns).every(v => { return Boolean(v.optional || (v.value && v.fieldIdx > -1)); }) ); } /** * Check whether layer has data * * @param {Array | Object} layerData * @returns {boolean} yes or no */ hasLayerData(layerData) { if (!layerData) { return false; } return Boolean(layerData.data && layerData.data.length); } isValidToSave(): boolean { return Boolean(this.type && this.hasAllColumns()); } shouldRenderLayer(data): boolean { return ( Boolean(this.type) && this.hasAllColumns() && this.hasLayerData(data) && typeof this.renderLayer === 'function' ); } getColorScale(colorScale: string, colorDomain: VisualChannelDomain, colorRange: ColorRange) { if (colorRange.type === hexColor) { return (_, color) => { const {r, g, b} = rgb(color); return [r, g, b].some(isNaN) ? UNKNOWN_COLOR : [r, g, b]; }; } else if (Array.isArray(colorRange.colorMap)) { const cMap = new Map(); colorRange.colorMap.forEach(([k, v]) => { cMap.set(k, typeof v === 'string' ? hexToRgb(v) : v); }); const scale = SCALE_FUNC[colorScale]() .domain(cMap.keys()) .range(cMap.values()) .unknown(UNKNOWN_COLOR); return scale; } return this.getVisChannelScale(colorScale, colorDomain, colorRange.colors.map(hexToRgb)); } getCustomMarkersScale(range, fallbackIcon) { let unknownValue = fallbackIcon || FALLBACK_ICON; if (!range) { return () => unknownValue; } const mapping = new Map(); if (range.othersMarker) { unknownValue = range.othersMarker; } for (const {value, markerUrl} of range.markerMap) { if (markerUrl) { mapping.set(value, markerUrl); } } // @ts-ignore d3 scale const scale = scaleOrdinal() .domain(Array.from(mapping.keys())) .range(Array.from(mapping.values())) .unknown(unknownValue); return scale; } /** * Mapping from visual channels to deck.gl accesors * @param {Object} param Parameters * @param {Function} param.dataAccessor Access kepler.gl layer data from deck.gl layer * @param {import('utils/table-utils/data-container-interface').DataContainerInterface} param.dataContainer DataContainer to use use with dataAccessor * @return {Object} attributeAccessors - deck.gl layer attribute accessors */ getAttributeAccessors({ dataAccessor = defaultDataAccessor, dataContainer }: { dataAccessor?: typeof defaultDataAccessor; dataContainer: DataContainerInterface; }) { const attributeAccessors: {[key: string]: any} = {}; // eslint-disable-next-line complexity Object.keys(this.visualChannels).forEach(channel => { const { field, fixed, scale, domain, range, accessor, defaultValue, getAttributeValue, nullValue, channelScaleType, aggregation } = this.visualChannels[channel]; const shouldGetScale = Boolean( (scale || channelScaleType === CHANNEL_SCALES.identity) && this.config[field] ); if (shouldGetScale) { const isFixed = fixed && this.config.visConfig[fixed]; // It's important to declare as const all the parameters that we are going to be use in the getEncodedChannelValue call // otherwise, the oldLayerData parameter in formatLayerData method will be wrong (you can check the formatLayerData TileLayer method in Builder as example) const fieldConfig = this.config[field]; const aggregationValue = this.config.visConfig[aggregation]; const scaleFunction = channelScaleType === CHANNEL_SCALES.identity ? scaleIdentity : channelScaleType === CHANNEL_SCALES.color ? this.getColorScale( this.config[scale], this.config[domain], this.config.visConfig[range] ) : channelScaleType === CHANNEL_SCALES.customMarker ? this.getCustomMarkersScale( this.config.visConfig[range], this.config.visConfig.customMarkersUrl ) : this.getVisChannelScale( this.config[scale], this.config[domain], this.config.visConfig[range], isFixed ); attributeAccessors[accessor] = d => this.getEncodedChannelValue({ channel, dataContainer, scale: scaleFunction, data: dataAccessor(dataContainer)(d), field: fieldConfig, aggregation: aggregationValue, nullValue }); } else if (typeof getAttributeValue === 'function') { attributeAccessors[accessor] = getAttributeValue(this.config); } else { attributeAccessors[accessor] = typeof defaultValue === 'function' ? defaultValue(this.config) : defaultValue; } if (!(accessor in attributeAccessors)) { Console.warn(`Failed to provide accessor function for ${accessor || channel}`); } }); return attributeAccessors; } getVisChannelScale( scale: string, domain: VisualChannelDomain, range: any, fixed?: boolean ): () => any | null { if (scale === SCALE_TYPES.ordinal) { return SCALE_FUNC[scale]() .domain(domain.slice(0, range.length) as any) .range(range) .unknown(UNKNOWN_COLOR) as any; } // for `count` aggregation, we domain is actually `[0, Infinity]` so it doesn't make sense // to use it, so skip specyfying this domains const domainIsInfinite = domain && domain[0] === 0 && domain[1] === Infinity; const scaleFactory = SCALE_FUNC[fixed ? 'linear' : scale]; // @ts-ignore d3-scale type let result = scaleFactory(); if (domain && !domainIsInfinite) { result = result.domain(domain); } return result.range(fixed ? domain : range); } /** * Get longitude and latitude bounds of the data. * @param {import('utils/table-utils/data-container-interface').DataContainerInterface} dataContainer DataContainer to calculate bounds for. * @param {(d: {index: number}, dc: import('utils/table-utils/data-container-interface').DataContainerInterface) => number[]} getPosition Access kepler.gl layer data from deck.gl layer * @return {number[]|null} bounds of the data. */ getPointsBounds(dataContainer, getPosition) { // no need to loop through the entire dataset // get a sample of data to calculate bounds const sampleData = dataContainer.numRows() > MAX_SAMPLE_SIZE ? getSampleData(dataContainer, MAX_SAMPLE_SIZE) : dataContainer; const points = sampleData.mapIndex(getPosition); const latBounds = getLatLngBounds(points, 1, [-90, 90]); const lngBounds = getLatLngBounds(points, 0, [-180, 180]); if (!latBounds || !lngBounds) { return null; } return [lngBounds[0], latBounds[0], lngBounds[1], latBounds[1]]; } getChangedTriggers(dataUpdateTriggers) { const triggerChanged = diffUpdateTriggers(dataUpdateTriggers, this._oldDataUpdateTriggers); this._oldDataUpdateTriggers = dataUpdateTriggers; return triggerChanged; } getEncodedChannelValue({ channel, scale, data, field, dataContainer, // In builder we overwrite the getValue function and we use the _aggregation parameter in spatial index layers as the QuadkeyLayer. aggregation, nullValue = NO_VALUE_COLOR as RGBAColor, getValue = defaultGetFieldValue }: { channel: string; scale: (...value) => any; data: any[]; field: VisualChannelField; dataContainer: any; aggregation: any; nullValue?: RGBAColor; getValue?: typeof defaultGetFieldValue; }) { const {type} = field; const value = getValue(field, data); const channelConfig = this.config.visConfig[this.visualChannels[channel].range]; const channelField = this.config[this.visualChannels[channel].field]; let colorColumnValue: any; if (channelConfig?.type === hexColor) { const columnField = { id: channelField.colorColumn, name: channelField.colorColumn, fieldIdx: channelField.colorColumnIdx, valueAccessor: d => { return dataContainer.valueAt(d.index, channelField.colorColumnIdx); } }; colorColumnValue = getValue(columnField, data); } else if (!notNullorUndefined(value)) { return nullValue; } let attributeValue; if (type === ALL_FIELD_TYPES.timestamp) { // shouldn't need to convert here // scale Function should take care of it attributeValue = scale(new Date(value), colorColumnValue); } else { attributeValue = scale(value, colorColumnValue); } if (!notNullorUndefined(attributeValue)) { attributeValue = nullValue; } return attributeValue; } updateMeta(meta) { this.meta = {...this.meta, ...meta}; } updateLayerLegend(settings) { (this as any).legendSettings = settings; } // @ts-expect-error getDataUpdateTriggers({filteredIndex, id, allData}: KeplerTable): any { const {columns} = this.config; return { getData: {datasetId: id, allData, columns, filteredIndex}, getMeta: {datasetId: id, allData, columns}, ...(this.config.textLabel || []).reduce( (accu, tl, i) => ({ ...accu, [`getLabelCharacterSet-${i}`]: tl.field ? tl.field.name : null }), {} ) }; } updateData(datasets, oldLayerData) { if (!this.config.dataId) { return {}; } const dataset = this.getDataset(datasets); const {dataContainer} = dataset; this.updateVisualChannelFields(dataset); this.updateHexColorDomain(dataset); const getPosition = this.getPositionAccessor(dataContainer); const dataUpdateTriggers = this.getDataUpdateTriggers(dataset); const triggerChanged = this.getChangedTriggers(dataUpdateTriggers); if (triggerChanged && triggerChanged.getMeta) { this.updateLayerMeta(dataContainer, getPosition); } let data = []; if (!(triggerChanged && triggerChanged.getData) && oldLayerData && oldLayerData.data) { // same data data = oldLayerData.data; } else { data = this.calculateDataAttribute(dataset, getPosition); } return {data, triggerChanged}; } /** * Update styled fields to match ones from new dataset. * * @param {Object} dataset */ updateVisualChannelFields(dataset) { for (const {field, range} of Object.values(this.visualChannels)) { const oldField = this.config[field]; const isUsingHexColorCode = this.config.visConfig[range]?.hexColor; if (!oldField) { return; } let newDataSetField = dataset.fields.find(fd => fd.name === oldField.name) || null; if (isUsingHexColorCode) { newDataSetField = { ...newDataSetField, colorColumn: newDataSetField.colorColumn || oldField.colorColumn, colorColumnIdx: newDataSetField.colorColumnIdx || oldField.colorColumnIdx }; } this.updateLayerConfig({[field]: newDataSetField}); } } /** * helper function to update one layer domain when state.data changed * if state.data change is due ot update filter, newFiler will be passed * called by updateAllLayerDomainData * @param datasets * @param newFilter * @returns layer */ updateLayerDomain(datasets: Datasets, newFilter?: Filter): Layer { const table = this.getDataset(datasets); if (!table) { return this; } Object.values(this.visualChannels).forEach(channel => { const {scale} = channel; const scaleType = this.config[scale]; // ordinal domain is based on dataContainer, if only filter changed // no need to update ordinal domain if (!newFilter || scaleType !== SCALE_TYPES.ordinal) { const {domain} = channel; const updatedDomain = this.calculateLayerDomain(table, channel); this.updateLayerConfig({[domain]: updatedDomain}); } }); return this; } getDataset(datasets) { return this.config.dataId ? datasets[this.config.dataId] : null; } /** * Validate visual channel field and scales based on supported field & scale type * @param channel */ validateVisualChannel(channel: string) { this.validateFieldType(channel); this.validateScale(channel); } /** * Validate field type based on channelScaleType */ validateFieldType(channel: string) { const visualChannel = this.visualChannels[channel]; const {field, channelScaleType, supportedFieldTypes} = visualChannel; if (this.config[field]) { // if field is selected, check if field type is supported const channelSupportedFieldTypes = supportedFieldTypes || CHANNEL_SCALE_SUPPORTED_FIELDS[channelScaleType]; if (!channelSupportedFieldTypes.includes(this.config[field].type)) { // field type is not supported, set it back to null // set scale back to default this.updateLayerConfig({[field]: null}); } } } /** * Validate scale type based on aggregation */ validateScale(channel) { const visualChannel = this.visualChannels[channel]; const {scale, range} = visualChannel; if ( !scale || (this.isColoredByColorColumn(range) && this.config[scale] === SCALE_TYPES.identity) ) { // visualChannel doesn't have scale return; } const scaleOptions = this.getScaleOptions(channel); // check if current selected scale is // supported, if not, change to default if (!scaleOptions.includes(this.config[scale])) { this.updateLayerConfig({[scale]: scaleOptions[0]}); } } /** * Get scale options based on current field * @param {string} channel * @returns {string[]} */ getScaleOptions(channel) { const visualChannel = this.visualChannels[channel]; const {field, scale, channelScaleType} = visualChannel; return this.config[field] ? FIELD_OPTS[this.config[field].type].scale[channelScaleType] : [this.getDefaultLayerConfig()[scale]]; } updateLayerVisualChannel(dataset: KeplerTable, channel: string) { const visualChannel = this.visualChannels[channel]; this.validateVisualChannel(channel); // calculate layer channel domain const updatedDomain = this.calculateLayerDomain(dataset, visualChannel); this.updateLayerConfig({[visualChannel.domain]: updatedDomain}); } getVisualChannelUpdateTriggers(): UpdateTriggers { const updateTriggers: UpdateTriggers = {}; Object.values(this.visualChannels).forEach(visualChannel => { // field range scale domain const {accessor, field, scale, domain, range, defaultValue, fixed} = visualChannel; if (accessor) { updateTriggers[accessor] = { [field]: this.config[field], [scale]: this.config[scale], [domain]: this.config[domain], [range]: this.config.visConfig[range], defaultValue: typeof defaultValue === 'function' ? defaultValue(this.config) : defaultValue, ...(fixed ? {[fixed]: this.config.visConfig[fixed]} : {}) }; } }); return updateTriggers; } calculateLayerDomain(dataset, visualChannel) { const {scale} = visualChannel; const field = this.config[visualChannel.field]; if (!field || !scale) { // if colorField or sizeField were set back to null return defaultDomain; } const scaleType = this.config[scale]; const dataSetField = dataset.fields.find(f => f.name === field.name); if (!scaleType || !dataSetField) { // if new dataset doesn't contain previously set field return defaultDomain; } return dataset.getColumnLayerDomain(dataSetField, scaleType) || defaultDomain; } updateHexColorDomain(dataset) { if (!dataset) { return this; } Object.values(this.visualChannels).forEach(channel => { const channelConfig = this.config.visConfig[channel.range]; const field = this.config[channel.field]; if (channelConfig?.type === hexColor && field?.colorColumn) { const dataSetField = dataset.fields.find(f => f.name === field.colorColumn); const colors = dataset .getColumnLayerDomain(dataSetField, SCALE_TYPES.ordinal) .map(c => checkColorOrFallback(c)); this.updateLayerVisConfig({ [channel.range]: {...channelConfig, colors} }); } }); return this; } calculateHexColumnLegend(datasets) { const dataset = this.getDataset(datasets); if (!datasets) { return; } const legendSettings = Object.keys(this.visualChannels).reduce((legends, channel) => { const {range, field: channelField} = this.visualChannels[channel]; const channelConfig = this.config.visConfig[range]; const field = this.config[channelField]; if (channelConfig?.type === hexColor && field?.colorColumn) { const dataSetField = dataset.fields.find(f => f.name === field.colorColumn); const legend = dataset.calculateLegend(field, dataSetField); legends[channel] = legend; } return legends; }, {}); this.updateLayerLegend(legendSettings); } hasHoveredObject(objectInfo) { return this.isLayerHovered(objectInfo) && objectInfo.object ? objectInfo.object : null; } isLayerHovered(objectInfo): boolean { return objectInfo?.picked && objectInfo?.layer?.props?.id === this.id; } getRadiusScaleByZoom(mapState: MapState, fixedRadius?: boolean) { const radiusChannel = Object.values(this.visualChannels).find(vc => vc.property === 'radius'); if (!radiusChannel) { return 1; } const field = radiusChannel.field; const {radius} = this.config.visConfig; // @ts-ignore return (this.config[field] ? 1 : radius) * this.getZoomFactor(mapState); } getRadiusScale() { const radiusChannel = Object.values(this.visualChannels).find(vc => vc.property === 'radius'); if (!radiusChannel || this.config[radiusChannel.field]) { return 1; } return this.config.visConfig.radius; } getMaxMarkerSize() { const {radiusField, sizeField, visConfig} = this.config as any; const {radiusRange, radius} = visConfig; const field = radiusField || sizeField; return Math.ceil(field && radiusRange ? radiusRange[1] : radius); } shouldCalculateLayerData(props: string[]) { return props.some(p => !this.noneLayerDataAffectingProps.includes(p)); } getBrushingExtensionProps(interactionConfig, brushingTarget?) { const {brush} = interactionConfig; return { // brushing autoHighlight: !brush.enabled, brushingRadius: brush.config.size * 1000, brushingTarget: brushingTarget || 'source', brushingEnabled: brush.enabled }; } getDefaultDeckLayerProps({ idx, gpuFilter, mapState, visible, maskPolygon }: { idx: number; gpuFilter: GpuFilter; mapState: MapState; visible: boolean; maskPolygon: any; }) { const maskExtensionProps = getMaskExtensionProps(maskPolygon); return { ...maskExtensionProps, id: this.id, idx, coordinateSystem: COORDINATE_SYSTEM.LNGLAT, pickable: this.hasInteractivity, wrapLongitude: true, parameters: {depthTest: Boolean(mapState.dragRotate || this.config.visConfig.enable3d)}, hidden: this.config.hidden, visibilityByZoom: (this.config as any).visibilityByZoom, // visconfig opacity: this.config.visConfig.opacity, highlightColor: this.config.highlightColor, // data filtering extensions: [dataFilterExtension, ...maskExtensionProps.extensions], filterRange: gpuFilter ? gpuFilter.filterRange : undefined, // layer should be visible and if splitMap, shown in to one of panel visible: this.config.isVisible && visible }; } getDefaultHoverLayerProps() { return { id: `${this.id}-hovered`, pickable: false, wrapLongitude: true, coordinateSystem: COORDINATE_SYSTEM.LNGLAT }; } renderTextLabelLayer( {getPosition, getRadius, radiusScale, updateTriggers, sharedProps}, renderOpts ) { const {data} = renderOpts; const {textLabel, visibilityByZoom} = this.config as any; const extensions = [...sharedProps.extensions, collisionFilterExtension]; if (!data || !data.textLabels || !data.textLabels[0] || !data.textLabels[0].getText) { return []; } const [mainLabel, secondaryLabel] = textLabel; const { alignment, anchor, color: getColor, field: mainField, outlineColor, size: sizeScale } = mainLabel; const { color: getSecondaryColor, field: secondaryField, outlineColor: secondaryOutlineColor, size: secondarySizeScale } = secondaryLabel || {}; return [ new PointLabelLayer({ ...sharedProps, // Recreate layer when labels change otherwise rendering breaks // TODO could be more efficient with updateTriggers? id: `${this.id}-${mainField?.name}-${secondaryField?.name}-label`, data: data.data, visibilityByZoom, extensions, characterSet: 'auto', collisionEnabled: true, collisionGroup: `${this.id}-labels`, getText: data.textLabels[0].getText, getColor, sizeScale, outlineColor, ...(secondaryField && { getSecondaryText: data.textLabels[1].getText, getSecondaryColor, secondarySizeScale, secondaryOutlineColor }), // Position getPosition, getRadius, radiusScale, getTextAnchor: anchor, getAlignmentBaseline: alignment, // Style fontFamily: 'Inter, sans', fontSettings: {sdf: true}, fontWeight: 500, outlineWidth: 4, parameters: { // text will always show on top of all layers depthTest: false }, getFilterValue: data.getFilterValue, updateTriggers: { ...updateTriggers, getText: mainField?.name, getColor: (mainField as any)?.color, getTextAnchor: mainLabel.anchor, getAlignmentBaseline: mainLabel.alignment, getSecondaryText: secondaryField?.name } }) ]; } /** * Create deck.gl `IconLayer` "autoPack" mode accessor for custom markers. * * @param {*} iconUrlAccessor deck.gl accessor (or value) that resolves to URL of icon for given row * @returns deck.gl accessor that returns UnpackedIcon ready to consume by IconLayer */ getCustomMarkerIconAccessor(iconUrlAccessor, mask = true) { const maxIconSize = this.getMaxMarkerSize(); return d => { const wantedUrl = typeof iconUrlAccessor === 'function' ? iconUrlAccessor(d) : iconUrlAccessor; const iconOk = Boolean(wantedUrl); mask = iconOk ? mask : true; const url = iconOk ? wantedUrl : FALLBACK_ICON; return { id: `${url}@@${maxIconSize}@${mask ? 'm' : 'c'}`, url, width: maxIconSize, height: maxIconSize, mask }; }; } calculateDataAttribute(keplerTable: KeplerTable, getPosition): any { // implemented in subclasses return []; } updateLayerMeta(dataContainer: DataContainerInterface, getPosition) { // implemented in subclasses } getPositionAccessor(dataContainer?: DataContainerInterface): (...args: any[]) => any { // implemented in subclasses return () => null; } getHexColumn(range) { const field = Object.values(this.visualChannels)?.find(value => value.range === range)?.field; if (field) { return this.config[field]?.colorColumn; } } isColoredByColorColumn(range) { return this.config.visConfig[range]?.hexColor; } } export default Layer;