UNPKG

kepler.gl

Version:

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

337 lines (286 loc) 9.53 kB
// Copyright (c) 2020 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 window from 'global/window'; import {BrushingExtension} from '@deck.gl/extensions'; import {hexToRgb} from 'utils/color-utils'; import SvgIconLayer from 'deckgl-layers/svg-icon-layer/svg-icon-layer'; import IconLayerIcon from './icon-layer-icon'; import {ICON_FIELDS, CLOUDFRONT} from 'constants/default-settings'; import IconInfoModalFactory from './icon-info-modal'; import Layer from '../base-layer'; import {getTextOffsetByRadius, formatTextLabelData} from '../layer-text-label'; const brushingExtension = new BrushingExtension(); export const SVG_ICON_URL = `${CLOUDFRONT}/icons/svg-icons.json`; export const iconPosAccessor = ({lat, lng}) => d => [d.data[lng.fieldIdx], d.data[lat.fieldIdx]]; export const iconAccessor = ({icon}) => d => d.data[icon.fieldIdx]; export const iconRequiredColumns = ['lat', 'lng', 'icon']; export const pointVisConfigs = { radius: 'radius', fixedRadius: 'fixedRadius', opacity: 'opacity', colorRange: 'colorRange', radiusRange: 'radiusRange' }; function flatterIconPositions(icon) { // had to flip y, since @luma modal has changed return icon.mesh.cells.reduce((prev, cell) => { cell.forEach(p => { prev.push( ...[icon.mesh.positions[p][0], -icon.mesh.positions[p][1], icon.mesh.positions[p][2]] ); }); return prev; }, []); } export default class IconLayer extends Layer { constructor(props = {}) { super(props); this.registerVisConfig(pointVisConfigs); this.getPositionAccessor = () => iconPosAccessor(this.config.columns); this.getIconAccessor = () => iconAccessor(this.config.columns); // prepare layer info modal this._layerInfoModal = IconInfoModalFactory(); this.iconGeometry = props.iconGeometry || null; this.getSvgIcons(); } get type() { return 'icon'; } get requiredLayerColumns() { return iconRequiredColumns; } get columnPairs() { return this.defaultPointColumnPairs; } get layerIcon() { return IconLayerIcon; } get visualChannels() { return { ...super.visualChannels, size: { ...super.visualChannels.size, range: 'radiusRange', property: 'radius', channelScaleType: 'radius' } }; } get layerInfoModal() { return { id: 'iconInfo', template: this._layerInfoModal, modalProps: { title: 'modal.iconInfo.title' } }; } getSvgIcons() { const fetchConfig = { method: 'GET', mode: 'cors', cache: 'no-cache' }; if (window.fetch) { window .fetch(SVG_ICON_URL, fetchConfig) .then(response => response.json()) .then((parsed = {}) => { const {svgIcons = []} = parsed; this.iconGeometry = svgIcons.reduce( (accu, curr) => ({ ...accu, [curr.id]: flatterIconPositions(curr) }), {} ); this._layerInfoModal = IconInfoModalFactory(svgIcons); }); } } static findDefaultLayerProps({fieldPairs = [], fields = []}) { const notFound = {props: []}; if (!fieldPairs.length || !fields.length) { return notFound; } const iconFields = fields.filter(({name}) => name .replace(/[_,.]+/g, ' ') .trim() .split(' ') .some(seg => ICON_FIELDS.icon.some(t => t.includes(seg))) ); if (!iconFields.length) { return notFound; } // create icon layers for first point pair const ptPair = fieldPairs[0]; const props = iconFields.map(iconField => ({ label: iconField.name.replace(/[_,.]+/g, ' ').trim(), columns: { lat: ptPair.pair.lat, lng: ptPair.pair.lng, icon: { value: iconField.name, fieldIdx: iconField.tableFieldIndex - 1 } }, isVisible: true })); return {props}; } calculateDataAttribute({allData, filteredIndex}, getPosition) { const getIcon = this.getIconAccessor(); const data = []; for (let i = 0; i < filteredIndex.length; i++) { const index = filteredIndex[i]; const pos = getPosition({data: allData[index]}); const icon = getIcon({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) && typeof icon === 'string') { data.push({ index, icon, data: allData[index] }); } } return data; } formatLayerData(datasets, oldLayerData, opt = {}) { const { colorScale, colorDomain, colorField, color, sizeField, sizeScale, sizeDomain, textLabel, visConfig: {radiusRange, colorRange} } = this.config; const getPosition = this.getPositionAccessor(); const {gpuFilter} = datasets[this.config.dataId]; const {data, triggerChanged} = this.updateData(datasets, oldLayerData); // point color const cScale = colorField && this.getVisChannelScale(colorScale, colorDomain, colorRange.colors.map(hexToRgb)); // point radius const rScale = sizeField && this.getVisChannelScale(sizeScale, sizeDomain, radiusRange, 0); const getRadius = rScale ? d => this.getEncodedChannelValue(rScale, d.data, sizeField) : 1; const getFillColor = cScale ? d => this.getEncodedChannelValue(cScale, d.data, colorField) : color; // get all distinct characters in the text labels const textLabels = formatTextLabelData({ textLabel, triggerChanged, oldLayerData, data }); return { data, getPosition, getFillColor, getFilterValue: gpuFilter.filterValueAccessor(), getRadius, textLabels }; } updateLayerMeta(allData, getPosition) { const bounds = this.getPointsBounds(allData, d => getPosition({data: d})); this.updateMeta({bounds}); } renderLayer(opts) { const {data, gpuFilter, objectHovered, mapState, interactionConfig} = opts; const radiusScale = this.getRadiusScaleByZoom(mapState); const layerProps = { radiusScale, ...(this.config.visConfig.fixedRadius ? {} : {radiusMaxPixels: 500}) }; const updateTriggers = { getFilterValue: gpuFilter.filterValueUpdateTriggers, getRadius: { sizeField: this.config.colorField, radiusRange: this.config.visConfig.radiusRange, sizeScale: this.config.sizeScale }, getFillColor: { color: this.config.color, colorField: this.config.colorField, colorRange: this.config.visConfig.colorRange, colorScale: this.config.colorScale } }; const defaultLayerProps = this.getDefaultDeckLayerProps(opts); const brushingProps = this.getBrushingExtensionProps(interactionConfig); const getPixelOffset = getTextOffsetByRadius(radiusScale, data.getRadius, mapState); const extensions = [...defaultLayerProps.extensions, brushingExtension]; // shared Props between layer and label layer const sharedProps = { getFilterValue: data.getFilterValue, extensions, filterRange: defaultLayerProps.filterRange, ...brushingProps }; const labelLayers = [ ...this.renderTextLabelLayer( { getPosition: data.getPosition, sharedProps, getPixelOffset, updateTriggers }, opts ) ]; return !this.iconGeometry ? [] : [ new SvgIconLayer({ ...defaultLayerProps, ...brushingProps, ...layerProps, ...data, getIconGeometry: id => this.iconGeometry[id], // update triggers updateTriggers, extensions }), ...(this.isLayerHovered(objectHovered) ? [ new SvgIconLayer({ ...this.getDefaultHoverLayerProps(), ...layerProps, data: [objectHovered.object], getPosition: data.getPosition, getRadius: data.getRadius, getFillColor: this.config.highlightColor, getIconGeometry: id => this.iconGeometry[id] }) ] : []), // text label layer ...labelLayers ]; } }