Skip to content

Commit

Permalink
feat: v1.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
Waver-Velvet committed Aug 9, 2024
1 parent 73080a7 commit 79f627b
Show file tree
Hide file tree
Showing 17 changed files with 817 additions and 112 deletions.
203 changes: 203 additions & 0 deletions app/course/course-card.tsx
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}&nbsp;
</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.
122 changes: 122 additions & 0 deletions app/course/course-trend-chart.tsx
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>
);
}
72 changes: 72 additions & 0 deletions app/course/page.tsx
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>
</>
);
}
Loading

0 comments on commit 79f627b

Please sign in to comment.