|
|
|
@ -7,7 +7,9 @@ import { Point } from "@visx/point"; |
|
|
|
|
import { scaleBand, scaleLinear, scaleOrdinal } from "@visx/scale"; |
|
|
|
|
import { BarStack, Line } from "@visx/shape"; |
|
|
|
|
import { SeriesPoint } from "@visx/shape/lib/types"; |
|
|
|
|
import { useTooltip, useTooltipInPortal, defaultStyles } from "@visx/tooltip"; |
|
|
|
|
//import { useTooltip, useTooltipInPortal, withTooltip } from "@visx/tooltip";
|
|
|
|
|
import { withTooltip, Tooltip } from "@visx/tooltip"; |
|
|
|
|
import { WithTooltipProvidedProps } from "@visx/tooltip/lib/enhancers/withTooltip"; |
|
|
|
|
import React from "react"; |
|
|
|
|
import { Color } from "utils/Color"; |
|
|
|
|
|
|
|
|
@ -45,6 +47,8 @@ export type StackedBarProps = { |
|
|
|
|
numTicksLeftAxis?: number; |
|
|
|
|
/** Distance between the left axis labels and the start of the lines of the graph, in px. */ |
|
|
|
|
valueAxisLeftOffset?: number; |
|
|
|
|
/** Distance between the bottom axis and the bottom of the container of the graph, in px. */ |
|
|
|
|
categoryAxisBottomOffset?: 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. */ |
|
|
|
@ -57,204 +61,201 @@ export type StackedBarProps = { |
|
|
|
|
scalePadding?: number; |
|
|
|
|
/** Margin for each item in the legend */ |
|
|
|
|
itemMargin?: string; |
|
|
|
|
/** Factor multiplied to the left offset to center the labels in the x-axis. >1 for width <600 and <1 for width >600 */ |
|
|
|
|
categoryAxisLeftFactor?: number; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
let tooltipTimeout: number; |
|
|
|
|
|
|
|
|
|
export function StackedBarGraph({ |
|
|
|
|
data, |
|
|
|
|
width, |
|
|
|
|
height, |
|
|
|
|
keys, |
|
|
|
|
colorRange, |
|
|
|
|
margin, |
|
|
|
|
scalePadding = 0.3, |
|
|
|
|
numTicksLeftAxis = 6, |
|
|
|
|
valueAxisLeftOffset = 40, |
|
|
|
|
strokeWidth = 2.5, |
|
|
|
|
strokeDashArray = "10,4", |
|
|
|
|
legendLeftOffset = 40, |
|
|
|
|
legendTopOffset = 40, |
|
|
|
|
itemMargin = "15px 0 0 0", |
|
|
|
|
}: StackedBarProps) { |
|
|
|
|
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 xScale = scaleBand<string>({ |
|
|
|
|
domain: data.map(getCategory), |
|
|
|
|
padding: scalePadding, |
|
|
|
|
}); |
|
|
|
|
const yScale = scaleLinear<number>({ |
|
|
|
|
domain: [0, Math.max(...yTotals)], |
|
|
|
|
nice: true, |
|
|
|
|
}); |
|
|
|
|
const colorScale = scaleOrdinal<string, string>({ |
|
|
|
|
domain: keys, |
|
|
|
|
range: colorRange, |
|
|
|
|
}); |
|
|
|
|
const { |
|
|
|
|
export const StackedBarGraph = withTooltip<StackedBarProps, TooltipData>( |
|
|
|
|
({ |
|
|
|
|
data, |
|
|
|
|
width, |
|
|
|
|
height, |
|
|
|
|
keys, |
|
|
|
|
colorRange, |
|
|
|
|
margin, |
|
|
|
|
scalePadding = 0.3, |
|
|
|
|
numTicksLeftAxis = 6, |
|
|
|
|
valueAxisLeftOffset = 40, |
|
|
|
|
categoryAxisBottomOffset = 40, |
|
|
|
|
strokeWidth = 2.5, |
|
|
|
|
strokeDashArray = "10,4", |
|
|
|
|
legendLeftOffset = 40, |
|
|
|
|
legendTopOffset = 40, |
|
|
|
|
itemMargin = "15px 0 0 0", |
|
|
|
|
categoryAxisLeftFactor = 1, |
|
|
|
|
tooltipOpen, |
|
|
|
|
tooltipLeft, |
|
|
|
|
tooltipTop, |
|
|
|
|
tooltipData, |
|
|
|
|
hideTooltip, |
|
|
|
|
showTooltip, |
|
|
|
|
} = useTooltip<TooltipData>(); |
|
|
|
|
}: 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 { containerRef, TooltipInPortal } = useTooltipInPortal({ |
|
|
|
|
// TooltipInPortal is rendered in a separate child of <body /> and positioned
|
|
|
|
|
// with page coordinates which should be updated on scroll.
|
|
|
|
|
scroll: true, |
|
|
|
|
}); |
|
|
|
|
const TICK_LABEL_FONT_WEIGHT = 800; |
|
|
|
|
|
|
|
|
|
if (width < 10) return null; |
|
|
|
|
// bounds
|
|
|
|
|
const xMax = width; |
|
|
|
|
const yMax = height - margin.top - 50; |
|
|
|
|
// accessors
|
|
|
|
|
const getCategory = (d: StackedBarData) => d.category; |
|
|
|
|
|
|
|
|
|
xScale.rangeRound([0, xMax - valueAxisLeftOffset]); |
|
|
|
|
yScale.range([yMax, 0]); |
|
|
|
|
// scales
|
|
|
|
|
const xScale = scaleBand<string>({ |
|
|
|
|
domain: data.map(getCategory), |
|
|
|
|
padding: scalePadding, |
|
|
|
|
}); |
|
|
|
|
const yScale = scaleLinear<number>({ |
|
|
|
|
domain: [0, Math.max(...yTotals)], |
|
|
|
|
nice: true, |
|
|
|
|
}); |
|
|
|
|
const colorScale = scaleOrdinal<string, string>({ |
|
|
|
|
domain: keys, |
|
|
|
|
range: colorRange, |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
return width < 10 ? null : ( |
|
|
|
|
<div className={styles.container}> |
|
|
|
|
<svg ref={containerRef} width={width} height={height}> |
|
|
|
|
<Group top={margin.top} left={margin.left}> |
|
|
|
|
<GridRows |
|
|
|
|
scale={yScale} |
|
|
|
|
width={xMax} |
|
|
|
|
height={yMax} |
|
|
|
|
left={valueAxisLeftOffset} |
|
|
|
|
numTicks={numTicksLeftAxis} |
|
|
|
|
stroke={Color.tertiaryBackground} |
|
|
|
|
strokeWidth={strokeWidth} |
|
|
|
|
strokeDasharray={strokeDashArray} |
|
|
|
|
/> |
|
|
|
|
<GridColumns |
|
|
|
|
scale={xScale} |
|
|
|
|
height={yMax} |
|
|
|
|
left={xScale.bandwidth() / 2} |
|
|
|
|
offset={xScale.bandwidth() / 2} |
|
|
|
|
stroke={Color.tertiaryBackground} |
|
|
|
|
strokeWidth={strokeWidth} |
|
|
|
|
strokeDasharray={strokeDashArray} |
|
|
|
|
/> |
|
|
|
|
<Group left={valueAxisLeftOffset}> |
|
|
|
|
<BarStack<StackedBarData, string> |
|
|
|
|
data={data} |
|
|
|
|
keys={keys} |
|
|
|
|
x={getCategory} |
|
|
|
|
xScale={xScale} |
|
|
|
|
yScale={yScale} |
|
|
|
|
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); |
|
|
|
|
// TooltipInPortal expects coordinates to be relative to containerRef
|
|
|
|
|
// localPoint returns coordinates relative to the nearest SVG
|
|
|
|
|
const eventSvgCoords = localPoint(event); |
|
|
|
|
const left = bar.x + bar.width / 2; |
|
|
|
|
showTooltip({ |
|
|
|
|
tooltipData: bar, |
|
|
|
|
tooltipTop: eventSvgCoords?.y, |
|
|
|
|
tooltipLeft: left, |
|
|
|
|
}); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
)) |
|
|
|
|
) |
|
|
|
|
// bounds
|
|
|
|
|
const xMax = width; |
|
|
|
|
const yMax = height - margin.top - categoryAxisBottomOffset; |
|
|
|
|
|
|
|
|
|
xScale.rangeRound([0, xMax - valueAxisLeftOffset]); |
|
|
|
|
yScale.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={yScale} |
|
|
|
|
width={xMax} |
|
|
|
|
height={yMax} |
|
|
|
|
left={valueAxisLeftOffset} |
|
|
|
|
numTicks={numTicksLeftAxis} |
|
|
|
|
stroke={Color.tertiaryBackground} |
|
|
|
|
strokeWidth={strokeWidth} |
|
|
|
|
strokeDasharray={strokeDashArray} |
|
|
|
|
/> |
|
|
|
|
<GridColumns |
|
|
|
|
scale={xScale} |
|
|
|
|
height={yMax} |
|
|
|
|
left={valueAxisLeftOffset} |
|
|
|
|
offset={xScale.bandwidth() / 2} |
|
|
|
|
stroke={Color.tertiaryBackground} |
|
|
|
|
strokeWidth={strokeWidth} |
|
|
|
|
strokeDasharray={strokeDashArray} |
|
|
|
|
/> |
|
|
|
|
<Group left={valueAxisLeftOffset}> |
|
|
|
|
<BarStack<StackedBarData, string> |
|
|
|
|
data={data} |
|
|
|
|
keys={keys} |
|
|
|
|
x={getCategory} |
|
|
|
|
xScale={xScale} |
|
|
|
|
yScale={yScale} |
|
|
|
|
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: valueAxisLeftOffset, y: 0 })} |
|
|
|
|
from={new Point({ x: valueAxisLeftOffset, y: yMax })} |
|
|
|
|
stroke={Color.tertiaryBackground} |
|
|
|
|
strokeWidth={strokeWidth} |
|
|
|
|
strokeDasharray={strokeDashArray} |
|
|
|
|
/> |
|
|
|
|
<AxisBottom |
|
|
|
|
top={yMax} |
|
|
|
|
scale={xScale} |
|
|
|
|
left={ |
|
|
|
|
((xScale.bandwidth() * 100) / width) * categoryAxisLeftFactor |
|
|
|
|
} |
|
|
|
|
</BarStack> |
|
|
|
|
</Group> |
|
|
|
|
<Line |
|
|
|
|
fill={Color.tertiaryBackground} |
|
|
|
|
to={new Point({ x: valueAxisLeftOffset, y: 0 })} |
|
|
|
|
from={new Point({ x: valueAxisLeftOffset, y: yMax })} |
|
|
|
|
stroke={Color.tertiaryBackground} |
|
|
|
|
strokeWidth={strokeWidth} |
|
|
|
|
strokeDasharray={strokeDashArray} |
|
|
|
|
/> |
|
|
|
|
<AxisBottom |
|
|
|
|
top={yMax} |
|
|
|
|
scale={xScale} |
|
|
|
|
left={xScale.bandwidth() / 6} |
|
|
|
|
hideTicks |
|
|
|
|
hideAxisLine |
|
|
|
|
labelProps={{ |
|
|
|
|
fontSize: `${10 / 16}rem`, |
|
|
|
|
}} |
|
|
|
|
tickLabelProps={() => ({ |
|
|
|
|
fill: Color.label, |
|
|
|
|
fontWeight: TICK_LABEL_FONT_WEIGHT, |
|
|
|
|
})} |
|
|
|
|
/> |
|
|
|
|
<AxisLeft |
|
|
|
|
scale={yScale} |
|
|
|
|
top={5} |
|
|
|
|
numTicks={numTicksLeftAxis} |
|
|
|
|
hideAxisLine |
|
|
|
|
labelProps={{ |
|
|
|
|
fontSize: `${10 / 16}rem`, |
|
|
|
|
}} |
|
|
|
|
tickLabelProps={() => { |
|
|
|
|
return { |
|
|
|
|
hideTicks |
|
|
|
|
hideAxisLine |
|
|
|
|
labelProps={{ |
|
|
|
|
fontSize: `${10 / 16}rem`, |
|
|
|
|
}} |
|
|
|
|
tickLabelProps={() => ({ |
|
|
|
|
fill: Color.label, |
|
|
|
|
fontWeight: TICK_LABEL_FONT_WEIGHT, |
|
|
|
|
}; |
|
|
|
|
}} |
|
|
|
|
})} |
|
|
|
|
/> |
|
|
|
|
<AxisLeft |
|
|
|
|
scale={yScale} |
|
|
|
|
top={5} |
|
|
|
|
numTicks={numTicksLeftAxis} |
|
|
|
|
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} |
|
|
|
|
/> |
|
|
|
|
</Group> |
|
|
|
|
</svg> |
|
|
|
|
<div |
|
|
|
|
className={styles.legend} |
|
|
|
|
style={{ left: width + legendLeftOffset, top: legendTopOffset }} |
|
|
|
|
> |
|
|
|
|
<LegendOrdinal |
|
|
|
|
scale={colorScale} |
|
|
|
|
direction="column" |
|
|
|
|
itemMargin={itemMargin} |
|
|
|
|
/> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
{tooltipOpen && tooltipData ? ( |
|
|
|
|
<TooltipInPortal |
|
|
|
|
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> |
|
|
|
|
</TooltipInPortal> |
|
|
|
|
) : null} |
|
|
|
|
</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> |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
); |
|
|
|
|