UNPKG

kepler.gl

Version:

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

478 lines (402 loc) 13.6 kB
// Copyright (c) 2021 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 {console as Console} from 'global/console'; import {TRIP_POINT_FIELDS, SORT_ORDER} from 'constants/default-settings'; import {ascending, descending} from 'd3-array'; // import {validateInputData} from 'processors/data-processor'; import {generateHashId} from 'utils/utils'; import {getGpuFilterProps, getDatasetFieldIndexForFilter} from 'utils/gpu-filter-utils'; import { getFilterProps, getFilterRecord, diffFilters, getFilterFunction, filterDataByFilterTypes, getNumericFieldDomain, getTimestampFieldDomain } from 'utils/filter-utils'; import {maybeToDate, getSortingFunction} from 'utils/data-utils'; import { getQuantileDomain, getOrdinalDomain, getLogDomain, getLinearDomain } from 'utils/data-scale-utils'; import {ALL_FIELD_TYPES, SCALE_TYPES} from 'constants/default-settings'; import {createDataContainer} from './data-container-utils'; // Unique identifier of each field const FID_KEY = 'name'; /** @typedef {import('./kepler-table').KeplerTable} KeplerTableClass} */ /** * @type {KeplerTableClass} */ class KeplerTable { constructor({info = {}, data, color, metadata, supportedFilterTypes}) { // TODO - what to do if validation fails? Can kepler handle exceptions? // const validatedData = validateInputData(data); // if (!validatedData) { // return this; // } const dataContainer = createDataContainer(data.rows, {fields: data.fields}); const datasetInfo = { id: generateHashId(4), label: 'new dataset', ...(info || {}) }; const dataId = datasetInfo.id; const fields = data.fields.map((f, i) => ({ ...f, fieldIdx: i, id: f.name, displayName: f.displayName || f.name, valueAccessor: maybeToDate.bind( null, // is time f.type === ALL_FIELD_TYPES.timestamp, i, f.format, dataContainer ) })); const allIndexes = dataContainer.getPlainIndex(); this.id = datasetInfo.id; this.label = datasetInfo.label; this.color = color; this.metadata = { ...metadata, id: datasetInfo.id, label: datasetInfo.label }; this.dataContainer = dataContainer; this.allIndexes = allIndexes; this.filteredIndex = allIndexes; this.filteredIndexForDomain = allIndexes; this.fieldPairs = findPointFieldPairs(fields); this.fields = fields; this.gpuFilter = getGpuFilterProps([], dataId, fields); if (supportedFilterTypes) { this.supportedFilterTypes = supportedFilterTypes; } } /** * Get field * @param columnName */ getColumnField(columnName) { const field = this.fields.find(fd => fd[FID_KEY] === columnName); this._assetField(columnName, field); return field; } /** * Get fieldIdx * @param columnName */ getColumnFieldIdx(columnName) { const fieldIdx = this.fields.findIndex(fd => fd[FID_KEY] === columnName); this._assetField(columnName, Boolean(fieldIdx > -1)); return fieldIdx; } /** * Get the value of a cell */ getValue(columnName, rowIdx) { const field = this.getColumnField(columnName); return field ? field.valueAccessor({index: rowIdx}) : null; } /** * Updates existing field with a new object * @param fieldIdx * @param newField */ updateColumnField(fieldIdx, newField) { this.fields = Object.assign([...this.fields], {[fieldIdx]: newField}); } /** * Save filterProps to field and retrieve it * @param {string} columnName */ getColumnFilterProps(columnName) { const fieldIdx = this.getColumnFieldIdx(columnName); if (fieldIdx < 0) { return null; } const field = this.fields[fieldIdx]; if (field.hasOwnProperty('filterProps')) { return field.filterProps; } const fieldDomain = this.getColumnFilterDomain(field); if (!fieldDomain) { return null; } const filterProps = getFilterProps(field, fieldDomain); const newField = { ...field, filterProps }; this.updateColumnField(fieldIdx, newField); return filterProps; } /** * Apply filters to dataset, return the filtered dataset with updated `gpuFilter`, `filterRecord`, `filteredIndex`, `filteredIndexForDomain` * @param filters * @param layers * @param opt */ filterTable(filters, layers, opt) { const {dataContainer, id: dataId, filterRecord: oldFilterRecord, fields} = this; // if there is no filters const filterRecord = getFilterRecord(dataId, filters, opt || {}); this.filterRecord = filterRecord; this.gpuFilter = getGpuFilterProps(filters, dataId, fields); // const newDataset = set(['filterRecord'], filterRecord, dataset); if (!filters.length) { this.filteredIndex = this.allIndexes; this.filteredIndexForDomain = this.allIndexes; return this; } this.changedFilters = diffFilters(filterRecord, oldFilterRecord); // generate 2 sets of filter result // filteredIndex used to calculate layer data // filteredIndexForDomain used to calculate layer Domain const shouldCalDomain = Boolean(this.changedFilters.dynamicDomain); const shouldCalIndex = Boolean(this.changedFilters.cpu); let filterResult = {}; if (shouldCalDomain || shouldCalIndex) { const dynamicDomainFilters = shouldCalDomain ? filterRecord.dynamicDomain : null; const cpuFilters = shouldCalIndex ? filterRecord.cpu : null; const filterFuncs = filters.reduce((acc, filter) => { const fieldIndex = getDatasetFieldIndexForFilter(this.id, filter); const field = fieldIndex !== -1 ? fields[fieldIndex] : null; return { ...acc, [filter.id]: getFilterFunction(field, this.id, filter, layers, dataContainer) }; }, {}); filterResult = filterDataByFilterTypes( {dynamicDomainFilters, cpuFilters, filterFuncs}, dataContainer ); } this.filteredIndex = filterResult.filteredIndex || this.filteredIndex; this.filteredIndexForDomain = filterResult.filteredIndexForDomain || this.filteredIndexForDomain; return this; } /** * Apply filters to a dataset all on CPU, assign to `filteredIdxCPU`, `filterRecordCPU` * @param filters * @param layers */ filterTableCPU(filters, layers) { const opt = { cpuOnly: true, ignoreDomain: true }; // no filter if (!filters.length) { this.filteredIdxCPU = this.allIndexes; this.filterRecordCPU = getFilterRecord(this.id, filters, opt); return this; } // no gpu filter if (!filters.find(f => f.gpu)) { this.filteredIdxCPU = this.filteredIndex; this.filterRecordCPU = getFilterRecord(this.id, filters, opt); return this; } // make a copy for cpu filtering const copied = copyTable(this); copied.filterRecord = this.filterRecordCPU; copied.filteredIndex = this.filteredIdxCPU || []; const filtered = copied.filterTable(filters, layers, opt); this.filteredIdxCPU = filtered.filteredIndex; this.filterRecordCPU = filtered.filterRecord; return this; } /** * Calculate field domain based on field type and data * for Filter */ getColumnFilterDomain(field) { const {dataContainer} = this; const {valueAccessor} = field; let domain; switch (field.type) { case ALL_FIELD_TYPES.real: case ALL_FIELD_TYPES.integer: // calculate domain and step return getNumericFieldDomain(dataContainer, valueAccessor); case ALL_FIELD_TYPES.boolean: return {domain: [true, false]}; case ALL_FIELD_TYPES.string: case ALL_FIELD_TYPES.date: domain = getOrdinalDomain(dataContainer, valueAccessor); return {domain}; case ALL_FIELD_TYPES.timestamp: return getTimestampFieldDomain(dataContainer, valueAccessor); default: return {domain: getOrdinalDomain(dataContainer, valueAccessor)}; } } /** * Get the domain of this column based on scale type */ getColumnLayerDomain(field, scaleType) { const {dataContainer, filteredIndexForDomain} = this; if (!SCALE_TYPES[scaleType]) { Console.error(`scale type ${scaleType} not supported`); return null; } const {valueAccessor} = field; const indexValueAccessor = i => valueAccessor({index: i}); const sortFunction = getSortingFunction(field.type); 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 getOrdinalDomain(dataContainer, valueAccessor); case SCALE_TYPES.quantile: return getQuantileDomain(filteredIndexForDomain, indexValueAccessor, sortFunction); case SCALE_TYPES.log: return getLogDomain(filteredIndexForDomain, indexValueAccessor); case SCALE_TYPES.quantize: case SCALE_TYPES.linear: case SCALE_TYPES.sqrt: default: return getLinearDomain(filteredIndexForDomain, indexValueAccessor); } } /** * Get a sample of rows to calculate layer boundaries */ // getSampleData(rows) /** * Parse cell value based on column type and return a string representation * Value the field value, type the field type */ // parseFieldValue(value, type) // sortDatasetByColumn() /** * Assert whether field exist * @param fieldName * @param condition */ _assetField(fieldName, condition) { if (!condition) { Console.error(`${fieldName} doesnt exist in dataset ${this.id}`); } } } // HELPER FUNCTIONS (MAINLY EXPORTED FOR TEST...) export function removeSuffixAndDelimiters(layerName, suffix) { return layerName .replace(new RegExp(suffix, 'ig'), '') .replace(/[_,.]+/g, ' ') .trim(); } /** * Find point fields pairs from fields * * @param fields * @returns found point fields * @type {typeof import('./kepler-table').findPointFieldPairs} */ export function findPointFieldPairs(fields) { const allNames = fields.map(f => f.name.toLowerCase()); // get list of all fields with matching suffixes const acc = []; return allNames.reduce((carry, fieldName, idx) => { // This search for pairs will early exit if found. for (const suffixPair of TRIP_POINT_FIELDS) { // match first suffix``` if (fieldName.endsWith(suffixPair[0])) { // match second suffix const otherPattern = new RegExp(`${suffixPair[0]}\$`); const partner = fieldName.replace(otherPattern, suffixPair[1]); const partnerIdx = allNames.findIndex(d => d === partner); if (partnerIdx > -1) { const defaultName = removeSuffixAndDelimiters(fieldName, suffixPair[0]); carry.push({ defaultName, pair: { lat: { fieldIdx: idx, value: fields[idx].name }, lng: { fieldIdx: partnerIdx, value: fields[partnerIdx].name } }, suffix: suffixPair }); return carry; } } } return carry; }, acc); } /** * * @param dataset * @param column * @param mode * @type {typeof import('./kepler-table').sortDatasetByColumn} */ export function sortDatasetByColumn(dataset, column, mode) { const {allIndexes, fields, dataContainer} = dataset; const fieldIndex = fields.findIndex(f => f.name === column); if (fieldIndex < 0) { return dataset; } const sortBy = SORT_ORDER[mode] || SORT_ORDER.ASCENDING; if (sortBy === SORT_ORDER.UNSORT) { return { ...dataset, sortColumn: {}, sortOrder: null }; } const sortFunction = sortBy === SORT_ORDER.ASCENDING ? ascending : descending; const sortOrder = allIndexes .slice() .sort((a, b) => sortFunction(dataContainer.valueAt(a, fieldIndex), dataContainer.valueAt(b, fieldIndex)) ); return { ...dataset, sortColumn: { [column]: sortBy }, sortOrder }; } export function copyTable(original) { return Object.assign(Object.create(Object.getPrototypeOf(original)), original); } export function copyTableAndUpdate(original, options = {}) { return Object.entries(options).reduce((acc, entry) => { acc[entry[0]] = entry[1]; return acc; }, copyTable(original)); } export default KeplerTable;