aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/DataVizBox/components/PieChart.tsx
diff options
context:
space:
mode:
authorSophie Zhang <sophie_zhang@brown.edu>2023-09-18 17:40:01 -0400
committerSophie Zhang <sophie_zhang@brown.edu>2023-09-18 17:40:01 -0400
commit013f25f01e729feee5db94900c61f4be4dd46869 (patch)
tree765dd5f2e06d6217ca79438e1098cefc8da627bf /src/client/views/nodes/DataVizBox/components/PieChart.tsx
parentf5e765adff1e7b32250eb503c9724a4ac99117f3 (diff)
parent84aa8806a62e2e957e8281d7d492139e3d8225f2 (diff)
Merge branch 'master' into sophie-report-manager
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/PieChart.tsx')
-rw-r--r--src/client/views/nodes/DataVizBox/components/PieChart.tsx401
1 files changed, 401 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..561f39141
--- /dev/null
+++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx
@@ -0,0 +1,401 @@
+import { ColorPicker, EditableText, Size, Type } from 'browndash-components';
+import * as d3 from 'd3';
+import { action, computed, IReactionDisposer, observable, reaction } from 'mobx';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { FaFillDrip } from 'react-icons/fa';
+import { Doc, NumListCast, StrListCast } from '../../../../../fields/Doc';
+import { List } from '../../../../../fields/List';
+import { listSpec } from '../../../../../fields/Schema';
+import { Cast, DocCast, StrCast } from '../../../../../fields/Types';
+import { Docs } from '../../../../documents/Documents';
+import { undoable } from '../../../../util/UndoManager';
+import { PinProps, PresBox } from '../../trails';
+import './Chart.scss';
+import { Checkbox } from '@material-ui/core';
+
+export interface PieChartProps {
+ rootDoc: Doc;
+ layoutDoc: Doc;
+ axes: string[];
+ records: { [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 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
+ @observable _currSelected: any | undefined = undefined; // Object of selected slice
+
+ @computed get _tableDataIds() {
+ return !this.parentViz ? this.props.records.map((rec, i) => i) : NumListCast(this.parentViz.dataViz_selectedRows);
+ }
+ // returns all the data records that will be rendered by only returning those records that have been selected by the parent visualization (or all records if there is no parent)
+ @computed get _tableData() {
+ return !this.parentViz ? this.props.records : this._tableDataIds.map(rowId => this.props.records[rowId]);
+ }
+
+ // organized by specified number percentages/ratios if one column is selected and it contains numbers
+ // otherwise, assume data is organized by categories
+ @computed get byCategory() {
+ return !/\d/.test(this.props.records[0][this.props.axes[0]]) || this.props.layoutDoc.dataViz_pie_asHistogram;
+ }
+ // filters all data to just display selected data if brushed (created from an incoming link)
+ @computed get _pieChartData() {
+ if (this.props.axes.length < 1) return [];
+
+ const ax0 = this.props.axes[0];
+ if (this.props.axes.length < 2) {
+ return this._tableData.map(record => ({ [ax0]: record[this.props.axes[0]] }));
+ }
+ const ax1 = this.props.axes[1];
+ return this._tableData.map(record => ({ [ax0]: record[this.props.axes[0]], [ax1]: record[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.records[0][ax0]) || !ax1) {
+ return ax0 + ' Pie Chart';
+ }
+ return ax1 + ' by ' + ax0 + ' Pie Chart';
+ }
+
+ @computed get parentViz() {
+ return DocCast(this.props.rootDoc.dataViz_parentViz);
+ // return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links
+ // .filter(link => link.link_anchor_1 == this.props.rootDoc.dataViz_parentViz) // 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.rootDoc);
+ 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) => {
+ const validData = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key])));
+ const field = dataSet[0] ? Object.keys(dataSet[0])[0] : undefined;
+ return !field
+ ? undefined
+ : validData.map((d: { [x: string]: any }) =>
+ this.byCategory
+ ? d[field] //
+ : +d[field].replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '')
+ );
+ };
+
+ // 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 }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key])));
+ 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) => {
+ 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');
+ const possibleDataPointVals: { [x: string]: any }[] = [];
+ pieDataSet.forEach((each: { [x: string]: any | { valueOf(): number } }) => {
+ var dataPointVal: { [x: string]: any } = {};
+ dataPointVal[percentField] = each[percentField];
+ if (descriptionField) dataPointVal[descriptionField] = each[descriptionField];
+ try {
+ dataPointVal[percentField] = Number(dataPointVal[percentField].replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, ''));
+ } catch (error) {}
+ possibleDataPointVals.push(dataPointVal);
+ });
+ const sliceColors = StrListCast(this.props.layoutDoc.dataViz_pie_sliceColors).map(each => each.split('::'));
+ arcs.append('path')
+ .attr('fill', (d, i) => {
+ var dataPoint;
+ const possibleDataPoints = possibleDataPointVals.filter((pval: any) => pval[percentField] === Number(d.data));
+ 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) {
+ const sliceTitle = dataPoint[this.props.axes[0]];
+ const accessByName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle;
+ sliceColors.forEach(each => each[0] == 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.size() < 100 &&
+ 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 * 0.02)) + ',' + (centroid[1] + heightOffset) + ')';
+ })
+ .attr('text-anchor', 'middle')
+ .text(function (d) {
+ var dataPoint;
+ const possibleDataPoints = possibleDataPointVals.filter((pval: any) => pval[percentField] === Number(d.data));
+ if (possibleDataPoints.length == 1) dataPoint = pieDataSet[possibleDataPointVals.indexOf(possibleDataPoints[0])];
+ else {
+ dataPoint = pieDataSet[possibleDataPointVals.indexOf(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);
+ const sliceTitle = this._currSelected[this.props.axes[0]];
+ const sliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle;
+
+ const sliceColors = Cast(this.props.layoutDoc.dataViz_pie_sliceColors, listSpec('string'), null);
+ sliceColors.map(each => {
+ if (each.split('::')[0] == sliceName) sliceColors.splice(sliceColors.indexOf(each), 1);
+ });
+ sliceColors.push(StrCast(sliceName + '::' + color));
+ };
+
+ @action changeHistogramCheckBox = () => {
+ this.props.layoutDoc.dataViz_pie_asHistogram = !this.props.layoutDoc.dataViz_pie_asHistogram;
+ this.drawChart(this._pieChartData, this.width, this.height);
+ };
+
+ render() {
+ var titleAccessor: any = '';
+ if (this.props.axes.length == 2) titleAccessor = 'dataViz_pie_title' + this.props.axes[0] + '-' + this.props.axes[1];
+ else if (this.props.axes.length > 0) titleAccessor = 'dataViz_pie_title' + this.props.axes[0];
+ if (!this.props.layoutDoc[titleAccessor]) this.props.layoutDoc[titleAccessor] = this.defaultGraphTitle;
+ if (!this.props.layoutDoc.dataViz_pie_sliceColors) this.props.layoutDoc.dataViz_pie_sliceColors = new List<string>();
+ var selected: string;
+ var curSelectedSliceName = '';
+ if (this._currSelected) {
+ const sliceTitle = this._currSelected[this.props.axes[0]];
+ curSelectedSliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle;
+ 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.dataViz_pie_sliceColors).map(each => each.split('::'));
+ sliceColors.forEach(each => {
+ if (each[0] == curSelectedSliceName!) selectedSliceColor = each[1];
+ });
+
+ if (this._pieChartData.length > 0 || !this.parentViz) {
+ 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>
+ {this.props.axes.length === 1 && /\d/.test(this.props.records[0][this.props.axes[0]]) ? (
+ <div className={'asHistogram-checkBox'} style={{ width: this.props.width }}>
+ <Checkbox color="primary" onChange={this.changeHistogramCheckBox} checked={this.props.layoutDoc.dataViz_pie_asHistogram as boolean} />
+ Organize data as histogram
+ </div>
+ ) : null}
+ <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')}
+ setFinalColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Slice Color')}
+ 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>
+ );
+ }
+}