aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorbobzel <zzzman@gmail.com>2023-08-16 13:31:56 -0400
committerGitHub <noreply@github.com>2023-08-16 13:31:56 -0400
commitd1e31265f8707bea63e21bf9a7b1dd10ccbf2009 (patch)
treeb9c8b7d2aa084c206d272828843fc2b2ce911089 /src
parentad74cd03fa38a66101331ac0d3ea6cda841e3eee (diff)
parent61fb855ec92540c48cc4cc844d3b21728e8a4754 (diff)
Merge pull request #206 from brown-dash/data-visualization-sarah
Data visualization sarah
Diffstat (limited to 'src')
-rw-r--r--src/client/documents/Documents.ts2
-rw-r--r--src/client/util/CurrentUserUtils.ts14
-rw-r--r--src/client/util/SettingsManager.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeView.tsx64
-rw-r--r--src/client/views/nodes/DataVizBox/DataVizBox.scss12
-rw-r--r--src/client/views/nodes/DataVizBox/DataVizBox.tsx108
-rw-r--r--src/client/views/nodes/DataVizBox/components/Chart.scss43
-rw-r--r--src/client/views/nodes/DataVizBox/components/Histogram.tsx489
-rw-r--r--src/client/views/nodes/DataVizBox/components/LineChart.tsx105
-rw-r--r--src/client/views/nodes/DataVizBox/components/PieChart.tsx380
-rw-r--r--src/client/views/nodes/DataVizBox/components/TableBox.tsx237
-rw-r--r--src/client/views/nodes/DocumentView.tsx10
12 files changed, 1269 insertions, 197 deletions
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 8a4a82e6d..5c33e319d 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -1163,7 +1163,7 @@ export namespace Docs {
}
export function DataVizDocument(url: string, options?: DocumentOptions, overwriteDoc?: Doc) {
- return InstanceFromProto(Prototypes.get(DocumentType.DATAVIZ), new CsvField(url), { title: 'Data Viz', ...options }, undefined, undefined, undefined, overwriteDoc);
+ return InstanceFromProto(Prototypes.get(DocumentType.DATAVIZ), new CsvField(url), { title: 'Data Viz', type: 'dataviz', ...options }, undefined, undefined, undefined, overwriteDoc);
}
export function DockDocument(documents: Array<Doc>, config: string, options: DocumentOptions, id?: string) {
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index b5c1bb4c4..47cd866cb 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -294,7 +294,7 @@ export class CurrentUserUtils {
{ toolTip: "Tap or drag to create a note", title: "Note", icon: "sticky-note", dragFactory: doc.emptyNote as Doc, clickFactory: DocCast(doc.emptyNote)},
{ toolTip: "Tap or drag to create a flashcard", title: "Flashcard", icon: "id-card", dragFactory: doc.emptyFlashcard as Doc, clickFactory: DocCast(doc.emptyFlashcard)},
{ toolTip: "Tap or drag to create an equation", title: "Math", icon: "calculator", dragFactory: doc.emptyEquation as Doc, clickFactory: DocCast(doc.emptyEquation)},
- { toolTip: "Tap or drag to create a physics simulation", title: "Simulation", icon: "atom", dragFactory: doc.emptySimulation as Doc, funcs: { hidden: "IsNoviceMode()"}},
+ { toolTip: "Tap or drag to create a physics simulation",title: "Simulation", icon: "rocket",dragFactory: doc.emptySimulation as Doc, clickFactory: DocCast(doc.emptySimulation)},
{ toolTip: "Tap or drag to create a note board", title: "Notes", icon: "folder", dragFactory: doc.emptyNoteboard as Doc, clickFactory: DocCast(doc.emptyNoteboard)},
{ toolTip: "Tap or drag to create a collection", title: "Col", icon: "folder", dragFactory: doc.emptyCollection as Doc, clickFactory: DocCast(doc.emptyTab)},
{ toolTip: "Tap or drag to create a webpage", title: "Web", icon: "globe-asia", dragFactory: doc.emptyWebpage as Doc, clickFactory: DocCast(doc.emptyWebpage)},
@@ -304,12 +304,12 @@ export class CurrentUserUtils {
{ toolTip: "Tap or drag to create a screen grabber", title: "Grab", icon: "photo-video", dragFactory: doc.emptyScreengrab as Doc, clickFactory: DocCast(doc.emptyScreengrab), openFactoryLocation: OpenWhere.overlay},
{ toolTip: "Tap or drag to create a WebCam recorder", title: "WebCam", icon: "photo-video", dragFactory: doc.emptyWebCam as Doc, clickFactory: DocCast(doc.emptyWebCam), openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}},
{ toolTip: "Tap or drag to create a button", title: "Button", icon: "bolt", dragFactory: doc.emptyButton as Doc, clickFactory: DocCast(doc.emptyButton)},
- { toolTip: "Tap or drag to create a scripting box", title: "Script", icon: "terminal", dragFactory: doc.emptyScript as Doc, clickFactory: DocCast(doc.emptyScript), funcs: { hidden: "IsNoviceMode()"}},
- { toolTip: "Tap or drag to create a data viz node", title: "DataViz", icon: "file", dragFactory: doc.emptyDataViz as Doc, clickFactory: DocCast(doc.emptyDataViz)},
- { toolTip: "Tap or drag to create a bullet slide", title: "PPT Slide", icon: "file", dragFactory: doc.emptySlide as Doc, clickFactory: DocCast(doc.emptySlide), openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}},
- { toolTip: "Tap or drag to create a data note", title: "DataNote", icon: "window-maximize",dragFactory: doc.emptyHeader as Doc,clickFactory: DocCast(doc.emptyHeader), openFactoryAsDelegate: true , funcs: { hidden: "IsNoviceMode()"}},
- { toolTip: "Toggle a Calculator REPL", title: "replviewer", icon: "calculator", clickFactory: '<ScriptingRepl />' as any, openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}}, // hack: clickFactory is not a Doc but will get interpreted as a custom UI by the openDoc() onClick script
- { toolTip: "Toggle an UndoStack", title: "undostacker", icon: "calculator", clickFactory: "<UndoStack>" as any, openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}},
+ { toolTip: "Tap or drag to create a scripting box", title: "Script", icon: "terminal", dragFactory: doc.emptyScript as Doc, clickFactory: DocCast(doc.emptyScript)},
+ { toolTip: "Tap or drag to create a data viz node", title: "DataViz", icon: "chart-bar", dragFactory: doc.emptyDataViz as Doc, clickFactory: DocCast(doc.emptyDataViz)},
+ { toolTip: "Tap or drag to create a bullet slide", title: "PPT Slide", icon: "file", dragFactory: doc.emptySlide as Doc, clickFactory: DocCast(doc.emptySlide), openFactoryLocation: OpenWhere.overlay},
+ { toolTip: "Tap or drag to create a data note", title: "DataNote", icon: "window-maximize",dragFactory: doc.emptyHeader as Doc,clickFactory: DocCast(doc.emptyHeader), openFactoryAsDelegate: true },
+ { toolTip: "Toggle a Calculator REPL", title: "replviewer", icon: "calculator", clickFactory: '<ScriptingRepl />' as any, openFactoryLocation: OpenWhere.overlay}, // hack: clickFactory is not a Doc but will get interpreted as a custom UI by the openDoc() onClick script
+ { toolTip: "Toggle an UndoStack", title: "undostacker", icon: "calculator", clickFactory: "<UndoStack>" as any, openFactoryLocation: OpenWhere.overlay},
].map(tuple => (
{ openFactoryLocation: OpenWhere.addRight,
scripts: { onClick: 'openDoc(copyDragFactory(this.clickFactory,this.openFactoryAsDelegate), this.openFactoryLocation)',
diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx
index b2b5be070..6acba8af4 100644
--- a/src/client/util/SettingsManager.tsx
+++ b/src/client/util/SettingsManager.tsx
@@ -450,7 +450,7 @@ export class SettingsManager extends React.Component<{}> {
</div>
<div className="settings-user">
- <div style={{ color: 'black' }}>{DashVersion}</div>
+ <div style={{ color: this.userBackgroundColor }}>{DashVersion}</div>
<div className="settings-username" style={{ color: this.userBackgroundColor }}>
{Doc.CurrentUserEmail}
</div>
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index 7c53bfdbe..1c3da1dc5 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -170,7 +170,15 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque
TreeView._editTitleOnLoad = { id: slide[Id], parent: undefined };
this.props.addDocument?.(slide);
e.stopPropagation();
- }*/ else if (!e.ctrlKey && !e.metaKey && SelectionManager.Views().length < 2) {
+ }*/ else if (e.key === 'p' && e.ctrlKey) {
+ e.preventDefault();
+ (async () => {
+ const text: string = await navigator.clipboard.readText();
+ const ns = text.split('\n').filter(t => t.trim() !== '\r' && t.trim() !== '');
+ this.pasteTable(ns, x, y);
+ })();
+ e.stopPropagation();
+ } else if (!e.ctrlKey && !e.metaKey && SelectionManager.Views().length < 2) {
FormattedTextBox.SelectOnLoadChar = Doc.UserDoc().defaultTextLayout && !this.props.childLayoutString ? e.key : '';
FormattedTextBox.LiveTextUndo = UndoManager.StartBatch('type new note');
this.props.addLiveTextDocument(DocUtils.GetNewTextDoc('-typed text-', x, y, 200, 100, this.props.xPadding === 0));
@@ -185,44 +193,26 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque
// any row that has only one column is a section header-- this header is then added as a column to subsequent rows until the next header
// assumes each cell is a string or a number
pasteTable(ns: string[], x: number, y: number) {
- while (ns.length > 0 && ns[0].split('\t').length < 2) {
- ns.splice(0, 1);
- }
- if (ns.length > 0) {
- const columns = ns[0].split('\t');
- const docList: Doc[] = [];
- let groupAttr: string | number = '';
- const rowProto = new Doc();
- rowProto.title = rowProto.Id;
- rowProto._width = 200;
- rowProto.isDataDoc = true;
- for (let i = 1; i < ns.length - 1; i++) {
- const values = ns[i].split('\t');
- if (values.length === 1 && columns.length > 1) {
- groupAttr = values[0];
- continue;
- }
- const docDataProto = Doc.MakeDelegate(rowProto);
- docDataProto.isDataDoc = true;
- columns.forEach((col, i) => (docDataProto[columns[i]] = values.length > i ? (values[i].indexOf(Number(values[i]).toString()) !== -1 ? Number(values[i]) : values[i]) : undefined));
- if (groupAttr) {
- docDataProto._group = groupAttr;
- }
- docDataProto.title = i.toString();
- const doc = Doc.MakeDelegate(docDataProto);
- doc._width = 200;
- docList.push(doc);
+ let csvRows = [];
+ const headers = ns[0].split('\t');
+ csvRows.push(headers.join(','));
+ ns[0] = '';
+ const eachCell = ns.join('\t').split('\t')
+ let eachRow = []
+ for (let i=1; i<eachCell.length; i++){
+ eachRow.push(eachCell[i].replace(/\,/g, ''));
+ if (i % headers.length == 0){
+ csvRows.push(eachRow)
+ eachRow = [];
}
- const newCol = Docs.Create.SchemaDocument([...(groupAttr ? [new SchemaHeaderField('_group', '#f1efeb')] : []), ...columns.filter(c => c).map(c => new SchemaHeaderField(c, '#f1efeb'))], docList, {
- x: x,
- y: y,
- title: 'droppedTable',
- _width: 300,
- _height: 100,
- });
-
- this.props.addDocument?.(newCol);
}
+
+ const blob = new Blob([csvRows.join('\n')], {type: 'text/csv'})
+ const options = { x: x, y: y, title: 'droppedTable', _width: 300, _height: 100, type:'text/csv'}
+ const file = new File([blob], 'droppedTable', options);
+ const loading = Docs.Create.LoadingDocument(file, options);
+ DocUtils.uploadFileToDoc(file, {}, loading);
+ this.props.addDocument?.(loading);
}
@action
diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.scss b/src/client/views/nodes/DataVizBox/DataVizBox.scss
index cd500e9ae..a69881b7c 100644
--- a/src/client/views/nodes/DataVizBox/DataVizBox.scss
+++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss
@@ -1,4 +1,14 @@
.dataviz {
- overflow: auto;
+ overflow: scroll;
height: 100%;
+ width: 100%;
+
+ .datatype-button{
+ display: flex;
+ flex-direction: row;
+ }
+}
+.start-message {
+ margin: 10px;
+ align-self: baseline;
}
diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx
index d548ab9f1..0cc73f32f 100644
--- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx
+++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx
@@ -1,9 +1,9 @@
-import { action, computed, observable, ObservableMap, ObservableSet } from 'mobx';
+import { action, computed, ObservableMap, ObservableSet } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
-import { Doc, StrListCast } from '../../../../fields/Doc';
+import { Doc, Field, StrListCast } from '../../../../fields/Doc';
import { List } from '../../../../fields/List';
-import { Cast, CsvCast, NumCast, StrCast } from '../../../../fields/Types';
+import { Cast, CsvCast, StrCast } from '../../../../fields/Types';
import { CsvField } from '../../../../fields/URLField';
import { Docs } from '../../../documents/Documents';
import { ViewBoxAnnotatableComponent } from '../../DocComponent';
@@ -12,56 +12,47 @@ import { PinProps } from '../trails';
import { LineChart } from './components/LineChart';
import { TableBox } from './components/TableBox';
import './DataVizBox.scss';
+import { Histogram } from './components/Histogram';
+import { PieChart } from './components/PieChart';
+import { Toggle, ToggleType, Type } from 'browndash-components';
export enum DataVizView {
TABLE = 'table',
LINECHART = 'lineChart',
+ HISTOGRAM = 'histogram',
+ PIECHART = 'pieChart'
}
@observer
export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
- public static LayoutString(fieldKey: string) {
- return FieldView.LayoutString(DataVizBox, fieldKey);
+ public static LayoutString(fieldStr: string) {
+ return FieldView.LayoutString(DataVizBox, fieldStr);
}
- // says we have an object and any string
- // 2 ways of doing it
- // @observable private pairs: { [key: string]: number | string | undefined }[] = [];
- // @observable private pairs: { [key: string]: FieldResult }[] = [];
+
+ // all data
static pairSet = new ObservableMap<string, { [key: string]: string }[]>();
@computed.struct get pairs() {
return DataVizBox.pairSet.get(CsvCast(this.rootDoc[this.fieldKey]).url.href);
}
- private _chartRenderer: LineChart | undefined;
- // // another way would be store a schema that defines the type of data we are expecting from an imported doc
-
- // method1() {
- // this.pairs[0].x = 3;
- // }
-
- // method() {
- // // this.pairs[0].x = 3;
- // // go through the pairs
- // const x = this.pairs[0].x;
- // if (typeof x == 'number') {
- // let x1 = Number(x);
- // // let x1 = NumCast(x);
- // }
- // }
- // could use field result
- // [key: string]: FieldResult;
- // instead of numeric x,y in there,
-
- // TODO: nda - use onmousedown and onmouseup when dragging and changing height and width to update the height and width props only when dragging stops
+ private _chartRenderer: LineChart | Histogram | PieChart | undefined;
+ // current displayed chart type
@computed get dataVizView(): DataVizView {
return StrCast(this.layoutDoc._dataVizView, 'table') as DataVizView;
}
- @action
+ @action // pinned / linked anchor doc includes selected rows, graph titles, and graph colors
restoreView = (data: Doc) => {
const changedView = this.dataVizView !== data.presDataVizView && (this.layoutDoc._dataVizView = data.presDataVizView);
const changedAxes = this.axes.join('') !== StrListCast(data.presDataVizAxes).join('') && (this.layoutDoc._data_vizAxes = new List<string>(StrListCast(data.presDataVizAxes)));
+ this.layoutDoc.selected = Field.Copy(data.selected);
+ this.layoutDoc.histogramBarColors = Field.Copy(data.histogramBarColors);
+ this.layoutDoc.defaultHistogramColor = data.defaultHistogramColor;
+ this.layoutDoc.pieSliceColors = Field.Copy(data.pieSliceColors);
+ Object.keys(this.layoutDoc).map(key => {
+ if (key.startsWith('histogram-title') || key.startsWith('lineChart-title') || key.startsWith('pieChart-title')){ this.layoutDoc['_'+key] = data[key]; }
+ })
const func = () => this._chartRenderer?.restoreView(data);
if (changedView || changedAxes) {
setTimeout(func, 100);
@@ -69,7 +60,6 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
}
return func() ?? false;
};
-
getAnchor = (addAsAnnotation?: boolean, pinProps?: PinProps) => {
const anchor = !pinProps
? this.rootDoc
@@ -79,10 +69,15 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
// this is for when we want the whole doc (so when the chartBox getAnchor returns without a marker)
/*put in some options*/
});
-
anchor.presDataVizView = this.dataVizView;
anchor.presDataVizAxes = this.axes.length ? new List<string>(this.axes) : undefined;
-
+ anchor.selected = Field.Copy(this.layoutDoc.selected);
+ anchor.histogramBarColors = Field.Copy(this.layoutDoc.histogramBarColors);
+ anchor.defaultHistogramColor = this.layoutDoc.defaultHistogramColor;
+ anchor.pieSliceColors = Field.Copy(this.layoutDoc.pieSliceColors);
+ Object.keys(this.layoutDoc).map(key => {
+ if (key.startsWith('histogram-title') || key.startsWith('lineChart-title') || key.startsWith('pieChart-title')){ anchor[key] = this.layoutDoc[key]; }
+ })
this.addDocument(anchor);
return anchor;
};
@@ -92,17 +87,20 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
}
selectAxes = (axes: string[]) => (this.layoutDoc.data_vizAxes = new List<string>(axes));
+ // toggles for user to decide which chart type to view the data in
@computed get selectView() {
const width = this.props.PanelWidth() * 0.9;
const height = (this.props.PanelHeight() - 32) /* height of 'change view' button */ * 0.9;
- const margin = { top: 10, right: 25, bottom: 50, left: 25 };
+ const margin = { top: 10, right: 25, bottom: 75, left: 45 };
if (!this.pairs) return 'no data';
- // prettier-ignore
switch (this.dataVizView) {
- case DataVizView.TABLE: return <TableBox pairs={this.pairs} axes={this.axes} docView={this.props.DocumentView} selectAxes={this.selectAxes}/>;
- case DataVizView.LINECHART: return <LineChart ref={r => (this._chartRenderer = r ?? undefined)} height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />;
+ case DataVizView.TABLE: return <TableBox layoutDoc={this.layoutDoc} pairs={this.pairs} axes={this.axes} height={height} width={width} margin={margin} rootDoc={this.rootDoc} docView={this.props.DocumentView} selectAxes={this.selectAxes}/>;
+ case DataVizView.LINECHART: return <LineChart layoutDoc={this.layoutDoc} ref={r => (this._chartRenderer = r ?? undefined)} height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />;
+ case DataVizView.HISTOGRAM: return <Histogram layoutDoc={this.layoutDoc} ref={r => (this._chartRenderer = r ?? undefined)} height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />;
+ case DataVizView.PIECHART: return <PieChart layoutDoc={this.layoutDoc} ref={r => (this._chartRenderer = r ?? undefined)} height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />;
}
}
+
@computed get dataUrl() {
return Cast(this.dataDoc[this.fieldKey], CsvField);
}
@@ -119,16 +117,15 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
.then(res => res.json().then(action(res => !res.errno && DataVizBox.pairSet.set(CsvCast(this.rootDoc[this.fieldKey]).url.href, res))));
}
- // handle changing the view using a button
- @action changeViewHandler(e: React.MouseEvent<HTMLButtonElement>) {
- e.preventDefault();
- e.stopPropagation();
- this.layoutDoc._dataVizView = this.dataVizView === DataVizView.TABLE ? DataVizView.LINECHART : DataVizView.TABLE;
- }
-
render() {
+ if (!this.layoutDoc._dataVizView) this.layoutDoc._dataVizView = this.dataVizView;
return !this.pairs?.length ? (
- <div>Loading...</div>
+ // 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"
@@ -143,7 +140,24 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
{ passive: false }
)
}>
- <button onClick={e => this.changeViewHandler(e)}>{this.dataVizView === DataVizView.TABLE ? DataVizView.LINECHART : DataVizView.TABLE}</button>
+ <div className={'datatype-button'}>
+ <Toggle text={"TABLE"} toggleType={ToggleType.BUTTON} type={Type.SEC} color={"black"}
+ onClick={e => this.layoutDoc._dataVizView = DataVizView.TABLE}
+ toggleStatus={this.layoutDoc._dataVizView == DataVizView.TABLE}
+ />
+ <Toggle text={"LINECHART"} toggleType={ToggleType.BUTTON} type={Type.SEC} color={"black"}
+ onClick={e => this.layoutDoc._dataVizView = DataVizView.LINECHART}
+ toggleStatus={this.layoutDoc._dataVizView == DataVizView.LINECHART}
+ />
+ <Toggle text={"HISTOGRAM"} toggleType={ToggleType.BUTTON} type={Type.SEC} color={"black"}
+ onClick={e => this.layoutDoc._dataVizView = DataVizView.HISTOGRAM}
+ toggleStatus={this.layoutDoc._dataVizView == DataVizView.HISTOGRAM}
+ />
+ <Toggle text={"PIE CHART"} toggleType={ToggleType.BUTTON} type={Type.SEC} color={"black"}
+ onClick={e => this.layoutDoc._dataVizView = DataVizView.PIECHART}
+ toggleStatus={this.layoutDoc._dataVizView == DataVizView.PIECHART}
+ />
+ </div>
{this.selectView}
</div>
);
diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss
index d4f7bfb32..35e5187b2 100644
--- a/src/client/views/nodes/DataVizBox/components/Chart.scss
+++ b/src/client/views/nodes/DataVizBox/components/Chart.scss
@@ -3,6 +3,43 @@
flex-direction: column;
align-items: center;
cursor: default;
+ margin-top: 10px;
+ overflow-y: visible;
+
+ .graph{
+ overflow: visible;
+ }
+ .graph-title{
+ align-items: center;
+ font-size: larger;
+ display: flex;
+ flex-direction: row;
+ margin-top: -10px;
+ margin-bottom: -10px;
+ }
+ .selected-data{
+ align-items: center;
+ text-align: center;
+ display: flex;
+ flex-direction: row;
+ margin: 10px;
+ margin-top: -25px;
+ margin-bottom: 5px;
+ }
+ .slice {
+ &.hover {
+ stroke: black;
+ stroke-width: 2px;
+ }
+ }
+
+ .histogram-bar{
+ outline: thin solid black;
+ &.hover{
+ outline: 3px solid black;
+ outline-offset: -3px;
+ }
+ }
.tooltip {
// make the height width bigger
@@ -39,3 +76,9 @@
fill: red;
}
}
+.table-container{
+ overflow: scroll;
+ margin: 10px;
+ margin-left: 25px;
+ margin-top: 25px;
+} \ No newline at end of file
diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx
new file mode 100644
index 000000000..df6aac6bc
--- /dev/null
+++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx
@@ -0,0 +1,489 @@
+import { observer } from "mobx-react";
+import { Doc, StrListCast } from "../../../../../fields/Doc";
+import * as React from 'react';
+import * as d3 from 'd3';
+import { IReactionDisposer, action, computed, observable, reaction } from "mobx";
+import { LinkManager } from "../../../../util/LinkManager";
+import { Cast, DocCast, StrCast} from "../../../../../fields/Types";
+import { PinProps, PresBox } from "../../trails";
+import { Docs } from "../../../../documents/Documents";
+import { List } from "../../../../../fields/List";
+import './Chart.scss';
+import { ColorPicker, EditableText, IconButton, Size, Type } from "browndash-components";
+import { FaFillDrip } from "react-icons/fa";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { listSpec } from "../../../../../fields/Schema";
+import { scaleCreatorNumerical, yAxisCreator } from "../utils/D3Utils";
+import { undoBatch, undoable } from "../../../../util/UndoManager";
+
+export interface HistogramProps {
+ rootDoc: Doc;
+ layoutDoc: Doc;
+ axes: string[];
+ pairs: { [key: string]: any }[];
+ width: number;
+ height: number;
+ dataDoc: Doc;
+ fieldKey: string;
+ margin: {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ };
+}
+
+@observer
+export class Histogram extends React.Component<HistogramProps> {
+
+ private _disposers: { [key: string]: IReactionDisposer } = {};
+ private _histogramRef: React.RefObject<HTMLDivElement> = React.createRef();
+ 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 hoverOverData: any = undefined; // Selection of bar being hovered over
+
+ // filters all data to just display selected data if brushed (created from an incoming link)
+ @computed get _histogramData() {
+ var guids = StrListCast(this.props.layoutDoc.rowGuids);
+ if (this.props.axes.length < 1) return [];
+ if (this.props.axes.length < 2) {
+ var ax0 = this.props.axes[0];
+ if (/\d/.test(this.props.pairs[0][ax0])){ this.numericalXData = true }
+ return this.props.pairs
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)])))
+ .map(pair => ({ [ax0]: (pair[this.props.axes[0]])}))
+ };
+ var ax0 = this.props.axes[0];
+ var ax1 = this.props.axes[1];
+ if (/\d/.test(this.props.pairs[0][ax0])) { this.numericalXData = true;}
+ if (/\d/.test(this.props.pairs[0][ax1]) ) { this.numericalYData = true;}
+ return this.props.pairs
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)])))
+ .map(pair => ({ [ax0]: (pair[this.props.axes[0]]), [ax1]: (pair[this.props.axes[1]]) }))
+ }
+
+ @computed get defaultGraphTitle(){
+ var ax0 = this.props.axes[0];
+ var ax1 = (this.props.axes.length>1)? this.props.axes[1] : undefined;
+ if (this.props.axes.length<2 || !ax1 || !/\d/.test(this.props.pairs[0][ax1]) || !this.numericalYData){
+ return ax0 + " Histogram";
+ }
+ else return ax0 + " by " + ax1 + " Histogram";
+ }
+
+ @computed get incomingLinks() {
+ return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links
+ .filter(link => link.link_anchor_1 == this.props.rootDoc.draggedFrom) // 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 rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } {
+ if (this.numericalXData){
+ const data = this.data(this._histogramData);
+ return {xMin: Math.min.apply(null, data), xMax: Math.max.apply(null, data), yMin:0, yMax:0}
+ }
+ return {xMin:0, xMax:0, yMin:0, yMax:0}
+ }
+
+ componentWillUnmount() {
+ 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 }) => {
+ if (dataSet!.length>0) {
+ this.drawChart(dataSet, w, h);
+ }
+ },
+ { fireImmediately: true }
+ );
+ };
+
+ @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.dataDoc);
+ return anchor;
+ };
+
+ @computed get height() {
+ return this.props.height - this.props.margin.top - this.props.margin.bottom;
+ }
+
+ @computed get width() {
+ return this.props.width - this.props.margin.left - this.props.margin.right;
+ }
+
+ // cleans data by converting numerical data to numbers and taking out empty cells
+ data = (dataSet: any) => {
+ var validData = dataSet.filter((d: { [x: string]: unknown; }) => {
+ var valid = true;
+ Object.keys(dataSet[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ })
+ return valid;
+ })
+ var field = dataSet[0]? Object.keys(dataSet[0])[0] : undefined;
+ const data = validData.map((d: { [x: string]: any; }) => {
+ if (this.numericalXData) { return +d[field!].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') }
+ return d[field!]
+ })
+ return data;
+ }
+
+ // 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;
+ 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 (this.numericalXData){
+ // calculating frequency
+ 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;
+ }
+ else this.hoverOverData = d;
+ return true
+ }
+ return false;
+ });
+ if (changeSelectedVariables){
+ if (sameAsCurrent!) this.curBarSelected = undefined;
+ else this.curBarSelected = selected;
+ }
+ }
+
+ // 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();
+
+ var data = this.data(dataSet);
+ var xAxisTitle = Object.keys(dataSet[0])[0]
+ var yAxisTitle = this.numericalYData ? Object.keys(dataSet[0])[1] : 'frequency';
+ let 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;
+ if (numBins>this.maxBins) numBins = this.maxBins;
+ var startingPoint = this.numericalXData? this.rangeVals.xMin! : 0;
+ var endingPoint = this.numericalXData? this.rangeVals.xMax! : numBins;
+
+ // converts data into Objects
+ var histDataSet = dataSet.filter((d: { [x: string]: unknown; }) => {
+ var valid = true;
+ Object.keys(dataSet[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ })
+ return valid;
+ });
+ if (!this.numericalXData) {
+ var 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]})
+ }
+ }
+ else{
+ for (let i=0; i<uniqueArr.length; i++){
+ histStringDataSet.push({[yAxisTitle]: 0, [xAxisTitle]: uniqueArr[i]})
+ }
+ for (let i=0; i<data.length; i++){
+ let barData = histStringDataSet.filter(each => each[xAxisTitle]==data[i])
+ histStringDataSet.filter(each => each[xAxisTitle]==data[i])[0][yAxisTitle] = Number(barData[0][yAxisTitle]) + 1;
+ }
+ }
+ histDataSet = histStringDataSet
+ }
+
+ // initial graph and binning data for histogram
+ var svg = (this._histogramSvg = d3
+ .select(this._histogramRef.current)
+ .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.scaleLinear()
+ .domain(this.numericalXData? [startingPoint!, endingPoint!] : [0, numBins])
+ .range([0, width ]);
+ var histogram = d3.histogram()
+ .value(function(d) {return 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;
+ bins[0].x0 = graphStartingPoint;
+ x = x.domain([graphStartingPoint, endingPoint])
+ .range([0, Number.isInteger(this.rangeVals.xMin!)? (width-eachRectWidth) : width ])
+ var xAxis;
+
+ // more calculations based on bins
+ // x-axis
+ if (!this.numericalXData) { // reorganize to match data if the data is strings rather than numbers
+ // uniqueArr.sort()
+ histDataSet.sort()
+ for (let i=0; i<data.length; i++){
+ var index = 0
+ for (let j=0; j<uniqueArr.length; j++){
+ if (uniqueArr[j] == data[i]){
+ index = j;
+ }
+ }
+ if (bins[index]) bins[index].push(data[i])
+ }
+ bins.pop();
+ eachRectWidth = width/(bins.length)
+ bins.forEach(d => d.x0 = d.x0!)
+ xAxis = d3.axisBottom(x)
+ .ticks(bins.length>1? bins.length-1: 1)
+ .tickFormat( i => uniqueArr[i.valueOf()] as string)
+ .tickPadding(10)
+ x.range([0, width-eachRectWidth])
+ x.domain([0, bins.length-1])
+ translateXAxis = eachRectWidth / 2;
+ }
+ else {
+ var allSame = true;
+ for (var i=0; i<bins.length; i++){
+ if (bins[i] && bins[i][0]){
+ var compare = bins[i][0];
+ for (let j=1; j<bins[i].length; j++){
+ if (bins[i][j] != compare) allSame = false;
+ }
+ }
+ }
+ if (allSame){
+ translateXAxis = eachRectWidth / 2;
+ 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();
+ x.domain([curDomain[0], curDomain[0] + tickDiff*bins.length])
+ }
+
+ xAxis = d3.axisBottom(x)
+ .ticks(bins.length-1)
+ 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]);
+ y.domain([0, +maxFrequency!]);
+ var yAxis = d3.axisLeft(y)
+ .ticks(maxFrequency!)
+ if (this.numericalYData){
+ const yScale = scaleCreatorNumerical(0, Number(maxFrequency), height, 0);
+ yAxisCreator(svg.append('g'), width, yScale);
+ }
+ else{
+ svg.append("g")
+ .call(yAxis);
+ }
+ svg.append("g")
+ .attr("transform", "translate(" + translateXAxis + ", " + height + ")")
+ .call(xAxis)
+
+ // click/hover
+ const onPointClick = action((e: any) => this.highlightSelectedBar(true, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet));
+ const onHover = action((e: any) => {
+ const selected = this.highlightSelectedBar(false, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet)
+ updateHighlights();
+ })
+ const mouseOut = action((e: any) => {
+ this.hoverOverData = undefined;
+ 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'; })
+ }
+ svg.on('click', onPointClick)
+ .on('mouseover', onHover)
+ .on('mouseout', mouseOut)
+
+ // axis titles
+ svg.append("text")
+ .attr("transform", "translate(" + (width/2) + " ," + (height+40) + ")")
+ .style("text-anchor", "middle")
+ .text(xAxisTitle);
+ svg.append("text")
+ .attr("transform", "rotate(-90)" + " " + "translate( 0, " + -10 + ")")
+ .attr("x", -(height/2))
+ .attr("y", -20)
+ .style("text-anchor", "middle")
+ .text(yAxisTitle);
+ d3.format('.0f')
+
+ // draw bars
+ var selected = this.selectedData;
+ svg.selectAll("rect")
+ .data(bins)
+ .enter()
+ .append("rect")
+ .attr("transform", this.numericalYData?
+ function (d) {
+ var eachData = histDataSet.filter((data: { [x: string]: number; }) => {return data[xAxisTitle]==d[0]})
+ var 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) + ")"; })
+ .attr("height", this.numericalYData?
+ function(d) {
+ var eachData = histDataSet.filter((data: { [x: string]: number; }) => {return data[xAxisTitle]==d[0]})
+ var length = eachData.length? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') : 0;
+ return height-y(length)}
+ : function(d) {
+ return 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("fill", (d)=>{
+ var barColor;
+ var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::'));
+ barColors.map(each => {
+ if (d[0] && d[0].toString() && each[0]==d[0].toString()) barColor = each[1];
+ else {
+ var range = StrCast(each[0]).split(" to ");
+ if (Number(range[0])<=d[0] && d[0]<=Number(range[1])) barColor = each[1];
+ }
+ });
+ return barColor? StrCast(barColor) : StrCast(this.props.layoutDoc.defaultHistogramColor)})
+ };
+
+ @action changeSelectedColor = (color: string) => {
+ this.curBarSelected.attr("fill", color);
+ var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''))
+
+ const barColors = Cast(this.props.layoutDoc.histogramBarColors, listSpec("string"), null);
+ barColors.map(each => { if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1) });
+ barColors.push(StrCast(barName + '::' + color));
+ };
+
+ @action eraseSelectedColor= () => {
+ this.curBarSelected.attr("fill", this.props.layoutDoc.defaultHistogramColor);
+ var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''))
+
+ const barColors = Cast(this.props.layoutDoc.histogramBarColors, listSpec("string"), null);
+ barColors.map(each => { if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1) });
+ };
+
+ render() {
+ this._histogramData
+ var curSelectedBarName;
+ var titleAccessor: any='';
+ if (this.props.axes.length==2) titleAccessor = 'histogram-title-'+this.props.axes[0]+'-'+this.props.axes[1];
+ else if (this.props.axes.length>0) titleAccessor = 'histogram-title-'+this.props.axes[0];
+ if (!this.props.layoutDoc[titleAccessor]) this.props.layoutDoc[titleAccessor] = this.defaultGraphTitle;
+ if (!this.props.layoutDoc.defaultHistogramColor) this.props.layoutDoc.defaultHistogramColor = '#69b3a2';
+ if (!this.props.layoutDoc.histogramBarColors) this.props.layoutDoc.histogramBarColors = new List<string>();
+ var selected: string;
+ if (this._currSelected){
+ curSelectedBarName = StrCast(this._currSelected![this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''))
+ selected = '{ ';
+ Object.keys(this._currSelected).map(key => {
+ key!=''? selected += key + ': ' + this._currSelected[key] + ', ': '';
+ })
+ selected = selected.substring(0, selected.length-2);
+ selected += ' }';
+ }
+ else selected = 'none';
+ var selectedBarColor;
+ var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::'));
+ barColors.map(each => {if (each[0]==curSelectedBarName!) selectedBarColor = each[1]});
+
+ this.componentDidMount();
+
+ if (this._histogramData.length>0){
+ return (
+ this.props.axes.length >= 1 ? (
+ <div className="chart-container" >
+ <div className="graph-title">
+ <EditableText
+ val={StrCast(this.props.layoutDoc[titleAccessor])}
+ setVal={undoable (action(val => this.props.layoutDoc[titleAccessor] = val as string), "Change Graph Title")}
+ color={"black"}
+ size={Size.LARGE}
+ fillWidth
+ />
+ &nbsp; &nbsp;
+ <ColorPicker
+ tooltip={'Change Default Bar Color'}
+ type={Type.SEC}
+ icon={<FaFillDrip/>}
+ selectedColor={StrCast(this.props.layoutDoc.defaultHistogramColor)}
+ setSelectedColor={undoable (color => this.props.layoutDoc.defaultHistogramColor = color, "Change Default Bar Color")}
+ size={Size.XSMALL}
+ />
+ </div>
+ <div ref={this._histogramRef} />
+ {selected != 'none' ?
+ <div className={'selected-data'}>
+ Selected: {selected}
+ &nbsp; &nbsp;
+ <ColorPicker
+ tooltip={'Change Bar Color'}
+ type={Type.SEC}
+ icon={<FaFillDrip/>}
+ selectedColor={selectedBarColor? selectedBarColor : this.curBarSelected.attr("fill")}
+ setSelectedColor={undoable (color => this.changeSelectedColor(color), "Change Selected Bar Color")}
+ size={Size.XSMALL}
+ />
+ &nbsp;
+ <IconButton
+ icon={<FontAwesomeIcon icon={'eraser'} />}
+ size={Size.XSMALL}
+ color={'black'}
+ type={Type.SEC}
+ tooltip={'Revert to the default bar color'}
+ onClick={undoable (action(() => this.eraseSelectedColor()), "Change Selected Bar Color")}
+ />
+ </div>
+ : 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>
+ )
+ }
+} \ No newline at end of file
diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx
index 6b564b0c9..8bace941f 100644
--- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx
+++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx
@@ -1,13 +1,12 @@
import { action, computed, IReactionDisposer, observable, reaction } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
-// import d3
import * as d3 from 'd3';
-import { Doc, DocListCast } from '../../../../../fields/Doc';
+import { Doc, DocListCast, StrListCast } from '../../../../../fields/Doc';
import { Id } from '../../../../../fields/FieldSymbols';
import { List } from '../../../../../fields/List';
import { listSpec } from '../../../../../fields/Schema';
-import { Cast, DocCast } from '../../../../../fields/Types';
+import { Cast, DocCast, StrCast } from '../../../../../fields/Types';
import { Docs } from '../../../../documents/Documents';
import { DocumentManager } from '../../../../util/DocumentManager';
import { LinkManager } from '../../../../util/LinkManager';
@@ -15,16 +14,19 @@ import { PinProps, PresBox } from '../../trails';
import { DataVizBox } from '../DataVizBox';
import { createLineGenerator, drawLine, minMaxRange, scaleCreatorNumerical, xAxisCreator, xGrid, yAxisCreator, yGrid } from '../utils/D3Utils';
import './Chart.scss';
+import { EditableText, Size } from 'browndash-components';
+import { undoable } from '../../../../util/UndoManager';
export interface DataPoint {
x: number;
y: number;
}
-interface SelectedDataPoint extends DataPoint {
+export interface SelectedDataPoint extends DataPoint {
elem?: d3.Selection<d3.BaseType, unknown, SVGGElement, unknown>;
}
export interface LineChartProps {
rootDoc: Doc;
+ layoutDoc: Doc;
axes: string[];
pairs: { [key: string]: any }[];
width: number;
@@ -48,18 +50,25 @@ export class LineChart extends React.Component<LineChartProps> {
// TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates
@computed get _lineChartData() {
+ var guids = StrListCast(this.props.layoutDoc.rowGuids);
if (this.props.axes.length <= 1) return [];
return this.props.pairs
- ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select'))))
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)])))
.map(pair => ({ x: Number(pair[this.props.axes[0]]), y: Number(pair[this.props.axes[1]]) }))
.sort((a, b) => (a.x < b.x ? -1 : 1));
}
+ @computed get graphTitle(){
+ return this.props.axes[1] + " vs. " + this.props.axes[0] + " Line Chart";
+ }
@computed get incomingLinks() {
return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links
- .filter(link => link.link_anchor_1 !== this.props.rootDoc) // get links where this chart doc is the target of the link
+ .filter(link => {
+ return link.link_anchor_1 == this.props.rootDoc.draggedFrom}) // 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 incomingSelected() {
+ // return selected x and y axes
+ // otherwise, use the selection of whatever is linked to us
return this.incomingLinks // all links that are pointing to this node
.map(anchor => DocumentManager.Instance.getFirstDocumentView(anchor)?.ComponentView as DataVizBox) // get their data viz boxes
.filter(dvb => dvb)
@@ -180,6 +189,15 @@ export class LineChart extends React.Component<LineChartProps> {
return this.props.width - this.props.margin.left - this.props.margin.right;
}
+ @computed get defaultGraphTitle(){
+ var ax0 = this.props.axes[0];
+ var ax1 = (this.props.axes.length>1)? this.props.axes[1] : undefined;
+ if (this.props.axes.length<2 || !/\d/.test(this.props.pairs[0][ax0]) || !ax1){
+ return ax0 + " Line Chart";
+ }
+ else return ax1 + " by " + ax0 + " Line Chart";
+ }
+
setupTooltip() {
return d3
.select(this._lineChartRef.current)
@@ -197,9 +215,9 @@ export class LineChart extends React.Component<LineChartProps> {
@action
setCurrSelected(x?: number, y?: number) {
// TODO: nda - get rid of svg element in the list?
- this._currSelected = x !== undefined && y !== undefined ? { x, y } : undefined;
+ 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.pairs.forEach(pair => pair[this.props.axes[0]] === x && pair[this.props.axes[1]] === y && (pair.selected = true));
- this.props.pairs.forEach(pair => (pair.selected = pair[this.props.axes[0]] === x && pair[this.props.axes[1]] === y ? true : undefined));
}
drawDataPoints(data: DataPoint[], idx: number, xScale: d3.ScaleLinear<number, number, never>, yScale: d3.ScaleLinear<number, number, never>) {
@@ -219,7 +237,7 @@ export class LineChart extends React.Component<LineChartProps> {
}
// TODO: nda - can use d3.create() to create html element instead of appending
- drawChart = (dataSet: DataPoint[][], rangeVals: { xMin?: number; xMax?: number; yMin?: number; yMax?: number }, width: number, height: number) => {
+ 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();
@@ -238,6 +256,7 @@ export class LineChart extends React.Component<LineChartProps> {
const svg = (this._lineChartSvg = d3
.select(this._lineChartRef.current)
.append('svg')
+ .attr("class", "graph")
.attr('width', `${width + margin.left + margin.right}`)
.attr('height', `${height + margin.top + margin.bottom}`)
.append('g')
@@ -249,13 +268,20 @@ export class LineChart extends React.Component<LineChartProps> {
xAxisCreator(svg.append('g'), height, xScale);
yAxisCreator(svg.append('g'), width, yScale);
- // draw the plot line
+ // get valid data points
const data = dataSet[0];
const lineGen = createLineGenerator(xScale, yScale);
- drawLine(svg.append('path'), data, lineGen);
-
+ var validData = data.filter((d => {
+ var valid = true;
+ Object.keys(data[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ })
+ return valid;
+ }))
+ // draw the plot line
+ drawLine(svg.append('path'), validData, lineGen);
// draw the datapoint circle
- this.drawDataPoints(data, 0, xScale, yScale);
+ this.drawDataPoints(validData, 0, xScale, yScale);
const higlightFocusPt = svg.append('g').style('display', 'none');
higlightFocusPt.append('circle').attr('r', 5).attr('class', 'circle');
@@ -293,6 +319,20 @@ export class LineChart extends React.Component<LineChartProps> {
.on('mouseout', () => tooltip.transition().duration(300).style('opacity', 0))
.on('mousemove', mousemove)
.on('click', onPointClick);
+
+ // axis titles
+ svg.append("text")
+ .attr("transform", "translate(" + (width/2) + " ," + (height+40) + ")")
+ .style("text-anchor", "middle")
+ .text(this.props.axes[0]);
+ svg.append("text")
+ .attr("transform", "rotate(-90)" + " " + "translate( 0, " + -10 + ")")
+ .attr("x", -(height/2))
+ .attr("y", -20)
+ .attr("height", 20)
+ .attr("width", 20)
+ .style("text-anchor", "middle")
+ .text(this.props.axes[1]);
};
private updateTooltip(
@@ -308,15 +348,42 @@ export class LineChart extends React.Component<LineChartProps> {
tooltip
.html(() => `<b>(${d0.x},${d0.y})</b>`) // text content for tooltip
.style('pointer-events', 'none')
- .style('transform', `translate(${xScale(d0.x) - this.width / 2}px,${yScale(d0.y) - 30}px)`);
+ .style('transform', `translate(${xScale(d0.x)-this.width}px,${yScale(d0.y)}px)`);
}
render() {
- const selectedPt = this._currSelected ? `x: ${this._currSelected.x} y: ${this._currSelected.y}` : 'none';
- return (
- <div ref={this._lineChartRef} className="chart-container">
- <span> {this.props.axes.length < 2 ? 'first use table view to select two axes to plot' : `Selected: ${selectedPt}`}</span>
+ this.componentDidMount();
+ var titleAccessor:any = '';
+ if (this.props.axes.length==2) titleAccessor = 'lineChart-title-'+this.props.axes[0]+'-'+this.props.axes[1];
+ else if (this.props.axes.length>0) titleAccessor = 'lineChart-title-'+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';
+ if (this._lineChartData.length>0){
+ return (
+ this.props.axes.length>=2 && /\d/.test(this.props.pairs[0][this.props.axes[0]]) && /\d/.test(this.props.pairs[0][this.props.axes[1]]) ? (
+ <div className="chart-container" >
+ <div className="graph-title">
+ <EditableText
+ val={StrCast(this.props.layoutDoc[titleAccessor])}
+ setVal={undoable (action(val => this.props.layoutDoc[titleAccessor] = val as string), "Change Graph Title")}
+ color={"black"}
+ size={Size.LARGE}
+ fillWidth
+ />
+ </div>
+ <div ref={this._lineChartRef} />
+ {selectedPt!='none'?
+ <div className={'selected-data'}> {`Selected: ${selectedPt}`}</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>
- );
+ )
}
}
diff --git a/src/client/views/nodes/DataVizBox/components/PieChart.tsx b/src/client/views/nodes/DataVizBox/components/PieChart.tsx
new file mode 100644
index 000000000..0c54d0a4e
--- /dev/null
+++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx
@@ -0,0 +1,380 @@
+import { observer } from "mobx-react";
+import { Doc, StrListCast } from "../../../../../fields/Doc";
+import * as React from 'react';
+import * as d3 from 'd3';
+import { IReactionDisposer, action, computed, observable, reaction } from "mobx";
+import { LinkManager } from "../../../../util/LinkManager";
+import { Cast, DocCast, StrCast} from "../../../../../fields/Types";
+import { PinProps, PresBox } from "../../trails";
+import { Docs } from "../../../../documents/Documents";
+import { List } from "../../../../../fields/List";
+import './Chart.scss';
+import { ColorPicker, EditableText, Size, Type } from "browndash-components";
+import { FaFillDrip } from "react-icons/fa";
+import { listSpec } from "../../../../../fields/Schema";
+import { undoable } from "../../../../util/UndoManager";
+
+export interface PieChartProps {
+ rootDoc: Doc;
+ layoutDoc: Doc;
+ axes: string[];
+ pairs: { [key: string]: any }[];
+ width: number;
+ height: number;
+ dataDoc: Doc;
+ fieldKey: string;
+ margin: {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ };
+}
+
+@observer
+export class PieChart extends React.Component<PieChartProps> {
+
+ private _disposers: { [key: string]: IReactionDisposer } = {};
+ private _piechartRef: React.RefObject<HTMLDivElement> = React.createRef();
+ private _piechartSvg: d3.Selection<SVGGElement, unknown, null, undefined> | undefined;
+ private byCategory: boolean = true; // whether the data is organized by category or by specified number percentages/ratios
+ @observable _currSelected: any | undefined = undefined; // Object of selected slice
+ private curSliceSelected: any = undefined; // d3 data of selected slice
+ private selectedData: any = undefined; // Selection of selected slice
+ private hoverOverData: any = undefined; // Selection of slice being hovered over
+
+ // filters all data to just display selected data if brushed (created from an incoming link)
+ @computed get _piechartData() {
+ var guids = StrListCast(this.props.layoutDoc.rowGuids);
+ if (this.props.axes.length < 1) return [];
+ if (this.props.axes.length < 2) {
+ var ax0 = this.props.axes[0];
+ if (/\d/.test(this.props.pairs[0][ax0])){ this.byCategory = false }
+ return this.props.pairs
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)])))
+ .map(pair => ({ [ax0]: (pair[this.props.axes[0]])}))
+ };
+ var ax0 = this.props.axes[0];
+ var ax1 = this.props.axes[1];
+ if (/\d/.test(this.props.pairs[0][ax0])) { this.byCategory = false; }
+ return this.props.pairs
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)])))
+ .map(pair => ({ [ax0]: (pair[this.props.axes[0]]), [ax1]: (pair[this.props.axes[1]]) }))
+ }
+
+ @computed get defaultGraphTitle(){
+ var ax0 = this.props.axes[0];
+ var ax1 = (this.props.axes.length>1)? this.props.axes[1] : undefined;
+ if (this.props.axes.length<2 || !/\d/.test(this.props.pairs[0][ax0]) || !ax1){
+ return ax0 + " Pie Chart";
+ }
+ else return ax1 + " by " + ax0 + " Pie Chart";
+ }
+
+ @computed get incomingLinks() {
+ return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links
+ .filter(link => link.link_anchor_1 == this.props.rootDoc.draggedFrom) // 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 }) => {
+ if (dataSet!.length>0) {
+ this.drawChart(dataSet, w, h);
+ }
+ },
+ { fireImmediately: true }
+ );
+ };
+
+ @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.dataDoc);
+ return anchor;
+ };
+
+ @computed get height() {
+ return this.props.height - this.props.margin.top - this.props.margin.bottom;
+ }
+
+ @computed get width() {
+ return this.props.width - this.props.margin.left - this.props.margin.right;
+ }
+
+ // cleans data by converting numerical data to numbers and taking out empty cells
+ data = (dataSet: any) => {
+ var validData = dataSet.filter((d: { [x: string]: unknown; }) => {
+ var valid = true;
+ Object.keys(dataSet[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ })
+ return valid;
+ })
+ var field = dataSet[0]? Object.keys(dataSet[0])[0] : undefined;
+ const data = validData.map((d: { [x: string]: any; }) => {
+ if (!this.byCategory) { return +d[field!].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') }
+ return d[field!]
+ })
+ return data;
+ }
+
+ // 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;
+ 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
+
+ // draw an imaginary horizontal line from the pointer to see how many times it crosses a slice edge
+ var 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
+ if (pointer[0] <= (pointer[1]-p1[1])*(p2[0]-p1[0])/(p2[1]-p1[1])+p1[0]) lineCrossCount++; } // intercepts x
+ if (Math.min(p2[1], p3[1])<=pointer[1] && pointer[1]<=Math.max(p2[1], p3[1])){
+ if (pointer[0] <= (pointer[1]-p2[1])*(p3[0]-p2[0])/(p3[1]-p2[1])+p2[0]) lineCrossCount++; }
+ if (Math.min(p3[1], p4[1])<=pointer[1] && pointer[1]<=Math.max(p3[1], p4[1])){
+ if (pointer[0] <= (pointer[1]-p3[1])*(p4[0]-p3[0])/(p4[1]-p3[1])+p3[0]) lineCrossCount++; }
+ 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) { // inside the slice of it crosses an odd number of edges
+ var showSelected = this.byCategory? pieDataSet[index] : this._piechartData[index];
+ 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;
+ }
+ else this.hoverOverData = d;
+ return true;
+ }
+ return false;
+ });
+ if (changeSelectedVariables){
+ if (sameAsCurrent!) this.curSliceSelected = undefined;
+ else this.curSliceSelected = selected;
+ }
+ }
+
+ // 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();
+
+ 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
+
+ // converts data into Objects
+ var data = this.data(dataSet);
+ var pieDataSet = dataSet.filter((d: { [x: string]: unknown; }) => {
+ var valid = true;
+ Object.keys(dataSet[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ })
+ return valid;
+ });
+ if (this.byCategory){
+ let uniqueCategories = [...new Set(data)]
+ var 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;
+ }
+ pieDataSet = pieStringDataSet
+ percentField = Object.keys(pieDataSet[0])[0]
+ descriptionField = Object.keys(pieDataSet[0])[1]!
+ data = this.data(pieStringDataSet)
+ }
+ var 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)
+ .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);
+
+ // click/hover
+ const onPointClick = action((e: any) => this.highlightSelectedSlice(true, svg, arc, radius, d3.pointer(e), pieDataSet));
+ const onHover = action((e: any) => {
+ const selected = this.highlightSelectedSlice(false, svg, arc, radius, d3.pointer(e), pieDataSet)
+ updateHighlights();
+ })
+ const mouseOut = action((e: any) => {
+ 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")
+ arcs.append("path")
+ .attr("fill", (d, i)=>{
+ var possibleDataPoints = pieDataSet.filter((each: { [x: string]: any | { valueOf(): number; }; }) => {
+ try {
+ return Number(each[percentField].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''))==Number(d.data)
+ } catch (error) {
+ return each[percentField]==d.data
+ }})
+ var dataPoint;
+ if (possibleDataPoints.length==1) dataPoint = possibleDataPoints[0];
+ else{
+ dataPoint = possibleDataPoints[trackDuplicates[d.data.toString()]]
+ trackDuplicates[d.data.toString()] = trackDuplicates[d.data.toString()] + 1;
+ }
+ var sliceColor;
+ if (dataPoint){
+ var accessByName = dataPoint[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '');
+ var sliceColors = StrListCast(this.props.layoutDoc.pieSliceColors).map(each => each.split('::'));
+ sliceColors.map(each => {if (each[0]==StrCast(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('d', arc)
+ .on('click', onPointClick)
+ .on('mouseover', onHover)
+ .on('mouseout', mouseOut);
+
+ // adding labels
+ trackDuplicates = {};
+ data.forEach((eachData: any) => !trackDuplicates[eachData]? trackDuplicates[eachData] = 0: null)
+ 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])
+ return "translate("+ (centroid[0]+centroid[0]/(radius*.02)) + "," + (centroid[1]+heightOffset) + ")"; })
+ .attr("text-anchor", "middle")
+ .text(function(d){
+ var possibleDataPoints = pieDataSet.filter((each: { [x: string]: any | { valueOf(): number; }; }) => {
+ try {
+ return Number(each[percentField].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''))==Number(d.data)
+ } catch (error) {
+ return each[percentField]==d.data
+ }})
+ var dataPoint;
+ if (possibleDataPoints.length==1) dataPoint = possibleDataPoints[0];
+ else{
+ dataPoint = possibleDataPoints[trackDuplicates[d.data.toString()]]
+ trackDuplicates[d.data.toString()] = trackDuplicates[d.data.toString()] + 1;
+ }
+ return dataPoint? dataPoint[percentField]! + (!descriptionField? '' : (' - ' + dataPoint[descriptionField]))! : ''})
+ };
+
+ @action changeSelectedColor = (color: string) => {
+ this.curSliceSelected.attr("fill", color);
+ var sliceName = this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')
+
+ const sliceColors = Cast(this.props.layoutDoc.pieSliceColors, listSpec("string"), null);
+ sliceColors.map(each => { if (each.split('::')[0] == sliceName) sliceColors.splice(sliceColors.indexOf(each), 1) });
+ sliceColors.push(StrCast(sliceName + '::' + color));
+ };
+
+ render() {
+ this.componentDidMount();
+ var titleAccessor: any='';
+ if (this.props.axes.length==2) titleAccessor = 'pieChart-title-'+this.props.axes[0]+'-'+this.props.axes[1];
+ else if (this.props.axes.length>0) titleAccessor = 'pieChart-title-'+this.props.axes[0];
+ if (!this.props.layoutDoc[titleAccessor]) this.props.layoutDoc[titleAccessor] = this.defaultGraphTitle;
+ if (!this.props.layoutDoc.pieSliceColors) this.props.layoutDoc.pieSliceColors = new List<string>();
+ var selected: string;
+ var curSelectedSliceName;
+ if (this._currSelected){
+ curSelectedSliceName = StrCast(this._currSelected![this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''))
+ selected = '{ ';
+ Object.keys(this._currSelected).map(key => {
+ key!=''? selected += key + ': ' + this._currSelected[key] + ', ': '';
+ })
+ selected = selected.substring(0, selected.length-2);
+ selected += ' }';
+ }
+ else selected = 'none';
+ var selectedSliceColor;
+ var sliceColors = StrListCast(this.props.layoutDoc.pieSliceColors).map(each => each.split('::'));
+ sliceColors.map(each => {if (each[0]==curSelectedSliceName!) selectedSliceColor = each[1]});
+
+ if (this._piechartData.length>0){
+ return (
+ this.props.axes.length >= 1 ? (
+ <div className="chart-container">
+ <div className="graph-title">
+ <EditableText
+ val={StrCast(this.props.layoutDoc[titleAccessor])}
+ setVal={undoable (action(val => this.props.layoutDoc[titleAccessor] = val as string), "Change Graph Title")}
+ color={"black"}
+ size={Size.LARGE}
+ fillWidth
+ />
+ </div>
+ <div ref={this._piechartRef} />
+ {selected != 'none' ?
+ <div className={'selected-data'}>
+ Selected: {selected}
+ &nbsp; &nbsp;
+ <ColorPicker
+ tooltip={'Change Slice Color'}
+ type={Type.SEC}
+ icon={<FaFillDrip/>}
+ selectedColor={selectedSliceColor? selectedSliceColor : this.curSliceSelected.attr("fill")}
+ setSelectedColor={undoable (color => this.changeSelectedColor(color), "Change Selected Slice Color")}
+ size={Size.XSMALL}
+ />
+ </div>
+ : 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>
+ )
+ }
+
+} \ No newline at end of file
diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx
index d84e34d52..f56d34fa6 100644
--- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx
+++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx
@@ -1,105 +1,180 @@
-import { action, computed } from 'mobx';
+import { action, computed, } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
-import { Doc } from '../../../../../fields/Doc';
-import { Id } from '../../../../../fields/FieldSymbols';
+import { Doc, Field, StrListCast } from '../../../../../fields/Doc';
import { List } from '../../../../../fields/List';
-import { emptyFunction, returnFalse, setupMoveUpEvents, Utils } from '../../../../../Utils';
+import { emptyFunction, setupMoveUpEvents, Utils } from '../../../../../Utils';
import { DragManager } from '../../../../util/DragManager';
import { DocumentView } from '../../DocumentView';
import { DataVizView } from '../DataVizBox';
+import { LinkManager } from '../../../../util/LinkManager';
+import { Cast, DocCast } from '../../../../../fields/Types';
+import './Chart.scss';
+import { listSpec } from '../../../../../fields/Schema';
interface TableBoxProps {
+ rootDoc: Doc;
+ layoutDoc: Doc;
pairs: { [key: string]: any }[];
selectAxes: (axes: string[]) => void;
axes: string[];
+ width: number;
+ height: number;
+ margin: {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ };
docView?: () => DocumentView | undefined;
}
@observer
export class TableBox extends React.Component<TableBoxProps> {
+
+ // filters all data to just display selected data if brushed (created from an incoming link)
+ @computed get _tableData() {
+ if (this.incomingLinks.length! <= 0) return this.props.pairs;
+ var guids = StrListCast(this.props.layoutDoc.rowGuids);
+ return this.props.pairs?.filter(pair => this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)]))
+ }
+
+ @computed get incomingLinks() {
+ return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links
+ .filter(link => {
+ return link.link_anchor_1 == this.props.rootDoc.draggedFrom}) // 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.props.pairs.length ? Array.from(Object.keys(this.props.pairs[0])) : [];
+ if (!this.props.layoutDoc.rowGuids) this.props.layoutDoc.rowGuids = new List<string>();
+ const guids = Cast(this.props.layoutDoc.rowGuids, listSpec("string"), null);
+ if (guids.length==0) this.props.pairs.map(row => guids.push(Utils.GenerateGuid()));
+ return this._tableData.length ? Array.from(Object.keys(this._tableData[0])).filter(header => header!='' && header!=undefined) : [];
+ }
+
+ // updates the 'selected' field to no longer include rows that aren't in the table
+ filterSelectedRowsDown() {
+ if (!this.props.layoutDoc.selected) this.props.layoutDoc.selected = new List<string>();
+ const selected = Cast(this.props.layoutDoc.selected, listSpec("string"), null);
+ const incomingSelected = this.incomingLinks.length? StrListCast(this.incomingLinks[0].selected) : undefined;
+ if (incomingSelected){
+ selected.map(guid => {
+ if (!incomingSelected.includes(guid)) selected.splice(selected.indexOf(guid), 1)}); // filters through selected to remove guids that were removed in the incoming data
+ }
}
+
render() {
- return (
- <div className="table-container">
- <table className="table">
- <thead>
- <tr className="table-row">
- {this.columns
- .filter(col => !col.startsWith('select'))
- .map(col => {
- const header = React.createRef<HTMLElement>();
+ this.filterSelectedRowsDown();
+ if (this._tableData.length>0){
+ return (
+ <div className="table-container" style={{height: this.props.height}}>
+ <table className="table">
+ <thead>
+ <tr className="table-row">
+ {this.columns
+ .filter(col => !col.startsWith('select'))
+ .map(col => {
+ const header = React.createRef<HTMLElement>();
+ return (
+ <th
+ key={this.columns.indexOf(col)}
+ ref={header as any}
+ style={{
+ color: this.props.axes.slice().reverse().lastElement() === col ? 'darkgreen' : this.props.axes.lastElement() === col ? 'darkred' : undefined,
+ background: this.props.axes.slice().reverse().lastElement() === col ? '#E3fbdb' : this.props.axes.lastElement() === col ? '#Fbdbdb' : undefined,
+ fontWeight: 'bolder', border: '3px solid black'
+ }}
+ onPointerDown={e => {
+ const downX = e.clientX;
+ const downY = e.clientY;
+ setupMoveUpEvents(
+ {},
+ e,
+ e => { // dragging off a column to create a brushed DataVizBox
+ const sourceAnchorCreator = () => this.props.docView?.()!.rootDoc!;
+ const targetCreator = (annotationOn: Doc | undefined) => {
+ const embedding = Doc.MakeEmbedding(this.props.docView?.()!.rootDoc!);
+ embedding._dataVizView = DataVizView.TABLE;
+ embedding._data_vizAxes = new List<string>([col, col]);
+ embedding._draggedFrom = this.props.docView?.()!.rootDoc!;
+ embedding.annotationOn = annotationOn; //this.props.docView?.()!.rootDoc!;
+ embedding.histogramBarColors = Field.Copy(this.props.layoutDoc.histogramBarColors);
+ embedding.defaultHistogramColor = this.props.layoutDoc.defaultHistogramColor;
+ embedding.pieSliceColors = Field.Copy(this.props.layoutDoc.pieSliceColors);
+ return embedding;
+ };
+ if (this.props.docView?.() && !Utils.isClick(e.clientX, e.clientY, downX, downY, Date.now())) {
+ DragManager.StartAnchorAnnoDrag([header.current!], 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.annoDragData.linkSourceDoc.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this.props.rootDoc;
+ // e.annoDragData.linkSourceDoc.followLinkZoom = false;
+ }
+ },
+ });
+ return true;
+ }
+ return false;
+ },
+ emptyFunction,
+ action(e => {
+ const newAxes = this.props.axes;
+ if (newAxes.includes(col)) {
+ newAxes.splice(newAxes.indexOf(col), 1);
+ } else if (newAxes.length > 1) {
+ newAxes[1] = col;
+ } else {
+ newAxes.push(col);
+ }
+ this.props.selectAxes(newAxes);
+ })
+ );
+ }}>
+ {col}
+ </th>
+ );
+ })}
+ </tr>
+ </thead>
+ <tbody>
+ {this._tableData?.map((p, i) => {
+ var containsData = false;
+ var guid = StrListCast(this.props.layoutDoc.rowGuids)![this.props.pairs.indexOf(p)]
+ this.columns.map(col => {if (p[col]!='' && p[col]!=null && p[col]!=undefined) containsData = true})
+ if (containsData){
return (
- <th
- ref={header as any}
- style={{
- color: this.props.axes.slice().reverse().lastElement() === col ? 'green' : this.props.axes.lastElement() === col ? 'red' : undefined,
- fontWeight: this.props.axes.includes(col) ? 'bolder' : 'normal',
- }}
- onPointerDown={e => {
- const downX = e.clientX;
- const downY = e.clientY;
- setupMoveUpEvents(
- {},
- e,
- e => {
- const sourceAnchorCreator = () => this.props.docView?.()!.rootDoc!;
- const targetCreator = (annotationOn: Doc | undefined) => {
- const embedding = Doc.MakeEmbedding(this.props.docView?.()!.rootDoc!);
- embedding._dataVizView = DataVizView.LINECHART;
- embedding._data_vizAxes = new List<string>([col, col]);
- embedding.annotationOn = annotationOn; //this.props.docView?.()!.rootDoc!;
- return embedding;
- };
- if (this.props.docView?.() && !Utils.isClick(e.clientX, e.clientY, downX, downY, Date.now())) {
- DragManager.StartAnchorAnnoDrag([header.current!], 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.annoDragData.linkSourceDoc.followLinkToggle = e.annoDragData.dropDocument.annotationOn === this.props.rootDoc;
- // e.annoDragData.linkSourceDoc.followLinkZoom = false;
- }
- },
- });
- return true;
- }
- return false;
- },
- emptyFunction,
- action(e => {
- const newAxes = this.props.axes;
- if (newAxes.includes(col)) {
- newAxes.splice(newAxes.indexOf(col), 1);
- } else if (newAxes.length >= 1) {
- newAxes[1] = col;
- } else {
- newAxes[0] = col;
- }
- this.props.selectAxes(newAxes);
- })
- );
- }}>
- {col}
- </th>
+ <tr key={i} className="table-row" onClick={action(e => {
+ // selecting a row
+ const selected = Cast(this.props.layoutDoc.selected, listSpec("string"), null);
+ if (selected.includes(guid)) selected.splice(selected.indexOf(guid), 1);
+ else {
+ selected.push(guid)};
+ })} style={{ background: StrListCast(this.props.layoutDoc.selected).includes(guid) ? 'lightgrey' : '', width: '110%' }}>
+ {this.columns.map(col => {
+ // each cell
+ var colSelected = this.props.axes[0]==col || this.props.axes[1]==col;
+ return (
+ <td key={this.columns.indexOf(col)} style={{border: colSelected? '3px solid black' : '1px solid black', fontWeight: colSelected? 'bolder' : 'normal'}}>
+ {p[col]}
+ </td>
+ )})}
+ </tr>
);
- })}
- </tr>
- </thead>
- <tbody>
- {this.props.pairs?.map((p, i) => {
- return (
- <tr className="table-row" onClick={action(e => (p['select' + this.props.docView?.()?.rootDoc![Id]] = !p['select' + this.props.docView?.()?.rootDoc![Id]]))}>
- {this.columns.map(col => (
- <td style={{ fontWeight: p['select' + this.props.docView?.()?.rootDoc![Id]] ? 'bold' : '' }}>{p[col]}</td>
- ))}
- </tr>
- );
- })}
- </tbody>
- </table>
+ }
+ })}
+ </tbody>
+ </table>
+ </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>
- );
+ )
}
}
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index bb9f45bdd..90fb55290 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -846,6 +846,10 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
documentationDescription = 'See text node documentation';
documentationLink = 'https://brown-dash.github.io/Dash-Documentation/documents/text/';
break;
+ case DocumentType.DATAVIZ:
+ documentationDescription = 'See DataViz node documentation';
+ documentationLink = 'https://brown-dash.github.io/Dash-Documentation/documents/dataViz/';
+ break;
}
// Add link to help documentation
if (!this.props.treeViewDoc && documentationDescription && documentationLink) {
@@ -974,8 +978,8 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps
TraceMobx();
return LinkManager.Instance.getAllRelatedLinks(this.rootDoc).filter(
link =>
- Doc.AreProtosEqual(link.link_anchor_1 as Doc, this.rootDoc) ||
- Doc.AreProtosEqual(link.link_anchor_2 as Doc, this.rootDoc) ||
+ (link.link_matchEmbeddings ? link.link_anchor_1 === this.rootDoc : Doc.AreProtosEqual(link.link_anchor_1 as Doc, this.rootDoc)) ||
+ (link.link_matchEmbeddings ? link.link_anchor_2 === this.rootDoc : Doc.AreProtosEqual(link.link_anchor_2 as Doc, this.rootDoc)) ||
((link.link_anchor_1 as Doc)?.layout_unrendered && Doc.AreProtosEqual((link.link_anchor_1 as Doc)?.annotationOn as Doc, this.rootDoc)) ||
((link.link_anchor_2 as Doc)?.layout_unrendered && Doc.AreProtosEqual((link.link_anchor_2 as Doc)?.annotationOn as Doc, this.rootDoc))
);
@@ -1356,7 +1360,7 @@ export class DocumentView extends React.Component<DocumentViewProps> {
return this.docView?._componentView;
}
get allLinks() {
- return this.docView?.allLinks || [];
+ return (this.docView?.allLinks || []).filter(link => !link.link_matchEmbeddings || link.link_anchor_1 === this.rootDoc || link.link_anchor_2 === this.rootDoc);
}
get LayoutFieldKey() {
return this.docView?.LayoutFieldKey || 'layout';