kepler.gl
Version:
kepler.gl is a webgl based application to visualize large scale location data in the browser
621 lines (561 loc) • 19.5 kB
JavaScript
// 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 React, {Component, createRef} from 'react';
import {ScrollSync, AutoSizer} from 'react-virtualized';
import styled, {withTheme} from 'styled-components';
import classnames from 'classnames';
import {createSelector} from 'reselect';
import get from 'lodash.get';
import debounce from 'lodash.debounce';
import OptionDropdown from './option-dropdown';
import Grid from './grid';
import Button from './button';
import {ArrowUp, ArrowDown, VertThreeDots} from 'components/common/icons';
import {parseFieldValue} from 'utils/data-utils';
import {adjustCellsToContainer} from './cell-size';
import {ALL_FIELD_TYPES, SORT_ORDER} from 'constants/default-settings';
import FieldTokenFactory from 'components/common/field-token';
const defaultHeaderRowHeight = 55;
const defaultRowHeight = 32;
const overscanColumnCount = 10;
const overscanRowCount = 10;
const fieldToAlignRight = {
[ALL_FIELD_TYPES.integer]: true,
[ALL_FIELD_TYPES.real]: true
};
export const Container = styled.div`
display: flex;
font-size: 11px;
flex-grow: 1;
color: ${props => props.theme.dataTableTextColor};
width: 100%;
.ReactVirtualized__Grid:focus,
.ReactVirtualized__Grid:active {
outline: 0;
}
.cell {
&::-webkit-scrollbar {
display: none;
}
}
*:focus {
outline: 0;
}
.results-table-wrapper {
position: relative;
min-height: 100%;
max-height: 100%;
display: flex;
flex-direction: row;
flex-grow: 1;
overflow: hidden;
border-top: none;
.scroll-in-ui-thread::after {
content: '';
height: 100%;
left: 0;
position: absolute;
pointer-events: none;
top: 0;
width: 100%;
}
.grid-row {
position: relative;
display: flex;
flex-direction: row;
}
.grid-column {
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
.pinned-grid-container {
flex: 0 0 75px;
z-index: 10;
position: absolute;
left: 0;
top: 0;
border-right: 2px solid ${props => props.theme.pinnedGridBorderColor};
}
.header-grid {
overflow: hidden !important;
}
.even-row {
background-color: ${props => props.theme.evenRowBackground};
}
.odd-row {
background-color: ${props => props.theme.oddRowBackground};
}
.cell,
.header-cell {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
text-align: center;
overflow: hidden;
.n-sort-idx {
font-size: 9px;
}
}
.cell {
border-bottom: 1px solid ${props => props.theme.cellBorderColor};
border-right: 1px solid ${props => props.theme.cellBorderColor};
white-space: nowrap;
overflow: auto;
padding: 0 ${props => props.theme.cellPaddingSide}px;
font-size: ${props => props.theme.cellFontSize}px;
.result-link {
text-decoration: none;
}
}
.cell.end-cell,
.header-cell.end-cell {
border-right: none;
padding-right: ${props => props.theme.cellPaddingSide + props.theme.edgeCellPaddingSide}px;
}
.cell.first-cell,
.header-cell.first-cell {
padding-left: ${props => props.theme.cellPaddingSide + props.theme.edgeCellPaddingSide}px;
}
.cell.bottom-cell {
border-bottom: none;
}
.cell.align-right {
align-items: flex-end;
}
.header-cell {
border-bottom: 1px solid ${props => props.theme.headerCellBorderColor};
border-top: 1px solid ${props => props.theme.headerCellBorderColor};
padding-top: ${props => props.theme.headerPaddingTop}px;
padding-right: 0;
padding-bottom: ${props => props.theme.headerPaddingBottom}px;
padding-left: ${props => props.theme.cellPaddingSide}px;
align-items: center;
justify-content: space-between;
display: flex;
flex-direction: row;
background-color: ${props => props.theme.headerCellBackground};
&:hover {
.more {
color: ${props => props.theme.headerCellIconColor};
}
}
.n-sort-idx {
font-size: 9px;
}
.details {
font-weight: 500;
display: flex;
flex-direction: column;
justify-content: flex-start;
height: 100%;
overflow: hidden;
flex-grow: 1;
.col-name {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
.col-name__left {
display: flex;
align-items: center;
overflow: hidden;
svg {
margin-left: 6px;
}
}
.col-name__name {
overflow: hidden;
white-space: nowrap;
}
}
}
.more {
color: transparent;
margin-left: 5px;
}
}
}
:focus {
outline: none;
}
`;
const defaultColumnWidth = 200;
const columnWidthFunction = (columns, cellSizeCache, ghost) => ({index}) => {
return (columns[index] || {}).ghost ? ghost : cellSizeCache[columns[index]] || defaultColumnWidth;
};
/*
* This is an accessor method used to generalize getting a cell from a data row
*/
const getRowCell = ({dataContainer, columns, column, colMeta, rowIndex, sortOrder}) => {
const rowIdx = sortOrder && sortOrder.length ? get(sortOrder, rowIndex) : rowIndex;
const {type} = colMeta[column];
let value = dataContainer.valueAt(rowIdx, columns.indexOf(column));
if (value === undefined) value = 'Err';
return parseFieldValue(value, type);
};
export const TableSection = ({
classList,
isPinned,
columns,
headerGridProps,
fixedWidth,
fixedHeight = undefined,
onScroll,
scrollTop,
dataGridProps,
columnWidth,
setGridRef,
headerCellRender,
dataCellRender,
scrollLeft = undefined
}) => (
<AutoSizer>
{({width, height}) => {
const gridDimension = {
columnCount: columns.length,
columnWidth,
width: fixedWidth || width
};
const dataGridHeight = fixedHeight || height;
return (
<>
<div className={classnames('scroll-in-ui-thread', classList.header)}>
<Grid
cellRenderer={headerCellRender}
{...headerGridProps}
{...gridDimension}
scrollLeft={scrollLeft}
/>
</div>
<div
className={classnames('scroll-in-ui-thread', classList.rows)}
style={{
top: headerGridProps.height
}}
>
<Grid
cellRenderer={dataCellRender}
{...dataGridProps}
{...gridDimension}
className={isPinned ? 'pinned-grid' : 'body-grid'}
height={dataGridHeight - headerGridProps.height}
onScroll={onScroll}
scrollTop={scrollTop}
setGridRef={setGridRef}
/>
</div>
</>
);
}}
</AutoSizer>
);
DataTableFactory.deps = [FieldTokenFactory];
function DataTableFactory(FieldToken) {
class DataTable extends Component {
static defaultProps = {
dataContainer: null,
pinnedColumns: [],
colMeta: {},
cellSizeCache: {},
sortColumn: {},
fixedWidth: null,
fixedHeight: null,
theme: {}
};
state = {
cellSizeCache: {},
moreOptionsColumn: null
};
componentDidMount() {
window.addEventListener('resize', this.scaleCellsToWidth);
this.scaleCellsToWidth();
}
componentDidUpdate(prevProps) {
if (
this.props.cellSizeCache !== prevProps.cellSizeCache ||
this.props.pinnedColumns !== prevProps.pinnedColumns
) {
this.scaleCellsToWidth();
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.scaleCellsToWidth);
// fix Warning: Can't perform a React state update on an unmounted component
this.setState = () => {
return;
};
}
root = createRef();
columns = props => props.columns;
pinnedColumns = props => props.pinnedColumns;
unpinnedColumns = createSelector(this.columns, this.pinnedColumns, (columns, pinnedColumns) =>
!Array.isArray(pinnedColumns) ? columns : columns.filter(c => !pinnedColumns.includes(c))
);
toggleMoreOptions = moreOptionsColumn =>
this.setState({
moreOptionsColumn:
this.state.moreOptionsColumn === moreOptionsColumn ? null : moreOptionsColumn
});
getCellSizeCache = () => {
const {cellSizeCache: propsCache, fixedWidth, pinnedColumns} = this.props;
const unpinnedColumns = this.unpinnedColumns(this.props);
const width = fixedWidth ? fixedWidth : this.root.current ? this.root.current.clientWidth : 0;
// pin column border is 2 pixel vs 1 pixel
const adjustWidth = pinnedColumns.length ? width - 1 : width;
const {cellSizeCache, ghost} = adjustCellsToContainer(
adjustWidth,
propsCache,
pinnedColumns,
unpinnedColumns
);
return {
cellSizeCache,
ghost
};
};
doScaleCellsToWidth = () => {
this.setState(this.getCellSizeCache());
};
scaleCellsToWidth = debounce(this.doScaleCellsToWidth, 300);
renderHeaderCell = (
columns,
isPinned,
props,
toggleMoreOptions,
moreOptionsColumn,
TokenComponent
) => {
// eslint-disable-next-line react/display-name
return cellInfo => {
const {columnIndex, key, style} = cellInfo;
const {colMeta, sortColumn, sortTableColumn, pinTableColumn, copyTableColumn} = props;
const column = columns[columnIndex];
const isGhost = column.ghost;
const isSorted = sortColumn[column];
const firstCell = columnIndex === 0;
return (
<div
className={classnames('header-cell', {
[`column-${columnIndex}`]: true,
'pinned-header-cell': isPinned,
'first-cell': firstCell
})}
key={key}
style={style}
onClick={e => {
e.shiftKey ? sortTableColumn(column) : null;
}}
onDoubleClick={() => sortTableColumn(column)}
title={column}
>
{isGhost ? (
<div />
) : (
<>
<section className="details">
<div className="col-name">
<div className="col-name__left">
<div className="col-name__name">{colMeta[column].name}</div>
<Button className="col-name__sort" onClick={() => sortTableColumn(column)}>
{isSorted ? (
isSorted === SORT_ORDER.ASCENDING ? (
<ArrowUp height="14px" />
) : (
<ArrowDown height="14px" />
)
) : null}
</Button>
</div>
<Button className="more" onClick={() => toggleMoreOptions(column)}>
<VertThreeDots height="14px" />
</Button>
</div>
<FieldToken type={colMeta[column].type} />
</section>
<section className="options">
<OptionDropdown
isOpened={moreOptionsColumn === column}
type={colMeta[column].type}
column={column}
toggleMoreOptions={toggleMoreOptions}
sortTableColumn={mode => sortTableColumn(column, mode)}
sortMode={sortColumn && sortColumn[column]}
pinTableColumn={() => pinTableColumn(column)}
copyTableColumn={() => copyTableColumn(column)}
isSorted={isSorted}
isPinned={isPinned}
/>
</section>
</>
)}
</div>
);
};
};
renderDataCell = (columns, isPinned, props) => {
return cellInfo => {
const {columnIndex, key, style, rowIndex} = cellInfo;
const {dataContainer, colMeta} = props;
const column = columns[columnIndex];
const isGhost = column.ghost;
const rowCell = isGhost ? '' : getRowCell({...props, column, rowIndex});
const type = isGhost ? null : colMeta[column].type;
const lastRowIndex = dataContainer ? dataContainer.numRows() - 1 : 0;
const endCell = columnIndex === columns.length - 1;
const firstCell = columnIndex === 0;
const bottomCell = rowIndex === lastRowIndex;
const alignRight = fieldToAlignRight[type];
const cell = (
<div
className={classnames('cell', {
[rowIndex % 2 === 0 ? 'even-row' : 'odd-row']: true,
[`row-${rowIndex}`]: true,
'pinned-cell': isPinned,
'first-cell': firstCell,
'end-cell': endCell,
'bottom-cell': bottomCell,
'align-right': alignRight
})}
key={key}
style={style}
title={isGhost ? undefined : rowCell}
>
{`${rowCell}${endCell ? '\n' : '\t'}`}
</div>
);
return cell;
};
};
render() {
const {dataContainer, pinnedColumns, theme = {}, fixedWidth, fixedHeight} = this.props;
const unpinnedColumns = this.unpinnedColumns(this.props);
const {cellSizeCache, moreOptionsColumn, ghost} = this.state;
const unpinnedColumnsGhost = ghost ? [...unpinnedColumns, {ghost: true}] : unpinnedColumns;
const pinnedColumnsWidth = pinnedColumns.reduce(
(acc, val) => acc + get(cellSizeCache, val, 0),
0
);
const hasPinnedColumns = Boolean(pinnedColumns.length);
const {headerRowHeight = defaultHeaderRowHeight, rowHeight = defaultRowHeight} = theme;
const headerGridProps = {
cellSizeCache,
className: 'header-grid',
height: headerRowHeight,
rowCount: 1,
rowHeight: headerRowHeight
};
const dataGridProps = {
cellSizeCache,
overscanColumnCount,
overscanRowCount,
rowCount: dataContainer ? dataContainer.numRows() : 0,
rowHeight
};
return (
<Container className="data-table-container" ref={this.root}>
{Object.keys(cellSizeCache).length && (
<ScrollSync>
{({onScroll, scrollLeft, scrollTop}) => {
return (
<div className="results-table-wrapper">
{hasPinnedColumns && (
<div key="pinned-columns" className="pinned-columns grid-row">
<TableSection
classList={{
header: 'pinned-columns--header pinned-grid-container',
rows: 'pinned-columns--rows pinned-grid-container'
}}
isPinned
columns={pinnedColumns}
headerGridProps={headerGridProps}
fixedWidth={pinnedColumnsWidth}
onScroll={args => onScroll({...args, scrollLeft})}
scrollTop={scrollTop}
dataGridProps={dataGridProps}
setGridRef={pinnedGrid => (this.pinnedGrid = pinnedGrid)}
columnWidth={columnWidthFunction(pinnedColumns, cellSizeCache)}
headerCellRender={this.renderHeaderCell(
pinnedColumns,
true,
this.props,
this.toggleMoreOptions,
moreOptionsColumn
)}
dataCellRender={this.renderDataCell(pinnedColumns, true, this.props)}
/>
</div>
)}
<div
key="unpinned-columns"
style={{
marginLeft: `${hasPinnedColumns ? `${pinnedColumnsWidth}px` : '0'}`
}}
className="unpinned-columns grid-column"
>
<TableSection
classList={{
header: 'unpinned-columns--header unpinned-grid-container',
rows: 'unpinned-columns--rows unpinned-grid-container'
}}
isPinned={false}
columns={unpinnedColumnsGhost}
headerGridProps={headerGridProps}
fixedWidth={fixedWidth}
fixedHeight={fixedHeight}
onScroll={onScroll}
scrollTop={scrollTop}
scrollLeft={scrollLeft}
dataGridProps={dataGridProps}
setGridRef={unpinnedGrid => (this.unpinnedGrid = unpinnedGrid)}
columnWidth={columnWidthFunction(
unpinnedColumnsGhost,
cellSizeCache,
ghost
)}
headerCellRender={this.renderHeaderCell(
unpinnedColumnsGhost,
false,
this.props,
this.toggleMoreOptions,
moreOptionsColumn
)}
dataCellRender={this.renderDataCell(
unpinnedColumnsGhost,
false,
this.props
)}
/>
</div>
</div>
);
}}
</ScrollSync>
)}
</Container>
);
}
}
return withTheme(DataTable);
}
export default DataTableFactory;