UNPKG

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
// 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 }) ] : []) ]; } }