From 70564544927f31a985e860ee0ec40a7985fed311 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 13 Aug 2025 11:20:33 +0200 Subject: [PATCH 01/14] :tada: added export dialogue to hamburger menu --- src/components/TopBar/ApplicationMenu.tsx | 28 +++++++++++++++++ src/components/TopBar/PopUps/ExportDialog.tsx | 31 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/components/TopBar/PopUps/ExportDialog.tsx diff --git a/src/components/TopBar/ApplicationMenu.tsx b/src/components/TopBar/ApplicationMenu.tsx index 0424fc06..725ad0b7 100644 --- a/src/components/TopBar/ApplicationMenu.tsx +++ b/src/components/TopBar/ApplicationMenu.tsx @@ -13,12 +13,14 @@ import Box from '@mui/system/Box'; import {useAppSelector} from 'store/hooks'; import {AuthContext, IAuthContext} from 'react-oauth2-code-pkce'; import CircularProgress from '@mui/material/CircularProgress'; +import Download from '@mui/icons-material/Download'; const ChangelogDialog = React.lazy(() => import('./PopUps/ChangelogDialog')); const ImprintDialog = React.lazy(() => 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 ExportDialog = React.lazy(() => import('./PopUps/ExportDialog')); type TokenData = { realm_access?: { @@ -50,6 +52,7 @@ 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 keycloakLogout = () => { window.location.assign( @@ -110,6 +113,11 @@ export default function ApplicationMenu(): JSX.Element { setChangelogOpen(true); }; + const exportClicked = () => { + closeMenu(); + setExportOpen(true); + }; + return ( + + ); +} From 76fec9925915c2f5c6e41c0abf41710bf95eafd4 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 13 Aug 2025 15:43:15 +0200 Subject: [PATCH 02/14] :arrow_up_small: added pdfmake types --- package-lock.json | 20 ++++++++++++++++++++ package.json | 2 ++ 2 files changed, 22 insertions(+) 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..e0977458 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", @@ -77,6 +78,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", From bf22ddcde4791956b69b76584b1102df6af973af Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 13 Aug 2025 15:44:23 +0200 Subject: [PATCH 03/14] :tada: integrated amcharts exporting functionality with new context and hooks, now able to export multiple amchart components --- src/App.tsx | 41 ++++----- .../LineChartComponents/LineChart.tsx | 55 +++++++----- .../Sidebar/MapComponents/HeatMap.tsx | 17 ++++ src/components/TopBar/PopUps/ExportDialog.tsx | 85 ++++++++++++++++++- src/components/shared/Exporting.tsx | 34 ++++++++ src/context/ExportContext.tsx | 42 +++++++++ 6 files changed, 234 insertions(+), 40 deletions(-) create mode 100644 src/components/shared/Exporting.tsx create mode 100644 src/context/ExportContext.tsx 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..a29c8f4e 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,21 @@ export default function LineChart({ yAxisLabel, ]); + const {register} = useExportingRegistry(); + const exportSettings = useMemo(() => { + return { + filePrefix: exportedFileName, + }; + }, [exportedFileName]); + + const exporting = useExporting(root, exportSettings); + + useEffect(() => { + if (exporting) { + register('lineChart', exporting); + } + }, [exporting, register]); + return ( (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 ( { + 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; +}; export default function ExportDialog(): JSX.Element { const {t} = useTranslation(); const theme = useTheme(); + const {get} = useExportingRegistry(); + + const handleExport = useCallback(() => { + void (async () => { + const lineExp = get('lineChart'); + const mapExp = get('map'); + + 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?.()); + + const [lineImg, mapImg] = await Promise.all([lineExp?.export?.('png'), mapExp?.export?.('png')]); + + const lineDataUrl = toDataUrl(lineImg); + const mapDataUrl = toDataUrl(mapImg); + + const doc: TDocumentDefinitions = { + pageSize: 'A4', + pageOrientation: 'portrait', + pageMargins: [30, 30, 30, 30], + content: [], + styles: {header: {fontSize: 18, bold: true, margin: [0, 0, 0, 10]}}, + }; + + (doc.content as Content[]).push({text: t('export.header'), style: 'header'}); + + if (lineDataUrl) { + (doc.content as ContentImage[]).push({ + image: lineDataUrl, + width: 500, + }); + } + + if (mapDataUrl) { + (doc.content as ContentImage[]).push({ + image: mapDataUrl, + fit: [300, 300], + }); + } + + // (doc.content as ContentTable[]).push({ + // table: { + // body: [ + // [{text: 'Line Chart Data'}, {text: 'Line Chart Data'}], + // [{text: 'Line Chart Data'}, {text: 'Line Chart Data'}], + // ], + // }, + // }); + + const columns: Column[] = [ + { + text: 'Line Chart Data', + }, + { + text: 'Map Data', + }, + ]; + + (doc.content as Content[]).push({ + columns: columns, + columnGap: 10, + }); + + const pdfmake = pdfMake as {createPdf?: (doc: unknown) => {download: (name: string) => void}}; + pdfmake?.createPdf?.(doc)?.download('ESID-export.pdf'); + })(); + }, [get, t]); return ( {t('export.description')}
-
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; +} From 232c5eff56311f8e1d74f31a1b30fee280d7cb14 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 13 Aug 2025 15:49:48 +0200 Subject: [PATCH 04/14] :memo: added docs for reference --- src/components/LineChartComponents/LineChart.tsx | 2 ++ src/components/TopBar/PopUps/ExportDialog.tsx | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/components/LineChartComponents/LineChart.tsx b/src/components/LineChartComponents/LineChart.tsx index a29c8f4e..3da50c6a 100644 --- a/src/components/LineChartComponents/LineChart.tsx +++ b/src/components/LineChartComponents/LineChart.tsx @@ -727,6 +727,8 @@ export default function LineChart({ ]); const {register} = useExportingRegistry(); + + // https://www.amcharts.com/docs/v5/reference/exporting/ docs for export settings from amcharts const exportSettings = useMemo(() => { return { filePrefix: exportedFileName, diff --git a/src/components/TopBar/PopUps/ExportDialog.tsx b/src/components/TopBar/PopUps/ExportDialog.tsx index 84b04d62..911f997a 100644 --- a/src/components/TopBar/PopUps/ExportDialog.tsx +++ b/src/components/TopBar/PopUps/ExportDialog.tsx @@ -41,6 +41,9 @@ export default function ExportDialog(): JSX.Element { const lineDataUrl = toDataUrl(lineImg); const mapDataUrl = toDataUrl(mapImg); + /** + * More information how to work with pdfmake to create a pdf: https://pdfmake.github.io/docs/0.1/document-definition-object/ + */ const doc: TDocumentDefinitions = { pageSize: 'A4', pageOrientation: 'portrait', From 1b2f29e16b57069a61b8c772143d5dd6812b7ddd Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 20 Aug 2025 14:43:26 +0200 Subject: [PATCH 05/14] :tada: set export values from redux --- src/components/TopBar/PopUps/ExportDialog.tsx | 174 +++++++++++++++--- 1 file changed, 151 insertions(+), 23 deletions(-) diff --git a/src/components/TopBar/PopUps/ExportDialog.tsx b/src/components/TopBar/PopUps/ExportDialog.tsx index 911f997a..07f617f7 100644 --- a/src/components/TopBar/PopUps/ExportDialog.tsx +++ b/src/components/TopBar/PopUps/ExportDialog.tsx @@ -1,14 +1,16 @@ // SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) // SPDX-License-Identifier: Apache-2.0 -import React, {useCallback} from 'react'; +import React, {useCallback, useContext, useMemo} from 'react'; import Box from '@mui/material/Box'; import useTheme from '@mui/material/styles/useTheme'; import {useTranslation} from 'react-i18next'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import {useExportingRegistry} from 'context/ExportContext'; -import type {Content, TDocumentDefinitions, ContentImage, Column} from 'pdfmake/interfaces'; +import type {Content, TDocumentDefinitions, ContentImage, Column, ContentTable} from 'pdfmake/interfaces'; +import {DataContext} from 'context/SelectedDataContext'; +import {useAppSelector} from 'store/hooks'; const toDataUrl = (img: unknown): string | undefined => { if (!img) return undefined; @@ -22,8 +24,65 @@ const toDataUrl = (img: unknown): string | undefined => { export default function ExportDialog(): JSX.Element { const {t} = useTranslation(); + const {t: tBackend, i18n: i18nBackend} = useTranslation('backend'); const theme = useTheme(); 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 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 handleExport = useCallback(() => { void (async () => { @@ -47,13 +106,42 @@ export default function ExportDialog(): JSX.Element { const doc: TDocumentDefinitions = { pageSize: 'A4', pageOrientation: 'portrait', - pageMargins: [30, 30, 30, 30], + pageMargins: [10, 10, 10, 10], content: [], styles: {header: {fontSize: 18, bold: true, margin: [0, 0, 0, 10]}}, }; (doc.content as Content[]).push({text: t('export.header'), style: 'header'}); + const columns: Column[] = [ + [ + { + text: 'Selected District', + fontSize: 14, + }, + { + text: selectedDistrictName, + fontSize: 10, + }, + ], + [ + { + text: 'Selected Scenario', + fontSize: 14, + }, + { + text: selectedScenarioName ?? '', + fontSize: 10, + }, + ], + ]; + + (doc.content as Content[]).push({ + columns: columns, + columnGap: 10, + margin: [0, 0, 0, 10], + }); + if (lineDataUrl) { (doc.content as ContentImage[]).push({ image: lineDataUrl, @@ -68,33 +156,73 @@ export default function ExportDialog(): JSX.Element { }); } - // (doc.content as ContentTable[]).push({ - // table: { - // body: [ - // [{text: 'Line Chart Data'}, {text: 'Line Chart Data'}], - // [{text: 'Line Chart Data'}, {text: 'Line Chart Data'}], - // ], - // }, - // }); - - const columns: Column[] = [ - { - text: 'Line Chart Data', - }, - { - text: 'Map Data', - }, + // add each compartment name to the table + const tableBody = [ + [ + { + text: ' ', + fontSize: 14, + }, + { + text: 'Reference Date', + fontSize: 12, + }, + { + text: 'Selected Date', + fontSize: 12, + }, + ], + [ + { + text: ' ', + fontSize: 10, + }, + { + text: referenceDay ?? '', + fontSize: 10, + }, + { + text: selectedDate ?? '', + fontSize: 10, + }, + ], + [ + {text: 'Compartment', bold: true, fontSize: 10}, + {text: 'Value', bold: true, fontSize: 10, colSpan: 2}, + ], ]; - (doc.content as Content[]).push({ - columns: columns, - columnGap: 10, + for (const compartment of compartmentNames) { + tableBody.push([ + {text: compartment.name, fontSize: 10}, + {text: compartmentValues[compartment.id].toString(), fontSize: 10}, + {text: cardValues[selectedScenario ?? '']?.[compartment.id]?.toString() ?? '', fontSize: 10}, + ]); + } + + (doc.content as ContentTable[]).push({ + layout: 'lightHorizontalLines', // optional + table: { + headerRows: 3, + body: tableBody, + }, }); const pdfmake = pdfMake as {createPdf?: (doc: unknown) => {download: (name: string) => void}}; pdfmake?.createPdf?.(doc)?.download('ESID-export.pdf'); })(); - }, [get, t]); + }, [ + get, + t, + compartmentNames, + compartmentValues, + selectedScenarioName, + selectedDistrictName, + referenceDay, + cardValues, + selectedScenario, + selectedDate, + ]); return ( Date: Tue, 9 Sep 2025 15:15:22 +0200 Subject: [PATCH 06/14] :wrench: improve layout and use translations --- locales/de-global.json5 | 20 +++ locales/en-global.json5 | 20 +++ src/components/TopBar/PopUps/ExportDialog.tsx | 160 ++++++++++-------- 3 files changed, 133 insertions(+), 67 deletions(-) diff --git a/locales/de-global.json5 b/locales/de-global.json5 index 0b3886e4..beb3f12d 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: { + selectedDistrict: 'Ausgewählter Landkreis', + selectedScenario: 'Ausgewähltes Szenario', + referenceDate: 'Referenzdatum', + selectedDate: 'Ausgewähltes Datum', + }, + images: { + lineChartLabel: 'Liniendiagramm', + mapLabel: 'Karte', + }, + table: { + compartment: 'Zustand', + referenceValue: 'Referenzwert', + selectedValue: 'Ausgewählter Wert', + }, + }, } diff --git a/locales/en-global.json5 b/locales/en-global.json5 index 77ba7ae0..01111b6c 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: { + selectedDistrict: 'Selected District', + selectedScenario: 'Selected Scenario', + referenceDate: 'Reference Date', + selectedDate: 'Selected Date', + }, + images: { + lineChartLabel: 'Line chart', + mapLabel: 'Map', + }, + table: { + compartment: 'Compartment', + referenceValue: 'Reference Value', + selectedValue: 'Selected Value', + }, + }, } diff --git a/src/components/TopBar/PopUps/ExportDialog.tsx b/src/components/TopBar/PopUps/ExportDialog.tsx index 07f617f7..aa6d28d3 100644 --- a/src/components/TopBar/PopUps/ExportDialog.tsx +++ b/src/components/TopBar/PopUps/ExportDialog.tsx @@ -8,7 +8,7 @@ import {useTranslation} from 'react-i18next'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import {useExportingRegistry} from 'context/ExportContext'; -import type {Content, TDocumentDefinitions, ContentImage, Column, ContentTable} from 'pdfmake/interfaces'; +import type {Content, TDocumentDefinitions, ContentImage, ContentTable, TableLayout} from 'pdfmake/interfaces'; import {DataContext} from 'context/SelectedDataContext'; import {useAppSelector} from 'store/hooks'; @@ -84,6 +84,8 @@ export default function ExportDialog(): JSX.Element { return result; }, [compartmentNames, scenarioCardData, scenariosState]); + //TODO: add a error handler in case the card values doesn't have any data for the selected scenario + const handleExport = useCallback(() => { void (async () => { const lineExp = get('lineChart'); @@ -103,109 +105,133 @@ export default function ExportDialog(): JSX.Element { /** * 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(); const doc: TDocumentDefinitions = { pageSize: 'A4', pageOrientation: 'portrait', - pageMargins: [10, 10, 10, 10], + pageMargins: [30, 30, 30, 40], content: [], - styles: {header: {fontSize: 18, bold: true, margin: [0, 0, 0, 10]}}, + styles: { + header: {fontSize: 20, bold: true, margin: [0, 0, 0, 8]}, + 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: (currentPage: number, pageCount: number) => ({ + text: `${currentPage} / ${pageCount}`, + alignment: 'right', + margin: [30, 0, 30, 20], + fontSize: 8, + color: '#666', + }), + defaultStyle: {fontSize: 11}, + info: {title: 'ESID Export', subject: 'Exported report', creator: 'ESID'}, }; (doc.content as Content[]).push({text: t('export.header'), style: 'header'}); - - const columns: Column[] = [ - [ - { - text: 'Selected District', - fontSize: 14, - }, - { - text: selectedDistrictName, - fontSize: 10, - }, - ], - [ - { - text: 'Selected Scenario', - fontSize: 14, - }, - { - text: selectedScenarioName ?? '', - fontSize: 10, - }, - ], - ]; - (doc.content as Content[]).push({ - columns: columns, - columnGap: 10, - margin: [0, 0, 0, 10], + text: `${selectedDistrictName} — ${selectedScenarioName ?? ''} • ${nowStr}`, + style: 'subheader', }); + const infoTable: ContentTable = { + table: { + widths: ['*', '*', '*', '*'], + body: [ + [ + {text: 'Selected District', style: 'tableHeader'}, + {text: selectedDistrictName, style: 'tableCell'}, + {text: 'Selected Scenario', style: 'tableHeader'}, + {text: selectedScenarioName ?? '', style: 'tableCell'}, + ], + [ + {text: 'Reference Date', style: 'tableHeader'}, + {text: referenceDay ?? '', style: 'tableCell'}, + {text: '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], + }; + + (doc.content as Content[]).push(infoTable); + if (lineDataUrl) { (doc.content as ContentImage[]).push({ image: lineDataUrl, - width: 500, + fit: [540, 320], + alignment: 'center', + margin: [0, 0, 0, 8], + }); + (doc.content as Content[]).push({ + text: 'Line chart', + style: 'small', + alignment: 'center', + margin: [0, 0, 0, 12], }); } if (mapDataUrl) { (doc.content as ContentImage[]).push({ image: mapDataUrl, - fit: [300, 300], + fit: [540, 320], + alignment: 'center', + margin: [0, 0, 0, 8], + }); + (doc.content as Content[]).push({ + text: 'Map', + style: 'small', + alignment: 'center', + margin: [0, 0, 0, 12], }); } // add each compartment name to the table const tableBody = [ [ - { - text: ' ', - fontSize: 14, - }, - { - text: 'Reference Date', - fontSize: 12, - }, - { - text: 'Selected Date', - fontSize: 12, - }, - ], - [ - { - text: ' ', - fontSize: 10, - }, - { - text: referenceDay ?? '', - fontSize: 10, - }, - { - text: selectedDate ?? '', - fontSize: 10, - }, - ], - [ - {text: 'Compartment', bold: true, fontSize: 10}, - {text: 'Value', bold: true, fontSize: 10, colSpan: 2}, + {text: 'Compartment', style: 'tableHeader'}, + {text: 'Reference Value', style: 'tableHeader', alignment: 'right'}, + {text: 'Selected Value', style: 'tableHeader', alignment: 'right'}, ], ]; for (const compartment of compartmentNames) { tableBody.push([ - {text: compartment.name, fontSize: 10}, - {text: compartmentValues[compartment.id].toString(), fontSize: 10}, - {text: cardValues[selectedScenario ?? '']?.[compartment.id]?.toString() ?? '', fontSize: 10}, + {text: compartment.name, style: 'tableCell'}, + { + text: (compartmentValues[compartment.id] ?? '').toString(), + style: 'tableCell', + alignment: 'right', + }, + { + text: (cardValues[selectedScenario ?? '']?.[compartment.id] ?? '').toString(), + style: 'tableCell', + alignment: 'right', + }, ]); } + const zebraLayout: TableLayout = { + fillColor: (rowIndex: number) => (rowIndex === 0 ? '#f5f5f5' : rowIndex % 2 === 0 ? '#fafafa' : null), + hLineColor: '#e0e0e0', + vLineColor: '#e0e0e0', + }; + (doc.content as ContentTable[]).push({ - layout: 'lightHorizontalLines', // optional + layout: zebraLayout, table: { - headerRows: 3, + headerRows: 1, + widths: ['*', 'auto', 'auto'], body: tableBody, }, + margin: [0, 0, 0, 4], }); const pdfmake = pdfMake as {createPdf?: (doc: unknown) => {download: (name: string) => void}}; From 10b3d295beee3f22bf5243839cdac0ad72db4e54 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Tue, 9 Sep 2025 15:54:02 +0200 Subject: [PATCH 07/14] :beetle: :wrench: fix test by adding export context --- test/components/LineChart.test.tsx | 10 ++++++---- test/components/Sidebar/HeatMap.test.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) 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/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(); From 735b0eeceb0f234119897666047e2e8267e71f07 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Tue, 16 Sep 2025 17:08:27 +0200 Subject: [PATCH 08/14] :tada: :wrench: :sparkles: exports heatmap, improved styling and formatting --- locales/de-global.json5 | 16 +- locales/en-global.json5 | 16 +- .../Sidebar/MapComponents/HeatLegend.tsx | 21 +- src/components/TopBar/PopUps/ExportDialog.tsx | 191 +++++++++++++----- 4 files changed, 172 insertions(+), 72 deletions(-) diff --git a/locales/de-global.json5 b/locales/de-global.json5 index beb3f12d..9dbc800b 100644 --- a/locales/de-global.json5 +++ b/locales/de-global.json5 @@ -139,19 +139,19 @@ description: 'Aktuelle Auswahl als PDF-Bericht exportieren.', button: 'PDF exportieren', info: { - selectedDistrict: 'Ausgewählter Landkreis', - selectedScenario: 'Ausgewähltes Szenario', - referenceDate: 'Referenzdatum', - selectedDate: 'Ausgewähltes Datum', + 'selected-district': 'Ausgewählter Landkreis', + 'selected-scenario': 'Ausgewähltes Szenario', + 'reference-date': 'Referenzdatum', + 'selected-date': 'Ausgewähltes Datum', }, images: { - lineChartLabel: 'Liniendiagramm', - mapLabel: 'Karte', + 'line-chart-label': 'Liniendiagramm', + 'map-label': 'Karte', }, table: { compartment: 'Zustand', - referenceValue: 'Referenzwert', - selectedValue: 'Ausgewählter Wert', + 'reference-value': 'Referenzwert', + 'selected-value': 'Ausgewählter Wert', }, }, } diff --git a/locales/en-global.json5 b/locales/en-global.json5 index 01111b6c..3f707b1c 100644 --- a/locales/en-global.json5 +++ b/locales/en-global.json5 @@ -154,19 +154,19 @@ description: 'Export the current selection as a PDF report.', button: 'Export PDF', info: { - selectedDistrict: 'Selected District', - selectedScenario: 'Selected Scenario', - referenceDate: 'Reference Date', - selectedDate: 'Selected Date', + 'selected-district': 'Selected District', + 'selected-scenario': 'Selected Scenario', + 'reference-date': 'Reference Date', + 'selected-date': 'Selected Date', }, images: { - lineChartLabel: 'Line chart', - mapLabel: 'Map', + 'line-chart-label': 'Line chart', + 'map-label': 'Map', }, table: { compartment: 'Compartment', - referenceValue: 'Reference Value', - selectedValue: 'Selected Value', + 'reference-value': 'Reference Value', + 'selected-value': 'Selected Value', }, }, } diff --git a/src/components/Sidebar/MapComponents/HeatLegend.tsx b/src/components/Sidebar/MapComponents/HeatLegend.tsx index 7b74bf87..807862cd 100644 --- a/src/components/Sidebar/MapComponents/HeatLegend.tsx +++ b/src/components/Sidebar/MapComponents/HeatLegend.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) // SPDX-License-Identifier: Apache-2.0 -import React, {useCallback, useLayoutEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo} from 'react'; import * as am5 from '@amcharts/amcharts5'; import Box from '@mui/material/Box'; import {HeatmapLegend} from 'types/heatmapLegend'; @@ -10,6 +10,9 @@ import {Localization} from 'types/localization'; import useRoot from 'components/shared/Root'; import useHeatLegend from 'components/shared/HeatMap/Legend'; +// export +import {useExportingRegistry} from 'context/ExportContext'; +import useExporting from '@/components/shared/Exporting'; interface HeatProps { /** * Object defining the legend for the heatmap. @@ -76,6 +79,8 @@ export default function HeatLegend({ const unique_id = useMemo(() => 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/TopBar/PopUps/ExportDialog.tsx b/src/components/TopBar/PopUps/ExportDialog.tsx index aa6d28d3..027c9107 100644 --- a/src/components/TopBar/PopUps/ExportDialog.tsx +++ b/src/components/TopBar/PopUps/ExportDialog.tsx @@ -8,7 +8,17 @@ import {useTranslation} from 'react-i18next'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; import {useExportingRegistry} from 'context/ExportContext'; -import type {Content, TDocumentDefinitions, ContentImage, ContentTable, TableLayout} from 'pdfmake/interfaces'; +import {NumberFormatter} from 'util/hooks'; +import i18n from 'util/i18n'; +import { + Content, + TDocumentDefinitions, + ContentImage, + ContentTable, + TableLayout, + ContentText, + ContentColumns, +} from 'pdfmake/interfaces'; import {DataContext} from 'context/SelectedDataContext'; import {useAppSelector} from 'store/hooks'; @@ -24,7 +34,9 @@ const toDataUrl = (img: unknown): string | undefined => { export default function ExportDialog(): 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 theme = useTheme(); const {get} = useExportingRegistry(); const {compartments, referenceDateValues, scenarioCardData} = useContext(DataContext)!; @@ -35,6 +47,7 @@ export default function ExportDialog(): JSX.Element { const selectedDate = useAppSelector((state) => state.dataSelection.date); const referenceDay = useAppSelector((state) => state.dataSelection.simulationStart); + const languageSuffix = i18nBackend.language === 'de' ? '-de' : '-en'; const compartmentNames = useMemo(() => { return ( compartments?.map((compartment) => { @@ -90,29 +103,41 @@ export default function ExportDialog(): JSX.Element { 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 (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] = await Promise.all([lineExp?.export?.('png'), mapExp?.export?.('png')]); + const [lineImg, mapImg, legendImg] = await Promise.all([ + lineExp?.export?.('png'), + mapExp?.export?.('png'), + legendExp?.export?.('png'), + ]); const lineDataUrl = toDataUrl(lineImg); const mapDataUrl = toDataUrl(mapImg); + const legendDataUrl = toDataUrl(legendImg); + + if (!lineDataUrl || !mapDataUrl || !legendDataUrl) { + 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(); const doc: TDocumentDefinitions = { - pageSize: 'A4', - pageOrientation: 'portrait', - pageMargins: [30, 30, 30, 40], + pageSize: 'A4', // customizable + pageOrientation: 'portrait', // customizable + pageMargins: [20, 20, 20, 20], // customizable content: [], styles: { - header: {fontSize: 20, bold: true, margin: [0, 0, 0, 8]}, + 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'}, @@ -126,29 +151,73 @@ export default function ExportDialog(): JSX.Element { color: '#666', }), defaultStyle: {fontSize: 11}, - info: {title: 'ESID Export', subject: 'Exported report', creator: 'ESID'}, + info: {title: 'ESID Export', subject: 'Exported report', creator: 'DLR'}, }; - (doc.content as Content[]).push({text: t('export.header'), style: 'header'}); - (doc.content as Content[]).push({ - text: `${selectedDistrictName} — ${selectedScenarioName ?? ''} • ${nowStr}`, + const docContents = doc.content as Content[]; + + // Header and subheader + const header = {text: t('export.header'), style: 'header'} as ContentText; + const subheader = { + text: `${t(selectedDistrictName)} — ${selectedScenarioName ?? ''}`, style: 'subheader', - }); + } as ContentText; + + const dateSubheader = { + text: `${nowStr}`, + style: 'subheader', + } as ContentText; + + const subheaderColumn: ContentColumns = { + columns: [ + { + width: '50%', + text: subheader, + alignment: 'left', + }, + { + width: '50%', + text: dateSubheader, + alignment: 'right', + }, + ], + margin: [0, 4, 0, 12], + }; + + docContents.push(header, subheaderColumn); + + const lineChart = { + image: lineDataUrl, + 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); + docContents.push(lineChartText); + + // Info table const infoTable: ContentTable = { table: { widths: ['*', '*', '*', '*'], body: [ [ - {text: 'Selected District', style: 'tableHeader'}, + {text: tGlobal('export.info.selected-district'), style: 'tableHeader'}, {text: selectedDistrictName, style: 'tableCell'}, - {text: 'Selected Scenario', style: 'tableHeader'}, + {text: tGlobal('export.info.selected-scenario'), style: 'tableHeader'}, {text: selectedScenarioName ?? '', style: 'tableCell'}, ], [ - {text: 'Reference Date', style: 'tableHeader'}, + {text: tGlobal('export.info.reference-date'), style: 'tableHeader'}, {text: referenceDay ?? '', style: 'tableCell'}, - {text: 'Selected Date', style: 'tableHeader'}, + {text: tGlobal('export.info.selected-date'), style: 'tableHeader'}, {text: selectedDate ?? '', style: 'tableCell'}, ], ], @@ -161,44 +230,35 @@ export default function ExportDialog(): JSX.Element { margin: [0, 0, 0, 12], }; - (doc.content as Content[]).push(infoTable); - - if (lineDataUrl) { - (doc.content as ContentImage[]).push({ - image: lineDataUrl, - fit: [540, 320], - alignment: 'center', - margin: [0, 0, 0, 8], - }); - (doc.content as Content[]).push({ - text: 'Line chart', - style: 'small', - alignment: 'center', - margin: [0, 0, 0, 12], - }); - } + const mapWidth = 200; - if (mapDataUrl) { - (doc.content as ContentImage[]).push({ - image: mapDataUrl, - fit: [540, 320], - alignment: 'center', - margin: [0, 0, 0, 8], - }); - (doc.content as Content[]).push({ - text: 'Map', - style: 'small', - alignment: 'center', - margin: [0, 0, 0, 12], - }); - } + const map = { + image: mapDataUrl, + width: mapWidth, + alignment: 'left' as const, + margin: [0, 0, 0, 0], + } as ContentImage; - // add each compartment name to the table + const mapLegend = { + image: legendDataUrl, + 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 const tableBody = [ [ - {text: 'Compartment', style: 'tableHeader'}, - {text: 'Reference Value', style: 'tableHeader', alignment: 'right'}, - {text: 'Selected Value', style: 'tableHeader', alignment: 'right'}, + {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'}, ], ]; @@ -206,25 +266,26 @@ export default function ExportDialog(): JSX.Element { tableBody.push([ {text: compartment.name, style: 'tableCell'}, { - text: (compartmentValues[compartment.id] ?? '').toString(), + text: formatNumber(compartmentValues[compartment.id] ?? 0), style: 'tableCell', alignment: 'right', }, { - text: (cardValues[selectedScenario ?? '']?.[compartment.id] ?? '').toString(), + 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', }; - (doc.content as ContentTable[]).push({ + const numbersTable: ContentTable = { layout: zebraLayout, table: { headerRows: 1, @@ -232,10 +293,27 @@ export default function ExportDialog(): JSX.Element { 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.pdf'); + pdfmake?.createPdf?.(doc)?.download(`ESID-export${languageSuffix}.pdf`); })(); }, [ get, @@ -248,6 +326,9 @@ export default function ExportDialog(): JSX.Element { cardValues, selectedScenario, selectedDate, + languageSuffix, + formatNumber, + tGlobal, ]); return ( From 71c1a90b96d0fbfca774d3fe383990352abaa0d9 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Tue, 16 Sep 2025 18:54:44 +0200 Subject: [PATCH 09/14] :wrench: enhance ExportDialog with custom header, footer, and logo integration --- src/components/TopBar/PopUps/ExportDialog.tsx | 108 ++++++++++++------ 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/src/components/TopBar/PopUps/ExportDialog.tsx b/src/components/TopBar/PopUps/ExportDialog.tsx index 027c9107..2ed3830a 100644 --- a/src/components/TopBar/PopUps/ExportDialog.tsx +++ b/src/components/TopBar/PopUps/ExportDialog.tsx @@ -18,10 +18,19 @@ import { TableLayout, ContentText, ContentColumns, + ContentSvg, } from 'pdfmake/interfaces'; import {DataContext} from 'context/SelectedDataContext'; import {useAppSelector} from 'store/hooks'; +const esidLogoSvg = ` + + + + + + { if (!img) return undefined; if (typeof img === 'string') return img; @@ -131,34 +140,16 @@ export default function ExportDialog(): JSX.Element { * 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(); - const doc: TDocumentDefinitions = { - pageSize: 'A4', // customizable - pageOrientation: 'portrait', // customizable - pageMargins: [20, 20, 20, 20], // customizable - content: [], - 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: (currentPage: number, pageCount: number) => ({ - text: `${currentPage} / ${pageCount}`, - alignment: 'right', - margin: [30, 0, 30, 20], - fontSize: 8, - color: '#666', - }), - defaultStyle: {fontSize: 11}, - info: {title: 'ESID Export', subject: 'Exported report', creator: 'DLR'}, - }; - const docContents = doc.content as Content[]; + // header and subheader + const headerContent = { + svg: esidLogoSvg, + width: 50, + alignment: 'left', + margin: [0, 0, 0, 0], + } as ContentSvg; - // Header and subheader - const header = {text: t('export.header'), style: 'header'} as ContentText; - const subheader = { + const subheaderContent = { text: `${t(selectedDistrictName)} — ${selectedScenarioName ?? ''}`, style: 'subheader', } as ContentText; @@ -168,11 +159,11 @@ export default function ExportDialog(): JSX.Element { style: 'subheader', } as ContentText; - const subheaderColumn: ContentColumns = { + const subheaderColumns: ContentColumns = { columns: [ { width: '50%', - text: subheader, + text: subheaderContent, alignment: 'left', }, { @@ -181,11 +172,59 @@ export default function ExportDialog(): JSX.Element { alignment: 'right', }, ], - margin: [0, 4, 0, 12], + margin: [0, 6, 0, 12], }; - docContents.push(header, subheaderColumn); + 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: lineDataUrl, width: 550, @@ -200,13 +239,12 @@ export default function ExportDialog(): JSX.Element { margin: [0, 6, 0, 12], } as ContentText; - docContents.push(lineChart); - docContents.push(lineChartText); + docContents.push(lineChart, lineChartText); - // Info table + // Info table with selected district, scenario, reference date and selected date const infoTable: ContentTable = { table: { - widths: ['*', '*', '*', '*'], + widths: ['*', 'auto', '*', 'auto'], body: [ [ {text: tGlobal('export.info.selected-district'), style: 'tableHeader'}, @@ -253,7 +291,7 @@ export default function ExportDialog(): JSX.Element { margin: [0, 6, 0, 0], } as ContentText; - // Compartment table + // Compartment table with compartment name, reference value and selected value const tableBody = [ [ {text: tGlobal('export.table.compartment'), style: 'tableHeader'}, From 4136e9abad238a7c113abafaea06462435022221 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 17 Sep 2025 19:13:14 +0200 Subject: [PATCH 10/14] :tada: :wrench: changed export as a menu instead of dialog, prep csv export --- src/components/TopBar/ApplicationMenu.tsx | 53 ++++++++++++------- .../ExportDialog.tsx => ExportMenu.tsx} | 48 +++++++---------- 2 files changed, 53 insertions(+), 48 deletions(-) rename src/components/TopBar/{PopUps/ExportDialog.tsx => ExportMenu.tsx} (93%) diff --git a/src/components/TopBar/ApplicationMenu.tsx b/src/components/TopBar/ApplicationMenu.tsx index 725ad0b7..101f640e 100644 --- a/src/components/TopBar/ApplicationMenu.tsx +++ b/src/components/TopBar/ApplicationMenu.tsx @@ -13,14 +13,13 @@ import Box from '@mui/system/Box'; import {useAppSelector} from 'store/hooks'; import {AuthContext, IAuthContext} from 'react-oauth2-code-pkce'; import CircularProgress from '@mui/material/CircularProgress'; -import Download from '@mui/icons-material/Download'; const ChangelogDialog = React.lazy(() => import('./PopUps/ChangelogDialog')); const ImprintDialog = React.lazy(() => 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 ExportDialog = React.lazy(() => import('./PopUps/ExportDialog')); +const ExportMenu = React.lazy(() => import('./ExportMenu')); type TokenData = { realm_access?: { @@ -53,6 +52,7 @@ export default function ApplicationMenu(): JSX.Element { 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( @@ -70,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(); @@ -113,11 +123,6 @@ export default function ApplicationMenu(): JSX.Element { setChangelogOpen(true); }; - const exportClicked = () => { - closeMenu(); - setExportOpen(true); - }; - return ( - } - > - + + + { + closeExportMenu(); + closeMenu(); + }} + /> - + ); } diff --git a/src/components/TopBar/PopUps/ExportDialog.tsx b/src/components/TopBar/ExportMenu.tsx similarity index 93% rename from src/components/TopBar/PopUps/ExportDialog.tsx rename to src/components/TopBar/ExportMenu.tsx index 2ed3830a..18fa92fc 100644 --- a/src/components/TopBar/PopUps/ExportDialog.tsx +++ b/src/components/TopBar/ExportMenu.tsx @@ -2,11 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import React, {useCallback, useContext, useMemo} from 'react'; -import Box from '@mui/material/Box'; -import useTheme from '@mui/material/styles/useTheme'; import {useTranslation} from 'react-i18next'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; +import MenuItem from '@mui/material/MenuItem'; import {useExportingRegistry} from 'context/ExportContext'; import {NumberFormatter} from 'util/hooks'; import i18n from 'util/i18n'; @@ -41,12 +38,13 @@ const toDataUrl = (img: unknown): string | undefined => { return undefined; }; -export default function ExportDialog(): JSX.Element { +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 theme = useTheme(); const {get} = useExportingRegistry(); const {compartments, referenceDateValues, scenarioCardData} = useContext(DataContext)!; @@ -106,9 +104,8 @@ export default function ExportDialog(): JSX.Element { return result; }, [compartmentNames, scenarioCardData, scenariosState]); - //TODO: add a error handler in case the card values doesn't have any data for the selected scenario - - const handleExport = useCallback(() => { + const handleExportPdf = useCallback(() => { + onDone?.(); void (async () => { const lineExp = get('lineChart'); const mapExp = get('map'); @@ -128,11 +125,11 @@ export default function ExportDialog(): JSX.Element { legendExp?.export?.('png'), ]); - const lineDataUrl = toDataUrl(lineImg); + const lineChartDataUrl = toDataUrl(lineImg); const mapDataUrl = toDataUrl(mapImg); - const legendDataUrl = toDataUrl(legendImg); + const mapLegendDataUrl = toDataUrl(legendImg); - if (!lineDataUrl || !mapDataUrl || !legendDataUrl) { + if (!lineChartDataUrl || !mapDataUrl || !mapLegendDataUrl) { return; } @@ -226,7 +223,7 @@ export default function ExportDialog(): JSX.Element { // Line chart const lineChart = { - image: lineDataUrl, + image: lineChartDataUrl, width: 550, alignment: 'left', margin: [0, 0, 0, 0], @@ -278,7 +275,7 @@ export default function ExportDialog(): JSX.Element { } as ContentImage; const mapLegend = { - image: legendDataUrl, + image: mapLegendDataUrl, width: mapWidth, alignment: 'left' as const, margin: [0, 0, 0, 0], @@ -367,22 +364,17 @@ export default function ExportDialog(): JSX.Element { languageSuffix, formatNumber, tGlobal, + onDone, ]); + const handleExportCsv = useCallback(() => { + onDone?.(); + }, [onDone]); + return ( - - {t('export.header')} -
- {t('export.description')} -
- -
+ <> + PDF + CSV (WIP) + ); } From 474bf8d4885ceefb943376f604a250616a19ca2c Mon Sep 17 00:00:00 2001 From: kunkoala Date: Mon, 22 Sep 2025 15:05:33 +0200 Subject: [PATCH 11/14] :beetle: :wrench: fix HeatLegend test to include ExportingRegistry context --- test/components/Sidebar/HeatLegend.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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'); From 064709455c37733edd546bb311178dad019c6d2c Mon Sep 17 00:00:00 2001 From: kunkoala Date: Tue, 25 Nov 2025 12:48:28 +0100 Subject: [PATCH 12/14] :beetle: fix duplicate deps --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index e0977458..55d1d7b8 100644 --- a/package.json +++ b/package.json @@ -69,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", From ad3802b0666e0c30e5826116cf689f34cd453fe8 Mon Sep 17 00:00:00 2001 From: kunkoala Date: Tue, 25 Nov 2025 14:59:39 +0100 Subject: [PATCH 13/14] :wrench: apply suggestions --- locales/de-global.json5 | 6 +++--- locales/en-global.json5 | 6 +++--- src/components/TopBar/ExportMenu.tsx | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/locales/de-global.json5 b/locales/de-global.json5 index 9dbc800b..7c4d7994 100644 --- a/locales/de-global.json5 +++ b/locales/de-global.json5 @@ -149,9 +149,9 @@ 'map-label': 'Karte', }, table: { - compartment: 'Zustand', - 'reference-value': 'Referenzwert', - 'selected-value': 'Ausgewählter Wert', + 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 3f707b1c..5d028180 100644 --- a/locales/en-global.json5 +++ b/locales/en-global.json5 @@ -164,9 +164,9 @@ 'map-label': 'Map', }, table: { - compartment: 'Compartment', - 'reference-value': 'Reference Value', - 'selected-value': 'Selected Value', + compartment: 'Infection state', + 'reference-value': 'Value Reference Date', + 'selected-value': 'Value Selected Date', }, }, } diff --git a/src/components/TopBar/ExportMenu.tsx b/src/components/TopBar/ExportMenu.tsx index 18fa92fc..fcc752cd 100644 --- a/src/components/TopBar/ExportMenu.tsx +++ b/src/components/TopBar/ExportMenu.tsx @@ -53,8 +53,7 @@ export default function ExportMenu({onDone}: ExportMenuProps): JSX.Element { 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 === 'de' ? '-de' : '-en'; + const languageSuffix = `-${i18nBackend.language}`; const compartmentNames = useMemo(() => { return ( compartments?.map((compartment) => { @@ -374,7 +373,9 @@ export default function ExportMenu({onDone}: ExportMenuProps): JSX.Element { return ( <> PDF - CSV (WIP) + + CSV + ); } From c03ab4664d788d6dde53748e1993e504623aa6af Mon Sep 17 00:00:00 2001 From: kunkoala Date: Wed, 26 Nov 2025 13:17:25 +0100 Subject: [PATCH 14/14] :wrench: fix asset import --- src/components/TopBar/ExportMenu.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/TopBar/ExportMenu.tsx b/src/components/TopBar/ExportMenu.tsx index fcc752cd..a9a8a546 100644 --- a/src/components/TopBar/ExportMenu.tsx +++ b/src/components/TopBar/ExportMenu.tsx @@ -19,14 +19,7 @@ import { } from 'pdfmake/interfaces'; import {DataContext} from 'context/SelectedDataContext'; import {useAppSelector} from 'store/hooks'; - -const esidLogoSvg = ` - - - - - - { if (!img) return undefined; @@ -139,7 +132,7 @@ export default function ExportMenu({onDone}: ExportMenuProps): JSX.Element { // header and subheader const headerContent = { - svg: esidLogoSvg, + svg: esidLogo, width: 50, alignment: 'left', margin: [0, 0, 0, 0],