import { ColorPicker, EditableText, IconButton, Size, Type } from '@dash/components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 } 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 { PinDocView, PinProps } from '../../../PinFuncs'; import { scaleCreatorNumerical, yAxisCreator } from '../utils/D3Utils'; import './Chart.scss'; export interface HistogramData { [key: string]: string | number; } export interface HistogramProps { Doc: Doc; layoutDoc: Doc; axes: string[]; records: HistogramData[]; 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 private _selectedBars: HistogramData[] = []; @observable private _currSelected: { [key: string]: string | number; frequency: number } | undefined = undefined; constructor(props: HistogramProps) { super(props); makeObservable(this); } @computed get xAxis() { return this._props.axes[0]; } @computed get yAxis() { return this._props.axes[1]; } @computed get Doc() { return this._props.Doc; } @computed get layoutDoc() { return this._props.layoutDoc; } @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(): Record[] { return !this.parentViz ? this._props.records : this._tableDataIds.map(rowId => this._props.records[rowId]); } @computed get _histogramData(): HistogramData[] { if (this._props.axes.length < 1) return []; if (this._props.axes.length < 2) { if (!/[A-Za-z-:]/.test(this._props.records[0][this.xAxis] as string)) { this._numericalXData = true; } return this._tableData.map(record => ({ [this.xAxis]: record[this.xAxis] })); } if (!/[A-Za-z-:]/.test(this._props.records[0][this.xAxis] as string)) { this._numericalXData = true; } if (!/[A-Za-z-:]/.test(this._props.records[0][this.yAxis] as string)) { this._numericalYData = true; } return this._tableData.map(record => ({ [this.xAxis]: record[this.xAxis], [this.yAxis]: record[this.yAxis], })); } @computed get defaultGraphTitle(): string { if (!this.yAxis || !/\d/.test(this._props.records[0][this.yAxis] as string) || !this._numericalYData) { return this.xAxis + ' Histogram'; } return this.xAxis + ' by ' + this.yAxis + ' Histogram'; } @computed get parentViz() { return DocCast(this._props.Doc.dataViz_parentViz); } @computed get defaultBarColor() { return Cast(this.layoutDoc.dataViz_histogram_defaultColor, 'string', '#69b3a2'); } @computed get barColors() { return Cast(this.layoutDoc.dataViz_histogram_barColors, listSpec('string'), null); } @computed get selectedBins() { return Cast(this.layoutDoc.dataViz_histogram_selectedBins, listSpec('number'), null); } @computed get rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } { const data = this._numericalXData ? this.data(this._histogramData) : [0]; return { xMin: Math.min(...data), xMax: Math.max(...data), yMin: 0, yMax: 0 }; } componentWillUnmount() { Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]()); } componentDidMount() { // restore selected bars this._histogramSvg?.selectAll('rect').attr('class', dIn => { const d = dIn as HistogramData; if (this.selectedBins.some(selBin => d[0] === selBin)) { this._selectedBars.push(d); return 'histogram-bar hover'; } return 'histogram-bar'; }); // setup filters to watch selections and filter toggle this._disposers.selection = reaction( () => ({ filter: this.layoutDoc.dataViz_filterSelection, hists: this._selectedBars.slice(), cur: this._currSelected }), ({ filter, hists }) => { this.layoutDoc.dataViz_selectedRows = !filter ? undefined : new List( this._tableDataIds.filter(rowID => hists.some(h => { const val = this._props.records[rowID][this.xAxis]; return val == h.x0 || (+val >= +h.x0 && +val <= +h.x1); }) ) ); }, { fireImmediately: true } ); } // 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.Doc); 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: HistogramData[]): number[] => { const validData = dataSet.filter((d: { [x: string]: unknown }) => !Object.keys(dataSet[0]).some(key => !d[key] || isNaN(d[key] as number))); const field = dataSet[0] ? Object.keys(dataSet[0])[0] : undefined; return !field ? [] : validData.map(d => !this._numericalXData // ? (d[field] as number) : +d[field].toString().replace(/\$/g, '').replace(/%/g, '').replace(/ | HistogramData) => '' + (Array.isArray(d) ? d[0] : d[0]); // outlines the bar selected / hovered over highlightSelectedBar = (changeSelectedVariables: boolean, svg: d3.Selection, eachRectWidth: number, pointerX: number, xAxisTitle: string, yAxisTitle: string, histDataSet: HistogramData[]) => { let barCounter = -1; let hoverOverBar: HistogramData | undefined; svg.selectAll('.histogram-bar').filter(dIn => { const d = dIn as HistogramData; 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 + 1 || (!barCounter && pointerX <= 0)) && pointerX - 1 <= (barCounter + 1) * eachRectWidth) { if (changeSelectedVariables) { // for when a bar is selected - not just hovered over const alreadySelected = this._selectedBars.findIndex(eachData => !Object.keys(d).some(key => d[key] !== eachData[key])); if (alreadySelected !== -1) { this._selectedBars.splice(alreadySelected, 1); this.selectedBins.splice(alreadySelected, 1); } else { this._selectedBars.push(d); this.selectedBins.push(d[0] as number); } const showSelectedLabel = (dataset: HistogramData[]) => { const datum = dataset.lastElement(); const datumNum = datum as unknown as number[]; const showSelectedStart = this._numericalYData ? this._histogramData.filter(data => StrCast(data[xAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/ StrCast(data[xAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/ 1 ? dataset.map(dd => this.barLabel(dd)).join('::') : !this._numericalXData ? this.barLabel(d) : datum[0] !== undefined && datum[1] && datum[0] !== datum[1] ? d3.min(datumNum) + ' to ' + d3.max(datumNum) : !this._numericalYData ? showSelectedStart?.[xAxisTitle] : this.barLabel(d); // prettier-ignore return { [xAxisTitle]: selectionLabel, frequency: dataset.length > 1 ? Number.NaN : datum.length } as { [key: string]: string | number; frequency: number }; }; this._currSelected = this._selectedBars.length ? showSelectedLabel(this._selectedBars) : undefined; } else hoverOverBar = d; return true; } return false; }); return hoverOverBar; }; // draws the histogram drawChart = (dataSet: HistogramData[], width: number, height: number) => { 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 number))); if (!this._numericalXData) { const histStringDataSet: { [x: string]: number }[] = []; if (this._numericalYData) { for (let i = 0; i < dataSet.length; i++) { histStringDataSet.push({ [yAxisTitle]: dataSet[i][yAxisTitle] as number, [xAxisTitle]: dataSet[i][xAxisTitle] as number }); } } else { for (let i = 0; i < uniqueArr.length; i++) { histStringDataSet.push({ [yAxisTitle]: 0, [xAxisTitle]: uniqueArr[i] as number }); } 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 .bin() .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; 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 => (d[yAxisTitle] ? Number(StrCast(d[yAxisTitle]).replace(/\$/g, '').replace(/%/g, '').replace(/ d.length); // prettier-ignore const y = d3.scaleLinear().range([height, 0]); y.domain([0, maxFrequency ?? 0]); const yAxis = d3.axisLeft(y).ticks(maxFrequency!); if (this._numericalYData) { const yScale = scaleCreatorNumerical(0, maxFrequency ?? 0, 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateHighlights = (hoverOverBar?: HistogramData) => svg.selectAll('rect').attr('class', (d: any) => 'histogram-bar' + (hoverOverBar?.[0] == d[0] || this._selectedBars.some(hist => d[0] === hist[0]) ? ' hover' : '')); // eslint-disable-next-line @typescript-eslint/no-explicit-any const onPointClick = action((e: any) => this.highlightSelectedBar(true, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const mouseEnter = (e: any) => updateHighlights(this.highlightSelectedBar(false, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet)); svg.on('click', onPointClick).on('pointerenter', mouseEnter).on('pointerleave', updateHighlights); // 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._selectedBars; svg.selectAll('rect') .data(bins) .enter() .append('rect') .attr('transform', this._numericalYData ? d => { const eachData = histDataSet.filter((hData: HistogramData) => 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 => 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?.[0]?.x0 == d.x0 ? 'histogram-bar hover' : 'histogram-bar') : () => 'histogram-bar') .attr('fill', d => this.barColors?.map(bar => bar.split('::')).find(([barLabel]) => barLabel === this.barLabel(d))?.[1] ?? this.defaultBarColor); // prettier-ignore }; @action changeSelectedColor = (color: string, erase?: boolean) => { if (!this.barColors) this.layoutDoc.dataViz_histogram_barColors = new List(); this._selectedBars.map(this.barLabel).forEach(barName => { this.barColors.forEach(bar => bar.split('::')[0] === barName && this.barColors.splice(this.barColors.indexOf(bar), 1)); !erase && this.barColors.push(barName + '::' + color); }); }; render() { if (!this.selectedBins) this.layoutDoc.dataViz_histogram_selectedBins = new List(); const titleAccessor = 'dataViz_histogram_title ' + this.xAxis + (this.yAxis ? '-' + this._props.axes[1] : ''); const selected = !this._currSelected ? 'none' : '{ ' + Object.keys(this._currSelected).map(key => key ? key + ': ' + this._currSelected?.[key]:'').join(", ") + ' }'; // prettier-ignore const curSelectedBarName = this._selectedBars.length && this.barLabel(this._selectedBars.lastElement()); //.[this.xAxis]).replace(/\$/g, '').replace(/%/g, '').replace(/ bar.split('::'))?.find(([barLabel]) => barLabel === curSelectedBarName)?.[1]; if (this._histogramData.length > 0 || !this.parentViz) { return this._props.axes.length >= 1 ? (
(this.layoutDoc[titleAccessor] = val)), 'Change Graph Title' )} color="black" size={Size.LARGE} fillWidth />     } selectedColor={this.defaultBarColor} setFinalColor={undoable(color => (this.layoutDoc.dataViz_histogram_defaultColor = color), 'Change Default Bar Color')} setSelectedColor={undoable(color => (this.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} setFinalColor={undoable(this.changeSelectedColor, 'Change Selected Bar Color')} setSelectedColor={undoable(this.changeSelectedColor, '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.changeSelectedColor(this.defaultBarColor, true), '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.
; } }