kepler.gl.geoiq
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
945 lines (863 loc) • 27.7 kB
JavaScript
// Copyright (c) 2019 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 Layer from '../base-layer';
import {GeoJsonLayer} from 'deck.gl';
import memoize from 'lodash.memoize';
import {TextLayer} from 'deck.gl';
import HighlightPolygonLayer from 'deckgl-layers/geojson-layer/solid-polygon-layer';
import {aggregate} from 'utils/aggregate-utils';
import ScatterplotBrushingLayer from 'deckgl-layers/scatterplot-brushing-layer/scatterplot-brushing-layer';
import uniq from 'lodash.uniq';
import {hexToRgb} from 'utils/color-utils';
import BoundaryLayerIcon from './boundary-layer-icon';
import {DEFAULT_LAYER_COLOR} from 'constants/default-settings';
import axios from 'axios';
import collect from '@turf/collect';
import {featureCollection, point, polygon, feature} from '@turf/helpers';
import {extent} from 'd3-array';
import {
CHANNEL_SCALES,
FIELD_OPTS,
DEFAULT_AGGREGATION,
SCALE_TYPES,
ALL_FIELD_TYPES,
NO_VALUE_COLOR
} from 'constants/default-settings';
import {
maybeToDate,
getSortingFunction,
notNullorUndefined,
unique
} from 'utils/data-utils';
function onlyUnique(value, index, self) {
return self.indexOf(value) === index;
}
function filterNull(arr) {
return arr > 0 || isNaN(arr) === true;
}
function saveText(text, filename) {
var a = document.createElement('a');
a.setAttribute(
'href',
'data:text/plain;charset=utf-u,' + encodeURIComponent(text)
);
a.setAttribute('download', filename);
a.click();
}
export const pointPosAccessor = ({columns, colorField}) => d => {
const {lat, lng, altitude} = columns;
var data;
if (colorField) {
data = d.data[colorField.tableFieldIndex - 1];
} else {
data = 1;
}
if (
d.data[lng.fieldIdx] &&
d.data[lat.fieldIdx]
// &&
// !is.NaN(d.data[lat.fieldIdx]) &&
// !is.NaN(d.data[lat.fieldIdx])
) {
return [
point([d.data[lng.fieldIdx], d.data[lat.fieldIdx]], {
aggregationData: data
})
];
}
};
export const pointPosResolver = ({columns, colorField}) =>
`${columns.lat.fieldIdx}-${columns.lng.fieldIdx}-${
columns.altitude ? columns.altitude.fieldIdx : 'z'
}-${colorField ? colorField.tableFieldIndex : null}`;
export const pointHeightPosAccessor = ({columns, heightField}) => d => {
const {lat, lng, altitude} = columns;
var data;
if (heightField) {
data = d.data[heightField.tableFieldIndex - 1];
} else {
data = 1;
}
if (
d.data[lng.fieldIdx] &&
d.data[lat.fieldIdx]
// &&
// !is.NaN(d.data[lat.fieldIdx]) &&
// !is.NaN(d.data[lat.fieldIdx])
) {
return [
point([d.data[lng.fieldIdx], d.data[lat.fieldIdx]], {
heightAggregationData: data
})
];
}
};
export const pointHeightPosResolver = ({columns, heightField}) =>
`${columns.lat.fieldIdx}-${columns.lng.fieldIdx}-${
columns.altitude ? columns.altitude.fieldIdx : 'z'
}-${heightField ? heightField.tableFieldIndex : null}`;
const getLayerColorRange = colorRange => colorRange.colors.map(hexToRgb);
export const pointLabelAccessor = textLabel => d =>
String(d.data[textLabel.field.tableFieldIndex - 1]);
export const pointLabelResolver = textLabel =>
textLabel.field && textLabel.field.tableFieldIndex;
export const boundaryRequiredColumns = ['lat', 'lng'];
// export const pointOptionalColumns = ['altitude'];
export const pointVisConfigs = {
radius: 'radius',
fixedRadius: 'fixedRadius',
opacity: 'opacity',
outline: 'outline',
thickness: 'thickness',
colorRange: 'colorRange',
radiusRange: 'radiusRange',
'hi-precision': 'hi-precision'
};
export const geojsonVisConfigs = {
opacity: 'opacity',
thickness: {
type: 'number',
defaultValue: 0.5,
label: 'Stroke Width',
isRanged: false,
range: [0, 100],
step: 0.1,
group: 'stroke',
property: 'thickness'
},
colorRange: 'colorRange',
radius: 'radius',
colorAggregation: 'aggregation',
heightAggregation: 'heightAggregation',
sizeRange: 'strokeWidthRange',
radiusRange: 'radiusRange',
heightRange: 'elevationRange',
elevationScale: 'elevationScale',
'hi-precision': 'hi-precision',
stroked: 'stroked',
filled: 'filled',
enable3d: 'enable3d',
wireframe: 'wireframe'
};
export default class BoundaryLayer extends Layer {
constructor(props) {
super(props);
this.registerVisConfig(geojsonVisConfigs);
this.getPosition = memoize(pointPosAccessor, pointPosResolver);
this.getHeightPosition = memoize(
pointHeightPosAccessor,
pointHeightPosResolver
);
this.getText = memoize(pointLabelAccessor, pointLabelResolver);
// this.getColorValue = memoize(getValueAggr, aggrResolver);
this.getColorRange = memoize(getLayerColorRange);
}
get type() {
return 'boundary';
}
get isAggregated() {
return true;
}
get layerIcon() {
return BoundaryLayerIcon;
}
get requiredLayerColumns() {
return boundaryRequiredColumns;
}
get columnPairs() {
return this.defaultPointColumnPairs;
}
get noneLayerDataAffectingProps() {
return [...super.noneLayerDataAffectingProps, 'radius'];
}
get visualChannels() {
return {
...super.visualChannels,
color: {
...super.visualChannels.color,
aggregation: 'colorAggregation',
channelScaleType: CHANNEL_SCALES.colorAggr,
defaultMeasure: 'Point Count',
domain: 'colorDomain',
field: 'colorField',
key: 'color',
property: 'color',
range: 'colorRange',
scale: 'colorScale'
},
size: {
...super.visualChannels.size,
property: 'stroke',
aggregation: 'sizeAggregation',
channelScaleType: CHANNEL_SCALES.sizeAggr,
condition: config => config.visConfig.enable3d,
defaultMeasure: 'Point Count',
domain: 'sizeDomain',
field: 'sizeField',
key: 'size',
property: 'stroke',
range: 'sizeRange',
scale: 'sizeScale'
},
height: {
aggregation: 'heightAggregation',
defaultMeasure: 'Point Count',
property: 'height',
field: 'heightField',
scale: 'heightScale',
domain: 'heightDomain',
range: 'heightRange',
key: 'height',
channelScaleType: 'sizeAggr',
condition: config => config.visConfig.enable3d
}
};
}
/**
* Get the description of a visualChannel config
* @param key
* @returns {{label: string, measure: (string|string)}}
*/
getVisualChannelDescription(key) {
// e.g. label: Color, measure: Average of ETA
const {range, field, defaultMeasure, aggregation} = this.visualChannels[
key
];
return {
label: this.visConfigSettings[range].label,
measure: this.config[field]
? `${this.config.visConfig[aggregation]} of ${this.config[field].name}`
: defaultMeasure
};
}
getHoverData(object, allData) {
// index of allData is saved to feature.properties
return object;
}
getDefaultLayerConfig(props = {}) {
return {
...super.getDefaultLayerConfig(props),
// add height visual channel
heightField: null,
heightDomain: [0, 1],
heightScale: 'linear'
};
}
/**
* Aggregation layer handles visual channel aggregation inside deck.gl layer
*/
updateLayerVisualChannel({data, allData}, channel) {
this.validateVisualChannel(channel);
}
// /**
// * Validate aggregation type on top of basic layer visual channel validation
// * @param channel
// */
validateVisualChannel(channel) {
// field type decides aggregation type decides scale type
this.validateFieldType(channel);
this.validateAggregationType(channel);
this.validateScale(channel);
}
/**
* Validate aggregation type based on selected field
*/
validateAggregationType(channel) {
const visualChannel = this.visualChannels[channel];
const {field, aggregation} = visualChannel;
const aggregationOptions = this.getAggregationOptions(channel);
if (!aggregation) {
return;
}
if (!aggregationOptions.length) {
// if field cannot be aggregated, set field to null
this.updateLayerConfig({[field]: null});
} else if (
!aggregationOptions.includes(this.config.visConfig[aggregation])
) {
// current aggregation type is not supported by this field
// set aggregation to the first supported option
this.updateLayerVisConfig({[aggregation]: aggregationOptions[0]});
}
}
getAggregationOptions(channel) {
const visualChannel = this.visualChannels[channel];
const {field, channelScaleType} = visualChannel;
return Object.keys(
this.config[field]
? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
: DEFAULT_AGGREGATION[channelScaleType]
);
}
/**
* Get scale options based on current field and aggregation type
* @param {string} channel
* @returns {string[]}
*/
getScaleOptions(channel) {
const visualChannel = this.visualChannels[channel];
const {field, aggregation, channelScaleType, scale} = visualChannel;
const aggregationType = this.config.visConfig[aggregation];
if (channel === 'height') {
return this.getDefaultLayerConfig()[scale];
}
return this.config[field]
? // scale options based on aggregation
FIELD_OPTS[this.config[field].type].scale[channelScaleType][
aggregationType
]
: // default scale options for point count
DEFAULT_AGGREGATION[channelScaleType][aggregationType];
}
axiosApiCall(datasets) {
const {dataId, columns} = this.config;
var {allData} = datasets[dataId];
const latIdx = columns.lat.fieldIdx;
const longIdx = columns.lng.fieldIdx;
allData = allData.map(d => {
if (!d[latIdx] || !d[longIdx]) {
return null;
}
const lat = d[latIdx].toFixed(4);
const lng = d[longIdx].toFixed(4);
return lat + '_' + lng;
});
var uniqueAllData = allData.filter(onlyUnique);
var uniqueAllData = allData.filter(filterNull);
var uniqueAllData = uniqueAllData.map(d => {
d = d.split('_');
return d.map(d => Number(d));
});
const {boundaryAggregation} = this.config;
let config = {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + localStorage.getItem('accessToken')
}
};
let data = {
type: boundaryAggregation,
coordinates: uniqueAllData
};
const url = 'https://app.geoiq.io/boundary/v1.0/boundary_query';
const response = axios
.post(url, data, config)
.then(function(response) {
return response.data.result;
})
.catch();
return response;
}
/**
* 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 {Object} dataset
* @param {Object} newFilter
* @returns {object} layer
* @returns {object} widget
*/
updateLayerDomain(dataset, layerData, newFilter) {
Object.values(this.visualChannels).forEach(channel => {
const {scale} = channel;
const scaleType = this.config[scale];
// ordinal domain is based on allData, if only filter changed
// no need to update ordinal domain
if (!newFilter || scaleType !== SCALE_TYPES.ordinal) {
const {domain} = channel;
const updatedDomain = this.calculateLayerDomain(
dataset,
layerData,
channel
);
this.updateLayerConfig({[domain]: updatedDomain});
}
});
return this;
}
calculateLayerDomain(dataset, layerData, visualChannel) {
const {allData, filteredIndexForDomain} = dataset;
const {colorField} = this.config;
// const defaultDomain = [0, 1];
var defaultDomain;
var values;
if (layerData && colorField && layerData.collected) {
values = layerData.collected.map(c => c.properties.aggregatedData);
return unique(values)
.filter(notNullorUndefined)
.sort();
} else if (layerData && layerData.collected) {
values = layerData.collected.map(c => c.properties.count);
return unique(values)
.filter(notNullorUndefined)
.sort();
} else {
defaultDomain = [0, 1];
}
const {scale} = visualChannel;
const scaleType = this.config[scale];
const field = this.config[visualChannel.field];
if (!field) {
// if colorField or sizeField were set back to null
return defaultDomain;
}
if (!SCALE_TYPES[scaleType]) {
Console.error(`scale type ${scaleType} not supported`);
return defaultDomain;
}
// TODO: refactor to add valueAccessor to field
const fieldIdx = field.tableFieldIndex - 1;
const isTime = field.type === ALL_FIELD_TYPES.timestamp;
const valueAccessor = maybeToDate.bind(
null,
isTime,
fieldIdx,
field.format
);
const indexValueAccessor = i => valueAccessor(allData[i]);
const sortFunction = getSortingFunction(field.type);
if (!values) {
return defaultDomain;
}
switch (scaleType) {
case SCALE_TYPES.ordinal:
case SCALE_TYPES.point:
// do not recalculate ordinal domain based on filtered data
// don't need to update ordinal domain every time
return defaultDomain;
case SCALE_TYPES.quantile:
return values.filter(notNullorUndefined).sort(sortFunction);
case SCALE_TYPES.quantize:
case SCALE_TYPES.linear:
case SCALE_TYPES.sqrt:
default:
return extent(values).map((d, i) => (d === undefined ? i : d));
}
}
getEncodedChannelValue(scale, data, field, defaultValue = NO_VALUE_COLOR) {
const {type} = field;
const value = data;
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));
} else {
attributeValue = scale(value);
}
if (!attributeValue) {
attributeValue = defaultValue;
}
return attributeValue;
}
calculateHeightDomain(value, minMax) {
if (!minMax.length) {
return [value, value];
}
let min = aggregate([value, minMax[0]], 'minimum');
let max = aggregate([value, minMax[1]], 'maximum');
return [min, max];
}
calculateBoundaryAggregatedData(
collected,
pointFC,
heightPointFC,
colorAggregation,
heightAggregation,
colorField,
heightField
) {
var colorDomain = [];
var heightDomain = [];
if (collected && colorAggregation) {
let minMax = [];
//converting collected data into feature collection
collected = featureCollection(collected);
var heightCollected = collect(
collected,
pointFC,
'aggregationData',
'values'
).features;
collected = collect(collected, pointFC, 'aggregationData', 'values')
.features;
collected.map((c, i) => {
var aggregatedData = 0;
var values = c.properties.values;
if (values.length) {
aggregatedData = aggregate(values, colorAggregation);
} else {
aggregatedData = 0;
}
if (!heightField) {
minMax = this.calculateHeightDomain(values.length, minMax);
}
c.properties.count = values.length;
c.properties.aggregatedData = aggregatedData;
colorDomain[i] = colorField ? aggregatedData : values.length;
heightDomain = [minMax[0], minMax[1]];
});
}
var heightCollected;
if (collected && heightField && heightAggregation) {
let minMax = [];
heightCollected = featureCollection(collected);
heightCollected = collect(
heightCollected,
heightPointFC,
'heightAggregationData',
'values'
).features;
heightCollected.map((c, i) => {
var heightAggregatedData = 0;
var values = c.properties.values;
if (values.length) {
heightAggregatedData = aggregate(values, heightAggregation);
} else {
heightAggregatedData = 0;
}
minMax = heightField
? this.calculateHeightDomain(heightAggregatedData, minMax)
: this.calculateHeightDomain(values.length, minMax);
collected[i].properties.heightAggregatedData = heightAggregatedData;
heightDomain = [minMax[0], minMax[1]];
});
}
return {collected, colorDomain, heightDomain};
}
// TODO: fix complexity
/* eslint-disable complexity */
formatLayerData(_, allData, filteredIndex, oldLayerData, response, opt) {
const {
colorScale,
colorField,
color,
sizeScale,
sizeDomain,
sizeField,
textLabel,
heightField,
heightScale,
radiusField,
radiusDomain,
radiusScale,
visConfig,
columns
} = this.config;
var {heightDomain, colorDomain} = this.config;
// const {
// colorScale,
// colorDomain,
// colorField,
// color,
// columns,
// sizeField,
// sizeScale,
// sizeDomain,
// textLabel,
// visConfig: {radiusRange, fixedRadius, colorRange}
// } = this.config;
const {
enable3d,
stroked,
colorRange,
heightRange,
sizeRange,
radiusRange,
colorAggregation,
heightAggregation
} = visConfig;
// this.getPosition.cache.delete();
const getPosition = this.getPosition({columns, colorField});
const getHeightPosition = this.getHeightPosition({columns, heightField});
// if (!oldLayerData || oldLayerData.getHeightPosition !== getHeightPosition) {
// this.updateLayerMeta(allData, getHeightPositon);
// }
if (!oldLayerData || oldLayerData.getPosition !== getPosition) {
this.updateLayerMeta(allData, getPosition);
}
if (oldLayerData && oldLayerData.collected) {
var collected = oldLayerData.collected;
}
let data, heightData;
if (
oldLayerData &&
oldLayerData.data &&
opt.sameData &&
oldLayerData.getPosition === getPosition &&
oldLayerData.getHeightPosition === getHeightPosition
) {
data = oldLayerData.data;
heightData = oldLayerData.heightData;
} else {
data = filteredIndex.reduce((accu, index) => {
const pos = getPosition({data: allData[index]});
// if doesn't have point lat or lng, do not add the point
// deck.gl can't handle position = null
// if (!pos.every(Number.isFinite)) {
// return accu;
// }
if (pos) {
accu.push(...pos);
}
return accu;
}, []);
heightData = filteredIndex.reduce((accu, index) => {
const pos = getHeightPosition({data: allData[index]});
// if doesn't have point lat or lng, do not add the point
// deck.gl can't handle position = null
// if (!pos.every(Number.isFinite)) {
// return accu;
// }
if (pos) {
accu.push(...pos);
}
return accu;
}, []);
}
var pointFC = featureCollection(data);
var heightPointFC = heightField ? featureCollection(heightData) : [];
colorDomain = [];
if (response) {
collected = collect(response, pointFC, 'aggregationData', 'values')
.features;
collected.map(c => {
var values = c.properties.values;
c.properties.count = values.length;
colorDomain.push(values.length);
});
}
var {
collected,
colorDomain,
heightDomain
} = this.calculateBoundaryAggregatedData(
collected,
pointFC,
heightPointFC,
colorAggregation,
heightAggregation,
colorField,
heightField
);
Object.values(this.visualChannels).forEach(channel => {
const {scale, domain} = channel;
// ordinal domain is based on allData, if only filter changed
// no need to update ordinal domain
if (collected) {
const updatedDomain = this.calculateLayerDomain(
{},
{collected: collected},
channel
);
this.updateLayerConfig({[domain]: updatedDomain});
}
});
//used for saving geoJson
// if (collected) {
// saveText(JSON.stringify(featureCollection(collected)), 'filename.json');
// }
// }
const cScale = this.getVisChannelScale(
colorScale,
colorDomain,
colorRange.colors.map(hexToRgb)
);
// calculate stroke scale - if stroked = true
const sScale =
sizeField &&
stroked &&
this.getVisChannelScale(sizeScale, sizeDomain, sizeRange);
// calculate elevation scale - if extruded = true
const eScale = this.getVisChannelScale(
heightScale,
heightDomain,
heightRange
);
return {
collected,
data,
heightData,
getPosition,
getHeightPosition,
getFillColor: d => {
return colorField
? this.getEncodedChannelValue(
cScale,
d.properties.aggregatedData,
colorField
)
: this.getEncodedChannelValue(cScale, d.properties.count, {
type: 'real'
});
},
getLineColor: d =>
colorField
? this.getEncodedChannelValue(
cScale,
d.properties.aggregatedData,
colorField
)
: this.getEncodedChannelValue(cScale, d.properties.count, {
type: 'real'
}),
getLineWidth: d =>
sScale
? this.getEncodedChannelValue(
sScale,
allData[d.properties.index],
sizeField,
0
)
: d.properties.lineWidth || 1,
getElevation: d =>
heightField
? this.getEncodedChannelValue(
eScale,
d.properties.heightAggregatedData,
heightField,
0
)
: this.getEncodedChannelValue(
eScale,
d.properties.count,
{
type: 'real'
},
0
)
};
}
/* eslint-enable complexity */
updateLayerMeta(allData, getPosition) {
const bounds = this.getPointsBounds(allData, d => getPosition({data: d}));
// get bounds from features
// const bounds = getGeojsonBounds(allFeatures);
// get lightSettings from points
const lightSettings = this.getLightSettingsFromBounds(bounds);
// if any of the feature has properties.hi-precision set to be true
const fp64 = false;
const fixedRadius = false;
// keep a record of what type of geometry the collection has
const featureTypes = {polygon: true};
this.updateMeta({bounds, lightSettings, fp64, fixedRadius, featureTypes});
}
renderLayer({data, idx, objectHovered, mapState, interactionConfig}) {
const {fp64, lightSettings, fixedRadius} = this.meta;
const radiusScale = this.getRadiusScaleByZoom(mapState, fixedRadius);
const zoomFactor = this.getZoomFactor(mapState);
const {visConfig} = this.config;
// console.log(this.config.apiCallRequest);
const layerProps = {
// multiplier applied just so it being consistent with previously saved maps
lineWidthScale: visConfig.thickness * zoomFactor * 8,
lineWidthMinPixels: 1,
elevationScale: visConfig.elevationScale,
pointRadiusScale: radiusScale,
fp64: fp64 || visConfig['hi-precision'],
lineMiterLimit: 4
};
const updateTriggers = {
getElevation: {
heightField: this.config.heightField,
heightScale: this.config.heightScale,
aggregationType: visConfig.heightAggregation,
heightDomain: this.config.heightDomain,
heightRange: visConfig.heightRange
},
getFillColor: {
color: this.config.color,
colorField: this.config.colorField,
colorRange: visConfig.colorRange,
colorScale: this.config.colorScale,
aggregationType: visConfig.colorAggregation,
colorDomain: this.config.colorDomain,
apiCall: this.config.apiCallRequest,
boundary: this.config.boundaryAggregation
},
getLineColor: {
color: this.config.color,
colorField: this.config.colorField,
colorRange: visConfig.colorRange,
colorScale: this.config.colorScale,
aggregationType: visConfig.colorAggregation,
colorDomain: this.config.colorDomain,
apiCallComplete: this.config.apiCallComplete,
apiCall: this.config.apiCallRequest,
boundary: this.config.boundaryAggregation
},
getLineWidth: {
sizeField: this.config.sizeField,
sizeRange: visConfig.sizeRange
}
};
// this.config.apiCallComplete = false;
// this.config.apiCallRequest = false;
return [
new GeoJsonLayer({
...layerProps,
id: this.id,
idx,
data: data.collected,
getFillColor: data.getFillColor,
getLineColor: data.getLineColor,
getLineWidth: data.getLineWidth,
getElevation: data.getElevation,
// highlight
pickable: true,
// highlightColor: this.config.highlightColor,
autoHighlight: visConfig.enable3d,
// parameters
parameters: {
depthTest: Boolean(visConfig.enable3d || mapState.dragRotate)
},
opacity: visConfig.opacity,
stroked: visConfig.stroked,
filled: visConfig.filled,
extruded: visConfig.enable3d,
wireframe: visConfig.wireframe,
lightSettings,
updateTriggers,
subLayers: {
...GeoJsonLayer.defaultProps.subLayers,
PolygonLayer: HighlightPolygonLayer
}
}),
// text label layer
...(this.isLayerHovered(objectHovered) && !visConfig.enable3d
? [
new GeoJsonLayer({
...layerProps,
id: `${this.id}-hovered`,
data: [objectHovered.object],
getLineWidth: data.getLineWidth,
getElevation: data.getElevation,
getLineColor: this.config.highlightColor,
getFillColor: this.config.highlightColor,
updateTriggers,
stroked: true,
pickable: false,
filled: false
})
]
: [])
];
}
}