From 3a3e39ad7c68ca7c3ea105bdb26957a26b72fe40 Mon Sep 17 00:00:00 2001 From: TijnvdK Date: Sat, 19 Oct 2024 10:46:19 +0200 Subject: [PATCH] style (frontend): Restyle grade analyzer component --- .../comparison-view/comparison-view.tsx | 71 ++++ .../comparison-view/grade-correlation.tsx | 101 ++++++ .../graphs/correlation-graph.tsx | 27 ++ .../graphs/pass-rate-bar-graph.tsx | 40 +++ .../crystals/comparison-view/pass-rate.tsx | 66 ++++ .../pages/admin/analyzer/analyzer.tsx | 323 ++++++------------ .../admin/analyzer/select-comparison.tsx | 68 ++++ .../pages/admin/analyzer/tile-select.tsx | 57 ++++ 8 files changed, 529 insertions(+), 224 deletions(-) create mode 100644 IguideME.Web/Frontend/src/components/crystals/comparison-view/comparison-view.tsx create mode 100644 IguideME.Web/Frontend/src/components/crystals/comparison-view/grade-correlation.tsx create mode 100644 IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/correlation-graph.tsx create mode 100644 IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/pass-rate-bar-graph.tsx create mode 100644 IguideME.Web/Frontend/src/components/crystals/comparison-view/pass-rate.tsx create mode 100644 IguideME.Web/Frontend/src/components/pages/admin/analyzer/select-comparison.tsx create mode 100644 IguideME.Web/Frontend/src/components/pages/admin/analyzer/tile-select.tsx diff --git a/IguideME.Web/Frontend/src/components/crystals/comparison-view/comparison-view.tsx b/IguideME.Web/Frontend/src/components/crystals/comparison-view/comparison-view.tsx new file mode 100644 index 00000000..0e83b09c --- /dev/null +++ b/IguideME.Web/Frontend/src/components/crystals/comparison-view/comparison-view.tsx @@ -0,0 +1,71 @@ +import type { ReactElement } from 'react'; + +import { GradeCorrelation } from './grade-correlation'; +import { PassRate } from './pass-rate'; +import { useQuery } from '@tanstack/react-query'; +import type { CompareParams, CompareTitles } from '@/components/pages/admin/analyzer/analyzer'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { getCompareGrades } from '@/api/grades'; +import { Tabs, type TabsProps } from 'antd'; +import QueryLoading from '@/components/particles/QueryLoading'; + +export default function ComparisonView({ + compareParams, + compareTitles, +}: { + compareParams: CompareParams; + compareTitles: CompareTitles; +}): ReactElement { + const { + data: gradesA, + isError: isErrorA, + isLoading: isLoadingA, + } = useQuery({ + queryKey: ['g:' + Number(compareParams.tileAId) + compareParams.tileAType], + queryFn: async () => await getCompareGrades(Number(compareParams.tileAId), compareParams.tileAType), + enabled: Number(compareParams.tileAId) !== -1, + refetchOnWindowFocus: false, + }); + + const { + data: gradesB, + isError: isErrorB, + isLoading: isLoadingB, + } = useQuery({ + queryKey: ['g:' + compareParams.tileBId + compareParams.tileBType], + queryFn: async () => await getCompareGrades(Number(compareParams.tileBId), compareParams.tileBType), + enabled: Number(compareParams.tileBId) !== -1, + refetchOnWindowFocus: false, + }); + + const items: TabsProps['items'] = [ + { + key: 'correlation', + label: 'Grade correlation', + children: + isErrorA || isErrorB ? +
+ + Error: Failed to retrieve the grades. +
+ : , + }, + { + key: 'passRate', + label: 'Pass rate', + children: + isErrorA || isErrorB ? +
+ + Error: Failed to retrieve the grades. +
+ : , + }, + ]; + + return ( + + + + ); +} diff --git a/IguideME.Web/Frontend/src/components/crystals/comparison-view/grade-correlation.tsx b/IguideME.Web/Frontend/src/components/crystals/comparison-view/grade-correlation.tsx new file mode 100644 index 00000000..b3972dfc --- /dev/null +++ b/IguideME.Web/Frontend/src/components/crystals/comparison-view/grade-correlation.tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; + +import { CorrelationGraph } from './graphs/correlation-graph'; +import { varFixed, type UserGrade } from '@/types/grades'; +import type { CompareTitles } from '@/components/pages/admin/analyzer/analyzer'; +import { Card } from 'antd'; + +function CorrelationStatistics({ gradeData }: { gradeData: GradeData[] }): ReactElement { + let sumX = 0; + let sumY = 0; + let sumXY = 0; + let sumXX = 0; + let sumYY = 0; + let minX = 100; + let minY = 100; + let maxX = 0; + let maxY = 0; + + const dataCount = gradeData.length; + + gradeData.forEach(({ x, y }) => { + sumX += x; + sumY += y; + sumXY += x * y; + sumXX += x * x; + sumYY += y * y; + minX = minX > x ? x : minX; + minY = minY > y ? y : minY; + maxX = maxX < x ? x : maxX; + maxY = maxY < y ? y : maxY; + }); + + const correlationCoefficient = + (dataCount * sumXY - sumX * sumY) / + (Math.sqrt(dataCount * sumXX - sumX * sumX) * Math.sqrt(dataCount * sumYY - sumY * sumY)); + const averageX = sumX / dataCount; + const averageY = sumY / dataCount; + const stdDevX = Math.sqrt( + gradeData.reduce((sum, { x }) => sum + (x - averageX) * (x - averageX), 0) / (dataCount - 1), + ); + const stdDevY = Math.sqrt( + gradeData.reduce((sum, { y }) => sum + (y - averageY) * (y - averageY), 0) / (dataCount - 1), + ); + + return ( + Correlation statistics}> +
+

Correlation Coefficient: {correlationCoefficient.toFixed(5)}

+

Average X: {varFixed(averageX)}

+

Average Y: {varFixed(averageY)}

+

Data Count (size): {dataCount}

+

Standard Deviation X: {varFixed(stdDevX)}

+

Standard Deviation Y: {varFixed(stdDevY)}

+

Minimum X: {varFixed(minX)}

+

Minimum Y: {varFixed(minY)}

+

Maximum X: {varFixed(maxX)}

+

Maximum Y: {varFixed(maxY)}

+
+
+ ); +} + +interface GradeCorrelationProps { + gradesA: UserGrade[]; + gradesB: UserGrade[]; + compareTitles: CompareTitles; +} + +interface GradeData { + x: number; + y: number; +} + +function GradeCorrelation({ gradesA, gradesB, compareTitles }: GradeCorrelationProps): ReactElement { + const gradeData: GradeData[] = gradesA + .map((gradeA) => { + const correspondingGradeB = gradesB.find((gradeB) => gradeB.userID === gradeA.userID); + return correspondingGradeB ? { x: gradeA.grade, y: correspondingGradeB.grade } : undefined; + }) + .filter((gradePair) => gradePair !== undefined); + + return ( +
+
+ {gradeData.length === 0 ? + Correlation statistics} className='flex-1'> +

+ No comparison possible. This might be because one of the two tiles or entries have no grades. +

+
+ : } +
+
+ +
+
+ ); +} + +export type { GradeCorrelationProps, GradeData }; +export { GradeCorrelation }; diff --git a/IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/correlation-graph.tsx b/IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/correlation-graph.tsx new file mode 100644 index 00000000..59b1783b --- /dev/null +++ b/IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/correlation-graph.tsx @@ -0,0 +1,27 @@ +import type { ReactElement } from 'react'; +import { CartesianGrid, Label, ResponsiveContainer, Scatter, ScatterChart, XAxis, YAxis } from 'recharts'; +import type { GradeData } from '../grade-correlation'; +import type { CompareTitles } from '@/components/pages/admin/analyzer/analyzer'; + +export function CorrelationGraph({ + gradeData, + compareTitles, +}: { + gradeData: GradeData[]; + compareTitles: CompareTitles; +}): ReactElement { + return ( + + + + + + + + + + + ); +} diff --git a/IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/pass-rate-bar-graph.tsx b/IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/pass-rate-bar-graph.tsx new file mode 100644 index 00000000..90e8c9f2 --- /dev/null +++ b/IguideME.Web/Frontend/src/components/crystals/comparison-view/graphs/pass-rate-bar-graph.tsx @@ -0,0 +1,40 @@ +import type { UserGrade } from '@/types/grades'; +import type { ReactElement } from 'react'; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis } from 'recharts'; + +export function PassRateBarGraph({ grades }: { grades: UserGrade[] }): ReactElement { + const chartData = Array.from({ length: 11 }, (_, i) => ({ + grade: i, + failed: 0, + passed: 0, + })); + + grades.forEach(({ grade }) => { + const viewingGrade = Math.round(grade / 10); + const existingGrade = chartData.find((data) => data.grade === Math.round(viewingGrade)); + if (existingGrade) { + if (viewingGrade < 5.5) { + existingGrade.failed += 1; + } else { + existingGrade.passed += 1; + } + } else { + chartData.push({ + grade: viewingGrade, + failed: viewingGrade < 5.5 ? 1 : 0, + passed: viewingGrade >= 5.5 ? 1 : 0, + }); + } + }); + + return ( + + + + + + + + + ); +} diff --git a/IguideME.Web/Frontend/src/components/crystals/comparison-view/pass-rate.tsx b/IguideME.Web/Frontend/src/components/crystals/comparison-view/pass-rate.tsx new file mode 100644 index 00000000..99c6d070 --- /dev/null +++ b/IguideME.Web/Frontend/src/components/crystals/comparison-view/pass-rate.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; + +import { PassRateBarGraph } from './graphs/pass-rate-bar-graph'; +import type { GradeCorrelationProps } from './grade-correlation'; +import type { UserGrade } from '@/types/grades'; +import { Card } from 'antd'; + +function Statistics({ grades }: { grades: UserGrade[] }): ReactElement { + const sortedGrades = grades.sort((a, b) => a.grade - b.grade); + const averageGrade = sortedGrades.reduce((acc, grade) => acc + grade.grade, 0) / sortedGrades.length; + const passRate = (grades.filter((grade) => grade.grade >= 55).length / grades.length) * 100; + + return ( + Pass rate statistics}> + {grades.length === 0 ? +

No grades available

+ :
+

Minium grade: {sortedGrades[0]?.grade.toFixed(1)}

+

Average grade: {averageGrade.toFixed(1)}

+

Maximum grade: {sortedGrades[sortedGrades.length - 1]?.grade.toFixed(1)}

+

Pass rate: {passRate.toFixed(1)}%

+
+ } +
+ ); +} + +export function PassRate({ gradesA, gradesB, compareTitles }: GradeCorrelationProps): ReactElement { + return ( +
+ + {compareTitles.a.length === 0 ? + No title found + : compareTitles.a} + + } + className='flex-1' + > +
+ + +
+
+ + + {compareTitles.b.length === 0 ? + No title found + : compareTitles.b} + + } + className='flex-1' + > +
+ + +
+
+
+ ); +} diff --git a/IguideME.Web/Frontend/src/components/pages/admin/analyzer/analyzer.tsx b/IguideME.Web/Frontend/src/components/pages/admin/analyzer/analyzer.tsx index 7cc1693f..06db2330 100644 --- a/IguideME.Web/Frontend/src/components/pages/admin/analyzer/analyzer.tsx +++ b/IguideME.Web/Frontend/src/components/pages/admin/analyzer/analyzer.tsx @@ -1,256 +1,131 @@ -import { getCompareGrades } from '@/api/grades'; +import type { ReactElement } from 'react'; + import { getTiles } from '@/api/tiles'; +import { type Tile, TileType } from '@/types/tile'; import AdminTitle from '@/components/atoms/admin-titles/admin-titles'; -import Loading from '@/components/particles/loading'; -import { TileType } from '@/types/tile'; +import { Card } from 'antd'; +import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { SelectComparison } from './select-comparison'; +import ComparisonView from '@/components/crystals/comparison-view/comparison-view'; import { useQuery } from '@tanstack/react-query'; -import { Select } from 'antd'; -import { DefaultOptionType } from 'antd/es/select'; -import { Dispatch, SetStateAction, useState, type FC, type ReactElement } from 'react'; -import { CartesianGrid, Label, ResponsiveContainer, Scatter, ScatterChart, XAxis, YAxis } from 'recharts'; -import tailwindConfig from '@/../tailwind.config'; -import resolveConfig from 'tailwindcss/resolveConfig'; -import { varFixed } from '@/types/grades'; - -type ContentType = 'tile' | 'ass' | 'disc' | 'goal'; -interface SelectionData { - id: number; - type: ContentType; - title: string; +import QueryLoading from '@/components/particles/QueryLoading'; +import { useSearchParams } from 'react-router-dom'; + +interface CompareParams { + tileAType: string; + tileAId: string; + tileBType: string; + tileBId: string; } -interface SelectProps { - selected: SelectionData | undefined; - setSelected: Dispatch>; +interface CompareTitles { + a: string; + b: string; } -interface CompareProps { - selectedA: SelectionData | undefined; - selectedB: SelectionData | undefined; -} +function parseSearchParams(searchParams: URLSearchParams): CompareParams | undefined { + const a = searchParams.get('a'); + const b = searchParams.get('b'); + if (!a || !b) return undefined; -interface GradesProps { - data: Array<{ x: number; y: number }>; - titleA: string; - titleB: string; -} + const [tileAType, tileAId] = a.split('-'); + const [tileBType, tileBId] = b.split('-'); -const GradeAnalyzer: FC = (): ReactElement => { - const [selectedA, setSelectedA] = useState(); - const [selectedB, setSelectedB] = useState(); - return ( -
- -
-
- -
-
to
-
- -
-
- {selectedA && selectedB && } -
- ); -}; - -function Comparison({ selectedA, selectedB }: CompareProps) { - const { - data: gradesA, - isError: isErrorA, - isLoading: isLoadingA, - } = useQuery({ - queryKey: ['g:' + selectedA?.id + selectedA?.type], - queryFn: () => getCompareGrades(selectedA!.id, selectedA!.type), - enabled: selectedA?.id !== -1, - refetchOnWindowFocus: false, - }); - - const { - data: gradesB, - isError: isErrorB, - isLoading: isLoadingB, - } = useQuery({ - queryKey: ['g:' + selectedB?.id + selectedB?.type], - queryFn: () => getCompareGrades(selectedB!.id, selectedB!.type), - enabled: selectedB?.id !== -1, - refetchOnWindowFocus: false, - }); - - if (isErrorA || isErrorB) return 'TODO:'; - if (isLoadingA || isLoadingB || !gradesA || !gradesB) return <>; - - const data = gradesA - .map((a) => { - const b = gradesB.find((b) => b.userID === a.userID); - return b ? { x: a.grade, y: b.grade } : undefined; - }) - .filter((x) => x !== undefined); + if (!tileAType || !tileAId || !tileBType || !tileBId) { + return undefined; + } - return ( - <> - - - - ); + return { + tileAType, + tileAId, + tileBType, + tileBId, + }; } -function ComparisonStatistics({ data, titleA, titleB }: GradesProps) { - titleA; - titleB; - let sum_x = 0; - let sum_y = 0; - let sum_xy = 0; - let sum_xx = 0; - let sum_yy = 0; - let min_x = 100; - let min_y = 100; - let max_x = 0; - let max_y = 0; - const n = data.length; - data.forEach(({ x, y }) => { - sum_x += x; - sum_y += y; - sum_xy += x * y; - sum_xx += x * x; - sum_yy += y * y; - min_x = min_x > x ? x : min_x; - min_y = min_y > y ? y : min_y; - max_x = max_x < x ? x : max_x; - max_y = max_y < y ? y : max_y; - }); - const r = - (n * sum_xy - sum_x * sum_y) / (Math.sqrt(n * sum_xx - sum_x * sum_x) * Math.sqrt(n * sum_yy - sum_y * sum_y)); - const avg_x = sum_x / n; - const avg_y = sum_y / n; - const std_x = Math.sqrt(data.reduce((sum, { x }) => sum + (x - avg_x) * (x - avg_x), 0) / (n - 1)); - const std_y = Math.sqrt(data.reduce((sum, { y }) => sum + (y - avg_y) * (y - avg_y), 0) / (n - 1)); - - return ( - <> - r={r.toFixed(5)}, avg_x={varFixed(avg_x)}, avg_y={varFixed(avg_y)}, size = {n}, std_x = {varFixed(std_x)}, std_y ={' '} - {varFixed(std_y)}, min_x= {varFixed(min_x)}, min_y = {varFixed(min_y)}, max_x= {varFixed(max_x)}, max_y ={' '} - {varFixed(max_y)} - - ); -} -function ComparisonScatter({ data, titleA, titleB }: GradesProps) { - const fullConfig = resolveConfig(tailwindConfig); +function findTitle(tiles: Tile[], type: string, id: string): string { + if (type === 'tile') { + return tiles.find((tile) => tile.id === Number(id))?.title ?? ''; + } - return ( -
- - - - - - - - - - -
- ); - // + return tiles.reduce((acc, tile) => { + if ( + (tile.type === TileType.assignments && type !== 'ass') || + (tile.type === TileType.discussions && type !== 'dics') || + (tile.type === TileType.learning_outcomes && type !== 'goal') + ) { + return acc; + } + + const entry = tile.entries.find((e) => e.content_id === Number(id)); + return entry ? entry.title : acc; + }, ''); } -const SelectSource: FC = ({ selected, setSelected }): ReactElement => { +function GradeAnalyzer(): ReactElement { const { data: tiles, - isError: isTilesError, - isLoading: isTilesLoading, + isLoading, + isError, } = useQuery({ queryKey: ['tiles'], queryFn: getTiles, }); - if (isTilesError) { - return <>TODO; - } + const [searchParams] = useSearchParams(); - const onChange = (val: number, option: DefaultOptionType) => { - val; - setSelected(option.data); - }; + let titles: CompareTitles | undefined; + const compareParams = parseSearchParams(searchParams); + if (compareParams) { + const titleA = findTitle(tiles ?? [], compareParams.tileAType, compareParams.tileAId); + const titleB = findTitle(tiles ?? [], compareParams.tileBType, compareParams.tileBId); - const options: DefaultOptionType[] = [ - { - label: Tiles , - title: 'tiles', - key: 'g1', - options: tiles?.map((tile) => ({ - label: {tile.title}, - value: 'tile' + tile.id, - data: { id: tile.id, type: 'tile', title: tile.title }, - key: 'tile' + tile.id, - })), - }, - { - label: Entries , - title: 'entries', - key: 'g2', - options: tiles?.reduce((acc, tile) => { - tile.entries.forEach((entry) => { - if (acc.find((x) => x.value === entry.content_id)) { - return acc; - } - let data; - let key; - switch (tile.type) { - case TileType.assignments: - data = { type: 'ass', id: entry.content_id, title: entry.title }; - key = 'ass' + entry.content_id; - break; - case TileType.discussions: - data = { type: 'disc', id: entry.content_id, title: entry.title }; - key = 'disc' + entry.content_id; - break; - case TileType.learning_outcomes: - data = { type: 'goal', id: entry.content_id, title: entry.title }; - key = 'goal' + entry.content_id; - break; - } + titles = { + a: titleA, + b: titleB, + }; + } - acc.push({ - label: {entry.title}, - value: key, - key: key, - data: data, - }); - }); - return acc; - }, []), - }, - ]; + console.log('titles', titles); return ( <> - + + {tiles.map((tile) => ( + + ))} + + + + {tiles.reduce((acc, tile) => { + tile.entries.forEach((entry) => { + if (acc.some((x) => x.key === `key-${String(entry.content_id)}`)) return; + + let key = ''; + switch (tile.type) { + case TileType.assignments: + key = `ass-${String(entry.content_id)}`; + break; + case TileType.discussions: + key = `disc-${String(entry.content_id)}`; + break; + case TileType.learning_outcomes: + key = `goal-${String(entry.content_id)}`; + break; + default: + return; + } + + acc.push( + , + ); + }); + return acc; + }, [])} + + + ); +}