diff --git a/.eslintrc.js b/.eslintrc.js index 7beb879..756f84b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,10 +15,15 @@ module.exports = { "plugin:react/recommended", "plugin:prettier/recommended", ], - plugins: ["@typescript-eslint", "react", "react-hooks", "prettier"], + plugins: ["@typescript-eslint", "react", "react-hooks", "prettier", "unused-imports"], rules: { - "prettier/prettier": "error", - + "no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "error", + { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" } + ], + "prettier/prettier": ["error", { "endOfLine": "auto" }], "import/first": "error", "import/order": [ "error", diff --git a/components/BarGraph.module.css b/components/BarGraph.module.css index 7ccdc8b..d709ac5 100644 --- a/components/BarGraph.module.css +++ b/components/BarGraph.module.css @@ -33,4 +33,4 @@ font-family: "Inconsolata", monospace; font-weight: 800; fill: var(--label); -} +} \ No newline at end of file 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/BottomNav.module.css b/components/BottomNav.module.css new file mode 100644 index 0000000..8a0bce9 --- /dev/null +++ b/components/BottomNav.module.css @@ -0,0 +1,106 @@ +.container { + display: flex; + flex-flow: center; + align-items: center; + justify-content: space-between; + margin: calc(40rem / 16) 0; +} + +.subBox { + display: inline-block; +} + +.item { + color: var(--primary-text); + font-size: calc(28rem / 16); + position: relative; + margin: calc(24rem / 16); +} + +.subBox { + display: flex; + flex-direction: row; + align-items: center; +} + +.arrow { + width: calc(250rem / 16); + height: calc(20rem / 16); + display: flex; + align-items: center; + justify-content: center; +} + + +.item:after { + content: ''; + position: absolute; + width: 100%; + transform: scaleX(0); + height: calc(2rem / 16); + bottom: 0; + left: 0; + background-color: var(--primary-accent); + cursor: pointer; + transform-origin: bottom right; + transition: transform 0.25s ease-out; +} + +.item:hover:after { + transform: scaleX(1); + transform-origin: bottom left; +} + +.linePath { + stroke: var(--primary-text); +} + +.arrowPath { + fill: var(--primary-text); +} + +.right { + transform: rotate(180deg); +} + +@media screen and (max-width: 1000px) { + .subBox { + flex-direction: column; + align-items: flex-start; + } + + .subBoxLeft { + flex-direction: column-reverse; + align-items: flex-end; + } + + + .item { + font-size: calc(20rem / 16); + margin: 0; + margin-bottom: calc(10rem / 16); + } + + .arrow { + width: calc(200rem / 16); + } +} + +@media screen and (max-width: 500px) { + .container { + justify-content: center; + gap: calc(50rem / 16); + } + + .arrow { + width: 100%; + } +} + +.containerOnlyRightArrow { + justify-content: flex-end; +} + +.containerOnlyLeftArrow { + justify-content: flex-start; +} \ No newline at end of file diff --git a/components/BottomNav.tsx b/components/BottomNav.tsx new file mode 100644 index 0000000..f31b9a0 --- /dev/null +++ b/components/BottomNav.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import React from "react"; + +import styles from "./BottomNav.module.css"; + +interface PagesInfo { + leftPage?: { + url: string; + name: string; + }; + rightPage?: { + url: string; + name: string; + }; +} + +export function BottomNav(props: PagesInfo) { + const onlyRightArrow = props.rightPage && !props.leftPage; + const onlyLeftArrow = !props.rightPage && props.leftPage; + return ( +
+ {props.leftPage ? ( +
+ + + + + + + {props.leftPage.name} + +
+ ) : null} + {props.rightPage ? ( +
+ + {props.rightPage.name} + + + + + + +
+ ) : null} +
+ ); +} + +interface ArrowProps { + isPointingRight?: boolean; +} + +function Arrow({ isPointingRight }: ArrowProps) { + return ( + + + + + + + + + + ); +} diff --git a/components/ComponentWrapper.module.css b/components/ComponentWrapper.module.css index 4fad333..8cbdc3b 100644 --- a/components/ComponentWrapper.module.css +++ b/components/ComponentWrapper.module.css @@ -3,7 +3,7 @@ display: flex; padding: calc(40rem / 16) calc(50rem / 16); margin: calc(65rem / 16) 0; - width: 90%; + width: 88%; } .wrapperRight { @@ -43,7 +43,12 @@ padding: 0 15%; } -@media screen and (max-width: 768px) { +.wrapperNoBodyText { + flex-direction: column; + align-items: center; +} + +@media screen and (max-width: 900px) { .sideWrapperCommon { margin: auto; flex-direction: column; @@ -52,6 +57,10 @@ border-radius: 0; width: 100%; } + + .wrapperCenter { + padding: 0; + } } .internalWrapper { diff --git a/components/ComponentWrapper.tsx b/components/ComponentWrapper.tsx index 8893905..400b0c5 100644 --- a/components/ComponentWrapper.tsx +++ b/components/ComponentWrapper.tsx @@ -7,7 +7,7 @@ type AlignOption = "left" | "center" | "right"; type ComponentWrapperProps = { children: React.ReactNode; heading: string; - bodyText: string; + bodyText?: string; align?: AlignOption; noBackground?: boolean; }; @@ -30,11 +30,12 @@ export function ComponentWrapper({ className={` ${alignClasses[align]} ${noBackground ? styles.noBackground : ""} + ${bodyText ? "" : styles.wrapperNoBodyText} `} >

{heading}

-

{bodyText}

+ {bodyText ?

{bodyText}

: null}
{children}
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/components/Header.module.css b/components/Header.module.css new file mode 100644 index 0000000..5ec72d4 --- /dev/null +++ b/components/Header.module.css @@ -0,0 +1,137 @@ +.headerWrapper { + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + left: 0; + background: var(--dark--primary-background); + z-index: 98; + box-sizing: border-box; + padding: calc(10rem / 16) calc(100rem / 16) 0; +} + +.titleHeader { + margin: calc(16rem / 16) 0; +} + +.sideBarCommon { + position: fixed; + right: 0; + top: 0; + min-width: calc(400rem / 16); + height: 100vh; + background: var(--secondary-background); + padding: calc(100rem / 16); + margin: 0; + z-index: 100; + padding: 0; + padding-right: calc(20rem / 16); + transition: transform 0.8s; + overflow: auto; +} + +.sideBarShown { + composes: sideBarCommon; + /* -1% to hide slight line tip showing in some browsers */ + transform: translateX(-1%); +} + +.sideBarHidden { + composes: sideBarCommon; + transform: translateX(100%); +} + +.backgroundTintCommon { + background-color: var(--label); + animation: fadeIn 1s; + position: fixed; + z-index: 99; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + transition: opacity 0.8s, visibility 0.8s; +} + +.backgroundTintShow { + composes: backgroundTintCommon; + visibility: visible; + opacity: 0.2; +} + +.backgroundTintHidden { + composes: backgroundTintCommon; + visibility: hidden; + opacity: 0; +} + + +.menuHeader { + margin-bottom: 0; + padding-left: calc(30rem / 16); + padding-bottom: 0; + color: var(--dark--secondary-heading); +} + +.sectionsWrapper { + padding-left: calc(30rem / 16); +} + +.menuIcon { + background: none; + border: none; +} + +.menuIcon:hover { + opacity: 0.8; + cursor: pointer; +} + + +@media screen and (max-width: 768px) { + .sideBarCommon { + min-width: calc(300rem / 16); + max-width: calc(500rem / 16); + } + + .menuHeader { + padding-left: calc(20rem / 16); + } + + .sectionsWrapper { + padding-left: calc(20rem / 16); + } + + .headerWrapper { + padding: calc(10rem / 16) calc(20rem / 16) 0; + } +} + +.closeMenuButton { + background: var(--primary-heading); + padding: 0 calc(20rem / 16); + border-radius: calc(50rem / 16); + display: flex; + flex-direction: row; + margin-left: calc(20rem / 16); + /* transparent border fixes weird coloring on the border in some browsers */ + border: calc(1rem / 16) solid transparent; +} + +.closeMenuButton:hover { + background-color: var(--secondary-accent-light); + cursor: pointer; +} + +.lineWrapper { + width: 100%; + display: flex; +} + +.lineWrapper:before { + content: ""; + flex: 1 1; + border-bottom: 3px solid white; + margin: auto; +} \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..c150c87 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,69 @@ +import { pageRoutes } from "data/routes"; +import Link from "next/link"; +import React, { useState } from "react"; + +import { Sections } from "./Sections"; + +import styles from "./Header.module.css"; + +export function Header() { + const [isShowingMenu, setIsShowingMenu] = useState(false); + + return ( + <> +
{ + setIsShowingMenu(false); + }} + /> +
+

+ CS 2022 +

+ +
+ +
+

Sections

+
+ +
+
+ +
+
+ + ); +} diff --git a/components/LineGraph.module.css b/components/LineGraph.module.css new file mode 100644 index 0000000..bb8cdfe --- /dev/null +++ b/components/LineGraph.module.css @@ -0,0 +1,35 @@ +.tickLabel { + font-family: "Inconsolata", monospace; + font-weight: 800; + fill: var(--label); +} + +.line:hover { + filter: drop-shadow(0 0 calc(4rem / 16) var(--primary-accent)); +} + +.tooltip { + font-family: "Inconsolata", monospace; + font-weight: bold; + top: 0; + left: 0; + position: absolute; + background-color: var(--label); + color: var(--primary-background); + box-shadow: 0px calc(1rem / 16) calc(2rem / 16) var(--card-background); + pointer-events: none; + padding: calc(10rem / 16); + font-size: calc(18rem / 16); + border-radius: calc(10rem / 16); +} + +.wrapper { + display: flex; + align-items: center; + width: min-content; +} + +.legend { + display: flex; + margin: calc(16rem / 8); +} \ No newline at end of file diff --git a/components/LineGraph.tsx b/components/LineGraph.tsx new file mode 100644 index 0000000..7e98241 --- /dev/null +++ b/components/LineGraph.tsx @@ -0,0 +1,311 @@ +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 { LinePath } from "@visx/shape"; +import { useTooltip, useTooltipInPortal } from "@visx/tooltip"; +import React from "react"; +import { Color } from "utils/Color"; + +import styles from "./LineGraph.module.css"; + +interface LineData { + label: string; + yValues: number[]; +} + +interface PointData { + x: string; + y: number; +} + +interface LineGraphData { + xValues: string[]; + lines: LineData[]; +} + +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; + }; +} + +interface LineGraphProps { + data: LineGraphData; + /** 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 tick labels, in pixels. Default is 16px. */ + xTickLabelSize?: number; + /** Font size of the value tick labels, in pixels. Default is 16px. */ + yTickLabelSize?: number; + /** Font size of the value that appears when hovering over a bar, in pixels. */ + hoverLabelSize?: number; + /** Label text for the category axis. */ + xAxisLabel?: string; + /** Font size of the label for the cateogry axis, in pixels. */ + xAxisLabelSize?: number; + /** Controls the distance between the category axis label and the category axis. */ + xAxisLabelOffset?: number; + /** Label text for the value axis. */ + yAxisLabel?: string; + /** Font size of the label for the value axis, in pixels. */ + yAxisLabelSize?: number; + /** Controls the distance between the value axis label and the value axis. */ + yAxisLabelOffset?: number; + legendProps?: LegendProps; +} + +const DEFAULT_LABEL_SIZE = 16; +const DEFAULT_LEGEND_GAP = 16; + +// TODO: Address unused props in this file +/* eslint-disable unused-imports/no-unused-vars*/ +export function LineGraph(props: LineGraphProps) { + const { + width, + height, + margin, + data, + className, + xTickLabelSize = DEFAULT_LABEL_SIZE, + yTickLabelSize = DEFAULT_LABEL_SIZE, + hoverLabelSize, + xAxisLabel, + xAxisLabelSize = DEFAULT_LABEL_SIZE, + xAxisLabelOffset = 0, + yAxisLabel, + yAxisLabelSize = DEFAULT_LABEL_SIZE, + yAxisLabelOffset = 0, + legendProps, + } = props; + + const { + position: legendPosition = "right", + itemLabelSize: legendLabelSize = DEFAULT_LABEL_SIZE, + itemGap: legendItemGap = DEFAULT_LEGEND_GAP, + margin: legendMargin = {}, + } = legendProps ?? {}; + + const xLength = data.xValues.length; + + data.lines.forEach((line) => { + if (line.yValues.length != xLength) { + throw new Error("Invalid data with wrong length."); + } + }); + + const { + tooltipData, + tooltipLeft, + tooltipTop, + tooltipOpen, + showTooltip, + hideTooltip, + } = useTooltip(); + + const { containerRef, TooltipInPortal } = useTooltipInPortal({ + // use TooltipWithBounds + detectBounds: true, + // when tooltip containers are scrolled, this will correctly update the Tooltip position + scroll: true, + }); + + const yMax = height - margin.top - margin.bottom; + const xMax = width - margin.left - margin.right; + + const actualData = data.lines.map((line) => { + return line.yValues.map((val, idx) => { + return { x: data.xValues[idx], y: val }; + }); + }); + + const yMaxValue = Math.max( + ...data.lines.map((line) => { + return Math.max(...line.yValues); + }) + ); + + // data accessors + const getX = (d: PointData) => d.x; + const getY = (d: PointData) => d.y; + + // scales + const xScale = scaleBand({ + range: [0, xMax], + domain: data.xValues, + }); + + const yScale = scaleLinear({ + range: [0, yMax], + nice: true, + domain: [yMaxValue, 0], + }); + + const keys = data.lines.map((line) => line.label); + + const legendScale = scaleOrdinal({ + domain: keys, + range: [Color.primaryAccent, Color.secondaryAccent], + }); + + return ( +
+ + + + + { + return { + ...bottomTickLabelProps(), + className: styles.tickLabel, + dy: "-0.25rem", + fontSize: `${xTickLabelSize / 16}rem`, + width: xScale.bandwidth(), + }; + }} + /> + { + return { + ...leftTickLabelProps(), + className: styles.tickLabel, + dx: "1.25rem", + dy: "0.25rem", + fontSize: `${yTickLabelSize / 16}rem`, + }; + }} + /> + + {actualData.map((lineData, i) => { + const isEven = i % 2 === 0; + return ( + + { + 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: data.lines[i].label, + tooltipTop: eventSvgCoords.y, + tooltipLeft: eventSvgCoords.x, + }); + }} + onMouseOut={hideTooltip} + data={lineData} + className={styles.line} + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + x={(d) => xScale(getX(d))!} + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + y={(d) => yScale(getY(d))!} + stroke={ + isEven ? Color.primaryAccent : Color.secondaryAccent + } + strokeWidth={4} + strokeOpacity={2} + /> + + ); + })} + + + + + + {tooltipOpen && ( + + <>{tooltipData} + + )} +
+ ); +} diff --git a/components/SectionHeader.module.css b/components/SectionHeader.module.css new file mode 100644 index 0000000..a9faa52 --- /dev/null +++ b/components/SectionHeader.module.css @@ -0,0 +1,19 @@ +.header { + display: flex; + flex-direction: column; + justify-content: center; + padding: calc(40rem / 16) 0; + text-align: center; +} + +.title { + color: var(--primary-accent-light); + font-size: calc(70rem / 16); + margin: calc(40rem / 16) auto; +} + +.subTitle { + color: var(--primary-accent-lighter); + font-size: calc(26rem / 16); + margin: auto; +} \ No newline at end of file diff --git a/components/SectionHeader.tsx b/components/SectionHeader.tsx new file mode 100644 index 0000000..f8a9b46 --- /dev/null +++ b/components/SectionHeader.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +import styles from "./SectionHeader.module.css"; + +interface SectionHeaderProps { + title: string; + subtitle?: string; +} + +export function SectionHeader({ title, subtitle }: SectionHeaderProps) { + return ( +
+

{title}

+ {subtitle &&
{subtitle}
} +
+ ); +} diff --git a/components/Sections.tsx b/components/Sections.tsx index 8ba08b1..685bbd7 100644 --- a/components/Sections.tsx +++ b/components/Sections.tsx @@ -1,18 +1,14 @@ +import { PageRoutes } from "data/routes"; import React from "react"; import styles from "./Sections.module.css"; -interface SectionsData { - name: string; - url: string; -} - interface SectionsProps { /* Whether to display the "Sections" title and separator that appears on the left. */ showHeader?: boolean; /* Width of the entire Sections, in px. */ width?: number; - data: SectionsData[]; + data: PageRoutes; className?: string; } @@ -39,7 +35,7 @@ export function Sections({ )}