import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ColorPicker, EditableText, IconButton, Size, Type } from '@dash/components'; import * as d3 from 'd3'; import { IReactionDisposer, action, computed, makeObservable, observable } 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 { scaleCreatorNumerical, yAxisCreator } from '../utils/D3Utils'; import './Chart.scss'; export interface HistogramProps { 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 Histogram extends ObservableReactComponent { private _disposers: { [key: string]: IReactionDisposer } = {}; private _histogramRef: HTMLDivElement | null = null; 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 for when just one bar is selected private selectedData: any[] = []; // array of selected bars 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) { const ax0 = this._props.axes[0]; if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])) { this.numericalXData = true; } return this._tableData.map(record => ({ [ax0]: record[this._props.axes[0]] })); } const [ax0, ax1] = this._props.axes; if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])) { this.numericalXData = true; } if (!/[A-Za-z-:]/.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() { const [ax0, ax1] = this._props.axes; if (this._props.axes.length < 2 || !ax1 || !/\d/.test(this._props.records[0][ax1]) || !this.numericalYData) { return ax0 + ' Histogram'; } return ax0 + ' by ' + ax1 + ' Histogram'; } @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); } @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() { // restore selected bars const svg = this._histogramSvg; if (svg) { const selectedDataBars = StrListCast(this._props.layoutDoc.dataViz_histogram_selectedData); svg.selectAll('rect').attr('class', (d: any) => { let selected = false; selectedDataBars.forEach(eachSelectedData => { if (d[0] === eachSelectedData) selected = true; }); if (selected) { this.selectedData.push(d); return 'histogram-bar hover'; } return 'histogram-bar'; }); } } // 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, }); 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] as any))); 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(/ { let 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 (d.length && barCounter * eachRectWidth <= pointerX && pointerX <= (barCounter + 1) * eachRectWidth) { let 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(/ { if (!sameAsAny) { let match = true; Object.keys(d).forEach(key => { if (d[key] !== eachData[key]) match = false; }); if (match) { sameAsAny = true; const index = this.selectedData.indexOf(eachData); this.selectedData.splice(index, 1); selectedDataBars.splice(index, 1); this._currSelected = undefined; } } }); if (!sameAsAny) { this.selectedData.push(d); selectedDataBars.push(d[0]); this._currSelected = this.selectedData.length > 1 ? undefined : showSelected; } // for filtering child dataviz docs if (this._props.layoutDoc.dataViz_filterSelection) { const selectedRows = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); this._tableDataIds.forEach(rowID => { let match = false; for (let i = 0; i < d.length; i++) { console.log('Compare: ' + this._props.records[rowID][xAxisTitle].replace(/\$/g, '').replace(/%/g, '').replace(/ { if (dataSet?.length <= 0) return; d3.select(this._histogramRef).select('svg').remove(); d3.select(this._histogramRef).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)]; let numBins = this.numericalXData && Number.isInteger(data[0]) ? this.rangeVals.xMax! - this.rangeVals.xMin! : uniqueArr.length; let 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 let histDataSet = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || isNaN(d[key] as any))); if (!this.numericalXData) { const 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++) { const 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 const svg = (this._histogramSvg = d3 .select(this._histogramRef) .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 + ')')); let x = d3 .scaleLinear() .domain(this.numericalXData ? [startingPoint!, endingPoint!] : [0, numBins]) .range([0, width]); const histogram = d3 .histogram() .value(d => d) .domain([startingPoint!, endingPoint!]) .thresholds(x.ticks(numBins)); const bins = histogram(data); let eachRectWidth = width / bins.length; const 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]); let 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++) { let 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 { let allSame = true; for (let i = 0; i < bins.length; i++) { if (bins[i] && bins[i][0]) { const 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); const tickDiff = bins.length >= 2 ? bins[bins.length - 2].x1! - bins[bins.length - 2].x0! : 0; const 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, (d: any) => (d[yAxisTitle] ? Number(d[yAxisTitle]!.replace(/\$/g, '') .replace(/%/g, '').replace(/ d.length); // prettier-ignore const y = d3.scaleLinear().range([height, 0]); y.domain([0, +maxFrequency!]); const yAxis = d3.axisLeft(y).ticks(maxFrequency!); if (this.numericalYData) { const yScale = scaleCreatorNumerical(0, Number(maxFrequency), height, 0); yAxisCreator(svg.append('g'), width, yScale); } else { svg.append('g').call(yAxis); } svg.append('g') .attr('transform', 'translate(' + translateXAxis + ', ' + height + ')') .call(xAxis); // click/hover const onPointClick = action((e: any) => 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); // eslint-disable-next-line no-use-before-define updateHighlights(); }); const mouseOut = action(() => { this.hoverOverData = undefined; // eslint-disable-next-line no-use-before-define updateHighlights(); }); const updateHighlights = () => { const hoverOverBar = this.hoverOverData; const { selectedData } = this; svg.selectAll('rect').attr('class', (d: any) => { let selected = false; selectedData.forEach(eachSelectedData => { if (d[0] === eachSelectedData[0]) selected = true; }); return (hoverOverBar && hoverOverBar[0] == d[0]) || selected ? '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 const selected = this.selectedData; svg.selectAll('rect') .data(bins) .enter() .append('rect') .attr( 'transform', this.numericalYData ? d => { const eachData = histDataSet.filter((hData: { [x: string]: number }) => hData[xAxisTitle] == d[0]); const length = eachData.length ? StrCast(eachData[0][yAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/ 'translate(' + x(d.x0!) + ',' + y(d.length) + ')' ) .attr( 'height', this.numericalYData ? d => { const eachData = histDataSet.filter((hData: { [x: string]: number }) => hData[xAxisTitle] == d[0]); const length = eachData.length ? StrCast(eachData[0][yAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/ height - y(d.length) ) .attr('width', eachRectWidth) .attr('class', selected ? d => (selected && selected[0] == d[0] ? 'histogram-bar hover' : 'histogram-bar') : () => 'histogram-bar') .attr('fill', d => { let barColor; const barColors = StrListCast(this._props.layoutDoc.dataViz_histogram_barColors).map(each => each.split('::')); barColors.forEach(each => { // eslint-disable-next-line prefer-destructuring if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1]; else { const range = StrCast(each[0]).split(' to '); // eslint-disable-next-line prefer-destructuring 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)); }; // reloads the bar colors and selected bars updateSavedUI = () => { const svg = this._histogramSvg; if (svg) { // bar color svg.selectAll('rect').attr('fill', (d: any) => { let barColor; const barColors = StrListCast(this._props.layoutDoc.dataViz_histogram_barColors).map(each => each.split('::')); barColors.forEach(each => { // eslint-disable-next-line prefer-destructuring if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1]; else { const range = StrCast(each[0]).split(' to '); // eslint-disable-next-line prefer-destructuring 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.updateSavedUI(); this._histogramData; let curSelectedBarName = ''; let titleAccessor: any = 'dataViz_histogram_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_histogram_defaultColor) this._props.layoutDoc.dataViz_histogram_defaultColor = '#69b3a2'; if (!this._props.layoutDoc.dataViz_histogram_barColors) this._props.layoutDoc.dataViz_histogram_barColors = new List(); if (!this._props.layoutDoc.dataViz_histogram_selectedData) this._props.layoutDoc.dataViz_histogram_selectedData = new List(); let 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) + ' }'; 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); } } let selectedBarColor; const barColors = StrListCast(this._props.layoutDoc.histogramBarColors).map(each => each.split('::')); barColors.forEach(each => { // eslint-disable-next-line prefer-destructuring 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} />
{ this._histogramRef = r; r && this.drawChart(this._histogramData, this.width, this.height); }} /> {selected !== 'none' ? (
Selected: {selected}     } selectedColor={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(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.
; } }