import { Checkbox } from '@mui/material'; import { ColorPicker, EditableText, Size, Type } from 'browndash-components'; import * as d3 from 'd3'; import { IReactionDisposer, action, computed, makeObservable, 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 { ObservableReactComponent } from '../../../ObservableReactComponent'; import { PinProps, PinDocView } from '../../../PinFuncs'; import './Chart.scss'; export interface PieChartProps { Document: Doc; layoutDoc: Doc; axes: string[]; titleCol: 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 ObservableReactComponent { private _disposers: { [key: string]: IReactionDisposer } = {}; private _piechartRef: React.RefObject = React.createRef(); private _piechartSvg: d3.Selection | 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 constructor(props: any) { super(props); makeObservable(this); } @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() { const ax0 = this._props.axes[0]; const 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.Document.dataViz_parentViz); // return LinkManager.Links(this._props.Document) // out of all links // .filter(link => link.link_anchor_1 == this._props.Document.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 }) => 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, }); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this._props.Document); 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] || 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(/ { let index = -1; let sameAsCurrent: boolean; const selected = svg.selectAll('.slice').filter((d: any) => { index++; const p1 = [0, 0]; // center of pie const p3 = [arc.centroid(d)[0] * 2, arc.centroid(d)[1] * 2]; // outward peak of arc const p2 = [radius * Math.sin(d.startAngle), -radius * Math.cos(d.startAngle)]; // start of arc const 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 let 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 const 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(); let percentField = Object.keys(dataSet[0])[0]; let descriptionField = Object.keys(dataSet[0])[1]!; const radius = Math.min(width, height - this._props.margin.top - this._props.margin.bottom) / 2; // converts data into Objects let data = this.data(dataSet); let pieDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || isNaN(d[key]))); if (this.byCategory) { const uniqueCategories = [...new Set(data)]; const 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++) { // eslint-disable-next-line no-loop-func const sliceData = pieStringDataSet.filter((each: any) => each[percentField] === data[i]); sliceData[0].frequency += 1; } pieDataSet = pieStringDataSet; [percentField, descriptionField] = Object.keys(pieDataSet[0]); data = this.data(pieStringDataSet); } let trackDuplicates: { [key: string]: any } = {}; data.forEach((eachData: any) => { !trackDuplicates[eachData] ? (trackDuplicates[eachData] = 0) : null; }); // initial chart const 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')); const g = svg.append('g').attr('transform', 'translate(' + (width / 2 + this._props.margin.left) + ',' + height / 2 + ')'); const pie = d3.pie(); const arc = d3.arc().innerRadius(0).outerRadius(radius); const updateHighlights = () => { const hoverOverSlice = this.hoverOverData; const { selectedData } = this; svg.selectAll('path').attr('class', (d: any) => (selectedData && d.startAngle === selectedData.startAngle && d.endAngle === selectedData.endAngle) || (hoverOverSlice && d.startAngle === hoverOverSlice.startAngle && d.endAngle === hoverOverSlice.endAngle) ? 'slice hover' : 'slice' ); }; // 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(() => { this.hoverOverData = undefined; updateHighlights(); }); // drawing the slices const selected = this.selectedData; const arcs = g.selectAll('arc').data(pie(data)).enter().append('g'); const possibleDataPointVals: { [x: string]: any }[] = []; pieDataSet.forEach((each: { [x: string]: any | { valueOf(): number } }) => { const 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(/ each.split('::')); arcs.append('path') .attr('fill', (d, i) => { let dataPoint; const possibleDataPoints = possibleDataPointVals.filter((pval: any) => pval[percentField] === Number(d.data)); if (possibleDataPoints.length === 1) [dataPoint] = possibleDataPoints; else { dataPoint = possibleDataPoints[trackDuplicates[d.data.toString()]]; trackDuplicates[d.data.toString()] = trackDuplicates[d.data.toString()] + 1; } let sliceColor; if (dataPoint) { const sliceTitle = dataPoint[this._props.axes[0]]; const accessByName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/ { // eslint-disable-next-line prefer-destructuring 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 ? d => (selected && d.startAngle === selected.startAngle && d.endAngle === selected.endAngle ? 'slice hover' : 'slice') : () => 'slice') // @ts-ignore .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', d => { const centroid = arc.centroid(d as unknown as d3.DefaultArcObject); const 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(d => { let 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(/ { 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() { let titleAccessor: any = 'dataViz_pie_title'; if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; else if (this._props.axes.length > 0) titleAccessor += 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(); let selected: string; let curSelectedSliceName = ''; if (this._currSelected) { selected = '{ '; const sliceTitle = this._currSelected[this._props.axes[0]]; curSelectedSliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/ { key !== '' ? (selected += key + ': ' + this._currSelected[key] + ', ') : ''; }); selected = selected.substring(0, selected.length - 2); selected += ' }'; if (this._props.titleCol !== '' && (!this._currSelected.frequency || this._currSelected.frequency < 10)) { selected += '\n' + this._props.titleCol + ': '; this._tableData.forEach(each => { if (this._currSelected[this._props.axes[0]] === each[this._props.axes[0]]) { if (this._props.axes[1]) { if (this._currSelected[this._props.axes[1]] === each[this._props.axes[1]]) selected += each[this._props.titleCol] + ', '; } else selected += each[this._props.titleCol] + ', '; } }); selected = selected.slice(0, -1).slice(0, -1); } } else selected = 'none'; let selectedSliceColor; const sliceColors = StrListCast(this._props.layoutDoc.dataViz_pie_sliceColors).map(each => each.split('::')); sliceColors.forEach(each => { // eslint-disable-next-line prefer-destructuring if (each[0] === curSelectedSliceName!) selectedSliceColor = each[1]; }); if (this._pieChartData.length > 0 || !this.parentViz) { return this._props.axes.length >= 1 ? (
{ this._props.layoutDoc[titleAccessor] = val as string; }), 'Change Graph Title' )} color="black" size={Size.LARGE} fillWidth />
{this._props.axes.length === 1 && /\d/.test(this._props.records[0][this._props.axes[0]]) ? (
Organize data as histogram
) : null}
{selected !== 'none' ? (
Selected: {selected}     } selectedColor={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} />
) : null}
) : ( first use table view to select a column to graph ); } return ( // when it is a brushed table and the incoming table doesn't have any rows selected
Selected rows of data from the incoming DataVizBox to display.
); } }