import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ColorPicker, EditableText, IconButton, Size, Type } from 'browndash-components'; import * as d3 from 'd3'; import { action, computed, IReactionDisposer, 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 { PinProps, PresBox } from '../../trails'; import { scaleCreatorNumerical, yAxisCreator } from '../utils/D3Utils'; import './Chart.scss'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; export interface HistogramProps { Document: 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 Histogram extends ObservableReactComponent { private _disposers: { [key: string]: IReactionDisposer } = {}; private _histogramRef: React.RefObject = React.createRef(); private _histogramSvg: d3.Selection | undefined; private numericalXData: boolean = false; // whether the data is organized by numbers rather than categoreis private numericalYData: boolean = false; // whether the y axis is controlled by provided data rather than frequency private maxBins = 15; // maximum number of bins that is readable on a normal sized doc @observable _currSelected: any | undefined = undefined; // Object of selected bar private curBarSelected: any = undefined; // histogram bin of selected bar private selectedData: any = undefined; // Selection of selected bar private hoverOverData: any = undefined; // Selection of bar being hovered over 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]); } // filters all data to just display selected data if brushed (created from an incoming link) @computed get _histogramData() { if (this._props.axes.length < 1) return []; if (this._props.axes.length < 2) { var ax0 = this._props.axes[0]; if (/\d/.test(this._props.records[0][ax0])) { this.numericalXData = true; } return this._tableData.map(record => ({ [ax0]: record[this._props.axes[0]] })); } var ax0 = this._props.axes[0]; var ax1 = this._props.axes[1]; if (/\d/.test(this._props.records[0][ax0])) { this.numericalXData = true; } if (/\d/.test(this._props.records[0][ax1])) { this.numericalYData = true; } 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 || !ax1 || !/\d/.test(this._props.records[0][ax1]) || !this.numericalYData) { return ax0 + ' Histogram'; } else return ax0 + ' by ' + ax1 + ' Histogram'; } @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); // return LinkManager.Instance.getAllRelatedLinks(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 } @computed get rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } { if (this.numericalXData) { const data = this.data(this._histogramData); return { xMin: Math.min.apply(null, data), xMax: Math.max.apply(null, data), yMin: 0, yMax: 0 }; } return { xMin: 0, xMax: 0, yMin: 0, yMax: 0 }; } componentWillUnmount() { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount = () => { this._disposers.chartData = reaction( () => ({ dataSet: this._histogramData, 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: 'histogram doc selection' + this._currSelected, }); PresBox.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) => { var 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 ? [] : validData.map((d: { [x: string]: any }) => !this.numericalXData // ? d[field] : +d[field!].replace(/\$/g, '').replace(/\%/g, '').replace(/\ { var sameAsCurrent: boolean; var barCounter = -1; const selected = svg.selectAll('.histogram-bar').filter((d: any) => { barCounter++; // uses the order of bars and width of each bar to find which one the pointer is over if (barCounter * eachRectWidth <= pointerX && pointerX <= (barCounter + 1) * eachRectWidth) { var showSelected = this.numericalYData ? this._histogramData.filter((data: { [x: string]: any }) => StrCast(data[xAxisTitle]).replace(/\$/g, '').replace(/\%/g, '').replace(/\ data[xAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\ { d3.select(this._histogramRef.current).select('svg').remove(); d3.select(this._histogramRef.current).select('.tooltip').remove(); const data = this.data(dataSet); const xAxisTitle = Object.keys(dataSet[0])[0]; const yAxisTitle = this.numericalYData ? Object.keys(dataSet[0])[1] : 'frequency'; const uniqueArr: unknown[] = [...new Set(data)]; var numBins = this.numericalXData && Number.isInteger(data[0]) ? this.rangeVals.xMax! - this.rangeVals.xMin! : uniqueArr.length; var translateXAxis = !this.numericalXData || numBins < this.maxBins ? width / (numBins + 1) / 2 : 0; if (numBins > this.maxBins) numBins = this.maxBins; const startingPoint = this.numericalXData ? this.rangeVals.xMin! : 0; const endingPoint = this.numericalXData ? this.rangeVals.xMax! : numBins; // converts data into Objects var histDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || Number.isNaN(d[key]))); if (!this.numericalXData) { var histStringDataSet: { [x: string]: unknown }[] = []; if (this.numericalYData) { for (let i = 0; i < dataSet.length; i++) { histStringDataSet.push({ [yAxisTitle]: dataSet[i][yAxisTitle], [xAxisTitle]: dataSet[i][xAxisTitle] }); } } else { for (let i = 0; i < uniqueArr.length; i++) { histStringDataSet.push({ [yAxisTitle]: 0, [xAxisTitle]: uniqueArr[i] }); } for (let i = 0; i < data.length; i++) { let barData = histStringDataSet.filter(each => each[xAxisTitle] == data[i]); histStringDataSet.filter(each => each[xAxisTitle] == data[i])[0][yAxisTitle] = Number(barData[0][yAxisTitle]) + 1; } } histDataSet = histStringDataSet; } // initial graph and binning data for histogram var svg = (this._histogramSvg = d3 .select(this._histogramRef.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') .attr('transform', 'translate(' + this._props.margin.left + ',' + this._props.margin.top + ')')); var x = d3 .scaleLinear() .domain(this.numericalXData ? [startingPoint!, endingPoint!] : [0, numBins]) .range([0, width]); var histogram = d3 .histogram() .value(function (d) { return d; }) .domain([startingPoint!, endingPoint!]) .thresholds(x.ticks(numBins)); var bins = histogram(data); var eachRectWidth = width / bins.length; var graphStartingPoint = bins[0].x1 && bins[1] ? bins[0].x1! - (bins[1].x1! - bins[1].x0!) : 0; bins[0].x0 = graphStartingPoint; x = x.domain([graphStartingPoint, endingPoint]).range([0, Number.isInteger(this.rangeVals.xMin!) ? width - eachRectWidth : width]); var xAxis; // more calculations based on bins // x-axis if (!this.numericalXData) { // reorganize to match data if the data is strings rather than numbers // uniqueArr.sort() histDataSet.sort(); for (let i = 0; i < data.length; i++) { var index = 0; for (let j = 0; j < uniqueArr.length; j++) { if (uniqueArr[j] == data[i]) { index = j; } } if (bins[index]) bins[index].push(data[i]); } bins.pop(); eachRectWidth = width / bins.length; bins.forEach(d => (d.x0 = d.x0!)); xAxis = d3 .axisBottom(x) .ticks(bins.length > 1 ? bins.length - 1 : 1) .tickFormat(i => uniqueArr[i.valueOf()] as string) .tickPadding(10); x.range([0, width - eachRectWidth]); x.domain([0, bins.length - 1]); translateXAxis = eachRectWidth / 2; } else { var allSame = true; for (var i = 0; i < bins.length; i++) { if (bins[i] && bins[i][0]) { var compare = bins[i][0]; for (let j = 1; j < bins[i].length; j++) { if (bins[i][j] != compare) allSame = false; } } } if (allSame) { translateXAxis = eachRectWidth / 2; eachRectWidth = width / bins.length; } else { eachRectWidth = width / (bins.length + 1); var tickDiff = bins.length >= 2 ? bins[bins.length - 2].x1! - bins[bins.length - 2].x0! : 0; var curDomain = x.domain(); x.domain([curDomain[0], curDomain[0] + tickDiff * bins.length]); } xAxis = d3.axisBottom(x).ticks(bins.length - 1); x.range([0, width - eachRectWidth]); } // y-axis const maxFrequency = this.numericalYData ? d3.max(histDataSet, function (d: any) { return d[yAxisTitle] ? Number(d[yAxisTitle]!.replace(/\$/g, '').replace(/\%/g, '').replace(/\ this.highlightSelectedBar(true, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet)); const onHover = action((e: any) => { this.highlightSelectedBar(false, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet); updateHighlights(); }); const mouseOut = action((e: any) => { this.hoverOverData = undefined; updateHighlights(); }); const updateHighlights = () => { const hoverOverBar = this.hoverOverData; const selectedData = this.selectedData; svg.selectAll('rect').attr('class', function (d: any) { return (hoverOverBar && hoverOverBar[0] == d[0]) || (selectedData && selectedData[0] == d[0]) ? 'histogram-bar hover' : 'histogram-bar'; }); }; svg.on('click', onPointClick).on('mouseover', onHover).on('mouseout', mouseOut); // axis titles svg.append('text') .attr('transform', 'translate(' + width / 2 + ' ,' + (height + 40) + ')') .style('text-anchor', 'middle') .text(xAxisTitle); svg.append('text') .attr('transform', 'rotate(-90)' + ' ' + 'translate( 0, ' + -10 + ')') .attr('x', -(height / 2)) .attr('y', -20) .style('text-anchor', 'middle') .text(yAxisTitle); d3.format('.0f'); // draw bars var selected = this.selectedData; svg.selectAll('rect') .data(bins) .enter() .append('rect') .attr( 'transform', this.numericalYData ? function (d) { const eachData = histDataSet.filter((data: { [x: string]: number }) => { return data[xAxisTitle] == d[0]; }); const length = eachData.length ? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\ { return data[xAxisTitle] == d[0]; }); const length = eachData.length ? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\ { var barColor; const barColors = StrListCast(this._props.layoutDoc.dataViz_histogram_barColors).map(each => each.split('::')); barColors.forEach(each => { if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1]; else { const range = StrCast(each[0]).split(' to '); if (Number(range[0]) <= d[0] && d[0] <= Number(range[1])) barColor = each[1]; } }); return barColor ? StrCast(barColor) : StrCast(this._props.layoutDoc.dataViz_histogram_defaultColor); }); }; @action changeSelectedColor = (color: string) => { this.curBarSelected.attr('fill', color); const barName = StrCast(this._currSelected[this._props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\ each.split('::')[0] === barName && barColors.splice(barColors.indexOf(each), 1)); barColors.push(StrCast(barName + '::' + color)); }; @action eraseSelectedColor = () => { this.curBarSelected.attr('fill', this._props.layoutDoc.dataViz_histogram_defaultColor); const barName = StrCast(this._currSelected[this._props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\ each.split('::')[0] === barName && barColors.splice(barColors.indexOf(each), 1)); }; updateBarColors = () => { var svg = this._histogramSvg; if (svg) svg.selectAll('rect').attr('fill', (d: any) => { var barColor; const barColors = StrListCast(this._props.layoutDoc.dataViz_histogram_barColors).map(each => each.split('::')); barColors.forEach(each => { if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1]; else { const range = StrCast(each[0]).split(' to '); if (Number(range[0]) <= d[0] && d[0] <= Number(range[1])) barColor = each[1]; } }); return barColor ? StrCast(barColor) : StrCast(this._props.layoutDoc.dataViz_histogram_defaultColor); }); }; render() { this.updateBarColors(); this._histogramData; var curSelectedBarName = ''; var titleAccessor: any = ''; if (this._props.axes.length == 2) titleAccessor = 'dataViz_histogram_title' + this._props.axes[0] + '-' + this._props.axes[1]; else if (this._props.axes.length > 0) titleAccessor = 'dataViz_histogram_title' + this._props.axes[0]; if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; if (!this._props.layoutDoc.dataViz_histogram_defaultColor) this._props.layoutDoc.dataViz_histogram_defaultColor = '#69b3a2'; if (!this._props.layoutDoc.dataViz_histogram_barColors) this._props.layoutDoc.dataViz_histogram_barColors = new List(); var selected = 'none'; if (this._currSelected) { curSelectedBarName = StrCast(this._currSelected![this._props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\ key // ? (selected += key + ': ' + this._currSelected[key] + ', ') : '' ); selected = selected.substring(0, selected.length - 2) + ' }'; } var selectedBarColor; var barColors = StrListCast(this._props.layoutDoc.histogramBarColors).map(each => each.split('::')); barColors.forEach(each => each[0] === curSelectedBarName && (selectedBarColor = each[1])); if (this._histogramData.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 />     } selectedColor={StrCast(this._props.layoutDoc.dataViz_histogram_defaultColor)} setFinalColor={undoable(color => (this._props.layoutDoc.dataViz_histogram_defaultColor = color), 'Change Default Bar Color')} setSelectedColor={undoable(color => (this._props.layoutDoc.dataViz_histogram_defaultColor = color), 'Change Default Bar Color')} size={Size.XSMALL} />
{selected != 'none' ? (
Selected: {selected}     } selectedColor={selectedBarColor ? selectedBarColor : this.curBarSelected.attr('fill')} setFinalColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Bar Color')} setSelectedColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Bar Color')} size={Size.XSMALL} />   } size={Size.XSMALL} color={'black'} type={Type.SEC} tooltip={'Revert to the default bar color'} onClick={undoable( action(() => this.eraseSelectedColor()), 'Change Selected Bar Color' )} />
) : null}
) : ( {'first use table view to select a column to graph'} ); } // when it is a brushed table and the incoming table doesn't have any rows selected return
Selected rows of data from the incoming DataVizBox to display.
; } }