From 23619010c30c6d6a5457e25156e919597a96cede Mon Sep 17 00:00:00 2001 From: Severiano Badajoz Date: Mon, 15 Jun 2020 11:32:55 -0700 Subject: [PATCH] Componentize bar charts (#1557) * create miniHistogram Component * use MiniHistogram * create MiniStackedBar Component * Use MiniStackedBar * update graphs on colorAccessor change * Move bin creation out of miniHistogram * rename expressionLabel * breakout stackedbar bin creation * Trigger workflow * move components to individual folders * rename constant --- .../src/components/categorical/value/index.js | 147 ++++++++++++++++-- client/src/components/miniHistogram/index.js | 97 ++++++++++++ client/src/components/miniStackedBar/index.js | 70 +++++++++ 3 files changed, 301 insertions(+), 13 deletions(-) create mode 100644 client/src/components/miniHistogram/index.js create mode 100644 client/src/components/miniStackedBar/index.js diff --git a/client/src/components/categorical/value/index.js b/client/src/components/categorical/value/index.js index 6018912d..ae23a737 100644 --- a/client/src/components/categorical/value/index.js +++ b/client/src/components/categorical/value/index.js @@ -1,5 +1,6 @@ import { connect } from "react-redux"; import React from "react"; +import * as d3 from "d3"; import { Button, @@ -10,7 +11,6 @@ import { Icon, PopoverInteractionKind, } from "@blueprintjs/core"; -import Occupancy from "./occupancy"; import * as globals from "../../../globals"; import styles from "../categorical.css"; import AnnoDialog from "../annoDialog"; @@ -19,6 +19,8 @@ import Truncate from "../../util/truncate"; import { AnnotationsHelpers } from "../../../util/stateManager"; import { labelPrompt, isLabelErroneous } from "../labelUtil"; +import MiniHistogram from "../../miniHistogram"; +import MiniStackedBar from "../../miniStackedBar"; /* this is defined outside of the class so we can use it in connect() */ function _currentLabelAsString(ownProps) { @@ -286,6 +288,94 @@ class CategoryValue extends React.Component { this.setState({ editedLabelText: e.target }); }; + createHistogramBins = ( + world, + metadataField, + colorAccessor, + value, + width, + height + ) => { + /* + Knowing that colorScale is based off continuous data, + createHistogramBins fetches the continuous data in relation to the cells relevant to the category value. + It then separates that data into 50 bins for drawing the mini-histogram + */ + const groupBy = world.obsAnnotations.col(metadataField); + + const col = + world.obsAnnotations.col(colorAccessor) || + world.varData.col(colorAccessor); + + const range = col.summarize(); + + const histogramMap = col.histogram( + 50, + [range.min, range.max], + groupBy + ); /* Because the signature changes we really need different names for histogram to differentiate signatures */ + + const bins = histogramMap.has(value) + ? histogramMap.get(value) + : new Array(50).fill(0); + + const xScale = d3.scaleLinear().domain([0, bins.length]).range([0, width]); + + const largestBin = Math.max(...bins); + + const yScale = d3.scaleLinear().domain([0, largestBin]).range([0, height]); + + return { + xScale, + yScale, + bins, + }; + }; + + createStackedGraphBins = ( + world, + metadataField, + colorAccessor, + categoryValue, + width + ) => { + /* + Knowing that the color scale is based off of categorical data, + createOccupancyStack obtains a map showing the number if cells per colored value + Using the colorScale a stack of colored bars is drawn representing the map + */ + const { schema } = world; + + const groupBy = world.obsAnnotations.col(metadataField); + const occupancyMap = world.obsAnnotations + .col(colorAccessor) + .histogramCategorical(groupBy); + + const occupancy = occupancyMap.get(categoryValue); + + if (occupancy && occupancy.size > 0) { + // not all categories have occupancy, so occupancy may be undefined. + const scale = d3 + .scaleLinear() + /* get all the keys d[1] as an array, then find the sum */ + .domain([0, d3.sum(Array.from(occupancy.values()))]) + .range([0, width]); + const categories = + schema.annotations.obsByName[colorAccessor]?.categories; + + const dfColumn = world.obsAnnotations.col(colorAccessor); + const categoryValues = dfColumn.summarizeCategorical().categories; + + return { + domainValues: categoryValues, + scale, + domain: categories, + occupancy, + }; + } + return null; + }; + currentLabelAsString() { return _currentLabelAsString(this.props); } @@ -365,6 +455,7 @@ class CategoryValue extends React.Component { const valueToggleLabel = `value-toggle-checkbox-${displayString}`; + const VALUE_HEIGHT = 11; const LEFT_MARGIN = 33; const CHECKBOX = 26; const CELL_NUMBER = 61; @@ -378,11 +469,11 @@ class CategoryValue extends React.Component { LABEL_MARGIN + (isUserAnno ? ANNO_MENU : 0); - const OCCUPANCY_WIDTH = 100; + const CHART_WIDTH = 100; const labelWidth = colorAccessor && !isColorBy - ? globals.leftSidebarWidth - otherElementsWidth - OCCUPANCY_WIDTH + ? globals.leftSidebarWidth - otherElementsWidth - CHART_WIDTH : globals.leftSidebarWidth - otherElementsWidth; return ( @@ -502,14 +593,44 @@ class CategoryValue extends React.Component { {colorAccessor && !isColorBy && !annotations.isEditingLabelName ? ( - + categoricalSelection[colorAccessor] ? ( + + ) : ( + + ) ) : null} @@ -536,8 +657,8 @@ class CategoryValue extends React.Component { display={isColorBy && categories ? "auto" : "none"} style={{ marginLeft: 5, - width: 11, - height: 11, + width: VALUE_HEIGHT, + height: VALUE_HEIGHT, backgroundColor: isColorBy && categories ? colorScale(categories.indexOf(value)) diff --git a/client/src/components/miniHistogram/index.js b/client/src/components/miniHistogram/index.js new file mode 100644 index 00000000..fd30edc5 --- /dev/null +++ b/client/src/components/miniHistogram/index.js @@ -0,0 +1,97 @@ +import React from "react"; +import { + Popover, + PopoverInteractionKind, + Position, + Classes, +} from "@blueprintjs/core"; + +export default class MiniHistogram extends React.PureComponent { + constructor(props) { + super(props); + this.canvasRef = React.createRef(); + } + + drawHistogram = () => { + const { xScale, yScale, bins, width, height } = this.props; + const ctx = this.canvasRef.current.getContext("2d"); + + ctx.clearRect(0, 0, width, height); + + ctx.fillStyle = "#000"; + + let x; + let y; + + const rectWidth = width / bins.length; + + for (let i = 0, { length } = bins; i < length; i += 1) { + x = xScale(i); + y = yScale(bins[i]); + ctx.fillRect(x, height - y, rectWidth, y); + } + }; + + componentDidMount = () => { + this.drawHistogram(); + }; + + componentDidUpdate = (prevProps) => { + const { obsOrVarContinuousFieldDisplayName } = this.props; + if ( + prevProps.obsOrVarContinuousFieldDisplayName !== + obsOrVarContinuousFieldDisplayName + ) + this.drawHistogram(); + }; + + render() { + const { + domainLabel, + obsOrVarContinuousFieldDisplayName, + width, + height, + } = this.props; + + return ( + + +
+

+ This histograms shows the distribution of{" "} + {obsOrVarContinuousFieldDisplayName} within{" "} + {domainLabel}. +
+
+ The x axis is the same for each histogram, while the y axis is + scaled to the largest bin within this histogram instead of the + largest bin within the whole category. +

+
+
+ ); + } +} diff --git a/client/src/components/miniStackedBar/index.js b/client/src/components/miniStackedBar/index.js new file mode 100644 index 00000000..ef4d92b3 --- /dev/null +++ b/client/src/components/miniStackedBar/index.js @@ -0,0 +1,70 @@ +// jshint esversion: 6 +import React from "react"; + +export default class MiniStackedBar extends React.PureComponent { + constructor(props) { + super(props); + this.canvasRef = React.createRef(); + } + + drawStacks = () => { + const { + domainValues, + scale, + domain, + colorScale, + occupancy, + width, + height, + } = this.props; + + const ctx = this.canvasRef?.current.getContext("2d"); + + ctx.clearRect(0, 0, width, height); + let currentOffset = 0; + + let occupancyValue; + let scaledValue; + let value; + + for (let i = 0, { length } = domainValues; i < length; i += 1) { + value = domainValues[i]; + occupancyValue = occupancy.get(value); + scaledValue = scale(occupancyValue); + ctx.fillStyle = occupancyValue + ? colorScale(domain.indexOf(value)) + : "rgb(255,255,255)"; + ctx.fillRect(currentOffset, 0, occupancyValue ? scaledValue : 0, height); + currentOffset += occupancyValue ? scaledValue : 0; + } + }; + + componentDidUpdate = (prevProps) => { + const { occupancy } = this.props; + if (occupancy !== prevProps.occupancy) this.drawStacks(); + }; + + componentDidMount = () => { + this.drawStacks(); + }; + + render() { + const { width, height } = this.props; + const { canvas } = this; + if (canvas) canvas.getContext("2d").clearRect(0, 0, width, height); + + return ( + + ); + } +}