diff options
author | bobzel <zzzman@gmail.com> | 2023-08-16 13:31:56 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-08-16 13:31:56 -0400 |
commit | d1e31265f8707bea63e21bf9a7b1dd10ccbf2009 (patch) | |
tree | b9c8b7d2aa084c206d272828843fc2b2ce911089 /src/client/views/nodes/DataVizBox/components/Histogram.tsx | |
parent | ad74cd03fa38a66101331ac0d3ea6cda841e3eee (diff) | |
parent | 61fb855ec92540c48cc4cc844d3b21728e8a4754 (diff) |
Merge pull request #206 from brown-dash/data-visualization-sarah
Data visualization sarah
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/Histogram.tsx')
-rw-r--r-- | src/client/views/nodes/DataVizBox/components/Histogram.tsx | 489 |
1 files changed, 489 insertions, 0 deletions
diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx new file mode 100644 index 000000000..df6aac6bc --- /dev/null +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -0,0 +1,489 @@ +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<HistogramProps> { + + private _disposers: { [key: string]: IReactionDisposer } = {}; + private _histogramRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _histogramSvg: d3.Selection<SVGGElement, unknown, null, undefined> | 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 || !ax1 || !/\d/.test(this.props.pairs[0][ax1]) || !this.numericalYData){ + return ax0 + " Histogram"; + } + else return ax0 + " by " + ax1 + " 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(/\</g, '') } + return d[field!] + }) + return data; + } + + // outlines the bar selected / hovered over + highlightSelectedBar = (changeSelectedVariables: boolean, svg: any, eachRectWidth: any, pointerX: any, xAxisTitle: any, yAxisTitle: any, histDataSet: any) => { + 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(/\</g, '')==d[0])[0] + : histDataSet.filter((data: { [x: string]: any; }) => data[xAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')==d[0])[0]; + if (this.numericalXData){ + // calculating frequency + if (d[0] && d[1] && d[0]!=d[1]){ + showSelected = {[xAxisTitle]: (d3.min(d) + " to " + d3.max(d)), frequency: d.length} + } + else if (!this.numericalYData) showSelected = {[xAxisTitle]: showSelected[xAxisTitle], frequency: d.length} + } + if (changeSelectedVariables){ + // for when a bar is selected - not just hovered over + sameAsCurrent = this._currSelected? + (showSelected[xAxisTitle]==this._currSelected![xAxisTitle] + && showSelected[yAxisTitle]==this._currSelected![yAxisTitle]) + : false; + this._currSelected = sameAsCurrent? undefined: showSelected; + this.selectedData = sameAsCurrent? undefined: d; + } + else this.hoverOverData = d; + return true + } + return false; + }); + if (changeSelectedVariables){ + if (sameAsCurrent!) this.curBarSelected = undefined; + else this.curBarSelected = selected; + } + } + + // draws the histogram + drawChart = (dataSet: any, width: number, height: number) => { + 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 || numBins<this.maxBins ? width/(numBins+1)/2 : 0; + if (numBins>this.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<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(/\</g, '')) : 0}) + : d3.max(bins, function(d) { return d.length; }) + var y = d3.scaleLinear() + .range([height, 0]); + y.domain([0, +maxFrequency!]); + var 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) => { + 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(/\</g, '') : 0; + return "translate(" + x(d.x0!) + "," + y(length) + ")"; + } + : function(d) { return "translate(" + x(d.x0!) + "," + y(d.length) + ")"; }) + .attr("height", 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(/\</g, '') : 0; + return height-y(length)} + : function(d) { + return height - y(d.length)}) + .attr("width", eachRectWidth) + .attr("class", selected? + function(d) { + return (selected && selected[0]==d[0])? 'histogram-bar hover' : 'histogram-bar'; + }: function(d) {return 'histogram-bar'}) + .attr("fill", (d)=>{ + var barColor; + var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::')); + barColors.map(each => { + if (d[0] && d[0].toString() && each[0]==d[0].toString()) barColor = each[1]; + else { + var 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.defaultHistogramColor)}) + }; + + @action changeSelectedColor = (color: string) => { + this.curBarSelected.attr("fill", color); + var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')) + + const barColors = Cast(this.props.layoutDoc.histogramBarColors, listSpec("string"), null); + barColors.map(each => { 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(/\</g, '')) + + const barColors = Cast(this.props.layoutDoc.histogramBarColors, listSpec("string"), null); + barColors.map(each => { if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1) }); + }; + + render() { + this._histogramData + 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<string>(); + var selected: string; + if (this._currSelected){ + curSelectedBarName = StrCast(this._currSelected![this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')) + selected = '{ '; + Object.keys(this._currSelected).map(key => { + 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(); + + if (this._histogramData.length>0){ + return ( + this.props.axes.length >= 1 ? ( + <div className="chart-container" > + <div className="graph-title"> + <EditableText + val={StrCast(this.props.layoutDoc[titleAccessor])} + setVal={undoable (action(val => this.props.layoutDoc[titleAccessor] = val as string), "Change Graph Title")} + color={"black"} + size={Size.LARGE} + fillWidth + /> + + <ColorPicker + tooltip={'Change Default Bar Color'} + type={Type.SEC} + icon={<FaFillDrip/>} + selectedColor={StrCast(this.props.layoutDoc.defaultHistogramColor)} + setSelectedColor={undoable (color => this.props.layoutDoc.defaultHistogramColor = color, "Change Default Bar Color")} + size={Size.XSMALL} + /> + </div> + <div ref={this._histogramRef} /> + {selected != 'none' ? + <div className={'selected-data'}> + Selected: {selected} + + <ColorPicker + tooltip={'Change Bar Color'} + type={Type.SEC} + icon={<FaFillDrip/>} + selectedColor={selectedBarColor? selectedBarColor : this.curBarSelected.attr("fill")} + setSelectedColor={undoable (color => this.changeSelectedColor(color), "Change Selected Bar Color")} + size={Size.XSMALL} + /> + + <IconButton + icon={<FontAwesomeIcon icon={'eraser'} />} + size={Size.XSMALL} + color={'black'} + type={Type.SEC} + tooltip={'Revert to the default bar color'} + onClick={undoable (action(() => this.eraseSelectedColor()), "Change Selected Bar Color")} + /> + </div> + : null} + </div> + ) : <span className="chart-container"> {'first use table view to select a column to graph'}</span> + ); + } + else return ( + // when it is a brushed table and the incoming table doesn't have any rows selected + <div className='chart-container'> + Selected rows of data from the incoming DataVizBox to display. + </div> + ) + } +}
\ No newline at end of file |