diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx
index 316b04dc0f1dc0671fc5bdc7b397a9bb918cd1e3..ff315557fa8bb89315ac9b9ded17712158a33193 100644
--- a/webapp/src/App.tsx
+++ b/webapp/src/App.tsx
@@ -12,6 +12,7 @@ import StaffGraph from "./pages/StaffGraph";
import { FilterSelection } from "./Schema";
import SubOrganisation from "./pages/SubOrganisation";
import ParentOrganisation from "./pages/ParentOrganisation";
+import ECProjects from "./pages/ECProjects";
function App(): ReactElement {
@@ -35,6 +36,7 @@ function App(): ReactElement {
<Route path="/charging" element={<ChargingStructurePage />} />
<Route path="/suborganisations" element={<SubOrganisation filterSelection={filterSelection} setFilterSelection={setFilterSelection} />} />
<Route path="/parentorganisation" element={<ParentOrganisation filterSelection={filterSelection} setFilterSelection={setFilterSelection} />} />
+ <Route path="/ec-projects" element={<ECProjects filterSelection={filterSelection} setFilterSelection={setFilterSelection} />} />
<Route path="*" element={<Landing />} />
</Routes>
<GeantFooter />
diff --git a/webapp/src/Schema.tsx b/webapp/src/Schema.tsx
index 449752b6489019b3dc9f6585ed7cb5c51c34e913..2161fa843b5e4223b20076b571fad6e01b0eb8a2 100644
--- a/webapp/src/Schema.tsx
+++ b/webapp/src/Schema.tsx
@@ -130,3 +130,9 @@ export interface Organisation {
name: string,
role?: string
}
+
+export interface ECProject {
+ nren: string,
+ year: number,
+ project: string,
+}
diff --git a/webapp/src/pages/CompendiumData.tsx b/webapp/src/pages/CompendiumData.tsx
index 998d8e4399ec32a78add487dfe0eaeeae37bfb54..50eee8b2fb5c4f244820a01b531796190bdcf3e7 100644
--- a/webapp/src/pages/CompendiumData.tsx
+++ b/webapp/src/pages/CompendiumData.tsx
@@ -66,6 +66,11 @@ function CompendiumData(): ReactElement {
<span>NREN Parent Organisations</span>
</Link>
</Row>
+ <Row>
+ <Link to="/ec-projects" className="link-text-underline">
+ <span>NREN Involvement in European Commission Projects</span>
+ </Link>
+ </Row>
</div>
</CollapsibleBox>
</div>
diff --git a/webapp/src/pages/ECProjects.tsx b/webapp/src/pages/ECProjects.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9691673409e1410d8b2664323aea66edd9af5a05
--- /dev/null
+++ b/webapp/src/pages/ECProjects.tsx
@@ -0,0 +1,147 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Col, Container, Row, Table } from "react-bootstrap";
+import Filter from "../components/graphing/Filter"
+
+import {
+ ECProject,
+ FilterSelection
+} from "../Schema";
+
+async function getData(): Promise<ECProject[]> {
+ try {
+ const response = await fetch('/api/ec-project');
+ return response.json();
+ } catch (error) {
+ console.error(`Failed to load data: ${error}`);
+ throw error;
+ }
+}
+
+function getYearsAndNrens(sourceData: ECProject[]) {
+ 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 ECProjects({ filterSelection, setFilterSelection }: inputProps) {
+ const [projectData, setProjectData] = useState<ECProject[]>();
+
+ const { years, nrens } = useMemo(
+ () => getYearsAndNrens(projectData || []),
+ [projectData]
+ );
+
+ const selectedData = (projectData || []).filter(project =>
+ filterSelection.selectedYears.includes(project.year) && filterSelection.selectedNrens.includes(project.nren)
+ );
+ const projectDataByYear = new Map<number, Map<string, ECProject[]>>();
+
+ // group ECProjects by year and nren
+ selectedData.forEach(project => {
+ if (!projectDataByYear.has(project.year)) {
+ projectDataByYear.set(project.year, new Map<string, ECProject[]>());
+ }
+ const ecProjectsForYear = projectDataByYear.get(project.year)!;
+
+ const nrenData = ecProjectsForYear.get(project.nren) || [];
+ nrenData.push(project);
+ ecProjectsForYear.set(project.nren, nrenData);
+
+ });
+
+ function nrensort(a: ECProject, b: ECProject) {
+ const byNREN = a.nren < b.nren ? -1 : a.nren > b.nren ? 1 : 0;
+ const byYear = a.year < b.year ? -1 : a.year > b.year ? 1 : 0;
+ return byYear || byNREN;
+ }
+
+ // sort projectDataByYear by year and nren, maps keep insertion order
+ projectDataByYear.forEach((nrenData, year) => {
+ const sortedNrenData = new Map<string, ECProject[]>();
+ [...nrenData.entries()].sort((a, b) => nrensort(a[1][0], b[1][0])).forEach(([nren, projects]) => {
+ sortedNrenData.set(nren, projects);
+ });
+ projectDataByYear.set(year, sortedNrenData);
+ });
+
+
+ useEffect(() => {
+ const loadData = async () => {
+ const data = await getData();
+ setProjectData(data);
+
+ // filter fallback for when nothing is selected (only last year for all nrens)
+ const { years, nrens } = getYearsAndNrens(data);
+ 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 Involvement in European Commission Projects</h3>
+ </Row>
+ <Row>
+ <Table>
+ <thead>
+ <tr>
+ <th>NREN</th>
+ <th>Year</th>
+ <th>EC Project Membership</th>
+ </tr>
+ </thead>
+ <tbody className='table-bg-highlighted'>
+ {Array.from(projectDataByYear.keys()).sort().map((year) => {
+ return projectDataByYear.get(year) && Array.from(projectDataByYear.get(year)!.entries()).map(([nren, projects]) => (
+ <tr key={nren + year + projects.length}>
+ <td style={{ whiteSpace: 'nowrap' }}>{nren}</td>
+ <td>{year}</td>
+ <td>
+ <ul>
+ {projects.map(project => (
+ <li key={project.project}>{project.project}</li>
+ ))}
+ </ul>
+ </td>
+ </tr>
+ ))
+ })}
+ </tbody>
+ </Table>
+ </Row>
+ </Col>
+ <Col xs={3}>
+ <div className="sticky-top" style={{ top: '1rem' }}>
+ {/* the sticky-top class makes the filter follow the user as they scroll down the page */}
+ <Filter
+ filterOptions={{ availableYears: [...years], availableNrens: [...nrens] }}
+ filterSelection={filterSelection}
+ setFilterSelection={setFilterSelection}
+ />
+ </div>
+
+
+ </Col>
+ </Row>
+ </Container>
+ );
+}
+export default ECProjects;
diff --git a/webapp/src/scss/layout/_components.scss b/webapp/src/scss/layout/_components.scss
index 58a20fb698315bbe3d9c93837eefe5dd19b93ba6..2f629cd7f2649fa9a78d7c2af05167d150c9b9a7 100644
--- a/webapp/src/scss/layout/_components.scss
+++ b/webapp/src/scss/layout/_components.scss
@@ -86,12 +86,12 @@
}
@mixin linkHover {
- &:hover{
+ &:hover {
color: #003753
}
}
-.link-text{
+.link-text {
text-decoration: none;
color: #003753;
@include linkHover;
@@ -108,7 +108,7 @@
.page-footer {
min-height: 350px;
width: 100%;
- bottom:0;
+ bottom: 0;
justify-content: center;
align-items: center;
padding-top: 20px;
@@ -125,22 +125,45 @@
}
.btn-compendium {
- --bs-btn-color:#fff;
- --bs-btn-bg:#003753;
- --bs-btn-border-color:#003753;
- --bs-btn-hover-color:#fff;
- --bs-btn-hover-bg:#3b536b;
- --bs-btn-hover-border-color:#3b536b;
- --bs-btn-focus-shadow-rgb:49,132,253;
- --bs-btn-active-color:#f5f5f5;
- --bs-btn-active-bg:#3b536b;
- --bs-btn-active-border-color:#003753;
- --bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);
- --bs-btn-disabled-color:#fff;
- --bs-btn-disabled-bg:#0d6efd;
- --bs-btn-disabled-border-color:#0d6efd
+ --bs-btn-color: #fff;
+ --bs-btn-bg: #003753;
+ --bs-btn-border-color: #003753;
+ --bs-btn-hover-color: #fff;
+ --bs-btn-hover-bg: #3b536b;
+ --bs-btn-hover-border-color: #3b536b;
+ --bs-btn-focus-shadow-rgb: 49, 132, 253;
+ --bs-btn-active-color: #f5f5f5;
+ --bs-btn-active-bg: #3b536b;
+ --bs-btn-active-border-color: #003753;
+ --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ --bs-btn-disabled-color: #fff;
+ --bs-btn-disabled-bg: #0d6efd;
+ --bs-btn-disabled-border-color: #0d6efd
}
.fit-max-content {
min-width: max-content;
}
+
+.table-bg-highlighted {
+ tr:nth-child(even) {
+ background-color: rgba(102, 121, 139, 0.178);
+ }
+
+ tr:hover {
+ background-color: rgba(102, 121, 139, 0.521);
+ }
+
+ // set LI icons to be a minus sign
+ li {
+ list-style-type: square;
+ list-style-position: inside;
+ }
+
+}
+
+.sticky-top {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
\ No newline at end of file