Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make the quarters offered tooltip more useful #462

Merged
merged 6 commits into from
Mar 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading