cs-2022-class-profile/components/BarGraph.tsx

373 lines
11 KiB
TypeScript

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 { scaleBand, scaleLinear } from "@visx/scale";
import { Bar } from "@visx/shape";
import { Text } from "@visx/text";
import React, { useState } from "react";
interface BarGraphProps {
data: BarGraphData[];
/** 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 labels, in pixels. Default is 16px. */
categoryLabelSize?: number;
/** Font size of the value labels, in pixels. Default is 16px. */
valueLabelSize?: number;
/** Font size of the value that appears when hovering over a bar, in pixels. */
hoverLabelSize?: number;
}
interface BarGraphData {
category: string;
value: number;
}
const COLOURS = {
salmon: "#ffcad0",
navy: "#2c3651",
white: "#ffffff",
pink: "#ef83b1",
darkpink: "#cc5773",
};
const DEFAULT_LABEL_SIZE = 16;
export function BarGraphHorizontal(props: BarGraphProps) {
const {
width,
height,
margin,
data,
className,
categoryLabelSize = DEFAULT_LABEL_SIZE,
valueLabelSize = DEFAULT_LABEL_SIZE,
hoverLabelSize,
} = props;
const barPadding = 0.4;
const categoryMax = height - margin.top - margin.bottom;
const valueMax = width - margin.left - margin.right;
const getCategory = (d: BarGraphData) => d.category;
const getValue = (d: BarGraphData) => d.value;
const [isBarHovered, setIsBarHovered] = useState(
Object.fromEntries(
data.map((d, idx) => [`${getCategory(d)}-${idx}`, false])
)
);
const categoryScale = scaleBand({
range: [0, categoryMax],
domain: data.map(getCategory),
padding: barPadding,
});
const valueScale = scaleLinear({
range: [0, valueMax],
nice: true,
domain: [0, Math.max(...data.map(getValue))],
});
const categoryPoint = (d: BarGraphData) => categoryScale(getCategory(d));
const valuePoint = (d: BarGraphData) => valueScale(getValue(d));
return (
<svg className={className} width={width} height={height}>
<filter id="glow">
<feDropShadow
dx="0"
dy="0"
stdDeviation={4}
floodColor={COLOURS.darkpink}
/>
</filter>
<Group top={margin.top} left={margin.left}>
<Group>
{data.map((d, idx) => {
const barName = `${getCategory(d)}-${idx}`;
const barWidth = categoryScale.bandwidth();
const backgroundBarWidth = barWidth / (1 - barPadding);
return idx % 2 === 0 ? (
<Bar
key={`bar-${barName}-background`}
x={0}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
y={categoryPoint(d)! - (backgroundBarWidth - barWidth) / 2}
width={valueMax}
height={backgroundBarWidth}
fill={COLOURS.navy}
/>
) : null;
})}
</Group>
<GridColumns
scale={valueScale}
height={categoryMax}
numTicks={5}
stroke={COLOURS.white}
strokeWidth={4}
strokeDasharray="10"
strokeLinecap="round"
/>
<Group>
{data.map((d, idx) => {
const barName = `${getCategory(d)}-${idx}`;
const barLength = valuePoint(d);
const barWidth = categoryScale.bandwidth();
return (
<Group
key={`bar-${barName}`}
onMouseEnter={() => {
setIsBarHovered({ ...isBarHovered, [barName]: true });
}}
onMouseLeave={() => {
setIsBarHovered({ ...isBarHovered, [barName]: false });
}}
>
<Bar
x={0}
y={categoryPoint(d)}
width={barLength}
height={barWidth}
fill={isBarHovered[barName] ? COLOURS.pink : COLOURS.salmon}
filter={isBarHovered[barName] ? "url(#glow)" : undefined}
/>
{isBarHovered[barName] ? (
<Text
x={valuePoint(d) - 12}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
y={categoryPoint(d)! + barWidth / 2}
fill={COLOURS.white}
fontFamily="Inconsolata"
fontWeight={800}
fontSize={hoverLabelSize ?? barWidth * 0.75}
textAnchor="end"
verticalAnchor="middle"
>
{getValue(d)}
</Text>
) : null}
</Group>
);
})}
</Group>
</Group>
<AxisLeft
scale={categoryScale}
top={margin.top}
left={margin.left}
hideAxisLine
hideTicks
tickLabelProps={() => {
return {
...leftTickLabelProps(),
dx: "-0.5rem",
dy: "0.25rem",
fill: COLOURS.white,
fontSize: `${categoryLabelSize / 16}rem`,
fontFamily: "Inconsolata",
fontWeight: 800,
};
}}
/>
<AxisBottom
scale={valueScale}
top={margin.top + categoryMax}
left={margin.left}
hideAxisLine
hideTicks
numTicks={5}
tickLabelProps={() => {
return {
...bottomTickLabelProps(),
dy: "0.25rem",
fill: COLOURS.white,
fontSize: `${valueLabelSize / 16}rem`,
fontFamily: "Inconsolata",
fontWeight: 800,
};
}}
/>
</svg>
);
}
export function BarGraphVertical(props: BarGraphProps) {
const {
width,
height,
margin,
data,
className,
categoryLabelSize = DEFAULT_LABEL_SIZE,
valueLabelSize = DEFAULT_LABEL_SIZE,
hoverLabelSize,
} = props;
const barPadding = 0.4;
const categoryMax = width - margin.left - margin.right;
const valueMax = height - margin.top - margin.bottom;
const getCategory = (d: BarGraphData) => d.category;
const getValue = (d: BarGraphData) => d.value;
const [isBarHovered, setIsBarHovered] = useState(
Object.fromEntries(
data.map((d, idx) => [`${getCategory(d)}-${idx}`, false])
)
);
const categoryScale = scaleBand({
range: [0, categoryMax],
domain: data.map(getCategory),
padding: barPadding,
});
const valueScale = scaleLinear({
range: [valueMax, 0],
nice: true,
domain: [0, Math.max(...data.map(getValue))],
});
const categoryPoint = (d: BarGraphData) => categoryScale(getCategory(d));
const valuePoint = (d: BarGraphData) => valueScale(getValue(d));
return (
<svg className={className} width={width} height={height}>
<filter id="glow">
<feDropShadow
dx="0"
dy="0"
stdDeviation={4}
floodColor={COLOURS.darkpink}
/>
</filter>
<Group top={margin.top} left={margin.left}>
<Group>
{data.map((d, idx) => {
const barName = `${getCategory(d)}-${idx}`;
const barWidth = categoryScale.bandwidth();
const backgroundBarWidth = barWidth / (1 - barPadding);
return idx % 2 === 0 ? (
<Bar
key={`bar-${barName}-background`}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
x={categoryPoint(d)! - (backgroundBarWidth - barWidth) / 2}
y={0}
width={backgroundBarWidth}
height={valueMax}
fill={COLOURS.navy}
/>
) : null;
})}
</Group>
<GridRows
scale={valueScale}
width={categoryMax}
numTicks={5}
stroke={COLOURS.white}
strokeWidth={4}
strokeDasharray="10"
strokeLinecap="round"
/>
<Group>
{data.map((d, idx) => {
const barName = `${getCategory(d)}-${idx}`;
const barHeight = valueMax - valuePoint(d);
const barWidth = categoryScale.bandwidth();
return (
<Group
key={`bar-${barName}`}
onMouseEnter={() => {
setIsBarHovered({ ...isBarHovered, [barName]: true });
}}
onMouseLeave={() => {
setIsBarHovered({ ...isBarHovered, [barName]: false });
}}
>
<Bar
x={categoryPoint(d)}
y={valueMax - barHeight}
width={barWidth}
height={barHeight}
fill={isBarHovered[barName] ? COLOURS.pink : COLOURS.salmon}
filter={isBarHovered[barName] ? "url(#glow)" : undefined}
/>
{isBarHovered[barName] ? (
<Text
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
x={categoryPoint(d)! + barWidth / 2}
y={valueMax - barHeight + 12}
fill={COLOURS.white}
fontFamily="Inconsolata"
fontWeight={800}
fontSize={hoverLabelSize ?? barWidth * 0.5}
textAnchor="middle"
verticalAnchor="start"
>
{getValue(d)}
</Text>
) : null}
</Group>
);
})}
</Group>
</Group>
<AxisBottom
scale={categoryScale}
top={valueMax + margin.top}
left={margin.left}
hideAxisLine
hideTicks
tickLabelProps={() => {
return {
...bottomTickLabelProps(),
dy: "-0.25rem",
fill: COLOURS.white,
fontSize: `${categoryLabelSize / 16}rem`,
fontFamily: "Inconsolata",
fontWeight: 800,
width: categoryScale.bandwidth(),
verticalAnchor: "start",
};
}}
/>
<AxisLeft
scale={valueScale}
top={margin.top}
left={margin.left}
hideAxisLine
hideTicks
numTicks={5}
tickLabelProps={() => {
return {
...leftTickLabelProps(),
dx: "-0.5rem",
dy: "0.25rem",
fill: COLOURS.white,
fontSize: `${valueLabelSize / 16}rem`,
fontFamily: "Inconsolata",
fontWeight: 800,
};
}}
/>
</svg>
);
}