Add Stacked Bar Graph component (Closes #3) #37
Merged
e26chiu
merged 14 commits from stackedbar-component
into main
4 months ago
@ -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; |
||||
} |
@ -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; |
||||
|
||||
[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]); |
||||
a258wang
commented 5 months ago
Review
Please change the range to |
||||
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> |
||||
); |
||||
} |
||||
); |
Loading…
Reference in new issue
Have we considered
?
This would allow for a key called "category", at the expense of more nesting.
Also is there a reason why the value can beEDIT: nvm, it's to make TS stop complainingnumber | string
and not justnumber
?https://stackoverflow.com/questions/54460029/typescript-inteface-where-id-must-be-number-and-other-properties-must-be-strin
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:It specifically needs
category
to be a key indata
and all the other keys to be directly nested inside ofdata
(cannot be nested inside ofvalues
).