diff options
Diffstat (limited to 'src')
10 files changed, 993 insertions, 57 deletions
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 5ef033e35..426eaa14d 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -1143,7 +1143,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/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 090cf356c..7e574e839 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -170,6 +170,14 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque TreeView._editTitleOnLoad = { id: slide[Id], parent: undefined }; this.props.addDocument?.(slide); e.stopPropagation(); + } else if (e.key === 't' && 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'); @@ -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..32c0bbfc1 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.scss +++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss @@ -1,4 +1,10 @@ .dataviz { - overflow: auto; + overflow: hidden; height: 100%; + width: 100%; + + .datatype-button{ + display: flex; + flex-direction: row; + } } diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index d548ab9f1..4ddebb833 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -12,10 +12,15 @@ 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 } from 'browndash-components'; export enum DataVizView { TABLE = 'table', LINECHART = 'lineChart', + HISTOGRAM = 'histogram', + PIECHART = 'pieChart' } @observer @@ -99,8 +104,10 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { 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.TABLE: return <TableBox pairs={this.pairs} axes={this.axes} rootDoc={this.rootDoc} 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.HISTOGRAM: return <Histogram 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 height={height} width={width} fieldKey={this.fieldKey} margin={margin} rootDoc={this.rootDoc} axes={this.axes} pairs={this.pairs} dataDoc={this.dataDoc} />; } } @computed get dataUrl() { @@ -127,6 +134,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } render() { + if (!this.layoutDoc._dataVizView) this.layoutDoc._dataVizView = this.dataVizView; return !this.pairs?.length ? ( <div>Loading...</div> ) : ( @@ -143,7 +151,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={"secondary"} color={"black"} + onClick={e => this.layoutDoc._dataVizView = DataVizView.TABLE} + toggleStatus={this.layoutDoc._dataVizView == DataVizView.TABLE} + /> + <Toggle text={"LINECHART"} toggleType={ToggleType.BUTTON} type={"secondary"} color={"black"} + onClick={e => this.layoutDoc._dataVizView = DataVizView.LINECHART} + toggleStatus={this.layoutDoc._dataVizView == DataVizView.LINECHART} + /> + <Toggle text={"HISTOGRAM"} toggleType={ToggleType.BUTTON} type={"secondary"} color={"black"} + onClick={e => this.layoutDoc._dataVizView = DataVizView.HISTOGRAM} + toggleStatus={this.layoutDoc._dataVizView == DataVizView.HISTOGRAM} + /> + <Toggle text={"PIE CHART"} toggleType={ToggleType.BUTTON} type={"secondary"} 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..5945840b5 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -3,6 +3,31 @@ flex-direction: column; align-items: center; cursor: default; + margin-top: 10px; + + .graph{ + overflow: visible; + } + .graph-title{ + align-items: center; + font-size: larger; + } + .selected-data{ + align-items: center; + } + .slice { + &.hover { + stroke: black; + } + } + + .histogram-bar{ + outline: thin solid black; + fill: #69b3a2; + &.hover{ + fill: grey; + } + } .tooltip { // make the height width bigger 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..fdde29c81 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -0,0 +1,443 @@ +import { observer } from "mobx-react"; +import { Doc, DocListCast } 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} from "../../../../../fields/Types"; +import { DataPoint, SelectedDataPoint } from "./LineChart"; +import { DocumentManager } from "../../../../util/DocumentManager"; +import { Id } from "../../../../../fields/FieldSymbols"; +import { DataVizBox } from "../DataVizBox"; +import { listSpec } from "../../../../../fields/Schema"; +import { PinProps, PresBox } from "../../trails"; +import { Docs } from "../../../../documents/Documents"; +import { List } from "../../../../../fields/List"; +import './Chart.scss'; + +export interface HistogramProps { + rootDoc: 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; + // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates + + @computed get _histogramData() { + 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 : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .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.props.pairs.length < this.maxBins) { this.numericalYData = true;} + return this.props.pairs + ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .map(pair => ({ [ax0]: (pair[this.props.axes[0]]), [ax1]: (pair[this.props.axes[1]]) })) + // .sort((a, b) => (a[ax0] < b[ax0] ? -1 : 1)); + return this.props.pairs + ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .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(){ + 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 + " Histogram"; + } + else return ax1 + " by " + ax0 + " Histogram"; + } + @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 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) + .map(dvb => dvb.pairs?.filter((pair: { [x: string]: any; }) => pair['select' + dvb.rootDoc[Id]])) // get all the datapoints they have selected field set by incoming anchor + .lastElement(); + } + @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); + + // redraw annotations when the chart data has changed, or the local or inherited selection has changed + this.clearAnnotations(); + this._currSelected && this.drawAnnotations(Number(this._currSelected.x), Number(this._currSelected.y), true); + this.incomingSelected?.forEach((pair: any) => this.drawAnnotations(Number(pair[this.props.axes[0]]), Number(pair[this.props.axes[1]]))); + } + }, + { fireImmediately: true } + ); + this._disposers.annos = reaction( + () => DocListCast(this.props.dataDoc[this.props.fieldKey + '_annotations']), + annotations => { + // modify how d3 renders so that anything in this annotations list would be potentially highlighted in some way + // could be blue colored to make it look like anchor + // this.drawAnnotations() + // loop through annotations and draw them + annotations.forEach(a => this.drawAnnotations(Number(a.x), Number(a.y))); + // this.drawAnnotations(annotations.x, annotations.y); + }, + { fireImmediately: true } + ); + this._disposers.highlights = reaction( + () => ({ + selected: this._currSelected, + incomingSelected: this.incomingSelected, + }), + ({ selected, incomingSelected }) => { + // redraw annotations when the chart data has changed, or the local or inherited selection has changed + this.clearAnnotations(); + selected && this.drawAnnotations(Number(selected.x), Number(selected.y), true); + incomingSelected?.forEach((pair: any) => this.drawAnnotations(Number(pair[this.props.axes[0]]), Number(pair[this.props.axes[1]]))); + }, + { fireImmediately: true } + ); + }; + + // anything that doesn't need to be recalculated should just be stored as drawCharts (i.e. computed values) and drawChart is gonna iterate over these observables and generate svgs based on that + + clearAnnotations = () => { + const elements = document.querySelectorAll('.datapoint'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + element.classList.remove('brushed'); + element.classList.remove('selected'); + } + }; + // gets called whenever the "data_annotations" fields gets updated + drawAnnotations = (dataX: number, dataY: number, selected?: boolean) => { + // TODO: nda - can optimize this by having some sort of mapping of the x and y values to the individual circle elements + // loop through all html elements with class .circle-d1 and find the one that has "data-x" and "data-y" attributes that match the dataX and dataY + // if it exists, then highlight it + // if it doesn't exist, then remove the highlight + const elements = document.querySelectorAll('.datapoint'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const x = element.getAttribute('data-x'); + const y = element.getAttribute('data-y'); + if (x === dataX.toString() && y === dataY.toString()) { + element.classList.add(selected ? 'selected' : 'brushed'); + } + // TODO: nda - this remove highlight code should go where we remove the links + // } else { + // } + } + }; + + removeAnnotations(dataX: number, dataY: number) { + // loop through and remove any annotations that no longer exist + } + + @action + restoreView = (data: Doc) => { + const coords = Cast(data.presDataVizSelection, listSpec('number'), null); + if (coords?.length > 1 && (this._currSelected?.x !== coords[0] || this._currSelected?.y !== coords[1])) { + this.setCurrSelected(coords[0], coords[1]); + return true; + } + if (this._currSelected) { + this.setCurrSelected(); + return true; + } + return false; + }; + + // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc) + getAnchor = (pinProps?: PinProps) => { + const anchor = Docs.Create.ConfigDocument({ + // + title: 'histogram doc selection' + this._currSelected?.x, + }); + PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this.props.dataDoc); + anchor.presDataVizSelection = this._currSelected ? new List<number>([this._currSelected.x, this._currSelected.y]) : undefined; + 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; + } + + setupTooltip() { + return d3 + .select(this._histogramRef.current) + .append('div') + .attr('class', 'tooltip') + .style('opacity', 0) + .style('background', '#fff') + .style('border', '1px solid #ccc') + .style('padding', '5px') + .style('position', 'absolute') + .style('font-size', '12px'); + } + + // TODO: nda - use this everyewhere we update currSelected? + @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; + 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)); + } + + 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; + } + + 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][xAxisTitle]))? (this.rangeVals.xMax! - this.rangeVals.xMin! + 1) : 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; + 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 sliceData = histStringDataSet.filter(each => each[xAxisTitle]==data[i]) + sliceData[0][yAxisTitle] = sliceData[0][yAxisTitle] + 1; + } + } + histDataSet = histStringDataSet + } + + 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-1)) + var bins = histogram(data) + var eachRectWidth = width/(bins.length) + var graphStartingPoint = bins[0].x1? 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; + + if (!this.numericalXData) { // reorganize 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; + } + } + 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) + .tickFormat( i => uniqueArr[i]) + .tickPadding(10) + x.range([0, width-eachRectWidth]) + x.domain([0, bins.length-1]) + translateXAxis = eachRectWidth / 2; + } + else { + xAxis = d3.axisBottom(x) + .ticks(numBins-1) + } + const maxFrequency = this.numericalYData? d3.max(histDataSet, function(d) { return d[yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')}) + : 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!) + svg.append("g") + .call(yAxis); + svg.append("g") + .attr("transform", "translate(" + translateXAxis + ", " + height + ")") + .call(xAxis) + + const onPointClick = action((e: any) => { + var pointerX = d3.pointer(e)[0]; + var sameAsCurrent: boolean; + var barCounter = -1; + const selected = svg.selectAll('.histogram-bar').filter((d: any) => { + barCounter++; + if ((barCounter*eachRectWidth ) <= pointerX && pointerX <= ((barCounter+1)*eachRectWidth)){ + var showSelected = this.numericalYData? this.props.pairs.filter((data: { [x: string]: any; }) => 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){ + 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} + } + sameAsCurrent = this._currSelected? + (showSelected[xAxisTitle]==this._currSelected![xAxisTitle] + && showSelected[yAxisTitle]==this._currSelected![yAxisTitle]) + : false; + this._currSelected = sameAsCurrent? undefined: showSelected; + return true + } + return false; + }); + const elements = document.querySelectorAll('.histogram-bar'); + for (let i = 0; i < elements.length; i++) { + elements[i].classList.remove('hover'); + } + if (!sameAsCurrent!) selected.attr('class', 'histogram-bar hover'); + }); + svg.on('click', onPointClick); + + 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') + 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[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''); + 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[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''); + return height-y(length)} + : function(d) { + return height - y(d.length)}) + .attr("width", eachRectWidth) + .attr("class", 'histogram-bar') + }; + + render() { + + var selected: string; + if (this._currSelected){ + selected = '{ '; + Object.keys(this._currSelected).map(key => { + key!=''? selected += key + ': ' + this._currSelected[key] + ', ': ''; + }) + selected = selected.substring(0, selected.length-2); + selected += ' }'; + } + else selected = 'none'; + return ( + this.props.axes.length >= 1 ? ( + <div className="chart-container" > + <div className="graph-title"> {this.graphTitle} </div> + <div className={'selected-data'}> {`Selected: ${selected}`}</div> + <div ref={this._histogramRef} /> + </div> + ) : <span className="chart-container"> {'first use table view to select a column to graph'}</span> + ); + } + +}
\ 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..da79df476 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -1,7 +1,6 @@ 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 { Id } from '../../../../../fields/FieldSymbols'; @@ -20,7 +19,7 @@ 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 { @@ -54,12 +53,18 @@ export class LineChart extends React.Component<LineChartProps> { .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) @@ -219,7 +224,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 +243,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 +255,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 +306,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( @@ -314,9 +341,13 @@ export class LineChart extends React.Component<LineChartProps> { 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.props.axes.length >= 2 ? ( + <div className="chart-container" > + <div className="graph-title"> {this.graphTitle} </div> + <div className={'selected-data'}> {`Selected: ${selectedPt}`}</div> + <div ref={this._lineChartRef} /> </div> + ) : <span className="chart-container"> {'first use table view to select two axes to plot'}</span> ); } } 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..27653b847 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx @@ -0,0 +1,393 @@ +import { observer } from "mobx-react"; +import { Doc, DocListCast } 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} from "../../../../../fields/Types"; +import { DataPoint, SelectedDataPoint } from "./LineChart"; +import { DocumentManager } from "../../../../util/DocumentManager"; +import { Id } from "../../../../../fields/FieldSymbols"; +import { DataVizBox } from "../DataVizBox"; +import { listSpec } from "../../../../../fields/Schema"; +import { PinProps, PresBox } from "../../trails"; +import { Docs } from "../../../../documents/Documents"; +import { List } from "../../../../../fields/List"; +import './Chart.scss'; + +export interface PieChartProps { + rootDoc: 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; + // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates + + @computed get _piechartData() { + 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 : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .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 : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .map(pair => ({ [ax0]: (pair[this.props.axes[0]]), [ax1]: (pair[this.props.axes[1]]) })) + // .sort((a, b) => (a[ax0] < b[ax0] ? -1 : 1)); + return this.props.pairs + ?.filter(pair => (!this.incomingLinks.length ? true : Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + .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(){ + 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 => { + 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) + .map(dvb => dvb.pairs?.filter((pair: { [x: string]: any; }) => pair['select' + dvb.rootDoc[Id]])) // get all the datapoints they have selected field set by incoming anchor + .lastElement(); + } + @computed get rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } { + if (!this.byCategory){ + const data = this.data(this._piechartData); + 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._piechartData, w: this.width, h: this.height }), + ({ dataSet, w, h }) => { + if (dataSet!.length>0) { + this.drawChart(dataSet, w, h); + + // redraw annotations when the chart data has changed, or the local or inherited selection has changed + this.clearAnnotations(); + this._currSelected && this.drawAnnotations(Number(this._currSelected.x), Number(this._currSelected.y), true); + this.incomingSelected?.forEach((pair: any) => this.drawAnnotations(Number(pair[this.props.axes[0]]), Number(pair[this.props.axes[1]]))); + } + }, + { fireImmediately: true } + ); + this._disposers.annos = reaction( + () => DocListCast(this.props.dataDoc[this.props.fieldKey + '_annotations']), + annotations => { + // modify how d3 renders so that anything in this annotations list would be potentially highlighted in some way + // could be blue colored to make it look like anchor + // this.drawAnnotations() + // loop through annotations and draw them + annotations.forEach(a => this.drawAnnotations(Number(a.x), Number(a.y))); + // this.drawAnnotations(annotations.x, annotations.y); + }, + { fireImmediately: true } + ); + this._disposers.highlights = reaction( + () => ({ + selected: this._currSelected, + incomingSelected: this.incomingSelected, + }), + ({ selected, incomingSelected }) => { + // redraw annotations when the chart data has changed, or the local or inherited selection has changed + this.clearAnnotations(); + selected && this.drawAnnotations(Number(selected.x), Number(selected.y), true); + incomingSelected?.forEach((pair: any) => this.drawAnnotations(Number(pair[this.props.axes[0]]), Number(pair[this.props.axes[1]]))); + }, + { fireImmediately: true } + ); + }; + + // anything that doesn't need to be recalculated should just be stored as drawCharts (i.e. computed values) and drawChart is gonna iterate over these observables and generate svgs based on that + + clearAnnotations = () => { + const elements = document.querySelectorAll('.datapoint'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + element.classList.remove('brushed'); + element.classList.remove('selected'); + } + }; + // gets called whenever the "data_annotations" fields gets updated + drawAnnotations = (dataX: number, dataY: number, selected?: boolean) => { + // TODO: nda - can optimize this by having some sort of mapping of the x and y values to the individual circle elements + // loop through all html elements with class .circle-d1 and find the one that has "data-x" and "data-y" attributes that match the dataX and dataY + // if it exists, then highlight it + // if it doesn't exist, then remove the highlight + const elements = document.querySelectorAll('.datapoint'); + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + const x = element.getAttribute('data-x'); + const y = element.getAttribute('data-y'); + if (x === dataX.toString() && y === dataY.toString()) { + element.classList.add(selected ? 'selected' : 'brushed'); + } + // TODO: nda - this remove highlight code should go where we remove the links + // } else { + // } + } + }; + + removeAnnotations(dataX: number, dataY: number) { + // loop through and remove any annotations that no longer exist + } + + @action + restoreView = (data: Doc) => { + const coords = Cast(data.presDataVizSelection, listSpec('number'), null); + if (coords?.length > 1 && (this._currSelected?.x !== coords[0] || this._currSelected?.y !== coords[1])) { + this.setCurrSelected(coords[0], coords[1]); + return true; + } + if (this._currSelected) { + this.setCurrSelected(); + return true; + } + return false; + }; + + // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc) + getAnchor = (pinProps?: PinProps) => { + const anchor = Docs.Create.ConfigDocument({ + // + title: 'piechart doc selection' + this._currSelected?.x, + }); + PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this.props.dataDoc); + anchor.presDataVizSelection = this._currSelected ? new List<number>([this._currSelected.x, this._currSelected.y]) : undefined; + 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; + } + + setupTooltip() { + return d3 + .select(this._piechartRef.current) + .append('div') + .attr('class', 'tooltip') + .style('opacity', 0) + .style('background', '#fff') + .style('border', '1px solid #ccc') + .style('padding', '5px') + .style('position', 'absolute') + .style('font-size', '12px'); + } + + // TODO: nda - use this everyewhere we update currSelected? + @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; + 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)); + } + + 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; + } + + 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) / 2 - Math.max(this.props.margin.top, this.props.margin.bottom, this.props.margin.left, this.props.margin.right) + 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 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, [percentField]: string }[] = []; + 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 => 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) + + var pie = d3.pie(); + var arc = d3.arc() + .innerRadius(0) + .outerRadius(radius); + + const onPointClick = action((e: any) => { + // check the 4 'corners' of each slice and see if the pointer is within those bounds to get the slice the user clicked on + const pointer = d3.pointer(e); + var index = -1; + var sameAsCurrent: boolean; + const selected = svg.selectAll('.slice').filter((d: any) => { + index++; + var p1 = [0,0]; + var p3 = [arc.centroid(d)[0]*2, arc.centroid(d)[1]*2]; + var p2 = [radius*Math.sin(d.startAngle), -radius*Math.cos(d.startAngle)]; + var p4 = [radius*Math.sin(d.endAngle), -radius*Math.cos(d.endAngle)]; + + // 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) { + var showSelected = this.byCategory? pieDataSet[index] : this.props.pairs[index]; + sameAsCurrent = (this.byCategory && 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; + return true; + } + return false; + }); + const elements = document.querySelectorAll('.slice'); + for (let i = 0; i < elements.length; i++) { + elements[i].classList.remove('hover'); + } + if (!sameAsCurrent!) selected.attr('class', 'slice hover'); + }); + + var arcs = g.selectAll("arc") + .data(pie(data)) + .enter() + .append("g") + arcs.append("path") + .attr("fill", (data, i)=>{ return d3.schemeSet3[i]? d3.schemeSet3[i]: d3.schemeSet3[i%12] }) + .attr("class", 'slice') + .attr("d", arc) + .on('click', onPointClick) + 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 each[percentField].replace(/[^0-9]/g,"")==d.data.toString().replace(/[^0-9]/g,"") + } 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[percentField]! + + (!descriptionField? '' : (' - ' + dataPoint[descriptionField]))!}) + + }; + + render() { + var selected: string; + if (this._currSelected){ + selected = '{ '; + Object.keys(this._currSelected).map(key => { + key!=''? selected += key + ': ' + this._currSelected[key] + ', ': ''; + }) + selected = selected.substring(0, selected.length-2); + selected += ' }'; + } + else selected = 'none'; + return ( + this.props.axes.length >= 1 ? ( + <div className="chart-container"> + <div className="graph-title"> {this.graphTitle} </div> + <div className={'selected-data'}> {`Selected: ${selected}`}</div> + <div ref={this._piechartRef} /> + </div> + ) : <span className="chart-container"> {'first use table view to select a column to graph'}</span> + ); + } + +}
\ 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..500e7b639 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -8,8 +8,11 @@ import { emptyFunction, returnFalse, setupMoveUpEvents, Utils } from '../../../. import { DragManager } from '../../../../util/DragManager'; import { DocumentView } from '../../DocumentView'; import { DataVizView } from '../DataVizBox'; +import { LinkManager } from '../../../../util/LinkManager'; +import { DocCast } from '../../../../../fields/Types'; interface TableBoxProps { + rootDoc: Doc; pairs: { [key: string]: any }[]; selectAxes: (axes: string[]) => void; axes: string[]; @@ -18,9 +21,24 @@ interface TableBoxProps { @observer export class TableBox extends React.Component<TableBoxProps> { + + @computed get _tableData() { + if (this.incomingLinks.length! <= 0) return this.props.pairs; + return this.props.pairs?.filter(pair => (Array.from(Object.keys(pair)).some(key => pair[key] && key.startsWith('select')))) + } + + @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])) : []; + // return this.props.pairs.length ? Array.from(Object.keys(this.props.pairs[0])) : []; + return this._tableData.length ? Array.from(Object.keys(this._tableData[0])) : []; } + render() { return ( <div className="table-container"> @@ -33,6 +51,7 @@ export class TableBox extends React.Component<TableBoxProps> { 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 ? 'green' : this.props.axes.lastElement() === col ? 'red' : undefined, @@ -50,6 +69,7 @@ export class TableBox extends React.Component<TableBoxProps> { const embedding = Doc.MakeEmbedding(this.props.docView?.()!.rootDoc!); embedding._dataVizView = DataVizView.LINECHART; embedding._data_vizAxes = new List<string>([col, col]); + embedding._draggedFrom = this.props.docView?.()!.rootDoc!; embedding.annotationOn = annotationOn; //this.props.docView?.()!.rootDoc!; return embedding; }; @@ -58,6 +78,7 @@ export class TableBox extends React.Component<TableBoxProps> { 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; } @@ -88,11 +109,13 @@ export class TableBox extends React.Component<TableBoxProps> { </tr> </thead> <tbody> - {this.props.pairs?.map((p, i) => { + {this._tableData?.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]]))}> + <tr key={i} 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> + <td key={this.columns.indexOf(col)} style={{ fontWeight: p['select' + this.props.docView?.()?.rootDoc![Id]] ? 'bold' : '' }}> + {p[col]} + </td> ))} </tr> ); diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 66352678c..f9fa1955f 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -974,8 +974,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)) ); @@ -1350,7 +1350,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'; |