Compare commits

...
44 Commits
Author SHA1 Message Date
joonas-a 3d0fd3563f Delete highcharts 2026-05-15 17:49:11 +03:00
joonas-a a929613e83 [Course stats] enable data view on chart + cleanup 2026-05-15 17:48:36 +03:00
joonas-a d64aca5285 [Course stats] convert grade dist to echarts 2026-05-15 17:46:38 +03:00
joonas-a 9e1307595c [Course stats] convert pass rate chart to echarts 2026-05-15 17:37:15 +03:00
joonas-a 0e9ba8490d Delete old files 2026-05-15 17:17:47 +03:00
joonas-a 50b4166a7f [Charts] cleanup 2026-05-15 17:01:03 +03:00
joonas-a 3843937429 [Graduation times chart] more refactor 2026-05-15 15:55:42 +03:00
joonas-a b60b1dd88b [Graduation times chart] refactor 2026-05-15 14:56:49 +03:00
joonas-a ed513c14bf [Error background] put veterans to rest 2026-05-12 16:23:38 +03:00
joonas-a 8e2b8e5443 [University][Backend] fix graduation targets
from months to semesters like frontend expects
2026-05-12 15:39:58 +03:00
joonas-a bb215bb785 [Charts] bring back missing legends oops 2026-05-12 15:38:27 +03:00
joonas-a 635fb97b25 [Charts] final tweaks 2026-05-12 14:54:07 +03:00
joonas-a a5334c8e2f [Progres bar chart] Change emphasis strategy
fixes incorrect highlighting bug when clicking categories from the
legend
2026-05-12 14:25:18 +03:00
joonas-a 18b8daabff [Charts] convert horizontal stacked bar chart 2026-05-12 14:24:26 +03:00
joonas-a b181fda7fc [Charts] Fix styling issues 2026-05-12 13:27:23 +03:00
joonas-a 747c37697c [Median graduations] add guard for undefined data and fix style 2026-05-12 12:05:12 +03:00
joonas-a 76d8fef4eb [Dist devlpmnt] Update infobox 2026-05-12 11:51:06 +03:00
joonas-a 743ccb6797 [Credit accumulation] infobox update 2026-05-11 18:00:13 +03:00
joonas-a dab81f994d [Charts][Crdt accmltn] bring back click to open individual student 2026-05-11 16:51:42 +03:00
joonas-a b17fa2a11d [Charts][Graduation times] Convert breakdown chart 2026-05-11 15:25:09 +03:00
joonas-a ea8a84420f [Charts][Graduation times] Invert order to match previous behaviour 2026-05-11 15:24:43 +03:00
joonas-a 56d98e16cd [Charts][Graduation times] convert median chart 2026-05-11 13:56:52 +03:00
joonas-a b9b90876f1 [Charts][Year expansion chart] first attempt at echarts conversion 2026-04-29 16:08:35 +03:00
joonas-a 777a19b291 [Charts][LineGraph] Add emphasis strategy
Will make it so when hovering only one item blurs all the others
2026-04-29 11:13:15 +03:00
joonas-a 362930c481 [Student tab] change credit acc graph dot icon 2026-04-28 11:42:52 +03:00
joonas-a 8ca1636517 [Charts][Progress of students] convert to echarts 2026-04-27 17:22:29 +03:00
joonas-a d6beafa13a [Charts][Graduated/thesis writers] convert graph to echart 2026-04-27 14:03:49 +03:00
joonas-a f92d72b77a [Charts][StackedBarChart] echart conversion 2026-04-27 13:42:46 +03:00
joonas-a dca144d58b [Charts] Replace students of x with echarts 2026-04-27 13:08:21 +03:00
joonas-a 9a9f723b46 [Charts][Distribution development] Migrate to echarts 2026-04-21 12:41:39 +03:00
joonas-a e6686c024a [Charts][Student details] final touches 2026-04-20 14:12:21 +03:00
joonas-a 2aa6e9c549 [Student grade graph] Move legend to right for consistency 2026-04-20 12:39:44 +03:00
joonas-a 898a2b1f1b [Charts][Student details] Renew grade graphs 2026-04-20 12:25:39 +03:00
joonas-a 6a9a49952b [Frontend] Further enhance StyledMessage 2026-04-20 12:02:49 +03:00
joonas-a 0f76e50e4b [Frontend] Enhance StyledMessage 2026-04-20 11:59:16 +03:00
joonas-a d9695901b2 [Charts][Student details] remake credit graph 2026-04-16 16:08:15 +03:00
joonas-a b85edaf6f0 [Charts][Custom pop] Use new credit acc
Also add graduations if programme set.
2026-04-16 14:30:53 +03:00
joonas-a b165ece770 [Lint] fix 2026-04-16 14:19:51 +03:00
joonas-a a90e82a31e [Charts][SGG] use new credit acc 2026-04-16 14:18:58 +03:00
joonas-a 651e37051d [Charts][Credit acc] fix combined programme graduation markers 2026-04-16 13:55:21 +03:00
joonas-a 77b6d1cb16 [Charts] cleanup and edge case handling 2026-04-16 12:48:30 +03:00
joonas-a afa6804d09 [Charts] Credit acc done for non bscmsc classes 2026-04-14 16:19:46 +03:00
joonas-a a57f2f01ca [Charts][Credit accmltn] hops filter functionality 2026-04-13 14:40:44 +03:00
joonas-a 1b4bdff37e [Charts][poc] credit accumulation 2026-04-02 16:14:04 +03:00
49 changed files with 3219 additions and 2474 deletions
+7 -1
View File
@@ -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[]>,
+53 -61
View File
@@ -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",
+2 -10
View File
@@ -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).
+7 -4
View File
@@ -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` })
}
@@ -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%' }}
/>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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
@@ -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>
)
@@ -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>
)
@@ -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>
)
@@ -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,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 = {
@@ -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
/>