diff options
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/PieChart.tsx')
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/PieChart.tsx | 382 |
1 files changed, 382 insertions, 0 deletions
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..6241e6221 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx @@ -0,0 +1,382 @@ +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 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 + .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: 'line 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 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; + }); + + var pie = d3.pie(); + var arc = d3.arc() + .innerRadius(0) + .outerRadius(radius); + + if (this.byCategory){ + let uniqueCategories = [...new Set(data)] + var pieStringDataSet: { frequency: any, label: any }[] = []; + for (let i=0; i<uniqueCategories.length; i++){ + pieStringDataSet.push({frequency: 0, label: uniqueCategories[i]}) + } + for (let i=0; i<data.length; i++){ + let sliceData = pieStringDataSet.filter(each => each.label==data[i]) + sliceData[0].frequency = sliceData[0].frequency + 1; + } + pieDataSet = pieStringDataSet + data = this.data(pieStringDataSet) + } + var trackDuplicates : {[key: string]: any} = {}; + data.forEach((eachData: any) => !trackDuplicates[eachData]? trackDuplicates[eachData] = 0: null) + + 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 percentField = Object.keys(pieDataSet[0])[0] + var descriptionField = Object.keys(pieDataSet[0])[1]! + 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", `${pieDataSet[0][percentField]} 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 && (this.incomingSelected? this.incomingSelected.length>0 : true) ? ( + <div ref={this._piechartRef} className="chart-container"> + <span className={'selected-data'}> {`Selected: ${selected}`}</span> + </div> + ) : <span className="chart-container"> {'first use table view to select a column to graph'}</span> + ); + } + +}
\ No newline at end of file |