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 (
+ }
+ variant="outlined"
+ onClick={handleClick}
+ >
+ {t('DOWNLOAD_BTN_LABEL')}
+
+ );
+};
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 => {
+
+
+ }
+ onClick={handleDownload}
+ >
+ {isLoading
+ ? tExport('DOWNLOADING_BTN_LABEL')
+ : tExport('DOWNLOAD_BTN_LABEL')}
+
+
+
);
};
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"