diff --git a/app/course/course-card.tsx b/app/course/course-card.tsx new file mode 100644 index 0000000..76376b1 --- /dev/null +++ b/app/course/course-card.tsx @@ -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 ( + setOpen(!open)} + > + + + #{rank}{" "} + + ({formattedScore}) + + +
+ + + + {subject}  + + + {number} + + + + + {samples} Reviews of {historicalInstructors.join("; ")} + +
+ + {letterGrade(percentile)} + +
+ + + +
+
+ Rating (Teaching): + {ratingTeaching.toFixed(3)} + + Rating (Workload): + {ratingWorkload.toFixed(3)} + + Rating (Content): + {ratingContent.toFixed(3)} + + Rating (Grading): + {ratingGrading.toFixed(3)} + + Overall Rating: + {score.toFixed(3)} + + Confidence: + {confidence.toFixed(3)} + + Percentile: + + {(percentile * 100).toFixed(1)}% + +
+
+ Instructors +
+ {instructors.map((it) => ( + + {it} + + ))} +
+ Historical Instructors +
+ {historicalInstructors.map( + (it) => + instructors.includes(it) || ( + + {it} + + ), + )} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/components/component/instructor-trend-chart.css b/app/course/course-trend-chart.css similarity index 100% rename from components/component/instructor-trend-chart.css rename to app/course/course-trend-chart.css diff --git a/app/course/course-trend-chart.tsx b/app/course/course-trend-chart.tsx new file mode 100644 index 0000000..0e3ed8e --- /dev/null +++ b/app/course/course-trend-chart.tsx @@ -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([]); + + const { scores } = props; + if (scores.length === 0) { + return
No score available
; + } + + 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 ( + [it.name as string, it.color])) + } + valueFormatter={valueFormatter} + /> + ); + }; + + return ( +
+ + + Content + + + Teaching + + + Grading + + + Workload + + + +
+ ); +} diff --git a/app/course/page.tsx b/app/course/page.tsx new file mode 100644 index 0000000..b3cd659 --- /dev/null +++ b/app/course/page.tsx @@ -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("bayesianScore"); + const [ratingWeights, setRatingWeights] = useState({ + ratingContent: 0.3, + ratingTeaching: 0.3, + ratingGrading: 0.3, + ratingWorkload: 0.1, + }); + + const result = searchCourses(query, sortBy, ratingWeights); + + return ( + <> + + +

+ UST Rankings +

+
+ ) => { + 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" + /> +
+ +
+ +
+ +
+ + {result.map((courseObj) => ( +
+ +
+ ))} +
+
+ + ); +} diff --git a/app/course/settings-card.tsx b/app/course/settings-card.tsx new file mode 100644 index 0000000..dba2607 --- /dev/null +++ b/app/course/settings-card.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Input, type InputProps } from "@/components/ui/input"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CourseRatingWeights, CourseSortBy } from "@/data/course"; +import { stopPropagation } from "@/lib/events"; +import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +import { type ChangeEvent, forwardRef } from "react"; + +export type SettingsCardProps = { + sortBy: CourseSortBy; + setSortBy: (sortBy: CourseSortBy) => void; + + formula: CourseRatingWeights; + setFormula: (ratingWeights: CourseRatingWeights) => void; +}; + +const NumInput = forwardRef((props, ref) => ( + +)); +NumInput.displayName = "NumInput"; + +export function SettingsCard(props: SettingsCardProps) { + const { sortBy, setSortBy } = props; + const { formula, setFormula } = props; + + return ( + + + + +
+ Sort by... +
+ + + + + + +

Sort the instructors by the selected criterion.

+
    +
  • + Score: The overall score of the course, calculated by the + formula below. +
  • +
  • Content: The rating of the content of the course.
  • +
  • Teaching: The rating of the teaching of the course.
  • +
  • Grading: The rating of the grading of the course.
  • +
  • Workload: The rating of the workload of the course.
  • +
  • + Abs: If not selected, the sorting will take the samples + count into consideration; otherwise, the sorting will only + take the value into consideration. +
  • +
+
+
+
+
+ + + Score + + Content + + + Teaching + + + Grading + + + Workload + + Score (Abs) + + Content (Abs) + + + Teaching (Abs) + + + Grading (Abs) + + + Workload (Abs) + + + +
+ + +
+ Score Formula... +
+ + + + + + +

The formula of the score of the course.

+

+ The preset formula is designed to reflect the score of the + course itself, rather than the difficulty. Therefore, the + content, teaching and grading are weighted more than the + workload. +

+
+
+
+
+ +
+ Score = + + ) => { + setFormula({ + ...formula, + ratingContent: e.target.valueAsNumber, + }); + }} + />{" "} + * Content{" "} + {" "} + + + + ) => { + setFormula({ + ...formula, + ratingTeaching: e.target.valueAsNumber, + }); + }} + />{" "} + * Teaching{" "} + {" "} + + + + ) => { + setFormula({ + ...formula, + ratingGrading: e.target.valueAsNumber, + }); + }} + />{" "} + * Grading{" "} + {" "} + + + + ) => { + setFormula({ + ...formula, + ratingWorkload: e.target.valueAsNumber, + }); + }} + />{" "} + * Workload{" "} + {" "} +
+
+
+
+
+ ); +} diff --git a/components/component/instructor-card.tsx b/app/instructor-card.tsx similarity index 71% rename from components/component/instructor-card.tsx rename to app/instructor-card.tsx index 7f70da6..aa491e8 100644 --- a/components/component/instructor-card.tsx +++ b/app/instructor-card.tsx @@ -1,5 +1,5 @@ -import { InstructorCourseLink } from "@/components/component/instructor-course-link"; -import { InstructorTrendChart } from "@/components/component/instructor-trend-chart"; +import { InstructorCourseLink } from "@/app/instructor-course-link"; +import { InstructorTrendChart } from "@/app/instructor-trend-chart"; import { Card, CardContent, @@ -12,7 +12,7 @@ import { type InstructorCourseObject, type InstructorObject, type SortBy, -} from "@/data"; +} from "@/data/instructor"; import { stopPropagation } from "@/lib/events"; import { naturalSort } from "@/lib/utils"; import React from "react"; @@ -98,9 +98,9 @@ export function InstructorCard({ instructorObj, sortBy }: InstructorCardProps) { const { courses, historicalCourses, rank, percentile } = instructorObj; const { - bayesianScore, score, samples, + confidence, ratingTeaching, ratingInstructor, ratingWorkload, @@ -110,8 +110,6 @@ export function InstructorCard({ instructorObj, sortBy }: InstructorCardProps) { const scoreFmt = (instructorObj.scores[0][sortBy] * 100).toFixed(1); - const coursesFmt = courses.map(formatCourse).sort(naturalSort); - const historicalCoursesFmt = historicalCourses .map(formatCourse) .sort(naturalSort); @@ -167,84 +165,56 @@ export function InstructorCard({ instructorObj, sortBy }: InstructorCardProps) { -
+
Rating (Teaching): {ratingTeaching.toFixed(3)} + Rating (Workload): {ratingWorkload.toFixed(3)} + Rating (Content): {ratingContent.toFixed(3)} + Rating (Grading): {ratingGrading.toFixed(3)} - Rating (Thumbs Up): + + Rating (Instructor): {ratingInstructor.toFixed(3)} + Overall Rating: {score.toFixed(3)} + + Confidence: + {confidence.toFixed(3)} + Percentile: {(percentile * 100).toFixed(1)}%
- - Courses{" "} - - (A = Available this Semester) - - + Courses
- {historicalCourses.map((it) => ( - + {courses.map((it) => ( + ))}
-
-
- -
-
- Rating (Teaching): - {ratingTeaching.toFixed(3)} - Rating (Thumbs Up): - - {ratingInstructor.toFixed(3)} - - Overall Rating: - {score.toFixed(3)} - Percentile: - - {(percentile * 100).toFixed(1)}% - -
-
- Courses - - (A = Available this Semester) - + Historical Courses
- {historicalCourses.map((it) => ( - - ))} + {historicalCourses.map( + (it) => + courses.map(formatCourse).includes(formatCourse(it)) || ( + + ), + )}
- - Instructor Details -
diff --git a/app/instructor-course-link.tsx b/app/instructor-course-link.tsx new file mode 100644 index 0000000..944e17e --- /dev/null +++ b/app/instructor-course-link.tsx @@ -0,0 +1,23 @@ +import { type InstructorCourseObject } from "@/data/instructor"; +import { stopPropagation } from "@/lib/events"; + +export function InstructorCourseLink({ + course, +}: { + course: InstructorCourseObject; +}) { + const linkUstSpace = `https://ust.space/review/${course.subject}${course.number}`; + + return ( + + + {course.subject} + {course.number} + + + ); +} diff --git a/app/instructor-trend-chart.css b/app/instructor-trend-chart.css new file mode 100644 index 0000000..3aac149 --- /dev/null +++ b/app/instructor-trend-chart.css @@ -0,0 +1,4 @@ +.recharts-surface { + /* Prevent X-Labels from Overflowing */ + overflow: visible; +} diff --git a/components/component/instructor-trend-chart.tsx b/app/instructor-trend-chart.tsx similarity index 98% rename from components/component/instructor-trend-chart.tsx rename to app/instructor-trend-chart.tsx index f24f57d..4507c5f 100644 --- a/components/component/instructor-trend-chart.tsx +++ b/app/instructor-trend-chart.tsx @@ -1,6 +1,6 @@ import "./instructor-trend-chart.css"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { type InstructorScoreObject } from "@/data"; +import { type InstructorScoreObject } from "@/data/instructor"; import { stopPropagation } from "@/lib/events"; import { AreaChart, type CustomTooltipProps } from "@tremor/react"; import ChartTooltip from "@tremor/react/dist/components/chart-elements/common/ChartTooltip"; diff --git a/app/layout.tsx b/app/layout.tsx index 5da66cc..3b7fd9c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -36,17 +36,29 @@ export default function RootLayout({ -
+
- FAQ + Instructor Rankings + + Course Rankings + + {/**/} + {/* FAQ*/} + {/**/} diff --git a/app/page.tsx b/app/page.tsx index 703995c..16410f8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,15 @@ "use client"; -import { InstructorCard } from "@/components/component/instructor-card"; +import { InstructorCard } from "@/app/instructor-card"; import { NewDomainBanner } from "@/components/component/new-domain-banner"; import { Input } from "@/components/ui/input"; -import { search, type SortBy } from "@/data"; +import { search, type SortBy } from "@/data/instructor"; import dynamic from "next/dynamic"; import React, { type ChangeEvent, useState } from "react"; import { WindowVirtualizer } from "virtua"; const SettingsCard = dynamic( - async () => - (await import("@/components/component/settings-card")).SettingsCard, + async () => (await import("@/app/settings-card")).SettingsCard, { ssr: false }, ); diff --git a/components/component/settings-card.tsx b/app/settings-card.tsx similarity index 97% rename from components/component/settings-card.tsx rename to app/settings-card.tsx index d35fb83..a5c46ab 100644 --- a/components/component/settings-card.tsx +++ b/app/settings-card.tsx @@ -14,10 +14,10 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { type RatingType, type SortBy } from "@/data"; +import { type RatingType, type SortBy } from "@/data/instructor"; import { stopPropagation } from "@/lib/events"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; -import { type ChangeEvent, forwardRef, useState } from "react"; +import { type ChangeEvent, forwardRef } from "react"; export type SettingsCardProps = { sortBy: SortBy; @@ -40,9 +40,7 @@ const NumInput = forwardRef((props, ref) => ( NumInput.displayName = "NumInput"; export function SettingsCard(props: SettingsCardProps) { - const [openSortBy, setOpenSortBy] = useState(false); const { sortBy, setSortBy } = props; - const [openFormula, setOpenFormula] = useState(false); const { formula, setFormula } = props; return ( diff --git a/components/component/instructor-course-link.tsx b/components/component/instructor-course-link.tsx deleted file mode 100644 index e92f518..0000000 --- a/components/component/instructor-course-link.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { type InstructorCourseObject } from "@/data"; - -export function InstructorCourseLink({ - course, - thisSem, -}: { - course: InstructorCourseObject; - thisSem: boolean; -}) { - const linkUstSpace = `https://ust.space/review/${course.subject}${course.number}`; - const linkClassScheduleQuota = `https://w5.ab.ust.hk/wcq/cgi-bin/2410/subject/${course.subject}#${course.subject}${course.number}`; - - return ( - { - e.stopPropagation(); - }} - > - - {course.subject} - {course.number} - -   - {thisSem && ( - - (A) - - )} - - ); -} diff --git a/data/course.ts b/data/course.ts new file mode 100644 index 0000000..a9a7e62 --- /dev/null +++ b/data/course.ts @@ -0,0 +1,134 @@ +import dataCourseJSON from "./data-course.json"; +import Fuse from "fuse.js"; +import _ from "lodash"; + +export type CourseScoreObject = { + subject: string; + number: string; + term: number; + + ratingContent: number; + ratingTeaching: number; + ratingGrading: number; + ratingWorkload: number; + samples: number; + confidence: number; + + individualRatingContent: number; + individualRatingTeaching: number; + individualRatingGrading: number; + individualRatingWorkload: number; + individualSamples: number; + individualConfidence: number; + + bayesianRatingContent: number; + bayesianRatingTeaching: number; + bayesianRatingGrading: number; + bayesianRatingWorkload: number; + + score: number; + bayesianScore: number; +}; + +export type CourseExtraObject = { + subject: string; + number: string; + terms: number[]; + instructors: string[]; + historicalInstructors: string[]; +}; + +export type CourseObject = { + rank: number; + percentile: number; + scores: CourseScoreObject[]; +} & CourseExtraObject; + +export const dataCourseObjects = dataCourseJSON as Record; +export const dataCourseKeys = Object.keys(dataCourseObjects); + +export type CourseSortBy = keyof Pick< + CourseScoreObject, + | "bayesianRatingContent" + | "bayesianRatingTeaching" + | "bayesianRatingGrading" + | "bayesianRatingWorkload" + | "ratingContent" + | "ratingTeaching" + | "ratingGrading" + | "ratingWorkload" + | "score" + | "bayesianScore" +>; + +export type CourseRatings = keyof Pick< + CourseScoreObject, + "ratingContent" | "ratingTeaching" | "ratingGrading" | "ratingWorkload" +>; + +export type CourseRatingWeights = Record; + +export function searchCourses( + query: string, + sortBy: CourseSortBy, + weights: CourseRatingWeights, +): CourseObject[] { + const sortedInstructorObjects = _.chain(dataCourseObjects) + .values() + // Calculate the score by the given formula + .map((courseObj) => ({ + ...courseObj, + scores: courseObj.scores.map((score) => ({ + ...score, + score: + weights.ratingContent * score.ratingContent + + weights.ratingTeaching * score.ratingTeaching + + weights.ratingGrading * score.ratingGrading + + weights.ratingWorkload * score.ratingWorkload, + bayesianScore: + weights.ratingContent * score.bayesianRatingContent + + weights.ratingTeaching * score.bayesianRatingTeaching + + weights.ratingGrading * score.bayesianRatingGrading + + weights.ratingWorkload * score.bayesianRatingWorkload, + })), + })) + // Sort the courses by the given criterion + .sortBy((instructor) => -instructor.scores[0][sortBy]) + // Assign the rank and percentile to each course + .map((instructorObj, i) => ({ + ...instructorObj, + rank: i + 1, + percentile: 1 - i / dataCourseKeys.length, + })) + // Create searching indices + .map((courseObj) => ({ + subject: courseObj.subject, + number: courseObj.number, + course: `${courseObj.subject} ${courseObj.number}`, + instructors: [ + ...courseObj.instructors.map((instructor) => `${instructor} (A)`), + ...courseObj.historicalInstructors.map((instructor) => `${instructor}`), + ...courseObj.instructors.flatMap((instructor) => + instructor.split(", "), + ), + ...courseObj.historicalInstructors.flatMap((instructor) => + instructor.split(", "), + ), + ], + obj: courseObj, + })) + .value(); + + if (query) { + const fuse = new Fuse(sortedInstructorObjects, { + keys: ["subject", "number", "course", "instructors"], + shouldSort: false, + useExtendedSearch: true, + threshold: 0.1, + }); + + return fuse.search(query).map((it) => it.item.obj); + } + + return sortedInstructorObjects.map((it) => it.obj); +} diff --git a/data/index.ts b/data/instructor.ts similarity index 100% rename from data/index.ts rename to data/instructor.ts diff --git a/package.json b/package.json index d0fe014..c3ed93a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ust-rankings", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "update-data": "node scripts/update-data.mjs", diff --git a/scripts/update-data.mjs b/scripts/update-data.mjs index 77203ca..c576823 100644 --- a/scripts/update-data.mjs +++ b/scripts/update-data.mjs @@ -15,4 +15,13 @@ async function updateInstructorData() { ); } -await Promise.all([updateInstructorData()]); +async function updateCourseData() { + await fs.writeFile( + "data/data-course.json", + await fetchText( + "https://raw.githubusercontent.com/ust-archive/ust-rankings-data/main/data-course.json", + ), + ); +} + +await Promise.all([updateInstructorData(), updateCourseData()]);