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