From 4458dcfa1fee8eed27a7f5e15d454c5bc6ad106f Mon Sep 17 00:00:00 2001 From: Amy Wang Date: Sun, 2 Oct 2022 13:11:42 -0400 Subject: [PATCH] Add Grouped Bar Graph (#65) Closes #2 Notes: - Named the component the GroupedBarGraph since it can support bar groups of > 2 bars. - The horizontal graph is mostly a copy of the vertical graph, with slight modifications. This ended up being the ~~laziest~~ easiest way to implement it. Reviewed-on: https://git.csclub.uwaterloo.ca/www/cs-2022-class-profile/pulls/65 Reviewed-by: Shahan Nedadahandeh --- components/BarGraph.tsx | 4 +- components/GroupedBarGraph.module.css | 38 ++ components/GroupedBarGraph.tsx | 621 ++++++++++++++++++++++++++ data/mocks.ts | 24 + package-lock.json | 1 + pages/playground.tsx | 51 +++ 6 files changed, 737 insertions(+), 2 deletions(-) create mode 100644 components/GroupedBarGraph.module.css create mode 100644 components/GroupedBarGraph.tsx diff --git a/components/BarGraph.tsx b/components/BarGraph.tsx index 222770d..b4b0a03 100644 --- a/components/BarGraph.tsx +++ b/components/BarGraph.tsx @@ -25,9 +25,9 @@ interface BarGraphProps { right: number; }; className?: string; - /** Font size of the category tick labels, in pixels. Default is 16px. */ + /** Font size of the category axis tick labels, in pixels. Default is 16px. */ categoryTickLabelSize?: number; - /** Font size of the value tick labels, in pixels. Default is 16px. */ + /** 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; diff --git a/components/GroupedBarGraph.module.css b/components/GroupedBarGraph.module.css new file mode 100644 index 0000000..96ab995 --- /dev/null +++ b/components/GroupedBarGraph.module.css @@ -0,0 +1,38 @@ +.wrapper { + display: flex; + align-items: center; + width: min-content; +} + +.barBackground { + fill: var(--card-background); +} + +.barText { + visibility: hidden; + + font-family: "Inconsolata", monospace; + font-weight: 800; + fill: var(--label); +} + +.singleBar:hover .barText { + visibility: visible; +} + +.tickLabel { + font-family: "Inconsolata", monospace; + font-weight: 800; + fill: var(--label); +} + +.axisLabel { + font-family: "Inconsolata", monospace; + font-weight: 800; + fill: var(--label); +} + +.legend { + display: flex; + margin: calc(16rem / 16); +} diff --git a/components/GroupedBarGraph.tsx b/components/GroupedBarGraph.tsx new file mode 100644 index 0000000..28ef034 --- /dev/null +++ b/components/GroupedBarGraph.tsx @@ -0,0 +1,621 @@ +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, ""); +} diff --git a/data/mocks.ts b/data/mocks.ts index f1eac77..55403cb 100644 --- a/data/mocks.ts +++ b/data/mocks.ts @@ -187,3 +187,27 @@ export const mockQuoteDataLong = [ "Hello, world!", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla in enim neque. Sed sit amet convallis tellus. Integer condimentum a felis id gravida. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nullam metus libero, sagittis in consectetur in, scelerisque sed sapien. Nullam ut feugiat sapien. Praesent dictum ac ipsum ac lacinia.", ]; + +export const mockGroupedBarGraphData = [ + { + category: "AJ", + values: { + Shooting: 7, + Melee: 9, + }, + }, + { + category: "Zen", + values: { + Shooting: 17, + Melee: 5, + }, + }, + { + category: "Lyra", + values: { + Shooting: 3, + Melee: 14, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 9a82f92..bd291af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "cs-2022-class-profile", "version": "0.1.0", "dependencies": { "@visx/axis": "^2.10.0", diff --git a/pages/playground.tsx b/pages/playground.tsx index 65443c4..15094d1 100644 --- a/pages/playground.tsx +++ b/pages/playground.tsx @@ -10,12 +10,17 @@ import { mockQuoteDataLong, mockPieData, mockTimelineData, + mockGroupedBarGraphData, } from "data/mocks"; import { sectionsData } from "data/routes"; import React from "react"; import { Color } from "utils/Color"; import { About } from "@/components/About"; +import { + GroupedBarGraphHorizontal, + GroupedBarGraphVertical, +} from "@/components/GroupedBarGraph"; import { PieChart } from "@/components/PieChart"; import { QuotationCarousel } from "@/components/QuotationCarousel"; import { Sections } from "@/components/Sections"; @@ -216,6 +221,52 @@ export default function Home() { circleDiameter={180} /> + +

+ {""} +

+ + +

+ {""} +

+

+ {""} takes the same props as{" "} + {""}. +

+ ); }