import { AxisBottom, AxisLeft } from "@visx/axis"; import { bottomTickLabelProps } from "@visx/axis/lib/axis/AxisBottom"; import { leftTickLabelProps } from "@visx/axis/lib/axis/AxisLeft"; import { localPoint } from "@visx/event"; import { GridColumns, GridRows } 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 { Bar, BarGroup, BarGroupHorizontal } from "@visx/shape"; import { BarGroupBar as BarGroupBarType } from "@visx/shape/lib/types"; import { withTooltip } from "@visx/tooltip"; import React, { useState } from "react"; import { Color } from "utils/Color"; import { getTooltipPosition, TooltipWrapper } from "./TooltipWrapper"; 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; /** Margin for each item in the legend */ itemMargin?: string; /** Minimum width of the graph. */ minWidth?: number; /** Breakpoint width of graph where alernating labels are displayed. Only for Vertical graphs */ widthAlternatingLabel?: number; /** Space added to the bottom of the graph to show overflowing labels. Only for Vertical graphs */ alternatingLabelSpace?: number; /** Default position of labels in x-axis, in px. */ defaultLabelDy?: string; /** Position of lower labels in x-axis, in px. Only for Vertical graphs */ lowerLabelDy?: string; } // Best format for props interface GroupedBarGraphData { category: string; values: { [key: string]: number; }; } // Best format for visx interface BarGroupData { category: string; [key: string]: string | number; } // BAR_PADDING must be in the range [0, 1) const BAR_PADDING = 0.2; const DEFAULT_LABEL_SIZE = 16; type TooltipData = string; export const GroupedBarGraphVertical = withTooltip< GroupedBarGraphProps, TooltipData >( ({ data: propsData, barColors, barHoverColorsMap, width, height, margin, className, minWidth = 500, categoryTickLabelSize = DEFAULT_LABEL_SIZE, valueTickLabelSize = DEFAULT_LABEL_SIZE, hoverLabelSize, categoryAxisLabel, categoryAxisLabelSize = DEFAULT_LABEL_SIZE, categoryAxisLabelOffset = 0, valueAxisLabel, valueAxisLabelSize = DEFAULT_LABEL_SIZE, valueAxisLabelOffset = 0, itemMargin = "0 0 0 15px", widthAlternatingLabel = 600, alternatingLabelSpace = 80, defaultLabelDy = `0px`, lowerLabelDy = `30px`, tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip, }) => { width = width < minWidth ? minWidth : width; // Ensuring graph's width >= minWidth const alternatingLabel = width <= widthAlternatingLabel; const final_margin_bottom = alternatingLabel ? margin.bottom + alternatingLabelSpace : margin.bottom; 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 - final_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) => ( { const tooltipPos = getTooltipPosition(e); showTooltip({ tooltipData: bar.value.toString(), tooltipTop: tooltipPos.y, tooltipLeft: tooltipPos.x, }); }} onMouseOut={hideTooltip} key={`bar-group-bar-${barGroup.x0}-${barGroup.index}-${bar.key}-${bar.index}`} bar={bar} valueMax={valueMax} hoverFillColor={barHoverColorsMap[bar.color]} hoverLabelSize={hoverLabelSize} /> ))} )) } { const alternatingDy = index % 2 == 0 ? defaultLabelDy : lowerLabelDy; return { ...bottomTickLabelProps(), className: styles.tickLabel, dy: alternatingLabel ? alternatingDy : defaultLabelDy, 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`, }} /> {tooltipOpen && ( )}
); } ); export const GroupedBarGraphHorizontal = withTooltip< GroupedBarGraphProps, TooltipData >( ({ data: propsData, barColors, barHoverColorsMap, width, height, margin, className, minWidth = 600, categoryTickLabelSize = DEFAULT_LABEL_SIZE, valueTickLabelSize = DEFAULT_LABEL_SIZE, hoverLabelSize, categoryAxisLabel, categoryAxisLabelSize = DEFAULT_LABEL_SIZE, categoryAxisLabelOffset = 0, valueAxisLabel, valueAxisLabelSize = DEFAULT_LABEL_SIZE, valueAxisLabelOffset = 0, itemMargin = "0 0 0 15px", defaultLabelDy = "0", tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip, }) => { width = width < minWidth ? minWidth : width; // Ensuring graph's width >= minWidth 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) => ( { const eventSvgCoords = localPoint( // ownerSVGElement is given by visx docs but not recognized by typescript // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore e.target.ownerSVGElement as Element, e ) as Point; showTooltip({ tooltipData: bar.value.toString(), tooltipTop: eventSvgCoords.y, tooltipLeft: eventSvgCoords.x, }); }} onMouseOut={hideTooltip} key={`bar-group-bar-${barGroup.y0}-${barGroup.index}-${bar.key}-${bar.index}`} bar={bar} valueMax={valueMax} hoverFillColor={barHoverColorsMap[bar.color]} hoverLabelSize={hoverLabelSize} isHorizontal /> ))} )) } { return { ...leftTickLabelProps(), className: styles.tickLabel, dx: "-0.5rem", dy: defaultLabelDy, 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`, }} /> {tooltipOpen && ( )}
); } ); interface HoverableBarProps { bar: BarGroupBarType; valueMax: number; hoverFillColor?: string; hoverLabelSize?: number; isHorizontal?: boolean; onMouseMove?: (e: React.MouseEvent) => void; onMouseOut?: () => void; } function HoverableBar(props: HoverableBarProps) { const { bar, hoverFillColor, onMouseMove, onMouseOut } = props; const [isHovered, setIsHovered] = useState(false); const colorId = removeBrackets(bar.color); return ( { setIsHovered(true); }} onMouseLeave={() => { setIsHovered(false); }} > ); } function removeBrackets(str: string) { return str.replace(/\(|\)/g, ""); }