UNPKG

kepler.gl

Version:

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

447 lines (388 loc) 11.7 kB
// Copyright (c) 2018 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 uniq from 'lodash.uniq'; import pick from 'lodash.pick'; import { getDefaultFilter, getFilterProps, getFilterPlot, filterData, adjustValueToFilterDomain } from 'utils/filter-utils'; import {LAYER_BLENDINGS} from 'constants/default-settings'; /** * Merge loaded filters with current state, if no fields or data are loaded * save it for later * * @param {Object} state * @param {Object[]} filtersToMerge * @return {Object} updatedState */ export function mergeFilters(state, filtersToMerge) { const merged = []; const unmerged = []; const {datasets} = state; if (!Array.isArray(filtersToMerge) || !filtersToMerge.length) { return state; } // merge filters filtersToMerge.forEach(filter => { // match filter.dataId with current datesets id // uploaded data need to have the same dataId with the filter if (datasets[filter.dataId]) { // datasets is already loaded const validateFilter = validateFilterWithData( datasets[filter.dataId], filter ); if (validateFilter) { merged.push(validateFilter); } } else { // datasets not yet loaded unmerged.push(filter); } }); // filter data const updatedFilters = [...(state.filters || []), ...merged]; const datasetToFilter = uniq(merged.map(d => d.dataId)); const updatedDataset = datasetToFilter.reduce( (accu, dataId) => ({ ...accu, [dataId]: { ...datasets[dataId], ...filterData(datasets[dataId].allData, dataId, updatedFilters) } }), datasets ); return { ...state, filters: updatedFilters, datasets: updatedDataset, filterToBeMerged: unmerged }; } /** * Merge layers from de-serialized state, if no fields or data are loaded * save it for later * * @param {object} state * @param {Object[]} layersToMerge * @return {Object} state */ export function mergeLayers(state, layersToMerge) { const mergedLayer = []; const unmerged = []; const {datasets} = state; if (!Array.isArray(layersToMerge) || !layersToMerge.length) { return state; } layersToMerge.forEach(layer => { if (datasets[layer.config.dataId]) { // datasets are already loaded const validateLayer = validateLayerWithData( datasets[layer.config.dataId], layer, state.layerClasses ); if (validateLayer) { mergedLayer.push(validateLayer); } } else { // datasets not yet loaded unmerged.push(layer); } }); const layers = [...state.layers, ...mergedLayer]; const newLayerOrder = mergedLayer.map((_, i) => state.layers.length + i); // put new layers in front of current layers const layerOrder = [...newLayerOrder, ...state.layerOrder]; return { ...state, layers, layerOrder, layerToBeMerged: unmerged }; } /** * Merge interactions with saved config * * @param {object} state * @param {Object} interactionToBeMerged * @return {Object} mergedState */ export function mergeInteractions(state, interactionToBeMerged) { const merged = {}; const unmerged = {}; if (interactionToBeMerged) { Object.keys(interactionToBeMerged).forEach(key => { if (!state.interactionConfig[key]) { return; } const {enabled, ...configSaved} = interactionToBeMerged[key] || {}; let configToMerge = configSaved; if (key === 'tooltip') { const {mergedTooltip, unmergedTooltip} = mergeInteractionTooltipConfig( state, configSaved ); // merge new dataset tooltips with original dataset tooltips configToMerge = { fieldsToShow: { ...state.interactionConfig[key].config.fieldsToShow, ...mergedTooltip } }; if (Object.keys(unmergedTooltip).length) { unmerged.tooltip = {fieldsToShow: unmergedTooltip, enabled}; } } merged[key] = { ...state.interactionConfig[key], enabled, config: pick( { ...state.interactionConfig[key].config, ...configToMerge }, Object.keys(state.interactionConfig[key].config) ) }; }); } return { ...state, interactionConfig: { ...state.interactionConfig, ...merged }, interactionToBeMerged: unmerged }; } /** * Merge interactionConfig.tooltip with saved config, * validate fieldsToShow * * @param {string} state * @param {Object} tooltipConfig * @return {Object} - {mergedTooltip: {}, unmergedTooltip: {}} */ export function mergeInteractionTooltipConfig(state, tooltipConfig = {}) { const unmergedTooltip = {}; const mergedTooltip = {}; if ( !tooltipConfig.fieldsToShow || !Object.keys(tooltipConfig.fieldsToShow).length ) { return {mergedTooltip, unmergedTooltip}; } for (const dataId in tooltipConfig.fieldsToShow) { if (!state.datasets[dataId]) { // is not yet loaded unmergedTooltip[dataId] = tooltipConfig.fieldsToShow[dataId]; } else { // if dataset is loaded const allFields = state.datasets[dataId].fields.map(d => d.name); const foundFieldsToShow = tooltipConfig.fieldsToShow[dataId].filter( name => allFields.includes(name) ); mergedTooltip[dataId] = foundFieldsToShow; } } return {mergedTooltip, unmergedTooltip}; } /** * Merge layerBlending with saved * * @param {object} state * @param {string} layerBlending * @return {object} merged state */ export function mergeLayerBlending(state, layerBlending) { if (layerBlending && LAYER_BLENDINGS[layerBlending]) { return { ...state, layerBlending }; } return state; } /** * Validate saved layer columns with new data, * update fieldIdx based on new fields * * @param {Object[]} fields * @param {Object} savedCols * @param {Object} emptyCols * @return {null | Object} - validated columns or null */ export function validateSavedLayerColumns(fields, savedCols, emptyCols) { const colFound = {}; // find actual column fieldIdx, in case it has changed const allColFound = Object.keys(emptyCols).every(key => { const saved = savedCols[key]; colFound[key] = {...emptyCols[key]}; const fieldIdx = fields.findIndex(({name}) => name === saved); if (fieldIdx > -1) { // update found columns colFound[key].fieldIdx = fieldIdx; colFound[key].value = saved; return true; } // if col is optional, allow null value return emptyCols[key].optional || false; }); return allColFound && colFound; } /** * Validate saved visual channels config with new data, * refer to vis-state-schema.js VisualChannelSchemaV1 * * @param {Object[]} fields * @param {Object} visualChannels * @param {Object} savedLayer * @return {Object} - validated visual channel in config or {} */ export function validateSavedVisualChannels( fields, visualChannels, savedLayer ) { return Object.values(visualChannels).reduce((found, {field, scale}) => { let foundField; if (savedLayer.config[field]) { foundField = fields.find(fd => Object.keys(savedLayer.config[field]).every( key => savedLayer.config[field][key] === fd[key] ) ); } return { ...found, ...(foundField ? {[field]: foundField} : {}), ...(savedLayer.config[scale] ? {[scale]: savedLayer.config[scale]} : {}) }; }, {}); } /** * Validate saved layer config with new data, * update fieldIdx based on new fields * * @param {Object[]} fields * @param {String} dataId * @param {Object} savedLayer * @param {Object} layerClasses * @return {null | Object} - validated layer or null */ export function validateLayerWithData({fields, id: dataId}, savedLayer, layerClasses) { const {type} = savedLayer; // layer doesnt have a valid type if ( !layerClasses.hasOwnProperty(type) || !savedLayer.config || !savedLayer.config.columns ) { return null; } const newLayer = new layerClasses[type]({ id: savedLayer.id, dataId, label: savedLayer.config.label, color: savedLayer.config.color, isVisible: savedLayer.config.isVisible }); // find column fieldIdx const columns = validateSavedLayerColumns( fields, savedLayer.config.columns, newLayer.getLayerColumns() ); if (!columns) { return null; } // visual channel field is saved to be {name, type} // find visual channel field by matching both name and type // refer to vis-state-schema.js VisualChannelSchemaV1 const foundVisualChannelConfigs = validateSavedVisualChannels( fields, newLayer.visualChannels, savedLayer ); // copy visConfig over to emptyLayer to make sure it has all the props const visConfig = newLayer.copyLayerConfig( newLayer.config.visConfig, savedLayer.config.visConfig || {}, {notToDeepMerge: 'colorRange'} ); newLayer.updateLayerConfig({ columns, visConfig, ...foundVisualChannelConfigs }); return newLayer; } /** * Validate saved filter config with new data, * calculate domain and fieldIdx based new fields and data * * @param {Object[]} dataset.fields * @param {Object[]} dataset.allData * @param {Object} filter - filter to be validate * @return {Object | null} - validated filter */ export function validateFilterWithData({fields, allData}, filter) { // match filter.name to field.name const fieldIdx = fields.findIndex(({name}) => name === filter.name); if (fieldIdx < 0) { // if can't find field with same name, discharge filter return null; } const field = fields[fieldIdx]; const value = filter.value; // return filter type, default value, fieldType and fieldDomain from field const filterPropsFromField = getFilterProps(allData, field); let matchedFilter = { ...getDefaultFilter(filter.dataId), ...filter, ...filterPropsFromField, freeze: true, fieldIdx }; const {yAxis} = matchedFilter; if (yAxis) { const matcheAxis = fields.find( ({name, type}) => name === yAxis.name && type === yAxis.type ); matchedFilter = matcheAxis ? { ...matchedFilter, yAxis: matcheAxis, ...getFilterPlot({...matchedFilter, yAxis: matcheAxis}, allData) } : matchedFilter; } matchedFilter.value = adjustValueToFilterDomain(value, matchedFilter); if (matchedFilter.value === null) { // cannt adjust saved value to filter return null; } return matchedFilter; }