diff options
Diffstat (limited to 'src/client/views/nodes/DataVizBox')
9 files changed, 1129 insertions, 708 deletions
diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.scss b/src/client/views/nodes/DataVizBox/DataVizBox.scss index 6b5738790..9825d926f 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.scss +++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss @@ -30,8 +30,17 @@ } .liveSchema-checkBox { + margin-left: 10px; margin-bottom: -35px; } + .filterData-checkBox { + margin-left: 10px; + margin-bottom: -10px; + } + + .displaySchemaLive { + margin-bottom: 20px; + } .dataviz-sidebar { position: absolute; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 66a08f13e..4d5f15a3e 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -1,33 +1,37 @@ +/* eslint-disable react/jsx-props-no-spreading */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Checkbox } from '@mui/material'; import { Colors, Toggle, ToggleType, Type } from 'browndash-components'; import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, setupMoveUpEvents } from '../../../../Utils'; +import { returnEmptyString, returnFalse, returnOne, setupMoveUpEvents } from '../../../../ClientUtils'; +import { emptyFunction } from '../../../../Utils'; import { Doc, DocListCast, Field, Opt, StrListCast } from '../../../../fields/Doc'; import { InkTool } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; -import { listSpec } from '../../../../fields/Schema'; import { Cast, CsvCast, DocCast, NumCast, StrCast } from '../../../../fields/Types'; import { CsvField } from '../../../../fields/URLField'; import { TraceMobx } from '../../../../fields/util'; -import { DocUtils, Docs } from '../../../documents/Documents'; -import { DocumentManager } from '../../../util/DocumentManager'; +import { DocUtils } from '../../../documents/DocUtils'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { Docs } from '../../../documents/Documents'; import { UndoManager, undoable } from '../../../util/UndoManager'; -import { ViewBoxAnnotatableComponent, ViewBoxInterface } from '../../DocComponent'; +import { ContextMenu } from '../../ContextMenu'; +import { ViewBoxAnnotatableComponent } from '../../DocComponent'; import { MarqueeAnnotator } from '../../MarqueeAnnotator'; +import { PinProps } from '../../PinFuncs'; import { SidebarAnnos } from '../../SidebarAnnos'; import { AnchorMenu } from '../../pdf/AnchorMenu'; -import { GPTPopup } from '../../pdf/GPTPopup/GPTPopup'; +import { GPTPopup, GPTPopupMode } from '../../pdf/GPTPopup/GPTPopup'; import { DocumentView } from '../DocumentView'; -import { FocusViewOptions, FieldView, FieldViewProps } from '../FieldView'; -import { PinProps } from '../trails'; +import { FieldView, FieldViewProps } from '../FieldView'; +import { FocusViewOptions } from '../FocusViewOptions'; import './DataVizBox.scss'; import { Histogram } from './components/Histogram'; import { LineChart } from './components/LineChart'; import { PieChart } from './components/PieChart'; import { TableBox } from './components/TableBox'; -import { Checkbox } from '@mui/material'; export enum DataVizView { TABLE = 'table', @@ -37,12 +41,13 @@ export enum DataVizView { } @observer -export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() implements ViewBoxInterface { +export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); private _marqueeref = React.createRef<MarqueeAnnotator>(); private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); private _disposers: { [name: string]: IReactionDisposer } = {}; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); + sidebarAddDoc: ((doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean) | undefined; crop: ((region: Doc | undefined, addCrop?: boolean) => Doc | undefined) | undefined; @observable _marqueeing: number[] | undefined = undefined; @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); @@ -62,9 +67,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im setupMoveUpEvents( this, e, - action(e => { + action(moveEv => { MarqueeAnnotator.clearAnnotations(this._savedAnnotations); - this._marqueeref.current?.onInitiateSelection([e.clientX, e.clientY]); + this._marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]); return true; }), returnFalse, @@ -95,7 +100,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im // all CSV records in the dataset (that aren't an empty row) @computed.struct get records() { - var records = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); + const records = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); return records?.filter(record => Object.keys(record).some(key => record[key])) ?? []; } @@ -110,31 +115,36 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im @computed.struct get axes() { return StrListCast(this.layoutDoc._dataViz_axes); } - selectAxes = (axes: string[]) => (this.layoutDoc._dataViz_axes = new List<string>(axes)); + selectAxes = (axes: string[]) => { + this.layoutDoc._dataViz_axes = new List<string>(axes); + }; @computed.struct get titleCol() { return StrCast(this.layoutDoc._dataViz_titleCol); } - selectTitleCol = (titleCol: string) => (this.layoutDoc._dataViz_titleCol = titleCol); + selectTitleCol = (titleCol: string) => { + this.layoutDoc._dataViz_titleCol = titleCol; + }; @action // pinned / linked anchor doc includes selected rows, graph titles, and graph colors restoreView = (data: Doc) => { - const changedView = this.dataVizView !== data.config_dataViz && (this.layoutDoc._dataViz = data.config_dataViz); - const changedAxes = this.axes.join('') !== StrListCast(data.config_dataVizAxes).join('') && (this.layoutDoc._dataViz_axes = new List<string>(StrListCast(data.config_dataVizAxes))); + // const changedView = data.config_dataViz && this.dataVizView !== data.config_dataViz && (this.layoutDoc._dataViz = data.config_dataViz); + // const changedAxes = data.config_dataVizAxes && this.axes.join('') !== StrListCast(data.config_dataVizAxes).join('') && (this.layoutDoc._dataViz_axes = new List<string>(StrListCast(data.config_dataVizAxes))); this.layoutDoc.dataViz_selectedRows = Field.Copy(data.dataViz_selectedRows); this.layoutDoc.dataViz_histogram_barColors = Field.Copy(data.dataViz_histogram_barColors); this.layoutDoc.dataViz_histogram_defaultColor = data.dataViz_histogram_defaultColor; this.layoutDoc.dataViz_pie_sliceColors = Field.Copy(data.dataViz_pie_sliceColors); - Object.keys(this.layoutDoc).map(key => { + Object.keys(this.layoutDoc).forEach(key => { if (key.startsWith('dataViz_histogram_title') || key.startsWith('dataViz_lineChart_title') || key.startsWith('dataViz_pieChart_title')) { this.layoutDoc['_' + key] = data[key]; } }); - const func = () => this._vizRenderer?.restoreView(data); - if (changedView || changedAxes) { - setTimeout(func, 100); - return true; - } - return func() ?? false; + return true; + // const func = () => this._vizRenderer?.restoreView(data); + // if (changedView || changedAxes) { + // setTimeout(func, 100); + // return true; + // } + // return func() ?? false; }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = AnchorMenu.Instance.GetAnchor?.(undefined, addAsAnnotation); @@ -150,7 +160,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im annotationOn: this.Document, // when we clear selection -> we should have it so chartBox getAnchor returns undefined // this is for when we want the whole doc (so when the chartBox getAnchor returns without a marker) - /*put in some options*/ + /* put in some options */ }); anchor.config_dataViz = this.dataVizView; anchor.config_dataVizAxes = this.axes.length ? new List<string>(this.axes) : undefined; @@ -158,24 +168,21 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im anchor.dataViz_histogram_barColors = Field.Copy(this.layoutDoc.dataViz_histogram_barColors); anchor.dataViz_histogram_defaultColor = this.layoutDoc.dataViz_histogram_defaultColor; anchor.dataViz_pie_sliceColors = Field.Copy(this.layoutDoc.dataViz_pie_sliceColors); - Object.keys(this.layoutDoc).map(key => { + Object.keys(this.layoutDoc).forEach(key => { if (key.startsWith('dataViz_histogram_title') || key.startsWith('dataViz_lineChart_title') || key.startsWith('dataViz_pieChart_title')) { anchor[key] = this.layoutDoc[key]; } }); this.addDocument(anchor); - //addAsAnnotation && this.addDocument(anchor); + // addAsAnnotation && this.addDocument(anchor); return anchor; }; createNoteAnnotation = () => { - const createFunc = undoable( - action(() => { - const note = this._sidebarRef.current?.anchorMenuClick(this.getAnchor(false), ['latitude', 'longitude', '-linkedTo']); - }), - 'create note annotation' - ); + const createFunc = undoable(() => { + this._sidebarRef.current?.anchorMenuClick(this.getAnchor(false), ['latitude', 'longitude', '-linkedTo']); + }, 'create note annotation'); if (!this.layoutDoc.layout_showSidebar) { this.toggleSidebar(); setTimeout(createFunc); @@ -192,7 +199,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im this.layoutDoc._width = this.layoutDoc._layout_showSidebar ? NumCast(this.layoutDoc._width) * 1.2 : Math.max(20, NumCast(this.layoutDoc._width) - prevWidth); }; @computed get SidebarShown() { - return this.layoutDoc._layout_showSidebar ? true : false; + return !!this.layoutDoc._layout_showSidebar; } @computed get sidebarHandle() { return ( @@ -206,7 +213,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK, }} onPointerDown={this.sidebarBtnDown}> - <FontAwesomeIcon style={{ color: Colors.WHITE }} icon={'comment-alt'} size="sm" /> + <FontAwesomeIcon style={{ color: Colors.WHITE }} icon="comment-alt" size="sm" /> </div> ); } @@ -218,7 +225,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im setupMoveUpEvents( this, e, - (e, down, delta) => + (moveEv, down, delta) => runInAction(() => { const localDelta = this._props .ScreenToLocalTransform() @@ -246,7 +253,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im options.didMove = true; this.toggleSidebar(); } - return new Promise<Opt<DocumentView>>(res => DocumentManager.Instance.AddViewRenderedCb(doc, dv => res(dv))); + return new Promise<Opt<DocumentView>>(res => { + DocumentView.addViewRenderedCb(doc, dv => res(dv)); + }); }; @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); @@ -266,59 +275,60 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im if (!DataVizBox.dataset.has(CsvCast(this.dataDoc[this.fieldKey]).url.href)) this.fetchData(); this._disposers.datavis = reaction( () => { - if (this.layoutDoc.dataViz_schemaLive==undefined) this.layoutDoc.dataViz_schemaLive = true; + if (this.layoutDoc.dataViz_schemaLive === undefined) this.layoutDoc.dataViz_schemaLive = true; const getFrom = DocCast(this.layoutDoc.dataViz_asSchema); - const keys = Cast(getFrom?.schema_columnKeys, listSpec('string'))?.filter(key => key != 'text'); - if (!keys) return; - const children = DocListCast(getFrom[Doc.LayoutFieldKey(getFrom)]); - var current: { [key: string]: string }[] = []; + if (!getFrom?.schema_columnKeys) return undefined; + const keys = StrListCast(getFrom?.schema_columnKeys).filter(key => key !== 'text'); + const children = DocListCast(getFrom?.[Doc.LayoutFieldKey(getFrom)]); + const current: { [key: string]: string }[] = []; children .filter(child => child) .forEach(child => { const row: { [key: string]: string } = {}; keys.forEach(key => { - var cell = child[key]; - if (cell && (cell as string)) cell = cell.toString().replace(/\,/g, ''); + let cell = child[key]; + if (cell && (cell as string)) cell = cell.toString().replace(/,/g, ''); row[key] = StrCast(cell); }); current.push(row); }); - if (!this.layoutDoc._dataViz_schemaOG){ // makes a copy of the original table for the "live" toggle - let csvRows = []; - csvRows.push(keys.join(',')); - for (let i = 0; i < children.length-1; i++) { - let eachRow = []; - for (let j = 0; j < keys.length; j++) { - var cell = children[i][keys[j]]; - if (cell && (cell as string)) cell = cell.toString().replace(/\,/g, ''); - eachRow.push(cell); - } - csvRows.push(eachRow); + if (!this.layoutDoc._dataViz_schemaOG) { + // makes a copy of the original table for the "live" toggle + const csvRows = []; + csvRows.push(keys.join(',')); + for (let i = 0; i < children.length - 1; i++) { + const eachRow = []; + for (let j = 0; j < keys.length; j++) { + let cell = children[i][keys[j]]; + if (cell && (cell as string)) cell = cell.toString().replace(/,/g, ''); + eachRow.push(cell); } - const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' }); - const options = { x: 0, y: 0, title: 'schemaTable for static dataviz', _width: 300, _height: 100, type: 'text/csv' }; - const file = new File([blob], 'schemaTable for static dataviz', options); - const loading = Docs.Create.LoadingDocument(file, options); - DocUtils.uploadFileToDoc(file, {}, loading); - this.layoutDoc._dataViz_schemaOG = loading; - } - const ogDoc = this.layoutDoc._dataViz_schemaOG as Doc - const ogHref = CsvCast(ogDoc[this.fieldKey])? CsvCast(ogDoc[this.fieldKey]).url.href : undefined; - const href = CsvCast(this.Document[this.fieldKey]).url.href - if (ogHref && !DataVizBox.datasetSchemaOG.has(href)){ // sets original dataset to the var - const lastRow = current.pop(); - DataVizBox.datasetSchemaOG.set(href, current); - current.push(lastRow!); - fetch('/csvData?uri=' + ogHref) - .then(res => res.json().then(action(res => !res.errno && DataVizBox.datasetSchemaOG.set(href, res)))); + csvRows.push(eachRow); } + const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' }); + const options = { x: 0, y: 0, title: 'schemaTable for static dataviz', _width: 300, _height: 100, type: 'text/csv' }; + const file = new File([blob], 'schemaTable for static dataviz', options); + const loading = Docs.Create.LoadingDocument(file, options); + DocUtils.uploadFileToDoc(file, {}, loading); + this.layoutDoc._dataViz_schemaOG = loading; + } + const ogDoc = this.layoutDoc._dataViz_schemaOG as Doc; + const ogHref = CsvCast(ogDoc[this.fieldKey]) ? CsvCast(ogDoc[this.fieldKey]).url.href : undefined; + const { href } = CsvCast(this.Document[this.fieldKey]).url; + if (ogHref && !DataVizBox.datasetSchemaOG.has(href)) { + // sets original dataset to the var + const lastRow = current.pop(); + DataVizBox.datasetSchemaOG.set(href, current); + current.push(lastRow!); + fetch('/csvData?uri=' + ogHref).then(res => res.json().then(action(jsonRes => !jsonRes.errno && DataVizBox.datasetSchemaOG.set(href, jsonRes)))); + } return current; }, current => { if (current) { - const href = CsvCast(this.Document[this.fieldKey]).url.href; - if (this.layoutDoc.dataViz_schemaLive) DataVizBox.dataset.set(href, current); - else DataVizBox.dataset.set(href, DataVizBox.datasetSchemaOG.get(href)!); + const { href } = CsvCast(this.Document[this.fieldKey]).url; + if (this.layoutDoc.dataViz_schemaLive) DataVizBox.dataset.set(href, current); + else DataVizBox.dataset.set(href, DataVizBox.datasetSchemaOG.get(href)!); } }, { fireImmediately: true } @@ -328,8 +338,8 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im fetchData = () => { if (!this.Document.dataViz_asSchema) { DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, []); // assign temporary dataset as a lock to prevent duplicate server requests - fetch('/csvData?uri=' + this.dataUrl?.url.href) // - .then(res => res.json().then(action(res => !res.errno && DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, res)))); + fetch('/csvData?uri=' + (this.dataUrl?.url.href ?? '')) // + .then(res => res.json().then(action(jsonRes => !jsonRes.errno && DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, jsonRes)))); } }; @@ -342,19 +352,21 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im records: this.records, axes: this.axes, titleCol: this.titleCol, - //width: this.SidebarShown? this._props.PanelWidth()*.9/1.2: this._props.PanelWidth() * 0.9, - height: (this._props.PanelHeight() / scale - 32) /* height of 'change view' button */ * 0.9, + // width: this.SidebarShown? this._props.PanelWidth()*.9/1.2: this._props.PanelWidth() * 0.9, + height: (this._props.PanelHeight() / scale - 55) /* height of 'change view' button */ * 0.8, width: ((this._props.PanelWidth() - this.sidebarWidth()) / scale) * 0.9, margin: { top: 10, right: 25, bottom: 75, left: 45 }, }; if (!this.records.length) return 'no data/visualization'; switch (this.dataVizView) { case DataVizView.TABLE: return <TableBox {...sharedProps} docView={this.DocumentView} selectAxes={this.selectAxes} selectTitleCol={this.selectTitleCol}/>; - case DataVizView.LINECHART: return <LineChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} vizBox={this} />; - case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} />; - case DataVizView.PIECHART: return <PieChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} + case DataVizView.LINECHART: return <LineChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} vizBox={this} />; + case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} />; + case DataVizView.PIECHART: return <PieChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} margin={{ top: 10, right: 15, bottom: 15, left: 15 }} />; + default: } // prettier-ignore + return null; } @action @@ -366,10 +378,13 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im this._marqueeing = [e.clientX, e.clientY]; const target = e.target as any; if (e.target && (target.className.includes('endOfContent') || (target.parentElement.className !== 'textLayer' && target.parentElement.parentElement?.className !== 'textLayer'))) { + /* empty */ } else { // if textLayer is hit, then we select text instead of using a marquee so clear out the marquee. setTimeout( - action(() => (this._marqueeing = undefined)), + action(() => { + this._marqueeing = undefined; + }), 100 ); // bcz: hack .. anchor menu is setup within MarqueeAnnotator so we need to at least create the marqueeAnnotator even though we aren't using it. @@ -397,16 +412,81 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im GPTPopup.Instance.addDoc = this.sidebarAddDocument; }; + // represents whether or not a data viz box created from a schema table displays live updates to the canvas @action changeLiveSchemaCheckbox = () => { - this.layoutDoc.dataViz_schemaLive = !this.layoutDoc.dataViz_schemaLive - } + this.layoutDoc.dataViz_schemaLive = !this.layoutDoc.dataViz_schemaLive; + }; + + // represents whether or not clicking on a peice of data in the visualization + // (i.e. a data point in a linechart, a bar on a histogram, or a slice of a pie chart) + // filters the data onto a new data viz doc created off of this one + @action + changeFilteringCheckbox = () => { + this.layoutDoc.dataViz_filterSelection = !this.layoutDoc.dataViz_filterSelection; + }; + + specificContextMenu = (): void => { + const cm = ContextMenu.Instance; + const options = cm.findByDescription('Options...'); + const optionItems = options && 'subitems' in options ? options.subitems : []; + optionItems.push({ description: `Analyze with AI`, event: () => this.askGPT(), icon: 'lightbulb' }); + !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); + }; + + askGPT = action(async () => { + GPTPopup.Instance.setSidebarId('data_sidebar'); + GPTPopup.Instance.addDoc = this.sidebarAddDocument; + GPTPopup.Instance.createFilteredDoc = this.createFilteredDoc; + GPTPopup.Instance.setDataJson(''); + GPTPopup.Instance.setMode(GPTPopupMode.DATA); + const data = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); + GPTPopup.Instance.setDataJson(JSON.stringify(data)); + GPTPopup.Instance.generateDataAnalysis(); + }); + + /** + * creates a new dataviz document filter from this one + * it appears to the right of this document, with the + * parameters passed in being used to create an initial display + */ + createFilteredDoc = (axes?: any) => { + const embedding = Doc.MakeEmbedding(this.Document!); + embedding._layout_showSidebar = false; + embedding._dataViz = DataVizView.LINECHART; + embedding._dataViz_axes = new List<string>(axes); + embedding._dataViz_parentViz = this.Document; + embedding.histogramBarColors = Field.Copy(this.layoutDoc.histogramBarColors); + embedding.defaultHistogramColor = this.layoutDoc.defaultHistogramColor; + embedding.pieSliceColors = Field.Copy(this.layoutDoc.pieSliceColors); + embedding._layout_showSidebar = false; + embedding.width = NumCast(this.layoutDoc._width) - this.sidebarWidth(); + embedding._layout_sidebarWidthPercent = '0%'; + this._props.addDocument?.(embedding); + embedding._dataViz_axes = new List<string>(axes); + this.layoutDoc.dataViz_selectedRows = new List<number>(this.records.map((rec, i) => i)); + embedding.x = Number(embedding.x) + Number(this.Document.width); + + return true; + }; render() { const scale = this._props.NativeDimScaling?.() || 1; + const toggleBtn = (name: string, type: DataVizView) => ( + <Toggle + text={name} + toggleType={ToggleType.BUTTON} + type={Type.SEC} + color="black" + onClick={() => { + this.layoutDoc._dataViz = type; + }} + toggleStatus={this.layoutDoc._dataViz === type} + /> + ); return !this.records.length ? ( // displays how to get data into the DataVizBox if its empty - <div className="start-message">To create a DataViz box, either import / drag a CSV file into your canvas or copy a data table and use the command 'ctrl + p' to bring the data table to your canvas.</div> + <div className="start-message">To create a DataViz box, either import / drag a CSV file into your canvas or copy a data table and use the command (ctrl + p) to bring the data table to your canvas.</div> ) : ( <div className="dataViz-box" @@ -418,20 +498,29 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im transform: `scale(${scale})`, position: 'absolute', }} + onContextMenu={this.specificContextMenu} onWheel={e => e.stopPropagation()} ref={this._mainCont}> <div className="datatype-button"> - <Toggle text={' TABLE '} toggleType={ToggleType.BUTTON} type={Type.SEC} color={'black'} onClick={e => (this.layoutDoc._dataViz = DataVizView.TABLE)} toggleStatus={this.layoutDoc._dataViz === DataVizView.TABLE} /> - <Toggle text={'LINECHART'} toggleType={ToggleType.BUTTON} type={Type.SEC} color={'black'} onClick={e => (this.layoutDoc._dataViz = DataVizView.LINECHART)} toggleStatus={this.layoutDoc._dataViz === DataVizView.LINECHART} /> - <Toggle text={'HISTOGRAM'} toggleType={ToggleType.BUTTON} type={Type.SEC} color={'black'} onClick={e => (this.layoutDoc._dataViz = DataVizView.HISTOGRAM)} toggleStatus={this.layoutDoc._dataViz === DataVizView.HISTOGRAM} /> - <Toggle text={'PIE CHART'} toggleType={ToggleType.BUTTON} type={Type.SEC} color={'black'} onClick={e => (this.layoutDoc._dataViz = DataVizView.PIECHART)} toggleStatus={this.layoutDoc._dataViz == -DataVizView.PIECHART} /> + {toggleBtn(' TABLE ', DataVizView.TABLE)} + {toggleBtn('LINECHART', DataVizView.LINECHART)} + {toggleBtn('HISTOGRAM', DataVizView.HISTOGRAM)} + {toggleBtn('PIE CHART', DataVizView.PIECHART)} </div> - {(this.layoutDoc && this.layoutDoc.dataViz_asSchema)?( - <div className={'liveSchema-checkBox'} style={{ width: this._props.width }}> - <Checkbox color="primary" onChange={this.changeLiveSchemaCheckbox} checked={this.layoutDoc.dataViz_schemaLive as boolean} /> - Display Live Updates to Canvas - </div> + {this.layoutDoc && this.layoutDoc.dataViz_asSchema ? ( + <div className="displaySchemaLive"> + <div className="liveSchema-checkBox" style={{ width: this._props.width }}> + <Checkbox color="primary" onChange={this.changeLiveSchemaCheckbox} checked={this.layoutDoc.dataViz_schemaLive as boolean} /> + Display Live Updates to Canvas + </div> + </div> + ) : null} + {this.layoutDoc._dataViz !== DataVizView.TABLE ? ( + <div className="filterData-checkBox"> + <Checkbox color="primary" onChange={this.changeFilteringCheckbox} checked={this.layoutDoc.dataViz_filterSelection as boolean} /> + Select data to filter + </div> ) : null} {this.renderVizView} @@ -444,7 +533,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im Document={this.Document} layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} - usePanelWidth={true} + usePanelWidth showSidebar={this.SidebarShown} nativeWidth={NumCast(this.layoutDoc._nativeWidth)} whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} @@ -478,3 +567,23 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im ); } } +Docs.Prototypes.TemplateMap.set(DocumentType.DATAVIZ, { + layout: { view: DataVizBox, dataField: 'data' }, + options: { + acl: '', + dataViz_title: '', + dataViz_line: '', + dataViz_pie: '', + dataViz_histogram: '', + dataViz: 'table', + _layout_fitWidth: true, + _layout_reflowHorizontal: true, + _layout_reflowVertical: true, + _layout_nativeDimEditable: true, + }, +}); + +Docs.Prototypes.TemplateMap.set(DocumentType.DATAVIZ, { + layout: { view: DataVizBox, dataField: 'data' }, + options: { dataViz_title: '', dataViz_line: '', dataViz_pie: '', dataViz_histogram: '', dataViz: 'table', _layout_fitWidth: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true, _layout_nativeDimEditable: true }, +}); diff --git a/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.tsx b/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.tsx index 24023077f..60bc8df18 100644 --- a/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.tsx +++ b/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.tsx @@ -1,9 +1,12 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/alt-text */ import { IconButton } from 'browndash-components'; import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { CgClose } from 'react-icons/cg'; -import { Utils, emptyFunction, setupMoveUpEvents } from '../../../../Utils'; +import { ClientUtils, setupMoveUpEvents } from '../../../../ClientUtils'; +import { emptyFunction } from '../../../../Utils'; import { Doc } from '../../../../fields/Doc'; import { StrCast } from '../../../../fields/Types'; import { DragManager } from '../../../util/DragManager'; @@ -14,56 +17,52 @@ interface SchemaCSVPopUpProps {} @observer export class SchemaCSVPopUp extends React.Component<SchemaCSVPopUpProps> { + // eslint-disable-next-line no-use-before-define static Instance: SchemaCSVPopUp; - @observable - public dataVizDoc: Doc | undefined = undefined; + @observable public dataVizDoc: Doc | undefined = undefined; + @observable public view: DocumentView | undefined = undefined; + @observable public target: Doc | undefined = undefined; + @observable public visible: boolean = false; + + constructor(props: SchemaCSVPopUpProps) { + super(props); + makeObservable(this); + SchemaCSVPopUp.Instance = this; + } + @action public setDataVizDoc = (doc: Doc) => { this.dataVizDoc = doc; }; - @observable - public view: DocumentView | undefined = undefined; @action public setView = (docView: DocumentView) => { this.view = docView; }; - @observable - public target: Doc | undefined = undefined; @action public setTarget = (doc: Doc) => { this.target = doc; }; - @observable - public visible: boolean = false; @action public setVisible = (vis: boolean) => { this.visible = vis; }; - constructor(props: SchemaCSVPopUpProps) { - super(props); - makeObservable(this); - SchemaCSVPopUp.Instance = this; - } - - dataBox = () => { - return ( - <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> - {this.heading('Schema Table as Data Visualization Doc')} - <div className="image-content-wrapper"> - <div className="img-wrapper"> - <div className="img-container" onPointerDown={e => this.drag(e)}> - <img width={150} height={150} src={'/assets/dataVizBox.png'} /> - </div> + dataBox = () => ( + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> + {this.heading('Schema Table as Data Visualization Doc')} + <div className="image-content-wrapper"> + <div className="img-wrapper"> + <div className="img-container" onPointerDown={e => this.drag(e)}> + <img width={150} height={150} src="/assets/dataVizBox.png" /> </div> </div> </div> - ); - }; + </div> + ); heading = (headingText: string) => ( <div className="summary-heading"> @@ -78,24 +77,22 @@ export class SchemaCSVPopUp extends React.Component<SchemaCSVPopUpProps> { setupMoveUpEvents( {}, e, - e => { + moveEv => { const sourceAnchorCreator = () => this.dataVizDoc!; - const targetCreator = (annotationOn: Doc | undefined) => { + const targetCreator = () => { const embedding = Doc.MakeEmbedding(this.dataVizDoc!); return embedding; }; - if (this.view && sourceAnchorCreator && !Utils.isClick(e.clientX, e.clientY, downX, downY, Date.now())) { - DragManager.StartAnchorAnnoDrag(e.target instanceof HTMLElement ? [e.target] : [], new DragManager.AnchorAnnoDragData(this.view, sourceAnchorCreator, targetCreator), downX, downY, { - dragComplete: e => { - this.setVisible(false); - }, + if (this.view && sourceAnchorCreator && !ClientUtils.isClick(moveEv.clientX, moveEv.clientY, downX, downY, Date.now())) { + DragManager.StartAnchorAnnoDrag(moveEv.target instanceof HTMLElement ? [moveEv.target] : [], new DragManager.AnchorAnnoDragData(this.view, sourceAnchorCreator, targetCreator), downX, downY, { + dragComplete: () => this.setVisible(false), }); return true; } return false; }, emptyFunction, - action(e => {}) + action(() => {}) ); }; diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index 41ce637ac..0eb27b65b 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -15,18 +15,12 @@ font-size: larger; display: flex; flex-direction: row; - margin-top: -20px; - margin-bottom: -20px; + margin-top: -35px; } .asHistogram-checkBox { - align-items: left; - align-self: left; - align-content: left; - justify-content: flex-end; - float: left; - left: 0; - position: relative; - margin-bottom: -35px; + margin-left: 10px; + margin-bottom: -10px; + margin-top: -20px; } .selected-data { align-items: center; @@ -120,11 +114,62 @@ } } } -.selectAll-buttons { - display: flex; - flex-direction: row; - justify-content: flex-end; +.tableBox-selectButtons { margin-top: 5px; - margin-right: 10px; - float: right; + margin-left: 25px; + display: inline-block; + padding: 2px; + .tableBox-selectTitle { + display: inline-flex; + flex-direction: row; + } + .tableBox-filtering { + display: flex; + flex-direction: row; + float: right; + margin-right: 10px; + .tableBox-filterAll { + min-width: 75px; + } + } +} + +.tableBox-filterPopup { + background: $light-gray; + position: absolute; + min-width: 235px; + top: 60px; + display: flex; + flex-direction: column; + align-items: flex-start; + z-index: 2; + padding: 7px; + border-radius: 5px; + margin: 3px; + .tableBox-filterPopup-selectColumn { + margin-top: 5px; + flex-direction: row; + .tableBox-filterPopup-selectColumn-each { + margin-left: 25px; + border-radius: 3px; + background: $light-gray; + } + } + .tableBox-filterPopup-setValue { + margin-top: 5px; + display: flex; + flex-direction: row; + .tableBox-filterPopup-setValue-each { + margin-right: 5px; + border-radius: 3px; + background: $light-gray; + } + .tableBox-filterPopup-setValue-input { + margin: 5px; + } + } + .tableBox-filterPopup-setFilter { + margin-top: 5px; + align-self: center; + } } diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx index 6672603f3..14d7e9bf6 100644 --- a/src/client/views/nodes/DataVizBox/components/Histogram.tsx +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -1,7 +1,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ColorPicker, EditableText, IconButton, Size, Type } from 'browndash-components'; import * as d3 from 'd3'; -import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; +import { IReactionDisposer, action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { FaFillDrip } from 'react-icons/fa'; @@ -12,7 +12,7 @@ import { Cast, DocCast, StrCast } from '../../../../../fields/Types'; import { Docs } from '../../../../documents/Documents'; import { undoable } from '../../../../util/UndoManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; -import { PinProps, PresBox } from '../../trails'; +import { PinProps, PinDocView } from '../../../PinFuncs'; import { scaleCreatorNumerical, yAxisCreator } from '../utils/D3Utils'; import './Chart.scss'; @@ -37,14 +37,14 @@ export interface HistogramProps { @observer export class Histogram extends ObservableReactComponent<HistogramProps> { private _disposers: { [key: string]: IReactionDisposer } = {}; - private _histogramRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _histogramRef: HTMLDivElement | null = null; private _histogramSvg: d3.Selection<SVGGElement, unknown, null, undefined> | undefined; private numericalXData: boolean = false; // whether the data is organized by numbers rather than categoreis private numericalYData: boolean = false; // whether the y axis is controlled by provided data rather than frequency private maxBins = 15; // maximum number of bins that is readable on a normal sized doc @observable _currSelected: any | undefined = undefined; // Object of selected bar - private curBarSelected: any = undefined; // histogram bin of selected bar - private selectedData: any = undefined; // Selection of selected bar + private curBarSelected: any = undefined; // histogram bin of selected bar for when just one bar is selected + private selectedData: any[] = []; // array of selected bars private hoverOverData: any = undefined; // Selection of bar being hovered over constructor(props: any) { @@ -63,14 +63,13 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { @computed get _histogramData() { if (this._props.axes.length < 1) return []; if (this._props.axes.length < 2) { - var ax0 = this._props.axes[0]; - if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])){ + const ax0 = this._props.axes[0]; + if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])) { this.numericalXData = true; } return this._tableData.map(record => ({ [ax0]: record[this._props.axes[0]] })); } - var ax0 = this._props.axes[0]; - var ax1 = this._props.axes[1]; + const [ax0, ax1] = this._props.axes; if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])) { this.numericalXData = true; } @@ -81,11 +80,11 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { } @computed get defaultGraphTitle() { - var ax0 = this._props.axes[0]; - var ax1 = this._props.axes.length > 1 ? this._props.axes[1] : undefined; + const [ax0, ax1] = this._props.axes; if (this._props.axes.length < 2 || !ax1 || !/\d/.test(this._props.records[0][ax1]) || !this.numericalYData) { return ax0 + ' Histogram'; - } else return ax0 + ' by ' + ax1 + ' Histogram'; + } + return ax0 + ' by ' + ax1 + ' Histogram'; } @computed get parentViz() { @@ -104,21 +103,30 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount() { - this._disposers.chartData = reaction( - () => ({ dataSet: this._histogramData, w: this.width, h: this.height }), - ({ dataSet, w, h }) => dataSet!.length > 0 && this.drawChart(dataSet, w, h), - { fireImmediately: true } - ); + // restore selected bars + const svg = this._histogramSvg; + if (svg) { + const selectedDataBars = StrListCast(this._props.layoutDoc.dataViz_histogram_selectedData); + svg.selectAll('rect').attr('class', (d: any) => { + let selected = false; + selectedDataBars.forEach(eachSelectedData => { + if (d[0] === eachSelectedData) selected = true; + }); + if (selected) { + this.selectedData.push(d); + return 'histogram-bar hover'; + } + return 'histogram-bar'; + }); + } } - @action - restoreView = (data: Doc) => {}; // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc) getAnchor = (pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ title: 'histogram doc selection' + this._currSelected, }); - PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); + PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); return anchor; }; @@ -132,68 +140,102 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { // cleans data by converting numerical data to numbers and taking out empty cells data = (dataSet: any) => { - var validData = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key]))); + const validData = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || isNaN(d[key] as any))); const field = dataSet[0] ? Object.keys(dataSet[0])[0] : undefined; return !field ? [] : validData.map((d: { [x: string]: any }) => !this.numericalXData // ? d[field] - : +d[field!].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') + : +d[field!].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') ); }; // outlines the bar selected / hovered over highlightSelectedBar = (changeSelectedVariables: boolean, svg: any, eachRectWidth: any, pointerX: any, xAxisTitle: any, yAxisTitle: any, histDataSet: any) => { - var sameAsCurrent: boolean; - var barCounter = -1; + let barCounter = -1; const selected = svg.selectAll('.histogram-bar').filter((d: any) => { barCounter++; // uses the order of bars and width of each bar to find which one the pointer is over - if (barCounter * eachRectWidth <= pointerX && pointerX <= (barCounter + 1) * eachRectWidth) { - var showSelected = this.numericalYData - ? this._histogramData.filter((data: { [x: string]: any }) => StrCast(data[xAxisTitle]).replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') == d[0])[0] - : histDataSet.filter((data: { [x: string]: any }) => data[xAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') == d[0])[0]; + if (d.length && barCounter * eachRectWidth <= pointerX && pointerX <= (barCounter + 1) * eachRectWidth) { + let showSelected = this.numericalYData + ? this._histogramData.filter((data: { [x: string]: any }) => StrCast(data[xAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') == d[0])[0] + : histDataSet.filter((data: { [x: string]: any }) => data[xAxisTitle].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') == d[0])[0]; if (this.numericalXData) { // calculating frequency - if (d[0] && d[1] && d[0] != d[1]) { + if (d[0] && d[1] && d[0] !== d[1]) { showSelected = { [xAxisTitle]: d3.min(d) + ' to ' + d3.max(d), frequency: d.length }; } else if (!this.numericalYData) showSelected = { [xAxisTitle]: showSelected[xAxisTitle], frequency: d.length }; } if (changeSelectedVariables) { // for when a bar is selected - not just hovered over - sameAsCurrent = this._currSelected ? showSelected[xAxisTitle] == this._currSelected![xAxisTitle] && showSelected[yAxisTitle] == this._currSelected![yAxisTitle] : false; - this._currSelected = sameAsCurrent ? undefined : showSelected; - this.selectedData = sameAsCurrent ? undefined : d; + let sameAsAny = false; + const selectedDataBars = Cast(this._props.layoutDoc.dataViz_histogram_selectedData, listSpec('number'), null); + this.selectedData.forEach(eachData => { + if (!sameAsAny) { + let match = true; + Object.keys(d).forEach(key => { + if (d[key] !== eachData[key]) match = false; + }); + if (match) { + sameAsAny = true; + const index = this.selectedData.indexOf(eachData); + this.selectedData.splice(index, 1); + selectedDataBars.splice(index, 1); + this._currSelected = undefined; + } + } + }); + if (!sameAsAny) { + this.selectedData.push(d); + selectedDataBars.push(d[0]); + this._currSelected = this.selectedData.length > 1 ? undefined : showSelected; + } + + // for filtering child dataviz docs + if (this._props.layoutDoc.dataViz_filterSelection) { + const selectedRows = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); + this._tableDataIds.forEach(rowID => { + let match = false; + for (let i = 0; i < d.length; i++) { + console.log('Compare: ' + this._props.records[rowID][xAxisTitle].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') + ' = ' + d[i]); + if (this._props.records[rowID][xAxisTitle].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') == d[i]) match = true; + } + if (match && !selectedRows?.includes(rowID)) + selectedRows?.push(rowID); // adding to filtered rows + else if (match && sameAsAny) selectedRows.splice(selectedRows.indexOf(rowID), 1); // removing from filtered rows + }); + } } else this.hoverOverData = d; return true; } return false; }); if (changeSelectedVariables) { - if (sameAsCurrent!) this.curBarSelected = undefined; - else this.curBarSelected = selected; + if (this._currSelected) this.curBarSelected = selected; + else this.curBarSelected = undefined; } }; // draws the histogram drawChart = (dataSet: any, width: number, height: number) => { - d3.select(this._histogramRef.current).select('svg').remove(); - d3.select(this._histogramRef.current).select('.tooltip').remove(); + if (dataSet?.length <= 0) return; + d3.select(this._histogramRef).select('svg').remove(); + d3.select(this._histogramRef).select('.tooltip').remove(); const data = this.data(dataSet); const xAxisTitle = Object.keys(dataSet[0])[0]; const yAxisTitle = this.numericalYData ? Object.keys(dataSet[0])[1] : 'frequency'; const uniqueArr: unknown[] = [...new Set(data)]; - var numBins = this.numericalXData && Number.isInteger(data[0]) ? this.rangeVals.xMax! - this.rangeVals.xMin! : uniqueArr.length; - var translateXAxis = !this.numericalXData || numBins < this.maxBins ? width / (numBins + 1) / 2 : 0; + let numBins = this.numericalXData && Number.isInteger(data[0]) ? this.rangeVals.xMax! - this.rangeVals.xMin! : uniqueArr.length; + let translateXAxis = !this.numericalXData || numBins < this.maxBins ? width / (numBins + 1) / 2 : 0; if (numBins > this.maxBins) numBins = this.maxBins; const startingPoint = this.numericalXData ? this.rangeVals.xMin! : 0; const endingPoint = this.numericalXData ? this.rangeVals.xMax! : numBins; // converts data into Objects - var histDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key]))); + let histDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || isNaN(d[key] as any))); if (!this.numericalXData) { - var histStringDataSet: { [x: string]: unknown }[] = []; + const histStringDataSet: { [x: string]: unknown }[] = []; if (this.numericalYData) { for (let i = 0; i < dataSet.length; i++) { histStringDataSet.push({ [yAxisTitle]: dataSet[i][yAxisTitle], [xAxisTitle]: dataSet[i][xAxisTitle] }); @@ -203,7 +245,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { histStringDataSet.push({ [yAxisTitle]: 0, [xAxisTitle]: uniqueArr[i] }); } for (let i = 0; i < data.length; i++) { - let barData = histStringDataSet.filter(each => each[xAxisTitle] == data[i]); + const barData = histStringDataSet.filter(each => each[xAxisTitle] == data[i]); histStringDataSet.filter(each => each[xAxisTitle] == data[i])[0][yAxisTitle] = Number(barData[0][yAxisTitle]) + 1; } } @@ -211,31 +253,29 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { } // initial graph and binning data for histogram - var svg = (this._histogramSvg = d3 - .select(this._histogramRef.current) + const svg = (this._histogramSvg = d3 + .select(this._histogramRef) .append('svg') .attr('class', 'graph') .attr('width', width + this._props.margin.right + this._props.margin.left) .attr('height', height + this._props.margin.top + this._props.margin.bottom) .append('g') .attr('transform', 'translate(' + this._props.margin.left + ',' + this._props.margin.top + ')')); - var x = d3 + let x = d3 .scaleLinear() .domain(this.numericalXData ? [startingPoint!, endingPoint!] : [0, numBins]) .range([0, width]); - var histogram = d3 + const histogram = d3 .histogram() - .value(function (d) { - return d; - }) + .value(d => d) .domain([startingPoint!, endingPoint!]) .thresholds(x.ticks(numBins)); - var bins = histogram(data); - var eachRectWidth = width / bins.length; - var graphStartingPoint = bins[0].x1 && bins[1] ? bins[0].x1! - (bins[1].x1! - bins[1].x0!) : 0; + const bins = histogram(data); + let eachRectWidth = width / bins.length; + const graphStartingPoint = bins[0].x1 && bins[1] ? bins[0].x1! - (bins[1].x1! - bins[1].x0!) : 0; bins[0].x0 = graphStartingPoint; x = x.domain([graphStartingPoint, endingPoint]).range([0, Number.isInteger(this.rangeVals.xMin!) ? width - eachRectWidth : width]); - var xAxis; + let xAxis; // more calculations based on bins // x-axis @@ -244,7 +284,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { // uniqueArr.sort() histDataSet.sort(); for (let i = 0; i < data.length; i++) { - var index = 0; + let index = 0; for (let j = 0; j < uniqueArr.length; j++) { if (uniqueArr[j] == data[i]) { index = j; @@ -254,7 +294,9 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { } bins.pop(); eachRectWidth = width / bins.length; - bins.forEach(d => (d.x0 = d.x0!)); + bins.forEach(d => { + d.x0 = d.x0!; + }); xAxis = d3 .axisBottom(x) .ticks(bins.length > 1 ? bins.length - 1 : 1) @@ -264,12 +306,12 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { x.domain([0, bins.length - 1]); translateXAxis = eachRectWidth / 2; } else { - var allSame = true; - for (var i = 0; i < bins.length; i++) { + let allSame = true; + for (let i = 0; i < bins.length; i++) { if (bins[i] && bins[i][0]) { - var compare = bins[i][0]; + const compare = bins[i][0]; for (let j = 1; j < bins[i].length; j++) { - if (bins[i][j] != compare) allSame = false; + if (bins[i][j] !== compare) allSame = false; } } } @@ -278,8 +320,8 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { eachRectWidth = width / bins.length; } else { eachRectWidth = width / (bins.length + 1); - var tickDiff = bins.length >= 2 ? bins[bins.length - 2].x1! - bins[bins.length - 2].x0! : 0; - var curDomain = x.domain(); + const tickDiff = bins.length >= 2 ? bins[bins.length - 2].x1! - bins[bins.length - 2].x0! : 0; + const curDomain = x.domain(); x.domain([curDomain[0], curDomain[0] + tickDiff * bins.length]); } @@ -287,16 +329,13 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { x.range([0, width - eachRectWidth]); } // y-axis - const maxFrequency = this.numericalYData - ? d3.max(histDataSet, function (d: any) { - return d[yAxisTitle] ? Number(d[yAxisTitle]!.replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')) : 0; - }) - : d3.max(bins, function (d) { - return d.length; - }); - var y = d3.scaleLinear().range([height, 0]); + const maxFrequency = this.numericalYData ? + d3.max(histDataSet, (d: any) => (d[yAxisTitle] ? Number(d[yAxisTitle]!.replace(/\$/g, '') + .replace(/%/g, '').replace(/</g, '')) : 0)) : + d3.max(bins, d => d.length); // prettier-ignore + const y = d3.scaleLinear().range([height, 0]); y.domain([0, +maxFrequency!]); - var yAxis = d3.axisLeft(y).ticks(maxFrequency!); + const yAxis = d3.axisLeft(y).ticks(maxFrequency!); if (this.numericalYData) { const yScale = scaleCreatorNumerical(0, Number(maxFrequency), height, 0); yAxisCreator(svg.append('g'), width, yScale); @@ -311,17 +350,23 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { const onPointClick = action((e: any) => this.highlightSelectedBar(true, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet)); const onHover = action((e: any) => { this.highlightSelectedBar(false, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet); + // eslint-disable-next-line no-use-before-define updateHighlights(); }); - const mouseOut = action((e: any) => { + const mouseOut = action(() => { this.hoverOverData = undefined; + // eslint-disable-next-line no-use-before-define updateHighlights(); }); const updateHighlights = () => { const hoverOverBar = this.hoverOverData; - const selectedData = this.selectedData; - svg.selectAll('rect').attr('class', function (d: any) { - return (hoverOverBar && hoverOverBar[0] == d[0]) || (selectedData && selectedData[0] == d[0]) ? 'histogram-bar hover' : 'histogram-bar'; + const { selectedData } = this; + svg.selectAll('rect').attr('class', (d: any) => { + let selected = false; + selectedData.forEach(eachSelectedData => { + if (d[0] === eachSelectedData[0]) selected = true; + }); + return (hoverOverBar && hoverOverBar[0] == d[0]) || selected ? 'histogram-bar hover' : 'histogram-bar'; }); }; svg.on('click', onPointClick).on('mouseover', onHover).on('mouseout', mouseOut); @@ -332,7 +377,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { .style('text-anchor', 'middle') .text(xAxisTitle); svg.append('text') - .attr('transform', 'rotate(-90)' + ' ' + 'translate( 0, ' + -10 + ')') + .attr('transform', 'rotate(-90) translate( 0, ' + -10 + ')') .attr('x', -(height / 2)) .attr('y', -20) .style('text-anchor', 'middle') @@ -340,7 +385,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { d3.format('.0f'); // draw bars - var selected = this.selectedData; + const selected = this.selectedData; svg.selectAll('rect') .data(bins) .enter() @@ -348,49 +393,34 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { .attr( 'transform', this.numericalYData - ? function (d) { - const eachData = histDataSet.filter((data: { [x: string]: number }) => { - return data[xAxisTitle] == d[0]; - }); - const length = eachData.length ? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') : 0; - return 'translate(' + x(d.x0!) + ',' + y(length) + ')'; - } - : function (d) { - return 'translate(' + x(d.x0!) + ',' + y(d.length) + ')'; + ? d => { + const eachData = histDataSet.filter((hData: { [x: string]: number }) => hData[xAxisTitle] == d[0]); + const length = eachData.length ? StrCast(eachData[0][yAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') : 0; + return 'translate(' + x(d.x0!) + ',' + y(Number(length)) + ')'; } + : d => 'translate(' + x(d.x0!) + ',' + y(d.length) + ')' ) .attr( 'height', this.numericalYData - ? function (d) { - const eachData = histDataSet.filter((data: { [x: string]: number }) => { - return data[xAxisTitle] == d[0]; - }); - const length = eachData.length ? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') : 0; - return height - y(length); - } - : function (d) { - return height - y(d.length); + ? d => { + const eachData = histDataSet.filter((hData: { [x: string]: number }) => hData[xAxisTitle] == d[0]); + const length = eachData.length ? StrCast(eachData[0][yAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/</g, '') : 0; + return height - y(Number(length)); } + : d => height - y(d.length) ) .attr('width', eachRectWidth) - .attr( - 'class', - selected - ? function (d) { - return selected && selected[0] === d[0] ? 'histogram-bar hover' : 'histogram-bar'; - } - : function (d) { - return 'histogram-bar'; - } - ) + .attr('class', selected ? d => (selected && selected[0] == d[0] ? 'histogram-bar hover' : 'histogram-bar') : () => 'histogram-bar') .attr('fill', d => { - var barColor; + let barColor; const barColors = StrListCast(this._props.layoutDoc.dataViz_histogram_barColors).map(each => each.split('::')); barColors.forEach(each => { + // eslint-disable-next-line prefer-destructuring if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1]; else { const range = StrCast(each[0]).split(' to '); + // eslint-disable-next-line prefer-destructuring if (Number(range[0]) <= d[0] && d[0] <= Number(range[1])) barColor = each[1]; } }); @@ -400,7 +430,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { @action changeSelectedColor = (color: string) => { this.curBarSelected.attr('fill', color); - const barName = StrCast(this._currSelected[this._props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')); + const barName = StrCast(this._currSelected[this._props.axes[0]].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '')); const barColors = Cast(this._props.layoutDoc.dataViz_histogram_barColors, listSpec('string'), null); barColors.forEach(each => each.split('::')[0] === barName && barColors.splice(barColors.indexOf(each), 1)); @@ -409,65 +439,73 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { @action eraseSelectedColor = () => { this.curBarSelected.attr('fill', this._props.layoutDoc.dataViz_histogram_defaultColor); - const barName = StrCast(this._currSelected[this._props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')); + const barName = StrCast(this._currSelected[this._props.axes[0]].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '')); const barColors = Cast(this._props.layoutDoc.dataViz_histogram_barColors, listSpec('string'), null); barColors.forEach(each => each.split('::')[0] === barName && barColors.splice(barColors.indexOf(each), 1)); }; - updateBarColors = () => { - var svg = this._histogramSvg; - if (svg) + // reloads the bar colors and selected bars + updateSavedUI = () => { + const svg = this._histogramSvg; + if (svg) { + // bar color svg.selectAll('rect').attr('fill', (d: any) => { - var barColor; + let barColor; const barColors = StrListCast(this._props.layoutDoc.dataViz_histogram_barColors).map(each => each.split('::')); barColors.forEach(each => { + // eslint-disable-next-line prefer-destructuring if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1]; else { const range = StrCast(each[0]).split(' to '); + // eslint-disable-next-line prefer-destructuring if (Number(range[0]) <= d[0] && d[0] <= Number(range[1])) barColor = each[1]; } }); return barColor ? StrCast(barColor) : StrCast(this._props.layoutDoc.dataViz_histogram_defaultColor); }); + } }; render() { - this.updateBarColors(); + this.updateSavedUI(); this._histogramData; - var curSelectedBarName = ''; - var titleAccessor: any = 'dataViz_histogram_title'; - if (this._props.axes.length == 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; - else if (this._props.axes.length > 0) titleAccessor = titleAccessor + this._props.axes[0]; + let curSelectedBarName = ''; + let titleAccessor: any = 'dataViz_histogram_title'; + if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; + else if (this._props.axes.length > 0) titleAccessor += this._props.axes[0]; if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; if (!this._props.layoutDoc.dataViz_histogram_defaultColor) this._props.layoutDoc.dataViz_histogram_defaultColor = '#69b3a2'; if (!this._props.layoutDoc.dataViz_histogram_barColors) this._props.layoutDoc.dataViz_histogram_barColors = new List<string>(); - var selected = 'none'; + if (!this._props.layoutDoc.dataViz_histogram_selectedData) this._props.layoutDoc.dataViz_histogram_selectedData = new List<string>(); + let selected = 'none'; if (this._currSelected) { - curSelectedBarName = StrCast(this._currSelected![this._props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')); + curSelectedBarName = StrCast(this._currSelected![this._props.axes[0]].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '')); selected = '{ '; - Object.keys(this._currSelected).forEach(key => + Object.keys(this._currSelected).forEach(key => { key // ? (selected += key + ': ' + this._currSelected[key] + ', ') - : '' - ); + : ''; + }); selected = selected.substring(0, selected.length - 2) + ' }'; - if (this._props.titleCol!="" && (!this._currSelected["frequency"] || this._currSelected["frequency"]<10)){ - selected+= "\n" + this._props.titleCol + ": " + if (this._props.titleCol !== '' && (!this._currSelected.frequency || this._currSelected.frequency < 10)) { + selected += '\n' + this._props.titleCol + ': '; this._tableData.forEach(each => { - if (this._currSelected[this._props.axes[0]]==each[this._props.axes[0]]) { - if (this._props.axes[1]){ - if (this._currSelected[this._props.axes[1]]==each[this._props.axes[1]]) selected+= each[this._props.titleCol] + ", "; - } - else selected+= each[this._props.titleCol] + ", "; + if (this._currSelected[this._props.axes[0]] === each[this._props.axes[0]]) { + if (this._props.axes[1]) { + if (this._currSelected[this._props.axes[1]] === each[this._props.axes[1]]) selected += each[this._props.titleCol] + ', '; + } else selected += each[this._props.titleCol] + ', '; } - }) - selected = selected.slice(0,-1).slice(0,-1); + }); + selected = selected.slice(0, -1).slice(0, -1); } } - var selectedBarColor; - var barColors = StrListCast(this._props.layoutDoc.histogramBarColors).map(each => each.split('::')); - barColors.forEach(each => each[0] === curSelectedBarName && (selectedBarColor = each[1])); + let selectedBarColor; + const barColors = StrListCast(this._props.layoutDoc.histogramBarColors).map(each => each.split('::')); + barColors.forEach(each => { + // eslint-disable-next-line prefer-destructuring + each[0] === curSelectedBarName && (selectedBarColor = each[1]); + }); if (this._histogramData.length > 0 || !this.parentViz) { return this._props.axes.length >= 1 ? ( @@ -476,55 +514,63 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { <EditableText val={StrCast(this._props.layoutDoc[titleAccessor])} setVal={undoable( - action(val => (this._props.layoutDoc[titleAccessor] = val as string)), + action(val => { + this._props.layoutDoc[titleAccessor] = val as string; + }), 'Change Graph Title' )} - color={'black'} + color="black" size={Size.LARGE} fillWidth /> <ColorPicker - tooltip={'Change Default Bar Color'} + tooltip="Change Default Bar Color" type={Type.SEC} icon={<FaFillDrip />} selectedColor={StrCast(this._props.layoutDoc.dataViz_histogram_defaultColor)} - setFinalColor={undoable(color => (this._props.layoutDoc.dataViz_histogram_defaultColor = color), 'Change Default Bar Color')} - setSelectedColor={undoable(color => (this._props.layoutDoc.dataViz_histogram_defaultColor = color), 'Change Default Bar Color')} + setFinalColor={undoable(color => { + this._props.layoutDoc.dataViz_histogram_defaultColor = color; + }, 'Change Default Bar Color')} + setSelectedColor={undoable(color => { + this._props.layoutDoc.dataViz_histogram_defaultColor = color; + }, 'Change Default Bar Color')} size={Size.XSMALL} /> </div> - <div ref={this._histogramRef} /> - {selected != 'none' ? ( - <div className={'selected-data'}> + <div + ref={r => { + this._histogramRef = r; + r && this.drawChart(this._histogramData, this.width, this.height); + }} + /> + {selected !== 'none' ? ( + <div className="selected-data"> Selected: {selected} <ColorPicker - tooltip={'Change Bar Color'} + tooltip="Change Bar Color" type={Type.SEC} icon={<FaFillDrip />} - selectedColor={selectedBarColor ? selectedBarColor : this.curBarSelected.attr('fill')} + selectedColor={selectedBarColor || this.curBarSelected.attr('fill')} setFinalColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Bar Color')} setSelectedColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Bar Color')} size={Size.XSMALL} /> <IconButton - icon={<FontAwesomeIcon icon={'eraser'} />} + icon={<FontAwesomeIcon icon="eraser" />} size={Size.XSMALL} - color={'black'} + color="black" type={Type.SEC} - tooltip={'Revert to the default bar color'} - onClick={undoable( - action(() => this.eraseSelectedColor()), - 'Change Selected Bar Color' - )} + tooltip="Revert to the default bar color" // + onClick={undoable(this.eraseSelectedColor, 'Change Selected Bar Color')} /> </div> ) : null} </div> ) : ( - <span className="chart-container"> {'first use table view to select a column to graph'}</span> + <span className="chart-container"> first use table view to select a column to graph</span> ); } // when it is a brushed table and the incoming table doesn't have any rows selected diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx index e093ec648..c2f5388a2 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -3,23 +3,20 @@ import * as d3 from 'd3'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { Doc, DocListCast, NumListCast, StrListCast } from '../../../../../fields/Doc'; +import { Doc, NumListCast, StrListCast } from '../../../../../fields/Doc'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; import { Cast, DocCast, StrCast } from '../../../../../fields/Types'; import { Docs } from '../../../../documents/Documents'; -import { DocumentManager } from '../../../../util/DocumentManager'; import { undoable } from '../../../../util/UndoManager'; +import {} from '../../../DocComponent'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; -import { PinProps, PresBox } from '../../trails'; +import { PinDocView, PinProps } from '../../../PinFuncs'; +import { DocumentView } from '../../DocumentView'; import { DataVizBox } from '../DataVizBox'; -import { createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from '../utils/D3Utils'; +import { DataPoint, createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from '../utils/D3Utils'; import './Chart.scss'; -export interface DataPoint { - x: number; - y: number; -} export interface SelectedDataPoint extends DataPoint { elem?: d3.Selection<d3.BaseType, unknown, SVGGElement, unknown>; } @@ -45,15 +42,23 @@ export interface LineChartProps { @observer export class LineChart extends ObservableReactComponent<LineChartProps> { private _disposers: { [key: string]: IReactionDisposer } = {}; - private _lineChartRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _lineChartRef: HTMLDivElement | null = null; private _lineChartSvg: d3.Selection<SVGGElement, unknown, null, undefined> | undefined; - @observable _currSelected: any | undefined = undefined; + @observable _currSelected: DataPoint | undefined = undefined; + // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates constructor(props: any) { super(props); makeObservable(this); } + @computed get titleAccessor() { + let titleAccessor: any = 'dataViz_lineChart_title'; + if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; + else if (this._props.axes.length > 0) titleAccessor += this._props.axes[0]; + return titleAccessor; + } + @computed get _tableDataIds() { return !this.parentViz ? this._props.records.map((rec, i) => i) : NumListCast(this.parentViz.dataViz_selectedRows); } @@ -62,7 +67,6 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { return !this.parentViz ? this._props.records : this._tableDataIds.map(rowId => this._props.records[rowId]); } @computed get _lineChartData() { - var guids = StrListCast(this._props.layoutDoc.dataViz_rowIds); if (this._props.axes.length <= 1) return []; return this._tableData.map(record => ({ x: Number(record[this._props.axes[0]]), y: Number(record[this._props.axes[1]]) })).sort((a, b) => (a.x < b.x ? -1 : 1)); } @@ -71,16 +75,11 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { } @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); - // return LinkManager.Instance.getAllRelatedLinks(this._props.Document) // out of all links - // .filter(link => { - // return link.link_anchor_1 == this._props.Document.dataViz_parentViz; - // }) // get links where this chart doc is the target of the link - // .map(link => DocCast(link.link_anchor_1)); // then return the source of the link } @computed get incomingHighlited() { // return selected x and y axes // otherwise, use the selection of whatever is linked to us - const incomingVizBox = DocumentManager.Instance.getFirstDocumentView(this.parentViz)?.ComponentView as DataVizBox; + const incomingVizBox = DocumentView.getFirstDocumentView(this.parentViz)?.ComponentView as DataVizBox; const highlitedRowIds = NumListCast(incomingVizBox?.layoutDoc?.dataViz_highlitedRows); return this._tableData.filter((record, i) => highlitedRowIds.includes(this._tableDataIds[i])); // get all the datapoints they have selected field set by incoming anchor } @@ -91,40 +90,10 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount() { - this._disposers.chartData = reaction( - () => ({ dataSet: this._lineChartData, w: this.width, h: this.height }), - ({ dataSet, w, h }) => { - if (dataSet) { - this.drawChart([dataSet], this.rangeVals, w, h); - } - }, - { fireImmediately: true } - ); - this._disposers.annos = reaction( - () => DocListCast(this._props.dataDoc[this._props.fieldKey + '_annotations']), - annotations => { - // modify how d3 renders so that anything in this annotations list would be potentially highlighted in some way - // could be blue colored to make it look like anchor - // this.drawAnnotations() - // loop through annotations and draw them - // annotations.forEach(a => this.drawAnnotations(Number(a.x), Number(a.y))); - // this.drawAnnotations(annotations.x, annotations.y); - }, - { fireImmediately: true } - ); - this._disposers.highlights = reaction( - () => ({ - selected: this._currSelected, - incomingHighlited: this.incomingHighlited, - }), - ({ selected, incomingHighlited }) => { - // redraw annotations when the chart data has changed, or the local or inherited selection has changed - this.clearAnnotations(); - selected && this.drawAnnotations(Number(selected.x), Number(selected.y), true); - incomingHighlited?.forEach((record: any) => this.drawAnnotations(Number(record[this._props.axes[0]]), Number(record[this._props.axes[1]]))); - }, - { fireImmediately: true } - ); + // coloring the selected point + if (!this._props.layoutDoc[this.titleAccessor]) this._props.layoutDoc[this.titleAccessor] = this.defaultGraphTitle; + if (!this._props.layoutDoc.dataViz_lineChart_selectedData) this._props.layoutDoc.dataViz_lineChart_selectedData = new List<string>(); + this._disposers.selector = reaction(() => StrListCast(this._props.layoutDoc.dataViz_lineChart_selectedData).slice(), this.colorSelectedPts, { fireImmediately: true }); } // anything that doesn't need to be recalculated should just be stored as drawCharts (i.e. computed values) and drawChart is gonna iterate over these observables and generate svgs based on that @@ -137,51 +106,33 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { element.classList.remove('selected'); } }; - // gets called whenever the "data_annotations" fields gets updated - drawAnnotations = (dataX: number, dataY: number, selected?: boolean) => { - // TODO: nda - can optimize this by having some sort of mapping of the x and y values to the individual circle elements - // loop through all html elements with class .circle-d1 and find the one that has "data-x" and "data-y" attributes that match the dataX and dataY - // if it exists, then highlight it - // if it doesn't exist, then remove the highlight - const elements = document.querySelectorAll('.datapoint'); - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - const x = element.getAttribute('data-x'); - const y = element.getAttribute('data-y'); - if (x === dataX.toString() && y === dataY.toString()) { - element.classList.add(selected ? 'selected' : 'brushed'); - } - // TODO: nda - this remove highlight code should go where we remove the links - // } else { - // } - } - }; - - @action - restoreView = (data: Doc) => { - const coords = Cast(data.config_dataVizSelection, listSpec('number'), null); - if (coords?.length > 1 && (this._currSelected?.x !== coords[0] || this._currSelected?.y !== coords[1])) { - this.setCurrSelected(coords[0], coords[1]); - return true; - } - if (this._currSelected) { - this.setCurrSelected(); - return true; - } - return false; - }; // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc) getAnchor = (pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ // - title: 'line doc selection' + this._currSelected?.x, + title: 'line doc selection' + (this._currSelected?.x ?? ''), }); - PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); + PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); anchor.config_dataVizSelection = this._currSelected ? new List<number>([this._currSelected.x, this._currSelected.y]) : undefined; return anchor; }; + private colorSelectedPts = () => { + const elements = document.querySelectorAll('.datapoint'); + for (let i = 0; i < elements.length; i++) { + const dx = Number(elements[i].getAttribute('data-x')); + const dy = Number(elements[i].getAttribute('data-y')); + const selectedDataBars = StrListCast(this._props.layoutDoc.dataViz_lineChart_selectedData); + const selected = selectedDataBars.some(eachSelectedData => { + const [sx, sy] = eachSelectedData.split(','); // parse each selected point into x,y + return Number(sx) === dx && Number(sy) === dy; + }); + if (selected) elements[i].classList.add('brushed'); + else elements[i].classList.remove('brushed'); + } + }; + @computed get height() { return this._props.height - this._props.margin.top - this._props.margin.bottom; } @@ -191,36 +142,55 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { } @computed get defaultGraphTitle() { - var ax0 = this._props.axes[0]; - var ax1 = this._props.axes.length > 1 ? this._props.axes[1] : undefined; + const ax0 = this._props.axes[0]; + const ax1 = this._props.axes.length > 1 ? this._props.axes[1] : undefined; if (this._props.axes.length < 2 || !/\d/.test(this._props.records[0][ax0]) || !ax1) { return ax0 + ' Line Chart'; - } else return ax1 + ' by ' + ax0 + ' Line Chart'; + } + return ax1 + ' by ' + ax0 + ' Line Chart'; } setupTooltip() { - return d3 - .select(this._lineChartRef.current) - .append('div') - .attr('class', 'tooltip') - .style('opacity', 0) - .style('background', '#fff') - .style('border', '1px solid #ccc') - .style('padding', '5px') - .style('position', 'absolute') - .style('font-size', '12px'); + return d3.select(this._lineChartRef).append('div').attr('class', 'tooltip').style('opacity', 0).style('background', '#fff').style('border', '1px solid #ccc').style('padding', '5px').style('position', 'absolute').style('font-size', '12px'); } - // TODO: nda - use this everyewhere we update currSelected? @action - setCurrSelected(x?: number, y?: number) { - // TODO: nda - get rid of svg element in the list? - if (this._currSelected && this._currSelected.x == x && this._currSelected.y == y) this._currSelected = undefined; - else this._currSelected = x !== undefined && y !== undefined ? { x, y } : undefined; - this._props.records.forEach(record => record[this._props.axes[0]] === x && record[this._props.axes[1]] === y && (record.selected = true)); + setCurrSelected(d: DataPoint) { + let ptWasSelected = false; + const selectedDatapoints = Cast(this._props.layoutDoc.dataViz_lineChart_selectedData, listSpec('string'), null); + selectedDatapoints?.forEach(eachData => { + if (!ptWasSelected) { + const [dx, dy] = eachData.split(','); + if (Number(dx) === d.x && Number(dy) === d.y) { + ptWasSelected = true; + const index = selectedDatapoints.indexOf(eachData); + selectedDatapoints.splice(index, 1); + this._currSelected = undefined; + } + } + }); + if (!ptWasSelected) { + selectedDatapoints.push(d.x + ',' + d.y); + this._currSelected = selectedDatapoints.length > 1 ? undefined : d; + } + + // for filtering child dataviz docs + if (this._props.layoutDoc.dataViz_filterSelection) { + const selectedRows = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); + this._tableDataIds.forEach(rowID => { + if ( + Number(this._props.records[rowID][this._props.axes[0]].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '')) === d.x && // + Number(this._props.records[rowID][this._props.axes[1]].replace(/\$/g, '').replace(/%/g, '').replace(/</g, '')) === d.y + ) { + if (!selectedRows?.includes(rowID)) + selectedRows?.push(rowID); // adding to filtered rows + else if (ptWasSelected) selectedRows.splice(selectedRows.indexOf(rowID), 1); // removing from filtered rows + } + }); + } } - drawDataPoints(data: DataPoint[], idx: number, xScale: d3.ScaleLinear<number, number, never>, yScale: d3.ScaleLinear<number, number, never>) { + drawDataPoints(data: DataPoint[], idx: number, xScale: d3.ScaleLinear<number, number, never>, yScale: d3.ScaleLinear<number, number, never>, higlightFocusPt: any, tooltip: any) { if (this._lineChartSvg) { const circleClass = '.circle-' + idx; this._lineChartSvg @@ -232,24 +202,38 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { .attr('cx', d => xScale(d.x)) .attr('cy', d => yScale(d.y)) .attr('data-x', d => d.x) - .attr('data-y', d => d.y); + .attr('data-y', d => d.y) + .on('mouseenter', e => { + const d0 = { x: Number(e.target.getAttribute('data-x')), y: Number(e.target.getAttribute('data-y')) }; + this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); + higlightFocusPt.style('display', null); + }) + .on('mouseleave', () => { + tooltip?.transition().duration(300).style('opacity', 0); + }) + .on('click', (e: any) => { + const d0 = { x: Number(e.target.getAttribute('data-x')), y: Number(e.target.getAttribute('data-y')) }; + // find .circle-d1 with data-x = d0.x and data-y = d0.y + this.setCurrSelected(d0); + this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); + }); } } drawChart = (dataSet: any[][], rangeVals: { xMin?: number; xMax?: number; yMin?: number; yMax?: number }, width: number, height: number) => { // clearing tooltip and the current chart - d3.select(this._lineChartRef.current).select('svg').remove(); - d3.select(this._lineChartRef.current).select('.tooltip').remove(); + d3.select(this._lineChartRef).select('svg').remove(); + d3.select(this._lineChartRef).select('.tooltip').remove(); - var { xMin, xMax, yMin, yMax } = rangeVals; + let { xMin, xMax, yMin, yMax } = rangeVals; if (xMin === undefined || xMax === undefined || yMin === undefined || yMax === undefined) { return; } // adding svg - const margin = this._props.margin; + const { margin } = this._props; const svg = (this._lineChartSvg = d3 - .select(this._lineChartRef.current) + .select(this._lineChartRef) .append('svg') .attr('class', 'graph') .attr('width', `${width + margin.left + margin.right}`) @@ -257,18 +241,19 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { .append('g') .attr('transform', `translate(${margin.left}, ${margin.top})`)); - var validSecondData; - if (this._props.axes.length>2){ // for when there are 2 lines on the chart - var next = this._tableData.map(record => ({ x: Number(record[this._props.axes[0]]), y: Number(record[this._props.axes[2]]) })).sort((a, b) => (a.x < b.x ? -1 : 1)); + let validSecondData; + if (this._props.axes.length > 2) { + // for when there are 2 lines on the chart + const next = this._tableData.map(record => ({ x: Number(record[this._props.axes[0]]), y: Number(record[this._props.axes[2]]) })).sort((a, b) => (a.x < b.x ? -1 : 1)); validSecondData = next.filter(d => { - if (!d.x || Number.isNaN(d.x) || !d.y || Number.isNaN(d.y)) return false; + if (!d.x || isNaN(d.x) || !d.y || isNaN(d.y)) return false; return true; }); - var secondDataRange = minMaxRange([validSecondData]); - if (secondDataRange.xMax!>xMax) xMax = secondDataRange.xMax; - if (secondDataRange.yMax!>yMax) yMax = secondDataRange.yMax; - if (secondDataRange.xMin!<xMin) xMin = secondDataRange.xMin; - if (secondDataRange.yMin!<yMin) yMin = secondDataRange.yMin; + const secondDataRange = minMaxRange([validSecondData]); + if (secondDataRange.xMax! > xMax) xMax = secondDataRange.xMax; + if (secondDataRange.yMax! > yMax) yMax = secondDataRange.yMax; + if (secondDataRange.xMin! < xMin) xMin = secondDataRange.xMin; + if (secondDataRange.yMin! < yMin) yMin = secondDataRange.yMin; } // creating the x and y scales @@ -282,85 +267,47 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { xAxisCreator(svg.append('g'), height, xScale); yAxisCreator(svg.append('g'), width, yScale); + const higlightFocusPt = svg.append('g').style('display', 'none'); + higlightFocusPt.append('circle').attr('r', 5).attr('class', 'circle'); + const tooltip = this.setupTooltip(); + if (validSecondData) { drawLine(svg.append('path'), validSecondData, lineGen, true); - this.drawDataPoints(validSecondData, 0, xScale, yScale); - svg.append('path').attr("stroke", "red"); + this.drawDataPoints(validSecondData, 0, xScale, yScale, higlightFocusPt, tooltip); + svg.append('path').attr('stroke', 'red'); // legend - var color = d3.scaleOrdinal() - .range(["black", "blue"]) - .domain([this._props.axes[1], this._props.axes[2]]) - svg.selectAll("mydots") + const color: any = d3.scaleOrdinal().range(['black', 'blue']).domain([this._props.axes[1], this._props.axes[2]]); + svg.selectAll('mydots') .data([this._props.axes[1], this._props.axes[2]]) .enter() - .append("circle") - .attr("cx", 5) - .attr("cy", function(d,i){ return -30 + i*15}) - .attr("r", 7) - .style("fill", function(d){ return color(d)}) - svg.selectAll("mylabels") + .append('circle') + .attr('cx', 5) + .attr('cy', (d, i) => -30 + i * 15) + .attr('r', 7) + .style('fill', d => color(d)); + svg.selectAll('mylabels') .data([this._props.axes[1], this._props.axes[2]]) .enter() - .append("text") - .attr("x", 25) - .attr("y", function(d,i){ return -30 + i*15}) - .style("fill", function(d){ return color(d)}) - .text(function(d){ return d}) - .attr("text-anchor", "left") - .style("alignment-baseline", "middle") + .append('text') + .attr('x', 25) + .attr('y', (d, i) => -30 + i * 15) + .style('fill', d => color(d)) + .text(d => d) + .attr('text-anchor', 'left') + .style('alignment-baseline', 'middle'); } // get valid data points const data = dataSet[0]; - var validData = data.filter(d => { - Object.keys(data[0]).map(key => { - if (!d[key] || Number.isNaN(d[key])) return false; - }); - return true; - }); + const keys = Object.keys(data[0]); + const validData = data.filter(d => !keys.some(key => isNaN(d[key]))); // draw the plot line drawLine(svg.append('path'), validData, lineGen, false); - // draw the datapoint circle - this.drawDataPoints(validData, 0, xScale, yScale); - const higlightFocusPt = svg.append('g').style('display', 'none'); - higlightFocusPt.append('circle').attr('r', 5).attr('class', 'circle'); - const tooltip = this.setupTooltip(); - // add all the tooltipContent to the tooltip - const mousemove = action((e: any) => { - const bisect = d3.bisector((d: DataPoint) => d.x).left; - const xPos = d3.pointer(e)[0]; - const x0 = Math.min(data.length - 1, bisect(data, xScale.invert(xPos - 5))); // shift x by -5 so that you can reach points on the left-side axis - const d0 = data[x0]; - if (d0) this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); - - this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); - }); - - const onPointClick = action((e: any) => { - const bisect = d3.bisector((d: DataPoint) => d.x).left; - const xPos = d3.pointer(e)[0]; - const x0 = bisect(data, xScale.invert(xPos - 5)); // shift x by -5 so that you can reach points on the left-side axis - const d0 = data[x0]; - // find .circle-d1 with data-x = d0.x and data-y = d0.y - const selected = svg.selectAll('.datapoint').filter((d: any) => d['data-x'] === d0.x && d['data-y'] === d0.y); - this.setCurrSelected(d0.x, d0.y); - this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); - }); - - svg.append('rect') - .attr('class', 'overlay') - .attr('width', width) - .attr('height', this.height + margin.top + margin.bottom) - .attr('fill', 'none') - .attr('translate', `translate(${margin.left}, ${-(margin.top + margin.bottom)})`) - .style('opacity', 0) - .on('mouseover', () => higlightFocusPt.style('display', null)) - .on('mouseout', () => tooltip.transition().duration(300).style('opacity', 0)) - .on('mousemove', mousemove) - .on('click', onPointClick); + // draw the datapoint circle + this.drawDataPoints(validData, 0, xScale, yScale, higlightFocusPt, tooltip); // axis titles svg.append('text') @@ -368,13 +315,14 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { .style('text-anchor', 'middle') .text(this._props.axes[0]); svg.append('text') - .attr('transform', 'rotate(-90)' + ' ' + 'translate( 0, ' + -10 + ')') + .attr('transform', 'rotate(-90) translate(0, -10)') .attr('x', -(height / 2)) .attr('y', -30) .attr('height', 20) .attr('width', 20) .style('text-anchor', 'middle') .text(this._props.axes[1]); + this.colorSelectedPts(); }; private updateTooltip( @@ -394,57 +342,61 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { } render() { - var titleAccessor: any = 'dataViz_lineChart_title'; - if (this._props.axes.length == 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; - else if (this._props.axes.length > 0) titleAccessor = titleAccessor + this._props.axes[0]; - if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; const selectedPt = this._currSelected ? `{ ${this._props.axes[0]}: ${this._currSelected.x} ${this._props.axes[1]}: ${this._currSelected.y} }` : 'none'; - var selectedTitle = ""; - if (this._currSelected && this._props.titleCol){ - selectedTitle+= "\n" + this._props.titleCol + ": " + let selectedTitle = ''; + if (this._currSelected && this._props.titleCol) { + selectedTitle += '\n' + this._props.titleCol + ': '; this._tableData.forEach(each => { - var mapThisEntry = false; - if (this._currSelected.x==each[this._props.axes[0]] && this._currSelected.y==each[this._props.axes[1]]) mapThisEntry = true; - else if (this._currSelected.y==each[this._props.axes[0]] && this._currSelected.x==each[this._props.axes[1]]) mapThisEntry = true; - if (mapThisEntry) selectedTitle += each[this._props.titleCol] + ", "; - }) - selectedTitle = selectedTitle.slice(0,-1).slice(0,-1); + let mapThisEntry = false; + if (this._currSelected?.x === each[this._props.axes[0]] && this._currSelected?.y === each[this._props.axes[1]]) mapThisEntry = true; + else if (this._currSelected?.y === each[this._props.axes[0]] && this._currSelected?.x === each[this._props.axes[1]]) mapThisEntry = true; + if (mapThisEntry) selectedTitle += each[this._props.titleCol] + ', '; + }); + selectedTitle = selectedTitle.slice(0, -1).slice(0, -1); } - if (this._lineChartData.length > 0 || !this.parentViz || this.parentViz.length == 0) { + if (this._lineChartData.length > 0 || !this.parentViz || this.parentViz.length === 0) { return this._props.axes.length >= 2 && /\d/.test(this._props.records[0][this._props.axes[0]]) && /\d/.test(this._props.records[0][this._props.axes[1]]) ? ( <div className="chart-container" style={{ width: this._props.width + this._props.margin.right }}> <div className="graph-title"> <EditableText - val={StrCast(this._props.layoutDoc[titleAccessor])} + val={StrCast(this._props.layoutDoc[this.titleAccessor])} setVal={undoable( - action(val => (this._props.layoutDoc[titleAccessor] = val as string)), + action(val => { + this._props.layoutDoc[this.titleAccessor] = val as string; + }), 'Change Graph Title' )} - color={'black'} + color="black" size={Size.LARGE} fillWidth /> </div> - <div ref={this._lineChartRef} /> - {selectedPt != 'none' ? ( - <div className={'selected-data'}> + <div + ref={r => { + this._lineChartRef = r; + this.drawChart([this._lineChartData], this.rangeVals, this.width, this.height); + }} + /> + {selectedPt !== 'none' ? ( + <div className="selected-data"> {`Selected: ${selectedPt}`} {`${selectedTitle}`} <Button - onClick={e => { + onClick={() => { this._props.vizBox.sidebarBtnDown; this._props.vizBox.sidebarAddDocument; - }}></Button> + }} + /> </div> ) : null} </div> ) : ( - <span className="chart-container"> {'first use table view to select two numerical axes to plot'}</span> - ); - } else - return ( - // when it is a brushed table and the incoming table doesn't have any rows selected - <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div> + <span className="chart-container"> first use table view to select two numerical axes to plot</span> ); + } + return ( + // when it is a brushed table and the incoming table doesn't have any rows selected + <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div> + ); } } diff --git a/src/client/views/nodes/DataVizBox/components/PieChart.tsx b/src/client/views/nodes/DataVizBox/components/PieChart.tsx index fc23f47de..19ea8e4fa 100644 --- a/src/client/views/nodes/DataVizBox/components/PieChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx @@ -1,7 +1,7 @@ import { Checkbox } from '@mui/material'; import { ColorPicker, EditableText, Size, Type } from 'browndash-components'; import * as d3 from 'd3'; -import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; +import { IReactionDisposer, action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { FaFillDrip } from 'react-icons/fa'; @@ -12,7 +12,7 @@ import { Cast, DocCast, StrCast } from '../../../../../fields/Types'; import { Docs } from '../../../../documents/Documents'; import { undoable } from '../../../../util/UndoManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; -import { PinProps, PresBox } from '../../trails'; +import { PinProps, PinDocView } from '../../../PinFuncs'; import './Chart.scss'; export interface PieChartProps { @@ -36,10 +36,10 @@ export interface PieChartProps { @observer export class PieChart extends ObservableReactComponent<PieChartProps> { private _disposers: { [key: string]: IReactionDisposer } = {}; - private _piechartRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _piechartRef: HTMLDivElement | null = null; private _piechartSvg: d3.Selection<SVGGElement, unknown, null, undefined> | undefined; - private curSliceSelected: any = undefined; // d3 data of selected slice - private selectedData: any = undefined; // Selection of selected slice + private curSliceSelected: any = undefined; // d3 data of selected slice for when just one slice is selected + private selectedData: any[] = []; // array of selected slices private hoverOverData: any = undefined; // Selection of slice being hovered over @observable _currSelected: any | undefined = undefined; // Object of selected slice @@ -74,8 +74,8 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { } @computed get defaultGraphTitle() { - var ax0 = this._props.axes[0]; - var ax1 = this._props.axes.length > 1 ? this._props.axes[1] : undefined; + const ax0 = this._props.axes[0]; + const ax1 = this._props.axes.length > 1 ? this._props.axes[1] : undefined; if (this._props.axes.length < 2 || !/\d/.test(this._props.records[0][ax0]) || !ax1) { return ax0 + ' Pie Chart'; } @@ -84,31 +84,37 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); - // return LinkManager.Instance.getAllRelatedLinks(this._props.Document) // out of all links - // .filter(link => link.link_anchor_1 == this._props.Document.dataViz_parentViz) // get links where this chart doc is the target of the link - // .map(link => DocCast(link.link_anchor_1)); // then return the source of the link } componentWillUnmount() { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount() { - this._disposers.chartData = reaction( - () => ({ dataSet: this._pieChartData, w: this.width, h: this.height }), - ({ dataSet, w, h }) => dataSet!.length > 0 && this.drawChart(dataSet, w, h), - { fireImmediately: true } - ); + // restore selected slices + const svg = this._piechartSvg; + if (svg && this._pieChartData[0]) { + const selectedDataBars = StrListCast(this._props.layoutDoc.dataViz_pie_selectedData); + svg.selectAll('path').attr('class', (d: any) => { + let selected = false; + selectedDataBars.forEach(eachSelectedData => { + if (d.data === eachSelectedData) selected = true; + }); + if (selected) { + this.selectedData.push(d); + return 'slice hover'; + } + return 'slice'; + }); + } } - @action - restoreView = (data: Doc) => {}; // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc) getAnchor = (pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ // title: 'piechart doc selection' + this._currSelected, }); - PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); + PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); return anchor; }; @@ -122,30 +128,29 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { // cleans data by converting numerical data to numbers and taking out empty cells data = (dataSet: any) => { - const validData = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key]))); + const validData = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] /* || isNaN(d[key] as any) */)); const field = dataSet[0] ? Object.keys(dataSet[0])[0] : undefined; return !field ? undefined : validData.map((d: { [x: string]: any }) => this.byCategory ? d[field] // - : +d[field].replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') + : +d[field].replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '') ); }; // outlines the slice selected / hovered over highlightSelectedSlice = (changeSelectedVariables: boolean, svg: any, arc: any, radius: any, pointer: any, pieDataSet: any) => { - var index = -1; - var sameAsCurrent: boolean; + let index = -1; const selected = svg.selectAll('.slice').filter((d: any) => { index++; - var p1 = [0, 0]; // center of pie - var p3 = [arc.centroid(d)[0] * 2, arc.centroid(d)[1] * 2]; // outward peak of arc - var p2 = [radius * Math.sin(d.startAngle), -radius * Math.cos(d.startAngle)]; // start of arc - var p4 = [radius * Math.sin(d.endAngle), -radius * Math.cos(d.endAngle)]; // end of arc + const p1 = [0, 0]; // center of pie + const p3 = [arc.centroid(d)[0] * 2, arc.centroid(d)[1] * 2]; // outward peak of arc + const p2 = [radius * Math.sin(d.startAngle), -radius * Math.cos(d.startAngle)]; // start of arc + const p4 = [radius * Math.sin(d.endAngle), -radius * Math.cos(d.endAngle)]; // end of arc // draw an imaginary horizontal line from the pointer to see how many times it crosses a slice edge - var lineCrossCount = 0; + let lineCrossCount = 0; // if for all 4 lines if (Math.min(p1[1], p2[1]) <= pointer[1] && pointer[1] <= Math.max(p1[1], p2[1])) { // within y bounds @@ -160,68 +165,116 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { if (Math.min(p4[1], p1[1]) <= pointer[1] && pointer[1] <= Math.max(p4[1], p1[1])) { if (pointer[0] <= ((pointer[1] - p4[1]) * (p1[0] - p4[0])) / (p1[1] - p4[1]) + p4[0]) lineCrossCount++; } - if (lineCrossCount % 2 != 0) { + if (lineCrossCount % 2 !== 0 || d.startAngle % (2 * Math.PI) === d.endAngle % (2 * Math.PI)) { // inside the slice of it crosses an odd number of edges - var showSelected = this.byCategory ? pieDataSet[index] : this._pieChartData[index]; + const showSelected = this.byCategory ? pieDataSet[index] : this._pieChartData[index]; + let key = 'data'; // key that represents slice + // eslint-disable-next-line prefer-destructuring + if (Object.keys(showSelected)[0] === 'frequency') key = Object.keys(showSelected)[1]; if (changeSelectedVariables) { - // for when a bar is selected - not just hovered over - sameAsCurrent = this._currSelected - ? showSelected[Object.keys(showSelected)[0]] == this._currSelected![Object.keys(showSelected)[0]] && showSelected[Object.keys(showSelected)[1]] == this._currSelected![Object.keys(showSelected)[1]] - : this._currSelected === showSelected; - this._currSelected = sameAsCurrent ? undefined : showSelected; - this.selectedData = sameAsCurrent ? undefined : d; + let sameAsAny = false; + const selectedDataSlices = Cast(this._props.layoutDoc.dataViz_pie_selectedData, listSpec('number'), null); + this.selectedData.forEach(eachData => { + if (!sameAsAny) { + let match = true; + Object.keys(d).forEach(objKey => { + if (d[objKey] !== eachData[objKey]) match = false; + }); + if (match) { + sameAsAny = true; + const selIndex = this.selectedData.indexOf(eachData); + this.selectedData.splice(selIndex, 1); + selectedDataSlices.splice(selIndex, 1); + this._currSelected = undefined; + } + } + }); + if (!sameAsAny) { + this.selectedData.push(d); + selectedDataSlices.push(d[key]); + this._currSelected = this.selectedData.length > 1 ? undefined : showSelected; + } + + // for filtering child dataviz docs + if (this._props.layoutDoc.dataViz_filterSelection) { + const selectedRows = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); + this._tableDataIds.forEach(rowID => { + let match = false; + if (this._props.records[rowID][this._props.axes[0]] == d[key]) match = true; + if (match && !selectedRows?.includes(rowID)) + selectedRows?.push(rowID); // adding to filtered rows + else if (match && sameAsAny) selectedRows.splice(selectedRows.indexOf(rowID), 1); // removing from filtered rows + }); + } } else this.hoverOverData = d; return true; } return false; }); if (changeSelectedVariables) { - if (sameAsCurrent!) this.curSliceSelected = undefined; - else this.curSliceSelected = selected; + if (this._currSelected) this.curSliceSelected = selected; + else this.curSliceSelected = undefined; } }; // draws the pie chart drawChart = (dataSet: any, width: number, height: number) => { - d3.select(this._piechartRef.current).select('svg').remove(); - d3.select(this._piechartRef.current).select('.tooltip').remove(); + if (!dataSet?.length) return; + d3.select(this._piechartRef).select('svg').remove(); + d3.select(this._piechartRef).select('.tooltip').remove(); - var percentField = Object.keys(dataSet[0])[0]; - var descriptionField = Object.keys(dataSet[0])[1]!; - var radius = Math.min(width, height - this._props.margin.top - this._props.margin.bottom) / 2; + let percentField = Object.keys(dataSet[0])[0]; + let descriptionField = Object.keys(dataSet[0])[1]!; + const radius = Math.min(width, height - this._props.margin.top - this._props.margin.bottom) / 2; // converts data into Objects - var data = this.data(dataSet); - var pieDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key]))); + let data = this.data(dataSet); + let pieDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key])); + if (!pieDataSet.length) return; if (this.byCategory) { - let uniqueCategories = [...new Set(data)]; - var pieStringDataSet: { frequency: number }[] = []; + const uniqueCategories = [...new Set(data)]; + const pieStringDataSet: { frequency: number }[] = []; for (let i = 0; i < uniqueCategories.length; i++) { pieStringDataSet.push({ frequency: 0, [percentField]: uniqueCategories[i] }); } for (let i = 0; i < data.length; i++) { - let sliceData = pieStringDataSet.filter((each: any) => each[percentField] == data[i]); - sliceData[0].frequency = sliceData[0].frequency + 1; + // eslint-disable-next-line no-loop-func + const sliceData = pieStringDataSet.filter((each: any) => each[percentField] == data[i]); + sliceData[0].frequency += 1; } pieDataSet = pieStringDataSet; - percentField = Object.keys(pieDataSet[0])[0]; - descriptionField = Object.keys(pieDataSet[0])[1]!; + if (!pieDataSet.length) return; + [percentField, descriptionField] = Object.keys(pieDataSet[0]); data = this.data(pieStringDataSet); } - var trackDuplicates: { [key: string]: any } = {}; - data.forEach((eachData: any) => (!trackDuplicates[eachData] ? (trackDuplicates[eachData] = 0) : null)); + let trackDuplicates: { [key: string]: any } = {}; + data.forEach((eachData: any) => { + !trackDuplicates[eachData] ? (trackDuplicates[eachData] = 0) : null; + }); // initial chart - var svg = (this._piechartSvg = d3 - .select(this._piechartRef.current) + const svg = (this._piechartSvg = d3 + .select(this._piechartRef) .append('svg') .attr('class', 'graph') .attr('width', width + this._props.margin.right + this._props.margin.left) .attr('height', height + this._props.margin.top + this._props.margin.bottom) .append('g')); - let g = svg.append('g').attr('transform', 'translate(' + (width / 2 + this._props.margin.left) + ',' + height / 2 + ')'); - var pie = d3.pie(); - var arc = d3.arc().innerRadius(0).outerRadius(radius); + const g = svg.append('g').attr('transform', 'translate(' + (width / 2 + this._props.margin.left) + ',' + height / 2 + ')'); + const pie = d3.pie(); + const arc = d3.arc().innerRadius(0).outerRadius(radius); + + const updateHighlights = () => { + const hoverOverSlice = this.hoverOverData; + const { selectedData } = this; + svg.selectAll('path').attr('class', (d: any) => { + let selected = false; + selectedData.forEach((eachSelectedData: any) => { + if (d.startAngle === eachSelectedData.startAngle) selected = true; + }); + return selected || (hoverOverSlice && d.startAngle === hoverOverSlice.startAngle && d.endAngle === hoverOverSlice.endAngle) ? 'slice hover' : 'slice'; + }); + }; // click/hover const onPointClick = action((e: any) => this.highlightSelectedSlice(true, svg, arc, radius, d3.pointer(e), pieDataSet)); @@ -229,61 +282,61 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { this.highlightSelectedSlice(false, svg, arc, radius, d3.pointer(e), pieDataSet); updateHighlights(); }); - const mouseOut = action((e: any) => { + const mouseOut = action(() => { this.hoverOverData = undefined; updateHighlights(); }); - const updateHighlights = () => { - const hoverOverSlice = this.hoverOverData; - const selectedData = this.selectedData; - svg.selectAll('path').attr('class', function (d: any) { - return (selectedData && d.startAngle == selectedData.startAngle && d.endAngle == selectedData.endAngle) || (hoverOverSlice && d.startAngle == hoverOverSlice.startAngle && d.endAngle == hoverOverSlice.endAngle) - ? 'slice hover' - : 'slice'; - }); - }; - // drawing the slices - var selected = this.selectedData; - var arcs = g.selectAll('arc').data(pie(data)).enter().append('g'); + const selected = this.selectedData; + const arcs = g.selectAll('arc').data(pie(data)).enter().append('g'); const possibleDataPointVals: { [x: string]: any }[] = []; pieDataSet.forEach((each: { [x: string]: any | { valueOf(): number } }) => { - var dataPointVal: { [x: string]: any } = {}; + const dataPointVal: { [x: string]: any } = {}; dataPointVal[percentField] = each[percentField]; if (descriptionField) dataPointVal[descriptionField] = each[descriptionField]; try { - dataPointVal[percentField] = Number(dataPointVal[percentField].replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '')); - } catch (error) {} + dataPointVal[percentField] = Number(dataPointVal[percentField].replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '')); + } catch (error) { + /* empty */ + } possibleDataPointVals.push(dataPointVal); }); const sliceColors = StrListCast(this._props.layoutDoc.dataViz_pie_sliceColors).map(each => each.split('::')); + + // to make sure all important slice information is on 'd' object + let addKey: any = false; + if (pieDataSet.length && Object.keys(pieDataSet[0])[0] === 'frequency') { + // eslint-disable-next-line prefer-destructuring + addKey = Object.keys(pieDataSet[0])[1]; + } arcs.append('path') - .attr('fill', (d, i) => { - var dataPoint; + .attr('fill', (d: any, i) => { + let dataPoint; const possibleDataPoints = possibleDataPointVals.filter((pval: any) => pval[percentField] === Number(d.data)); - if (possibleDataPoints.length == 1) dataPoint = possibleDataPoints[0]; + if (possibleDataPoints.length === 1) [dataPoint] = possibleDataPoints; else { dataPoint = possibleDataPoints[trackDuplicates[d.data.toString()]]; trackDuplicates[d.data.toString()] = trackDuplicates[d.data.toString()] + 1; } - var sliceColor; + let sliceColor; if (dataPoint) { + if (addKey) d[addKey] = dataPoint[addKey]; // adding all slice information to d const sliceTitle = dataPoint[this._props.axes[0]]; - const accessByName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle; - sliceColors.forEach(each => each[0] == accessByName && (sliceColor = each[1])); + const accessByName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '') : sliceTitle; + sliceColors.forEach(each => { + // eslint-disable-next-line prefer-destructuring + each[0] === accessByName && (sliceColor = each[1]); + }); } return sliceColor ? StrCast(sliceColor) : d3.schemeSet3[i] ? d3.schemeSet3[i] : d3.schemeSet3[i % d3.schemeSet3.length]; }) - .attr( - 'class', - selected - ? function (d) { - return selected && d.startAngle == selected.startAngle && d.endAngle == selected.endAngle ? 'slice hover' : 'slice'; - } - : function (d) { - return 'slice'; - } - ) + .attr('class', d => { + let selectThisData = false; + selected.forEach((eachSelectedData: any) => { + if (d.startAngle === eachSelectedData.startAngle) selectThisData = true; + }); + return selectThisData ? 'slice hover' : 'slice'; + }) // @ts-ignore .attr('d', arc) .on('click', onPointClick) @@ -292,36 +345,39 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { // adding labels trackDuplicates = {}; - data.forEach((eachData: any) => (!trackDuplicates[eachData] ? (trackDuplicates[eachData] = 0) : null)); + data.forEach((eachData: any) => { + !trackDuplicates[eachData] ? (trackDuplicates[eachData] = 0) : null; + }); arcs.size() < 100 && arcs .append('text') - .attr('transform', function (d) { - var centroid = arc.centroid(d as unknown as d3.DefaultArcObject); - var heightOffset = (centroid[1] / radius) * Math.abs(centroid[1]); + .attr('transform', d => { + const centroid = arc.centroid(d as unknown as d3.DefaultArcObject); + const heightOffset = (centroid[1] / radius) * Math.abs(centroid[1]); return 'translate(' + (centroid[0] + centroid[0] / (radius * 0.02)) + ',' + (centroid[1] + heightOffset) + ')'; }) + .attr('pointer-events', 'none') .attr('text-anchor', 'middle') - .text(function (d) { - var dataPoint; + .text(d => { + let dataPoint; const possibleDataPoints = possibleDataPointVals.filter((pval: any) => pval[percentField] === Number(d.data)); - if (possibleDataPoints.length == 1) dataPoint = pieDataSet[possibleDataPointVals.indexOf(possibleDataPoints[0])]; + if (possibleDataPoints.length === 1) dataPoint = pieDataSet[possibleDataPointVals.indexOf(possibleDataPoints[0])]; else { dataPoint = pieDataSet[possibleDataPointVals.indexOf(possibleDataPoints[trackDuplicates[d.data.toString()]])]; trackDuplicates[d.data.toString()] = trackDuplicates[d.data.toString()] + 1; } - return dataPoint ? dataPoint[percentField]! + (!descriptionField ? '' : ' - ' + dataPoint[descriptionField])! : ''; + return dataPoint ? (descriptionField ? dataPoint[descriptionField] : dataPoint[percentField]!) : ''; }); }; @action changeSelectedColor = (color: string) => { this.curSliceSelected.attr('fill', color); const sliceTitle = this._currSelected[this._props.axes[0]]; - const sliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle; + const sliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '') : sliceTitle; const sliceColors = Cast(this._props.layoutDoc.dataViz_pie_sliceColors, listSpec('string'), null); - sliceColors.map(each => { - if (each.split('::')[0] == sliceName) sliceColors.splice(sliceColors.indexOf(each), 1); + sliceColors.forEach(each => { + if (each.split('::')[0] === sliceName) sliceColors.splice(sliceColors.indexOf(each), 1); }); sliceColors.push(StrCast(sliceName + '::' + color)); }; @@ -332,39 +388,40 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { }; render() { - var titleAccessor: any = 'dataViz_pie_title'; - if (this._props.axes.length == 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; - else if (this._props.axes.length > 0) titleAccessor = titleAccessor + this._props.axes[0]; + let titleAccessor: any = 'dataViz_pie_title'; + if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; + else if (this._props.axes.length > 0) titleAccessor += this._props.axes[0]; if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; if (!this._props.layoutDoc.dataViz_pie_sliceColors) this._props.layoutDoc.dataViz_pie_sliceColors = new List<string>(); - var selected: string; - var curSelectedSliceName = ''; + if (!this._props.layoutDoc.dataViz_pie_selectedData) this._props.layoutDoc.dataViz_pie_selectedData = new List<string>(); + let selected: string; + let curSelectedSliceName = ''; if (this._currSelected) { selected = '{ '; const sliceTitle = this._currSelected[this._props.axes[0]]; - curSelectedSliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle; - Object.keys(this._currSelected).map(key => { - key != '' ? (selected += key + ': ' + this._currSelected[key] + ', ') : ''; + curSelectedSliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '') : sliceTitle; + Object.keys(this._currSelected).forEach(key => { + key !== '' ? (selected += key + ': ' + this._currSelected[key] + ', ') : ''; }); selected = selected.substring(0, selected.length - 2); selected += ' }'; - if (this._props.titleCol!="" && (!this._currSelected["frequency"] || this._currSelected["frequency"]<10)){ - selected+= "\n" + this._props.titleCol + ": " + if (this._props.titleCol !== '' && (!this._currSelected.frequency || this._currSelected.frequency < 10)) { + selected += '\n' + this._props.titleCol + ': '; this._tableData.forEach(each => { - if (this._currSelected[this._props.axes[0]]==each[this._props.axes[0]]) { - if (this._props.axes[1]){ - if (this._currSelected[this._props.axes[1]]==each[this._props.axes[1]]) selected+= each[this._props.titleCol] + ", "; - } - else selected+= each[this._props.titleCol] + ", "; + if (this._currSelected[this._props.axes[0]] === each[this._props.axes[0]]) { + if (this._props.axes[1]) { + if (this._currSelected[this._props.axes[1]] === each[this._props.axes[1]]) selected += each[this._props.titleCol] + ', '; + } else selected += each[this._props.titleCol] + ', '; } - }) - selected = selected.slice(0,-1).slice(0,-1); + }); + selected = selected.slice(0, -1).slice(0, -1); } } else selected = 'none'; - var selectedSliceColor; - var sliceColors = StrListCast(this._props.layoutDoc.dataViz_pie_sliceColors).map(each => each.split('::')); + let selectedSliceColor; + const sliceColors = StrListCast(this._props.layoutDoc.dataViz_pie_sliceColors).map(each => each.split('::')); sliceColors.forEach(each => { - if (each[0] == curSelectedSliceName!) selectedSliceColor = each[1]; + // eslint-disable-next-line prefer-destructuring + if (each[0] === curSelectedSliceName!) selectedSliceColor = each[1]; }); if (this._pieChartData.length > 0 || !this.parentViz) { @@ -374,30 +431,37 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { <EditableText val={StrCast(this._props.layoutDoc[titleAccessor])} setVal={undoable( - action(val => (this._props.layoutDoc[titleAccessor] = val as string)), + action(val => { + this._props.layoutDoc[titleAccessor] = val as string; + }), 'Change Graph Title' )} - color={'black'} + color="black" size={Size.LARGE} fillWidth /> </div> {this._props.axes.length === 1 && /\d/.test(this._props.records[0][this._props.axes[0]]) ? ( - <div className={'asHistogram-checkBox'} style={{ width: this._props.width }}> + <div className="asHistogram-checkBox" style={{ width: this._props.width }}> <Checkbox color="primary" onChange={this.changeHistogramCheckBox} checked={this._props.layoutDoc.dataViz_pie_asHistogram as boolean} /> Organize data as histogram </div> ) : null} - <div ref={this._piechartRef} /> - {selected != 'none' ? ( - <div className={'selected-data'}> + <div + ref={r => { + this._piechartRef = r; + this.drawChart(this._pieChartData, this.width, this.height); + }} + /> + {selected !== 'none' ? ( + <div className="selected-data"> Selected: {selected} <ColorPicker - tooltip={'Change Slice Color'} + tooltip="Change Slice Color" type={Type.SEC} icon={<FaFillDrip />} - selectedColor={selectedSliceColor ? selectedSliceColor : this.curSliceSelected.attr('fill')} + selectedColor={selectedSliceColor || this.curSliceSelected.attr('fill')} setFinalColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Slice Color')} setSelectedColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Slice Color')} size={Size.XSMALL} @@ -406,12 +470,12 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { ) : null} </div> ) : ( - <span className="chart-container"> {'first use table view to select a column to graph'}</span> - ); - } else - return ( - // when it is a brushed table and the incoming table doesn't have any rows selected - <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div> + <span className="chart-container"> first use table view to select a column to graph</span> ); + } + return ( + // when it is a brushed table and the incoming table doesn't have any rows selected + <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div> + ); } } diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index 1b239b5e5..a1deb1625 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -1,18 +1,25 @@ +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ import { Button, Type } from 'browndash-components'; -import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; +import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { Utils, emptyFunction, setupMoveUpEvents } from '../../../../../Utils'; +import { ClientUtils, setupMoveUpEvents } from '../../../../../ClientUtils'; +import { emptyFunction } from '../../../../../Utils'; import { Doc, Field, NumListCast } from '../../../../../fields/Doc'; +import { DocData } from '../../../../../fields/DocSymbols'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; import { Cast, DocCast } from '../../../../../fields/Types'; import { DragManager } from '../../../../util/DragManager'; +import { undoable } from '../../../../util/UndoManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { DocumentView } from '../../DocumentView'; import { DataVizView } from '../DataVizBox'; import './Chart.scss'; -const { default: { DATA_VIZ_TABLE_ROW_HEIGHT } } = require('../../../global/globalCssVariables.module.scss'); // prettier-ignore + +const { DATA_VIZ_TABLE_ROW_HEIGHT } = require('../../../global/globalCssVariables.module.scss'); // prettier-ignore + interface TableBoxProps { Document: Doc; layoutDoc: Doc; @@ -37,6 +44,13 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { _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; @@ -49,6 +63,11 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { // 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) + runInAction(() => { + this.hasRowsToFilter = true; + }); this.handleScroll(); } componentWillUnmount() { @@ -64,13 +83,10 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); - // return LinkManager.Instance.getAllRelatedLinks(this._props.Document) // out of all links - // .filter(link => link.link_anchor_1 == this._props.Document.dataViz_parentViz) // get links where this chart doc is the target of the link - // .map(link => DocCast(link.link_anchor_1)); // then return the source of the link } @computed get columns() { - return this._tableData.length ? Array.from(Object.keys(this._tableData[0])).filter(header => header != '' && header != undefined) : []; + 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 @@ -107,14 +123,13 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { if (highlited?.includes(rowId)) highlited.splice(highlited.indexOf(rowId), 1); else highlited?.push(rowId); if (!selected?.includes(rowId)) selected?.push(rowId); - } else { + } else if (selected?.includes(rowId)) { // 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); - } + 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; }; columnPointerDown = (e: React.PointerEvent, col: string) => { @@ -123,13 +138,13 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { setupMoveUpEvents( {}, e, - e => { + moveEv => { // 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<string>([col, col]); + embedding._dataViz_axes = new List<string>([col]); embedding._dataViz_parentViz = this._props.Document; embedding.annotationOn = annotationOn; embedding.histogramBarColors = Field.Copy(this._props.layoutDoc.histogramBarColors); @@ -137,15 +152,13 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { 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; + if (this._props.docView?.() && !ClientUtils.isClick(moveEv.clientX, moveEv.clientY, downX, downY, Date.now())) { + DragManager.StartAnchorAnnoDrag(moveEv.target instanceof HTMLElement ? [moveEv.target] : [], new DragManager.AnchorAnnoDragData(this._props.docView()!, sourceAnchorCreator, targetCreator), downX, downY, { + dragComplete: completeEv => { + if (!completeEv.aborted && completeEv.annoDragData && completeEv.annoDragData.linkSourceDoc && completeEv.annoDragData.dropDocument && completeEv.linkDocument) { + completeEv.linkDocument[DocData].link_matchEmbeddings = true; + completeEv.linkDocument[DocData].stroke_startMarker = true; + this._props.docView?.()?._props.addDocument?.(completeEv.linkDocument); } }, }); @@ -154,16 +167,16 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { return false; }, emptyFunction, - action(e => { - if (e.shiftKey){ - if (this._props.titleCol == col) this._props.titleCol = ""; + action(moveEv => { + if (moveEv.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{ + } 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 if (newAxes.length > 2) newAxes[newAxes.length - 1] = col; else newAxes.push(col); this._props.selectAxes(newAxes); } @@ -171,6 +184,134 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { ); }; + /** + * These functions handle the filtering popup for when the "filter" button is pressed to select rows + */ + filter = undoable((e: any) => { + let start: any; + let end: any; + if (this.filteringType === 'Range') { + start = Number.isNaN(Number(this.filteringVal[0])) ? this.filteringVal[0] : Number(this.filteringVal[0]); + end = Number.isNaN(Number(this.filteringVal[1])) ? this.filteringVal[1] : Number(this.filteringVal[1]); + } + + 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 = ['', '']; + }, 'filter table'); + @action + setFilterColumn = (e: any) => { + this.filteringColumn = e.currentTarget.value; + }; + @action + setFilterType = (e: any) => { + this.filteringType = e.currentTarget.value; + }; + changeFilterValue = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.filteringVal[0] = e.target.value; + }); + changeFilterRange0 = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.filteringVal[0] = e.target.value; + }); + changeFilterRange1 = action((e: React.ChangeEvent<HTMLInputElement>) => { + this.filteringVal[1] = e.target.value; + }); + @computed get renderFiltering() { + if (this.filteringColumn === '') [this.filteringColumn] = this.columns; + return ( + <div className="tableBox-filterPopup" style={{ right: this._props.width * 0.05 }}> + <div className="tableBox-filterPopup-selectColumn"> + Column: + <select className="tableBox-filterPopup-selectColumn-each" value={this.filteringColumn !== '' ? this.filteringColumn : this.columns[0]} onChange={e => this.setFilterColumn(e)}> + {this.columns.map(column => ( + <option className="" key={column} value={column}> + {' '} + {column}{' '} + </option> + ))} + </select> + </div> + <div className="tableBox-filterPopup-setValue"> + <select className="tableBox-filterPopup-setValue-each" value={this.filteringType} onChange={e => this.setFilterType(e)}> + <option className="" key="Value" value="Value"> + {' '} + {'Value'}{' '} + </option> + <option className="" key="Range" value="Range"> + {' '} + {'Range'}{' '} + </option> + </select> + : + {this.filteringType === 'Value' ? ( + <input + className="tableBox-filterPopup-setValue-input" + defaultValue="" + autoComplete="off" + onChange={this.changeFilterValue} + onKeyDown={e => { + e.stopPropagation(); + }} + type="text" + placeholder="" + id="search-input" + /> + ) : ( + <div> + <input + className="tableBox-filterPopup-setValue-input" + defaultValue="" + autoComplete="off" + onChange={this.changeFilterRange0} + onKeyDown={e => { + e.stopPropagation(); + }} + type="text" + placeholder="" + id="search-input" + style={{ width: this._props.width * 0.15 }} + /> + to + <input + className="tableBox-filterPopup-setValue-input" + defaultValue="" + autoComplete="off" + onChange={this.changeFilterRange1} + onKeyDown={e => { + e.stopPropagation(); + }} + type="text" + placeholder="" + id="search-input" + style={{ width: this._props.width * 0.15 }} + /> + </div> + )} + </div> + <div className="tableBox-filterPopup-setFilter"> + <Button onClick={action(e => this.filter(e))} text="Set Filter" type={Type.SEC} color="black" /> + </div> + </div> + ); + } + render() { if (this._tableData.length > 0) { return ( @@ -184,9 +325,53 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { this._props.layoutDoc.dataViz_selectedRows = new List<number>(this._tableDataIds); } }}> - <div className="selectAll-buttons"> - <Button onClick={action(() => (this._props.layoutDoc.dataViz_selectedRows = new List<number>(this._tableDataIds)))} text="Select All" type={Type.SEC} color={'black'} /> - <Button onClick={action(() => (this._props.layoutDoc.dataViz_selectedRows = new List<number>()))} text="Deselect All" type={Type.SEC} color={'black'} /> + <div className="tableBox-selectButtons"> + <div className="tableBox-selectTitle"> + <Button + onClick={action(() => { + this.settingTitle = !this.settingTitle; + })} + text="Select Title Column" + type={Type.SEC} + color="black" + /> + </div> + <div className="tableBox-filtering"> + {this.filtering ? this.renderFiltering : null} + <Button + onClick={action(() => { + this.filtering = !this.filtering; + })} + text="Filter" + type={Type.SEC} + color="black" + /> + <div className="tableBox-filterAll"> + {this.hasRowsToFilter ? ( + <Button + onClick={action(() => { + this._props.layoutDoc.dataViz_selectedRows = new List<number>(); + this.hasRowsToFilter = false; + })} + text="Deselect All" + type={Type.SEC} + color="black" + tooltip="Select rows to be displayed in any DataViz boxes dragged off of this one." + /> + ) : ( + <Button + onClick={action(() => { + this._props.layoutDoc.dataViz_selectedRows = new List<number>(this._tableDataIds); + this.hasRowsToFilter = true; + })} + text="Select All" + type={Type.SEC} + color="black" + tooltip="Select rows to be displayed in any DataViz boxes dragged off of this one." + /> + )} + </div> + </div> </div> <div className={`tableBox-container ${this.columns[0]}`} @@ -220,15 +405,23 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { <th key={this.columns.indexOf(col)} style={{ - color: this._props.axes.slice().reverse().lastElement() === col ? 'darkgreen' - : (this._props.axes.length>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._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, - // blue: #ADD8E6 - // green: #E3fbdb - // red: #Fbdbdb + color: + this._props.axes.slice().reverse().lastElement() === col + ? 'darkgreen' + : this._props.axes.length > 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', }} @@ -250,11 +443,11 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { 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; + let 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 ( <td key={this.columns.indexOf(col)} style={{ border: colSelected ? '3px solid black' : '1px solid black', fontWeight: colSelected ? 'bolder' : 'normal' }}> <div className="tableBox-cell">{this._props.records[rowId][col]}</div> @@ -269,10 +462,10 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { </div> </div> ); - } else - return ( - // when it is a brushed table and the incoming table doesn't have any rows selected - <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div> - ); + } + return ( + // when it is a brushed table and the incoming table doesn't have any rows selected + <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div> + ); } } diff --git a/src/client/views/nodes/DataVizBox/utils/D3Utils.ts b/src/client/views/nodes/DataVizBox/utils/D3Utils.ts index 336935d23..ffc859c92 100644 --- a/src/client/views/nodes/DataVizBox/utils/D3Utils.ts +++ b/src/client/views/nodes/DataVizBox/utils/D3Utils.ts @@ -1,15 +1,18 @@ import * as d3 from 'd3'; -import { DataPoint } from '../components/LineChart'; +export interface DataPoint { + x: number; + y: number; +} // TODO: nda - implement function that can handle range for strings export const minMaxRange = (dataPts: DataPoint[][]) => { // find the max and min of all the data points - const yMin = d3.min(dataPts, d => d3.min(d, d => Number(d.y))); - const yMax = d3.max(dataPts, d => d3.max(d, d => Number(d.y))); + const yMin = d3.min(dataPts, d => d3.min(d, m => Number(m.y))); + const yMax = d3.max(dataPts, d => d3.max(d, m => Number(m.y))); - const xMin = d3.min(dataPts, d => d3.min(d, d => Number(d.x))); - const xMax = d3.max(dataPts, d => d3.max(d, d => Number(d.x))); + const xMin = d3.min(dataPts, d => d3.min(d, m => Number(m.x))); + const xMax = d3.max(dataPts, d => d3.max(d, m => Number(m.x))); return { xMin, xMax, yMin, yMax }; }; @@ -20,18 +23,15 @@ export const scaleCreatorCategorical = (labels: string[], range: number[]) => { return scale; }; -export const scaleCreatorNumerical = (domA: number, domB: number, rangeA: number, rangeB: number) => { - return d3.scaleLinear().domain([domA, domB]).range([rangeA, rangeB]); -}; +export const scaleCreatorNumerical = (domA: number, domB: number, rangeA: number, rangeB: number) => d3.scaleLinear().domain([domA, domB]).range([rangeA, rangeB]); -export const createLineGenerator = (xScale: d3.ScaleLinear<number, number, never>, yScale: d3.ScaleLinear<number, number, never>) => { +export const createLineGenerator = (xScale: d3.ScaleLinear<number, number, never>, yScale: d3.ScaleLinear<number, number, never>) => // TODO: nda - look into the different types of curves - return d3 + d3 .line<DataPoint>() .x(d => xScale(d.x)) .y(d => yScale(d.y)) .curve(d3.curveMonotoneX); -}; export const xAxisCreator = (g: d3.Selection<SVGGElement, unknown, null, undefined>, height: number, xScale: d3.ScaleLinear<number, number, never>) => { g.attr('class', 'x-axis').attr('transform', `translate(0,${height})`).call(d3.axisBottom(xScale).tickSize(15)); @@ -48,7 +48,7 @@ export const xGrid = (g: d3.Selection<SVGGElement, unknown, null, undefined>, he d3 .axisBottom(scale) .tickSize(-height) - .tickFormat((a, b) => '') + .tickFormat((/* a, b */) => '') ); }; @@ -57,10 +57,16 @@ export const yGrid = (g: d3.Selection<SVGGElement, unknown, null, undefined>, wi d3 .axisLeft(scale) .tickSize(-width) - .tickFormat((a, b) => '') + .tickFormat((/* a, b */) => '') ); }; export const drawLine = (p: d3.Selection<SVGPathElement, unknown, null, undefined>, dataPts: DataPoint[], lineGen: d3.Line<DataPoint>, extra: boolean) => { - p.datum(dataPts).attr('fill', 'none').attr('stroke', 'rgba(53, 162, 235, 0.5)').attr('stroke-width', 2).attr('stroke', extra? 'blue' : 'black').attr('class', 'line').attr('d', lineGen); + p.datum(dataPts) + .attr('fill', 'none') + .attr('stroke', 'rgba(53, 162, 235, 0.5)') + .attr('stroke-width', 2) + .attr('stroke', extra ? 'blue' : 'black') + .attr('class', 'line') + .attr('d', lineGen); }; |
