Skip to content

Commit

Permalink
[Enhancement] Render data table with smarter cell size, prevent scrol…
Browse files Browse the repository at this point in the history
…l back (#2117)

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Co-authored-by: Shan He <heshan0131@gmail.com>
  • Loading branch information
igorDykhta and heshan0131 committed Feb 7, 2023
1 parent b1d92c8 commit 918aaf9
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 70 deletions.
14 changes: 13 additions & 1 deletion src/components/src/common/data-table/cell-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const MIN_CELL_SIZE = 45;
// first column have padding on the left
const EDGE_COLUMN_PADDING = 10;

// in case cell content is small, column name is big, we allow max empty space to
// be added to min cell width in order to show column name
const MAX_EMPTY_COLUMN_SPACE = 60;

type RenderSizeParam = {
text: {dataContainer: DataContainerInterface; column: string};
type?: string;
Expand Down Expand Up @@ -100,7 +104,9 @@ export function renderedSize({
const headerWidth =
Math.ceil(context.measureText(column).width) + cellPadding / 2 + optionsButton;

// min row width is measured by cell content
const minRowWidth = minCellSize + cellPadding;
// min header width is measured by cell
const minHeaderWidth = minCellSize + cellPadding / 2 + optionsButton;

const clampedRowWidth = clamp(minRowWidth, maxCellSize, rowWidth);
Expand All @@ -123,11 +129,17 @@ function getColumnOrder(pinnedColumns: string[] = [], unpinnedColumns: string[]
return [...pinnedColumns, ...unpinnedColumns];
}

// If total min cell size is bigger than containerWidth adjust column
function getMinCellSize(cellSizeCache: CellSizeCache) {
return Object.keys(cellSizeCache).reduce(
(accu, col) => ({
...accu,
[col]: cellSizeCache[col].row
// if row is larger than header, use row
[col]:
cellSizeCache[col].row > cellSizeCache[col].header
? cellSizeCache[col].row
: // if row is smaller than header, use the smaller of MAX_EMPTY_COLUMN_SPACE + row width and header
Math.min(cellSizeCache[col].header, cellSizeCache[col].row + MAX_EMPTY_COLUMN_SPACE)
}),
{}
);
Expand Down
33 changes: 32 additions & 1 deletion src/components/src/common/data-table/grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,37 @@ import isEqual from 'lodash.isequal';
export default class GridHack extends PureComponent<GridProps> {
grid: Grid | null = null;

_preventScrollBack = e => {
const {scrollLeft} = this.props;
if (scrollLeft !== undefined && scrollLeft <= 0 && e.deltaX < 0) {
// Prevent Scroll On Scrollable Elements, avoid browser backward navigation
// https://alvarotrigo.com/blog/prevent-scroll-on-scrollable-element-js/
e.preventDefault();
e.stopPropagation();
return false;
}
return;
};
_updateRef = x => {
if (!this.grid && x) {
this.grid = x;
/*
* This hack exists because we need to add wheel event listener to the div rendered by Grid
*
*/
//@ts-expect-error _scrollingContainer not typed in Grid
this.grid?._scrollingContainer?.addEventListener('wheel', this._preventScrollBack, {
passive: false
});
}
};
componentWillUnmount() {
//@ts-expect-error _scrollingContainer not typed in Grid
this.grid?._scrollingContainer?.removeEventListener('wheel', this._preventScrollBack, {
passive: false
});
}

componentDidUpdate(preProps) {
/*
* This hack exists because in react-virtualized the
Expand All @@ -47,7 +78,7 @@ export default class GridHack extends PureComponent<GridProps> {
<Grid
ref={x => {
if (setGridRef) setGridRef(x);
this.grid = x;
this._updateRef(x);
}}
key="grid-hack"
{...rest}
Expand Down
143 changes: 90 additions & 53 deletions src/components/src/common/data-table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

import React, {Component, createRef} from 'react';
import React, {Component, createRef, useMemo} from 'react';
import {ScrollSync, AutoSizer, OnScrollParams, GridProps, Index} from 'react-virtualized';
import styled, {withTheme} from 'styled-components';
import classnames from 'classnames';
Expand All @@ -42,11 +42,23 @@ const defaultHeaderStatsControlHeight = 40;
const defaultRowHeight = 32;
const overscanColumnCount = 10;
const overscanRowCount = 10;
// The default scrollbar width can range anywhere from 12px to 17px
const browserScrollBarWidth = 17;
const fieldToAlignRight = {
[ALL_FIELD_TYPES.integer]: true,
[ALL_FIELD_TYPES.real]: true
};

const pinnedClassList = {
header: 'pinned-columns--header pinned-grid-container',
rows: 'pinned-columns--rows pinned-grid-container'
};

const unpinnedClassList = {
header: 'unpinned-columns--header unpinned-grid-container',
rows: 'unpinned-columns--rows unpinned-grid-container'
};

export const Container = styled.div`
display: flex;
font-size: 11px;
Expand Down Expand Up @@ -79,6 +91,15 @@ export const Container = styled.div`
overflow: hidden;
border-top: none;
.scroll-in-ui-thread.pinned-columns--header,
.scroll-in-ui-thread.unpinned-columns--header {
width: 100vw;
overflow: hidden;
border-bottom: 1px solid ${props => props.theme.cellBorderColor};
// leave room for scrollbar
padding-bottom: ${browserScrollBarWidth}px;
}
.scroll-in-ui-thread::after {
content: '';
height: 100%;
Expand Down Expand Up @@ -123,7 +144,8 @@ export const Container = styled.div`
align-items: flex-start;
text-align: center;
overflow: hidden;
// header border is rendered by header container
border-bottom: 0;
.n-sort-idx {
font-size: 9px;
}
Expand Down Expand Up @@ -301,49 +323,69 @@ export const TableSection = ({
headerCellRender,
dataCellRender,
scrollLeft = 0
}: TableSectionProps) => (
<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}
onScroll={onScroll}
/>
</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}
scrollLeft={scrollLeft}
scrollTop={scrollTop}
setGridRef={setGridRef}
/>
</div>
</>
);
}}
</AutoSizer>
);
}: TableSectionProps) => {
const headerHeight = headerGridProps.height;

const headerStyle = useMemo(
() => ({
height: `${headerHeight}px`
}),
[headerHeight]
);
const contentStyle = useMemo(
() => ({
top: `${headerHeight}px`
}),
[headerHeight]
);

return (
<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)}
style={headerStyle}
>
<Grid
cellRenderer={headerCellRender}
{...headerGridProps}
{...gridDimension}
height={headerGridProps.height + browserScrollBarWidth}
scrollLeft={scrollLeft}
onScroll={onScroll}
/>
</div>
<div
className={classnames('scroll-in-ui-thread', classList?.rows)}
style={contentStyle}
>
<Grid
cellRenderer={dataCellRender}
{...dataGridProps}
{...gridDimension}
className={isPinned ? 'pinned-grid' : 'body-grid'}
height={dataGridHeight - headerGridProps.height}
onScroll={onScroll}
scrollLeft={scrollLeft}
scrollTop={scrollTop}
setGridRef={setGridRef}
/>
</div>
</>
);
}}
</AutoSizer>
);
};

export interface DataTableProps {
dataId?: string;
Expand Down Expand Up @@ -574,16 +616,14 @@ function DataTableFactory(HeaderCell: ReturnType<typeof HeaderCellFactory>) {
{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'
}}
classList={pinnedClassList}
isPinned
columns={pinnedColumns}
headerGridProps={headerGridProps}
fixedWidth={pinnedColumnsWidth}
onScroll={args => onScroll({...args, scrollLeft})}
scrollTop={scrollTop}
scrollLeft={scrollLeft}
dataGridProps={dataGridProps}
setGridRef={pinnedGrid => (this.pinnedGrid = pinnedGrid)}
columnWidth={columnWidthFunction(pinnedColumns, cellSizeCache)}
Expand All @@ -606,10 +646,7 @@ function DataTableFactory(HeaderCell: ReturnType<typeof HeaderCellFactory>) {
className="unpinned-columns grid-column"
>
<TableSection
classList={{
header: 'unpinned-columns--header unpinned-grid-container',
rows: 'unpinned-columns--rows unpinned-grid-container'
}}
classList={unpinnedClassList}
isPinned={false}
columns={unpinnedColumnsGhost}
headerGridProps={headerGridProps}
Expand Down
4 changes: 2 additions & 2 deletions src/components/src/common/field-token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ function FieldTokenFactory(
font-weight: 400;
padding: 0 5px;
text-align: center;
width: 40px;
line-height: 20px;
width: ${props => props.theme.fieldTokenWidth}px;
line-height: ${props => props.theme.fieldTokenHeight}px;
`;

const FieldToken = ({type}: FieldTokenProps) => (
Expand Down
2 changes: 1 addition & 1 deletion src/components/src/modals/data-table-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {renderedSize} from '../common/data-table/cell-size';
import CanvasHack from '../common/data-table/canvas';
import KeplerTable, {Datasets} from '@kepler.gl/table';

const MIN_STATS_CELL_SIZE = 142;
const MIN_STATS_CELL_SIZE = 122;

const dgSettings = {
sidePadding: '38px',
Expand Down
6 changes: 5 additions & 1 deletion src/styles/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,8 @@ export const actionPanelHeight = 32;

// Styled Token
export const fieldTokenRightMargin = 4;
export const fieldTokenHeight = 20;
export const fieldTokenWidth = 40;

export const textTruncate = {
maxWidth: '100%',
Expand Down Expand Up @@ -1524,7 +1526,9 @@ export const theme = {
layerConfiguratorPadding,

// Styled token
fieldTokenRightMargin
fieldTokenRightMargin,
fieldTokenHeight,
fieldTokenWidth
};

export const themeLT = {
Expand Down
22 changes: 11 additions & 11 deletions test/browser/components/modals/data-table-modal-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,19 @@ const expectedCellSizeCache = {

const expectedExpandedCellSize = {
cellSizeCache: {
'gps_data.utc_timestamp': 155,
'gps_data.lat': 96,
'gps_data.lng': 96,
'gps_data.utc_timestamp': 160,
'gps_data.lat': 108,
'gps_data.lng': 110,
'gps_data.types': 125,
epoch: 121,
has_result: 75,
uid: 75,
has_result: 99,
uid: 90,
time: 180,
begintrip_ts_utc: 182,
begintrip_ts_local: 182,
date: 95
date: 143
},
ghost: undefined
ghost: null
};

// This is to Mock Canvas.measureText response which we will not be testing
Expand Down Expand Up @@ -275,7 +275,7 @@ test('Components -> DataTableModal -> render DataTable: csv 1', t => {
const enriched = {
...props,
cellSizeCache: expectedCellSizeCache,
fixedWidth: 1300,
fixedWidth: 1500,
fixedHeight: 800,
theme: {}
};
Expand Down Expand Up @@ -545,9 +545,9 @@ test('Components -> cellSize -> renderedSize', t => {
.props();

const expected = {
_geojson: {row: 500, header: 206},
'income level of people over 65': {row: 182, header: 223},
engagement: {row: 182, header: 206}
_geojson: {row: 500, header: 186},
'income level of people over 65': {row: 162, header: 223},
engagement: {row: 162, header: 186}
};

t.deepEqual(
Expand Down

0 comments on commit 918aaf9

Please sign in to comment.