import { Button, Type } from 'browndash-components'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Utils, emptyFunction, setupMoveUpEvents } from '../../../../../Utils'; import { Doc, Field, NumListCast } from '../../../../../fields/Doc'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; import { Cast, DocCast } from '../../../../../fields/Types'; import { DragManager } from '../../../../util/DragManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { DocumentView } from '../../DocumentView'; import { DataVizView } from '../DataVizBox'; import './Chart.scss'; import { undoBatch } from '../../../../util/UndoManager'; const { default: { DATA_VIZ_TABLE_ROW_HEIGHT } } = require('../../../global/globalCssVariables.module.scss'); // prettier-ignore interface TableBoxProps { Document: Doc; layoutDoc: Doc; records: { [key: string]: any }[]; selectAxes: (axes: string[]) => void; selectTitleCol: (titleCol: string) => void; axes: string[]; titleCol: string; width: number; height: number; margin: { top: number; right: number; bottom: number; left: number; }; docView?: () => DocumentView | undefined; } @observer export class TableBox extends ObservableReactComponent { _inputChangedDisposer?: IReactionDisposer; _containerRef: HTMLDivElement | null = null; @observable settingTitle: boolean = false; // true when setting a title column @observable hasRowsToFilter: boolean = false; // true when any rows are selected @observable filtering: boolean = false; // true when the filtering menu is open @observable filteringColumn: any = ""; // column to filter @observable filteringType: string = "Value"; // "Value" or "Range" filteringVal: any[] = ["", ""]; // value or range to filter the column with @observable _scrollTop = -1; @observable _tableHeight = 0; @observable _tableContainerHeight = 0; constructor(props: any) { super(props); makeObservable(this); } componentDidMount() { // if the tableData changes (ie., when records are selected by the parent (input) visulization), // then we need to remove any selected rows that are no longer part of the visualized dataset. this._inputChangedDisposer = reaction(() => this._tableData.slice(), this.filterSelectedRowsDown, { fireImmediately: true }); const selected = NumListCast(this._props.layoutDoc.dataViz_selectedRows); if (selected.length>0) this.hasRowsToFilter = true; this.handleScroll(); } componentWillUnmount() { this._inputChangedDisposer?.(); } @computed get _tableDataIds() { return !this.parentViz ? this._props.records.map((rec, i) => i) : NumListCast(this.parentViz.dataViz_selectedRows); } // returns all the data records that will be rendered by only returning those records that have been selected by the parent visualization (or all records if there is no parent) @computed get _tableData() { return !this.parentViz ? this._props.records : this._tableDataIds.map(rowId => this._props.records[rowId]); } @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); } @computed get columns() { return this._tableData.length ? Array.from(Object.keys(this._tableData[0])).filter(header => header != '' && header != undefined) : []; } // updates the 'dataViz_selectedRows' and 'dataViz_highlightedRows' fields to no longer include rows that aren't in the table filterSelectedRowsDown = () => { const selected = NumListCast(this._props.layoutDoc.dataViz_selectedRows); this._props.layoutDoc.dataViz_selectedRows = new List(selected.filter(rowId => this._tableDataIds.includes(rowId))); // filters through selected to remove guids that were removed in the incoming data const highlighted = NumListCast(this._props.layoutDoc.dataViz_highlitedRows); this._props.layoutDoc.dataViz_highlitedRows = new List(highlighted.filter(rowId => this._tableDataIds.includes(rowId))); // filters through highlighted to remove guids that were removed in the incoming data }; @computed get viewScale() { return this._props.docView?.()?.screenToViewTransform().Scale || 1; } @computed get rowHeight() { return (this.viewScale * this._tableHeight) / this._tableDataIds.length; } @computed get startID() { return this.rowHeight ? Math.max(Math.floor(this._scrollTop / this.rowHeight) - 1, 0) : 0; } @computed get endID() { return Math.ceil(this.startID + (this._tableContainerHeight * this.viewScale) / (this.rowHeight || 1)); } @action handleScroll = () => { if (!this._props.docView?.()?.ContentDiv?.hidden) { this._scrollTop = this._containerRef?.scrollTop ?? 0; } }; @action tableRowClick = (e: React.MouseEvent, rowId: number) => { const highlited = Cast(this._props.layoutDoc.dataViz_highlitedRows, listSpec('number'), null); const selected = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); if (e.metaKey) { // highlighting a row if (highlited?.includes(rowId)) highlited.splice(highlited.indexOf(rowId), 1); else highlited?.push(rowId); if (!selected?.includes(rowId)) selected?.push(rowId); } else { // selecting a row if (selected?.includes(rowId)) { if (highlited?.includes(rowId)) highlited.splice(highlited.indexOf(rowId), 1); selected.splice(selected.indexOf(rowId), 1); } else selected?.push(rowId); } e.stopPropagation(); this.hasRowsToFilter = (selected.length>0)? true : false; }; columnPointerDown = (e: React.PointerEvent, col: string) => { const downX = e.clientX; const downY = e.clientY; setupMoveUpEvents( {}, e, e => { // dragging off a column to create a brushed DataVizBox const sourceAnchorCreator = () => this._props.docView?.()!.Document!; const targetCreator = (annotationOn: Doc | undefined) => { const embedding = Doc.MakeEmbedding(this._props.docView?.()!.Document!); embedding._dataViz = DataVizView.TABLE; embedding._dataViz_axes = new List([col, col]); embedding._dataViz_parentViz = this._props.Document; embedding.annotationOn = annotationOn; embedding.histogramBarColors = Field.Copy(this._props.layoutDoc.histogramBarColors); embedding.defaultHistogramColor = this._props.layoutDoc.defaultHistogramColor; embedding.pieSliceColors = Field.Copy(this._props.layoutDoc.pieSliceColors); return embedding; }; if (this._props.docView?.() && !Utils.isClick(e.clientX, e.clientY, downX, downY, Date.now())) { DragManager.StartAnchorAnnoDrag(e.target instanceof HTMLElement ? [e.target] : [], new DragManager.AnchorAnnoDragData(this._props.docView()!, sourceAnchorCreator, targetCreator), downX, downY, { dragComplete: e => { if (!e.aborted && e.annoDragData && e.annoDragData.linkSourceDoc && e.annoDragData.dropDocument && e.linkDocument) { e.linkDocument.link_displayLine = true; e.linkDocument.link_matchEmbeddings = true; e.linkDocument.link_displayArrow = true; // e.annoDragData.linkSourceDoc.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this._props.Document; // e.annoDragData.linkSourceDoc.followLinkZoom = false; } }, }); return true; } return false; }, emptyFunction, action(e => { if (e.shiftKey || this.settingTitle){ if (this.settingTitle) this.settingTitle = false; if (this._props.titleCol == col) this._props.titleCol = ""; else this._props.titleCol = col; this._props.selectTitleCol(this._props.titleCol); } else{ const newAxes = this._props.axes; if (newAxes.includes(col)) newAxes.splice(newAxes.indexOf(col), 1); else if (newAxes.length > 2) newAxes[newAxes.length-1] = col; else newAxes.push(col); this._props.selectAxes(newAxes); } }) ); }; /** * These functions handle the filtering popup for when the "filter" button is pressed to select rows */ @undoBatch filter = (e: any) => { var start: any; var end: any; if (this.filteringType=="Range"){ start = (this.filteringVal[0] as Number)? Number(this.filteringVal[0]): this.filteringVal[0] end = (this.filteringVal[1] as Number)? Number(this.filteringVal[1]): this.filteringVal[0] } this._tableDataIds.forEach(rowID => { if (this.filteringType=="Value"){ if (this._props.records[rowID][this.filteringColumn]==this.filteringVal[0]) { if (!NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowID)) { this.tableRowClick(e, rowID); } } } else { let compare = this._props.records[rowID][this.filteringColumn] if (compare as Number) compare = Number(compare) if (start<=compare && compare<=end){ if (!NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowID)) { this.tableRowClick(e, rowID); } } } }) this.filtering = false; this.filteringColumn = ""; this.filteringVal = ["", ""]; } @action setFilterColumn = (e:any) => { this.filteringColumn = e.currentTarget.value; } @action setFilterType = (e:any) => { this.filteringType = e.currentTarget.value; } changeFilterValue = action((e: React.ChangeEvent) => { this.filteringVal[0] = e.target.value; }); changeFilterRange0 = action((e: React.ChangeEvent) => { this.filteringVal[0] = e.target.value; }); changeFilterRange1 = action((e: React.ChangeEvent) => { this.filteringVal[1] = e.target.value; }); @computed get renderFiltering() { if (this.filteringColumn==="") this.filteringColumn = this.columns[0]; return (
Column:
: {this.filteringType=="Value"? {e.stopPropagation();}} type="text" placeholder="" id="search-input" /> :
{e.stopPropagation();}} type="text" placeholder="" id="search-input" style={{width: this._props.width*.15}} /> to {e.stopPropagation();}} type="text" placeholder="" id="search-input" style={{width: this._props.width*.15}} />
}
) } render() { if (this._tableData.length > 0) { return (
{ if (this._props.layoutDoc && e.key === 'a' && (e.ctrlKey || e.metaKey)) { e.stopPropagation(); this._props.layoutDoc.dataViz_selectedRows = new List(this._tableDataIds); } }}>
{this.filtering? this.renderFiltering : null}
{ this._containerRef = r; if (!this._props.docView?.()?.ContentDiv?.hidden && r) { this._tableContainerHeight = r.getBoundingClientRect().height ?? 0; r.addEventListener( 'wheel', // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this) (e: WheelEvent) => { if (!r.scrollTop && e.deltaY <= 0) e.preventDefault(); e.stopPropagation(); }, { passive: false } ); } })}> { if (!this._props.docView?.()?.ContentDiv?.hidden && r) { this._tableHeight = r?.getBoundingClientRect().height ?? 0; } })}>
{this.columns.map(col => ( ))} {this._tableDataIds .filter((rowId, i) => this.startID - 2 <= i && i <= this.endID + 2) ?.map(rowId => ( this.tableRowClick(e, rowId)} style={{ background: NumListCast(this._props.layoutDoc.dataViz_highlitedRows).includes(rowId) ? 'lightYellow' : NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowId) ? 'lightgrey' : '', }}> {this.columns.map(col => { var colSelected = false; if (this._props.axes.length>2) colSelected = this._props.axes[0]==col || this._props.axes[1]==col || this._props.axes[2]==col; else if (this._props.axes.length>1) colSelected = this._props.axes[0]==col || this._props.axes[1]==col; else if (this._props.axes.length>0) colSelected = this._props.axes[0]==col; if (this._props.titleCol==col) colSelected = true; return ( ); })} ))}
2 && this._props.axes.lastElement() === col) ? 'darkred' : (this._props.axes.lastElement()===col || (this._props.axes.length>2 && this._props.axes[1]==col))? 'darkblue' : undefined, background: this.settingTitle? 'lightgrey' : this._props.axes.slice().reverse().lastElement() === col ? '#E3fbdb' : (this._props.axes.length>2 && this._props.axes.lastElement() === col) ? '#Fbdbdb' : (this._props.axes.lastElement()===col || (this._props.axes.length>2 && this._props.axes[1]==col))? '#c6ebf7' : undefined, fontWeight: 'bolder', border: '3px solid black', }} onPointerDown={e => this.columnPointerDown(e, col)}> {col}
{this._props.records[rowId][col]}
); } else return ( // when it is a brushed table and the incoming table doesn't have any rows selected
Selected rows of data from the incoming DataVizBox to display.
); } }