aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/DataVizBox/components/PieChart.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/PieChart.tsx')
-rw-r--r--src/client/views/nodes/DataVizBox/components/PieChart.tsx380
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}
+ &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