import { observer } from "mobx-react"; import { Doc, StrListCast } from "../../../../../fields/Doc"; import * as React from 'react'; import * as d3 from 'd3'; import { IReactionDisposer, action, computed, observable, reaction } from "mobx"; import { LinkManager } from "../../../../util/LinkManager"; import { Cast, DocCast, StrCast} from "../../../../../fields/Types"; import { PinProps, PresBox } from "../../trails"; import { Docs } from "../../../../documents/Documents"; import { List } from "../../../../../fields/List"; import './Chart.scss'; import { ColorPicker, EditableText, IconButton, Size, Type } from "browndash-components"; import { FaFillDrip } from "react-icons/fa"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { listSpec } from "../../../../../fields/Schema"; import { scaleCreatorNumerical, yAxisCreator } from "../utils/D3Utils"; import { undoBatch, undoable } from "../../../../util/UndoManager"; export interface HistogramProps { rootDoc: Doc; layoutDoc: Doc; axes: string[]; pairs: { [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 React.Component { 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 // filters all data to just display selected data if brushed (created from an incoming link) @computed get _histogramData() { var guids = StrListCast(this.props.layoutDoc.rowGuids); if (this.props.axes.length < 1) return []; if (this.props.axes.length < 2) { var ax0 = this.props.axes[0]; if (/\d/.test(this.props.pairs[0][ax0])){ this.numericalXData = true } return this.props.pairs ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)]))) .map(pair => ({ [ax0]: (pair[this.props.axes[0]])})) }; var ax0 = this.props.axes[0]; var ax1 = this.props.axes[1]; if (/\d/.test(this.props.pairs[0][ax0])) { this.numericalXData = true;} if (/\d/.test(this.props.pairs[0][ax1]) ) { this.numericalYData = true;} return this.props.pairs ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.selected && StrListCast(this.incomingLinks[0].selected).includes(guids[this.props.pairs.indexOf(pair)]))) .map(pair => ({ [ax0]: (pair[this.props.axes[0]]), [ax1]: (pair[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.pairs[0][ax0]) || !ax1 || !this.numericalYData){ return ax0 + " Histogram"; } else return ax1 + " by " + ax0 + " Histogram"; } @computed get incomingLinks() { return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links .filter(link => link.link_anchor_1 == this.props.rootDoc.draggedFrom) // 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 }) => { 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: 'histogram doc selection' + this._currSelected, }); PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this.props.dataDoc); 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; }) => { var valid = true; Object.keys(dataSet[0]).map(key => { if (!d[key] || Number.isNaN(d[key])) valid = false; }) return valid; }) var field = dataSet[0]? Object.keys(dataSet[0])[0] : undefined; const data = validData.map((d: { [x: string]: any; }) => { if (this.numericalXData) { return +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; }) => 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(); var data = this.data(dataSet); var xAxisTitle = Object.keys(dataSet[0])[0] var yAxisTitle = this.numericalYData ? Object.keys(dataSet[0])[1] : 'frequency'; let 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 || numBinsthis.maxBins) numBins = this.maxBins; var startingPoint = this.numericalXData? this.rangeVals.xMin! : 0; var endingPoint = this.numericalXData? this.rangeVals.xMax! : numBins; // converts data into Objects var histDataSet = dataSet.filter((d: { [x: string]: unknown; }) => { var valid = true; Object.keys(dataSet[0]).map(key => { if (!d[key] || Number.isNaN(d[key])) valid = false; }) return valid; }); if (!this.numericalXData) { var histStringDataSet: { [x: string]: unknown; }[] = []; if (this.numericalYData){ for (let i=0; i 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 d.x0 = d.x0!) xAxis = d3.axisBottom(x) .ticks(bins.length-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=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) => { const selected = 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) { var eachData = histDataSet.filter((data: { [x: string]: number; }) => {return data[xAxisTitle]==d[0]}) var length = eachData.length? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\ {return data[xAxisTitle]==d[0]}) var length = eachData.length? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\{ var barColor; var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::')); barColors.map(each => {if (each[0]==StrCast(d[0])) barColor = each[1]}); return barColor? StrCast(barColor) : StrCast(this.props.layoutDoc.defaultHistogramColor)}) }; @action changeSelectedColor = (color: string) => { this.curBarSelected.attr("fill", color); var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\ { if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1) }); barColors.push(StrCast(barName + '::' + color)); }; @action eraseSelectedColor= () => { this.curBarSelected.attr("fill", this.props.layoutDoc.defaultHistogramColor); var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\ { if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1) }); }; render() { var curSelectedBarName; var titleAccessor: any=''; if (this.props.axes.length==2) titleAccessor = 'histogram-title-'+this.props.axes[0]+'-'+this.props.axes[1]; else if (this.props.axes.length>0) titleAccessor = 'histogram-title-'+this.props.axes[0]; if (!this.props.layoutDoc[titleAccessor]) this.props.layoutDoc[titleAccessor] = this.defaultGraphTitle; if (!this.props.layoutDoc.defaultHistogramColor) this.props.layoutDoc.defaultHistogramColor = '#69b3a2'; if (!this.props.layoutDoc.histogramBarColors) this.props.layoutDoc.histogramBarColors = new List(); var selected: string; 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); selected += ' }'; } else selected = 'none'; var selectedBarColor; var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::')); barColors.map(each => {if (each[0]==curSelectedBarName!) selectedBarColor = each[1]}); this.componentDidMount(); 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.defaultHistogramColor)} setSelectedColor={undoable (color => this.props.layoutDoc.defaultHistogramColor = color, "Change Default Bar Color")} size={Size.XSMALL} />
{selected != 'none' ?
Selected: {selected}     } selectedColor={selectedBarColor? selectedBarColor : this.curBarSelected.attr("fill")} 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'} ); } }