Add Stacked Bar Graph component (Closes #3) #37

Merged
e26chiu merged 14 commits from stackedbar-component into main 2022-10-01 20:54:06 -04:00
6 changed files with 608 additions and 1 deletions

View File

@ -0,0 +1,35 @@
.container {
position: relative;
}
.barStack:hover {
filter: drop-shadow(0 0 calc(4rem / 16) var(--label));
}
.legend {
position: absolute;
display: flex;
font-size: calc(16rem / 16);
top: 0;
}
.toolTip {
font-family: "Inconsolata", monospace;
top: 0;
left: 0;
position: absolute;
background-color: var(--label);
color: var(--primary-background);
pointer-events: none;
border-radius: calc(10rem / 16);
padding: calc(10rem / 16);
}
.toolTip p {
margin: 0 calc(5rem / 16);
font-size: calc(16rem / 16);
}
.key {
font-weight: bold;
}

View File

@ -0,0 +1,455 @@
import { AxisLeft, AxisBottom } from "@visx/axis";
import { localPoint } from "@visx/event";
import { GridRows, GridColumns } from "@visx/grid";
import { Group } from "@visx/group";
import { LegendOrdinal } from "@visx/legend";
import { Point } from "@visx/point";
import { scaleBand, scaleLinear, scaleOrdinal } from "@visx/scale";
import { BarStack, BarStackHorizontal, Line } from "@visx/shape";
import { SeriesPoint } from "@visx/shape/lib/types";
import { withTooltip, Tooltip } from "@visx/tooltip";
import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip";
import React from "react";
import { Color } from "utils/Color";
import styles from "./StackedBarGraph.module.css";
interface StackedBarData {
category: string;
Review

Have we considered

interface StackedBarData {
  category: string;
  values: {
    [key: string]: number;
  };
}

?
This would allow for a key called "category", at the expense of more nesting.

Have we considered ```typescript interface StackedBarData { category: string; values: { [key: string]: number; }; } ``` ? This would allow for a key called "category", at the expense of more nesting.
Review

Also is there a reason why the value can be number | string and not just number? EDIT: nvm, it's to make TS stop complaining

https://stackoverflow.com/questions/54460029/typescript-inteface-where-id-must-be-number-and-other-properties-must-be-strin

~~Also is there a reason why the value can be `number | string` and not just `number`?~~ EDIT: nvm, it's to make TS stop complaining https://stackoverflow.com/questions/54460029/typescript-inteface-where-id-must-be-number-and-other-properties-must-be-strin
Review

I have considered the above, but it doesn't work since the format of the data doesn't match what is expected in the props data taken by the <BarStack> component:

<BarStack<StackedBarData, string> 
   data={data}
   keys={keys}

It specifically needs category to be a key in data and all the other keys to be directly nested inside of data (cannot be nested inside of values).

I have considered the above, but it doesn't work since the format of the data doesn't match what is expected in the props `data` taken by the `<BarStack>` component: ``` <BarStack<StackedBarData, string> data={data} keys={keys} ``` It specifically needs `category` to be a key in `data` and all the other keys to be directly nested inside of `data` (cannot be nested inside of `values`).
[key: string]: number | string;
}
type TooltipData = {
bar: SeriesPoint<StackedBarData>;
key: string;
index: number;
height: number;
width: number;
x: number;
y: number;
color: string;
};
export type StackedBarProps = {
data: StackedBarData[];
/** Width of the entire graph, in pixels, greater than 10. */
width: number;
/** Height of the entire graph, in pixels. */
height: number;
/** Names of the groups appearing in the legend */
keys: string[];
/** Colours for each key */
colorRange: string[];
/** Distance between the edge of the graph and the area where the bars are drawn, in pixels. */
margin: { top: number; left: number };
/** Number of ticks for the value axis */
numTicksValueAxis?: number;
/** Distance between the left axis labels and the start of the lines of the graph, in px. */
axisLeftOffset?: number;
/** Distance between the bottom axis and the bottom of the container of the graph, in px. */
axisBottomOffset?: number;
/** Distance between the right side of the graph and the legend, in px. */
legendLeftOffset?: number;
/** Distance between the top of the graph and the legend, in px. */
legendTopOffset?: number;
/** Width of the lines in the graph, in px. */
strokeWidth?: number;
/** Length of the dashes and the gaps in the graph, in px. */
strokeDashArray?: string;
/** Padding between each bar in the stacked bar graph, from 0 to 1 */
scalePadding?: number;
/** Margin for each item in the legend */
itemMargin?: string;
/** Factor multiplied with an offset to center the labels of the category-axis depending on the width/height of the graph.
* >1 for width/height <600 and <1 for width/height >600 (vertical=width/horizontal=height) */
categoryAxisLeftFactor?: number;
};
let tooltipTimeout: number;
export const StackedBarGraphVertical = withTooltip<
StackedBarProps,
TooltipData
>(
({
data,
width,
height,
keys,
colorRange,
margin,
scalePadding = 0.3,
numTicksValueAxis = 6,
axisLeftOffset = 40,
axisBottomOffset = 40,
strokeWidth = 2.5,
strokeDashArray = "10,4",
legendLeftOffset = 40,
legendTopOffset = 40,
itemMargin = "15px 0 0 0",
categoryAxisLeftFactor = 1,
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
}: StackedBarProps & WithTooltipProvidedProps<TooltipData>) => {
const yTotals = data.reduce((allTotals, currCategory) => {
const yTotal = keys.reduce((categoryTotal, k) => {
categoryTotal += currCategory[k] as number;
return categoryTotal;
}, 0);
allTotals.push(yTotal);
return allTotals;
}, [] as number[]);
const TICK_LABEL_FONT_WEIGHT = 800;
// accessors
const getCategory = (d: StackedBarData) => d.category;
// scales
const categoryScale = scaleBand<string>({
domain: data.map(getCategory),
padding: scalePadding,
});
const valueScale = scaleLinear<number>({
domain: [0, Math.max(...yTotals)],
nice: true,
});
const colorScale = scaleOrdinal<string, string>({
domain: keys,
range: colorRange,
});
// bounds
const xMax = width;
const yMax = height - margin.top - axisBottomOffset;
categoryScale.rangeRound([0, xMax - axisLeftOffset]);
valueScale.range([yMax, 0]);
return width < 10 ? null : (
<div className={styles.container}>
<svg width={width} height={height}>
<Group top={margin.top} left={margin.left}>
<GridRows
scale={valueScale}
width={xMax}
height={yMax}
left={axisLeftOffset}
numTicks={numTicksValueAxis}
stroke={Color.tertiaryBackground}
strokeWidth={strokeWidth}
strokeDasharray={strokeDashArray}
/>
<GridColumns
scale={categoryScale}
height={yMax}
left={axisLeftOffset}
offset={categoryScale.bandwidth() / 2}
stroke={Color.tertiaryBackground}
strokeWidth={strokeWidth}
strokeDasharray={strokeDashArray}
/>
<Group left={axisLeftOffset}>
<BarStack<StackedBarData, string>
data={data}
keys={keys}
x={getCategory}
xScale={categoryScale}
yScale={valueScale}
color={colorScale}
>
{(barStacks) =>
barStacks.map((barStack) =>
barStack.bars.map((bar) => (
<rect
className={styles.barStack}
key={`bar-stack-${barStack.index}-${bar.index}`}
x={bar.x}
y={bar.y}
height={bar.height}
width={bar.width / 2}
fill={bar.color}
onMouseLeave={() => {
tooltipTimeout = window.setTimeout(() => {
hideTooltip();
}, 300);
}}
onMouseMove={(event) => {
if (tooltipTimeout) clearTimeout(tooltipTimeout);
const eventSvgCoords = localPoint(event);
const left = bar.x + bar.width / 2;
showTooltip({
tooltipData: bar,
tooltipTop: eventSvgCoords?.y,
tooltipLeft: left,
});
}}
/>
))
)
}
</BarStack>
</Group>
<Line
fill={Color.tertiaryBackground}
to={new Point({ x: axisLeftOffset, y: 0 })}
from={new Point({ x: axisLeftOffset, y: yMax })}
stroke={Color.tertiaryBackground}
strokeWidth={strokeWidth}
strokeDasharray={strokeDashArray}
/>
<AxisBottom
top={yMax}
scale={categoryScale}
left={
((categoryScale.bandwidth() * 100) / width) *
categoryAxisLeftFactor
}
hideTicks
hideAxisLine
labelProps={{
fontSize: `${10 / 16}rem`,
}}
tickLabelProps={() => ({
fill: Color.label,
fontWeight: TICK_LABEL_FONT_WEIGHT,
})}
/>
<AxisLeft
scale={valueScale}
top={5}
numTicks={numTicksValueAxis}
hideAxisLine
labelProps={{
fontSize: `${10 / 16}rem`,
}}
tickLabelProps={() => {
return {
fill: Color.label,
fontWeight: TICK_LABEL_FONT_WEIGHT,
};
}}
/>
</Group>
</svg>
<div
className={styles.legend}
style={{ left: width + legendLeftOffset, top: legendTopOffset }}
>
<LegendOrdinal
scale={colorScale}
direction="column"
itemMargin={itemMargin}
/>
</div>
{tooltipOpen && tooltipData ? (
<Tooltip
className={styles.toolTip}
top={tooltipTop}
left={tooltipLeft}
unstyled
>
<p className={styles.key}>{tooltipData.key}</p>
<p>{tooltipData.bar.data[tooltipData.key]}</p>
<p>{getCategory(tooltipData.bar.data)}</p>
</Tooltip>
) : null}
</div>
);
}
);
export const StackedBarGraphHorizontal = withTooltip<
StackedBarProps,
TooltipData
>(
({
data,
width,
height,
keys,
colorRange,
margin,
scalePadding = 0.3,
numTicksValueAxis = 6,
axisLeftOffset = 40,
axisBottomOffset = 40,
strokeWidth = 2.5,
strokeDashArray = "10,4",
legendLeftOffset = 40,
legendTopOffset = 40,
itemMargin = "15px 0 0 0",
categoryAxisLeftFactor = 1,
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip,
}: StackedBarProps & WithTooltipProvidedProps<TooltipData>) => {
const yTotals = data.reduce((allTotals, currCategory) => {
const yTotal = keys.reduce((categoryTotal, k) => {
categoryTotal += currCategory[k] as number;
return categoryTotal;
}, 0);
allTotals.push(yTotal);
return allTotals;
}, [] as number[]);
const TICK_LABEL_FONT_WEIGHT = 800;
// accessors
const getCategory = (d: StackedBarData) => d.category;
// scales
const valueScale = scaleLinear<number>({
domain: [0, Math.max(...yTotals)],
nice: true,
});
const categoryScale = scaleBand<string>({
domain: data.map(getCategory),
padding: scalePadding,
});
const colorScale = scaleOrdinal<string, string>({
domain: keys,
range: colorRange,
});
// bounds
const xMax = width;
const yMax = height - margin.top - axisBottomOffset;
categoryScale.rangeRound([yMax, 0]);
Review

Please change the range to [0, yMax] - it makes it so the order of the categories is the same top-to-bottom when the graph has horizontal bars vs. left-to-right when the graph has vertical bars.

Please change the range to `[0, yMax]` - it makes it so the order of the categories is the same top-to-bottom when the graph has horizontal bars vs. left-to-right when the graph has vertical bars.
valueScale.range([0, xMax - axisLeftOffset]);
return width < 10 ? null : (
<div className={styles.container}>
<svg width={width} height={height}>
<Group top={margin.top} left={margin.left}>
<GridRows
scale={categoryScale}
width={xMax}
height={yMax}
offset={categoryScale.bandwidth() / 2}
left={axisLeftOffset}
stroke={Color.tertiaryBackground}
strokeWidth={strokeWidth}
strokeDasharray={strokeDashArray}
/>
<GridColumns
scale={valueScale}
height={yMax}
numTicks={numTicksValueAxis}
left={axisLeftOffset}
stroke={Color.tertiaryBackground}
strokeWidth={strokeWidth}
strokeDasharray={strokeDashArray}
/>
<Group left={axisLeftOffset}>
<BarStackHorizontal<StackedBarData, string>
data={data}
keys={keys}
y={getCategory}
xScale={valueScale}
yScale={categoryScale}
color={colorScale}
>
{(barStacks) =>
barStacks.map((barStack) =>
barStack.bars.map((bar) => (
<rect
className={styles.barStack}
key={`bar-stack-${barStack.index}-${bar.index}`}
x={bar.x}
y={bar.y}
height={bar.height / 2}
width={bar.width}
fill={bar.color}
onMouseLeave={() => {
tooltipTimeout = window.setTimeout(() => {
hideTooltip();
}, 300);
}}
onMouseMove={(event) => {
if (tooltipTimeout) clearTimeout(tooltipTimeout);
const eventSvgCoords = localPoint(event);
const left = bar.x + bar.width / 2;
showTooltip({
tooltipData: bar,
tooltipTop: eventSvgCoords?.y,
tooltipLeft: left,
});
}}
/>
))
)
}
</BarStackHorizontal>
</Group>
<AxisBottom
top={yMax}
scale={valueScale}
left={axisLeftOffset}
numTicks={numTicksValueAxis}
hideAxisLine
hideTicks
labelProps={{
fontSize: `${10 / 16}rem`,
}}
tickLabelProps={() => ({
fill: Color.label,
fontWeight: TICK_LABEL_FONT_WEIGHT,
})}
/>
<AxisLeft
top={
-((categoryScale.bandwidth() * 100) / width) *
categoryAxisLeftFactor
}
scale={categoryScale}
hideAxisLine
hideTicks
labelProps={{
fontSize: `${10 / 16}rem`,
}}
tickLabelProps={() => {
return {
fill: Color.label,
fontWeight: TICK_LABEL_FONT_WEIGHT,
};
}}
/>
</Group>
</svg>
<div
className={styles.legend}
style={{ left: width + legendLeftOffset, top: legendTopOffset }}
>
<LegendOrdinal
scale={colorScale}
direction="column"
itemMargin={itemMargin}
/>
</div>
{tooltipOpen && tooltipData ? (
<Tooltip
className={styles.toolTip}
top={tooltipTop}
left={tooltipLeft}
unstyled
>
<p className={styles.key}>{tooltipData.key}</p>
<p>{tooltipData.bar.data[tooltipData.key]}</p>
<p>{getCategory(tooltipData.bar.data)}</p>
</Tooltip>
) : null}
</div>
);
}
);

View File

@ -80,6 +80,45 @@ export const moreMockCategoricalData = [
{ key: "Dart", value: 2.21 }, { key: "Dart", value: 2.21 },
]; ];
export const mockStackedBarGraphData = [
{
category: "1A",
"geese watchers": 60,
"geese breeders": 80,
"geese catchers": 90,
},
{
category: "1B",
"geese watchers": 25,
"geese breeders": 37,
"geese catchers": 80,
},
{
category: "2A",
"geese watchers": 40,
"geese breeders": 50,
"geese catchers": 70,
},
{
category: "2B",
"geese watchers": 40,
"geese breeders": 80,
"geese catchers": 88,
},
{
category: "3A",
"geese watchers": 15,
"geese breeders": 30,
"geese catchers": 45,
},
];
export const mockStackedBarKeys = [
"geese watchers",
"geese breeders",
"geese catchers",
];
export const mockTimelineData = [ export const mockTimelineData = [
{ {
time: "Fall 2020", time: "Fall 2020",

28
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@visx/event": "^2.6.0", "@visx/event": "^2.6.0",
"@visx/grid": "^2.10.0", "@visx/grid": "^2.10.0",
"@visx/group": "^2.10.0", "@visx/group": "^2.10.0",
"@visx/legend": "^2.10.0",
"@visx/mock-data": "^2.1.2", "@visx/mock-data": "^2.1.2",
"@visx/scale": "^2.2.2", "@visx/scale": "^2.2.2",
"@visx/shape": "^2.10.0", "@visx/shape": "^2.10.0",
@ -973,6 +974,21 @@
"react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
} }
}, },
"node_modules/@visx/legend": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@visx/legend/-/legend-2.10.0.tgz",
"integrity": "sha512-OI8BYE6QQI9eXAng/C7UzuVw7d0fwlzrth6RmrdhlyT1K+BA3WpExapV+pDfwxu/tkEik8Ps5cZRV6HjX1/Mww==",
"dependencies": {
"@types/react": "*",
"@visx/group": "2.10.0",
"@visx/scale": "2.2.2",
"classnames": "^2.3.1",
"prop-types": "^15.5.10"
},
"peerDependencies": {
"react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
}
},
"node_modules/@visx/mock-data": { "node_modules/@visx/mock-data": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz",
@ -5086,6 +5102,18 @@
"prop-types": "^15.6.2" "prop-types": "^15.6.2"
} }
}, },
"@visx/legend": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@visx/legend/-/legend-2.10.0.tgz",
"integrity": "sha512-OI8BYE6QQI9eXAng/C7UzuVw7d0fwlzrth6RmrdhlyT1K+BA3WpExapV+pDfwxu/tkEik8Ps5cZRV6HjX1/Mww==",
"requires": {
"@types/react": "*",
"@visx/group": "2.10.0",
"@visx/scale": "2.2.2",
"classnames": "^2.3.1",
"prop-types": "^15.5.10"
}
},
"@visx/mock-data": { "@visx/mock-data": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@visx/mock-data/-/mock-data-2.1.2.tgz",

View File

@ -19,6 +19,7 @@
"@visx/event": "^2.6.0", "@visx/event": "^2.6.0",
"@visx/grid": "^2.10.0", "@visx/grid": "^2.10.0",
"@visx/group": "^2.10.0", "@visx/group": "^2.10.0",
"@visx/legend": "^2.10.0",
"@visx/mock-data": "^2.1.2", "@visx/mock-data": "^2.1.2",
"@visx/scale": "^2.2.2", "@visx/scale": "^2.2.2",
"@visx/shape": "^2.10.0", "@visx/shape": "^2.10.0",

View File

@ -3,6 +3,8 @@ import { BoxPlot } from "components/Boxplot";
import { import {
mockCategoricalData, mockCategoricalData,
moreMockCategoricalData, moreMockCategoricalData,
mockStackedBarKeys,
mockStackedBarGraphData,
mockBoxPlotData, mockBoxPlotData,
mockQuoteData, mockQuoteData,
mockQuoteDataLong, mockQuoteDataLong,
@ -11,11 +13,16 @@ import {
} from "data/mocks"; } from "data/mocks";
import { sectionsData } from "data/routes"; import { sectionsData } from "data/routes";
import React from "react"; import React from "react";
import { Color } from "utils/Color";
import { About } from "@/components/About"; import { About } from "@/components/About";
import { PieChart } from "@/components/PieChart"; import { PieChart } from "@/components/PieChart";
import { QuotationCarousel } from "@/components/QuotationCarousel"; import { QuotationCarousel } from "@/components/QuotationCarousel";
import { Sections } from "@/components/Sections"; import { Sections } from "@/components/Sections";
import {
StackedBarGraphVertical,
StackedBarGraphHorizontal,
} from "@/components/StackedBarGraph";
import { Timeline } from "@/components/Timeline"; import { Timeline } from "@/components/Timeline";
import { CenterWrapper } from "../components/CenterWrapper"; import { CenterWrapper } from "../components/CenterWrapper";
@ -26,7 +33,7 @@ import styles from "./playground.module.css";
export default function Home() { export default function Home() {
return ( return (
<div className={styles.page}> <div className={styles.page} suppressHydrationWarning>
<h1>Playground</h1> <h1>Playground</h1>
<p>Show off your components here!</p> <p>Show off your components here!</p>
@ -95,6 +102,48 @@ export default function Home() {
}))} }))}
/> />
<h2>
<code>{"<StackedBarGraphVertical />"}</code>
</h2>
<StackedBarGraphVertical
width={600}
height={400}
keys={mockStackedBarKeys}
colorRange={[
Color.primaryAccent,
Color.secondaryAccentLight,
Color.primaryAccentLighter,
]}
data={mockStackedBarGraphData}
margin={{
top: 20,
left: 20,
}}
/>
<h2>
<code>{"<StackedBarGraphHorizontal />"}</code>
</h2>
<p>
<code>{"<StackedBarGraphHorizontal />"}</code> takes the same props as{" "}
<code>{"<StackedBarGraphVertical />"}</code>.
</p>
<StackedBarGraphHorizontal
width={600}
height={400}
keys={mockStackedBarKeys}
colorRange={[
Color.primaryAccent,
Color.secondaryAccentLight,
Color.primaryAccentLighter,
]}
data={mockStackedBarGraphData}
margin={{
top: 20,
left: 20,
}}
/>
<h2> <h2>
<code>{"<Timeline />"}</code> <code>{"<Timeline />"}</code>
</h2> </h2>