diff options
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/PieChart.tsx')
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/PieChart.tsx | 380 |
1 files changed, 380 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..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} + + <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 |