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