diff --git a/locales/de-global.json5 b/locales/de-global.json5 index 0b3886e4..7c4d7994 100644 --- a/locales/de-global.json5 +++ b/locales/de-global.json5 @@ -134,4 +134,24 @@ 'loki-logo': 'LOKI-Logo', okay: 'Okay', yAxisLabel: 'Wert', + export: { + header: 'ESID', + description: 'Aktuelle Auswahl als PDF-Bericht exportieren.', + button: 'PDF exportieren', + info: { + 'selected-district': 'Ausgewählter Landkreis', + 'selected-scenario': 'Ausgewähltes Szenario', + 'reference-date': 'Referenzdatum', + 'selected-date': 'Ausgewähltes Datum', + }, + images: { + 'line-chart-label': 'Liniendiagramm', + 'map-label': 'Karte', + }, + table: { + compartment: 'Infektionszustand', + 'reference-value': 'Wert Referenzdatum', + 'selected-value': 'Wert Ausgewähltes Datum', + }, + }, } diff --git a/locales/en-global.json5 b/locales/en-global.json5 index 77ba7ae0..5d028180 100644 --- a/locales/en-global.json5 +++ b/locales/en-global.json5 @@ -149,4 +149,24 @@ WIP: 'This functionality is still work in progress.', okay: 'Okay', yAxisLabel: 'Value', + export: { + header: 'ESID', + description: 'Export the current selection as a PDF report.', + button: 'Export PDF', + info: { + 'selected-district': 'Selected District', + 'selected-scenario': 'Selected Scenario', + 'reference-date': 'Reference Date', + 'selected-date': 'Selected Date', + }, + images: { + 'line-chart-label': 'Line chart', + 'map-label': 'Map', + }, + table: { + compartment: 'Infection state', + 'reference-value': 'Value Reference Date', + 'selected-value': 'Value Selected Date', + }, + }, } diff --git a/package-lock.json b/package-lock.json index 0d93e409..2efa6605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@mui/system": "5.15.0", "@mui/x-date-pickers": "6.20.0", "@reduxjs/toolkit": "2.0.1", + "@types/pdfmake": "0.2.11", "country-flag-icons": "1.5.9", "dayjs": "1.11.10", "i18next": "23.7.10", @@ -3377,6 +3378,25 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/pdfkit": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.2.tgz", + "integrity": "sha512-a7mqP/l8lsLMVNhQ3N2blU5pA1KX0YFE8FxWp0OTqZQKEZoPk7ndAlW+kdFBAWpFmLpy6fFbMRm4a6ZELWNgOQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pdfmake": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@types/pdfmake/-/pdfmake-0.2.11.tgz", + "integrity": "sha512-gglgMQhnG6C2kco13DJlvokqTxL+XKxHwCejElH8fSCNF9ZCkRK6Mzo011jQ0zuug+YlIgn6BpcpZrARyWdW3Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/pdfkit": "*" + } + }, "node_modules/@types/polylabel": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz", diff --git a/package.json b/package.json index e8d42fbc..55d1d7b8 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@mui/system": "5.15.0", "@mui/x-date-pickers": "6.20.0", "@reduxjs/toolkit": "2.0.1", + "@types/pdfmake": "0.2.11", "country-flag-icons": "1.5.9", "dayjs": "1.11.10", "i18next": "23.7.10", @@ -68,7 +69,6 @@ "rooks": "7.14.1" }, "devDependencies": { - "@types/geojson": "7946.0.13", "@babel/eslint-parser": "7.23.10", "@babel/preset-react": "7.23.3", "@nabla/vite-plugin-eslint": "2.0.5", @@ -77,6 +77,7 @@ "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/country-flag-icons": "1.2.2", + "@types/geojson": "7946.0.13", "@types/node-fetch": "2.6.9", "@types/react": "18.2.45", "@types/react-dom": "18.2.17", diff --git a/src/App.tsx b/src/App.tsx index 9c517aec..85377ffa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import {MUILocalization} from 'components/shared/MUILocalization'; import AuthProvider from './components/AuthProvider'; import BaseDataContext from 'context/BaseDataContext'; +import ExportingRegistry from 'context/ExportContext'; /** * This is the root element of the React application. It divides the main screen area into the three main components. * The top bar, the sidebar and the main content area. @@ -36,26 +37,28 @@ export default function App(): JSX.Element { - - - - - - - - + + + + + + + + + + - - + + diff --git a/src/components/LineChartComponents/LineChart.tsx b/src/components/LineChartComponents/LineChart.tsx index a737afe9..3da50c6a 100644 --- a/src/components/LineChartComponents/LineChart.tsx +++ b/src/components/LineChartComponents/LineChart.tsx @@ -30,6 +30,8 @@ import useValueAxisRange from 'components/shared/LineChart/ValueAxisRange'; import {LineChartData} from 'types/lineChart'; import {LineSeries} from '@amcharts/amcharts5/xy'; import {useSeriesRange} from 'components/shared/LineChart/SeriesRange'; +import {useExportingRegistry} from 'context/ExportContext'; +import useExporting from 'components/shared/Exporting'; interface LineChartProps { /** Optional unique identifier for the chart. Defaults to 'chartdiv'. */ @@ -688,25 +690,25 @@ export default function LineChart({ }); } - // Let's import this lazily, since it contains a lot of code. - import('@amcharts/amcharts5/plugins/exporting') - .then((module) => { - // Update export menu - module.Exporting.new(root as Root, { - menu: module.ExportingMenu.new(root as Root, {}), - filePrefix: exportedFileName, - dataSource: data, - dateFields: ['date'], - dateFormat: `${ - memoizedLocalization.overrides?.['dateFormat'] - ? customT(memoizedLocalization.overrides['dateFormat']) - : defaultT('dateFormat') - }`, - dataFields: dataFields, - dataFieldsOrder: dataFieldsOrder, - }); - }) - .catch(() => console.warn("Couldn't load exporting functionality!")); + // // Let's import this lazily, since it contains a lot of code. + // import('@amcharts/amcharts5/plugins/exporting') + // .then((module) => { + // // Update export menu + // const exporting = module.Exporting.new(root as Root, { + // menu: module.ExportingMenu.new(root as Root, {}), + // filePrefix: exportedFileName, + // dataSource: data, + // dateFields: ['date'], + // dateFormat: `${ + // memoizedLocalization.overrides?.['dateFormat'] + // ? customT(memoizedLocalization.overrides['dateFormat']) + // : defaultT('dateFormat') + // }`, + // dataFields: dataFields, + // dataFieldsOrder: dataFieldsOrder, + // }); + // }) + // .catch(() => console.warn("Couldn't load exporting functionality!")); setReferenceDayX(); // Re-run this effect whenever the data itself changes (or any variable the effect uses) @@ -724,6 +726,23 @@ export default function LineChart({ yAxisLabel, ]); + const {register} = useExportingRegistry(); + + // https://www.amcharts.com/docs/v5/reference/exporting/ docs for export settings from amcharts + const exportSettings = useMemo(() => { + return { + filePrefix: exportedFileName, + }; + }, [exportedFileName]); + + const exporting = useExporting(root, exportSettings); + + useEffect(() => { + if (exporting) { + register('lineChart', exporting); + } + }, [exporting, register]); + return ( id + String(Date.now() + Math.random()), [id]); const theme = useTheme(); + const {register} = useExportingRegistry(); + const root = useRoot(unique_id); const memoizedLocalization = useMemo(() => { @@ -137,5 +142,19 @@ export default function HeatLegend({ // This effect should only run when the legend object changes }, [heatLegend, legend, min, max, exposeLegend]); + const exportSettings = useMemo(() => { + return { + filePrefix: 'map', + }; + }, []); + + const exporting = useExporting(root, exportSettings); + + useEffect(() => { + if (exporting) { + register('legend', exporting); + } + }, [exporting, register]); + return ; } diff --git a/src/components/Sidebar/MapComponents/HeatMap.tsx b/src/components/Sidebar/MapComponents/HeatMap.tsx index ee6f4c32..63a63b06 100644 --- a/src/components/Sidebar/MapComponents/HeatMap.tsx +++ b/src/components/Sidebar/MapComponents/HeatMap.tsx @@ -23,6 +23,8 @@ import {Localization} from 'types/localization'; // Utils import {useConst} from 'util/hooks'; +import useExporting from 'components/shared/Exporting'; +import {useExportingRegistry} from 'context/ExportContext'; interface MapProps { /** The data to be displayed on the map, in GeoJSON format. */ @@ -123,6 +125,7 @@ export default function HeatMap({ const lastSelectedPolygon = useRef(null); const [longLoadTimeout, setLongLoadTimeout] = useState(); + const {register} = useExportingRegistry(); const root = useRoot(mapId); // MapControlBar.tsx @@ -353,6 +356,20 @@ export default function HeatMap({ isDataFetching, ]); + const exportSettings = useMemo(() => { + return { + filePrefix: 'map', + }; + }, []); + + const exporting = useExporting(root, exportSettings); + + useEffect(() => { + if (exporting) { + register('map', exporting); + } + }, [exporting, register]); + return ( import('./PopUps/ImprintDialog')); const PrivacyPolicyDialog = React.lazy(() => import('./PopUps/PrivacyPolicyDialog')); const AccessibilityDialog = React.lazy(() => import('./PopUps/AccessibilityDialog')); const AttributionDialog = React.lazy(() => import('./PopUps/AttributionDialog')); +const ExportMenu = React.lazy(() => import('./ExportMenu')); type TokenData = { realm_access?: { @@ -50,6 +51,8 @@ export default function ApplicationMenu(): JSX.Element { const [accessibilityOpen, setAccessibilityOpen] = React.useState(false); const [attributionsOpen, setAttributionsOpen] = React.useState(false); const [changelogOpen, setChangelogOpen] = React.useState(false); + const [exportOpen, setExportOpen] = React.useState(false); + const [exportAnchorElement, setExportAnchorElement] = React.useState(null); const keycloakLogout = () => { window.location.assign( @@ -67,6 +70,16 @@ export default function ApplicationMenu(): JSX.Element { setAnchorElement(null); }; + const openExportMenu = (event: MouseEvent) => { + setExportAnchorElement(event.currentTarget); + setExportOpen(true); + }; + + const closeExportMenu = () => { + setExportAnchorElement(null); + setExportOpen(false); + }; + /** This method gets called, when the login menu entry was clicked. */ const loginClicked = () => { closeMenu(); @@ -140,6 +153,16 @@ export default function ApplicationMenu(): JSX.Element { {t('topBar.menu.accessibility')} {t('topBar.menu.attribution')} {t('topBar.menu.changelog')} + + + Export + + › + + setImprintOpen(false)}> @@ -201,6 +224,24 @@ export default function ApplicationMenu(): JSX.Element { + + + + { + closeExportMenu(); + closeMenu(); + }} + /> + + ); } diff --git a/src/components/TopBar/ExportMenu.tsx b/src/components/TopBar/ExportMenu.tsx new file mode 100644 index 00000000..a9a8a546 --- /dev/null +++ b/src/components/TopBar/ExportMenu.tsx @@ -0,0 +1,374 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) +// SPDX-License-Identifier: Apache-2.0 + +import React, {useCallback, useContext, useMemo} from 'react'; +import {useTranslation} from 'react-i18next'; +import MenuItem from '@mui/material/MenuItem'; +import {useExportingRegistry} from 'context/ExportContext'; +import {NumberFormatter} from 'util/hooks'; +import i18n from 'util/i18n'; +import { + Content, + TDocumentDefinitions, + ContentImage, + ContentTable, + TableLayout, + ContentText, + ContentColumns, + ContentSvg, +} from 'pdfmake/interfaces'; +import {DataContext} from 'context/SelectedDataContext'; +import {useAppSelector} from 'store/hooks'; +import esidLogo from 'assets/logo/logo-200x66.svg?raw'; + +const toDataUrl = (img: unknown): string | undefined => { + if (!img) return undefined; + if (typeof img === 'string') return img; + if (typeof img === 'object' && img !== null && 'data' in (img as Record)) { + const d = (img as Record).data; + return typeof d === 'string' ? d : undefined; + } + return undefined; +}; + +type ExportMenuProps = {onDone?: () => void}; + +export default function ExportMenu({onDone}: ExportMenuProps): JSX.Element { + const {t} = useTranslation(); + const {formatNumber} = NumberFormatter(i18n.language, 1, 0); + const {t: tBackend, i18n: i18nBackend} = useTranslation('backend'); + const {t: tGlobal} = useTranslation('global'); + const {get} = useExportingRegistry(); + const {compartments, referenceDateValues, scenarioCardData} = useContext(DataContext)!; + + const selectedScenario = useAppSelector((state) => state.dataSelection.scenario); + const scenariosState = useAppSelector((state) => state.dataSelection.scenarios); + const selectedDistrict = useAppSelector((state) => state.dataSelection.district); + const selectedDate = useAppSelector((state) => state.dataSelection.date); + const referenceDay = useAppSelector((state) => state.dataSelection.simulationStart); + const languageSuffix = `-${i18nBackend.language}`; + const compartmentNames = useMemo(() => { + return ( + compartments?.map((compartment) => { + const name = i18nBackend.exists(`infection-states.${compartment.name}`, {ns: 'backend'}) + ? tBackend(`infection-states.${compartment.name}`) + : compartment.name; + + return {id: compartment.id, name}; + }) ?? [] + ); + }, [compartments, i18nBackend, tBackend]); + + const compartmentValues = useMemo(() => { + const result: Record = {}; + referenceDateValues?.forEach((referenceDate) => { + const key = i18nBackend.exists(`infection-states.${referenceDate.compartment}`, {ns: 'backend'}) + ? tBackend(`infection-states.${referenceDate.compartment}`) + : referenceDate.compartment!; + + result[key] = referenceDate.value; + }); + return result; + }, [i18nBackend, referenceDateValues, tBackend]); + + const selectedScenarioName = useMemo(() => { + return scenariosState[selectedScenario ?? '']?.name ?? ''; + }, [selectedScenario, scenariosState]); + + const selectedDistrictName = useMemo(() => { + return selectedDistrict.name === '00000' ? t('germany') : t(`${selectedDistrict.name}`); + }, [selectedDistrict, t]); + + const cardValues = useMemo(() => { + const result: Record> = {}; + Object.keys(scenariosState).forEach((id) => { + result[id] = {}; + compartmentNames.forEach((c) => (result[id][c.id] = null)); + }); + + Object.entries(scenarioCardData ?? {}).forEach(([id, infectionData]) => { + infectionData.forEach((entry) => { + if (entry.compartment) { + result[id][entry.compartment] = entry.value; + } + }); + }); + return result; + }, [compartmentNames, scenarioCardData, scenariosState]); + + const handleExportPdf = useCallback(() => { + onDone?.(); + void (async () => { + const lineExp = get('lineChart'); + const mapExp = get('map'); + const legendExp = get('legend'); + + const pdfMake = + (await (lineExp as unknown as {getPDFMake?: () => Promise})?.getPDFMake?.()) || + (await (lineExp as unknown as {getPdfmake?: () => Promise})?.getPdfmake?.()) || + (await (mapExp as unknown as {getPDFMake?: () => Promise})?.getPDFMake?.()) || + (await (mapExp as unknown as {getPdfmake?: () => Promise})?.getPdfmake?.()) || + (await (legendExp as unknown as {getPDFMake?: () => Promise})?.getPDFMake?.()) || + (await (legendExp as unknown as {getPdfmake?: () => Promise})?.getPdfmake?.()); + + const [lineImg, mapImg, legendImg] = await Promise.all([ + lineExp?.export?.('png'), + mapExp?.export?.('png'), + legendExp?.export?.('png'), + ]); + + const lineChartDataUrl = toDataUrl(lineImg); + const mapDataUrl = toDataUrl(mapImg); + const mapLegendDataUrl = toDataUrl(legendImg); + + if (!lineChartDataUrl || !mapDataUrl || !mapLegendDataUrl) { + return; + } + + /** + * More information how to work with pdfmake to create a pdf: https://pdfmake.github.io/docs/0.1/document-definition-object/ + */ + const nowStr = new Date().toLocaleString(); + + // header and subheader + const headerContent = { + svg: esidLogo, + width: 50, + alignment: 'left', + margin: [0, 0, 0, 0], + } as ContentSvg; + + const subheaderContent = { + text: `${t(selectedDistrictName)} — ${selectedScenarioName ?? ''}`, + style: 'subheader', + } as ContentText; + + const dateSubheader = { + text: `${nowStr}`, + style: 'subheader', + } as ContentText; + + const subheaderColumns: ContentColumns = { + columns: [ + { + width: '50%', + text: subheaderContent, + alignment: 'left', + }, + { + width: '50%', + text: dateSubheader, + alignment: 'right', + }, + ], + margin: [0, 6, 0, 12], + }; + + const header: ContentColumns = { + columns: [{width: '100%', stack: [headerContent, subheaderColumns]}], + margin: [20, 20, 20, 0], + }; + + // custom footer with page column and attributions + const footer = (currentPage: number, pageCount: number) => + ({ + columns: [ + { + width: '50%', + text: 'DLR', + alignment: 'left', + fontSize: 8, + color: '#666', + margin: [30, 0, 30, 20], + }, + { + width: '50%', + text: `${currentPage} / ${pageCount}`, + alignment: 'right', + fontSize: 8, + color: '#666', + margin: [30, 0, 30, 20], + }, + ], + }) as ContentColumns; + + const doc: TDocumentDefinitions = { + pageSize: 'A4', // customizable + pageOrientation: 'portrait', // customizable + pageMargins: [20, 70, 20, 20], // customizable + content: [], + header: header, + styles: { + header: {fontSize: 18, bold: true, margin: [0, 0, 0, 0]}, + subheader: {fontSize: 12, color: '#666', margin: [0, 0, 0, 12]}, + tableHeader: {bold: true, fontSize: 10, color: '#333'}, + tableCell: {fontSize: 10, color: '#333'}, + small: {fontSize: 8, color: '#666'}, + }, + footer: footer, + defaultStyle: {fontSize: 11}, + info: {title: 'ESID Export', subject: 'Exported report', creator: 'DLR'}, + // watermark: {text: 'ESID Export', color: '#666', opacity: 0.1, fontSize: 100}, //we can add this if we want + }; + + const docContents = doc.content as Content[]; + + // Line chart + const lineChart = { + image: lineChartDataUrl, + width: 550, + alignment: 'left', + margin: [0, 0, 0, 0], + } as ContentImage; + + const lineChartText = { + text: t('export.images.line-chart-label'), + style: 'small', + alignment: 'left', + margin: [0, 6, 0, 12], + } as ContentText; + + docContents.push(lineChart, lineChartText); + + // Info table with selected district, scenario, reference date and selected date + const infoTable: ContentTable = { + table: { + widths: ['*', 'auto', '*', 'auto'], + body: [ + [ + {text: tGlobal('export.info.selected-district'), style: 'tableHeader'}, + {text: selectedDistrictName, style: 'tableCell'}, + {text: tGlobal('export.info.selected-scenario'), style: 'tableHeader'}, + {text: selectedScenarioName ?? '', style: 'tableCell'}, + ], + [ + {text: tGlobal('export.info.reference-date'), style: 'tableHeader'}, + {text: referenceDay ?? '', style: 'tableCell'}, + {text: tGlobal('export.info.selected-date'), style: 'tableHeader'}, + {text: selectedDate ?? '', style: 'tableCell'}, + ], + ], + }, + layout: { + fillColor: (rowIndex: number) => (rowIndex === 0 ? '#f5f5f5' : null), + hLineColor: '#e0e0e0', + vLineColor: '#e0e0e0', + } as TableLayout, + margin: [0, 0, 0, 12], + }; + + const mapWidth = 200; + + const map = { + image: mapDataUrl, + width: mapWidth, + alignment: 'left' as const, + margin: [0, 0, 0, 0], + } as ContentImage; + + const mapLegend = { + image: mapLegendDataUrl, + width: mapWidth, + alignment: 'left' as const, + margin: [0, 0, 0, 0], + } as ContentImage; + + const mapText = { + text: t('export.images.map-label'), + style: 'small', + alignment: 'left', + margin: [0, 6, 0, 0], + } as ContentText; + + // Compartment table with compartment name, reference value and selected value + const tableBody = [ + [ + {text: tGlobal('export.table.compartment'), style: 'tableHeader'}, + {text: tGlobal('export.table.reference-value'), style: 'tableHeader', alignment: 'right'}, + {text: tGlobal('export.table.selected-value'), style: 'tableHeader', alignment: 'right'}, + ], + ]; + + for (const compartment of compartmentNames) { + tableBody.push([ + {text: compartment.name, style: 'tableCell'}, + { + text: formatNumber(compartmentValues[compartment.id] ?? 0), + style: 'tableCell', + alignment: 'right', + }, + { + text: formatNumber(cardValues[selectedScenario ?? '']?.[compartment.id] ?? 0), + style: 'tableCell', + alignment: 'right', + }, + ]); + } + + // Compartment table layout + const zebraLayout: TableLayout = { + fillColor: (rowIndex: number) => (rowIndex === 0 ? '#f5f5f5' : rowIndex % 2 === 0 ? '#fafafa' : null), + hLineColor: '#e0e0e0', + vLineColor: '#e0e0e0', + }; + + const numbersTable: ContentTable = { + layout: zebraLayout, + table: { + headerRows: 1, + widths: ['*', 'auto', 'auto'], + body: tableBody, + }, + margin: [0, 0, 0, 4], + }; + + const mapInfoColumn: ContentColumns = { + alignment: 'left', + columns: [ + { + width: '40%', + stack: [map, mapLegend, mapText], + }, + { + width: '60%', + stack: [infoTable, numbersTable], + }, + ], + }; + + docContents.push(mapInfoColumn); + + // Download the pdf + const pdfmake = pdfMake as {createPdf?: (doc: unknown) => {download: (name: string) => void}}; + pdfmake?.createPdf?.(doc)?.download(`ESID-export${languageSuffix}.pdf`); + })(); + }, [ + get, + t, + compartmentNames, + compartmentValues, + selectedScenarioName, + selectedDistrictName, + referenceDay, + cardValues, + selectedScenario, + selectedDate, + languageSuffix, + formatNumber, + tGlobal, + onDone, + ]); + + const handleExportCsv = useCallback(() => { + onDone?.(); + }, [onDone]); + + return ( + <> + PDF + + CSV + + + ); +} diff --git a/src/components/shared/Exporting.tsx b/src/components/shared/Exporting.tsx new file mode 100644 index 00000000..9a6c5aa5 --- /dev/null +++ b/src/components/shared/Exporting.tsx @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) +// SPDX-License-Identifier: Apache-2.0 + +import {useLayoutEffect, useState} from 'react'; +import {Root} from '@amcharts/amcharts5/.internal/core/Root'; +import {Exporting} from '@amcharts/amcharts5/.internal/plugins/exporting/Exporting'; +import {IExportingSettings} from '@amcharts/amcharts5/.internal/plugins/exporting/Exporting'; + +export default function useExporting( + root: Root | null, + settings: IExportingSettings, + initializer?: (exporting: Exporting) => void +): Exporting | null { + const [exporting, setExporting] = useState(); + + useLayoutEffect(() => { + if (!root) { + return; + } + + const newExporting = Exporting.new(root, settings); + setExporting(newExporting); + + if (initializer) { + initializer(newExporting); + } + + return () => { + newExporting.dispose(); + }; + }, [root, settings, initializer]); + + return exporting || null; +} diff --git a/src/context/ExportContext.tsx b/src/context/ExportContext.tsx new file mode 100644 index 00000000..b2b2810b --- /dev/null +++ b/src/context/ExportContext.tsx @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) +// SPDX-License-Identifier: Apache-2.0 + +import React, {createContext, useCallback, useContext, useState} from 'react'; +import {Exporting} from '@amcharts/amcharts5/.internal/plugins/exporting/Exporting'; + +type ExportRegistry = Record; + +interface ExportContextAPI { + register: (name: string, exporting: Exporting) => void; + unregister: (name: string) => void; + get: (name: string) => Exporting | null; +} + +export const ExportContext = createContext(null); + +export default function ExportingRegistry({children}: {children: React.ReactNode}): JSX.Element { + const [exporting, setExporting] = useState({}); + + const register = useCallback((name: string, exporting: Exporting) => { + setExporting((prev) => ({...prev, [name]: exporting})); + }, []); + + const unregister = useCallback((name: string) => { + setExporting((prev) => { + const {[name]: _, ...rest} = prev; + return rest; + }); + }, []); + + const get = useCallback((name: string) => exporting[name] ?? null, [exporting]); + + return {children}; +} + +export function useExportingRegistry(): ExportContextAPI { + const context = useContext(ExportContext); + if (!context) { + throw new Error('useExportingRegistry must be used within a ExportContext'); + } + return context; +} diff --git a/test/components/LineChart.test.tsx b/test/components/LineChart.test.tsx index 98575e8e..c7b5ffdd 100644 --- a/test/components/LineChart.test.tsx +++ b/test/components/LineChart.test.tsx @@ -7,8 +7,8 @@ import {render, screen} from '@testing-library/react'; import {describe, test, expect, vi} from 'vitest'; import {I18nextProvider} from 'react-i18next'; import i18n from 'util/i18nForTests'; -import {color} from '@amcharts/amcharts5/.internal/core/util/Color'; import {ResizeObserverMock} from 'mocks/resize'; +import ExportingRegistry from '@/context/ExportContext'; const LineChartTest = () => { const localization = useMemo(() => { @@ -58,9 +58,11 @@ describe('LineChart', () => { vi.stubGlobal('ResizeObserver', ResizeObserverMock); test('renders LineChart', () => { render( - - - + + + + + ); expect(screen.getByTestId('chartdiv')).toBeInTheDocument(); diff --git a/test/components/Sidebar/HeatLegend.test.tsx b/test/components/Sidebar/HeatLegend.test.tsx index fc69de67..23cd3f79 100644 --- a/test/components/Sidebar/HeatLegend.test.tsx +++ b/test/components/Sidebar/HeatLegend.test.tsx @@ -6,6 +6,7 @@ import {describe, test, expect} from 'vitest'; import {render} from '@testing-library/react'; import {ThemeProvider} from '@mui/system'; import Theme from '@/util/Theme'; +import ExportingRegistry from '@/context/ExportContext'; import HeatLegend from '@/components/Sidebar/MapComponents/HeatLegend'; const HeatLegendTest = () => { @@ -26,9 +27,11 @@ const HeatLegendTest = () => { describe('HeatLegend', () => { test('renders HeatLegend component', () => { render( - - - + + + + + ); const canvasElement = document.querySelector('canvas'); diff --git a/test/components/Sidebar/HeatMap.test.tsx b/test/components/Sidebar/HeatMap.test.tsx index 84334397..8a8fc65c 100644 --- a/test/components/Sidebar/HeatMap.test.tsx +++ b/test/components/Sidebar/HeatMap.test.tsx @@ -9,6 +9,7 @@ import HeatMap from '@/components/Sidebar/MapComponents/HeatMap'; import {ThemeProvider} from '@mui/system'; import Theme from '@/util/Theme'; import {FeatureCollection, GeoJsonProperties} from 'geojson'; +import ExportingRegistry from '@/context/ExportContext'; const HeatMapTest = () => { const geoData = useMemo(() => { @@ -111,9 +112,11 @@ const HeatMapTest = () => { describe('HeatMap', () => { test('renders HeatMap component', () => { render( - - - + + + + + ); expect(screen.getByTestId('map')).toBeInTheDocument();