import { AxisBottom, AxisLeft } from "@visx/axis"; import { bottomTickLabelProps } from "@visx/axis/lib/axis/AxisBottom"; import { leftTickLabelProps } from "@visx/axis/lib/axis/AxisLeft"; import { GridColumns, GridRows } from "@visx/grid"; import { Group } from "@visx/group"; import { LegendOrdinal } from "@visx/legend"; import { scaleBand, scaleLinear, scaleOrdinal } from "@visx/scale"; import { Bar, BarGroup, BarGroupHorizontal } from "@visx/shape"; import { BarGroupBar as BarGroupBarType } from "@visx/shape/lib/types"; import { Text } from "@visx/text"; import React, { useState } from "react"; import { Color } from "utils/Color"; import styles from "./GroupedBarGraph.module.css"; interface GroupedBarGraphProps { data: GroupedBarGraphData[]; /** Colours of bars in each group. */ barColors: string[]; /** Object mapping from the possible colours of bars in each group (barColors) to the colour of the bar when hovered. */ barHoverColorsMap: Record; /** Width of the entire graph, in pixels. */ width: number; /** Height of the entire graph, in pixels. */ height: number; /** Distance between the edge of the graph and the area where the bars are drawn, in pixels. */ margin: { top: number; bottom: number; left: number; right: number; }; className?: string; /** Font size of the category axis tick labels, in pixels. Default is 16px. */ categoryTickLabelSize?: number; /** Font size of the value axis tick labels, in pixels. Default is 16px. */ valueTickLabelSize?: number; /** Font size of the value that appears when hovering over a bar, in pixels. */ hoverLabelSize?: number; /** Label text for the category axis. */ categoryAxisLabel?: string; /** Font size of the label for the cateogry axis, in pixels. */ categoryAxisLabelSize?: number; /** Controls the distance between the category axis label and the category axis. */ categoryAxisLabelOffset?: number; /** Label text for the value axis. */ valueAxisLabel?: string; /** Font size of the label for the value axis, in pixels. */ valueAxisLabelSize?: number; /** Controls the distance between the value axis label and the value axis. */ valueAxisLabelOffset?: number; legendProps?: LegendProps; } // Best format for props interface GroupedBarGraphData { category: string; values: { [key: string]: number; }; } // Best format for visx interface BarGroupData { category: string; [key: string]: string | number; } interface LegendProps { /** Position of the legend, relative to the graph. */ position?: "top" | "right"; /** Font size of the labels in the legend, in pixels. Default is 16px. */ itemLabelSize?: number; /** Gap between items in the legend, in pixels. */ itemGap?: number; /** Distance between the legend and other adjacent elements, in pixels. */ margin?: { top?: number; bottom?: number; left?: number; right?: number; }; } // BAR_PADDING must be in the range [0, 1) const BAR_PADDING = 0.2; const BAR_TEXT_PADDING = 12; const DEFAULT_LABEL_SIZE = 16; const DEFAULT_LEGEND_GAP = 16; export function GroupedBarGraphVertical(props: GroupedBarGraphProps) { const { data: propsData, barColors, barHoverColorsMap, width, height, margin, className, categoryTickLabelSize = DEFAULT_LABEL_SIZE, valueTickLabelSize = DEFAULT_LABEL_SIZE, hoverLabelSize, categoryAxisLabel, categoryAxisLabelSize = DEFAULT_LABEL_SIZE, categoryAxisLabelOffset = 0, valueAxisLabel, valueAxisLabelSize = DEFAULT_LABEL_SIZE, valueAxisLabelOffset = 0, legendProps, } = props; const { position: legendPosition = "right", itemLabelSize: legendLabelSize = DEFAULT_LABEL_SIZE, itemGap: legendItemGap = DEFAULT_LEGEND_GAP, margin: legendMargin = {}, } = legendProps ?? {}; const data: BarGroupData[] = propsData.map((datum: GroupedBarGraphData) => { return { category: datum.category, ...datum.values }; }); const keys = Object.keys(propsData[0].values); propsData.forEach((d: GroupedBarGraphData) => { const currentKeys = Object.keys(d.values); if ( keys.length != currentKeys.length || !keys.every((key: string) => currentKeys.includes(key)) ) { throw new Error( "Every category in a GroupedBarGraph must have the same keys. Check the data prop" ); } }); const allValues = propsData .map((d: GroupedBarGraphData) => Object.values(d.values)) .flat(); const categoryMax = width - margin.left - margin.right; const valueMax = height - margin.top - margin.bottom; const getCategory = (d: BarGroupData) => d.category; const categoryScale = scaleBand({ domain: data.map(getCategory), padding: BAR_PADDING, }); const keyScale = scaleBand({ domain: keys, }); const valueScale = scaleLinear({ domain: [0, Math.max(...allValues)], }); const colorScale = scaleOrdinal({ domain: keys, range: barColors, }); categoryScale.rangeRound([0, categoryMax]); keyScale.rangeRound([0, categoryScale.bandwidth()]); valueScale.rangeRound([valueMax, 0]); return (
{Object.keys(barHoverColorsMap).map((color: string) => { // remove brackets from colour name to make ids work const colorId = removeBrackets(color); return ( ); })} {data.map((d, idx) => { const barName = `${getCategory(d)}-${idx}`; const barWidth = categoryScale.bandwidth(); const backgroundBarWidth = barWidth / (1 - BAR_PADDING); return idx % 2 === 0 ? ( ) : null; })} {(barGroups) => barGroups.map((barGroup) => ( {barGroup.bars.map((bar) => ( ))} )) } { return { ...bottomTickLabelProps(), className: styles.tickLabel, dy: "-0.25rem", fontSize: `${categoryTickLabelSize / 16}rem`, width: categoryScale.bandwidth(), verticalAnchor: "start", }; }} label={categoryAxisLabel} labelClassName={styles.axisLabel} labelOffset={categoryAxisLabelOffset} labelProps={{ fontSize: `${categoryAxisLabelSize / 16}rem`, }} /> { return { ...leftTickLabelProps(), className: styles.tickLabel, dx: "-0.5rem", dy: "0.25rem", fontSize: `${valueTickLabelSize / 16}rem`, }; }} label={valueAxisLabel} labelClassName={styles.axisLabel} labelOffset={valueAxisLabelOffset} labelProps={{ fontSize: `${valueAxisLabelSize / 16}rem`, }} />
); } export function GroupedBarGraphHorizontal(props: GroupedBarGraphProps) { const { data: propsData, barColors, barHoverColorsMap, width, height, margin, className, categoryTickLabelSize = DEFAULT_LABEL_SIZE, valueTickLabelSize = DEFAULT_LABEL_SIZE, hoverLabelSize, categoryAxisLabel, categoryAxisLabelSize = DEFAULT_LABEL_SIZE, categoryAxisLabelOffset = 0, valueAxisLabel, valueAxisLabelSize = DEFAULT_LABEL_SIZE, valueAxisLabelOffset = 0, legendProps, } = props; const { position: legendPosition = "top", itemLabelSize: legendLabelSize = DEFAULT_LABEL_SIZE, itemGap: legendItemGap = DEFAULT_LEGEND_GAP, margin: legendMargin = {}, } = legendProps ?? {}; const data: BarGroupData[] = propsData.map((datum: GroupedBarGraphData) => { return { category: datum.category, ...datum.values }; }); const keys = Object.keys(propsData[0].values); propsData.forEach((d: GroupedBarGraphData) => { const currentKeys = Object.keys(d.values); if ( keys.length != currentKeys.length || !keys.every((key: string) => currentKeys.includes(key)) ) { throw new Error( "Every category in a GroupedBarGraph must have the same keys. Check the data prop" ); } }); const allValues = propsData .map((d: GroupedBarGraphData) => Object.values(d.values)) .flat(); const categoryMax = height - margin.top - margin.bottom; const valueMax = width - margin.left - margin.right; const getCategory = (d: BarGroupData) => d.category; const categoryScale = scaleBand({ domain: data.map(getCategory), padding: BAR_PADDING, }); const keyScale = scaleBand({ domain: keys, }); const valueScale = scaleLinear({ domain: [Math.max(...allValues), 0], }); const colorScale = scaleOrdinal({ domain: keys, range: barColors, }); categoryScale.rangeRound([0, categoryMax]); keyScale.rangeRound([0, categoryScale.bandwidth()]); valueScale.rangeRound([valueMax, 0]); return (
{Object.keys(barHoverColorsMap).map((color: string) => { // remove brackets from colour name to make ids work const colorId = removeBrackets(color); return ( ); })} {data.map((d, idx) => { const barName = `${getCategory(d)}-${idx}`; const barWidth = categoryScale.bandwidth(); const backgroundBarWidth = barWidth / (1 - BAR_PADDING); return idx % 2 === 0 ? ( ) : null; })} {(barGroups) => barGroups.map((barGroup) => ( {barGroup.bars.map((bar) => ( ))} )) } { return { ...leftTickLabelProps(), className: styles.tickLabel, dx: "-0.5rem", dy: "0.25rem", fontSize: `${valueTickLabelSize / 16}rem`, height: categoryScale.bandwidth(), }; }} label={categoryAxisLabel} labelClassName={styles.axisLabel} labelOffset={categoryAxisLabelOffset} labelProps={{ fontSize: `${categoryAxisLabelSize / 16}rem`, }} /> { return { ...bottomTickLabelProps(), className: styles.tickLabel, dy: "-0.25rem", fontSize: `${categoryTickLabelSize / 16}rem`, verticalAnchor: "start", }; }} label={valueAxisLabel} labelClassName={styles.axisLabel} labelOffset={valueAxisLabelOffset} labelProps={{ fontSize: `${valueAxisLabelSize / 16}rem`, }} />
); } interface HoverableBarProps { bar: BarGroupBarType; valueMax: number; hoverFillColor?: string; hoverLabelSize?: number; isHorizontal?: boolean; } function HoverableBar(props: HoverableBarProps) { const { bar, valueMax, hoverLabelSize, hoverFillColor, isHorizontal = false, } = props; const [isHovered, setIsHovered] = useState(false); const colorId = removeBrackets(bar.color); return ( { setIsHovered(true); }} onMouseLeave={() => { setIsHovered(false); }} > {bar.value} ); } function removeBrackets(str: string) { return str.replace(/\(|\)/g, ""); }