-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
73080a7
commit 79f627b
Showing
17 changed files
with
817 additions
and
112 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
import { CourseTrendChart } from "@/app/course/course-trend-chart"; | ||
import { | ||
Card, | ||
CardContent, | ||
CardDescription, | ||
CardHeader, | ||
CardTitle, | ||
} from "@/components/ui/card"; | ||
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; | ||
import { CourseObject, CourseSortBy } from "@/data/course"; | ||
import { stopPropagation } from "@/lib/events"; | ||
import React, { useState } from "react"; | ||
|
||
type CourseCardProps = { | ||
courseObj: CourseObject; | ||
sortBy: CourseSortBy; | ||
}; | ||
|
||
type Color = [number, number, number]; | ||
|
||
function letterGrade(percentile: number) { | ||
for (const [threshold, grade] of [ | ||
[0.9, "A+"], | ||
[0.8, "A"], | ||
[0.75, "A-"], | ||
[0.6, "B+"], | ||
[0.45, "B"], | ||
[0.35, "B-"], | ||
[0.3, "C+"], | ||
[0.25, "C"], | ||
[0.2, "C-"], | ||
[0.1, "D"], | ||
[0.0, "F"], | ||
] as Array<[number, string]>) { | ||
if (percentile >= threshold) { | ||
return grade; | ||
} | ||
} | ||
|
||
return "F"; | ||
} | ||
|
||
function gradeColor(ratio: number): Color { | ||
const colorStops = [ | ||
{ ratio: 0.0, color: [237, 27, 47] as Color }, | ||
{ ratio: 0.25, color: [250, 166, 26] as Color }, | ||
{ ratio: 0.75, color: [163, 207, 98] as Color }, | ||
{ ratio: 1.0, color: [0, 154, 97] as Color }, | ||
]; | ||
|
||
function lerp(start: number, end: number, t: number): number { | ||
return start * (1 - t) + end * t; | ||
} | ||
|
||
function blendColors(color1: Color, color2: Color, t: number): Color { | ||
return [ | ||
Math.round(lerp(color1[0], color2[0], t)), | ||
Math.round(lerp(color1[1], color2[1], t)), | ||
Math.round(lerp(color1[2], color2[2], t)), | ||
]; | ||
} | ||
|
||
for (let i = 0; i < colorStops.length - 1; i++) { | ||
const currentStop = colorStops[i]; | ||
const nextStop = colorStops[i + 1]; | ||
|
||
if (ratio >= currentStop.ratio && ratio <= nextStop.ratio) { | ||
const t = | ||
(ratio - currentStop.ratio) / (nextStop.ratio - currentStop.ratio); | ||
return blendColors(currentStop.color, nextStop.color, t); | ||
} | ||
} | ||
|
||
return [0, 0, 0]; // Default color if the ratio is out of range | ||
} | ||
|
||
function cssColor(color: Color) { | ||
return `rgb(${color[0]}, ${color[1]}, ${color[2]})`; | ||
} | ||
|
||
export function CourseCard({ courseObj, sortBy }: CourseCardProps) { | ||
const [open, setOpen] = useState(false); | ||
const { | ||
subject, | ||
number, | ||
rank, | ||
percentile, | ||
instructors, | ||
historicalInstructors, | ||
} = courseObj; | ||
|
||
const { | ||
score, | ||
ratingContent, | ||
ratingTeaching, | ||
ratingGrading, | ||
ratingWorkload, | ||
confidence, | ||
samples, | ||
} = courseObj.scores[0]; | ||
|
||
const sortByScore = courseObj.scores[0][sortBy]; | ||
const formattedScore = (sortByScore * 100).toFixed(1); | ||
|
||
const ustSpaceUrl = `https://ust.space/review/${subject}${number}`; | ||
|
||
return ( | ||
<Card | ||
className="flex cursor-pointer flex-col bg-white" | ||
onClick={() => setOpen(!open)} | ||
> | ||
<CardHeader className="flex w-full flex-row items-center gap-4 p-4 lg:p-6 lg:pr-10"> | ||
<CardTitle className="shrink-0 text-gray-600 lg:w-36"> | ||
#{rank}{" "} | ||
<span className="hidden font-medium lg:inline"> | ||
({formattedScore}) | ||
</span> | ||
</CardTitle> | ||
<div className="min-w-0 space-y-1 text-left"> | ||
<CardTitle className="tracking-normal"> | ||
<a | ||
className="group pointer-events-auto" | ||
href={ustSpaceUrl} | ||
target="_blank" | ||
onClick={stopPropagation} | ||
> | ||
<span className="inline-block group-hover:underline"> | ||
{subject} | ||
</span> | ||
<span className="inline-block group-hover:underline"> | ||
{number} | ||
</span> | ||
</a> | ||
</CardTitle> | ||
<CardDescription className="truncate"> | ||
{samples} Reviews of {historicalInstructors.join("; ")} | ||
</CardDescription> | ||
</div> | ||
<Card | ||
className="!my-auto !ml-auto w-12 shrink-0 py-2 text-white" | ||
style={{ backgroundColor: cssColor(gradeColor(percentile)) }} | ||
> | ||
<CardTitle>{letterGrade(percentile)}</CardTitle> | ||
</Card> | ||
</CardHeader> | ||
<Collapsible open={open}> | ||
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-slideUp data-[state=open]:animate-slideDown"> | ||
<CardContent> | ||
<div className="mx-6 mb-1 grid grid-cols-1 gap-2 text-left text-gray-500 lg:grid-cols-2"> | ||
<div className="grid auto-rows-min gap-x-2"> | ||
<span className="text-right">Rating (Teaching):</span> | ||
<span className="col-start-2">{ratingTeaching.toFixed(3)}</span> | ||
|
||
<span className="text-right">Rating (Workload):</span> | ||
<span className="col-start-2">{ratingWorkload.toFixed(3)}</span> | ||
|
||
<span className="text-right">Rating (Content):</span> | ||
<span className="col-start-2">{ratingContent.toFixed(3)}</span> | ||
|
||
<span className="text-right">Rating (Grading):</span> | ||
<span className="col-start-2">{ratingGrading.toFixed(3)}</span> | ||
|
||
<span className="text-right">Overall Rating: </span> | ||
<span className="col-start-2">{score.toFixed(3)}</span> | ||
|
||
<span className="text-right">Confidence: </span> | ||
<span className="col-start-2">{confidence.toFixed(3)}</span> | ||
|
||
<span className="text-right">Percentile: </span> | ||
<span className="col-start-2"> | ||
{(percentile * 100).toFixed(1)}% | ||
</span> | ||
</div> | ||
<div className="grid auto-rows-min gap-x-2"> | ||
<span className="font-medium">Instructors</span> | ||
<div className="grid gap-x-2"> | ||
{instructors.map((it) => ( | ||
<span key={it} className="text-nowrap underline"> | ||
{it} | ||
</span> | ||
))} | ||
</div> | ||
<span className="font-medium">Historical Instructors</span> | ||
<div className="grid gap-x-2"> | ||
{historicalInstructors.map( | ||
(it) => | ||
instructors.includes(it) || ( | ||
<span key={it} className="text-nowrap underline"> | ||
{it} | ||
</span> | ||
), | ||
)} | ||
</div> | ||
</div> | ||
</div> | ||
|
||
<CourseTrendChart scores={courseObj.scores} /> | ||
</CardContent> | ||
</CollapsibleContent> | ||
</Collapsible> | ||
</Card> | ||
); | ||
} |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import "./course-trend-chart.css"; | ||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; | ||
import { CourseScoreObject } from "@/data/course"; | ||
import { stopPropagation } from "@/lib/events"; | ||
import { AreaChart, type CustomTooltipProps } from "@tremor/react"; | ||
import ChartTooltip from "@tremor/react/dist/components/chart-elements/common/ChartTooltip"; | ||
import _ from "lodash"; | ||
import React, { useState } from "react"; | ||
|
||
type InstructorTrendChartProps = { | ||
scores: CourseScoreObject[]; | ||
}; | ||
|
||
export function formatTerm(n: number): string { | ||
const seasonMap = { | ||
0: "Fall", | ||
1: "Winter", | ||
2: "Spring", | ||
3: "Summer", | ||
}; | ||
const year = 2000 + Math.floor(n / 4); | ||
const season = seasonMap[n % 4] as string; | ||
return `${year}-${year + 1} ${season}`; | ||
} | ||
|
||
function allScoresAreInRegularTerm(scores: CourseScoreObject[]): boolean { | ||
// 0: Fall; 2: Spring | ||
return scores.every((score) => score.term % 4 === 0 || score.term % 4 === 2); | ||
} | ||
|
||
export function CourseTrendChart(props: InstructorTrendChartProps) { | ||
const [showRatings, setShowRatings] = useState<string[]>([]); | ||
|
||
const { scores } = props; | ||
if (scores.length === 0) { | ||
return <div>No score available</div>; | ||
} | ||
|
||
const scoreMap = _.keyBy(scores, (score) => score.term); | ||
|
||
// Asset non-null because there should be at least one score. | ||
const maxTerm = _.max(scores.map((score) => score.term))!; | ||
const minTerm = _.min(scores.map((score) => score.term))!; | ||
let terms = _.range(minTerm, maxTerm + 1); | ||
|
||
// If all scores are in regular term, we only show regular terms. | ||
if (allScoresAreInRegularTerm(scores)) { | ||
terms = terms.filter((term) => term % 4 === 0 || term % 4 === 2); | ||
} | ||
|
||
const chartData = terms.map((term) => { | ||
const score = scoreMap[term]; | ||
return { | ||
semester: formatTerm(term), | ||
"Rating (Content)": score?.individualRatingContent ?? NaN, | ||
"Rating (Teaching)": score?.individualRatingTeaching ?? NaN, | ||
"Rating (Grading)": score?.individualRatingGrading ?? NaN, | ||
"Rating (Workload)": score?.individualRatingWorkload ?? NaN, | ||
Score: score?.score ?? NaN, | ||
Samples: score?.individualSamples ?? 0, | ||
}; | ||
}); | ||
|
||
const valueFormatter = (v: number) => (isNaN(v) ? "N/A" : v.toFixed(3)); | ||
const CustomToolTip = (props: CustomTooltipProps) => { | ||
const { payload, active, label } = props; | ||
console.log(props); | ||
if (!payload) { | ||
return null; | ||
} | ||
|
||
const samples = payload[0]?.payload?.Samples as number; | ||
const newLabel = `${label} - Samples: ${samples}`; | ||
return ( | ||
<ChartTooltip | ||
active={active} | ||
payload={payload} | ||
label={newLabel} | ||
categoryColors={ | ||
new Map(payload.map((it) => [it.name as string, it.color])) | ||
} | ||
valueFormatter={valueFormatter} | ||
/> | ||
); | ||
}; | ||
|
||
return ( | ||
<div className="py-4" onClick={stopPropagation}> | ||
<ToggleGroup | ||
type="multiple" | ||
variant="outline" | ||
size="lg" | ||
value={showRatings} | ||
onValueChange={setShowRatings} | ||
className="flex-wrap px-4 font-bold" | ||
> | ||
<ToggleGroupItem className="font-semibold" value="Rating (Content)"> | ||
Content | ||
</ToggleGroupItem> | ||
<ToggleGroupItem className="font-semibold" value="Rating (Teaching)"> | ||
Teaching | ||
</ToggleGroupItem> | ||
<ToggleGroupItem className="font-semibold" value="Rating (Grading)"> | ||
Grading | ||
</ToggleGroupItem> | ||
<ToggleGroupItem className="font-semibold" value="Rating (Workload)"> | ||
Workload | ||
</ToggleGroupItem> | ||
</ToggleGroup> | ||
<AreaChart | ||
data={chartData} | ||
index="semester" | ||
categories={["Score", ...showRatings]} | ||
rotateLabelX={{ angle: -60 }} | ||
curveType="monotone" | ||
connectNulls={true} | ||
valueFormatter={valueFormatter} | ||
customTooltip={CustomToolTip} | ||
/> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
"use client"; | ||
|
||
import { CourseCard } from "@/app/course/course-card"; | ||
import { NewDomainBanner } from "@/components/component/new-domain-banner"; | ||
import { Input } from "@/components/ui/input"; | ||
import { | ||
CourseRatingWeights, | ||
CourseSortBy, | ||
searchCourses, | ||
} from "@/data/course"; | ||
import dynamic from "next/dynamic"; | ||
import React, { type ChangeEvent, useState } from "react"; | ||
import { WindowVirtualizer } from "virtua"; | ||
|
||
const SettingsCard = dynamic( | ||
async () => (await import("./settings-card")).SettingsCard, | ||
{ ssr: false }, | ||
); | ||
|
||
export default function Course() { | ||
const [query, setQuery] = useState(""); | ||
|
||
const [sortBy, setSortBy] = useState<CourseSortBy>("bayesianScore"); | ||
const [ratingWeights, setRatingWeights] = useState<CourseRatingWeights>({ | ||
ratingContent: 0.3, | ||
ratingTeaching: 0.3, | ||
ratingGrading: 0.3, | ||
ratingWorkload: 0.1, | ||
}); | ||
|
||
const result = searchCourses(query, sortBy, ratingWeights); | ||
|
||
return ( | ||
<> | ||
<NewDomainBanner className="-mt-12 max-w-sm lg:max-w-2xl" /> | ||
|
||
<h1 className="text-logo-gradient max-w-sm text-7xl font-bold tracking-tighter lg:max-w-2xl"> | ||
UST Rankings | ||
</h1> | ||
<form className="w-full max-w-sm lg:max-w-2xl"> | ||
<Input | ||
value={query} | ||
onChange={(e: ChangeEvent<HTMLInputElement>) => { | ||
setQuery(e.target.value); | ||
}} | ||
className="text-md h-12 rounded-full focus-visible:ring-gray-700" | ||
placeholder="Search for instructors by Name / Course / etc..." | ||
type="search" | ||
/> | ||
</form> | ||
|
||
<div className="max-w- w-full max-w-sm px-2 lg:max-w-3xl"> | ||
<SettingsCard | ||
sortBy={sortBy} | ||
setSortBy={setSortBy} | ||
formula={ratingWeights} | ||
setFormula={setRatingWeights} | ||
/> | ||
</div> | ||
|
||
<div className="w-full max-w-sm px-2 lg:max-w-3xl"> | ||
<WindowVirtualizer> | ||
{result.map((courseObj) => ( | ||
<div key={courseObj.subject + courseObj.number} className="my-2"> | ||
<CourseCard courseObj={courseObj} sortBy={sortBy} /> | ||
</div> | ||
))} | ||
</WindowVirtualizer> | ||
</div> | ||
</> | ||
); | ||
} |
Oops, something went wrong.