diff options
author | Sophie Zhang <sophie_zhang@brown.edu> | 2023-09-18 17:40:01 -0400 |
---|---|---|
committer | Sophie Zhang <sophie_zhang@brown.edu> | 2023-09-18 17:40:01 -0400 |
commit | 013f25f01e729feee5db94900c61f4be4dd46869 (patch) | |
tree | 765dd5f2e06d6217ca79438e1098cefc8da627bf /src/client/views/nodes/DataVizBox/components/PieChart.tsx | |
parent | f5e765adff1e7b32250eb503c9724a4ac99117f3 (diff) | |
parent | 84aa8806a62e2e957e8281d7d492139e3d8225f2 (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.tsx | 401 |
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} + + <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> + ); + } +} |