From 8b65e10a759e64b7f91d2c4b88f581c0b3f1fe07 Mon Sep 17 00:00:00 2001 From: Thibault Reidy <147397675+ReidyT@users.noreply.github.com> Date: Sun, 22 Dec 2024 13:54:19 +0100 Subject: [PATCH] feat: export data to CSV and PNG (#124) * feat: export data to csv * feat: download charts as PNG * feat: improve responsivity on small devices --- package.json | 3 + src/context/ChartContext.tsx | 8 +- src/hooks/useHouseCost.tsx | 28 +++++++ src/langs/en.json | 13 +++- src/modules/common/ExportCSVButton.tsx | 59 ++++++++++++++ .../useSimulationInformations.tsx | 21 +---- src/modules/common/charts/HeatLossCharts.tsx | 41 +++++++++- src/modules/main/PlayerView.tsx | 2 +- yarn.lock | 77 +++++++++++++++++++ 9 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 src/hooks/useHouseCost.tsx create mode 100644 src/modules/common/ExportCSVButton.tsx diff --git a/package.json b/package.json index 568d351..8de04bc 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/react": "18.3.16", "@types/react-dom": "18.3.5", "@types/recharts": "^1.8.29", + "file-saver": "^2.0.5", "i18next": "^23.9.0", "immer": "^10.1.1", "lucide-react": "^0.456.0", @@ -35,6 +36,7 @@ "react-i18next": "14.1.3", "react-toastify": "10.0.6", "recharts": "^2.15.0", + "recharts-to-png": "^2.4.0", "three": "^0.169.0", "typescript": "5.7.2", "use-debounce": "^10.0.4", @@ -74,6 +76,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/file-saver": "^2.0.7", "@types/i18n": "0.13.12", "@types/papaparse": "^5.3.15", "@types/three": "^0", diff --git a/src/context/ChartContext.tsx b/src/context/ChartContext.tsx index ccb639b..3cbdc7d 100644 --- a/src/context/ChartContext.tsx +++ b/src/context/ChartContext.tsx @@ -4,17 +4,11 @@ import { undefinedContextErrorFactory } from '@/utils/context'; type Period = { numberOfDays: number; - labelKey: - | 'ONE_MONTH' - | 'THREE_MONTHS' - | 'SIX_MONTHS' - | 'ONE_YEAR' - | 'THREE_YEARS'; + labelKey: 'ONE_MONTH' | 'SIX_MONTHS' | 'ONE_YEAR' | 'THREE_YEARS'; }; export const PERIODS: Period[] = [ { numberOfDays: 30, labelKey: 'ONE_MONTH' }, - { numberOfDays: 90, labelKey: 'THREE_MONTHS' }, { numberOfDays: 180, labelKey: 'SIX_MONTHS' }, { numberOfDays: 365, labelKey: 'ONE_YEAR' }, { numberOfDays: 1_095, labelKey: 'THREE_YEARS' }, diff --git a/src/hooks/useHouseCost.tsx b/src/hooks/useHouseCost.tsx new file mode 100644 index 0000000..31a8db2 --- /dev/null +++ b/src/hooks/useHouseCost.tsx @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; + +import { useSimulation } from '@/context/SimulationContext'; +import { HouseComponent } from '@/types/houseComponent'; + +export const useHouseCost = (): { wallCost: number } => { + const { + house: { getByType }, + } = useSimulation(); + + const wallCost = useMemo( + () => + getByType(HouseComponent.Wall).reduce( + (totCost, houseComponent) => + totCost + + houseComponent.actualArea * + houseComponent.buildingMaterials.reduce( + (componentCost, material) => + componentCost + material.price * material.thickness, + 0, + ), + 0, + ), + [getByType], + ); + + return { wallCost }; +}; diff --git a/src/langs/en.json b/src/langs/en.json index 857264f..28b5368 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -95,10 +95,19 @@ "SIMULATION_GRAPHICS": { "HEAT_LOSS": { "ONE_MONTH": "1 month", - "THREE_MONTHS": "3 months", "SIX_MONTHS": "6 months", "ONE_YEAR": "1 year", - "THREE_YEARS": "3 years" + "THREE_YEARS": "3 years", + "PERIOD_LABEL": "graphic period", + "HEAT_LOSS_LABEL": "Heat Loss", + "OUTDOOR_LABEL": "Outdoor" + }, + "EXPORT_CSV": { + "DOWNLOAD_BTN_LABEL": "Download CSV" + }, + "EXPORT_CHART": { + "DOWNLOAD_BTN_LABEL": "Download charts", + "DOWNLOADING_BTN_LABEL": "Downloading..." } }, "INSULATIONS": { diff --git a/src/modules/common/ExportCSVButton.tsx b/src/modules/common/ExportCSVButton.tsx new file mode 100644 index 0000000..e422ba2 --- /dev/null +++ b/src/modules/common/ExportCSVButton.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next'; + +import { Button } from '@mui/material'; + +import { FileSpreadsheet } from 'lucide-react'; +import Papa from 'papaparse'; + +import { useSimulation } from '@/context/SimulationContext'; +import { useHouseCost } from '@/hooks/useHouseCost'; + +export const ExportCSVButton = (): JSX.Element => { + const { t } = useTranslation('SIMULATION_GRAPHICS', { + keyPrefix: 'EXPORT_CSV', + }); + + const { + simulation: { + days: { simulationDays }, + }, + temperatures: { indoor, outdoor }, + electricity: { pricekWh }, + } = useSimulation(); + + const { wallCost } = useHouseCost(); + + const handleClick = (): void => { + const data = simulationDays.map((d) => ({ + 'date (UTC)': d.date.toUTCString(), + 'indoor (°C)': indoor, + 'outdoor (°C)': outdoor.value, + 'heat loss (W)': d.heatLoss.global, + 'total heat loss (W)': d.totalHeatLoss, + 'price kWh (CHF)': pricekWh, + 'total electricity cost (CHF)': d.totalElectricityCost, + 'wall cost (CHF)': wallCost, + })); + + const csv = Papa.unparse(data); + const csvData = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const csvURL = URL.createObjectURL(csvData); + + const link = document.createElement('a'); + link.href = csvURL; + link.download = `Insulation_Simulator_Data${new Date().getTime()}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( + + ); +}; diff --git a/src/modules/common/SimulationInformations/useSimulationInformations.tsx b/src/modules/common/SimulationInformations/useSimulationInformations.tsx index d8ebb86..5f782f0 100644 --- a/src/modules/common/SimulationInformations/useSimulationInformations.tsx +++ b/src/modules/common/SimulationInformations/useSimulationInformations.tsx @@ -4,6 +4,7 @@ import { Flower, Leaf, Snowflake, Sun } from 'lucide-react'; import { useSeason } from '@/context/SeasonContext'; import { useSimulation } from '@/context/SimulationContext'; +import { useHouseCost } from '@/hooks/useHouseCost'; import { FormattedHeatLoss } from '@/types/heatLoss'; import { HouseComponent } from '@/types/houseComponent'; import { Season, Seasons } from '@/types/seasons'; @@ -32,7 +33,7 @@ export const useSimulationInformations = const { heatLoss: { global: heatLoss }, - house: { getByType, getFirstOfType }, + house: { getFirstOfType }, } = useSimulation(); const wallComponent = useMemo( @@ -40,25 +41,11 @@ export const useSimulationInformations = [getFirstOfType], ); - const wallPrices = useMemo( - () => - getByType(HouseComponent.Wall).reduce( - (totCost, houseComponent) => - totCost + - houseComponent.actualArea * - houseComponent.buildingMaterials.reduce( - (componentCost, material) => - componentCost + material.price * material.thickness, - 0, - ), - 0, - ), - [getByType], - ); + const { wallCost } = useHouseCost(); return { heatLoss: formatHeatLossRate(heatLoss), - wallsPrice: Math.round(wallPrices), + wallsPrice: Math.round(wallCost), seasonIcon: iconsBySeason[season], formattedWallSize: wallComponent ? formatComponentSize({ diff --git a/src/modules/common/charts/HeatLossCharts.tsx b/src/modules/common/charts/HeatLossCharts.tsx index be5beae..547cf96 100644 --- a/src/modules/common/charts/HeatLossCharts.tsx +++ b/src/modules/common/charts/HeatLossCharts.tsx @@ -1,13 +1,16 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { + Button, Stack, ToggleButton, ToggleButtonGroup, Typography, } from '@mui/material'; +import { saveAs } from 'file-saver'; +import { FileChartLine } from 'lucide-react'; import { CartesianGrid, Legend, @@ -17,20 +20,28 @@ import { XAxis, YAxis, } from 'recharts'; +import { useCurrentPng } from 'recharts-to-png'; import { PERIODS, useChart } from '@/context/ChartContext'; import { useSimulation } from '@/context/SimulationContext'; +import { ExportCSVButton } from '../ExportCSVButton'; + type Props = { width: number }; export const HeatLossCharts = ({ width }: Props): JSX.Element => { const { t } = useTranslation('SIMULATION_GRAPHICS', { keyPrefix: 'HEAT_LOSS', }); + const { t: tExport } = useTranslation('SIMULATION_GRAPHICS', { + keyPrefix: 'EXPORT_CHART', + }); const { days: { simulationDays, currentIdx }, } = useSimulation('simulation'); const { period, updatePeriod } = useChart(); + const [getPng, { ref, isLoading }] = useCurrentPng(); + const chartData = useMemo( () => simulationDays.map((d) => ({ @@ -46,13 +57,21 @@ export const HeatLossCharts = ({ width }: Props): JSX.Element => { currentIdx + 1, ); + const handleDownload = useCallback(async () => { + const png = await getPng(); + + if (png) { + saveAs(png, 'simulation_heatloss_chart.png'); + } + }, [getPng]); + return ( updatePeriod(v)} - aria-label="graphic period" + aria-label={t('PERIOD_LABEL')} > {PERIODS.map((p) => ( { ))} { dataKey="hl" stroke="#8884d8" activeDot={{ r: 8 }} - name="Heat Loss" + name={t('HEAT_LOSS_LABEL')} unit=" kilowatt" dot={false} animationDuration={0} @@ -94,11 +114,24 @@ export const HeatLossCharts = ({ width }: Props): JSX.Element => { + + + + + ); }; diff --git a/src/modules/main/PlayerView.tsx b/src/modules/main/PlayerView.tsx index b5688dd..21d4c58 100644 --- a/src/modules/main/PlayerView.tsx +++ b/src/modules/main/PlayerView.tsx @@ -23,7 +23,7 @@ import { SimulationSettingsPanel } from '../common/SimulationSettingsPanel/Simul import { HeatLossCharts } from '../common/charts/HeatLossCharts'; import { Tabs } from '../common/tabs/Tabs'; -const SM = { width: 375, height: 400 }; +const SM = { width: 350, height: 475 }; const MD = { width: 500, height: 500 }; const PlayerViewComponent = (): JSX.Element => { diff --git a/yarn.lock b/yarn.lock index 978df36..d5812c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4966,6 +4966,13 @@ __metadata: languageName: node linkType: hard +"@types/file-saver@npm:^2.0.7": + version: 2.0.7 + resolution: "@types/file-saver@npm:2.0.7" + checksum: 10/c3d1cd80eab1214767922cabac97681f3fb688e82b74890450d70deaca49537949bbc96d80d363d91e8f0a4752c7164909cc8902d9721c5c4809baafc42a3801 + languageName: node + linkType: hard + "@types/find-cache-dir@npm:^3.2.1": version: 3.2.1 resolution: "@types/find-cache-dir@npm:3.2.1" @@ -6306,6 +6313,13 @@ __metadata: languageName: node linkType: hard +"base64-arraybuffer@npm:^1.0.2": + version: 1.0.2 + resolution: "base64-arraybuffer@npm:1.0.2" + checksum: 10/15e6400d2d028bf18be4ed97702b11418f8f8779fb8c743251c863b726638d52f69571d4cc1843224da7838abef0949c670bde46936663c45ad078e89fee5c62 + languageName: node + linkType: hard + "base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -6993,6 +7007,15 @@ __metadata: languageName: node linkType: hard +"css-line-break@npm:^2.1.0": + version: 2.1.0 + resolution: "css-line-break@npm:2.1.0" + dependencies: + utrie: "npm:^1.0.2" + checksum: 10/e75cae40de511026228d4fa69e8d464895714f8899880b8268a446b57f0faa84b490ba1bdda5ed9e7f38f99ab947c6bc941bb505d8119c49072db079c1cacea5 + languageName: node + linkType: hard + "css.escape@npm:^1.5.1": version: 1.5.1 resolution: "css.escape@npm:1.5.1" @@ -8853,6 +8876,13 @@ __metadata: languageName: node linkType: hard +"file-saver@npm:^2.0.5": + version: 2.0.5 + resolution: "file-saver@npm:2.0.5" + checksum: 10/fbba443d9b682fec0be6676c048a7ac688b9bd33b105c02e8e1a1cb3e354ed441198dc1f15dcbc137fa044bea73f8a7549f2617e3a952fa7d96390356d54a7c3 + languageName: node + linkType: hard + "file-system-cache@npm:2.3.0": version: 2.3.0 resolution: "file-system-cache@npm:2.3.0" @@ -9386,6 +9416,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.1.0" "@trivago/prettier-plugin-sort-imports": "npm:^4.3.0" + "@types/file-saver": "npm:^2.0.7" "@types/i18n": "npm:0.13.12" "@types/node": "npm:20.17.10" "@types/papaparse": "npm:^5.3.15" @@ -9412,6 +9443,7 @@ __metadata: eslint-plugin-prettier: "npm:5.2.1" eslint-plugin-react: "npm:^7.33.2" eslint-plugin-react-hooks: "npm:5.0.0" + file-saver: "npm:^2.0.5" globals: "npm:^15.11.0" husky: "npm:9.1.7" i18next: "npm:^23.9.0" @@ -9428,6 +9460,7 @@ __metadata: react-i18next: "npm:14.1.3" react-toastify: "npm:10.0.6" recharts: "npm:^2.15.0" + recharts-to-png: "npm:^2.4.0" three: "npm:^0.169.0" three-stdlib: "npm:2.33.0" typescript: "npm:5.7.2" @@ -9607,6 +9640,16 @@ __metadata: languageName: node linkType: hard +"html2canvas@npm:^1.2.0": + version: 1.4.1 + resolution: "html2canvas@npm:1.4.1" + dependencies: + css-line-break: "npm:^2.1.0" + text-segmentation: "npm:^1.0.3" + checksum: 10/595790810557a1d4287f07b6ead49aed4f169f08eb00e20c1f030b93344003e84797d28cd8a220e8ec1b78641d460ca6add11b8531960725526f11a7b9fa9900 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -12539,6 +12582,22 @@ __metadata: languageName: node linkType: hard +"recharts-to-png@npm:^2.4.0": + version: 2.4.0 + resolution: "recharts-to-png@npm:2.4.0" + dependencies: + html2canvas: "npm:^1.2.0" + peerDependencies: + react: ">=16.8.3" + react-dom: ">=16.8.3" + recharts: ">=2.9.0" + peerDependenciesMeta: + recharts: + optional: true + checksum: 10/8c760fb02843d3a3b74070ba3bbe29a5116de780db1d42be438823031990e4ea2af92c24b6ca168fd4950f2f062759ceee8a14857e2ab625e3cdb2594b0b7f20 + languageName: node + linkType: hard + "recharts@npm:^2.15.0": version: 2.15.0 resolution: "recharts@npm:2.15.0" @@ -13717,6 +13776,15 @@ __metadata: languageName: node linkType: hard +"text-segmentation@npm:^1.0.3": + version: 1.0.3 + resolution: "text-segmentation@npm:1.0.3" + dependencies: + utrie: "npm:^1.0.2" + checksum: 10/86191de83f09b96f356628c3dbaf6d281eda46a4dd4c94c3827495428871b9dd44ead4ef4cdf06a188b08a3b83e3943c5911841422b55286b121ba9e04c846dd + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -14372,6 +14440,15 @@ __metadata: languageName: node linkType: hard +"utrie@npm:^1.0.2": + version: 1.0.2 + resolution: "utrie@npm:1.0.2" + dependencies: + base64-arraybuffer: "npm:^1.0.2" + checksum: 10/0c9458380bf3113f425a268e3d605aef163bfbaea62bf24de517b62b6e6744394d8ef1cdd9b07423359aec5ed402baab4bb2c5beec64f674ec635dc2f8dbd4bf + languageName: node + linkType: hard + "uuid@npm:9.0.1, uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1"