Skip to content

Commit

Permalink
Make the quarters offered tooltip more useful (#462)
Browse files Browse the repository at this point in the history
* draft

* courses with no term data

* added to course and roadmap pages

* lint

* Update site/src/component/QuarterTooltip/Chart.tsx

Co-authored-by: Jacob Sommer <[email protected]>

---------

Co-authored-by: Jacob Sommer <[email protected]>
  • Loading branch information
ptruong0 and js0mmer authored Mar 9, 2024
1 parent 8d3bd82 commit 5032f17
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 114 deletions.
169 changes: 169 additions & 0 deletions site/src/component/QuarterTooltip/Chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React from 'react';
import { ResponsiveBar, BarTooltipProps, BarDatum } from '@nivo/bar';

import ThemeContext from '../../style/theme-context';
import { type Theme } from '@nivo/core';

const colors = ['#E8966D', '#60A3D1', '#FFC7DF', '#F5D77F', '#E8966D', '#EBEBEB'];

interface ChartProps {
terms: string[];
}

export default class Chart extends React.Component<ChartProps> {
getTheme = (darkMode: boolean): Theme => {
return {
axis: {
ticks: {
text: {
fill: darkMode ? '#eee' : '#333',
},
},
legend: {
text: {
fill: darkMode ? '#eee' : '#333',
},
},
},
};
};

/*
* Create an array of objects to feed into the chart.
* @return an array of JSON objects detailing the grades for each class
*/
getTermData = (): BarDatum[] => {
let fallCount = 0,
winterCount = 0,
springCount = 0;

// for summer, count unique years rather than total terms (e.g. count SS1 2023 and SS2 2023 as one)
const summerYears = new Set<string>();

this.props.terms.forEach((data) => {
const [year, term] = data.split(' ');
if (term === 'Fall') {
fallCount++;
} else if (term === 'Winter') {
winterCount++;
} else if (term === 'Spring') {
springCount++;
} else if (term.startsWith('Summer')) {
summerYears.add(year);
}
});

return [
{
id: 'fall',
label: 'Fall',
fall: fallCount,
color: '#E8966D',
},
{
id: 'winter',
label: 'Winter',
winter: winterCount,
color: '#2484C6',
},
{
id: 'spring',
label: 'Spring',
spring: springCount,
color: '#FFC7DF',
},
{
id: 'summer',
label: 'Summer',
summer: summerYears.size,
color: '#F9CE50',
},
];
};

tooltipStyle: Theme = {
tooltip: {
container: {
background: 'rgba(0,0,0,.87)',
color: '#ffffff',
fontSize: '1.2rem',
outline: 'none',
margin: 0,
padding: '0.25em 0.5em',
borderRadius: '2px',
},
},
};

/*
* Indicate how the tooltip should look like when users hover over the bar
* Code is slightly modified from: https://codesandbox.io/s/nivo-scatterplot-
* vs-bar-custom-tooltip-7u6qg?file=/src/index.js:1193-1265
* @param event an event object recording the mouse movement, etc.
* @return a JSX block styling the chart
*/
styleTooltip = (props: BarTooltipProps<BarDatum>) => {
return (
<div style={this.tooltipStyle.tooltip?.container}>
<strong>
{props.label}: {props.data[props.label]}
</strong>
</div>
);
};

/*
* Display the grade distribution chart.
* @return a JSX block rendering the chart
*/
render() {
const data = this.getTermData();

// greatestCount calculates the upper bound of the graph (i.e. the greatest number of students in a single grade)
const greatestCount = data.reduce(
(max, term) => ((term[term.id] as number) > max ? (term[term.id] as number) : max),
0,
);

// The base marginX is 30, with increments of 5 added on for every order of magnitude greater than 100 to accomadate for larger axis labels (1,000, 10,000, etc)
// For example, if greatestCount is 5173 it is (when rounding down (i.e. floor)), one magnitude (calculated with log_10) greater than 100, therefore we add one increment of 5px to our base marginX of 30px
// Math.max() ensures that we're not finding the log of a non-positive number
const marginX = 30 + 5 * Math.floor(Math.log10(Math.max(100, greatestCount) / 100));

return (
<>
<ThemeContext.Consumer>
{({ darkMode }) => (
<ResponsiveBar
data={data}
keys={['fall', 'winter', 'spring', 'summer']}
margin={{
top: 25,
right: marginX,
bottom: 25,
left: marginX,
}}
layout="vertical"
axisBottom={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: 'Term',
legendPosition: 'middle',
legendOffset: 36,
}}
axisLeft={{
tickValues: Array.from({ length: greatestCount }, (_, i) => i + 1), // integers from 1 to max
}}
enableLabel={false}
colors={colors}
theme={this.getTheme(darkMode)}
tooltipLabel={(datum) => String(datum.id)}
tooltip={this.styleTooltip}
/>
)}
</ThemeContext.Consumer>
</>
);
}
}
66 changes: 66 additions & 0 deletions site/src/component/QuarterTooltip/CourseQuarterIndicator.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.quarter-indicator-container {
display: flex;
margin-left: auto;
float: right;

.quarter-indicator-row {
display: flex;
justify-content: end;
max-height: 36px;

.emoji-xs {
font-size: 16px;
}

.emoji-sm {
font-size: 18px;
}

.emoji-lg {
font-size: 24px;
}
}
}

