UNPKG

kepler.gl.geoiq

Version:

kepler.gl is a webgl based application to visualize large scale location data in the browser

896 lines (814 loc) 25.6 kB
// Copyright (c) 2023 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 {hexToRgb} from 'utils/color-utils'; import PointLayerIcon from './point-layer-icon'; 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, SCALE_TYPES, ALL_FIELD_TYPES, NO_VALUE_COLOR } from 'constants/default-settings'; import { maybeToDate, getSortingFunction, notNullorUndefined, unique } from 'utils/data-utils'; import {ON_PREMESIS_URL} from 'constants/default-settings'; 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} = 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 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 PointLayer extends Layer { constructor(props) { super(props); this.registerVisConfig(geojsonVisConfigs); this.getHeightPosition = memoize( pointHeightPosAccessor, pointHeightPosResolver ); this.getColorRange = memoize(getLayerColorRange); } get type() { return 'backendPoint'; } get name() { return 'Point'; } get showLoader() { return true; } get layerIcon() { return PointLayerIcon; } get requiredLayerColumns() { return boundaryRequiredColumns; } get columnPairs() { return this.defaultPointColumnPairs; } get noneLayerDataAffectingProps() { return [...super.noneLayerDataAffectingProps, 'radius']; } get visualChannels() { return { ...super.visualChannels, 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' }, radius: { property: 'radius', field: 'radiusField', scale: 'radiusScale', domain: 'radiusDomain', range: 'radiusRange', key: 'radius', channelScaleType: 'radius' } }; } // /** // * 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 // }; // } shouldRenderColumnConfig() { return false; } getHoverData(object, auth, datasets, fieldsToShow) { // index of allData is saved to feature.properties return object; } getDefaultLayerConfig(props = {}) { return { ...super.getDefaultLayerConfig(props), apiCallRequest: true, // add height visual channel heightField: null, heightDomain: [0, 1], heightScale: 'linear' }; } updateLayerConfig(newConfig) { if ( Object.keys(newConfig).includes('colorField') || Object.keys(newConfig).includes('colorUI') ) { newConfig = {...newConfig, ...{legendApiCallRequest: true}}; } this.config = {...this.config, ...newConfig}; return this; } /** * 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.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; // console.log( // 'DEFAULT_AGGREGATION inside getAggregationOptions', // DEFAULT_AGGREGATION, // channelScaleType // ); // console.log('condition inside getAggregationOptions', this.config[field]); // console.log('default aggregation', DEFAULT_AGGREGATION[channelScaleType]); // 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, viewport, zoom, filters, auth, project) { const {dataId, colorField, colorAggregation} = this.config; const {indexName} = datasets[dataId]; const {isEdit} = project; const {uid} = auth; filters = filters.filter(f => f.dataId.includes(dataId)); let config = { headers: { 'Content-Type': 'application/json' // Authorization: 'Bearer ' + localStorage.getItem('accessToken') } }; let data = { colorField: colorField ? colorField.name : null, colorAggregation: colorField ? colorAggregation : 'count', filters: JSON.stringify(filters), indexName: indexName, userId: uid, viewport: JSON.stringify(viewport), zoom: zoom, permissionType: isEdit }; const response = axios .post( ON_PREMESIS_URL + '/geoiqlayers/pointlayer/v1.0/fetch', data, config ) .then(function(response) { return response.data.data; }) .catch(); return response; } axiosLegendAPICall(datasets, filters, auth, project) { const {dataId, colorField, colorScale, visConfig} = this.config; const {colorRange} = visConfig; const {indexName} = datasets[dataId]; const {uid} = auth; const {isEdit} = project; const noOfBreaks = colorRange.colors.length; filters = filters.filter(f => f.dataId.includes(dataId)); // const colorScale = 'quantile'; const legendAPIData = { colorField: colorField.name, userId: uid, colorFieldType: colorField.type, colorScale, permissionType: isEdit, noOfBreaks, indexName, filters }; const legendConfig = { headers: { 'Content-type': 'application/json' } }; const response = axios .post( `${ON_PREMESIS_URL}/geoiqutilities/legend/v1.0/fetch`, legendAPIData, legendConfig ) .then(result => { let breakPoints = result.data.data.Breakpoints; breakPoints = breakPoints.reduce((accu, breakpoint, index) => { if (accu.length < noOfBreaks) { accu.push({x1: breakpoint, x2: breakPoints[index + 1]}); } return accu; }, []); return breakPoints; // this.updateLayerConfig({ // legend: breakPoints, // apiCallRequest: true, // legendApiCallRequest: false // }); }); return response; // this.updateLayerConfig({ // apiCallRequest: true, // legendApiCallRequest: false // }); } /** * 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[colorField.name]); 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; } getPointEncodedChannelValue( legend, colorRange, colorField, data, defaultValue = NO_VALUE_COLOR ) { const {type} = colorField; const value = data; let attributeValue; if (type === ALL_FIELD_TYPES.string) { attributeValue = defaultValue; } else { attributeValue = colorRange[ legend.reduce((acc, breakpoint, index) => { if (value > breakpoint.x1 && value < breakpoint.x2) { acc = index; } return acc; }, null) ]; } 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, colorAggregation, colorField) { var colorDomain = []; var heightDomain = []; if (collected && colorField) { collected.map(c => { colorDomain.push(c.properties.latitude); }); } return {collected, colorDomain, heightDomain}; } // TODO: fix complexity /* eslint-disable complexity */ formatLayerData(_, allData, filteredIndex, oldLayerData, response, opt) { const { colorScale, colorField, color, sizeScale, sizeDomain, sizeField, heightField, heightScale, radiusField, radiusDomain, radiusScale, visConfig, strokeColorField, strokeColorScale, strokeColorDomain, legend } = this.config; var {heightDomain, colorDomain} = this.config; const { enable3d, stroked, colorRange, heightRange, sizeRange, strokeColor, radiusRange, colorAggregation } = visConfig; if (!oldLayerData) { this.updateLayerMeta(); } if (oldLayerData && oldLayerData.collected) { var collected = oldLayerData.collected; } // var pointFC = featureCollection(data); // var heightPointFC = featureCollection(heightData); colorDomain = []; if (response.points) { collected = response.points; } var {collected, colorDomain} = this.calculateBoundaryAggregatedData( collected, colorAggregation, colorField ); 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 = colorField && this.getVisChannelScale( colorScale, colorDomain, colorRange.colors.map(hexToRgb) ); // stroke color const scScale = strokeColorField && this.getVisChannelScale( strokeColorScale, strokeColorDomain, strokeColorRange.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 ); // point radius const rScale = radiusField && this.getVisChannelScale(radiusScale, radiusDomain, radiusRange); return { collected, getFillColor: d => { return cScale && legend ? this.getPointEncodedChannelValue( legend, colorRange.colors.map(hexToRgb), colorField, d.properties[colorField.name] ) : d.properties.fillColor || color; }, getLineColor: d => scScale ? this.getEncodedChannelValue( scScale, d.properties.aggregatedData, strokeColorField ) : d.properties.lineColor || strokeColor, 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 ), getRadius: d => rScale ? this.getEncodedChannelValue( rScale, allData[d.properties.index], radiusField, 0 ) : d.properties.radius || 1 }; } /* eslint-enable complexity */ updateLayerMeta() { // 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 = {point: true}; this.updateMeta({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; 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, legend: this.config.legend }, 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 }, getRadius: { radiusField: this.config.radiusField, radiusRange: visConfig.radiusRange } }; // 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, getRadius: data.getRadius, 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 }), // 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 }) ] : []) ]; } }