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
This commit is contained in:
Severiano Badajoz
2020-06-15 11:32:55 -07:00
committed by GitHub
parent 77ffa0712e
commit 23619010c3
3 changed files with 301 additions and 13 deletions

View File

@@ -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 {
</div>
<span style={{ flexShrink: 0 }}>
{colorAccessor && !isColorBy && !annotations.isEditingLabelName ? (
<Occupancy
categoryValue={value}
colorAccessor={colorAccessor}
metadataField={metadataField}
world={world}
colorScale={colorScale}
colorByIsCategorical={!!categoricalSelection[colorAccessor]}
/>
categoricalSelection[colorAccessor] ? (
<MiniStackedBar
/* eslint-disable react/jsx-props-no-spreading -- Disable unneeded on next release of eslint-config-airbnb */
{...{
colorScale,
...this.createStackedGraphBins(
world,
metadataField,
colorAccessor,
value,
CHART_WIDTH
),
}}
/* eslint-enable react/jsx-props-no-spreading -- enable */
height={VALUE_HEIGHT}
width={CHART_WIDTH}
/>
) : (
<MiniHistogram
/* eslint-disable react/jsx-props-no-spreading -- Disable unneeded on next release of eslint-config-airbnb */
{...{
colorScale,
...this.createHistogramBins(
world,
metadataField,
colorAccessor,
value,
CHART_WIDTH,
VALUE_HEIGHT
),
}}
/* eslint-enable react/jsx-props-no-spreading -- enable */
obsOrVarContinuousFieldDisplayName={colorAccessor}
domainLabel={value}
height={VALUE_HEIGHT}
width={CHART_WIDTH}
/>
)
) : null}
</span>
</div>
@@ -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))

View File

@@ -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 (
<Popover
interactionKind={PopoverInteractionKind.HOVER_TARGET_ONLY}
hoverOpenDelay={1500}
hoverCloseDelay={200}
position={Position.LEFT}
modifiers={{
preventOverflow: { enabled: false },
hide: { enabled: false },
}}
lazy
usePortal
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
>
<canvas
className="bp3-popover-targer"
style={{
marginRight: 5,
width,
height,
borderBottom: "solid rgb(230, 230, 230) 0.25px",
}}
width={width}
height={height}
ref={this.canvasRef}
/>
<div key="text" style={{ fontSize: "14px" }}>
<p style={{ margin: "0" }}>
This histograms shows the distribution of{" "}
<strong>{obsOrVarContinuousFieldDisplayName}</strong> within{" "}
<strong>{domainLabel}</strong>.
<br />
<br />
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.
</p>
</div>
</Popover>
);
}
}

View File

@@ -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 (
<canvas
className="bp3-popover-targer"
style={{
marginRight: 5,
width,
height,
}}
width={width}
height={height}
ref={this.canvasRef}
/>
);
}
}