aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/DataVizBox/components/Histogram.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/Histogram.tsx')
-rw-r--r--src/client/views/nodes/DataVizBox/components/Histogram.tsx489
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
+ />
+ &nbsp; &nbsp;
+ <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}
+ &nbsp; &nbsp;
+ <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}
+ />
+ &nbsp;
+ <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