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 {
+
+
+
+
);
}
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 (
+ <>
+
+
+ >
+ );
+}
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();