From 8012b9f01ead404f3f25337cf27d5d62fc5a5f70 Mon Sep 17 00:00:00 2001
From: Bjarke Madsen <bjarke@nordu.net>
Date: Thu, 20 Apr 2023 14:31:31 +0200
Subject: [PATCH] Add staff graph page in frontend
---
webapp/src/App.tsx | 15 ++-
webapp/src/Schema.tsx | 28 ++++-
webapp/src/helpers/dataconversion.tsx | 136 ++++++++++++++++++++-
webapp/src/pages/CompendiumData.tsx | 6 +
webapp/src/pages/StaffGraph.tsx | 170 ++++++++++++++++++++++++++
5 files changed, 344 insertions(+), 11 deletions(-)
create mode 100644 webapp/src/pages/StaffGraph.tsx
diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx
index 055bfaeb..ea11d0fe 100644
--- a/webapp/src/App.tsx
+++ b/webapp/src/App.tsx
@@ -8,6 +8,7 @@ import AnnualReport from "./pages/AnnualReport";
import CompendiumData from "./pages/CompendiumData";
import FundingSourcePage from "./pages/FundingSource";
import ChargingStructurePage from "./pages/ChargingStructure";
+import StaffGraph from "./pages/StaffGraph";
import { FilterSelection } from "./Schema";
@@ -21,21 +22,19 @@ function App(): ReactElement {
<ExternalPageNavBar />
<Routes>
<Route path="/data" element={<CompendiumData />} />
- <Route path="/analysis" element={<DataAnalysis
- filterSelection={filterSelection}
- setFilterSelection={setFilterSelection}/>}
+ <Route path="/analysis" element={
+ <DataAnalysis filterSelection={filterSelection} setFilterSelection={setFilterSelection} />}
/>
<Route path="/report" element={<AnnualReport />} />
- <Route path="/funding" element={<FundingSourcePage
- filterSelection={filterSelection}
- setFilterSelection={setFilterSelection}/>}
+ <Route path="/funding" element={<FundingSourcePage filterSelection={filterSelection} setFilterSelection={setFilterSelection} />}
/>
+ <Route path="/staff" element={<StaffGraph filterSelection={filterSelection} setFilterSelection={setFilterSelection} />} />
<Route path="/charging" element={<ChargingStructurePage />} />
<Route path="*" element={<Landing />} />
</Routes>
- <GeantFooter/>
+ <GeantFooter />
</Router>
-
+
</div>
);
}
diff --git a/webapp/src/Schema.tsx b/webapp/src/Schema.tsx
index e6f7c4c3..4d3a935d 100644
--- a/webapp/src/Schema.tsx
+++ b/webapp/src/Schema.tsx
@@ -55,7 +55,7 @@ export interface FundingSourceDataset {
}[]
}
-export interface ChargingStructure{
+export interface ChargingStructure {
NREN: string,
YEAR: number,
FEE_TYPE: (string | null),
@@ -97,3 +97,29 @@ export interface Service {
url: string,
value: string
}
+
+export interface NrenStaff {
+ nren: string,
+ year: number,
+ permanent_fte: number,
+ subcontracted_fte: number,
+ technical_fte: number,
+ non_technical_fte: number
+}
+
+export interface NrenStaffDataset {
+
+ labels: string[],
+ datasets: {
+ label: string,
+ data: number[],
+ backgroundColor: string
+ borderRadius: number,
+ borderSkipped: boolean,
+ barPercentage: number,
+ borderWidth: number,
+ categoryPercentage: number
+ stack: string
+ hidden: boolean
+ }[]
+}
\ No newline at end of file
diff --git a/webapp/src/helpers/dataconversion.tsx b/webapp/src/helpers/dataconversion.tsx
index a045bdd6..6a20cc92 100644
--- a/webapp/src/helpers/dataconversion.tsx
+++ b/webapp/src/helpers/dataconversion.tsx
@@ -1,10 +1,9 @@
import { cartesianProduct } from 'cartesian-product-multiple-arrays';
-
import {
FundingSource,
FundingSourceDataset,
ChargingStructure,
- ChargingStructureDataset, Budget, BasicDataset
+ ChargingStructureDataset, Budget, BasicDataset, NrenStaff, NrenStaffDataset
} from "../Schema";
const DEFAULT_CHARGING_STRUCTURE_DATA = [
@@ -244,3 +243,136 @@ export function createChargingStructureDataset(chargingStructureData: ChargingSt
}
+
+export const createNRENStaffDataset = (data: NrenStaff[], contractor: boolean) => {
+ function CreateDataLookup(data: NrenStaff[]) {
+ const dataLookup = new Map<string, Map<string, number>>();
+
+ data.forEach((item: NrenStaff) => {
+ const lookupKey = `${item.nren}/${item.year}`
+
+ let NrenStaffMap = dataLookup.get(lookupKey)
+ if (!NrenStaffMap) {
+ NrenStaffMap = new Map<string, number>();
+ }
+ const total = item.non_technical_fte + item.technical_fte
+ const technical_percentage = Math.round(((item.technical_fte / total) || 0) * 100)
+ const non_technical_percentage = Math.round(((item.non_technical_fte / total) || 0) * 100)
+
+ const contractor_total = item.subcontracted_fte + item.permanent_fte
+ const permanent_percentage = Math.round(((item.permanent_fte / contractor_total) || 0) * 100)
+ const subcontracted_percentage = Math.round(((item.subcontracted_fte / contractor_total) || 0) * 100)
+ NrenStaffMap.set("Technical FTE", technical_percentage)
+ NrenStaffMap.set("Non-technical FTE", non_technical_percentage)
+ NrenStaffMap.set("Permanent FTE", permanent_percentage)
+ NrenStaffMap.set("Subcontracted FTE", subcontracted_percentage)
+ dataLookup.set(lookupKey, NrenStaffMap)
+ })
+ return dataLookup
+ }
+
+ const dataLookup = CreateDataLookup(data)
+
+ const labelsYear = [...new Set(data.map((item: NrenStaff) => item.year))];
+ const labelsNREN = [...new Set(data.map((item: NrenStaff) => item.nren))];
+
+ let categories;
+
+ if (contractor) {
+ categories = [
+ "Permanent FTE",
+ "Subcontracted FTE"
+ ]
+ } else {
+ categories = [
+ "Technical FTE",
+ "Non-technical FTE"
+ ]
+ }
+
+
+ const categoriesPerYear = cartesianProduct(categories, labelsYear)
+
+ const nrenStaffDataset = categoriesPerYear.map(function ([category, year]) {
+
+ let color = ""
+ if (category === "Technical FTE") {
+ // color = dark blue
+ color = 'rgba(40, 40, 250, 0.8)'
+ } else if (category === "Permanent FTE") {
+ color = 'rgba(159, 129, 235, 1)'
+ } else if (category === "Subcontracted FTE") {
+ color = 'rgba(173, 216, 229, 1)'
+ }
+ else {
+ // light blue
+ color = 'rgba(116, 216, 242, 0.54)'
+ }
+ return {
+ backgroundColor: color,
+ label: `${category} (${year})`,
+ data: labelsNREN.map(nren => {
+ // ensure that the data is in the same order as the labels
+ const lookupKey = `${nren}/${year}`
+ const dataForYear = dataLookup.get(lookupKey)
+ if (!dataForYear) {
+ return 0
+ }
+ return dataForYear.get(category) ?? 0
+ }),
+ stack: year,
+ borderRadius: 10,
+ borderSkipped: true,
+ barPercentage: 0.8,
+ borderWidth: 0.5,
+ categoryPercentage: 0.8,
+ hidden: false
+ }
+ })
+
+ const dataset: NrenStaffDataset = {
+ datasets: nrenStaffDataset,
+ labels: labelsNREN.map(l => l.toString())
+ }
+
+
+ function sortDatasetByTechnicalFTE(dataset: NrenStaffDataset) {
+ // sort dataset by Technical FTE value descending and ensure labels are in the same order
+ const sortedDataset = { ...dataset }
+
+ const uniqueYears = [...new Set(dataset.datasets.map(d => d.stack))]
+
+ for (const year of uniqueYears) {
+ const technicalFTEIndex = dataset.datasets.findIndex(d => d.label === `Technical FTE (${year})` || d.label === `Permanent FTE (${year})`)
+ if (technicalFTEIndex === -1) {
+ continue
+ }
+ const nonTechnicalFTEIndex = dataset.datasets.findIndex(d => d.label === `Non-technical FTE (${year})` || d.label === `Subcontracted FTE (${year})`)
+ if (nonTechnicalFTEIndex === -1) {
+ continue
+ }
+
+ const datasetForYearTechnical = dataset.datasets[technicalFTEIndex];
+ const datasetForYearNonTechnical = dataset.datasets[nonTechnicalFTEIndex];
+
+ const dataByYear = datasetForYearTechnical.data.map((d, i) => ({
+ datatechnical: d,
+ datanontechnical: datasetForYearNonTechnical.data[i],
+ label: dataset.labels[i],
+ }))
+
+ function sortfunc(a: any, b: any) {
+ return b.datatechnical - a.datatechnical;
+ }
+ const sortedDataByYear = dataByYear.sort(sortfunc)
+
+ sortedDataset.datasets[technicalFTEIndex].data = sortedDataByYear.map(d => d.datatechnical)
+ sortedDataset.datasets[nonTechnicalFTEIndex].data = sortedDataByYear.map(d => d.datanontechnical)
+ sortedDataset.labels = sortedDataByYear.map(d => d.label)
+ }
+ return sortedDataset
+ }
+
+ const sortedDataset = sortDatasetByTechnicalFTE(dataset)
+ return sortedDataset;
+}
diff --git a/webapp/src/pages/CompendiumData.tsx b/webapp/src/pages/CompendiumData.tsx
index fdb821bb..6f413b30 100644
--- a/webapp/src/pages/CompendiumData.tsx
+++ b/webapp/src/pages/CompendiumData.tsx
@@ -44,6 +44,12 @@ function CompendiumData(): ReactElement {
<span>Charging Mechanism of NRENs per Year</span>
</Link>
</Row>
+
+ <Row>
+ <Link to="/staff" className="link-text-underline">
+ <span>Staff count of NRENs per Year</span>
+ </Link>
+ </Row>
</div>
</CollapsibleBox>
</div>
diff --git a/webapp/src/pages/StaffGraph.tsx b/webapp/src/pages/StaffGraph.tsx
new file mode 100644
index 00000000..5bdabfe3
--- /dev/null
+++ b/webapp/src/pages/StaffGraph.tsx
@@ -0,0 +1,170 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Col, Container, Row } from "react-bootstrap";
+import { Bar } from 'react-chartjs-2';
+import { createNRENStaffDataset } from "../helpers/dataconversion";
+import Filter from "../components/graphing/Filter"
+
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import {
+ NrenStaff,
+ FilterSelection
+} from "../Schema";
+
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Tooltip,
+ Legend
+);
+
+export const chartOptions = {
+ maintainAspectRatio: false,
+ animation: {
+ duration: 50
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: 'bottom' as const
+ },
+ tooltip: {
+ callbacks: {
+ label: function (tooltipItem) {
+ let label = tooltipItem.dataset.label || '';
+
+ if (tooltipItem.parsed.x !== null) {
+ label += `: ${tooltipItem.parsed.x}%`
+ }
+ return label;
+ }
+ },
+
+ },
+ },
+
+ scales: {
+ x: {
+ stacked: true,
+ ticks: {
+ callback: (value: string | number, index: number) => {
+ return `${index * 10}%`;
+ },
+ },
+ },
+ y: {
+ stacked: true,
+ ticks: {
+ autoSkip: false,
+ },
+ },
+ },
+ indexAxis: "y" as const
+};
+
+async function getData(): Promise<NrenStaff[]> {
+ try {
+ const response = await fetch('/api/staff/');
+ return response.json();
+ } catch (error) {
+ console.error(`Failed to load data: ${error}`);
+ throw error;
+ }
+}
+
+function getYearsAndNrens(sourceData: NrenStaff[]) {
+ const years = new Set<number>();
+ const nrens = new Set<string>();
+ sourceData.forEach(datapoint => {
+ years.add(datapoint.year);
+ nrens.add(datapoint.nren);
+ });
+ return { years: years, nrens: nrens };
+}
+
+interface inputProps {
+ filterSelection: FilterSelection
+ setFilterSelection: React.Dispatch<React.SetStateAction<FilterSelection>>
+}
+
+function StaffGraph({ filterSelection, setFilterSelection }: inputProps) {
+ const [staffData, setStaffData] = useState<NrenStaff[]>();
+ const [contractor, setContractor] = useState<boolean>(false);
+
+ const { years, nrens } = useMemo(
+ () => getYearsAndNrens(staffData || []),
+ [staffData]
+ );
+ const nrenStaffDataset = createNRENStaffDataset(staffData || [], contractor);
+
+ nrenStaffDataset.datasets.forEach(dataset => {
+ dataset.hidden = !filterSelection.selectedYears.includes(parseInt(dataset.stack));
+ });
+
+ // remove the datapoints and labels for the nrens that aren't selected
+ // unfortunately we cannot just hide them because graph.js doesn't want
+ // to create a stack from a single dataset
+ nrenStaffDataset.datasets.forEach(dataset => {
+ dataset.data = dataset.data.filter((e, i) => {
+ return filterSelection.selectedNrens.includes(nrenStaffDataset.labels[i]);
+ });
+ });
+ nrenStaffDataset.labels = nrenStaffDataset.labels.filter((e) => filterSelection.selectedNrens.includes(e));
+
+ useEffect(() => {
+ const loadData = async () => {
+ const staffData = await getData();
+ setStaffData(staffData);
+
+ // filter fallback for when nothing is selected (only last year for all nrens)
+ const { years, nrens } = getYearsAndNrens(staffData);
+ setFilterSelection(previous => {
+ const visibleYears = previous.selectedYears.filter(year => years.has(year));
+ const newSelectedYears = visibleYears.length ? previous.selectedYears : [Math.max(...years)];
+ const visibleNrens = previous.selectedNrens.filter(nren => nrens.has(nren));
+ const newSelectedNrens = visibleNrens.length ? previous.selectedNrens : [...nrens];
+ return { selectedYears: newSelectedYears, selectedNrens: newSelectedNrens };
+ });
+ }
+ loadData()
+ }, [setFilterSelection]);
+
+ return (
+ <Container>
+ <Row>
+ <Col xs={9}>
+ <Row>
+ <h3>NREN Staff</h3>
+ <input type="checkbox" id="contractor" name="contractor" value="contractor" onChange={() => setContractor(contractor => !contractor)} />
+ </Row>
+ <Row>
+ <div className="chart-container" style={{ 'minHeight': '60vh', 'width': '60vw', }}>
+ <Bar
+ data={nrenStaffDataset}
+ options={chartOptions}
+ />
+ </div>
+ </Row>
+ </Col>
+ <Col xs={3}>
+ <Filter
+ filterOptions={{ availableYears: [...years], availableNrens: [...nrens] }}
+ filterSelection={filterSelection}
+ setFilterSelection={setFilterSelection}
+ />
+ </Col>
+ </Row>
+ </Container>
+ );
+}
+export default StaffGraph;
--
GitLab