.quarter-tooltip {
.emoji-xs,
.emoji-sm,
.emoji-lg {
font-size: 14px;
}

.emoji-label {
margin-left: 4px;
}

.not-offered-text {
font-weight: bold;
margin-bottom: 6px;
}

.tooltip-column {
display: flex;
flex-direction: column;
margin: 4px;
}

.tooltip-chart-section {
margin: 16px 0;
}

.term-chart-container {
display: inline-flex;
height: 200px;
width: 300px;
}
}

@media only screen and (max-width: 400px) {
.quarter-indicator-container {
.quarter-indicator-row {
display: flex;
flex-direction: column;
justify-content: start;
}
}
}
147 changes: 147 additions & 0 deletions site/src/component/QuarterTooltip/CourseQuarterIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { FC } from 'react';
import { Label, Popup } from 'semantic-ui-react';
import './CourseQuarterIndicator.scss';
import Chart from './Chart';

interface CourseQuarterIndicatorProps {
terms: string[];
size: 'xs' | 'sm' | 'lg';
}

const CourseQuarterIndicator: FC<CourseQuarterIndicatorProps> = (props) => {
const emojiSize = `emoji-${props.size}`;

// if currently in fall quarter, previous academic year still includes the current
const prevYear = new Date().getMonth() > 9 ? new Date().getFullYear() : new Date().getFullYear() - 1;

// show in order of fall, winter, spring, summer
const termsInOrder = props.terms.slice().reverse();

const summerOfferings = [
termsInOrder.includes(`${prevYear} Summer10wk`) && 'Summer Session (10 Week)',
termsInOrder.includes(`${prevYear} Summer1`) && 'Summer Session 1',
termsInOrder.includes(`${prevYear} Summer2`) && 'Summer Session 2',
];

// check if the course was offered in the previous academic year at any term
const offeredLastYear =
termsInOrder.includes(`${prevYear - 1} Fall`) ||
termsInOrder.includes(`${prevYear} Winter`) ||
termsInOrder.includes(`${prevYear} Spring`) ||
// if the course was offered in any summer session from last year
summerOfferings.some((term) => term);

// find min and max year in term range
const years = termsInOrder.map((term) => parseInt(term.split(' ')[0]));
const minYear = years.reduce((min, val) => Math.min(min, val), prevYear);
const maxYear = years.reduce((max, val) => Math.max(max, val), 0);

return (
<div className="quarter-indicator-container">
<Popup
trigger={
offeredLastYear ? (
// icons to show which terms were offered last year
<span className="quarter-indicator-row">
{termsInOrder.includes(`${prevYear - 1} Fall`) && (
<span>
<span className={emojiSize}>🍂</span>
</span>
)}

{termsInOrder.includes(`${prevYear} Winter`) && (
<span>
<span className={emojiSize}>❄️</span>
</span>
)}

{termsInOrder.includes(`${prevYear} Spring`) && (
<span>
<span className={emojiSize}>🌸</span>
</span>
)}

{
// summer icon shows if there was any summer session offering
summerOfferings.some((term) => term) && (
<span>
<span className={emojiSize}>☀️</span>
</span>
)
}
</span>
) : (
// no offerings from last year, no icons to show
<Label circular color="grey" empty />
)
}
content={
<div className="quarter-tooltip">
{props.terms.length ? (
<div>
{offeredLastYear ? (
// legend to show terms corresponding to the icons
<div className="tooltip-column">
<h5 style={{ marginBottom: '4px' }}>Last offered in:</h5>
{termsInOrder.includes(`${prevYear - 1} Fall`) && (
<div>
<span className={emojiSize}>🍂</span>
<span className="emoji-label">Fall {prevYear - 1}</span>
</div>
)}

{termsInOrder.includes(`${prevYear} Winter`) && (
<div>
<span className={emojiSize}>❄️</span>
<span className="emoji-label">Winter {prevYear}</span>
</div>
)}

{termsInOrder.includes(`${prevYear} Spring`) && (
<div>
<span className={emojiSize}>🌸</span>
<span className="emoji-label">Spring {prevYear}</span>
</div>
)}

{summerOfferings.some((term) => term) && (
<div>
<span className={emojiSize}>☀️</span>
<span className="emoji-label">
{/* list out summer sessions offered */}
{summerOfferings.filter((term) => term).join(', ')} {prevYear}
</span>
</div>
)}
</div>
) : (
// hide legend if course has term data, but not for the previous year
<p className="not-offered-text">
This course was not offered in the {prevYear - 1}-{prevYear} academic year.
</p>
)}

{/* chart of past term offerings */}
<div className="tooltip-chart-section">
<h5 style={{ textAlign: 'center' }}>
Past Offerings ({minYear !== maxYear ? `${minYear} - ${maxYear}` : `${minYear}`})
</h5>
<div className="term-chart-container chart">
<Chart terms={props.terms} />
</div>
</div>
</div>
) : (
// hide legend and chart if there is no term data at all
<p className="not-offered-text">This course has not been offered in any recent years.</p>
)}
</div>
}
basic
position="bottom right"
/>
</div>
);
};

export default CourseQuarterIndicator;
8 changes: 8 additions & 0 deletions site/src/component/SideInfo/SideInfo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
h2 {
font-weight: bold;
}

.name-row {
display: flex;
}

.emoji {
font-size: 24px;
}
}

.side-info-ratings {
Expand Down
Loading

0 comments on commit 5032f17

Please sign in to comment.