diff options
Diffstat (limited to 'src/client/views/nodes/DataVizBox/DataVizBox.tsx')
-rw-r--r-- | src/client/views/nodes/DataVizBox/DataVizBox.tsx | 367 |
1 files changed, 322 insertions, 45 deletions
diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 299494c83..8365f4770 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -1,20 +1,32 @@ -import { Toggle, ToggleType, Type } from 'browndash-components'; -import { action, computed, ObservableMap } from 'mobx'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Colors, Toggle, ToggleType, Type } from 'browndash-components'; +import { ObservableMap, action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { Doc, Field, StrListCast } from '../../../../fields/Doc'; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, setupMoveUpEvents } from '../../../../Utils'; +import { Doc, DocListCast, Field, Opt, StrListCast } from '../../../../fields/Doc'; +import { InkTool } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; -import { Cast, CsvCast, StrCast } from '../../../../fields/Types'; +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 { Docs } from '../../../documents/Documents'; -import { ViewBoxAnnotatableComponent } from '../../DocComponent'; -import { FieldView, FieldViewProps } from '../FieldView'; +import { DocumentManager } from '../../../util/DocumentManager'; +import { UndoManager, undoable } from '../../../util/UndoManager'; +import { ViewBoxAnnotatableComponent, ViewBoxInterface } from '../../DocComponent'; +import { MarqueeAnnotator } from '../../MarqueeAnnotator'; +import { SidebarAnnos } from '../../SidebarAnnos'; +import { AnchorMenu } from '../../pdf/AnchorMenu'; +import { GPTPopup } from '../../pdf/GPTPopup/GPTPopup'; +import { DocumentView } from '../DocumentView'; +import { FocusViewOptions, FieldView, FieldViewProps } from '../FieldView'; import { PinProps } from '../trails'; +import './DataVizBox.scss'; import { Histogram } from './components/Histogram'; import { LineChart } from './components/LineChart'; import { PieChart } from './components/PieChart'; import { TableBox } from './components/TableBox'; -import './DataVizBox.scss'; export enum DataVizView { TABLE = 'table', @@ -24,7 +36,49 @@ export enum DataVizView { } @observer -export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { +export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() implements ViewBoxInterface { + private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); + private _marqueeref = React.createRef<MarqueeAnnotator>(); + private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); + anchorMenuClick?: () => undefined | ((anchor: Doc) => void); + crop: ((region: Doc | undefined, addCrop?: boolean) => Doc | undefined) | undefined; + @observable _schemaDataVizChildren: any = undefined; + @observable _marqueeing: number[] | undefined = undefined; + @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + this._props.setContentViewBox?.(this); + } + + @computed get annotationLayer() { + TraceMobx(); + return <div className="dataVizBox-annotationLayer" style={{ height: this._props.PanelHeight(), width: this._props.PanelWidth() }} ref={this._annotationLayer} />; + } + marqueeDown = (e: React.PointerEvent) => { + if (!e.altKey && e.button === 0 && NumCast(this.Document._freeform_scale, 1) <= NumCast(this.Document.freeform_scaleMin, 1) && this._props.isContentActive() && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(Doc.ActiveTool)) { + setupMoveUpEvents( + this, + e, + action(e => { + MarqueeAnnotator.clearAnnotations(this._savedAnnotations); + this._marqueeref.current?.onInitiateSelection([e.clientX, e.clientY]); + return true; + }), + returnFalse, + () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), + false + ); + } + }; + @action + finishMarquee = () => { + this._marqueeref.current?.onTerminateSelection(); + this._props.select(false); + }; + savedAnnotations = () => this._savedAnnotations; + public static LayoutString(fieldStr: string) { return FieldView.LayoutString(DataVizBox, fieldStr); } @@ -32,10 +86,11 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // all datasets that have been retrieved from the server stored as a map from the dataset url to an array of records static dataset = new ObservableMap<string, { [key: string]: string }[]>(); private _vizRenderer: LineChart | Histogram | PieChart | undefined; + private _sidebarRef = React.createRef<SidebarAnnos>(); // all CSV records in the dataset (that aren't an empty row) @computed.struct get records() { - var records = DataVizBox.dataset.get(CsvCast(this.rootDoc[this.fieldKey]).url.href); + var records = DataVizBox.dataset.get(CsvCast(this.dataDoc[this.fieldKey]).url.href); return records?.filter(record => Object.keys(record).some(key => record[key])) ?? []; } @@ -73,11 +128,18 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } return func() ?? false; }; - getAnchor = (addAsAnnotation?: boolean, pinProps?: PinProps) => { + getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { + const visibleAnchor = AnchorMenu.Instance.GetAnchor?.(undefined, addAsAnnotation); const anchor = !pinProps - ? this.rootDoc + ? this.Document : this._vizRenderer?.getAnchor(pinProps) ?? + visibleAnchor ?? Docs.Create.ConfigDocument({ + title: 'ImgAnchor:' + this.Document.title, + config_panX: NumCast(this.layoutDoc._freeform_panX), + config_panY: NumCast(this.layoutDoc._freeform_panY), + config_viewScale: Cast(this.layoutDoc._freeform_scale, 'number', null), + 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*/ @@ -93,73 +155,288 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { anchor[key] = this.layoutDoc[key]; } }); + 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' + ); + if (!this.layoutDoc.layout_showSidebar) { + this.toggleSidebar(); + setTimeout(createFunc); + } else createFunc(); + }; + + @observable _showSidebar = false; + @observable _previewNativeWidth: Opt<number> = undefined; + @observable _previewWidth: Opt<number> = undefined; + @action + toggleSidebar = () => { + const prevWidth = this.sidebarWidth(); + this.layoutDoc._layout_showSidebar = (this.layoutDoc._layout_sidebarWidthPercent = StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%') === '0%' ? `${(100 * 0.2) / 1.2}%` : '0%') !== '0%'; + 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; + } + @computed get sidebarHandle() { + return ( + <div + className="dataviz-overlayButton-sidebar" + key="sidebar" + title="Toggle Sidebar" + style={{ + display: !this._props.isContentActive() ? 'none' : undefined, + top: StrCast(this.Document._layout_showTitle) === 'title' ? 20 : 5, + backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK, + }} + onPointerDown={this.sidebarBtnDown}> + <FontAwesomeIcon style={{ color: Colors.WHITE }} icon={'comment-alt'} size="sm" /> + </div> + ); + } + /** + * Toggle sidebar onclick the tiny comment button on the top right corner + * @param e + */ + sidebarBtnDown = (e: React.PointerEvent) => { + setupMoveUpEvents( + this, + e, + (e, down, delta) => + runInAction(() => { + const localDelta = this._props + .ScreenToLocalTransform() + .scale(this._props.NativeDimScaling?.() || 1) + .transformDirection(delta[0], delta[1]); + const fullWidth = NumCast(this.layoutDoc._width); + const mapWidth = fullWidth - this.sidebarWidth(); + if (this.sidebarWidth() + localDelta[0] > 0) { + this.layoutDoc._layout_showSidebar = true; + this.layoutDoc._layout_sidebarWidthPercent = ((100 * (this.sidebarWidth() + localDelta[0])) / (fullWidth + localDelta[0])).toString() + '%'; + this.layoutDoc._width = fullWidth + localDelta[0]; + } else { + this.layoutDoc._layout_showSidebar = false; + this.layoutDoc._width = mapWidth; + this.layoutDoc._layout_sidebarWidthPercent = '0%'; + } + return false; + }), + emptyFunction, + () => UndoManager.RunInBatch(this.toggleSidebar, 'toggle sidebar') + ); + }; + getView = async (doc: Doc, options: FocusViewOptions) => { + if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) { + options.didMove = true; + this.toggleSidebar(); + } + return new Promise<Opt<DocumentView>>(res => DocumentManager.Instance.AddViewRenderedCb(doc, dv => res(dv))); + }; + @computed get sidebarWidthPercent() { + return StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); + } + @computed get sidebarColor() { + return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this._props.fieldKey + '_backgroundColor'], '#e4e4e4')); + } + sidebarWidth = () => (Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100) * this._props.PanelWidth(); + sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { + if (!this.SidebarShown) this.toggleSidebar(); + return this.addDocument(doc, sidebarKey); + }; + sidebarRemoveDocument = (doc: Doc | Doc[], sidebarKey?: string) => this.removeDocument(doc, sidebarKey); + componentDidMount() { - this.props.setContentView?.(this); - if (!DataVizBox.dataset.has(CsvCast(this.rootDoc[this.fieldKey]).url.href)) this.fetchData(); + this._props.setContentViewBox?.(this); + if (!DataVizBox.dataset.has(CsvCast(this.dataDoc[this.fieldKey]).url.href)) this.fetchData(); } fetchData = () => { - DataVizBox.dataset.set(CsvCast(this.rootDoc[this.fieldKey]).url.href, []); // assign temporary dataset as a lock to prevent duplicate server requests + 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.rootDoc[this.fieldKey]).url.href, res)))); + .then(res => res.json().then(action(res => !res.errno && DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, res)))); }; // toggles for user to decide which chart type to view the data in - renderVizView = () => { + @computed get renderVizView() { + const scale = this._props.NativeDimScaling?.() || 1; const sharedProps = { - rootDoc: this.rootDoc, + Document: this.Document, layoutDoc: this.layoutDoc, records: this.records, axes: this.axes, - height: (this.props.PanelHeight() - 32) /* height of 'change view' button */ * 0.9, - width: this.props.PanelWidth() * 0.9, + //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._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.props.DocumentView} selectAxes={this.selectAxes} />; - case DataVizView.LINECHART: - return <LineChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} />; - 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.TABLE: return <TableBox {...sharedProps} docView={this.DocumentView} selectAxes={this.selectAxes} />; + 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 }} />; + } // prettier-ignore + } + + @action + onPointerDown = (e: React.PointerEvent): void => { + if ((this.Document._freeform_scale || 1) !== 1) return; + if (!e.altKey && e.button === 0 && this._props.isContentActive() && ![InkTool.Highlighter, InkTool.Pen, InkTool.Write].includes(Doc.ActiveTool)) { + this._props.select(false); + MarqueeAnnotator.clearAnnotations(this._savedAnnotations); + 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'))) { + } else { + // if textLayer is hit, then we select text instead of using a marquee so clear out the marquee. + setTimeout( + 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. + + document.addEventListener('pointerup', this.onSelectEnd); + } } }; + @action + onSelectEnd = (e: PointerEvent): void => { + this._props.select(false); + document.removeEventListener('pointerup', this.onSelectEnd); + + const sel = window.getSelection(); + if (sel) { + AnchorMenu.Instance.setSelectedText(sel.toString()); + } + + if (sel?.type === 'Range') { + AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); + } + + // Changing which document to add the annotation to (the currently selected PDF) + GPTPopup.Instance.setSidebarId('data_sidebar'); + GPTPopup.Instance.addDoc = this.sidebarAddDocument; + }; + + @action + updateSchemaViz = () => { + 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 }[] = []; + for (let i = 0; i < children.length; i++) { + var row: { [key: string]: string } = {}; + if (children[i]) { + for (let j = 0; j < keys.length; j++) { + var cell = children[i][keys[j]]; + if (cell && (cell as string)) cell = cell.toString().replace(/\,/g, ''); + row[keys[j]] = StrCast(cell); + } + } + current.push(row); + } + DataVizBox.dataset.set(CsvCast(this.Document[this.fieldKey]).url.href, current); + }; + render() { + if (this.layoutDoc && this.layoutDoc.dataViz_asSchema) { + this._schemaDataVizChildren = DocListCast(DocCast(this.layoutDoc.dataViz_asSchema)[Doc.LayoutFieldKey(DocCast(this.layoutDoc.dataViz_asSchema))]).length; + this.updateSchemaViz(); + } + + const scale = this._props.NativeDimScaling?.() || 1; 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="dataViz" + className="dataViz-box" + onPointerDown={this.marqueeDown} style={{ - pointerEvents: this.props.isContentActive() === true ? 'all' : 'none', + pointerEvents: this._props.isContentActive() === true ? 'all' : 'none', + width: `${100 / scale}%`, + height: `${100 / scale}%`, + transform: `scale(${scale})`, + position: 'absolute', }} onWheel={e => e.stopPropagation()} - ref={r => - r?.addEventListener( - 'wheel', // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this) - (e: WheelEvent) => { - if (!r.scrollTop && e.deltaY <= 0) e.preventDefault(); - e.stopPropagation(); - }, - { passive: false } - ) - }> - <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} /> + 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} /> + </div> + + {/* <CollectionFreeFormView + ref={this._ffref} + {...this._props} + setContentView={emptyFunction} + renderDepth={this._props.renderDepth - 1} + fieldKey={this.annotationKey} + styleProvider={this._props.styleProvider} + isAnnotationOverlay={true} + annotationLayerHostsContent={false} + PanelWidth={this._props.PanelWidth} + PanelHeight={this._props.PanelHeight} + select={emptyFunction} + isAnyChildContentActive={returnFalse} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={this.addDocument}> + {this.renderVizView} + </CollectionFreeFormView> */} + + {this.renderVizView} + <div className="dataviz-sidebar" style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }} onPointerDown={this.onPointerDown}> + <SidebarAnnos + ref={this._sidebarRef} + {...this._props} + fieldKey={this.fieldKey} + Document={this.Document} + layoutDoc={this.layoutDoc} + dataDoc={this.dataDoc} + usePanelWidth={true} + showSidebar={this.SidebarShown} + nativeWidth={NumCast(this.layoutDoc._nativeWidth)} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + PanelWidth={this.sidebarWidth} + sidebarAddDocument={this.sidebarAddDocument} + moveDocument={this.moveDocument} + removeDocument={this.sidebarRemoveDocument} + /> </div> - {this.renderVizView()} + {this.sidebarHandle} + {this.annotationLayer} + {!this._mainCont.current || !this.DocumentView || !this._annotationLayer.current ? null : ( + <MarqueeAnnotator + ref={this._marqueeref} + Document={this.Document} + anchorMenuClick={this.anchorMenuClick} + scrollTop={0} + annotationLayerScrollTop={NumCast(this.Document._layout_scrollTop)} + scaling={returnOne} + docView={this.DocumentView} + addDocument={this.sidebarAddDocument} + finishMarquee={this.finishMarquee} + savedAnnotations={this.savedAnnotations} + selectionText={returnEmptyString} + annotationLayer={this._annotationLayer.current} + marqueeContainer={this._mainCont.current} + anchorMenuCrop={this.crop} + /> + )} </div> ); } |