mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
44
Commits
3.16.2
...
chart-refresh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0fd3563f | ||
|
|
a929613e83 | ||
|
|
d64aca5285 | ||
|
|
9e1307595c | ||
|
|
0e9ba8490d | ||
|
|
50b4166a7f | ||
|
|
3843937429 | ||
|
|
b60b1dd88b | ||
|
|
ed513c14bf | ||
|
|
8e2b8e5443 | ||
|
|
bb215bb785 | ||
|
|
635fb97b25 | ||
|
|
a5334c8e2f | ||
|
|
18b8daabff | ||
|
|
b181fda7fc | ||
|
|
747c37697c | ||
|
|
76d8fef4eb | ||
|
|
743ccb6797 | ||
|
|
dab81f994d | ||
|
|
b17fa2a11d | ||
|
|
ea8a84420f | ||
|
|
56d98e16cd | ||
|
|
b9b90876f1 | ||
|
|
777a19b291 | ||
|
|
362930c481 | ||
|
|
8ca1636517 | ||
|
|
d6beafa13a | ||
|
|
f92d72b77a | ||
|
|
dca144d58b | ||
|
|
9a9f723b46 | ||
|
|
e6686c024a | ||
|
|
2aa6e9c549 | ||
|
|
898a2b1f1b | ||
|
|
6a9a49952b | ||
|
|
0f76e50e4b | ||
|
|
d9695901b2 | ||
|
|
b85edaf6f0 | ||
|
|
b165ece770 | ||
|
|
a90e82a31e | ||
|
|
651e37051d | ||
|
|
77b6d1cb16 | ||
|
|
afa6804d09 | ||
|
|
a57f2f01ca | ||
|
|
1b4bdff37e |
@@ -14,6 +14,7 @@ import {
|
||||
setFacultyProgressStats,
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { GraduationTarget } from '../services/graduationHelpers'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -137,7 +138,12 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
const universityData = {
|
||||
goals: { bachelor: 36, bcMsCombo: 60, master: 24, doctor: 48 },
|
||||
goals: {
|
||||
bachelor: GraduationTarget.THREE_YEARS,
|
||||
bcMsCombo: GraduationTarget.FIVE_YEARS,
|
||||
master: GraduationTarget.TWO_YEARS,
|
||||
doctor: GraduationTarget.FOUR_YEARS,
|
||||
},
|
||||
programmeNames: getProgrammeNames(allFaculties),
|
||||
byGradYear: {
|
||||
medians: {} as Record<string, LevelGraduationStats[]>,
|
||||
|
||||
Generated
+53
-61
@@ -19,13 +19,13 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lodash-es": "^4.17.23",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -37,7 +37,6 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1"
|
||||
@@ -1864,9 +1863,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1884,9 +1880,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1904,9 +1897,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1924,9 +1914,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2104,13 +2091,6 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -2120,16 +2100,6 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/highcharts": {
|
||||
"version": "5.0.44",
|
||||
"resolved": "https://registry.npmjs.org/@types/highcharts/-/highcharts-5.0.44.tgz",
|
||||
"integrity": "sha512-Rby1gj3GbK7zzsbK/g9T9hoybUfM+x0sBIXrOlTnj25TIvuTkLend3nKRLLz2upvLJNo36GntO6KmTFoVQXWZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
||||
@@ -2202,17 +2172,6 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-highcharts": {
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-highcharts/-/react-highcharts-16.0.7.tgz",
|
||||
"integrity": "sha512-xQSSWvZjZaK+j6/OBAR016KsC/cKptGbzxE4e6uSAuXpiz65PUAtSTpvT22nkvCR2DGx+5+faYwNkn2C/si4/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/highcharts": "^5.0.44",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
@@ -2539,6 +2498,30 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts-for-react": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
|
||||
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"size-sensor": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||
"react": "^15.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
@@ -2663,6 +2646,12 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2875,12 +2864,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highcharts": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/highcharts/-/highcharts-11.4.8.tgz",
|
||||
"integrity": "sha512-5Tke9LuzZszC4osaFisxLIcw7xgNGz4Sy3Jc9pRMV+ydm6sYqsPYdU8ELOgpzGNrbrRNDRBtveoR5xS3SzneEA==",
|
||||
"license": "https://www.highcharts.com/license"
|
||||
},
|
||||
"node_modules/highlight-words": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlight-words/-/highlight-words-2.0.0.tgz",
|
||||
@@ -3988,18 +3971,6 @@
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-highcharts": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-highcharts/-/react-highcharts-16.1.0.tgz",
|
||||
"integrity": "sha512-CHpCSMN96lXKeTIpx8UJPsUgeNeJqh81NN6cbzraiHQBiQz2mzXa5aaIWYbMEQ2NHhAEWU5uj5DPhZY1f1Rq+A==",
|
||||
"dependencies": {
|
||||
"highcharts": "^6.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "~0.14 || ^15.0.0 || ^16.0.0",
|
||||
"react-dom": "~0.14 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
||||
@@ -4240,6 +4211,12 @@
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/size-sensor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
||||
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@@ -4356,6 +4333,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
@@ -4594,6 +4577,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"highcharts": "^11.4.8",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"lodash-es": "^4.17.23",
|
||||
"material-react-table": "^3.2.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -40,16 +40,8 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/react-highcharts": "^16.0.7",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
"highcharts": "$highcharts",
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export const populationStatisticsToolTips = {
|
||||
creditAccumulation: `
|
||||
Opiskelijoiden kumulatiiviset opintopistekertymät opiskelijakohtaisina kuvaajina.
|
||||
Kun hiiren vie kuvaajien päälle, saa näkyviin jokaista kuvaajaa vastaavan opiskelijanumeron.
|
||||
Opiskelijanumeron yhteydessä olevaa ympyrää klikkaamalla siirtyy ”student statistics”-sivulle, ja näkee valitun opiskelijan opintotiedot.
|
||||
Yksittäisen opiskelijan kuvaajaa klikkaamalla siirtyy **student statistics**-sivulle, ja näkee valitun opiskelijan opintotiedot.
|
||||
Timantin muotoinen ikoni kertoo opiskelijan valmistuneen valitusta koulutusohjelmasta kyseisellä hetkellä.
|
||||
|
||||
X-akselin skaalaa voi säätää harmaista painikkeista ”1m”, ”3m”, ”6m”, ”YTD”, ”1y” ja ”ALL” tai kuvaajan alla olevan sinisen näkymän harmaita ”kahvoja” siirtämällä.
|
||||
Y-akselin skaalaa voi säätää harmaista painikkeista ”small” ”medium” ja "large”
|
||||
X-akselin skaalaa voi säätää kuvaajan alla olevaa liukusäädintä käyttäen.
|
||||
Taulukon korkeutta voi säätää viereisistä painikkeista (small, medium, large).
|
||||
`,
|
||||
creditStatistics: `
|
||||
**Credits gained**
|
||||
@@ -58,7 +58,7 @@ export const populationStatisticsToolTips = {
|
||||
**Distribution development**
|
||||
|
||||
Pylväsdiagrammi kertoo opiskelijoiden jakauman opintopistehaarukoihin kalenterivuosittain, lukuvuosittain tai lukukausittain eriteltynä.
|
||||
Välien järjestystä pylväässä voi muuttaa valitsemalla **Stack ordering** -valikosta haluamansa järjestyksen.
|
||||
Välien järjestystä pylväässä voi muuttaa valitsemalla **Chart settings** -valikosta haluamansa järjestyksen.
|
||||
|
||||
Pylväiden osat toimivat myös suodattimina. Klikkaamalla pylvään osaa näkyviin jäävät vain kyseiset opiskelijat (esimerkiksi keväällä 2024 0–30 opintopistettä suorittaneet).
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'
|
||||
import { maxBy, orderBy, range } from 'lodash-es'
|
||||
|
||||
import { GetTextIn } from '@/components/LanguagePicker/useLanguage'
|
||||
import { SemestersData } from '@/redux/semesters'
|
||||
import type { Absence } from '@/types/students'
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
@@ -87,23 +88,25 @@ export const getStudentGradeMeanWeightedByCredits = (student: FormattedStudent,
|
||||
return mean
|
||||
}
|
||||
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' } as const
|
||||
const studyModuleTexts = { en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' } as const
|
||||
|
||||
export const getTextInWithOpen = (
|
||||
course: { code: string; name: Name },
|
||||
getTextIn: any,
|
||||
getTextIn: GetTextIn,
|
||||
isOpenCourse: boolean,
|
||||
isStudyModuleCredit: boolean
|
||||
) => {
|
||||
if (!course) return ''
|
||||
const openUniTexts = { fi: 'Avoin yo', en: 'Open uni', sv: 'Öppna uni' }
|
||||
let courseName = getTextIn(course.name)
|
||||
if (
|
||||
isOpenCourse &&
|
||||
!Object.values(openUniTexts).some(text => courseName.toLowerCase().startsWith(text.toLowerCase()))
|
||||
!Object.values(openUniTexts).some(text => courseName?.toLowerCase().startsWith(text.toLowerCase()))
|
||||
) {
|
||||
courseName = `${getTextIn(openUniTexts)}: ${courseName}`
|
||||
}
|
||||
if (isStudyModuleCredit) {
|
||||
courseName += ` [${getTextIn({ en: 'Study module', fi: 'Opintokokonaisuus', sv: 'Studiehelhet' })}]`
|
||||
courseName += ` [${getTextIn(studyModuleTexts)}]`
|
||||
}
|
||||
courseName += ` (${course.code})`
|
||||
return courseName
|
||||
|
||||
@@ -6,7 +6,6 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider/L
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import updateLocale from 'dayjs/plugin/updateLocale'
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
|
||||
@@ -22,21 +21,6 @@ import { isProduction } from '@/conf'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
// TODO: What other options should be set globally?
|
||||
HighCharts.setOptions({
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
})
|
||||
|
||||
dayjsExtend(updateLocale)
|
||||
dayjs.updateLocale('en', {
|
||||
week: {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
export const BarChart = ({ id, graphStats, years }: { id: string; graphStats: GraphStat[]; years: number[] }) => {
|
||||
if (!graphStats || !id || !years) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = graphStats.map((graphSeries, index) => ({
|
||||
...graphSeries,
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
color: colors[index % colors.length],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
distance: 8,
|
||||
formatter: (params: { value?: unknown }) => formatNumber(params.value),
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: `oodikone_graduations_and_thesis_of_degree_programme_${id}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
left: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress="graduated-and-thesis-writers-of-the-programme-bar-chart">
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450, width: '100%' }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import dayjs from 'dayjs'
|
||||
import ReactECharts, { EChartsInstance } from 'echarts-for-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode, FormattedStudent } from '@oodikone/shared/types'
|
||||
import { InfoBox } from '../InfoBox/InfoBoxWithTooltip'
|
||||
import { getGraduationDataPoints, getGraduationsByCodes, getIncludedCourseCodesByProgrammeCodes } from './util'
|
||||
|
||||
const INITIAL_GRAPH_HEIGHT = 600
|
||||
|
||||
/**
|
||||
* Credit accumulation graph for **class statistics**
|
||||
*
|
||||
* @param students filteredStudents (size must be more than 0)
|
||||
* @param programmeCodes array of 1 or 2 (for combined programme) codes
|
||||
* @param studyPlanFilter whether hops filter is active
|
||||
* @param showBachelorAndMaster (optional)
|
||||
*/
|
||||
export const CreditAccumulationGraph = ({
|
||||
students,
|
||||
programmeCodes,
|
||||
studyPlanFilter,
|
||||
showBachelorAndMaster,
|
||||
}: {
|
||||
students: FormattedStudent[]
|
||||
programmeCodes: string[]
|
||||
studyPlanFilter: boolean
|
||||
showBachelorAndMaster?: boolean
|
||||
}) => {
|
||||
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
|
||||
const [cutStudyplanCredits, setCutStudyplanCredits] = useState(false)
|
||||
|
||||
// Is same for all students in a population
|
||||
const populationStudyStart = new Date(students[0].studyrightStart).getTime()
|
||||
const creditDateThreshold = useMemo(
|
||||
() =>
|
||||
studyPlanFilter && !cutStudyplanCredits
|
||||
? Math.min(...students.flatMap(s => s.courses.map(c => new Date(c.date).getTime())))
|
||||
: populationStudyStart,
|
||||
[studyPlanFilter, cutStudyplanCredits, students, populationStudyStart]
|
||||
)
|
||||
|
||||
const studyRightStartMarker = studyPlanFilter
|
||||
? [
|
||||
{
|
||||
name: 'Population study start',
|
||||
type: 'line',
|
||||
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#ff0000',
|
||||
type: 'dashed',
|
||||
width: 3,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: 'Population study start',
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
data: [{ xAxis: populationStudyStart }],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const creditPoints = useMemo(
|
||||
() =>
|
||||
students.map(student => {
|
||||
const includedCourseCodes = getIncludedCourseCodesByProgrammeCodes(student, programmeCodes)
|
||||
|
||||
const courses = student.courses
|
||||
.filter(c => {
|
||||
const courseCode = c.course_code ?? (c as { course?: { code?: string } }).course?.code
|
||||
|
||||
return (
|
||||
dayjs(c.date).isSameOrAfter(creditDateThreshold) &&
|
||||
!c.isStudyModuleCredit &&
|
||||
(studyPlanFilter ? !!courseCode && includedCourseCodes.has(courseCode) : true)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
let credits = 0
|
||||
const points: number[][] = []
|
||||
const markPointsForStudent: any[] = []
|
||||
const graduations = getGraduationsByCodes(student, programmeCodes, !!showBachelorAndMaster)
|
||||
|
||||
for (const c of courses) {
|
||||
if (c.credittypecode === CreditTypeCode.PASSED || c.credittypecode === CreditTypeCode.APPROVED) {
|
||||
credits += c.credits
|
||||
points.push([new Date(c.date).getTime(), credits])
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: students can graduate without having a single course in hops
|
||||
if (points.length) {
|
||||
graduations.forEach(grad => getGraduationDataPoints(points, markPointsForStudent, grad))
|
||||
points.sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
type: 'line',
|
||||
showSymbol: false,
|
||||
markPoint: markPointsForStudent.length
|
||||
? {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 20,
|
||||
data: markPointsForStudent,
|
||||
}
|
||||
: undefined,
|
||||
symbolSize: 8,
|
||||
symbol: 'emptyCircle',
|
||||
data: points,
|
||||
smooth: 0.1,
|
||||
emphasis: { focus: 'series' },
|
||||
sampling: 'lttb',
|
||||
}
|
||||
}),
|
||||
[creditDateThreshold, students, studyPlanFilter, programmeCodes, showBachelorAndMaster]
|
||||
)
|
||||
|
||||
const [chartInstance, setChartInstance] = useState<EChartsInstance | null>(null)
|
||||
const lastHighlightedRef = useRef<{ seriesIndex: number; dataIndex: number } | null>(null)
|
||||
|
||||
const handleChartReady = useCallback((chart: EChartsInstance) => {
|
||||
setChartInstance(chart)
|
||||
}, [])
|
||||
|
||||
const resetHighlight = useCallback((chart: any) => {
|
||||
const last = lastHighlightedRef.current
|
||||
if (last) {
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
|
||||
lastHighlightedRef.current = null
|
||||
}
|
||||
chart.dispatchAction({ type: 'hideTip' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartInstance
|
||||
if (!chart) return () => undefined
|
||||
|
||||
const zr = chart.getZr()
|
||||
|
||||
const handleMouseMove = (event: { offsetX?: number; offsetY?: number }) => {
|
||||
const offsetX = event?.offsetX
|
||||
const offsetY = event?.offsetY
|
||||
|
||||
if (offsetX == null || offsetY == null) return
|
||||
|
||||
if (!chart.containPixel('grid', [offsetX, offsetY])) {
|
||||
resetHighlight(chart)
|
||||
return
|
||||
}
|
||||
|
||||
let nearestSeriesIndex: number | null = null
|
||||
let nearestDataIndex: number | null = null
|
||||
let nearestDist2 = Number.POSITIVE_INFINITY
|
||||
|
||||
creditPoints.forEach((series, seriesIndex) => {
|
||||
if (!Array.isArray(series.data) || series.data.length === 0) return
|
||||
|
||||
series.data.forEach((point, dataIndex) => {
|
||||
if (!Array.isArray(point) || point.length < 2) return
|
||||
|
||||
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, point)
|
||||
if (!Array.isArray(pixel)) return
|
||||
|
||||
const dx = pixel[0] - offsetX
|
||||
const dy = pixel[1] - offsetY
|
||||
const dist2 = dx * dx + dy * dy
|
||||
|
||||
if (dist2 < nearestDist2) {
|
||||
nearestSeriesIndex = seriesIndex
|
||||
nearestDataIndex = dataIndex
|
||||
nearestDist2 = dist2
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (nearestSeriesIndex == null || nearestDataIndex == null) return
|
||||
|
||||
const next = { seriesIndex: nearestSeriesIndex, dataIndex: nearestDataIndex }
|
||||
|
||||
const last = lastHighlightedRef.current
|
||||
if (last && last.seriesIndex === next.seriesIndex && last.dataIndex === next.dataIndex) return
|
||||
|
||||
if (last) {
|
||||
chart.dispatchAction({ type: 'downplay', seriesIndex: last.seriesIndex, dataIndex: last.dataIndex })
|
||||
}
|
||||
|
||||
chart.dispatchAction({ type: 'highlight', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
|
||||
chart.dispatchAction({ type: 'showTip', seriesIndex: next.seriesIndex, dataIndex: next.dataIndex })
|
||||
lastHighlightedRef.current = next
|
||||
}
|
||||
|
||||
const handleGlobalOut = () => resetHighlight(chart)
|
||||
const handleClick = (event: { offsetX?: number; offsetY?: number }) => {
|
||||
const offsetX = event?.offsetX
|
||||
const offsetY = event?.offsetY
|
||||
|
||||
if (offsetX == null || offsetY == null) return
|
||||
if (!chart.containPixel('grid', [offsetX, offsetY])) return
|
||||
|
||||
const last = lastHighlightedRef.current
|
||||
if (!last) return
|
||||
|
||||
const studentNumber = creditPoints[last.seriesIndex]?.name
|
||||
if (!studentNumber) return
|
||||
|
||||
window.open(`/students/${studentNumber}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
zr.on('mousemove', handleMouseMove)
|
||||
zr.on('globalout', handleGlobalOut)
|
||||
zr.on('click', handleClick)
|
||||
|
||||
return () => {
|
||||
zr.off('mousemove', handleMouseMove)
|
||||
zr.off('globalout', handleGlobalOut)
|
||||
zr.off('click', handleClick)
|
||||
}
|
||||
}, [chartInstance, creditPoints, resetHighlight])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'none',
|
||||
position: (_point: unknown, params: { value?: [number, number] }) => {
|
||||
const value = params?.value
|
||||
if (!Array.isArray(value)) return null
|
||||
|
||||
const chart = chartInstance
|
||||
if (!chart) return null
|
||||
|
||||
const pixel = chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, value)
|
||||
if (!Array.isArray(pixel)) return null
|
||||
|
||||
return [pixel[0], pixel[1]]
|
||||
},
|
||||
formatter: (params: { seriesName?: string; value?: [number, number] }) => {
|
||||
const { value } = params
|
||||
if (!Array.isArray(value)) return params.seriesName ?? ''
|
||||
|
||||
const [timestamp, credits] = value
|
||||
const dateLabel = reformatDate(new Date(timestamp), DateFormat.DISPLAY_DATE)
|
||||
return `<b>${params.seriesName ?? ''}</b><br/>${dateLabel}: ${credits} cr`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0 },
|
||||
{ type: 'slider', xAxisIndex: 0 },
|
||||
],
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
splitLine: { show: true },
|
||||
scale: true,
|
||||
},
|
||||
series: [...creditPoints, ...studyRightStartMarker],
|
||||
animation: true,
|
||||
}),
|
||||
[chartInstance, creditPoints, studyRightStartMarker]
|
||||
)
|
||||
|
||||
const GraphSizeButton = ({ height, label }) => (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setGraphHeight(height)}
|
||||
variant={height === graphHeight ? 'contained' : 'outlined'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'space-between',
|
||||
mx: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<GraphSizeButton height={400} label="Small" />
|
||||
<GraphSizeButton height={INITIAL_GRAPH_HEIGHT} label="Medium" />
|
||||
<GraphSizeButton height={900} label="Large" />
|
||||
{studyPlanFilter ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={cutStudyplanCredits} onChange={e => setCutStudyplanCredits(e.target.checked)} />
|
||||
}
|
||||
label="Display credits from study right start"
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<InfoBox content={populationStatisticsToolTips.creditAccumulation} />
|
||||
</Stack>
|
||||
|
||||
<ReactECharts onChartReady={handleChartReady} option={option} style={{ height: graphHeight }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
|
||||
import { getOnEvents, getSeriesLabel } from './util'
|
||||
|
||||
|
||||
type GraduationBreakdownProps =
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
fullWidth: true
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
|
||||
yearLabel?: never
|
||||
handleClick?: never
|
||||
names?: never
|
||||
expandKey?: never
|
||||
}
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
handleClick: (seriesCategory: string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
|
||||
names?: never
|
||||
expandKey?: never
|
||||
fullWidth?: never
|
||||
}
|
||||
| {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
handleClick: (seriesCategory: string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
|
||||
expandKey: string | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
|
||||
fullWidth?: never
|
||||
}
|
||||
|
||||
export const GraduationBreakdown = ({
|
||||
cypress,
|
||||
data,
|
||||
fullWidth,
|
||||
handleClick,
|
||||
mode,
|
||||
names,
|
||||
expandKey,
|
||||
yearLabel,
|
||||
}: GraduationBreakdownProps) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
const categories = data.map(item => item.name)
|
||||
const codeMap: Record<string, string | undefined> = {}
|
||||
|
||||
if (expandKey) {
|
||||
for (const item of data) {
|
||||
codeMap[item.name || item.code!] = item.code
|
||||
}
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
if (data.length === 1) {
|
||||
return 250
|
||||
}
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
const name = names[code]
|
||||
if (typeof name === 'string') {
|
||||
return name
|
||||
}
|
||||
return name?.[language] ?? name?.fi ?? ''
|
||||
}
|
||||
|
||||
const getTooltipText = (id: string, seriesName: string, amount: number) => {
|
||||
if (expandKey) {
|
||||
const code = codeMap[id] ?? ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
return `<b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
|
||||
const barWidth = data.length > 8 ? 16 : 20
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: 'On time',
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
data: data.map(item => item.statistics.onTime),
|
||||
},
|
||||
{
|
||||
name: 'Max. year overtime',
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
data: data.map(item => item.statistics.yearOver),
|
||||
},
|
||||
{
|
||||
name: 'Overtime',
|
||||
color: theme.palette.graduationTimes.wayOver,
|
||||
data: data.map(item => item.statistics.wayOver),
|
||||
},
|
||||
].map(seriesItem => ({
|
||||
type: 'bar',
|
||||
name: seriesItem.name,
|
||||
barWidth,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
color: seriesItem.color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#424949',
|
||||
formatter: (params: { value?: number }) => {
|
||||
const value = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return value ? `${value}` : ''
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: expandKey ? `Year ${expandKey} by ${yearLabel?.toLowerCase()}` : '',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'white',
|
||||
formatter: (params: { name?: string; seriesName?: string; value?: number }) => {
|
||||
const id = params.name ?? ''
|
||||
const seriesName = params.seriesName ?? ''
|
||||
const amount = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return getTooltipText(id, seriesName, amount)
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
title: 'View table data',
|
||||
},
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-graduation-breakdown',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 20,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
name: 'Number of students',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 32,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
name: getSeriesLabel(expandKey, yearLabel, mode),
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
|
||||
<ReactECharts onEvents={getOnEvents(handleClick)} option={option} opts={{ renderer: 'svg' }} style={{ height: getHeight() }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
export const LineGraph = ({
|
||||
cypress,
|
||||
exportFileName,
|
||||
graphStats,
|
||||
years,
|
||||
}: {
|
||||
cypress: string
|
||||
exportFileName: string
|
||||
graphStats: GraphStat[]
|
||||
years: number[]
|
||||
}) => {
|
||||
if (!graphStats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = graphStats.map((graphStat, index) => {
|
||||
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
|
||||
|
||||
return {
|
||||
...graphStat,
|
||||
type: 'line',
|
||||
showSymbol: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
lineStyle: {
|
||||
color,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: exportFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-line-graph`}>
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStatistics,
|
||||
GraduationStats,
|
||||
Name,
|
||||
NameWithCode,
|
||||
ProgrammeClassSizes,
|
||||
} from '@oodikone/shared/types'
|
||||
import { getOnEvents, getSeriesLabel } from './util'
|
||||
|
||||
type GraduationPoint = GraduationStats & { color?: string; realGoal?: number }
|
||||
|
||||
export const MedianGraduations = ({
|
||||
classSizes,
|
||||
cypress,
|
||||
data,
|
||||
expandKey,
|
||||
fullWidth,
|
||||
goal,
|
||||
goalExceptions,
|
||||
handleClick,
|
||||
level,
|
||||
mode,
|
||||
names,
|
||||
title,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes?: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks' | 'programme']
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
expandKey?: string
|
||||
facultyGraph?: boolean
|
||||
fullWidth?: true,
|
||||
goal: number
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
handleClick: (seriesCategory: string) => void | undefined
|
||||
level?: string
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode>
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
let modData: GraduationPoint[] | null = null
|
||||
if (!expandKey && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
|
||||
// change colors for longer medicine goal times
|
||||
modData = JSON.parse(JSON.stringify(data))
|
||||
if (modData) {
|
||||
for (const item of modData) {
|
||||
if (item.code && Object.keys(goalExceptions).includes(item.code)) {
|
||||
const realGoal = goal + goalExceptions[item.code]
|
||||
if (item.median <= realGoal) {
|
||||
item.color = theme.palette.graduationTimes.onTime
|
||||
} else if (item.median <= realGoal + 12) {
|
||||
item.color = theme.palette.graduationTimes.yearOver
|
||||
} else {
|
||||
item.color = theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
item.realGoal = realGoal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max, { median }) => {
|
||||
return median > max ? median : max
|
||||
}, goal * 2)
|
||||
|
||||
const getClassSize = (category: string) => {
|
||||
return expandKey ? classSizes?.[category]?.[expandKey ?? ''] : classSizes?.[category]
|
||||
}
|
||||
|
||||
const getPercentage = (amount: number, category: string) => {
|
||||
const percent = Math.round((amount / getClassSize(category)) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, category: string) => {
|
||||
if (yearLabel === 'Start year' && title === 'Bachelor study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, category)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
return names[code]?.[language] ?? names[code]?.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
name: string,
|
||||
code: string,
|
||||
amount: number,
|
||||
median: number,
|
||||
statistics: GraduationStatistics | undefined,
|
||||
realGoal: number | undefined
|
||||
) => {
|
||||
const sortingText =
|
||||
yearLabel === 'Start year'
|
||||
? `<b>From class of ${!expandKey ? name : expandKey}, ${amount}/${getClassSize(code)} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${!expandKey ? name : expandKey}</b>`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} semesters</b><br />`
|
||||
const statisticsText = `<br />${statistics?.onTime} graduated on time<br />${statistics?.yearOver} graduated max year overtime<br />${statistics?.wayOver} graduated over year late`
|
||||
|
||||
if (expandKey) {
|
||||
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} semesters **</b></p>` : ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
|
||||
}
|
||||
return `${timeText}${statisticsText}`
|
||||
}
|
||||
|
||||
const resolveBarColor = (median: number, override?: string) => {
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
if (median <= goal) {
|
||||
return theme.palette.graduationTimes.onTime
|
||||
}
|
||||
if (median <= goal + 2) {
|
||||
return theme.palette.graduationTimes.yearOver
|
||||
}
|
||||
return theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
|
||||
const dataWithOverrides: GraduationPoint[] = modData ?? data
|
||||
const seriesData = dataWithOverrides.map(item => ({
|
||||
value: item.median,
|
||||
name: item.name.toString(),
|
||||
code: item.code ?? item.name,
|
||||
amount: item.amount,
|
||||
statistics: item.statistics,
|
||||
realGoal: item.realGoal,
|
||||
itemStyle: {
|
||||
color: resolveBarColor(item.median, item.color),
|
||||
},
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: expandKey ? `Year ${expandKey} by ${yearLabel.toLowerCase()}` : '',
|
||||
left: 'center',
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'white',
|
||||
confine: true,
|
||||
formatter: (params: {
|
||||
name?: string
|
||||
value?: number
|
||||
data?: {
|
||||
code?: string
|
||||
amount?: number
|
||||
statistics?: GraduationStatistics
|
||||
realGoal?: number
|
||||
name?: string
|
||||
}
|
||||
}) => {
|
||||
const chartData = params.data
|
||||
if (!chartData) {
|
||||
return ''
|
||||
}
|
||||
const name = params.name ?? chartData.name ?? ''
|
||||
const code = chartData.code ?? name
|
||||
const amount = chartData.amount ?? 0
|
||||
const median = typeof params.value === 'number' ? params.value : Number(params.value)
|
||||
return getTooltipText(name, code, amount, median, chartData.statistics, chartData.realGoal)
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 15,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-graduation-medians',
|
||||
},
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
name: 'Graduation time (semesters)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 32,
|
||||
minInterval: 1,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: seriesData.map(item => item.name),
|
||||
name: getSeriesLabel(expandKey, yearLabel, mode),
|
||||
nameLocation: 'start',
|
||||
nameGap: 12,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: seriesData,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideLeft',
|
||||
color: '#424949',
|
||||
formatter: (params: { name?: string; data?: { amount?: number; code?: string } }) => {
|
||||
const amount = params.data?.amount ?? 0
|
||||
const code = params.data?.code ?? params.name ?? ''
|
||||
return getDataLabel(amount, code)
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
xAxis: goal,
|
||||
lineStyle: {
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
{
|
||||
xAxis: goal + 2,
|
||||
lineStyle: {
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: fullWidth ? '100%' : '50%' }}>
|
||||
<ReactECharts onEvents={getOnEvents(handleClick)} option={option} opts={{ renderer: 'svg' }} style={{ height: getHeight() }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const formatPercentage = (value: number) => `${value.toFixed(1)}%`
|
||||
|
||||
type ProgressData = {
|
||||
id: string
|
||||
stats: {
|
||||
data: number[]
|
||||
name: string
|
||||
}[]
|
||||
years: string[]
|
||||
}
|
||||
|
||||
export const ProgressBarChart = ({ cypress, data }: { cypress: string; data: ProgressData }) => {
|
||||
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
|
||||
const stats = data.stats ?? []
|
||||
const seriesNamesKey = stats.map(stat => stat.name).join('|')
|
||||
|
||||
useEffect(() => {
|
||||
setLegendSelection({})
|
||||
}, [data.id, seriesNamesKey])
|
||||
|
||||
const seriesCount = stats.length
|
||||
const gradientColors = seriesCount > 1 ? generateGradientColors(seriesCount - 1) : []
|
||||
const colors = [...gradientColors, '#ddd']
|
||||
|
||||
const pointCount = Math.max(data.years?.length ?? 0, ...stats.map(series => series.data.length))
|
||||
const categories = Array.from({ length: pointCount }, (_, index) => data.years?.[index] ?? '')
|
||||
|
||||
const isSeriesVisible = (name: string) => legendSelection[name] !== false
|
||||
|
||||
const totals = Array.from({ length: pointCount }, (_, index) =>
|
||||
stats.reduce((sum, currentSeries) => {
|
||||
if (!isSeriesVisible(currentSeries.name)) {
|
||||
return sum
|
||||
}
|
||||
|
||||
return sum + getNumericValue(currentSeries.data[index])
|
||||
}, 0)
|
||||
)
|
||||
|
||||
const series = stats.map((graphSeries, index) => ({
|
||||
name: graphSeries.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color: colors[index],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (params: { data?: { percentage?: number } }) => {
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
|
||||
return formatPercentage(percentage)
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
},
|
||||
data: Array.from({ length: pointCount }, (_, pointIndex) => {
|
||||
const rawValue = getNumericValue(graphSeries.data[pointIndex])
|
||||
const total = totals[pointIndex]
|
||||
const percentage = isSeriesVisible(graphSeries.name) && total ? (rawValue / total) * 100 : 0
|
||||
|
||||
return {
|
||||
value: percentage,
|
||||
rawValue,
|
||||
percentage,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
|
||||
if (!params.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setLegendSelection(params.selected)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const option = {
|
||||
legend: {
|
||||
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { data?: { rawValue?: number; percentage?: number }; seriesName?: string }) => {
|
||||
const rawValue = getNumericValue(params.data?.rawValue)
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
|
||||
return `<b>${params.seriesName ?? ''}: ${rawValue}</b><br/>${formatPercentage(percentage)}`
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
top: -10,
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: `oodikone_progress_of_students_in_${data.id}_by_starting_year`,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 48,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
z: 10,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
inverse: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-progress-bar-chart`}>
|
||||
<ReactECharts notMerge onEvents={onEvents} option={option} opts={{ renderer: 'svg' }} style={{ height: 500 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
export const StackedBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
labels,
|
||||
exportFileName = 'chart',
|
||||
}: {
|
||||
cypress: string
|
||||
data?: { data: number[]; name: string }[]
|
||||
labels?: string[]
|
||||
exportFileName: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = data.map((graphSeries, index) => {
|
||||
const color = theme.palette.graphColors[index % theme.palette.graphColors.length]
|
||||
|
||||
return {
|
||||
...graphSeries,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value?: unknown }) => formatNumber(params.value),
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const option = {
|
||||
legend: {},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number }) => {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const value = getNumericValue(params.value)
|
||||
|
||||
const total = data.reduce((sum, currentSeries) => sum + getNumericValue(currentSeries.data[dataIndex]), 0)
|
||||
const percentage = total ? ((value / total) * 100).toFixed(1) : '0.0'
|
||||
|
||||
return `<b>${params.seriesName ?? ''}: ${percentage} %</b>`
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: exportFileName,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 10,
|
||||
top: 10,
|
||||
right: 10,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-stacked-bar-chart`}>
|
||||
<ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import type { Absence } from '@/types/students'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
import type { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
|
||||
import {
|
||||
createGoalLineData,
|
||||
filterCoursesByStudyPlan,
|
||||
getGraduationDataPoints,
|
||||
getStudentTransferMarkers,
|
||||
} from './util'
|
||||
|
||||
const INITIAL_GRAPH_HEIGHT = 700
|
||||
|
||||
type StudentCreditAccumulationProps = {
|
||||
student: StudentPageStudent
|
||||
absences: Absence[]
|
||||
startDate: Date
|
||||
endDate: number
|
||||
studyRightId?: string
|
||||
selectedStudyPlan?: {
|
||||
programme_code: string
|
||||
included_courses: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const GraphSizeButton = ({
|
||||
graphHeight,
|
||||
height,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
graphHeight: number
|
||||
height: number
|
||||
label: string
|
||||
onSelect: (height: number) => void
|
||||
}) => (
|
||||
<Button color="primary" onClick={() => onSelect(height)} variant={height === graphHeight ? 'contained' : 'outlined'}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const StudentCreditAccumulation = ({
|
||||
student,
|
||||
absences,
|
||||
startDate,
|
||||
endDate,
|
||||
studyRightId,
|
||||
selectedStudyPlan,
|
||||
}: StudentCreditAccumulationProps) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(INITIAL_GRAPH_HEIGHT)
|
||||
const hasStudyRights = student.studyRights.length > 0
|
||||
|
||||
const selectedStudyRight = useMemo(
|
||||
() => (studyRightId ? student.studyRights.find(studyRight => studyRight.id === studyRightId) : undefined),
|
||||
[student.studyRights, studyRightId]
|
||||
)
|
||||
|
||||
const earliestStudyRightStart = useMemo(() => {
|
||||
const scope = selectedStudyRight ? [selectedStudyRight] : student.studyRights
|
||||
const times = scope
|
||||
.map(studyRight => new Date(studyRight.startDate).getTime())
|
||||
.filter(time => Number.isFinite(time))
|
||||
|
||||
return times.length ? Math.min(...times) : undefined
|
||||
}, [selectedStudyRight, student.studyRights])
|
||||
|
||||
const pointData = useMemo(() => {
|
||||
const filteredCourses = filterCoursesByStudyPlan(student.courses, selectedStudyPlan)
|
||||
.filter(course => new Date(course.date).getTime() <= Date.now())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
let credits = 0
|
||||
const points: Array<{ value: [number, number]; tooltipHtml: string }> = []
|
||||
const graduationMarkPoints: Array<{ coord: number[]; name: string }> = []
|
||||
|
||||
for (const course of filteredCourses) {
|
||||
if (![CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(course.credittypecode)) continue
|
||||
|
||||
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
|
||||
credits += gainedCredits
|
||||
const x = new Date(course.date).getTime()
|
||||
const y = credits
|
||||
const courseCode = course.course?.code
|
||||
const courseName = course.course?.name ? getTextIn(course.course.name) : ''
|
||||
const tooltipHtml = [
|
||||
`<strong>${courseName}${courseCode ? ` (${courseCode})` : ''}</strong>`,
|
||||
`Credits: ${course.credits}`,
|
||||
`Grade: ${course.grade || '-'}`,
|
||||
`Date: ${reformatDate(course.date, DateFormat.DISPLAY_DATE)}`,
|
||||
course.isStudyModuleCredit ? '[Study Module]' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('<br/>')
|
||||
|
||||
points.push({
|
||||
value: [x, y],
|
||||
tooltipHtml,
|
||||
})
|
||||
}
|
||||
|
||||
const graduationData = (selectedStudyRight ? [selectedStudyRight] : student.studyRights)
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => !!element.graduated)
|
||||
.map(element => ({
|
||||
date: new Date(element.endDate).getTime(),
|
||||
label: `Graduated from ${getTextIn(element.name) ?? ''}`.trim(),
|
||||
}))
|
||||
.sort((a, b) => a.date - b.date)
|
||||
|
||||
if (points.length) {
|
||||
const numericalPoints = points.map(point => [point.value[0], point.value[1]])
|
||||
graduationData.forEach(graduation =>
|
||||
getGraduationDataPoints(numericalPoints, graduationMarkPoints, graduation.date)
|
||||
)
|
||||
|
||||
const pointLookup = new Map(points.map(point => [`${point.value[0]}-${point.value[1]}`, point]))
|
||||
const pointsWithGraduationNodes = numericalPoints.map(value => {
|
||||
const key = `${value[0]}-${value[1]}`
|
||||
return pointLookup.get(key) ?? { value: [value[0], value[1]] as [number, number], tooltipHtml: '' }
|
||||
})
|
||||
|
||||
return { points: pointsWithGraduationNodes, graduationMarkPoints, graduationData }
|
||||
}
|
||||
|
||||
return { points, graduationMarkPoints, graduationData }
|
||||
}, [student, selectedStudyPlan, selectedStudyRight, getTextIn])
|
||||
|
||||
const goalSeries = useMemo(() => {
|
||||
const studyRightElement = selectedStudyRight?.studyRightElements.find(
|
||||
element => element.code === selectedStudyPlan?.programme_code
|
||||
)
|
||||
|
||||
const [targetStart, targetEnd] = getStudyRightElementTargetDates(studyRightElement, absences)
|
||||
const graphStart = (targetStart ? new Date(targetStart) : new Date(earliestStudyRightStart ?? startDate)).getTime()
|
||||
const graphEnd = (targetEnd ? new Date(targetEnd) : new Date(endDate)).getTime()
|
||||
|
||||
const filteredAbsences = absences.filter(absence => {
|
||||
const absenceStart = new Date(absence.startDate).getTime()
|
||||
const absenceEnd = new Date(absence.endDate).getTime()
|
||||
return absenceStart <= graphEnd && absenceEnd >= graphStart
|
||||
})
|
||||
|
||||
return createGoalLineData(graphStart, graphEnd, filteredAbsences)
|
||||
}, [selectedStudyRight, selectedStudyPlan, absences, startDate, endDate, earliestStudyRightStart])
|
||||
|
||||
const xAxisMin = useMemo(() => {
|
||||
const defaultMin = new Date(startDate).getTime()
|
||||
if (!hasStudyRights) return defaultMin
|
||||
|
||||
const goalStart = goalSeries.points[0]?.[0]
|
||||
if (typeof goalStart !== 'number' || Number.isNaN(goalStart)) return defaultMin
|
||||
|
||||
return Math.min(defaultMin, goalStart)
|
||||
}, [startDate, hasStudyRights, goalSeries.points])
|
||||
|
||||
const transferMarkers = useMemo(
|
||||
() => getStudentTransferMarkers(student, text => getTextIn(text as never)),
|
||||
[student, getTextIn]
|
||||
)
|
||||
|
||||
const yAxisMax = useMemo(() => {
|
||||
const studentMax = pointData.points.reduce((max, point) => Math.max(max, point.value[1]), 0)
|
||||
const goalMax = hasStudyRights
|
||||
? goalSeries.points.reduce((max, [, credits]) => (Number.isFinite(credits) ? Math.max(max, credits) : max), 0)
|
||||
: 0
|
||||
|
||||
const maxCredits = Math.max(studentMax, goalMax)
|
||||
if (maxCredits <= 0) return undefined
|
||||
|
||||
return Math.ceil(maxCredits / 10) * 10
|
||||
}, [pointData.points, goalSeries.points, hasStudyRights])
|
||||
|
||||
const tooltipsByTimestamp = useMemo(() => {
|
||||
const byTimestamp = new Map<number, string[]>()
|
||||
|
||||
for (const point of pointData.points) {
|
||||
if (!point.tooltipHtml) continue
|
||||
const timestamp = point.value[0]
|
||||
const existing = byTimestamp.get(timestamp) ?? []
|
||||
existing.push(point.tooltipHtml)
|
||||
byTimestamp.set(timestamp, existing)
|
||||
}
|
||||
|
||||
return byTimestamp
|
||||
}, [pointData.points])
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
},
|
||||
formatter: (
|
||||
rawParams:
|
||||
| {
|
||||
seriesName?: string
|
||||
data?: { tooltipHtml?: string }
|
||||
value?: [number, number]
|
||||
}
|
||||
| Array<{
|
||||
seriesName?: string
|
||||
data?: { tooltipHtml?: string }
|
||||
value?: [number, number]
|
||||
}>
|
||||
) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
|
||||
const timestamp = params.find(param => param.value)?.value?.[0]
|
||||
if (typeof timestamp === 'number') {
|
||||
const tooltipsAtTimestamp = tooltipsByTimestamp.get(timestamp)
|
||||
if (tooltipsAtTimestamp?.length) {
|
||||
return tooltipsAtTimestamp.join('<br/><br/>')
|
||||
}
|
||||
}
|
||||
|
||||
const studentPoint = params.find(param => param.data?.tooltipHtml)
|
||||
if (studentPoint?.data?.tooltipHtml) return studentPoint.data.tooltipHtml
|
||||
|
||||
return ''
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], filterMode: 'none' },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: endDate,
|
||||
},
|
||||
{
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: endDate,
|
||||
show: false,
|
||||
axisPointer: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Credits',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: yAxisMax,
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: student.studentNumber,
|
||||
type: 'line',
|
||||
showSymbol: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
smooth: 0.1,
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
scale: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 11,
|
||||
itemStyle: {
|
||||
color: '#1976d2',
|
||||
borderColor: '#1976d2',
|
||||
},
|
||||
},
|
||||
data: pointData.points,
|
||||
markPoint: pointData.graduationMarkPoints.length
|
||||
? {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 20,
|
||||
data: pointData.graduationMarkPoints,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
...(hasStudyRights
|
||||
? [
|
||||
{
|
||||
name: 'Goal',
|
||||
type: 'line',
|
||||
xAxisIndex: 1,
|
||||
data: goalSeries.points,
|
||||
showSymbol: false,
|
||||
silent: true,
|
||||
tooltip: { show: false },
|
||||
lineStyle: {
|
||||
color: '#99d8c4',
|
||||
width: 2,
|
||||
},
|
||||
markArea: {
|
||||
silent: true,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideTop',
|
||||
color: '#1f2937',
|
||||
fontSize: 11,
|
||||
padding: [2, 4],
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.65)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
data: goalSeries.markAreas.map(area => [
|
||||
{
|
||||
name: area.label,
|
||||
xAxis: area.range[0].xAxis,
|
||||
itemStyle: {
|
||||
color: `${area.color}33`,
|
||||
borderColor: area.color,
|
||||
borderType: area.borderType,
|
||||
},
|
||||
},
|
||||
{ xAxis: area.range[1].xAxis },
|
||||
]),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Graduations and transfers',
|
||||
type: 'line',
|
||||
data: [],
|
||||
silent: true,
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
data: [
|
||||
...transferMarkers.map(marker => ({
|
||||
xAxis: marker.value,
|
||||
label: {
|
||||
formatter: marker.label,
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
lineStyle: { color: '#cbd128', type: 'dashed', width: 2 },
|
||||
})),
|
||||
...pointData.graduationMarkPoints.map(marker => ({
|
||||
xAxis: marker.coord[0],
|
||||
label: {
|
||||
formatter:
|
||||
pointData.graduationData.find(graduation => graduation.date === marker.coord[0])?.label ??
|
||||
'Graduation',
|
||||
position: 'insideEndTop',
|
||||
},
|
||||
lineStyle: { color: '#a333c8', type: 'dashed', width: 2 },
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
animation: true,
|
||||
}),
|
||||
[
|
||||
student.studentNumber,
|
||||
pointData,
|
||||
goalSeries,
|
||||
transferMarkers,
|
||||
endDate,
|
||||
tooltipsByTimestamp,
|
||||
hasStudyRights,
|
||||
xAxisMin,
|
||||
yAxisMax,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ mx: '0.5rem' }}>
|
||||
<GraphSizeButton graphHeight={graphHeight} height={400} label="Small" onSelect={setGraphHeight} />
|
||||
<GraphSizeButton
|
||||
graphHeight={graphHeight}
|
||||
height={INITIAL_GRAPH_HEIGHT}
|
||||
label="Medium"
|
||||
onSelect={setGraphHeight}
|
||||
/>
|
||||
<GraphSizeButton graphHeight={graphHeight} height={1000} label="Large" onSelect={setGraphHeight} />
|
||||
</Stack>
|
||||
<ReactECharts option={option} style={{ height: graphHeight }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import chunk from 'lodash-es/chunk'
|
||||
import groupBy from 'lodash-es/groupBy'
|
||||
import { useMemo } from 'react'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
import { type GetTextIn, useLanguage } from '../LanguagePicker/useLanguage'
|
||||
|
||||
dayjsExtend(isSameOrAfter)
|
||||
|
||||
type SeriesData = {
|
||||
x: number
|
||||
y: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
const semesterChunkify = (courses, semesters, getTextIn: GetTextIn): SeriesData[] => {
|
||||
const semesterChunks = courses.reduce((acc, curr) => {
|
||||
const semester = semesters.find(
|
||||
semester => dayjs(curr.date).isSameOrAfter(semester.startdate) && dayjs(curr.date).isBefore(semester.enddate)
|
||||
)
|
||||
const semesterData = acc.find(data => data.semester === semester.name)
|
||||
if (semesterData) {
|
||||
semesterData.data.push(curr)
|
||||
} else {
|
||||
acc.push({ data: [curr], semester: semester.name })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunks.reduce((acc, curr) => {
|
||||
const gradeSum = curr.data.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.data.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.data.length > 0)
|
||||
acc.push({
|
||||
name: getTextIn(curr.semester),
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr.data[curr.data.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return semesterMeans
|
||||
}
|
||||
|
||||
const gradeMeanSeries = (
|
||||
student: StudentPageStudent,
|
||||
chunksize: number,
|
||||
semesters: SemestersData['semesters'] | undefined,
|
||||
getTextIn: ReturnType<typeof useLanguage>['getTextIn']
|
||||
) => {
|
||||
const filteredCourses = student.courses.filter(
|
||||
course => !Number.isNaN(Number(course.grade)) && !course.isStudyModuleCredit && course.passed
|
||||
)
|
||||
|
||||
const coursesGroupedByDate = groupBy(filteredCourses, 'date')
|
||||
|
||||
const gradesAndMeans = Object.values(coursesGroupedByDate).reduce(
|
||||
(acc, courses) => {
|
||||
for (const course of courses) {
|
||||
acc.grades.push({
|
||||
grade: Number(course.grade),
|
||||
date: course.date.toString(),
|
||||
credits: course.credits,
|
||||
})
|
||||
// Weighted average: each grade is multiplied by the amount of credits the course is worth
|
||||
acc.totalGradeSum += Number(course.grade) * course.credits
|
||||
acc.totalCredits += course.credits
|
||||
}
|
||||
acc.mean.push({ y: acc.totalGradeSum / acc.totalCredits, x: new Date(courses[0].date).getTime() })
|
||||
return acc
|
||||
},
|
||||
{
|
||||
grades: [] as Array<{ grade: number; date: string; credits: number }>,
|
||||
mean: [] as SeriesData[],
|
||||
totalGradeSum: 0,
|
||||
totalCredits: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const size = Number(chunksize) ? chunksize : 3
|
||||
const chunks = chunk(gradesAndMeans.grades, size)
|
||||
|
||||
const groupMeans = chunks.reduce<SeriesData[]>((acc, curr) => {
|
||||
const gradeSum = curr.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.length > 0)
|
||||
acc.push({
|
||||
name: `${curr.length} courses between ${reformatDate(curr[0].date, DateFormat.DISPLAY_DATE)} and ${reformatDate(curr[curr.length - 1].date, DateFormat.DISPLAY_DATE)}`,
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr[curr.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunkify(gradesAndMeans.grades, Object.values(semesters ?? {}), getTextIn)
|
||||
|
||||
return {
|
||||
totalMeans: [{ data: gradesAndMeans.mean }],
|
||||
groupMeans: [{ data: groupMeans }],
|
||||
semesterMeans: [{ data: semesterMeans }],
|
||||
}
|
||||
}
|
||||
|
||||
export const StudentGradeGraphs = ({
|
||||
student,
|
||||
graphMode,
|
||||
groupSize,
|
||||
}: {
|
||||
student: StudentPageStudent
|
||||
graphMode: 'total' | 'group' | 'semester'
|
||||
groupSize: number
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? { semesters: {} }
|
||||
|
||||
const { totalMeans, groupMeans, semesterMeans } = useMemo(
|
||||
() => gradeMeanSeries(student, groupSize, allSemesters, getTextIn),
|
||||
[student, groupSize, allSemesters, getTextIn]
|
||||
)
|
||||
|
||||
const selectedSeries: SeriesData[] =
|
||||
graphMode === 'group' ? groupMeans[0].data : graphMode === 'semester' ? semesterMeans[0].data : totalMeans[0].data
|
||||
|
||||
const gradeOption = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
},
|
||||
formatter: (
|
||||
rawParams:
|
||||
| { data?: { x?: number; y?: number; name?: string } }
|
||||
| Array<{ data?: { x?: number; y?: number; name?: string } }>
|
||||
) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
const point = params.find(param => param.data)
|
||||
const data = point?.data
|
||||
if (!data) return ''
|
||||
const date = data.x ? reformatDate(new Date(data.x), DateFormat.DISPLAY_DATE) : ''
|
||||
const description = data.name ? `<br/>${data.name}` : ''
|
||||
return `${data.y?.toFixed(2) ?? ''}${date ? `<br/>${date}` : ''}${description}`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
width: '100%',
|
||||
left: 'left',
|
||||
top: 55,
|
||||
},
|
||||
toolbox: {
|
||||
left: 'left',
|
||||
feature: {
|
||||
dataZoom: {
|
||||
yAxisIndex: 'none',
|
||||
},
|
||||
restore: {},
|
||||
saveAsImage: {},
|
||||
},
|
||||
},
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
|
||||
{ type: 'slider', xAxisIndex: 0, filterMode: 'none' },
|
||||
],
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
position: 'right',
|
||||
min: 1,
|
||||
max: 5.1,
|
||||
splitLine: { show: true },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
smooth: 0.1,
|
||||
showSymbol: false,
|
||||
data: selectedSeries.map(point => ({
|
||||
value: [point.x, point.y],
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
name: point.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
animation: true,
|
||||
}),
|
||||
[selectedSeries]
|
||||
)
|
||||
|
||||
return <ReactECharts option={gradeOption} style={{ height: 420 }} />
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { FormattedStudent, Phase } from '@oodikone/shared/types'
|
||||
|
||||
type CourseWithCode = {
|
||||
course_code?: string
|
||||
course?: {
|
||||
code?: string
|
||||
}
|
||||
}
|
||||
|
||||
type StudyPlanLike = {
|
||||
included_courses: string[]
|
||||
}
|
||||
|
||||
export const getOnEvents = (handleClick: ((category: string) => void) | undefined) => (
|
||||
handleClick ? {
|
||||
click: (params: { name: string }) => {
|
||||
handleClick(params.name)
|
||||
},
|
||||
} : undefined
|
||||
)
|
||||
|
||||
const resolveCourseCode = (course: CourseWithCode) => course.course_code ?? course.course?.code
|
||||
|
||||
const toMillis = (date: Date | string) => new Date(date).getTime()
|
||||
|
||||
|
||||
/** expandKey is only provided to the expanded instance of a chart */
|
||||
export const getSeriesLabel = (expandKey: string | undefined | null, yearLabel: string | undefined, mode: string) => (
|
||||
expandKey ? `${mode.charAt(0).toUpperCase()}${mode.slice(1)}` : yearLabel
|
||||
)
|
||||
|
||||
const getEnrollmentLabel = (enrollmentType: number, statutoryAbsence: boolean) => {
|
||||
if (enrollmentType === 2) return statutoryAbsence ? 'Absent (statutory)' : 'Absent'
|
||||
if (enrollmentType === 3) return 'No enrollment'
|
||||
return 'No study right'
|
||||
}
|
||||
|
||||
const getTermLabel = (startDate: number) => {
|
||||
const date = new Date(startDate)
|
||||
const month = date.getMonth()
|
||||
const year = date.getFullYear()
|
||||
const season = month >= 7 ? 'Fall' : 'Spring'
|
||||
return `${season} ${year}`
|
||||
}
|
||||
|
||||
const getTermRangeLabel = (startDate: number, endTermDate: number) => {
|
||||
const startTerm = getTermLabel(startDate)
|
||||
const endTerm = getTermLabel(endTermDate)
|
||||
if (startTerm === endTerm) return startTerm
|
||||
return `${startTerm} - ${endTerm}`
|
||||
}
|
||||
|
||||
export const getIncludedCourseCodesByProgrammeCodes = (
|
||||
student: FormattedStudent,
|
||||
programmeCodes: string[]
|
||||
): Set<string> => {
|
||||
if (!programmeCodes) return new Set()
|
||||
const studyplans = student.studyplans.filter(studyplan => programmeCodes.includes(studyplan.programme_code))
|
||||
return new Set(studyplans.flatMap(studyplan => studyplan.included_courses))
|
||||
}
|
||||
|
||||
export const getGraduationsByCodes = (
|
||||
student: FormattedStudent,
|
||||
programmeCodes: string[],
|
||||
showBachelorAndMaster: boolean
|
||||
): number[] => {
|
||||
if (!programmeCodes) return []
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
?.studyRightElements.filter(({ graduated }) => !!graduated) ?? []
|
||||
)
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(({ graduated, code }) => !!graduated && programmeCodes.includes(code))
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export const filterCoursesByStudyPlan = <TCourse extends CourseWithCode>(
|
||||
courses: TCourse[],
|
||||
studyPlan?: StudyPlanLike
|
||||
): TCourse[] => {
|
||||
if (!studyPlan) return courses
|
||||
return courses.filter(course => {
|
||||
const code = resolveCourseCode(course)
|
||||
return !!code && studyPlan.included_courses.includes(code)
|
||||
})
|
||||
}
|
||||
|
||||
export const createGoalLineData = (
|
||||
graphStartDate: number,
|
||||
graphEndDate: number,
|
||||
absences: {
|
||||
startDate: Date | string
|
||||
endDate: Date | string
|
||||
enrollmenttype: number
|
||||
statutoryAbsence: boolean
|
||||
}[]
|
||||
) => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const getColor = (type: number) => {
|
||||
if (type === 2) return '#ffb300'
|
||||
if (type === 3) return '#e53935'
|
||||
return '#96d7c3'
|
||||
}
|
||||
|
||||
let totalAbsenceYears = 0
|
||||
const now = Date.now()
|
||||
type ClippedAbsence = {
|
||||
start: number
|
||||
end: number
|
||||
enrollmenttype: number
|
||||
statutoryAbsence: boolean
|
||||
labelStart: number
|
||||
labelEnd: number
|
||||
}
|
||||
|
||||
const relevantAbsences = absences
|
||||
.map(absence => {
|
||||
const start = toMillis(absence.startDate)
|
||||
const end = toMillis(absence.endDate)
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
||||
|
||||
const clippedStart = Math.max(start, graphStartDate)
|
||||
const clippedEndByGraph = Math.min(end, graphEndDate)
|
||||
const clippedEnd = absence.enrollmenttype === 3 ? Math.min(clippedEndByGraph, now) : clippedEndByGraph
|
||||
|
||||
if (clippedEnd <= clippedStart) return null
|
||||
|
||||
return {
|
||||
start: clippedStart,
|
||||
end: clippedEnd,
|
||||
enrollmenttype: absence.enrollmenttype,
|
||||
statutoryAbsence: absence.statutoryAbsence,
|
||||
labelStart: clippedStart,
|
||||
labelEnd: clippedStart,
|
||||
}
|
||||
})
|
||||
.filter((absence): absence is ClippedAbsence => absence !== null)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
|
||||
const mergedAbsences = relevantAbsences.reduce<ClippedAbsence[]>((merged, absence) => {
|
||||
const previous = merged[merged.length - 1]
|
||||
|
||||
if (
|
||||
previous &&
|
||||
previous.enrollmenttype === absence.enrollmenttype &&
|
||||
previous.statutoryAbsence === absence.statutoryAbsence &&
|
||||
absence.start <= previous.end + DAY_MS
|
||||
) {
|
||||
previous.end = Math.max(previous.end, absence.end)
|
||||
previous.labelEnd = absence.labelEnd
|
||||
return merged
|
||||
}
|
||||
|
||||
merged.push({ ...absence })
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
const points: number[][] = [[graphStartDate, 0]]
|
||||
const markAreas: Array<{
|
||||
range: [{ xAxis: number }, { xAxis: number }]
|
||||
color: string
|
||||
borderType: 'solid' | 'dashed'
|
||||
label: string
|
||||
}> = []
|
||||
|
||||
for (const absence of mergedAbsences) {
|
||||
const absenceStart = absence.start
|
||||
const absenceEnd = absence.end
|
||||
const targetCreditsBeforeAbsence =
|
||||
((absenceStart - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000) - totalAbsenceYears) * 60
|
||||
|
||||
const absenceInYears = (absenceEnd - absenceStart) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
totalAbsenceYears += absenceInYears
|
||||
|
||||
points.push([absenceStart, targetCreditsBeforeAbsence])
|
||||
points.push([absenceEnd, targetCreditsBeforeAbsence])
|
||||
const enrollmentLabel = getEnrollmentLabel(absence.enrollmenttype, absence.statutoryAbsence)
|
||||
const termLabel = getTermRangeLabel(absence.labelStart, absence.labelEnd)
|
||||
const text = `${enrollmentLabel}\n(${termLabel})`
|
||||
|
||||
markAreas.push({
|
||||
range: [{ xAxis: absenceStart }, { xAxis: absenceEnd }],
|
||||
color: getColor(absence.enrollmenttype),
|
||||
borderType: absence.statutoryAbsence ? 'dashed' : 'solid',
|
||||
label: text,
|
||||
})
|
||||
}
|
||||
|
||||
const yearsFromStart = (graphEndDate - graphStartDate) / (365.25 * 24 * 60 * 60 * 1000)
|
||||
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
|
||||
points.push([graphEndDate, endingCredits])
|
||||
points.sort((a, b) => a[0] - b[0])
|
||||
|
||||
return { points, markAreas }
|
||||
}
|
||||
|
||||
export const getStudentTransferMarkers = (
|
||||
student: Pick<FormattedStudent, 'studyRights'>,
|
||||
getTextIn: (text: unknown) => string | null | undefined
|
||||
): Array<{ value: number; label: string }> =>
|
||||
student.studyRights.reduce<Array<{ value: number; label: string }>>((transfers, studyRight) => {
|
||||
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.ANY)
|
||||
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === Phase.MASTER)
|
||||
|
||||
for (const programmes of [phase1Programmes, phase2Programmes]) {
|
||||
for (const element of programmes.slice(0, -1)) {
|
||||
transfers.push({
|
||||
value: new Date(element.startDate).getTime(),
|
||||
label: `Transfer to ${getTextIn(element.name) ?? ''}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return transfers
|
||||
}, [])
|
||||
|
||||
/** HACK: markPoint (in this case the graduation marker) needs a datapoint to attach to. This creates a synthetic data point
|
||||
to the time of graduation and attaches the markPoint to it (remember to sort the datapoints again) */
|
||||
export const getGraduationDataPoints = (
|
||||
dataPoints: number[][],
|
||||
markPoints: { coord: number[]; name: string }[],
|
||||
graduationDate: number
|
||||
) => {
|
||||
dataPoints.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const index = dataPoints.findIndex(point => point[0] > graduationDate)
|
||||
let y: number
|
||||
if (index <= 0) {
|
||||
y = graduationDate > (dataPoints.at(0)?.[0] ?? 0) ? dataPoints[dataPoints.length - 1][1] : dataPoints[0][1]
|
||||
} else {
|
||||
const yBefore = dataPoints[index - 1][1]
|
||||
const yAfter = dataPoints[index][1]
|
||||
y = Math.round((yBefore + yAfter) / 2)
|
||||
}
|
||||
|
||||
const coord = [graduationDate, y]
|
||||
dataPoints.push(coord)
|
||||
dataPoints.sort((a, b) => a[0] - b[0])
|
||||
markPoints.push({ coord, name: `Graduation` })
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
|
||||
const Tile = ({ children }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', fontWeight: 'bold' }}>{children}</div>
|
||||
)
|
||||
const TileValue = ({ children }) => <div style={{ fontSize: '1.25em' }}>{children}</div>
|
||||
|
||||
export const CreditGraphTooltip = ({ credits, date, grade, isStudyModuleCredit, courseCode, courseName }) => {
|
||||
const title = `${courseName} (${courseCode})`
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardHeader
|
||||
content={`${title}${isStudyModuleCredit ? ' [Study Module]' : ''}`}
|
||||
style={{ whiteSpace: 'normal' }}
|
||||
/>
|
||||
<CardContent style={{ display: 'grid', grid: '1fr 1fr 1fr', gridRowGap: '0.25rem' }}>
|
||||
<Tile>
|
||||
Credits <TileValue>{credits}</TileValue>
|
||||
</Tile>
|
||||
<Tile>
|
||||
Grade <TileValue>{grade}</TileValue>
|
||||
</Tile>
|
||||
<Tile>
|
||||
Date <TileValue>{date}</TileValue>
|
||||
</Tile>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
// @ts-check
|
||||
import Button from '@mui/material/Button'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import dayjs from 'dayjs'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import { flatten } from 'lodash-es'
|
||||
import { useRef, useState } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import ReactHighstock from 'react-highcharts/ReactHighstock'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { useDeepMemo } from '@/hooks/deepMemo'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { CreditGraphTooltip } from './CreditGraphTooltip'
|
||||
|
||||
exporting(ReactHighstock.Highcharts)
|
||||
exportData(ReactHighstock.Highcharts)
|
||||
|
||||
const SINGLE_GRAPH_GOAL_SERIES_NAME = 'Goal'
|
||||
|
||||
const createGraphOptions = ({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
tooltipFormatter,
|
||||
onPointClicked,
|
||||
startDate,
|
||||
endDate,
|
||||
graduations,
|
||||
transfers,
|
||||
studyRightStartLine,
|
||||
}) => {
|
||||
const tooltip = {
|
||||
shared: false,
|
||||
split: false,
|
||||
}
|
||||
|
||||
if (singleStudent) {
|
||||
Object.assign(tooltip, {
|
||||
formatter() {
|
||||
return tooltipFormatter(this)
|
||||
},
|
||||
useHTML: true,
|
||||
style: {
|
||||
all: 'unset',
|
||||
display: 'none',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
plotOptions: {
|
||||
series: {
|
||||
findNearestPointBy: 'xy',
|
||||
point: {
|
||||
events: {
|
||||
click() {
|
||||
onPointClicked(this)
|
||||
},
|
||||
},
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
title: { text: 'Credits' },
|
||||
min: 0,
|
||||
max: undefined,
|
||||
},
|
||||
xAxis: {
|
||||
ordinal: false,
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
plotLines: graduations
|
||||
.map(graduation => ({
|
||||
value: graduation.value,
|
||||
color: '#a333c8',
|
||||
width: 2,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: `Graduation ${graduation.studyright}`,
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
.concat(
|
||||
studyRightStartLine.map(value => ({
|
||||
value,
|
||||
color: 'red',
|
||||
width: 3,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: 'Population study start',
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
)
|
||||
.concat(
|
||||
transfers.map(transfer => ({
|
||||
value: transfer.value,
|
||||
color: '#cbd128',
|
||||
width: 2,
|
||||
dashStyle: 'dash',
|
||||
label: {
|
||||
text: `Transfer ${transfer.studyright}`,
|
||||
fontSize: 30,
|
||||
},
|
||||
}))
|
||||
),
|
||||
},
|
||||
rangeSelector: {
|
||||
selected: 5, // Set default zoom to 'All'
|
||||
},
|
||||
series: seriesData,
|
||||
chart: {
|
||||
height: graphHeight,
|
||||
},
|
||||
tooltip,
|
||||
accessibility: {
|
||||
// Accessibility module doesn't seem to work with Highstock, at least for large amounts of data
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const filterCoursesByStudyPlan = (plan, courses) => {
|
||||
if (!plan) {
|
||||
return courses
|
||||
}
|
||||
return courses.filter(({ course, course_code }) => {
|
||||
if (!course?.code && !course_code) {
|
||||
return false
|
||||
}
|
||||
return plan.included_courses.includes(course_code ?? course.code)
|
||||
})
|
||||
}
|
||||
|
||||
const filterCoursesByDate = (courses, date) => courses.filter(course => dayjs(course.date).isSameOrAfter(dayjs(date)))
|
||||
|
||||
const filterCourses = (student, singleStudent, byStudyPlanOfCode, cutStudyPlanCredits, startDate, studyrightid) => {
|
||||
if (byStudyPlanOfCode && cutStudyPlanCredits)
|
||||
return filterCoursesByDate(
|
||||
filterCoursesByStudyPlan(
|
||||
student.studyplans.find(
|
||||
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
|
||||
),
|
||||
student.courses
|
||||
),
|
||||
student.studyrightStart
|
||||
)
|
||||
if (byStudyPlanOfCode)
|
||||
return filterCoursesByStudyPlan(
|
||||
student.studyplans.find(
|
||||
plan => plan.programme_code === byStudyPlanOfCode && plan.sis_study_right_id === studyrightid
|
||||
),
|
||||
student.courses
|
||||
)
|
||||
if (singleStudent) return student.courses
|
||||
return filterCoursesByDate(student.courses, startDate)
|
||||
}
|
||||
|
||||
const singleStudentTooltipFormatter = (point, student, getTextIn) => {
|
||||
const targetCourse = student.courses.find(
|
||||
({ course, date }) => point.key === course.code && point.x === new Date(date).getTime()
|
||||
)
|
||||
|
||||
if (!targetCourse.course) return null
|
||||
|
||||
const payload = {
|
||||
...targetCourse,
|
||||
courseCode: targetCourse.course.code,
|
||||
courseName: getTextIn(targetCourse.course.name),
|
||||
date: reformatDate(targetCourse.date, DateFormat.DISPLAY_DATE),
|
||||
}
|
||||
|
||||
return renderToString(<CreditGraphTooltip {...payload} />)
|
||||
}
|
||||
|
||||
const createGoalSeries = (graphStartDate, graphEndDate, absences) => {
|
||||
const getColor = type => {
|
||||
if (type === 2) return '#ffb300'
|
||||
if (type === 3) return '#e53935'
|
||||
return '#96d7c3'
|
||||
}
|
||||
|
||||
const absenceIsBetweenGraphDates = (startDate, endDate) =>
|
||||
(graphStartDate <= startDate && startDate <= graphEndDate) || (graphStartDate <= endDate && endDate <= graphEndDate)
|
||||
|
||||
let totalAbsenceYears = 0
|
||||
const absencePoints = absences
|
||||
.filter(a =>
|
||||
a.enrollmenttype === 3
|
||||
? absenceIsBetweenGraphDates(a.startDate, a.endDate) && a.startDate <= new Date()
|
||||
: absenceIsBetweenGraphDates(a.startDate, a.endDate)
|
||||
)
|
||||
.reduce((res, { startDate, endDate, enrollmenttype, statutoryAbsence }) => {
|
||||
const targetCreditsBeforeAbsence =
|
||||
(dayjs(startDate).diff(dayjs(graphStartDate), 'years', true) - totalAbsenceYears) * 60
|
||||
|
||||
const absenceInYears = dayjs(endDate).diff(dayjs(startDate), 'years', true)
|
||||
totalAbsenceYears += absenceInYears
|
||||
|
||||
res.push([startDate.getTime(), targetCreditsBeforeAbsence, null, null])
|
||||
res.push([endDate.getTime(), targetCreditsBeforeAbsence, enrollmenttype, statutoryAbsence])
|
||||
return res
|
||||
}, [])
|
||||
|
||||
const zones = []
|
||||
for (const [start, , enrollmenttype, statutoryAbsence] of absencePoints) {
|
||||
zones.push({
|
||||
value: start,
|
||||
color: getColor(enrollmenttype),
|
||||
dashStyle: statutoryAbsence ? 'Dash' : 'Solid',
|
||||
})
|
||||
}
|
||||
zones.push({ color: '#96d7c3' })
|
||||
|
||||
const yearsFromStart = dayjs(graphEndDate).diff(dayjs(graphStartDate), 'years', true)
|
||||
const endingCredits = (yearsFromStart - totalAbsenceYears) * 60
|
||||
const data = [[graphStartDate, 0], ...absencePoints, [graphEndDate, endingCredits]].sort((a, b) => a[0] - b[0])
|
||||
|
||||
return {
|
||||
name: SINGLE_GRAPH_GOAL_SERIES_NAME,
|
||||
data,
|
||||
enableMouseTracking: false,
|
||||
zones,
|
||||
zoneAxis: 'x',
|
||||
}
|
||||
}
|
||||
|
||||
const filterGraduations = (student, selectedStudyRight, getTextIn) => {
|
||||
const graduations = student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(element => element.graduated)
|
||||
if (!selectedStudyRight)
|
||||
return graduations.map(({ endDate, name }) => ({
|
||||
value: new Date(endDate).getTime(),
|
||||
studyright: getTextIn(name),
|
||||
}))
|
||||
const selectedGraduation = graduations.filter(({ studyRightId }) => studyRightId === selectedStudyRight.id)
|
||||
if (selectedGraduation.length === 0) return []
|
||||
return selectedGraduation.map(({ endDate, name }) => ({
|
||||
value: new Date(endDate).getTime(),
|
||||
studyright: getTextIn(name),
|
||||
}))
|
||||
}
|
||||
|
||||
const addGraduation = (points, graduationDate, notFirst) => {
|
||||
const index = points.findIndex(point => point.x > graduationDate)
|
||||
let graduationY
|
||||
if (index <= 0) {
|
||||
graduationY = graduationDate > points[0].x ? points[points.length - 1].y : points[0].y
|
||||
} else {
|
||||
const yBefore = points[index - 1].y
|
||||
const yAfter = points[index].y
|
||||
graduationY = Math.round((yBefore + yAfter) / 2)
|
||||
}
|
||||
|
||||
const marker = {
|
||||
radius: 10,
|
||||
symbol: 'diamond',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const masterMarker = {
|
||||
radius: 12,
|
||||
symbol: 'circle',
|
||||
lineWidth: 2,
|
||||
lineColor: '#ffffff',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
points.push({
|
||||
x: graduationDate,
|
||||
y: graduationY,
|
||||
marker: notFirst ? masterMarker : marker,
|
||||
})
|
||||
points.sort((a, b) => a.x - b.x)
|
||||
}
|
||||
|
||||
const findGraduationsByCodes = (student, programmeCodes, showBachelorAndMaster) => {
|
||||
if (showBachelorAndMaster) {
|
||||
return (
|
||||
student.studyRights
|
||||
.find(studyRight => studyRight.studyRightElements.some(element => programmeCodes.includes(element.code)))
|
||||
?.studyRightElements.filter(({ graduated }) => graduated === true) ?? []
|
||||
)
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return student.studyRights
|
||||
.flatMap(studyRight => studyRight.studyRightElements)
|
||||
.filter(({ graduated, code }) => graduated === true && programmeCodes.includes(code))
|
||||
.map(({ endDate }) => new Date(endDate).getTime())
|
||||
.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
const createStudentCreditLines = (
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster
|
||||
) =>
|
||||
students.map(student => {
|
||||
const { studyrightStart } = student
|
||||
const code = selectedStudyPlan?.programme_code
|
||||
const studyPlanProgrammeCode = singleStudent
|
||||
? code
|
||||
: studyPlanFilterIsActive && programmeCodes?.length > 0 && programmeCodes[0]
|
||||
|
||||
const { points } = filterCourses(
|
||||
student,
|
||||
singleStudent,
|
||||
studyPlanProgrammeCode,
|
||||
cutStudyPlanCredits,
|
||||
studyrightStart,
|
||||
studyRightId
|
||||
)
|
||||
.filter(({ date }) => new Date(date) <= new Date())
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.reduce(
|
||||
(acc, course) => {
|
||||
// Only include passed and transferred courses
|
||||
if (![4, 9].includes(course.credittypecode)) return acc
|
||||
|
||||
const gainedCredits = !course.isStudyModuleCredit ? course.credits : 0
|
||||
|
||||
const point = {
|
||||
x: new Date(course.date).getTime(),
|
||||
y: acc.credits + gainedCredits,
|
||||
}
|
||||
|
||||
if (singleStudent) point.name = course.course.code
|
||||
|
||||
acc.points.push(point)
|
||||
|
||||
return {
|
||||
...acc,
|
||||
credits: acc.credits + gainedCredits,
|
||||
}
|
||||
},
|
||||
{ credits: 0, points: [] }
|
||||
)
|
||||
|
||||
const graduationDates = programmeCodes ? findGraduationsByCodes(student, programmeCodes, showBachelorAndMaster) : []
|
||||
|
||||
if (points?.length > 0) {
|
||||
if (!singleStudent && points[0].y !== 0 && students.length < 100) {
|
||||
const xMinusTwoMonths = dayjs(new Date(points[0].x)).subtract(2, 'months').toDate().getTime()
|
||||
points.unshift({
|
||||
x: studyPlanFilterIsActive ? xMinusTwoMonths : Math.max(new Date(studyrightStart).getTime(), xMinusTwoMonths),
|
||||
y: 0,
|
||||
})
|
||||
}
|
||||
graduationDates.forEach((graduationDate, index) => addGraduation(points, graduationDate, index > 0))
|
||||
}
|
||||
|
||||
return {
|
||||
name: student.studentNumber,
|
||||
data: points,
|
||||
seriesThreshold: 150,
|
||||
marker: {
|
||||
enabled: !!singleStudent,
|
||||
radius: 4,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const filterTransfers = (student, getTextIn) =>
|
||||
student.studyRights.reduce((transfers, studyRight) => {
|
||||
const phase1Programmes = studyRight.studyRightElements.filter(element => element.phase === 1)
|
||||
const phase2Programmes = studyRight.studyRightElements.filter(element => element.phase === 2)
|
||||
for (const programmes of [phase1Programmes, phase2Programmes]) {
|
||||
// The oldest study right element is ignored, as there can be no transfer to it
|
||||
for (const element of programmes.toSpliced(-1)) {
|
||||
transfers.push({
|
||||
value: new Date(element.startDate),
|
||||
studyright: `to ${getTextIn(element.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return transfers
|
||||
}, [])
|
||||
|
||||
export const CreditAccumulationGraphHighCharts = ({
|
||||
students,
|
||||
singleStudent,
|
||||
absences,
|
||||
startDate,
|
||||
endDate,
|
||||
studyRightId,
|
||||
programmeCodes,
|
||||
customPopulation = false,
|
||||
studyPlanFilterIsActive,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster,
|
||||
infoBoxContent = '',
|
||||
}) => {
|
||||
const chartRef = useRef()
|
||||
const { getTextIn } = useLanguage()
|
||||
const [graphHeight, setGraphHeight] = useState(700)
|
||||
const [cutStudyPlanCredits, setCutStudyPlanCredits] = useState(false)
|
||||
const selectedStudyRight =
|
||||
singleStudent && studyRightId ? students[0].studyRights.find(({ id }) => id === studyRightId) : null
|
||||
|
||||
const seriesData = useDeepMemo(
|
||||
() =>
|
||||
createStudentCreditLines(
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster
|
||||
),
|
||||
[
|
||||
students,
|
||||
singleStudent,
|
||||
studyRightId,
|
||||
studyPlanFilterIsActive,
|
||||
cutStudyPlanCredits,
|
||||
programmeCodes,
|
||||
selectedStudyPlan,
|
||||
showBachelorAndMaster,
|
||||
]
|
||||
)
|
||||
|
||||
if (singleStudent) {
|
||||
const studyPlanProgrammeCode = selectedStudyPlan ? selectedStudyPlan.programme_code : null
|
||||
const correctStudyRightElement = selectedStudyRight?.studyRightElements.find(
|
||||
element => element.code === studyPlanProgrammeCode
|
||||
)
|
||||
|
||||
const startDates = selectedStudyRight
|
||||
? selectedStudyRight.studyRightElements
|
||||
.filter(e => e.phase === correctStudyRightElement.phase)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
||||
.map(e => e.startDate)
|
||||
: (students[0].studyRights ?? students[0].courses ?? []).map(e => e.startDate ?? e.date)
|
||||
|
||||
const firstDate = startDates.length
|
||||
? startDates.map(date => new Date(date)).sort((a, b) => a.getTime() - b.getTime())[0]
|
||||
: new Date()
|
||||
|
||||
const startDate = firstDate.getTime()
|
||||
|
||||
const [, studyRightTargetEnd] = getStudyRightElementTargetDates(
|
||||
selectedStudyRight?.studyRightElements
|
||||
.filter(element => element.phase === correctStudyRightElement.phase)
|
||||
.sort(({ startDate: a }, { startDate: b }) => new Date(a).getTime() - new Date(b).getTime())
|
||||
.at(0),
|
||||
absences
|
||||
)
|
||||
const ending = selectedStudyRight ? new Date(studyRightTargetEnd) : new Date(endDate ?? new Date())
|
||||
const starting = new Date(startDate)
|
||||
const filteredAbsences = selectedStudyRight
|
||||
? absences.filter(({ startDate, endDate }) => startDate >= starting && endDate <= ending)
|
||||
: absences
|
||||
|
||||
seriesData.push(createGoalSeries(starting.getTime(), ending.getTime(), filteredAbsences))
|
||||
}
|
||||
const getStudyRightStart = () => {
|
||||
const studyRightStartFromStudent = new Date(students[0]?.studyrightStart ?? new Date())
|
||||
if (studyRightStartFromStudent.getFullYear() < 2000)
|
||||
return Math.min(...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime()))))
|
||||
return studyRightStartFromStudent.getTime()
|
||||
}
|
||||
|
||||
const getGraphStart = () => {
|
||||
if (startDate) return new Date(startDate).getTime()
|
||||
if (customPopulation)
|
||||
return Math.min(
|
||||
...flatten(
|
||||
students.map(({ courses }) => {
|
||||
return courses.map(({ date }) => new Date(date).getTime())
|
||||
})
|
||||
)
|
||||
)
|
||||
const studyRightStart = getStudyRightStart()
|
||||
if (studyPlanFilterIsActive && cutStudyPlanCredits) return studyRightStart
|
||||
if (studyPlanFilterIsActive) {
|
||||
// math.min may return a infinite value, if beginning of course credits is selected and student's courses are filtered accordingly.
|
||||
const startPoint = Math.min(
|
||||
...flatten(students.map(({ courses }) => courses.map(({ date }) => new Date(date).getTime())))
|
||||
)
|
||||
return startPoint !== Infinity ? startPoint : studyRightStart
|
||||
}
|
||||
return studyRightStart
|
||||
}
|
||||
|
||||
const graduations = singleStudent ? filterGraduations(students[0], selectedStudyRight, getTextIn) : []
|
||||
const transfers = singleStudent ? filterTransfers(students[0], getTextIn) : []
|
||||
const studyRightStartLine =
|
||||
!singleStudent && studyPlanFilterIsActive && students[0]?.studyrightStart
|
||||
? [new Date(students[0].studyrightStart).getTime()]
|
||||
: []
|
||||
|
||||
const options = createGraphOptions({
|
||||
singleStudent,
|
||||
seriesData,
|
||||
graphHeight,
|
||||
startDate: getGraphStart(),
|
||||
endDate,
|
||||
tooltipFormatter: point => singleStudent && singleStudentTooltipFormatter(point, students[0], getTextIn),
|
||||
onPointClicked: point => {
|
||||
if (!singleStudent) {
|
||||
if (point.series.name.length > 10) return
|
||||
window.open(`/students/${point.series.name}`, '_blank')
|
||||
}
|
||||
},
|
||||
graduations,
|
||||
transfers,
|
||||
studyRightStartLine,
|
||||
})
|
||||
|
||||
const makeGraphSizeButton = (height, label) => (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setGraphHeight(height)}
|
||||
variant={graphHeight === height ? 'contained' : 'outlined'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: '400px', marginBottom: '15px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'row-reverse',
|
||||
alignItems: 'center',
|
||||
margin: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{!!infoBoxContent && <InfoBox content={infoBoxContent} />}
|
||||
<div>
|
||||
{!singleStudent && studyPlanFilterIsActive ? (
|
||||
<FormControlLabel
|
||||
control={<Switch />}
|
||||
label="Display credits from study right start"
|
||||
onClick={() => setCutStudyPlanCredits(!cutStudyPlanCredits)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{makeGraphSizeButton(400, 'Small')}
|
||||
{makeGraphSizeButton(700, 'Medium')}
|
||||
{makeGraphSizeButton(1000, 'Large')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReactHighstock config={options} ref={chartRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { useEffect } from 'react'
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { StyledMessage } from '@/components/common/StyledMessage'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import { InfoBox } from '@/components/InfoBox/InfoBoxWithTooltip'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { PopulationCourseStatsFlat } from '@/components/PopulationCourseStats/PopulationCourseStatsFlat'
|
||||
@@ -23,6 +22,7 @@ import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
|
||||
import { KeyboardBackspaceIcon, LabelIcon } from '@/theme'
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
|
||||
import { PageTitle } from '../common/PageTitle'
|
||||
import { StudentAmountLimiter } from '../common/StudentAmountLimiter'
|
||||
import { CustomPopulationProgrammeDist } from './CustomPopulationProgrammeDist'
|
||||
@@ -67,18 +67,10 @@ export const CustomPopulationContent = ({
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
content: (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
absences={null}
|
||||
customPopulation
|
||||
endDate={null}
|
||||
programmeCodes={null}
|
||||
selectedStudyPlan={null}
|
||||
showBachelorAndMaster={null}
|
||||
singleStudent={false}
|
||||
startDate={null}
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={[associatedProgramme].filter(Boolean) as string[]}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={null} // Atm the filter is not enabled in the view
|
||||
studyRightId={null}
|
||||
studyPlanFilter={false} // Not enabled for custom population yet.
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { random } from 'lodash-es'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
|
||||
import toskaLogo from '@/assets/toska.svg'
|
||||
import { Backdrop } from '@/components/common/Backdrop'
|
||||
@@ -12,63 +12,83 @@ import { useTitle } from '@/hooks/title'
|
||||
import { useLogoutMutation } from '@/redux/auth'
|
||||
import { LogoutIcon, RefreshIcon } from '@/theme'
|
||||
|
||||
const names = [
|
||||
'mluukkai',
|
||||
'jakousa',
|
||||
'totutotu',
|
||||
'sasumaki',
|
||||
'ikuisma',
|
||||
'eero3',
|
||||
'mitiaine',
|
||||
'rimi',
|
||||
'esakemp',
|
||||
'woltsu',
|
||||
'cxcorp',
|
||||
'ajhaa',
|
||||
'joonashak',
|
||||
'vaahtokarkki',
|
||||
'otahontas',
|
||||
'saarasat',
|
||||
'popalmu',
|
||||
'ollikehy',
|
||||
] as const
|
||||
|
||||
const dummyData = names.map(name => ({
|
||||
name,
|
||||
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, [] as number[]),
|
||||
type: 'line' as const,
|
||||
}))
|
||||
const dummyData = [...Array(18).keys()].map(_ => [...Array(new Date().getFullYear() - 2016).keys()].reduce<number[]>((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, []))
|
||||
|
||||
export const ErrorBackground = ({ header, content }) => {
|
||||
useTitle(header)
|
||||
const [logout] = useLogoutMutation()
|
||||
const yearLabels = dummyData[0]?.map((_, index) => (2017 + index).toString()) ?? []
|
||||
|
||||
return (
|
||||
<PageLayout maxWidth="lg">
|
||||
<Stack sx={{ alignItems: 'stretch', height: '100vh', justifyContent: 'space-evenly' }}>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
|
||||
series: dummyData,
|
||||
title: { text: 'Students of Computer Science 2018-2020' },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
/>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
chart: { type: 'column' },
|
||||
series: dummyData.map(element => ({
|
||||
name: element.name,
|
||||
data: [Math.max(...element.data)],
|
||||
type: 'column',
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: {
|
||||
text: `Students of Computer Science 2017-${new Date().getFullYear()}`,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 40,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: yearLabels,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Cumulative credits',
|
||||
},
|
||||
series: dummyData.map(data => ({
|
||||
type: 'line',
|
||||
data,
|
||||
})),
|
||||
title: { text: "Your students' future" },
|
||||
xAxis: { categories: ['2018'] },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: 300, width: '100%' }}
|
||||
/>
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: {
|
||||
text: "Your students' future",
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 40,
|
||||
right: 20,
|
||||
top: 40,
|
||||
bottom: 30,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['2018'],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: 'Cumulative credits',
|
||||
},
|
||||
series: dummyData.map(data => ({
|
||||
type: 'bar',
|
||||
data: [Math.max(...data)],
|
||||
})),
|
||||
}}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: 300, width: '100%' }}
|
||||
/>
|
||||
</Stack>
|
||||
<Backdrop
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { ProgressBarChart } from '@/components/common/ProgressBarChart'
|
||||
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
|
||||
import { FacultyProgressTable } from '@/components/Faculties/FacultyProgressTable'
|
||||
import { Section } from '@/components/Section'
|
||||
import { GetAllProgressStatsResponse } from '@/types/api/faculty'
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
import { NameWithCode } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const ProgrammeProgressChart = ({
|
||||
data,
|
||||
labels,
|
||||
@@ -27,70 +18,159 @@ export const ProgrammeProgressChart = ({
|
||||
names: string[]
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const [legendSelection, setLegendSelection] = useState<Record<string, boolean>>({})
|
||||
const labelsKey = labels.join('|')
|
||||
const seriesNamesKey = names.join('|')
|
||||
|
||||
useEffect(() => {
|
||||
setLegendSelection({})
|
||||
}, [labelsKey, seriesNamesKey])
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
legendselectchanged: (params: { selected?: Record<string, boolean> }) => {
|
||||
if (!params.selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setLegendSelection(params.selected)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <Typography variant="caption">No data available for this year</Typography>
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const transpose = (matrix: number[][]): number[][] => {
|
||||
return matrix.reduce<number[][]>((prev, next) => next.map((_, i) => (prev[i] || []).concat(next[i])), [])
|
||||
}
|
||||
|
||||
const colors = generateGradientColors(data[0]?.length || 6)
|
||||
const dataTranspose: HighCharts.SeriesBarOptions[] = transpose(data).map((obj, index) => ({
|
||||
const dataTranspose = transpose(data).map((obj, index) => ({
|
||||
name: names[index],
|
||||
data: obj,
|
||||
color: colors[index],
|
||||
type: 'bar',
|
||||
}))
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
marginTop: 60,
|
||||
height: 80 + 45 * labels.length,
|
||||
const isSeriesVisible = (name: string) => legendSelection[name] !== false
|
||||
|
||||
const totals = labels.map((_, index) =>
|
||||
dataTranspose.reduce((sum, series) => {
|
||||
if (!isSeriesVisible(series.name)) {
|
||||
return sum
|
||||
}
|
||||
|
||||
return sum + getNumericValue(series.data[index])
|
||||
}, 0)
|
||||
)
|
||||
|
||||
const series = dataTranspose.map((seriesItem, index) => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
itemStyle: {
|
||||
color: colors[index],
|
||||
},
|
||||
series: dataTranspose,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
reversed: true,
|
||||
title: {
|
||||
text: '',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: (params: { data?: { percentage?: number } }) => {
|
||||
const percentage = getNumericValue(params.data?.percentage)
|
||||
return percentage > 3 ? `${percentage.toFixed(1)}%` : ''
|
||||
},
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none',
|
||||
},
|
||||
data: labels.map((_, dataIndex) => {
|
||||
const rawValue = getNumericValue(seriesItem.data[dataIndex])
|
||||
const total = totals[dataIndex]
|
||||
const percentage = total ? (rawValue / total) * 100 : 0
|
||||
|
||||
return {
|
||||
value: percentage,
|
||||
rawValue,
|
||||
percentage,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const option = {
|
||||
legend: {
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
verticalAlign: 'top',
|
||||
bottom: 0,
|
||||
selected: Object.keys(legendSelection).length ? legendSelection : undefined,
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: 'white',
|
||||
formatter(this: any) {
|
||||
let tooltipString = `<b>${getTextIn(longLabels[this.x])}</b><br /><p>${this.x}</p><br />`
|
||||
this.points.forEach((point: any) => {
|
||||
tooltipString += `<span style="color:${point.color}">●</span> ${point.series.name}: <b>${point.y}</b><br />`
|
||||
formatter: (
|
||||
params: Array<{ name?: string; seriesName?: string; color?: string; data?: { rawValue?: number } }>
|
||||
) => {
|
||||
const tooltipParams = Array.isArray(params) ? params : [params]
|
||||
const label = tooltipParams[0]?.name ?? ''
|
||||
const title = getTextIn(longLabels[label]) ?? ''
|
||||
let tooltipString = `<b>${title}</b><br /><p>${label}</p><br />`
|
||||
|
||||
tooltipParams.forEach(point => {
|
||||
const rawValue = getNumericValue(point.data?.rawValue)
|
||||
tooltipString += `<span style="color:${point.color}">●</span> ${point.seriesName}: <b>${rawValue}</b><br />`
|
||||
})
|
||||
|
||||
return tooltipString
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
stacking: 'percent',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: '{point.percentage:.1f}%',
|
||||
filter: {
|
||||
property: 'percentage',
|
||||
operator: '>',
|
||||
value: 3,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {
|
||||
name: 'Oodikone-progress-of-students',
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 50,
|
||||
left: 10,
|
||||
right: 20,
|
||||
bottom: 50,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: {
|
||||
formatter: '{value}%',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
inverse: true,
|
||||
z: 10,
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={config} />
|
||||
return (
|
||||
<ReactECharts
|
||||
notMerge
|
||||
onEvents={onEvents}
|
||||
option={option}
|
||||
opts={{ renderer: 'svg' }}
|
||||
style={{ height: 80 + 45 * labels.length, width: '100%' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { GraduationStats, Name, NameWithCode } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const BreakdownBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
facultyGraph = true,
|
||||
handleClick,
|
||||
mode,
|
||||
names,
|
||||
year = null,
|
||||
yearLabel,
|
||||
}: {
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
facultyGraph?: boolean
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
year?: number | null
|
||||
yearLabel?: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
const statData: HighCharts.SeriesBarOptions[] = [
|
||||
{ type: 'bar', name: 'On time', color: theme.palette.graduationTimes.onTime, data: [] },
|
||||
{ type: 'bar', name: 'Max. year overtime', color: theme.palette.graduationTimes.yearOver, data: [] },
|
||||
{ type: 'bar', name: 'Overtime', color: theme.palette.graduationTimes.wayOver, data: [] },
|
||||
]
|
||||
|
||||
let categories: string[] = []
|
||||
const codeMap = {}
|
||||
|
||||
for (const item of data) {
|
||||
statData[0].data?.push(item.statistics.onTime)
|
||||
statData[1].data?.push(item.statistics.yearOver)
|
||||
statData[2].data?.push(item.statistics.wayOver)
|
||||
categories = [...categories, item.name]
|
||||
if (!facultyGraph) {
|
||||
codeMap[item.name || item.code!] = item.code
|
||||
}
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
if (data.length === 1) {
|
||||
return 250
|
||||
}
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
const name = names[code]
|
||||
if (typeof name === 'string') {
|
||||
return name
|
||||
}
|
||||
return name[language] ?? name.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (id: string, seriesName: string, amount: number) => {
|
||||
if (!facultyGraph) {
|
||||
const code = codeMap[id]
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br /><b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
return `<b>${seriesName}</b>: ${amount}`
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (mode === 'faculty') {
|
||||
return facultyGraph ? 'Graduation year' : 'Faculty'
|
||||
}
|
||||
return facultyGraph ? yearLabel! : `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: getHeight(),
|
||||
margin: [70, 0],
|
||||
},
|
||||
title: { text: !facultyGraph ? `Year ${year} by ${yearLabel!.toLowerCase()}` : '' },
|
||||
series: statData.map(series => ({
|
||||
...series,
|
||||
pointWidth: data.length > 8 ? 16 : 20,
|
||||
groupPadding: 0.15,
|
||||
})),
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
return getTooltipText(this.x as string, this.series.name, this.y!)
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
categories: categories.map(category => category.toString()),
|
||||
title: {
|
||||
text: getLabel(),
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: { text: 'Number of students' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
align: 'right',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
return this.y !== 0 ? this.y : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
series: {
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
handleClick(event, facultyGraph, categories[this.x])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: '50%' }}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
// TODO: Combine with @/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart
|
||||
export const BreakdownBarChart = ({ data, title, byStartYear = false }) => {
|
||||
const statData = [
|
||||
{ name: 'On time', color: '#90A959', data: [] },
|
||||
{ name: 'Max. year overtime', color: '#FEE191', data: [] },
|
||||
{ name: 'Overtime', color: '#FB6962', data: [] },
|
||||
]
|
||||
|
||||
let categories = []
|
||||
|
||||
for (const item of data) {
|
||||
statData[0].data = [...statData[0].data, item.statistics.onTime]
|
||||
statData[1].data = [...statData[1].data, item.statistics.yearOver]
|
||||
statData[2].data = [...statData[2].data, item.statistics.wayOver]
|
||||
categories = [...categories, item.name]
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 80 : 110
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getTooltipText = (seriesName, amount) => {
|
||||
return `<b>Graduated ${seriesName}</b>: ${amount} ${amount === 1 ? 'student' : 'students'}`
|
||||
}
|
||||
|
||||
const config = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
height: getHeight(),
|
||||
margin: [70, 0],
|
||||
},
|
||||
title: { text: title },
|
||||
series: statData,
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
fontSize: '25px',
|
||||
// eslint-disable-next-line
|
||||
formatter: function () {
|
||||
return getTooltipText(this.series.name, this.y)
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
categories,
|
||||
title: {
|
||||
text: byStartYear ? 'Start year' : 'Graduation year',
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: { text: 'Number of students' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
align: 'right',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
return this.y !== 0 ? this.y : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
series: {
|
||||
pointWidth: data.length > 8 ? 16 : 20,
|
||||
groupPadding: 0.15,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${title.split(' ')[0].toLowerCase()}-breakdown-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -2,44 +2,65 @@ import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { BreakdownBarChart } from '@/components/GraduationTimes/BreakdownDisplay/BreakdownBarChart'
|
||||
import { GraduationBreakdown } from '@/components/Charts/GraduationBreakdown'
|
||||
import { GraduationStats, Name, NameWithCode, ProgrammeMedians } from '@oodikone/shared/types'
|
||||
|
||||
export const BreakdownDisplay = ({
|
||||
data,
|
||||
handleClick,
|
||||
allowExpand,
|
||||
expandKey,
|
||||
level,
|
||||
levelProgrammeData,
|
||||
mode,
|
||||
names,
|
||||
programmeDataVisible,
|
||||
year,
|
||||
yearLabel,
|
||||
}: {
|
||||
data: GraduationStats[]
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number | string) => void
|
||||
handleClick: (seriesCategory: string) => void
|
||||
allowExpand: boolean
|
||||
expandKey: string | null,
|
||||
level: string
|
||||
levelProgrammeData: ProgrammeMedians
|
||||
levelProgrammeData?: ProgrammeMedians
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name> | undefined
|
||||
programmeDataVisible: boolean
|
||||
year: number | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
|
||||
if (!allowExpand) {
|
||||
return (
|
||||
<Box>
|
||||
<GraduationBreakdown
|
||||
cypress={`${level}-breakdown-bar-chart`}
|
||||
data={data}
|
||||
mode={mode}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography>Click a bar to view that year's {mode} level breakdown</Typography>
|
||||
<Stack direction={{ sm: 'column', md: 'row' }}>
|
||||
<BreakdownBarChart cypress={`${level}-breakdown-bar-chart`} data={data} handleClick={handleClick} mode={mode} />
|
||||
{programmeDataVisible && year && year in levelProgrammeData ? (
|
||||
<BreakdownBarChart
|
||||
<GraduationBreakdown
|
||||
cypress={`${level}-breakdown-bar-chart`}
|
||||
data={data}
|
||||
handleClick={handleClick}
|
||||
mode={mode}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData ? (
|
||||
<GraduationBreakdown
|
||||
cypress={`${level}BreakdownBarChartFaculty`}
|
||||
data={levelProgrammeData[year].data}
|
||||
facultyGraph={false}
|
||||
data={levelProgrammeData[expandKey].data}
|
||||
handleClick={handleClick}
|
||||
mode={mode}
|
||||
names={names}
|
||||
year={year}
|
||||
expandKey={expandKey}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { GraduationTimes } from '@/components/GraduationTimes'
|
||||
import { GraduationTimes, GraduationTimesProps } from '@/components/GraduationTimes'
|
||||
import { GetAllGraduationStatsResponse } from '@/types/api/university'
|
||||
|
||||
export const FacultyGraduations = ({
|
||||
@@ -24,17 +24,19 @@ export const FacultyGraduations = ({
|
||||
const programmeData = data?.byGradYear.programmes.medians
|
||||
const facultyNames = data?.programmeNames
|
||||
const classSizes = data?.classSizes
|
||||
const commonProps = {
|
||||
|
||||
const commonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'level' | 'title'> = {
|
||||
allowExpand: true,
|
||||
classSizes,
|
||||
names: facultyNames,
|
||||
goalExceptions,
|
||||
groupBy: 'byGradYear' as const,
|
||||
isError,
|
||||
isLoading,
|
||||
mode: universityMode ? ('faculty' as const) : ('programme' as const),
|
||||
mode: universityMode ? 'faculty' as const : 'programme' as const,
|
||||
showMedian,
|
||||
yearLabel: 'Graduation year' as const,
|
||||
}
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Box from '@mui/material/Box'
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import Highcharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStatistics,
|
||||
GraduationStats,
|
||||
Name,
|
||||
NameWithCode,
|
||||
ProgrammeClassSizes,
|
||||
} from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const MedianBarChart = ({
|
||||
classSizes,
|
||||
cypress,
|
||||
data,
|
||||
facultyGraph = true,
|
||||
goal,
|
||||
goalExceptions,
|
||||
handleClick,
|
||||
level,
|
||||
mode,
|
||||
names,
|
||||
title,
|
||||
year,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes: FacultyClassSizes['programmes'] | ProgrammeClassSizes['studyTracks']
|
||||
cypress: string
|
||||
data: GraduationStats[]
|
||||
facultyGraph?: boolean
|
||||
goal: number
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number) => void
|
||||
level?: string
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode>
|
||||
title: string
|
||||
year?: number | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
const { language } = useLanguage()
|
||||
const theme = useTheme()
|
||||
|
||||
let modData: Array<GraduationStats & { code: string; color: string; realGoal?: number }> | null = null
|
||||
if (!facultyGraph && goalExceptions?.needed && level && ['master', 'bcMsCombo'].includes(level)) {
|
||||
// change colors for longer medicine goal times
|
||||
modData = JSON.parse(JSON.stringify(data))
|
||||
for (const data of modData!) {
|
||||
if (Object.keys(goalExceptions).includes(data.code)) {
|
||||
const realGoal = goal + goalExceptions[data.code]
|
||||
if (data.median <= realGoal) {
|
||||
data.color = theme.palette.graduationTimes.onTime
|
||||
} else if (data.median <= realGoal + 12) {
|
||||
data.color = theme.palette.graduationTimes.yearOver
|
||||
} else {
|
||||
data.color = theme.palette.graduationTimes.wayOver
|
||||
}
|
||||
data.realGoal = realGoal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max, { median }) => {
|
||||
return median > max ? median : max
|
||||
}, goal * 2)
|
||||
|
||||
const getClassSize = (category: string) => {
|
||||
if (facultyGraph) {
|
||||
return classSizes[category]
|
||||
}
|
||||
return classSizes[category][year!]
|
||||
}
|
||||
|
||||
const getPercentage = (amount: number, category: string) => {
|
||||
const percent = Math.round((amount / getClassSize(category)) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, category: string) => {
|
||||
if (yearLabel === 'Start year' && title === 'Bachelor study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, category)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getFacultyOrProgrammeName = (code: string) => {
|
||||
if (!names) {
|
||||
return ''
|
||||
}
|
||||
return names[code]?.[language] ?? names[code]?.fi
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
name: string,
|
||||
code: string,
|
||||
amount: number,
|
||||
median: number,
|
||||
statistics: GraduationStatistics,
|
||||
realGoal: number | undefined
|
||||
) => {
|
||||
const sortingText =
|
||||
yearLabel === 'Start year'
|
||||
? `<b>From class of ${facultyGraph ? name : year}, ${amount}/${getClassSize(code)} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${facultyGraph ? name : year}</b>`
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${median} semesters</b><br />`
|
||||
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
|
||||
|
||||
if (!facultyGraph) {
|
||||
const goalText = realGoal ? `<br /><p><b>** Exceptional goal time: ${realGoal} semesters **</b></p>` : ''
|
||||
return `<b>${getFacultyOrProgrammeName(code)}</b> • ${code}<br />${timeText}${statisticsText}${goalText}`
|
||||
}
|
||||
return `${timeText}${statisticsText}`
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (mode === 'faculty') {
|
||||
return facultyGraph ? 'Graduation year' : 'Faculty'
|
||||
}
|
||||
return facultyGraph ? yearLabel : `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
|
||||
}
|
||||
|
||||
const config: Highcharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
margin: [70, 0],
|
||||
height: getHeight(),
|
||||
},
|
||||
title: { text: !facultyGraph ? `Year ${year} by ${yearLabel.toLowerCase()}` : '' },
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & {
|
||||
code?: string
|
||||
amount?: number
|
||||
statistics?: GraduationStatistics
|
||||
realGoal?: number
|
||||
}
|
||||
return getTooltipText(
|
||||
point.name,
|
||||
point.code ?? point.name,
|
||||
point.amount!,
|
||||
this.y!,
|
||||
point.statistics!,
|
||||
point.realGoal
|
||||
)
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
inside: true,
|
||||
overflow: 'allow',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: (modData ?? data).map(item => ({
|
||||
...item,
|
||||
y: item.median,
|
||||
name: item.name.toString(),
|
||||
})),
|
||||
dataLabels: {
|
||||
align: 'left',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & {
|
||||
code?: string
|
||||
amount?: number
|
||||
}
|
||||
return getDataLabel(point.amount!, point.code ?? point.name)
|
||||
},
|
||||
},
|
||||
showInLegend: false,
|
||||
zones: [
|
||||
{
|
||||
value: goal + 0.1,
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
},
|
||||
{
|
||||
value: goal + 2.1,
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
},
|
||||
{
|
||||
color: theme.palette.graduationTimes.wayOver,
|
||||
},
|
||||
],
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
handleClick(event, facultyGraph)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
title: {
|
||||
text: getLabel(),
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
title: { text: 'Graduation time (semesters)' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
plotLines: [
|
||||
{
|
||||
color: theme.palette.graduationTimes.onTime,
|
||||
width: 2,
|
||||
value: goal,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
{
|
||||
color: theme.palette.graduationTimes.yearOver,
|
||||
width: 2,
|
||||
value: goal + 2,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-cy={cypress} width={{ sm: '100%', md: '50%' }}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Box from '@mui/material/Box'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { MedianBarChart } from '@/components/GraduationTimes/MedianDisplay/MedianBarChart'
|
||||
import { MedianGraduations } from '@/components/Charts/MedianGraduations'
|
||||
import {
|
||||
FacultyClassSizes,
|
||||
GraduationStats,
|
||||
@@ -12,9 +12,29 @@ import {
|
||||
ProgrammeMedians,
|
||||
} from '@oodikone/shared/types'
|
||||
|
||||
type MedianDisplayProps = {
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
classSizes: FacultyClassSizes | ProgrammeClassSizes | undefined
|
||||
allowExpand: boolean
|
||||
names: Record<string, string | Name> | Record<string, Name | NameWithCode> | undefined
|
||||
data: GraduationStats[]
|
||||
expandKey: string | null
|
||||
goal: number | undefined
|
||||
goalExceptions: Record<string, number> | { needed: boolean }
|
||||
groupBy: 'byGradYear' | 'byStartYear'
|
||||
handleClick: (seriesCategory: string) => void
|
||||
level: string
|
||||
levelProgrammeData: ProgrammeMedians | undefined
|
||||
programmeDataVisible: boolean
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}
|
||||
|
||||
export const MedianDisplay = ({
|
||||
allowExpand,
|
||||
classSizes,
|
||||
data,
|
||||
expandKey,
|
||||
goal,
|
||||
goalExceptions,
|
||||
groupBy,
|
||||
@@ -25,24 +45,8 @@ export const MedianDisplay = ({
|
||||
names,
|
||||
programmeDataVisible,
|
||||
title,
|
||||
year,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes: FacultyClassSizes | ProgrammeClassSizes
|
||||
data: GraduationStats[]
|
||||
goal: number
|
||||
goalExceptions: Record<string, number> | { needed: boolean }
|
||||
groupBy: 'byGradYear' | 'byStartYear'
|
||||
handleClick: (event, isFacultyGraph: boolean, seriesCategory?: number) => void
|
||||
level: string
|
||||
levelProgrammeData: ProgrammeMedians
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
programmeDataVisible: boolean
|
||||
title: string
|
||||
year: number | null
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
}: MedianDisplayProps) => {
|
||||
return (
|
||||
<Box>
|
||||
{level === 'bcMsCombo' && groupBy === 'byStartYear' && (
|
||||
@@ -51,43 +55,58 @@ export const MedianDisplay = ({
|
||||
data in Sisu
|
||||
</Typography>
|
||||
)}
|
||||
{goalExceptions.needed && ['master', 'bcMsCombo'].includes(level) ? (
|
||||
{goalExceptions.needed && ['master', 'bcMsCombo'].includes(level) && (
|
||||
<Typography>
|
||||
<b>Different goal times</b> have been taken into account in all numbers and programme level bar coloring, but
|
||||
the faculty level bar color is based on the typical goal time of {goal} months
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography>Click a bar to view that year's {mode} level breakdown</Typography>
|
||||
<Stack direction={{ sm: 'column', md: 'row' }}>
|
||||
<MedianBarChart
|
||||
classSizes={classSizes?.[level] ?? {}}
|
||||
)}
|
||||
|
||||
{allowExpand ? <Typography>Click a bar to view that year's {mode} level breakdown</Typography> : null}
|
||||
|
||||
{!allowExpand ? (
|
||||
<MedianGraduations
|
||||
cypress={`${level}-median-bar-chart`}
|
||||
classSizes={!!classSizes && 'programme' in classSizes ? classSizes.programme : {}}
|
||||
data={data}
|
||||
goal={goal}
|
||||
fullWidth
|
||||
goal={goal ?? 0}
|
||||
handleClick={handleClick}
|
||||
mode={mode}
|
||||
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) : undefined}
|
||||
title={title}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
{programmeDataVisible && year && year in levelProgrammeData ? (
|
||||
<MedianBarChart
|
||||
classSizes={'programmes' in classSizes ? classSizes.programmes : classSizes.studyTracks}
|
||||
cypress={`${level}-median-bar-chart-faculty`}
|
||||
data={levelProgrammeData[year].data}
|
||||
facultyGraph={false}
|
||||
goal={goal}
|
||||
goalExceptions={goalExceptions}
|
||||
) : (
|
||||
<Stack direction={{ sm: 'column', md: 'row' }}>
|
||||
<MedianGraduations
|
||||
classSizes={!!classSizes ? ('programme' in classSizes ? classSizes.programme : level in classSizes ? classSizes[level] : {}) : {}}
|
||||
cypress={`${level}-median-bar-chart`}
|
||||
data={data}
|
||||
goal={goal ?? 0}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
mode={mode}
|
||||
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) : undefined}
|
||||
title={title}
|
||||
year={year}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
{programmeDataVisible && expandKey && levelProgrammeData && expandKey in levelProgrammeData && (
|
||||
<MedianGraduations
|
||||
classSizes={!!classSizes ? ('programmes' in classSizes ? classSizes.programmes : classSizes.studyTracks) : {}}
|
||||
cypress={`${level}-median-bar-chart-faculty`}
|
||||
data={levelProgrammeData[expandKey].data}
|
||||
expandKey={expandKey}
|
||||
goal={goal ?? 0}
|
||||
goalExceptions={goalExceptions}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
mode={mode}
|
||||
names={mode === 'faculty' ? (names as Record<string, Name | NameWithCode>) as any : undefined}
|
||||
title={title}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,63 +12,79 @@ import {
|
||||
ProgrammeMedians,
|
||||
} from '@oodikone/shared/types'
|
||||
|
||||
export type GraduationTimesProps = {
|
||||
mode: 'faculty' | 'programme' | 'study track' // "Mode" is one step below the current view in hierarchy e.g. viewing a programme overview -> mode should be study track
|
||||
classSizes?: FacultyClassSizes | ProgrammeClassSizes | undefined
|
||||
data: GraduationStats[] | undefined
|
||||
goal?: number | undefined
|
||||
goalExceptions?: Record<string, number> | { needed: boolean }
|
||||
groupBy?: 'byGradYear' | 'byStartYear'
|
||||
allowExpand?: boolean
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
level?: 'unset' | 'bachelor' | 'master' | 'bcMsCombo' | 'doctor'
|
||||
levelProgrammeData?: ProgrammeMedians
|
||||
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
showMedian: boolean
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}
|
||||
|
||||
export const GraduationTimes = ({
|
||||
classSizes,
|
||||
data,
|
||||
goal,
|
||||
goalExceptions,
|
||||
goalExceptions = { needed: false },
|
||||
groupBy = 'byStartYear',
|
||||
isError,
|
||||
allowExpand = true,
|
||||
isLoading,
|
||||
level,
|
||||
isError,
|
||||
level = 'unset',
|
||||
levelProgrammeData,
|
||||
mode,
|
||||
names,
|
||||
showMedian,
|
||||
title,
|
||||
yearLabel,
|
||||
}: {
|
||||
classSizes?: FacultyClassSizes | ProgrammeClassSizes
|
||||
data: GraduationStats[] | undefined
|
||||
goal: number | undefined
|
||||
goalExceptions: Record<string, number> | { needed: boolean }
|
||||
groupBy?: 'byGradYear' | 'byStartYear'
|
||||
isError: boolean
|
||||
isLoading: boolean
|
||||
level: string
|
||||
levelProgrammeData?: ProgrammeMedians
|
||||
mode: 'faculty' | 'programme' | 'study track'
|
||||
names?: Record<string, Name | NameWithCode> | Record<string, string | Name>
|
||||
showMedian: boolean
|
||||
title: string
|
||||
yearLabel: 'Graduation year' | 'Start year'
|
||||
}) => {
|
||||
}: GraduationTimesProps) => {
|
||||
const [programmeDataVisible, setProgrammeDataVisible] = useState(false)
|
||||
const [year, setYear] = useState<number | null>(null)
|
||||
const [expandKey, setExpandKey] = useState<string | null>(null)
|
||||
|
||||
const handleClick = (event, isFacultyGraph: boolean, seriesCategory: number | string | null = null) => {
|
||||
if (isFacultyGraph) {
|
||||
setYear(seriesCategory ?? event.point.name)
|
||||
const handleClick = (category: string) => {
|
||||
if (category) {
|
||||
setExpandKey(category)
|
||||
setProgrammeDataVisible(true)
|
||||
} else {
|
||||
setProgrammeDataVisible(false)
|
||||
setYear(null)
|
||||
setExpandKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const dataIsLoaded = classSizes && data && names && goal && levelProgrammeData
|
||||
|
||||
return (
|
||||
<Section
|
||||
cypress={`${level}-graduation-times`}
|
||||
isError={isError}
|
||||
isLoading={isLoading ? !dataIsLoaded : false}
|
||||
title={title}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{showMedian && dataIsLoaded ? (
|
||||
{!showMedian ? (
|
||||
<BreakdownDisplay
|
||||
allowExpand={allowExpand}
|
||||
data={data!}
|
||||
expandKey={expandKey}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
levelProgrammeData={levelProgrammeData}
|
||||
mode={mode}
|
||||
names={names}
|
||||
programmeDataVisible={programmeDataVisible}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : (
|
||||
<MedianDisplay
|
||||
allowExpand={allowExpand}
|
||||
classSizes={classSizes}
|
||||
data={data}
|
||||
data={data!}
|
||||
goal={goal}
|
||||
goalExceptions={goalExceptions}
|
||||
groupBy={groupBy}
|
||||
@@ -76,26 +92,13 @@ export const GraduationTimes = ({
|
||||
level={level}
|
||||
levelProgrammeData={levelProgrammeData}
|
||||
mode={mode}
|
||||
names={names}
|
||||
names={names ?? {}}
|
||||
programmeDataVisible={programmeDataVisible}
|
||||
title={title}
|
||||
year={year}
|
||||
expandKey={expandKey}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
{!showMedian && dataIsLoaded ? (
|
||||
<BreakdownDisplay
|
||||
data={data}
|
||||
handleClick={handleClick}
|
||||
level={level}
|
||||
levelProgrammeData={levelProgrammeData}
|
||||
mode={mode}
|
||||
names={names}
|
||||
programmeDataVisible={programmeDataVisible}
|
||||
year={year}
|
||||
yearLabel={yearLabel}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
+133
-91
@@ -5,15 +5,11 @@ import FormLabel from '@mui/material/FormLabel'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import RadioMui from '@mui/material/Radio'
|
||||
import RadioGroup from '@mui/material/RadioGroup'
|
||||
import dayjs from 'dayjs'
|
||||
import { Options } from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { groupBy, range } from 'lodash-es'
|
||||
import { useState } from 'react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getCreditCategories, getTargetCreditsForProgramme, TimeDivision } from '@/common'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
@@ -26,23 +22,15 @@ import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
/*
|
||||
This file is bit of a legacy mess still and imo not worth the effort refactoring unless replacing legacy react-highcharts
|
||||
(last publish 6 years ago as of 2025 btw) with the official newer version or something else entirely
|
||||
*/
|
||||
const splitStudentCredits = (student, timeSlots, cumulative) => {
|
||||
const splitStudentCredits = (student: FormattedStudent, timeSlots: any[], cumulative: boolean) => {
|
||||
if (!timeSlots.length) return []
|
||||
|
||||
let timeSlotN = 0
|
||||
const results = new Array(timeSlots.length).fill(0)
|
||||
const results: number[] = new Array(timeSlots.length).fill(0)
|
||||
|
||||
student.courses
|
||||
.filter(course => course.passed && !course.isStudyModuleCredit && dayjs(course.date).isAfter(timeSlots[0].start))
|
||||
.sort((a, b) => dayjs(a.date) > dayjs(b.date))
|
||||
.sort((a, b) => Number(dayjs(a.date) > dayjs(b.date)))
|
||||
.forEach(course => {
|
||||
while (timeSlotN < timeSlots.length && dayjs(course.date).isAfter(timeSlots[timeSlotN].end)) timeSlotN++
|
||||
if (timeSlots.length <= timeSlotN) return
|
||||
@@ -58,17 +46,17 @@ const splitStudentCredits = (student, timeSlots, cumulative) => {
|
||||
return results
|
||||
}
|
||||
|
||||
const hasGraduatedBeforeDate = (student, programme, date) => {
|
||||
const hasGraduatedBeforeDate = (student: FormattedStudent, programme: string, date: Dayjs) => {
|
||||
const correctStudyRight = student.studyRights.find(studyRight =>
|
||||
studyRight.studyRightElements.some(el => el.code === programme)
|
||||
)
|
||||
if (!correctStudyRight) return false
|
||||
const studyRightElement = correctStudyRight.studyRightElements.find(el => el.code === programme)
|
||||
return studyRightElement.graduated && date.isAfter(studyRightElement.endDate, 'day')
|
||||
return Boolean(studyRightElement?.graduated && date.isAfter(studyRightElement?.endDate, 'day'))
|
||||
}
|
||||
|
||||
const getChartData = (
|
||||
students: any[],
|
||||
students: FormattedStudent[],
|
||||
timeSlots: any[],
|
||||
programme: string,
|
||||
timeDivision: TimeDivision,
|
||||
@@ -87,43 +75,41 @@ const getChartData = (
|
||||
const colors = generateGradientColors(limits.length - 1)
|
||||
colors.push('#ddd') // Color for graduated (grey)
|
||||
|
||||
const data: { y: number; custom: { students: number[] } }[][] = limits.map(() =>
|
||||
const data: { value: number; students: Array<FormattedStudent['studentNumber']> }[][] = limits.map(() =>
|
||||
timeSlots.map(() => ({
|
||||
y: 0,
|
||||
custom: { students: [] },
|
||||
value: 0,
|
||||
students: [],
|
||||
}))
|
||||
)
|
||||
|
||||
const studentCredits = students.map(student => splitStudentCredits(student, timeSlots, cumulative))
|
||||
|
||||
timeSlots.forEach((slot, timeSlotIndex) => {
|
||||
students
|
||||
.map((student, i) => [student, i])
|
||||
.forEach(([student, studentIndex]) => {
|
||||
const hasGraduated = programme && hasGraduatedBeforeDate(student, programme, dayjs(slot.end))
|
||||
const credits = studentCredits[studentIndex][timeSlotIndex]
|
||||
students.forEach((student, studentIndex) => {
|
||||
const hasGraduated = !!programme && hasGraduatedBeforeDate(student, programme, dayjs(slot.end))
|
||||
const credits = studentCredits[studentIndex][timeSlotIndex]
|
||||
|
||||
const rangeIndex = hasGraduated
|
||||
? limits.indexOf('Graduated')
|
||||
: limits.findIndex(limit => {
|
||||
if (limit === 'Graduated') {
|
||||
return false
|
||||
}
|
||||
const rangeIndex = hasGraduated
|
||||
? limits.indexOf('Graduated')
|
||||
: limits.findIndex(limit => {
|
||||
if (limit === 'Graduated') {
|
||||
return false
|
||||
}
|
||||
|
||||
const [min, max] = limit
|
||||
const [min, max] = limit
|
||||
|
||||
if (min == null) {
|
||||
return credits < max
|
||||
}
|
||||
if (max == null) {
|
||||
return credits >= min
|
||||
}
|
||||
return credits >= min && credits < max
|
||||
})
|
||||
if (min == null) {
|
||||
return credits < max
|
||||
}
|
||||
if (max == null) {
|
||||
return credits >= min
|
||||
}
|
||||
return credits >= min && credits < max
|
||||
})
|
||||
|
||||
data[rangeIndex][timeSlotIndex].y += 1
|
||||
data[rangeIndex][timeSlotIndex].custom.students.push(student.studentNumber)
|
||||
})
|
||||
data[rangeIndex][timeSlotIndex].value += 1
|
||||
data[rangeIndex][timeSlotIndex].students.push(student.studentNumber)
|
||||
})
|
||||
})
|
||||
|
||||
const series = data.map((slots, limitN) => {
|
||||
@@ -170,7 +156,7 @@ export const CreditDistributionDevelopment = ({
|
||||
}: CreditDistributionDevelopmentProps) => {
|
||||
const [cumulative, setCumulative] = useState(true)
|
||||
const [timeDivision, setTimeDivision] = useState<TimeDivision>(TimeDivision.SEMESTER)
|
||||
const [isAscending, setIsAscending] = useState(true)
|
||||
const [isAscending, setIsAscending] = useState(false)
|
||||
|
||||
const { getTextIn } = useLanguage()
|
||||
const { filterDispatch } = useFilters()
|
||||
@@ -220,56 +206,112 @@ export const CreditDistributionDevelopment = ({
|
||||
const labels = timeSlots.map(ts => ts.label)
|
||||
const bcMsTitle = combinedProgramme === 'MH90_001' ? 'Bachelor + Licentiate' : 'Bachelor + Master'
|
||||
const title = combinedProgramme ? bcMsTitle : ''
|
||||
const ascendingSeries = useMemo(() => {
|
||||
const graduatedSeries = series.find(seriesItem => seriesItem.name === 'Graduated')
|
||||
const nonGraduatedSeries = series.filter(seriesItem => seriesItem.name !== 'Graduated')
|
||||
return graduatedSeries ? [...nonGraduatedSeries, graduatedSeries] : nonGraduatedSeries
|
||||
}, [series])
|
||||
|
||||
const config: Options = {
|
||||
series: isAscending ? series : series.toReversed(),
|
||||
title: { text: title },
|
||||
tooltip: {
|
||||
formatter() {
|
||||
// Highcharts formatter only accepts a subset of HTML as a string
|
||||
// eslint-disable-next-line react/no-this-in-sfc
|
||||
return `<div style="text-align: center; width: 100%"><b>${this.x}</b>, ${this.series.name}<br/>${this.y}/${this.total} students (${Math.round(this.percentage)}%)</div>`
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
max: filteredStudents.length,
|
||||
endOnTick: false,
|
||||
reversed: false,
|
||||
title: { text: 'Students' },
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
const displayedSeries = useMemo(
|
||||
() => (isAscending ? ascendingSeries : ascendingSeries.toReversed()),
|
||||
[isAscending, ascendingSeries]
|
||||
)
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
title: title
|
||||
? {
|
||||
text: title,
|
||||
left: 'center',
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { name?: string; seriesName?: string; value?: unknown; dataIndex?: number }) => {
|
||||
const value =
|
||||
typeof params.value === 'number'
|
||||
? params.value
|
||||
: Number.isFinite(Number(params.value))
|
||||
? Number(params.value)
|
||||
: 0
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const total = displayedSeries.reduce(
|
||||
(sum, currentSeries) => sum + (currentSeries.data[dataIndex]?.value ?? 0),
|
||||
0
|
||||
)
|
||||
const percentage = total ? Math.round((value / total) * 100) : 0
|
||||
|
||||
return `<div style="text-align: left; width: 100%"><b>${params.name ?? ''}</b>, ${params.seriesName ?? ''}<br/>${value}/${total} students (${percentage}%)</div>`
|
||||
},
|
||||
},
|
||||
series: {
|
||||
cursor: 'pointer',
|
||||
point: {
|
||||
events: {
|
||||
click(event) {
|
||||
const point = event.point as Highcharts.Point & { custom: { students: number[] } }
|
||||
filterDispatch(studentNumberFilter.actions.addToAllowlist(point.custom.students))
|
||||
},
|
||||
legend: {},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
top: 60,
|
||||
left: '15%',
|
||||
right: '15%',
|
||||
bottom: 90,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {},
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: filteredStudents.length,
|
||||
minInterval: 1,
|
||||
name: 'Students',
|
||||
},
|
||||
series: displayedSeries.map(seriesItem => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: 'students',
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
color: seriesItem.color,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value?: unknown }) => {
|
||||
const numericValue =
|
||||
typeof params.value === 'number'
|
||||
? params.value
|
||||
: Number.isFinite(Number(params.value))
|
||||
? Number(params.value)
|
||||
: 0
|
||||
return numericValue ? `${numericValue}` : ''
|
||||
},
|
||||
},
|
||||
})),
|
||||
animation: true,
|
||||
}),
|
||||
[title, labels, filteredStudents.length, displayedSeries]
|
||||
)
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
click: (params: { componentType?: string; data?: { students?: Array<FormattedStudent['studentNumber']> } }) => {
|
||||
if (params.componentType !== 'series') return
|
||||
if (!params.data?.students?.length) return
|
||||
|
||||
filterDispatch(studentNumberFilter.actions.addToAllowlist(params.data.students))
|
||||
},
|
||||
}),
|
||||
[filterDispatch]
|
||||
)
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, my: 2 }} variant="outlined">
|
||||
<Box sx={{ m: 2, width: '100%', display: 'flex', justifyContent: 'center', gap: '5em' }}>
|
||||
<Box sx={{ mt: 2, mx: 2, width: '100%', display: 'flex', justifyContent: 'center', gap: '5em' }}>
|
||||
<ToggleContainer>
|
||||
<FormLabel sx={{ mb: 1 }}>Chart settings</FormLabel>
|
||||
<Toggle
|
||||
@@ -282,7 +324,7 @@ export const CreditDistributionDevelopment = ({
|
||||
firstLabel="Ascending"
|
||||
secondLabel="Descending"
|
||||
setValue={() => setIsAscending(prev => !prev)}
|
||||
value={isAscending}
|
||||
value={!isAscending}
|
||||
/>
|
||||
</ToggleContainer>
|
||||
<FormControl>
|
||||
@@ -294,7 +336,7 @@ export const CreditDistributionDevelopment = ({
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<ReactHighcharts config={config} />
|
||||
<ReactECharts notMerge onEvents={onEvents} option={option} replaceMerge={['series']} style={{ height: 450 }} />
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import FormGroup from '@mui/material/FormGroup'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { Link } from '@/components/common/Link'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import {
|
||||
hopsFilter as studyPlanFilter,
|
||||
hopsFilter,
|
||||
@@ -28,6 +27,7 @@ import { getFullStudyProgrammeRights } from '@/util/access'
|
||||
|
||||
import { FilteredCourse } from '@/util/coursesOfPopulation'
|
||||
import { FormattedStudent } from '@oodikone/shared/types'
|
||||
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
|
||||
import { AdvancedSettings } from './AdvancedSettings'
|
||||
import { AgeStats } from './AgeStats'
|
||||
import { CourseTableModeSelector } from './CourseTableModeSelector'
|
||||
@@ -88,20 +88,16 @@ export const PopulationDetails = ({
|
||||
const panels = [
|
||||
{
|
||||
title: `Credit accumulation (for ${filteredStudents.length} students)`,
|
||||
content: (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
absences={null}
|
||||
endDate={null}
|
||||
infoBoxContent={populationStatisticsToolTips.creditAccumulation}
|
||||
programmeCodes={[programme, combinedProgramme].filter(Boolean)}
|
||||
selectedStudyPlan={null}
|
||||
content: filteredStudents.length ? (
|
||||
// TODO: make wrapper for when no data.
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={[programme, combinedProgramme].filter(c => c !== undefined)}
|
||||
showBachelorAndMaster={!!showBachelorAndMaster}
|
||||
singleStudent={false}
|
||||
startDate={null}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={studyPlanFilterIsActive}
|
||||
studyRightId={null}
|
||||
studyPlanFilter={studyPlanFilterIsActive}
|
||||
/>
|
||||
) : (
|
||||
<Typography>Nothing to show!</Typography>
|
||||
),
|
||||
},
|
||||
query.years.length <= 1
|
||||
|
||||
+6
-15
@@ -1,10 +1,8 @@
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isBetween from 'dayjs/plugin/isBetween'
|
||||
|
||||
import { populationStatisticsToolTips } from '@/common/InfoToolTips'
|
||||
import { PanelView } from '@/components/common/PanelView'
|
||||
import { ToggleWithTooltip } from '@/components/common/toggle/ToggleWithTooltip'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import { creditDateFilter, hopsFilter as studyPlanFilter } from '@/components/FilterView/filters'
|
||||
import { useFilters } from '@/components/FilterView/useFilters'
|
||||
import { AgeStats } from '@/components/PopulationDetails/AgeStats'
|
||||
@@ -19,6 +17,7 @@ import { useCurriculumState } from '../../hooks/useCurriculums'
|
||||
import { useColumns as columnsGeneralTab } from './studentColumns'
|
||||
import { StudyGuidanceGroupPopulationCourses } from './StudyGuidanceGroupPopulationCourses'
|
||||
import { createAcademicYearStartDate } from './utils'
|
||||
import { CreditAccumulationGraph } from '../Charts/CreditAccumulation'
|
||||
|
||||
dayjsExtend(isBetween)
|
||||
|
||||
@@ -73,27 +72,19 @@ export const SingleStudyGuidanceGroupPanels = ({
|
||||
onChange={toggleCreditDateFilter}
|
||||
/>
|
||||
)}
|
||||
<CreditAccumulationGraphHighCharts
|
||||
absences={null}
|
||||
endDate={null}
|
||||
infoBoxContent={populationStatisticsToolTips.creditAccumulation}
|
||||
<CreditAccumulationGraph
|
||||
programmeCodes={group?.tags?.studyProgramme ? [programme, combinedProgramme] : []}
|
||||
selectedStudyPlan={null}
|
||||
showBachelorAndMaster={null}
|
||||
singleStudent={false}
|
||||
startDate={null}
|
||||
students={filteredStudents}
|
||||
studyPlanFilterIsActive={studyPlanFilterIsActive}
|
||||
studyRightId={null}
|
||||
studyPlanFilter={studyPlanFilterIsActive}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
(programme || group?.tags?.studyProgramme) && groupYear
|
||||
? {
|
||||
title: 'Credit statistics',
|
||||
content: <CreditStatistics filteredStudents={filteredStudents} query={query} />,
|
||||
}
|
||||
title: 'Credit statistics',
|
||||
content: <CreditStatistics filteredStudents={filteredStudents} query={query} />,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
title: 'Age distribution',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const LineGraph = ({
|
||||
cypress,
|
||||
exportFileName,
|
||||
graphStats,
|
||||
years,
|
||||
}: {
|
||||
cypress: string
|
||||
exportFileName: string
|
||||
graphStats: GraphStat[]
|
||||
years: number[]
|
||||
}) => {
|
||||
if (!graphStats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataWithColors = graphStats.map((series, index) => ({
|
||||
...series,
|
||||
color: theme.palette.graphColors[index],
|
||||
type: 'line' as const,
|
||||
}))
|
||||
|
||||
const config = {
|
||||
series: dataWithColors,
|
||||
exporting: {
|
||||
filename: exportFileName,
|
||||
},
|
||||
chart: {
|
||||
height: '450px',
|
||||
},
|
||||
xAxis: {
|
||||
categories: years.map(String),
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-line-graph`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import Highcharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import type { GraduationStatistics } from '@oodikone/shared/types'
|
||||
import { ChartGraduationTimes } from '@oodikone/shared/types/graduations'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const MedianTimeBarChart = ({
|
||||
byStartYear,
|
||||
data,
|
||||
goal,
|
||||
title,
|
||||
}: {
|
||||
byStartYear: boolean
|
||||
data: ChartGraduationTimes['medians'] | undefined
|
||||
goal: ChartGraduationTimes['goal']
|
||||
title: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxValue = data.reduce((max: number, { y }) => {
|
||||
return y > max ? y : max
|
||||
}, goal * 2)
|
||||
|
||||
const getPercentage = (amount: number, classSize: number) => {
|
||||
const percent = Math.round((amount / classSize) * 100 * 10) / 10
|
||||
return Number.isNaN(percent) ? 0 : percent
|
||||
}
|
||||
|
||||
const getDataLabel = (amount: number, classSize: number) => {
|
||||
if (byStartYear && title !== 'Bachelor + master study right') {
|
||||
return `${amount} graduated (${getPercentage(amount, classSize)} % of class)`
|
||||
}
|
||||
return `${amount} graduated`
|
||||
}
|
||||
|
||||
const getHeight = () => {
|
||||
const multiplier = data.length > 8 ? 35 : 55
|
||||
return data.length * multiplier + 100
|
||||
}
|
||||
|
||||
const getTooltipText = (
|
||||
amount: number,
|
||||
y: number,
|
||||
year: string,
|
||||
statistics: GraduationStatistics,
|
||||
classSize: number
|
||||
) => {
|
||||
const sortingText =
|
||||
byStartYear && title !== 'Bachelor + master study right'
|
||||
? `<b>From class of ${year}, ${amount}/${classSize} students have graduated</b>`
|
||||
: `<b>${amount} students graduated in year ${year}</b>`
|
||||
|
||||
const timeText = `<br />${sortingText}<br /><b>median study time: ${y} semesters</b><br />`
|
||||
|
||||
const statisticsText = `<br />${statistics.onTime} graduated on time<br />${statistics.yearOver} graduated max year overtime<br />${statistics.wayOver} graduated over year late`
|
||||
|
||||
return `${timeText}${statisticsText}`
|
||||
}
|
||||
|
||||
const config: Highcharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
width: 700,
|
||||
margin: [70, 0],
|
||||
height: getHeight(),
|
||||
},
|
||||
title: { text: title },
|
||||
tooltip: {
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & {
|
||||
amount: number
|
||||
name: string
|
||||
statistics: GraduationStatistics
|
||||
classSize: number
|
||||
}
|
||||
return getTooltipText(point.amount, this.y!, point.name, point.statistics, point.classSize)
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
inside: true,
|
||||
overflow: 'allow' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data,
|
||||
dataLabels: {
|
||||
align: 'left',
|
||||
color: '#424949',
|
||||
style: {
|
||||
textOutline: 'none',
|
||||
},
|
||||
formatter() {
|
||||
const point = this.point as Highcharts.Point & { amount: number; classSize: number }
|
||||
return getDataLabel(point.amount, point.classSize)
|
||||
},
|
||||
},
|
||||
showInLegend: false,
|
||||
zones: [
|
||||
{
|
||||
value: goal + 0.1,
|
||||
color: '#90A959',
|
||||
},
|
||||
{
|
||||
value: goal + 2.1,
|
||||
color: '#FEE191',
|
||||
},
|
||||
{
|
||||
color: '#FB6962',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
title: {
|
||||
text: byStartYear ? 'Start year' : 'Graduation year',
|
||||
align: 'high',
|
||||
offset: 0,
|
||||
rotation: 0,
|
||||
y: -10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
title: { text: 'Graduation time (semesters)' },
|
||||
labels: {
|
||||
overflow: 'justify',
|
||||
},
|
||||
allowDecimals: false,
|
||||
showFirstLabel: false,
|
||||
plotLines: [
|
||||
{
|
||||
color: '#90A959',
|
||||
width: 2,
|
||||
value: goal,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
{
|
||||
color: '#FEE191',
|
||||
width: 2,
|
||||
value: goal + 2,
|
||||
dashStyle: 'ShortDash',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${title.split(' ')[0].toLowerCase()}-median-time-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { OptionsStackingValue, SeriesColumnOptions } from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { generateGradientColors } from '@/util/color'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const ProgressBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
}: {
|
||||
cypress: string
|
||||
data: {
|
||||
id: string
|
||||
stats: {
|
||||
data: number[]
|
||||
name: string
|
||||
}[]
|
||||
years: string[]
|
||||
}
|
||||
}) => {
|
||||
if (!data.stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const colors = generateGradientColors(Object.keys(data.stats).length - 1)
|
||||
colors.push('#ddd') // For graduated students
|
||||
const dataWithColors: SeriesColumnOptions[] = Object.values(data.stats).map((series, index) => ({
|
||||
...series,
|
||||
color: colors[index],
|
||||
type: 'column',
|
||||
}))
|
||||
|
||||
const config = {
|
||||
series: dataWithColors,
|
||||
xAxis: {
|
||||
categories: data?.years,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'percent' as OptionsStackingValue,
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
format: '{point.percentage:.1f}%',
|
||||
},
|
||||
},
|
||||
},
|
||||
exporting: {
|
||||
filename: `oodikone_progress_of_students_in_${data?.id}_by_starting_year`,
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-progress-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
import HighCharts from 'highcharts' // eslint-disable-line import-x/default
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const StackedBarChart = ({
|
||||
cypress,
|
||||
data,
|
||||
labels,
|
||||
exportFileName = 'chart',
|
||||
}: {
|
||||
cypress: string
|
||||
data?: { data: number[]; name: string }[]
|
||||
labels?: string[]
|
||||
exportFileName: string
|
||||
}) => {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataWithColors = data?.map((series, index) => ({
|
||||
...series,
|
||||
color: theme.palette.graphColors[index],
|
||||
type: 'column' as const,
|
||||
}))
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
series: dataWithColors,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
allowDecimals: false,
|
||||
},
|
||||
chart: {
|
||||
type: 'column',
|
||||
height: '450px',
|
||||
},
|
||||
exporting: {
|
||||
filename: exportFileName,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter() {
|
||||
if (Number.isInteger(this.y)) return `${this.y}`
|
||||
return `${this.y?.toFixed(1)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
pointFormat: '<b>{series.name}: {point.percentage:.1f} %</b>',
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress={`${cypress}-stacked-bar-chart`}>
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import AlertTitle from '@mui/material/AlertTitle'
|
||||
import type { SxProps } from '@mui/material/styles'
|
||||
|
||||
export const StyledMessage = ({ children, sx: propSx }: { children: React.ReactNode; sx?: SxProps }) => (
|
||||
<Alert icon={false} severity="info" sx={{ margin: 'auto', maxWidth: '800px', ...propSx }} variant="outlined">
|
||||
export const StyledMessage = ({
|
||||
children,
|
||||
variant = 'outlined',
|
||||
sx: propSx,
|
||||
showIcon,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
variant?: 'outlined' | 'standard' | 'filled'
|
||||
sx?: SxProps
|
||||
showIcon?: boolean
|
||||
title?: string
|
||||
}) => (
|
||||
<Alert
|
||||
icon={showIcon ? null : false} // Null keeps icon enabled (default behaviour)
|
||||
severity="info"
|
||||
sx={{ margin: 'auto', maxWidth: '800px', ...propSx }}
|
||||
variant={variant}
|
||||
>
|
||||
{title?.length ? <AlertTitle>{title}</AlertTitle> : null}
|
||||
{children}
|
||||
</Alert>
|
||||
)
|
||||
|
||||
+94
-18
@@ -1,18 +1,30 @@
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { ProgrammeStats, ViewMode } from '@/types/courseStat'
|
||||
import { getGradeSpread, getSeriesType, getThesisGradeSpread } from '../util'
|
||||
import { absoluteToRelative, getDataObject, getGraphOptions, getMaxValueOfSeries } from './util'
|
||||
import { absoluteToRelative, getDataObject, getMaxValueOfSeries } from './util'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(2)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const calculateSumAll = (newSeries: Record<string, number[]>) => {
|
||||
return Object.values(newSeries)[0].map((_, index) =>
|
||||
@@ -100,19 +112,83 @@ export const GradeDistributionChart = ({
|
||||
const seriesType = getSeriesType(grades)
|
||||
const maxGradeValue = isRelative ? 100 : getMaxValueOfSeries(series.absolute)
|
||||
|
||||
const graphOptions = getGraphOptions(
|
||||
colors[seriesType],
|
||||
colors[seriesType],
|
||||
isRelative,
|
||||
maxGradeValue,
|
||||
statYears,
|
||||
`Grades for group ${data.name}`,
|
||||
viewMode
|
||||
)
|
||||
const chartSeries = isRelative ? series.relative : series.absolute
|
||||
const chartColors = colors[seriesType]
|
||||
const yAxisTitle = isRelative ? `Share of ${viewMode.toLowerCase()}` : `Number of ${viewMode.toLowerCase()}`
|
||||
|
||||
const option = {
|
||||
color: chartColors,
|
||||
title: {
|
||||
text: `Grades for group ${data.name}`,
|
||||
left: 'center',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number; name?: string }) => {
|
||||
const value = getNumericValue(params.value)
|
||||
const category = params.name ?? ''
|
||||
const categoryLine = category ? `<b>${category}</b><br/>` : ''
|
||||
|
||||
if (isRelative) {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const total = chartSeries.reduce((sum, seriesItem) => sum + getNumericValue(seriesItem.data[dataIndex]), 0)
|
||||
const percentage = total ? (value / total) * 100 : 0
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(percentage)}%`
|
||||
}
|
||||
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: statYears,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: isRelative ? 100 : maxGradeValue,
|
||||
minInterval: 1,
|
||||
name: yAxisTitle,
|
||||
axisLabel: {
|
||||
margin: 50,
|
||||
formatter: isRelative ? '{value}%' : '{value}',
|
||||
},
|
||||
},
|
||||
series: chartSeries.map((seriesItem, index) => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: seriesItem.stack,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
borderRadius: 1,
|
||||
color: chartColors[index],
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts config={{ ...graphOptions, series: isRelative ? series.relative : series.absolute }} />
|
||||
<ReactECharts notMerge option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
<TotalsDisclaimer shownAsZero userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
)
|
||||
|
||||
+93
-19
@@ -1,17 +1,29 @@
|
||||
import useTheme from '@mui/material/styles/useTheme'
|
||||
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { TotalsDisclaimer } from '@/components/common/TotalsDisclaimer'
|
||||
import { FormattedStats, ProgrammeStats, ViewMode } from '@/types/courseStat'
|
||||
import { absoluteToRelative, getDataObject, getGraphOptions, getMaxValueOfSeries } from './util'
|
||||
import { absoluteToRelative, getDataObject } from './util'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Number.isInteger(numericValue)) {
|
||||
return `${numericValue}`
|
||||
}
|
||||
|
||||
return `${numericValue.toFixed(2)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const getPassRateSeries = (stats: FormattedStats[], viewMode: ViewMode) => {
|
||||
const all: number[] = []
|
||||
@@ -73,21 +85,83 @@ export const PassRateChart = ({
|
||||
const statYears = stats.map(year => year.name)
|
||||
|
||||
const series = getPassRateSeries(stats, viewMode)
|
||||
const maxPassRateVal = isRelative ? 100 : getMaxValueOfSeries(series.absolute)
|
||||
const chartSeries = isRelative ? series.relative : series.absolute
|
||||
const chartColors = isRelative ? colorsRelative : colors
|
||||
const yAxisTitle = isRelative ? `Share of ${viewMode.toLowerCase()}` : `Number of ${viewMode.toLowerCase()}`
|
||||
|
||||
const graphOptions = getGraphOptions(
|
||||
colors,
|
||||
colorsRelative,
|
||||
isRelative,
|
||||
maxPassRateVal,
|
||||
statYears,
|
||||
`Pass rate for group ${data.name}`,
|
||||
viewMode
|
||||
)
|
||||
const option = {
|
||||
color: chartColors,
|
||||
title: {
|
||||
text: `Pass rate for group ${data.name}`,
|
||||
left: 'center',
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
dataView: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { seriesName?: string; value?: unknown; dataIndex?: number; name?: string }) => {
|
||||
const value = getNumericValue(params.value)
|
||||
const category = params.name ?? ''
|
||||
const categoryLine = category ? `<b>${category}</b><br/>` : ''
|
||||
|
||||
if (isRelative) {
|
||||
const dataIndex = params.dataIndex ?? 0
|
||||
const total = chartSeries.reduce((sum, seriesItem) => sum + getNumericValue(seriesItem.data[dataIndex]), 0)
|
||||
const percentage = total ? (value / total) * 100 : 0
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(percentage)}%`
|
||||
}
|
||||
|
||||
return `${categoryLine}<b>${params.seriesName ?? ''}</b>: ${formatNumber(value)}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
},
|
||||
grid: {
|
||||
top: 60,
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 60,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: statYears,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: isRelative ? 100 : undefined,
|
||||
minInterval: 1,
|
||||
name: yAxisTitle,
|
||||
axisLabel: {
|
||||
margin: 50,
|
||||
formatter: isRelative ? '{value}%' : '{value}',
|
||||
},
|
||||
},
|
||||
series: chartSeries.map((seriesItem, index) => ({
|
||||
name: seriesItem.name,
|
||||
type: 'bar',
|
||||
stack: seriesItem.stack,
|
||||
data: seriesItem.data,
|
||||
itemStyle: {
|
||||
borderRadius: 0,
|
||||
color: chartColors[index],
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactHighcharts config={{ ...graphOptions, series: isRelative ? series.relative : series.absolute }} />
|
||||
<ReactECharts notMerge option={option} opts={{ renderer: 'svg' }} style={{ height: 450 }} />
|
||||
<TotalsDisclaimer shownAsZero userHasAccessToAllStats={userHasAccessToAllStats} />
|
||||
</div>
|
||||
)
|
||||
|
||||
+172
-96
@@ -1,19 +1,9 @@
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
/* eslint-disable-next-line import-x/default */
|
||||
import HighCharts from 'highcharts'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { theme } from '@/theme'
|
||||
import { NameWithCode } from '@oodikone/shared/types'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
export const CollapsedStackedBar = ({
|
||||
data,
|
||||
differenceData,
|
||||
@@ -30,20 +20,18 @@ export const CollapsedStackedBar = ({
|
||||
plotLinePlaces: string[][]
|
||||
}) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
|
||||
const transpose = (matrix: number[][]) => {
|
||||
return matrix.reduce((prev, next) => next.map((_item, i) => (prev[i] || []).concat(next[i])), [] as number[][])
|
||||
}
|
||||
if (names[0] === 'Started studying') {
|
||||
names[0] += ' (new in faculty)'
|
||||
}
|
||||
const dataTranspose = transpose(data)
|
||||
.map((obj, index) => ({
|
||||
name: names[index],
|
||||
data: obj,
|
||||
color: theme.palette.graphColors[index],
|
||||
type: 'bar' as const,
|
||||
}))
|
||||
.reverse()
|
||||
const seriesNames = names.map((name, index) =>
|
||||
index === 0 && name === 'Started studying' ? 'Started studying (new in faculty)' : name
|
||||
)
|
||||
const dataTranspose = transpose(data).map((obj, index) => ({
|
||||
name: seriesNames[index],
|
||||
data: obj,
|
||||
color: theme.palette.graphColors[index],
|
||||
}))
|
||||
|
||||
const differenceArray: Record<string, Record<string, number>> = Object.keys(differenceData).reduce(
|
||||
(programmes, programme) => ({
|
||||
@@ -51,7 +39,7 @@ export const CollapsedStackedBar = ({
|
||||
[programme]: differenceData[programme].reduce(
|
||||
(results, value, currentIndex) => ({
|
||||
...results,
|
||||
[names[currentIndex]]: value,
|
||||
[seriesNames[currentIndex]]: value,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
@@ -70,19 +58,8 @@ export const CollapsedStackedBar = ({
|
||||
? plotLinePlaces.map(value => ({
|
||||
color: '#90A959',
|
||||
width: 1,
|
||||
value: Number(value[0]) - 0.5,
|
||||
dashStyle: 'Solid' as const,
|
||||
label: {
|
||||
text: value[1],
|
||||
style: {
|
||||
color: 'black',
|
||||
fontWeight: 'bold',
|
||||
position: 'absolute',
|
||||
},
|
||||
align: 'right' as const,
|
||||
x: 0,
|
||||
y: 5,
|
||||
},
|
||||
value: Number(value[0]),
|
||||
label: value[1],
|
||||
}))
|
||||
: []
|
||||
|
||||
@@ -101,82 +78,181 @@ export const CollapsedStackedBar = ({
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const config: HighCharts.Options = {
|
||||
chart: {
|
||||
type: 'bar',
|
||||
marginTop: 60,
|
||||
height: getFlexHeight(labels.length),
|
||||
const formatNumber = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
|
||||
if (!Number.isFinite(numericValue)) return ''
|
||||
if (Number.isInteger(numericValue)) return `${numericValue}`
|
||||
return `${numericValue.toFixed(1)}`
|
||||
}
|
||||
|
||||
const getNumericValue = (value: unknown) => {
|
||||
const numericValue = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : 0
|
||||
}
|
||||
|
||||
const totals = labels.map((_, index) =>
|
||||
dataTranspose.reduce((sum, series) => sum + getNumericValue(series.data[index]), 0)
|
||||
)
|
||||
|
||||
// Prevents clutter
|
||||
const labelCutoff = Math.round(Math.max(...totals) * 0.05)
|
||||
|
||||
const totalMarkPoints = labels.map((label, index) => ({
|
||||
coord: [totals[index], label],
|
||||
value: totals[index],
|
||||
}))
|
||||
|
||||
const series = dataTranspose.map((graphSeries, index) => ({
|
||||
name: graphSeries.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: graphSeries.data,
|
||||
itemStyle: {
|
||||
color: graphSeries.color,
|
||||
},
|
||||
series: dataTranspose,
|
||||
xAxis: {
|
||||
categories: labels,
|
||||
title: {
|
||||
text: '',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'insideLeft',
|
||||
formatter: (params: { value?: unknown }) => {
|
||||
const value = getNumericValue(params.value)
|
||||
if (value <= labelCutoff) return ''
|
||||
return formatNumber(value)
|
||||
},
|
||||
plotLines: chartPlotLinePlaces,
|
||||
},
|
||||
yAxis: {
|
||||
min: 0,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
stackLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
labelLayout: {
|
||||
hideOverlap: true,
|
||||
},
|
||||
markPoint:
|
||||
index === dataTranspose.length - 1
|
||||
? {
|
||||
showSymbol: false,
|
||||
symbolSize: 0,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
distance: 10,
|
||||
formatter: (params: { value?: unknown }) => `(${formatNumber(params.value)})`,
|
||||
},
|
||||
data: totalMarkPoints,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const plotLineSeries = chartPlotLinePlaces.length
|
||||
? [
|
||||
{
|
||||
name: 'Plot lines',
|
||||
type: 'line',
|
||||
data: [],
|
||||
silent: true,
|
||||
tooltip: { show: false },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: '#000',
|
||||
width: 1,
|
||||
},
|
||||
data: chartPlotLinePlaces.map(place => ({
|
||||
yAxis: place.value,
|
||||
lineStyle: {
|
||||
width: 0,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: place.label,
|
||||
position: 'insideEnd',
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
distance: 5,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const option = {
|
||||
grid: {
|
||||
top: 20,
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 75,
|
||||
show: true,
|
||||
},
|
||||
legend: {
|
||||
layout: 'horizontal',
|
||||
align: 'left',
|
||||
x: 20,
|
||||
verticalAlign: 'top',
|
||||
y: -10,
|
||||
floating: true,
|
||||
backgroundColor: 'white',
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
shadow: false,
|
||||
borderWidth: 0,
|
||||
data: series.map(item => item.name),
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
backgroundColor: 'white',
|
||||
formatter() {
|
||||
let tooltipString = `<b>${getTextIn(longLabels[this.x!])}</b><br /><p>${this.x} - ${
|
||||
longLabels[this.x!]?.code
|
||||
}</p><br />`
|
||||
const diffArray = differenceArray[this.x!]
|
||||
this.points?.forEach(point => {
|
||||
tooltipString += `<span style="color:${point.color as string}">●</span> <b>${point.series.name}: ${point.y}</b>
|
||||
(<span style="color:${getColor(diffArray[point.series.name])};font-weight:bold">${getCorrectSign(
|
||||
diffArray[point.series.name]
|
||||
)}</span>)<br />`
|
||||
confine: true,
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: '#fff',
|
||||
borderColor: '#CCC',
|
||||
borderWidth: 1,
|
||||
formatter: (rawParams: unknown) => {
|
||||
const params = Array.isArray(rawParams) ? rawParams : [rawParams]
|
||||
const barParams = params.filter(
|
||||
param =>
|
||||
(param as { seriesType?: string })?.seriesType === 'bar' &&
|
||||
(param as { componentType?: string })?.componentType === 'series'
|
||||
) as Array<{ dataIndex?: number; seriesName?: string; value?: unknown; color?: string }>
|
||||
|
||||
const firstPoint = barParams[0]
|
||||
if (!firstPoint) return ''
|
||||
|
||||
const dataIndex = firstPoint.dataIndex ?? 0
|
||||
const label = labels[dataIndex] ?? ''
|
||||
const labelInfo = longLabels[label]
|
||||
let tooltipString = `<b>${getTextIn(labelInfo) ?? ''}</b><p>${label} - ${labelInfo?.code ?? ''}</p>`
|
||||
const diffArray = differenceArray[label] ?? {}
|
||||
|
||||
barParams.forEach(point => {
|
||||
const value = getNumericValue(point.value)
|
||||
const seriesName = point.seriesName ?? ''
|
||||
const diffValue = diffArray[seriesName]
|
||||
tooltipString += `<span style="color:${point.color ?? '#000'}">●</span> <b>${seriesName}: ${formatNumber(
|
||||
value
|
||||
)}</b>`
|
||||
if (typeof diffValue === 'number') {
|
||||
tooltipString += ` (<span style="color:${getColor(diffValue)};font-weight:bold">${getCorrectSign(
|
||||
diffValue
|
||||
)}</span>)`
|
||||
}
|
||||
tooltipString += '<br />'
|
||||
})
|
||||
tooltipString += `<b>Total: ${this.points?.reduce((prev, current) => prev + current.y!, 0)}</b>`
|
||||
const total = barParams.reduce((sum, current) => sum + getNumericValue(current.value), 0)
|
||||
|
||||
tooltipString += `<b>Total: ${formatNumber(total)}</b>`
|
||||
return tooltipString
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
stacking: 'normal',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
align: 'left',
|
||||
formatter() {
|
||||
if (Number.isInteger(this.y)) return `${this.y}`
|
||||
return `${this.y?.toFixed(1)}`
|
||||
},
|
||||
filter: {
|
||||
property: 'y',
|
||||
operator: '>',
|
||||
value: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: true },
|
||||
axisTick: { show: false },
|
||||
min: 0,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
inverse: true,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
z: 10,
|
||||
},
|
||||
series: [...series, ...plotLineSeries],
|
||||
}
|
||||
|
||||
if (!dataTranspose) {
|
||||
return <>No data provided</>
|
||||
}
|
||||
|
||||
return <ReactHighcharts config={config} />
|
||||
return <ReactECharts option={option} opts={{ renderer: 'svg' }} style={{ height: getFlexHeight(labels.length) }} />
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import Stack from '@mui/material/Stack'
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { LineGraph } from '@/components/common/LineGraph'
|
||||
import { StackedBarChart } from '@/components/common/StackedBarChart'
|
||||
import { LineGraph } from '@/components/Charts/LineGraph'
|
||||
import { StackedBarChart } from '@/components/Charts/StackedBarChart'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
|
||||
import { Section } from '@/components/Section'
|
||||
@@ -286,7 +286,7 @@ export const BasicInformationTab = ({
|
||||
<Stack gap={2}>
|
||||
<LineGraph
|
||||
cypress="graduated-of-the-faculty"
|
||||
exportFileName={`oodikon"graduated-of-the-faculty"_${faculty.code}_${getTimestamp()}`}
|
||||
exportFileName={`oodikone_graduated-of-the-faculty"_${faculty.code}_${getTimestamp()}`}
|
||||
graphStats={basics.data.graduationInfo.graphStats}
|
||||
years={basics.data.years}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { utils, writeFile } from 'xlsx'
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
|
||||
import { GraduationTimes } from '@/components/GraduationTimes'
|
||||
import { GraduationTimes, GraduationTimesProps } from '@/components/GraduationTimes'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
import { useGetFacultyGraduationTimesQuery } from '@/redux/facultyStats'
|
||||
@@ -38,18 +38,20 @@ export const GraduationTimesTab = ({
|
||||
const programmeNames = graduationStats?.data?.programmeNames
|
||||
const classSizes = graduationStats?.data?.classSizes
|
||||
const isError = graduationStats.isError || (graduationStats.isSuccess && !graduationStats.data)
|
||||
const isLoading = graduationStats.isLoading || graduationStats.isFetching
|
||||
const commonProps = {
|
||||
const isLoading = graduationStats.isFetching
|
||||
|
||||
const commonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'level' | 'title'> = {
|
||||
allowExpand: true,
|
||||
classSizes,
|
||||
goalExceptions,
|
||||
groupBy,
|
||||
isError,
|
||||
isLoading,
|
||||
mode: 'programme' as const,
|
||||
mode: 'programme',
|
||||
names: programmeNames,
|
||||
showMedian,
|
||||
yearLabel,
|
||||
}
|
||||
} as const
|
||||
|
||||
const exportToExcel = (data?: GetFacultyGraduationTimesResponse, programmeNames?: Record<string, Name>) => {
|
||||
if (!data || !programmeNames) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
|
||||
import { facultyToolTips } from '@/common/InfoToolTips'
|
||||
import { ProgressBarChart } from '@/components/common/ProgressBarChart'
|
||||
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
|
||||
import { FacultyProgressTable } from '@/components/Faculties/FacultyProgressTable'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
|
||||
@@ -1,56 +1,62 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import AlertTitle from '@mui/material/AlertTitle'
|
||||
import Box from '@mui/material/Box'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tab from '@mui/material/Tab'
|
||||
import Tabs from '@mui/material/Tabs'
|
||||
|
||||
import TextField from '@mui/material/TextField'
|
||||
import ToggleButton from '@mui/material/ToggleButton'
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
|
||||
import dayjs, { extend as dayjsExtend } from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import { chunk, flattenDeep, groupBy } from 'lodash-es'
|
||||
import { useMemo, useState } from 'react'
|
||||
import ReactHighcharts from 'react-highcharts/ReactHighstock'
|
||||
import { flattenDeep } from 'lodash-es'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { getStudyRightElementTargetDates } from '@/common'
|
||||
import { CreditAccumulationGraphHighCharts } from '@/components/CreditAccumulationGraphHighCharts'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { DateFormat } from '@/constants/date'
|
||||
import { SemestersData, useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { StudentCreditAccumulation } from '@/components/Charts/StudentCreditAccumulation'
|
||||
import { StudentGradeGraphs } from '@/components/Charts/StudentGradeGraphs'
|
||||
import { StyledMessage } from '@/components/common/StyledMessage'
|
||||
import type { Absence } from '@/types/students'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
import { StudentPageStudent } from '@oodikone/shared/types/studentData'
|
||||
|
||||
dayjsExtend(isSameOrAfter)
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
const getEarliestAttainmentDate = ({ courses }) => {
|
||||
if (!courses?.length) return null
|
||||
// Courses are already sorted by date in the backend
|
||||
return courses[0].date
|
||||
}
|
||||
|
||||
const getEarliestStudyRightStartDate = ({ studyRights }) => {
|
||||
if (!studyRights?.length) return null
|
||||
|
||||
const startDates = studyRights
|
||||
.map(({ startDate }) => new Date(startDate).getTime())
|
||||
.filter(time => !Number.isNaN(time))
|
||||
|
||||
if (!startDates.length) return null
|
||||
return new Date(Math.min(...startDates))
|
||||
}
|
||||
|
||||
const getCoursesIncludedInStudyPlan = (student, studyPlan) =>
|
||||
student.courses.filter(({ course }) => studyPlan.included_courses.includes(course.code))
|
||||
|
||||
const resolveGraphStartDate = (student, graphYearStart, selectedStudyPlan, studyRightTargetStart) => {
|
||||
const earliestAttainmentDate = getEarliestAttainmentDate(student)
|
||||
const earliestStudyRightStartDate = getEarliestStudyRightStartDate(student)
|
||||
if (!selectedStudyPlan)
|
||||
return Math.min(new Date(earliestAttainmentDate).getTime(), new Date(graphYearStart ?? new Date()).getTime())
|
||||
return Math.min(
|
||||
new Date(earliestAttainmentDate ?? earliestStudyRightStartDate ?? graphYearStart ?? new Date()).getTime(),
|
||||
new Date(graphYearStart ?? new Date()).getTime()
|
||||
)
|
||||
const filteredCourses = getCoursesIncludedInStudyPlan(student, selectedStudyPlan)
|
||||
|
||||
return Math.min(
|
||||
if (!filteredCourses.length) {
|
||||
return new Date(studyRightTargetStart ?? earliestStudyRightStartDate ?? graphYearStart ?? new Date()).getTime()
|
||||
}
|
||||
|
||||
const candidateTimes = [
|
||||
...flattenDeep<number>(filteredCourses.map(({ date }) => new Date(date).getTime())),
|
||||
new Date(studyRightTargetStart).getTime()
|
||||
)
|
||||
new Date(studyRightTargetStart ?? earliestStudyRightStartDate ?? graphYearStart ?? new Date()).getTime(),
|
||||
].filter(time => Number.isFinite(time))
|
||||
|
||||
return candidateTimes.length ? Math.min(...candidateTimes) : new Date(graphYearStart ?? new Date()).getTime()
|
||||
}
|
||||
|
||||
const resolveGraphEndDate = (
|
||||
@@ -110,180 +116,57 @@ const CreditsGraph = ({
|
||||
selectedStudyRightElement
|
||||
)
|
||||
return (
|
||||
<CreditAccumulationGraphHighCharts
|
||||
<StudentCreditAccumulation
|
||||
absences={absences}
|
||||
endDate={endDate}
|
||||
programmeCodes={null}
|
||||
selectedStudyPlan={selectedStudyPlan}
|
||||
showBachelorAndMaster={null}
|
||||
singleStudent
|
||||
startDate={selectedStart}
|
||||
students={[student]}
|
||||
studyPlanFilterIsActive={null}
|
||||
student={student}
|
||||
studyRightId={studyRightId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const semesterChunkify = (courses, semesters, getTextIn: ReturnType<typeof useLanguage>['getTextIn']) => {
|
||||
const semesterChunks = courses.reduce((acc, curr) => {
|
||||
const semester = semesters.find(
|
||||
semester => dayjs(curr.date).isSameOrAfter(semester.startdate) && dayjs(curr.date).isBefore(semester.enddate)
|
||||
)
|
||||
const semesterData = acc.find(data => data.semester === semester.name)
|
||||
if (semesterData) {
|
||||
semesterData.data.push(curr)
|
||||
} else {
|
||||
acc.push({ data: [curr], semester: semester.name })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunks.reduce((acc, curr) => {
|
||||
const gradeSum = curr.data.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.data.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.data.length > 0)
|
||||
acc.push({
|
||||
name: getTextIn(curr.semester),
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr.data[curr.data.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
return semesterMeans
|
||||
}
|
||||
|
||||
const gradeMeanSeries = (
|
||||
student: any,
|
||||
chunksize: number,
|
||||
semesters: SemestersData['semesters'] | undefined,
|
||||
getTextIn: ReturnType<typeof useLanguage>['getTextIn']
|
||||
) => {
|
||||
const filteredCourses = student.courses.filter(
|
||||
course => !Number.isNaN(Number(course.grade)) && !course.isStudyModuleCredit && course.passed
|
||||
)
|
||||
|
||||
const coursesGroupedByDate = groupBy(filteredCourses, 'date')
|
||||
|
||||
const gradesAndMeans = Object.values(coursesGroupedByDate).reduce(
|
||||
(acc, courses) => {
|
||||
for (const course of courses) {
|
||||
acc.grades.push({
|
||||
grade: Number(course.grade),
|
||||
date: course.date,
|
||||
credits: course.credits,
|
||||
})
|
||||
// Weighted average: each grade is multiplied by the amount of credits the course is worth
|
||||
acc.totalGradeSum += Number(course.grade) * course.credits
|
||||
acc.totalCredits += course.credits
|
||||
}
|
||||
acc.mean.push({ y: acc.totalGradeSum / acc.totalCredits, x: new Date(courses[0].date).getTime() })
|
||||
return acc
|
||||
},
|
||||
{
|
||||
grades: [] as Array<{ grade: number; date: string; credits: number }>,
|
||||
mean: [] as Array<{ y: number; x: number }>,
|
||||
totalGradeSum: 0,
|
||||
totalCredits: 0,
|
||||
}
|
||||
)
|
||||
|
||||
const size = Number(chunksize) ? chunksize : 3
|
||||
const chunks = chunk(gradesAndMeans.grades, size)
|
||||
|
||||
const groupMeans = chunks.reduce<Array<{ name: string; y: number; x: number }>>((acc, curr) => {
|
||||
const gradeSum = curr.reduce((a, b) => a + b.grade * b.credits, 0)
|
||||
const creditSum = curr.reduce((a, b) => a + b.credits, 0)
|
||||
if (curr.length > 0)
|
||||
acc.push({
|
||||
name: `${curr.length} courses between ${reformatDate(curr[0].date, DateFormat.DISPLAY_DATE)} and ${reformatDate(curr[curr.length - 1].date, DateFormat.DISPLAY_DATE)}`,
|
||||
y: gradeSum / creditSum,
|
||||
x: new Date(curr[curr.length - 1].date).getTime(),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
const semesterMeans = semesterChunkify(gradesAndMeans.grades, Object.values(semesters ?? {}), getTextIn)
|
||||
|
||||
return {
|
||||
totalMeans: [{ data: gradesAndMeans.mean }],
|
||||
groupMeans: [{ data: groupMeans }],
|
||||
semesterMeans: [{ data: semesterMeans }],
|
||||
}
|
||||
}
|
||||
|
||||
const GradeGraph = ({ student }: { student: any }) => {
|
||||
const { getTextIn } = useLanguage()
|
||||
const GradeGraphs = ({ student }: { student: StudentPageStudent }) => {
|
||||
const [graphMode, setGraphMode] = useState<'total' | 'group' | 'semester'>('total')
|
||||
const [groupSize, setGroupSize] = useState(5)
|
||||
const [graphMode, setGraphMode] = useState('total')
|
||||
const { data: semesters } = useGetSemestersQuery()
|
||||
const { semesters: allSemesters } = semesters ?? { semesters: {} }
|
||||
|
||||
const series = useMemo(
|
||||
() => gradeMeanSeries(student, groupSize, allSemesters, getTextIn),
|
||||
[student, groupSize, semesters, getTextIn]
|
||||
)
|
||||
const { totalMeans, groupMeans, semesterMeans } = series
|
||||
|
||||
const defaultOptions = {
|
||||
chart: {
|
||||
type: 'spline',
|
||||
},
|
||||
tooltip: {
|
||||
pointFormat: '{point.y:.2f}',
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime',
|
||||
},
|
||||
yAxis: {
|
||||
min: 1,
|
||||
max: 5.1,
|
||||
endOnTick: false,
|
||||
},
|
||||
}
|
||||
|
||||
const totalMeanOptions = { ...defaultOptions, series: totalMeans }
|
||||
const groupMeanOptions = { ...defaultOptions, series: groupMeans }
|
||||
const semesterMeanOptions = { ...defaultOptions, series: semesterMeans }
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<Alert severity="info" sx={{ maxWidth: '75%' }}>
|
||||
<AlertTitle>Grade graph</AlertTitle>
|
||||
Painotettu keskiarvo lasketaan kaikista niistä opintojaksoista, joiden arviointiasteikko on 0–5.{' '}
|
||||
<b>Total mean</b> näyttää, kuinka keskiarvo on kehittynyt opintojen aikana. <b>Group mean</b> jakaa kurssit
|
||||
valitun kokoisiin ryhmiin ja laskee niiden keskiarvot. <b>Semester mean</b> laskee jokaisen lukukauden
|
||||
keskiarvon.
|
||||
</Alert>
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
exclusive
|
||||
onChange={(_event, newMode) => setGraphMode(newMode)}
|
||||
value={graphMode}
|
||||
>
|
||||
<ToggleButton value="total">Show total mean</ToggleButton>
|
||||
<ToggleButton value="group">Show group mean</ToggleButton>
|
||||
<ToggleButton value="semester">Show semester mean</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{graphMode === 'group' && (
|
||||
<TextField
|
||||
data-cy="group-size-input"
|
||||
label="Group size"
|
||||
onChange={event => {
|
||||
if (!Number.isNaN(Number(event.target.value))) {
|
||||
setGroupSize(Number(event.target.value))
|
||||
}
|
||||
}}
|
||||
value={groupSize}
|
||||
/>
|
||||
)}
|
||||
<Box width="100%">
|
||||
{graphMode === 'total' && <ReactHighcharts config={totalMeanOptions} />}
|
||||
{graphMode === 'group' && <ReactHighcharts config={groupMeanOptions} />}
|
||||
{graphMode === 'semester' && <ReactHighcharts config={semesterMeanOptions} />}
|
||||
</Box>
|
||||
</Stack>
|
||||
<>
|
||||
<Stack alignItems="center" spacing={2}>
|
||||
<StyledMessage showIcon title="Grade graph" variant="standard">
|
||||
Painotettu keskiarvo lasketaan kaikista niistä opintojaksoista, joiden arviointiasteikko on 0–5.{' '}
|
||||
<b>Total mean</b> näyttää, kuinka keskiarvo on kehittynyt opintojen aikana. <b>Group mean</b> jakaa kurssit
|
||||
valitun kokoisiin ryhmiin ja laskee niiden keskiarvot. <b>Semester mean</b> laskee jokaisen lukukauden
|
||||
keskiarvon.
|
||||
</StyledMessage>
|
||||
|
||||
<ToggleButtonGroup
|
||||
color="primary"
|
||||
exclusive
|
||||
onChange={(_event, newMode) => setGraphMode(newMode)}
|
||||
value={graphMode}
|
||||
>
|
||||
<ToggleButton value="total">Show total mean</ToggleButton>
|
||||
<ToggleButton value="group">Show group mean</ToggleButton>
|
||||
<ToggleButton value="semester">Show semester mean</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
{graphMode === 'group' && (
|
||||
<TextField
|
||||
data-cy="group-size-input"
|
||||
label="Group size"
|
||||
onChange={event => {
|
||||
if (!Number.isNaN(Number(event.target.value))) {
|
||||
setGroupSize(Number(event.target.value))
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
value={groupSize}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<StudentGradeGraphs graphMode={graphMode} groupSize={groupSize} student={student} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -315,7 +198,7 @@ export const StudentGraphs = ({
|
||||
student={student}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 1 && <GradeGraph student={student} />}
|
||||
{activeTab === 1 && <GradeGraphs student={student} />}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import accessibility from 'highcharts/modules/accessibility'
|
||||
import exportData from 'highcharts/modules/export-data'
|
||||
import exporting from 'highcharts/modules/exporting'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
|
||||
import { Section } from '@/components/Section'
|
||||
import { GraphStat } from '@/types/graphStat'
|
||||
|
||||
exporting(ReactHighcharts.Highcharts)
|
||||
exportData(ReactHighcharts.Highcharts)
|
||||
accessibility(ReactHighcharts.Highcharts)
|
||||
|
||||
// TODO: Move to theme
|
||||
const colors = ['#003E65', '#1392c2', '#036415']
|
||||
|
||||
export const BarChart = ({ id, graphStats, years }: { id: string; graphStats: GraphStat[]; years: number[] }) => {
|
||||
if (!graphStats || !id || !years) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataWithColors = graphStats.map((series, index) => ({
|
||||
...series,
|
||||
color: colors[index],
|
||||
type: 'column' as const,
|
||||
}))
|
||||
|
||||
const config: Highcharts.Options = {
|
||||
series: dataWithColors,
|
||||
xAxis: {
|
||||
categories: years.map(year => year.toString()),
|
||||
},
|
||||
chart: {
|
||||
type: 'column' as const,
|
||||
height: '450px',
|
||||
},
|
||||
exporting: {
|
||||
filename: `oodikone_graduations_and_thesis_of_degree_programme_${id}`,
|
||||
},
|
||||
plotOptions: {
|
||||
column: {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
allowDecimals: false,
|
||||
min: 0,
|
||||
reversed: false,
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Section cypress="graduated-and-thesis-writers-of-the-programme-bar-chart">
|
||||
<ReactHighcharts config={config} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,11 @@ import Typography from '@mui/material/Typography'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
|
||||
import { LineGraph } from '@/components/common/LineGraph'
|
||||
import { MedianTimeBarChart } from '@/components/common/MedianTimeBarChart'
|
||||
import { StackedBarChart } from '@/components/common/StackedBarChart'
|
||||
import { BarChart } from '@/components/Charts/BarChart'
|
||||
import { LineGraph } from '@/components/Charts/LineGraph'
|
||||
import { StackedBarChart } from '@/components/Charts/StackedBarChart'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
|
||||
import { BreakdownBarChart } from '@/components/GraduationTimes/BreakdownDisplay/BreakdownBarChartV2'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { Section } from '@/components/Section'
|
||||
import { DataTable } from '@/components/StudyProgramme/DataTable'
|
||||
@@ -19,7 +18,7 @@ import { makeGraphData, makeTableStats } from '@/util/creditsProduced'
|
||||
import { getGraduationGraphTitle, isNewProgramme } from '@/util/studyProgramme'
|
||||
import { getTimestamp } from '@/util/timeAndDate'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { BarChart } from './BarChart'
|
||||
import { GraduationTimes, type GraduationTimesProps } from '@/components/GraduationTimes'
|
||||
|
||||
const getGraduatedText = (code: string) => {
|
||||
if (code.startsWith('T') || code.startsWith('LIS')) {
|
||||
@@ -87,6 +86,16 @@ export const BasicInformationTab = ({
|
||||
const graduationsIsError = graduations.isError || (graduations.isSuccess && !graduations.data)
|
||||
const hasErrors = basicsIsError || creditsIsError || graduationsIsError
|
||||
|
||||
const commonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'level' | 'title'> = {
|
||||
allowExpand: false,
|
||||
groupBy: 'byGradYear',
|
||||
isError: graduationsIsError,
|
||||
isLoading: graduationsIsLoading,
|
||||
mode: 'programme',
|
||||
showMedian,
|
||||
yearLabel: 'Graduation year',
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Section>
|
||||
@@ -209,9 +218,9 @@ export const BasicInformationTab = ({
|
||||
infoBoxContent={studyProgrammeToolTips.averageGraduationTimes}
|
||||
isError={graduationsIsError}
|
||||
isLoading={graduationsIsLoading}
|
||||
title="Average graduation times"
|
||||
title="Average graduation times by graduation year"
|
||||
>
|
||||
{graduations.isSuccess && graduations.data ? (
|
||||
{graduations.isSuccess && graduations.data && (
|
||||
<Stack gap={2}>
|
||||
<ToggleContainer>
|
||||
<Toggle
|
||||
@@ -224,52 +233,55 @@ export const BasicInformationTab = ({
|
||||
</ToggleContainer>
|
||||
{showMedian ? (
|
||||
<>
|
||||
{doCombo ? (
|
||||
<MedianTimeBarChart
|
||||
byStartYear={false}
|
||||
data={graduations?.data?.comboTimes?.medians}
|
||||
{doCombo && (
|
||||
<GraduationTimes
|
||||
data={graduations.data?.comboTimes.medians.map(item => ({ median: item.y, ...item }))}
|
||||
goal={graduations?.data?.comboTimes?.goal}
|
||||
title={getGraduationGraphTitle(studyProgramme, doCombo)}
|
||||
/>
|
||||
) : null}
|
||||
{studyProgramme !== 'MH90_001' && (
|
||||
<MedianTimeBarChart
|
||||
byStartYear={false}
|
||||
data={timesData?.medians}
|
||||
goal={graduations?.data.graduationTimes?.goal}
|
||||
title={getGraduationGraphTitle(studyProgramme)}
|
||||
title={getGraduationGraphTitle(studyProgramme, true)}
|
||||
{...commonProps}
|
||||
/>
|
||||
)}
|
||||
{combinedProgramme ? (
|
||||
<MedianTimeBarChart
|
||||
byStartYear={false}
|
||||
data={timesDataSecondProgramme?.medians}
|
||||
goal={graduations?.data.graduationTimesSecondProgramme?.goal}
|
||||
<GraduationTimes
|
||||
data={graduations.data?.graduationTimes.medians.map(item => ({ median: item.y, ...item }))}
|
||||
goal={graduations?.data?.graduationTimes?.goal}
|
||||
title={getGraduationGraphTitle(studyProgramme, false)}
|
||||
{...commonProps}
|
||||
/>
|
||||
{combinedProgramme && (
|
||||
<GraduationTimes
|
||||
data={graduations.data?.graduationTimesSecondProgramme.medians.map(item => ({ median: item.y, ...item }))}
|
||||
goal={graduations?.data?.graduationTimesSecondProgramme?.goal}
|
||||
title={getGraduationGraphTitle(combinedProgramme, true)}
|
||||
{...commonProps}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{doCombo ? (
|
||||
<BreakdownBarChart
|
||||
data={graduations?.data?.comboTimes?.medians}
|
||||
title={getGraduationGraphTitle(studyProgramme, doCombo)}
|
||||
{doCombo && (
|
||||
<GraduationTimes
|
||||
data={graduations.data?.comboTimes.medians.map(item => ({ median: item.y, ...item }))}
|
||||
goal={graduations?.data?.comboTimes?.goal}
|
||||
title={getGraduationGraphTitle(studyProgramme, true)}
|
||||
{...commonProps}
|
||||
/>
|
||||
) : null}
|
||||
{studyProgramme !== 'MH90_001' && (
|
||||
<BreakdownBarChart data={timesData?.medians} title={getGraduationGraphTitle(studyProgramme)} />
|
||||
)}
|
||||
{combinedProgramme ? (
|
||||
<BreakdownBarChart
|
||||
data={timesDataSecondProgramme?.medians}
|
||||
<GraduationTimes
|
||||
data={timesData?.medians.map(item => ({ median: item.y, ...item }))}
|
||||
title={getGraduationGraphTitle(studyProgramme, false)}
|
||||
{...commonProps}
|
||||
/>
|
||||
{combinedProgramme && (
|
||||
<GraduationTimes
|
||||
data={timesDataSecondProgramme?.medians.map(item => ({ median: item.y, ...item }))}
|
||||
title={getGraduationGraphTitle(combinedProgramme, true)}
|
||||
{...commonProps}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { ProgressBarChart } from '@/components/common/ProgressBarChart'
|
||||
import { ProgressBarChart } from '@/components/Charts/ProgressBarChart'
|
||||
import { ProgressTable } from './ProgressTable'
|
||||
|
||||
type ProgressStats = {
|
||||
|
||||
+69
-105
@@ -7,18 +7,16 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { getTargetCreditsForProgramme } from '@/common'
|
||||
import { studyProgrammeToolTips } from '@/common/InfoToolTips'
|
||||
import { MedianTimeBarChart } from '@/components/common/MedianTimeBarChart'
|
||||
import { Toggle } from '@/components/common/toggle/Toggle'
|
||||
import { ToggleContainer } from '@/components/common/toggle/ToggleContainer'
|
||||
import { GraduationTimes } from '@/components/GraduationTimes'
|
||||
import { BreakdownBarChart } from '@/components/GraduationTimes/BreakdownDisplay/BreakdownBarChartV2'
|
||||
import { GraduationTimes, GraduationTimesProps } from '@/components/GraduationTimes'
|
||||
import { Section } from '@/components/Section'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { useGetStudyTrackStatsQuery } from '@/redux/studyProgramme'
|
||||
import { hasAccessToProgrammePopulation } from '@/util/access'
|
||||
import { calculateStats } from '@/util/faculty'
|
||||
import { getGraduationGraphTitle } from '@/util/studyProgramme'
|
||||
import { ProgrammeOrStudyTrackGraduationStats, ProgrammeClassSizes, ProgrammeMedians } from '@oodikone/shared/types'
|
||||
import { ProgrammeOrStudyTrackGraduationStats, ProgrammeClassSizes, ProgrammeMedians, MedianEntry } from '@oodikone/shared/types'
|
||||
import { ProgressOfStudents } from './ProgressOfStudents'
|
||||
import { StudyTrackDataTable } from './StudyTrackDataTable'
|
||||
import { StudyTrackSelector } from './StudyTrackSelector'
|
||||
@@ -95,10 +93,10 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
const progressComboStats =
|
||||
Object.keys(studyTrackStats?.creditCountsCombo ?? {}).length > 0
|
||||
? calculateStats(
|
||||
studyTrackStats?.creditCountsCombo,
|
||||
studyTrackStats?.graduatedCount,
|
||||
getTargetCreditsForProgramme(programmeCode) + 180
|
||||
)
|
||||
studyTrackStats?.creditCountsCombo,
|
||||
studyTrackStats?.graduatedCount,
|
||||
getTargetCreditsForProgramme(programmeCode) + 180
|
||||
)
|
||||
: null
|
||||
|
||||
if (progressComboStats?.chartStats) {
|
||||
@@ -122,7 +120,7 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
const hasStudyTracks = Object.keys(studyTrackStats?.studyTracks ?? {}).length > 1 && studyTrack === studyProgramme
|
||||
const isStudyProgrammeMode = studyTrack === '' || studyTrack === studyProgramme
|
||||
|
||||
const calculateStudyTrackStats = (combo = false) => {
|
||||
const calculateStudyTrackStats = (combo: boolean) => {
|
||||
if (!studyTrackStats?.graduationTimes) {
|
||||
return {}
|
||||
}
|
||||
@@ -168,13 +166,28 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
return { studyTrackStatsGraduationStats, studyTrackStatsClassSizes }
|
||||
}
|
||||
|
||||
if (hasStudyTracks && Object.keys(studyTrackStats?.graduationTimes ?? {}).length > 1) {
|
||||
studyTrackStatsGraduationStats.basic = calculateStudyTrackStats()
|
||||
if (Object.keys(studyTrackStats?.graduationTimes ?? {}).length > 1) {
|
||||
studyTrackStatsGraduationStats.basic = calculateStudyTrackStats(false)
|
||||
if (studyTrackStats?.doCombo) {
|
||||
studyTrackStatsGraduationStats.combo = calculateStudyTrackStats(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatMedianEntries = (item: MedianEntry) => ({ median: item.y, ...item })
|
||||
|
||||
const graduationTimeCommonProps: Omit<GraduationTimesProps, 'data' | 'goal' | 'title'> = {
|
||||
allowExpand: hasStudyTracks,
|
||||
isError: hasErrors,
|
||||
isLoading: isFetchingOrLoading,
|
||||
goalExceptions: { needed: false },
|
||||
|
||||
names: studyTrackStats?.studyTracks,
|
||||
showMedian: showMedian,
|
||||
|
||||
mode: "study track",
|
||||
yearLabel: "Start year",
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Section cypress="study-track-selector">
|
||||
@@ -209,9 +222,9 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
isLoading={isFetchingOrLoading}
|
||||
title={`Students of the study ${isStudyProgrammeMode ? 'programme' : `track ${studyTrack}`} by starting year`}
|
||||
>
|
||||
{studyTrackStats ? (
|
||||
{!!studyTrackStats && (
|
||||
<Stack gap={2}>
|
||||
{hasAccessToPopulation ? (
|
||||
{hasAccessToPopulation && (
|
||||
<>
|
||||
<YearSelector
|
||||
studyProgramme={studyProgramme}
|
||||
@@ -220,7 +233,7 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
<ToggleContainer>
|
||||
<Toggle
|
||||
firstLabel="Hide percentages"
|
||||
@@ -229,7 +242,7 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
value={showPercentages}
|
||||
/>
|
||||
</ToggleContainer>
|
||||
{studyTrackStats ? (
|
||||
{!!studyTrackStats && (
|
||||
<StudyTrackDataTable
|
||||
combinedProgramme={combinedProgramme}
|
||||
dataOfAllTracks={studyTrackStats.mainStatsByYear}
|
||||
@@ -245,9 +258,9 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
titles={studyTrackStats.populationTitles}
|
||||
years={studyTrackStats.years}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{isStudyProgrammeMode ? (
|
||||
@@ -258,14 +271,14 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
isLoading={isFetchingOrLoading}
|
||||
title="Progress of students of the degree programme by starting year"
|
||||
>
|
||||
{studyTrackStats ? (
|
||||
{!!studyTrackStats && (
|
||||
<ProgressOfStudents
|
||||
progressComboStats={progressComboStats}
|
||||
progressStats={progressStats}
|
||||
studyProgramme={studyProgramme}
|
||||
years={studyTrackStats.years}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</Section>
|
||||
) : (
|
||||
<Section title={`Progress of students of the study track ${studyTrack} by starting year`}>
|
||||
@@ -277,7 +290,7 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{isSuccess && studyTrackStats?.graduationTimes[studyTrack] ? (
|
||||
{isSuccess && studyTrack in studyTrackStats?.graduationTimes && (
|
||||
<Section
|
||||
cypress="average-graduation-times"
|
||||
infoBoxContent={
|
||||
@@ -289,6 +302,7 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
isLoading={isFetchingOrLoading}
|
||||
title="Average graduation times by starting year"
|
||||
>
|
||||
|
||||
<Stack gap={2}>
|
||||
<ToggleContainer>
|
||||
<Toggle
|
||||
@@ -301,108 +315,58 @@ export const StudyTracksAndClassStatisticsTab = ({
|
||||
</ToggleContainer>
|
||||
{hasStudyTracks ? (
|
||||
<Stack gap={2}>
|
||||
{studyTrackStats.doCombo && studyTrackStatsGraduationStats.combo.studyTrackStatsGraduationStats ? (
|
||||
{studyTrackStats.doCombo && !!studyTrackStatsGraduationStats.combo.studyTrackStatsGraduationStats && (
|
||||
<GraduationTimes
|
||||
classSizes={studyTrackStatsGraduationStats.combo.studyTrackStatsClassSizes}
|
||||
data={studyTrackStats.graduationTimes[studyProgramme].medians.combo.map(year => ({
|
||||
amount: year.amount,
|
||||
median: year.y,
|
||||
name: year.name,
|
||||
statistics: year.statistics,
|
||||
times: null,
|
||||
}))}
|
||||
classSizes={studyTrackStatsGraduationStats.combo.studyTrackStatsClassSizes!}
|
||||
data={studyTrackStats.graduationTimes[studyProgramme].medians.combo.map(formatMedianEntries)}
|
||||
goal={studyTrackStats.graduationTimes.goals.combo}
|
||||
goalExceptions={{ needed: false }}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
level="programme"
|
||||
levelProgrammeData={studyTrackStatsGraduationStats.combo.studyTrackStatsGraduationStats}
|
||||
mode="study track"
|
||||
names={studyTrackStats.studyTracks}
|
||||
showMedian={showMedian}
|
||||
title={getGraduationGraphTitle(studyProgramme, true)}
|
||||
yearLabel="Start year"
|
||||
{...graduationTimeCommonProps}
|
||||
/>
|
||||
) : null}
|
||||
{studyTrackStatsGraduationStats.basic.studyTrackStatsGraduationStats ? (
|
||||
)}
|
||||
{!!studyTrackStatsGraduationStats.basic.studyTrackStatsGraduationStats && (
|
||||
<GraduationTimes
|
||||
classSizes={studyTrackStatsGraduationStats.basic.studyTrackStatsClassSizes}
|
||||
data={studyTrackStats.graduationTimes[studyProgramme].medians.basic.map(year => ({
|
||||
amount: year.amount,
|
||||
median: year.y,
|
||||
name: year.name,
|
||||
statistics: year.statistics,
|
||||
times: null,
|
||||
}))}
|
||||
classSizes={studyTrackStatsGraduationStats.basic.studyTrackStatsClassSizes!}
|
||||
data={studyTrackStats.graduationTimes[studyProgramme].medians.basic.map(formatMedianEntries)}
|
||||
goal={studyTrackStats.graduationTimes.goals.basic}
|
||||
goalExceptions={{ needed: false }}
|
||||
isError={false}
|
||||
isLoading={false}
|
||||
level="programme"
|
||||
levelProgrammeData={studyTrackStatsGraduationStats.basic.studyTrackStatsGraduationStats}
|
||||
mode="study track"
|
||||
names={studyTrackStats.studyTracks}
|
||||
showMedian={showMedian}
|
||||
title={getGraduationGraphTitle(studyProgramme, false)}
|
||||
yearLabel="Start year"
|
||||
{...graduationTimeCommonProps}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
{showMedian ? (
|
||||
<>
|
||||
{studyTrackStats.doCombo ? (
|
||||
<MedianTimeBarChart
|
||||
byStartYear
|
||||
data={studyTrackStats?.graduationTimes[studyTrack].medians.combo}
|
||||
goal={studyTrackStats?.graduationTimes.goals.combo}
|
||||
title={getGraduationGraphTitle(studyProgramme, studyTrackStats.doCombo)}
|
||||
/>
|
||||
) : null}
|
||||
<MedianTimeBarChart
|
||||
byStartYear
|
||||
data={studyTrackStats?.graduationTimes[studyTrack].medians.basic}
|
||||
goal={studyTrackStats?.graduationTimes.goals.basic}
|
||||
title={getGraduationGraphTitle(studyProgramme, false)}
|
||||
/>
|
||||
{combinedProgramme ? (
|
||||
<MedianTimeBarChart
|
||||
byStartYear
|
||||
data={studyTrackStats?.graduationTimesSecondProg[combinedProgramme]?.medians?.combo}
|
||||
goal={studyTrackStats?.graduationTimesSecondProg.goals.combo}
|
||||
title={getGraduationGraphTitle(combinedProgramme, true)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{studyTrackStats.doCombo ? (
|
||||
<BreakdownBarChart
|
||||
byStartYear
|
||||
data={studyTrackStats?.graduationTimes[studyTrack]?.medians?.combo}
|
||||
title={getGraduationGraphTitle(studyProgramme, studyTrackStats.doCombo)}
|
||||
/>
|
||||
) : null}
|
||||
<BreakdownBarChart
|
||||
byStartYear
|
||||
data={studyTrackStats?.graduationTimes[studyTrack]?.medians?.basic}
|
||||
title={getGraduationGraphTitle(studyProgramme, false)}
|
||||
/>
|
||||
{combinedProgramme ? (
|
||||
<BreakdownBarChart
|
||||
byStartYear
|
||||
data={studyTrackStats?.graduationTimesSecondProg[combinedProgramme]?.medians?.combo}
|
||||
title={getGraduationGraphTitle(combinedProgramme, true)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<Stack gap={2} >
|
||||
{studyTrackStats.doCombo && (
|
||||
<GraduationTimes
|
||||
data={studyTrackStats.graduationTimes[studyTrack].medians.combo.map(formatMedianEntries)}
|
||||
goal={studyTrackStats.graduationTimes.goals.combo}
|
||||
title={getGraduationGraphTitle(studyProgramme, true)}
|
||||
{...graduationTimeCommonProps}
|
||||
/>
|
||||
)}
|
||||
<GraduationTimes
|
||||
classSizes={studyTrackStatsGraduationStats.basic.studyTrackStatsClassSizes}
|
||||
data={studyTrackStats.graduationTimes[studyTrack].medians.basic.map(formatMedianEntries)}
|
||||
goal={studyTrackStats.graduationTimes.goals.basic}
|
||||
title={getGraduationGraphTitle(studyProgramme, false)}
|
||||
{...graduationTimeCommonProps}
|
||||
/>
|
||||
{!!combinedProgramme && (
|
||||
<GraduationTimes
|
||||
data={studyTrackStats.graduationTimesSecondProg[combinedProgramme].medians.combo.map(formatMedianEntries)}
|
||||
classSizes={studyTrackStatsGraduationStats.combo.studyTrackStatsClassSizes!}
|
||||
goal={studyTrackStats.graduationTimesSecondProg.goals.combo}
|
||||
title={getGraduationGraphTitle(combinedProgramme, true)}
|
||||
{...graduationTimeCommonProps}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Section>
|
||||
) : null}
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Section } from '@/components/Section'
|
||||
import { useGetAllFacultiesGraduationStatsQuery } from '@/redux/facultyStats'
|
||||
|
||||
export const FacultyGraduationsTab = () => {
|
||||
const { data, isFetching, isLoading, isError } = useGetAllFacultiesGraduationStatsQuery()
|
||||
const { data, isFetching, isError } = useGetAllFacultiesGraduationStatsQuery()
|
||||
|
||||
const [medianMode, setMedianMode] = useState(false)
|
||||
|
||||
@@ -24,7 +24,7 @@ export const FacultyGraduationsTab = () => {
|
||||
<ToggleContainer>
|
||||
<Toggle
|
||||
cypress="graduation-time-toggle"
|
||||
disabled={isFetching || isLoading || isError}
|
||||
disabled={isFetching || isError}
|
||||
firstLabel="Breakdown"
|
||||
secondLabel="Median times"
|
||||
setValue={() => setMedianMode(!medianMode)}
|
||||
@@ -35,7 +35,7 @@ export const FacultyGraduationsTab = () => {
|
||||
<FacultyGraduations
|
||||
data={data}
|
||||
isError={isError}
|
||||
isLoading={isFetching || isLoading}
|
||||
isLoading={isFetching}
|
||||
showMedian={medianMode}
|
||||
universityMode
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user