diff --git a/DashAI/front/package.json b/DashAI/front/package.json index e0c8c7d5e..cc0f80d2b 100644 --- a/DashAI/front/package.json +++ b/DashAI/front/package.json @@ -15,6 +15,7 @@ "@tanstack/react-table": "^8.21.3", "axios": "^1.3.4", "formik": "^2.2.9", + "html2canvas": "^1.4.1", "i18next": "^25.7.4", "i18next-browser-languagedetector": "^8.2.0", "i18next-cli": "^1.34.1", diff --git a/DashAI/front/src/components/DatasetVisualization.jsx b/DashAI/front/src/components/DatasetVisualization.jsx index 440781765..7f84ae9e2 100644 --- a/DashAI/front/src/components/DatasetVisualization.jsx +++ b/DashAI/front/src/components/DatasetVisualization.jsx @@ -9,6 +9,7 @@ import { Divider, Tabs, Tab, + Tooltip, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import { AddCircleOutline as AddIcon } from "@mui/icons-material"; @@ -20,7 +21,6 @@ import { import { useTourContext } from "./tour/TourProvider"; import { formatDate } from "../pages/results/constants/formatDate"; import Header from "./notebooks/dataset/header/Header"; -import Tooltip from "@mui/material/Tooltip"; import OverviewTab from "./notebooks/dataset/tabs/OverviewTab"; import { NumericTab } from "./notebooks/dataset/tabs/NumericTab"; import { CategoricalTab } from "./notebooks/dataset/tabs/CategoricalTab"; @@ -185,7 +185,7 @@ export default function DatasetVisualization({ {dataset.name} - + { + e.stopPropagation(); + setAnchorEl(e.currentTarget); + }; + + const handleClose = () => setAnchorEl(null); + + const captureElement = useCallback( + async (element) => { + if (!element) return; + setCapturing(true); + try { + // Wait a tick so the hidden button is removed from the DOM before capture + await new Promise((r) => requestAnimationFrame(r)); + const canvas = await html2canvas(element, { + backgroundColor: null, + scale: 2, + }); + const link = document.createElement("a"); + link.download = `${filename}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + } finally { + setCapturing(false); + } + }, + [filename], + ); + + const handleExportCard = useCallback(async () => { + handleClose(); + await captureElement(ref.current); + }, [captureElement]); + + const handleExportChart = useCallback(async () => { + handleClose(); + if (!ref.current) return; + const chart = + ref.current.querySelector(".recharts-wrapper") || + ref.current.querySelector(".js-plotly-plot"); + if (chart) { + setCapturing(true); + try { + await new Promise((r) => requestAnimationFrame(r)); + const canvas = await html2canvas(chart, { + backgroundColor: null, + scale: 2, + }); + const link = document.createElement("a"); + link.download = `${filename}_chart.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + } finally { + setCapturing(false); + } + } + }, [filename]); + + const handleExportCSV = useCallback(() => { + handleClose(); + if (!exportData) return; + const data = Array.isArray(exportData) ? exportData : [exportData]; + if (data.length === 0) return; + const headers = Object.keys(data[0]); + const csvRows = [ + headers.join(","), + ...data.map((row) => + headers + .map((h) => { + const val = row[h] ?? ""; + const str = String(val); + return str.includes(",") || str.includes('"') || str.includes("\n") + ? `"${str.replace(/"/g, '""')}"` + : str; + }) + .join(","), + ), + ]; + const blob = new Blob([csvRows.join("\n")], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}.csv`; + link.click(); + URL.revokeObjectURL(url); + }, [exportData, filename]); + + const handleExportJSON = useCallback(() => { + handleClose(); + if (!exportData) return; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}.json`; + link.click(); + URL.revokeObjectURL(url); + }, [exportData, filename]); + + const hasChart = true; // Charts are detected at runtime + const hasData = Boolean(exportData); + + return ( + + {!capturing && ( + + + + + + )} + + + + + + + {t("datasets:label.exportCardImage")} + + + + + + + {t("datasets:label.exportChartImage")} + + + {hasData && ( + + + + + {t("datasets:label.exportCSV")} + + )} + + {hasData && ( + + + + + {t("datasets:label.exportJSON")} + + )} + + + {children} + + ); +} diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/CategoricalTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/CategoricalTab.jsx index 42e8c1caf..699618909 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/CategoricalTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/CategoricalTab.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Typography, Card, CardContent } from "@mui/material"; +import { Box, Typography, CardContent } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import TitleIcon from "@mui/icons-material/Title"; import { @@ -15,6 +15,7 @@ import { Cell, } from "recharts"; import { StatBox } from "../StatBox"; +import ExportableCard from "../ExportableCard"; import { useTranslation } from "react-i18next"; export const CategoricalTab = ({ categoricalStats }) => { @@ -24,7 +25,12 @@ export const CategoricalTab = ({ categoricalStats }) => { return ( {Object.entries(categoricalStats).map(([column, stats]) => ( - + {/* Header */} @@ -148,7 +154,7 @@ export const CategoricalTab = ({ categoricalStats }) => { - + ))} ); diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/CorrelationsTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/CorrelationsTab.jsx index 18822e507..33381e97e 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/CorrelationsTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/CorrelationsTab.jsx @@ -1,7 +1,8 @@ -import React, { useMemo } from "react"; -import { Box, Typography, CardContent, Card } from "@mui/material"; +import { useMemo } from "react"; +import { Box, Typography, CardContent } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import Plot from "react-plotly.js"; +import ExportableCard from "../ExportableCard"; import { useTranslation } from "react-i18next"; const CorrelationsTab = ({ correlations }) => { @@ -45,7 +46,7 @@ const CorrelationsTab = ({ correlations }) => { }, [correlations]); return ( - + {t("datasets:label.correlationAnalysis")} @@ -150,7 +151,7 @@ const CorrelationsTab = ({ correlations }) => { - + ); }; diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/NumericTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/NumericTab.jsx index 8dd2eae17..d40993e69 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/NumericTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/NumericTab.jsx @@ -1,11 +1,12 @@ import React from "react"; -import { Box, Typography, Card, CardContent, Alert } from "@mui/material"; +import { Box, Typography, CardContent, Alert } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import TrendingUpIcon from "@mui/icons-material/TrendingUp"; import InfoIcon from "@mui/icons-material/Info"; import Plot from "react-plotly.js"; import { StatBox } from "../StatBox"; import { MetricRow } from "../MetricRow"; +import ExportableCard from "../ExportableCard"; import { Trans, useTranslation } from "react-i18next"; export const NumericTab = ({ numericStats }) => { @@ -15,7 +16,13 @@ export const NumericTab = ({ numericStats }) => { return ( {Object.entries(numericStats).map(([column, stats]) => ( - + {/* Title */} @@ -188,7 +195,7 @@ export const NumericTab = ({ numericStats }) => { )} - + ))} ); diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx index a54acacb8..74fadfcc1 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/OverviewTab.jsx @@ -11,6 +11,7 @@ import { Bar, } from "recharts"; import MrtDatasetTable from "../MrtDatasetTable"; +import ExportableCard from "../ExportableCard"; import { useTranslation } from "react-i18next"; import { getColorByColumnType } from "../../../../utils"; @@ -88,7 +89,11 @@ const OverviewTab = ({ {/* Missing Values Overview — only shown when there are missing values */} {missingData.some((data) => data.missing > 0) && ( - + - + )} ); diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/QualityTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/QualityTab.jsx index 4ec57ce9a..d68e414d4 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/QualityTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/QualityTab.jsx @@ -1,14 +1,8 @@ import React from "react"; -import { - Box, - Typography, - Paper, - Card, - CardContent, - Alert, -} from "@mui/material"; +import { Box, Typography, Paper, CardContent, Alert } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import IssueCard from "../IssueCard"; +import ExportableCard from "../ExportableCard"; import { useTranslation } from "react-i18next"; const QualityTab = ({ qualityInfo, totalRows }) => { @@ -18,7 +12,7 @@ const QualityTab = ({ qualityInfo, totalRows }) => { return ( {/* Data Quality Summary */} - + {t("datasets:label.dataQualitySummary")} @@ -127,10 +121,15 @@ const QualityTab = ({ qualityInfo, totalRows }) => { - + {/* Missing Data by Column */} - + ({ column: col, missing_ratio: ratio }), + )} + > {t("datasets:label.missingDataByColumn")} @@ -195,7 +194,7 @@ const QualityTab = ({ qualityInfo, totalRows }) => { )} - + ); }; diff --git a/DashAI/front/src/components/notebooks/dataset/tabs/TextTab.jsx b/DashAI/front/src/components/notebooks/dataset/tabs/TextTab.jsx index 2021f42e2..ce6080540 100644 --- a/DashAI/front/src/components/notebooks/dataset/tabs/TextTab.jsx +++ b/DashAI/front/src/components/notebooks/dataset/tabs/TextTab.jsx @@ -2,7 +2,6 @@ import React from "react"; import { Box, Typography, - Card, CardContent, Chip, Alert, @@ -22,6 +21,7 @@ import { } from "recharts"; import { StatBox } from "../StatBox"; import { MetricRow } from "../MetricRow"; +import ExportableCard from "../ExportableCard"; import { useTranslation } from "react-i18next"; export const TextTab = ({ textStats }) => { @@ -58,7 +58,13 @@ export const TextTab = ({ textStats }) => { : null; return ( - + {/* Title */} @@ -208,7 +214,7 @@ export const TextTab = ({ textStats }) => { - + ); })} diff --git a/DashAI/front/src/utils/i18n/locales/en/datasets.json b/DashAI/front/src/utils/i18n/locales/en/datasets.json index 2e79550f3..71494e2bb 100644 --- a/DashAI/front/src/utils/i18n/locales/en/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/en/datasets.json @@ -140,6 +140,12 @@ "explorationType": "Exploration Type", "explore": "Explore", "explorer": "Explorer", + "exportCardImage": "Card as image", + "exportChartImage": "Chart as image", + "exportCSV": "Download as CSV", + "exportImage": "Download", + "exportJSON": "Download as JSON", + "exportMetrics": "Export metrics as JSON", "fileSizeMB": "File Size (MB)", "firstLastStopsAtExtremes": "Color stops must include positions 0 at the start and 1 at the end.", "fontFamily": "Font Family", diff --git a/DashAI/front/src/utils/i18n/locales/es/datasets.json b/DashAI/front/src/utils/i18n/locales/es/datasets.json index bd974e05d..a514debb0 100644 --- a/DashAI/front/src/utils/i18n/locales/es/datasets.json +++ b/DashAI/front/src/utils/i18n/locales/es/datasets.json @@ -142,6 +142,12 @@ "explorationType": "Tipo de Exploración", "explore": "Explorar", "explorer": "Explorador", + "exportCardImage": "Tarjeta como imagen", + "exportChartImage": "Gráfico como imagen", + "exportCSV": "Descargar como CSV", + "exportImage": "Descargar", + "exportJSON": "Descargar como JSON", + "exportMetrics": "Exportar métricas como JSON", "fileSizeMB": "Tamaño del Archivo (MB)", "firstLastStopsAtExtremes": "Los puntos de color deben incluir posiciones 0 al inicio y 1 al final.", "fontFamily": "Familia de Fuente", diff --git a/DashAI/front/yarn.lock b/DashAI/front/yarn.lock index 2a78a3e25..645b74363 100644 --- a/DashAI/front/yarn.lock +++ b/DashAI/front/yarn.lock @@ -7436,6 +7436,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: ^1.0.2 + checksum: 37b1fe632b03be7a287cd394cef8b5285666343443125c510df9cfb6a4734a2c71e154ec8f7bbff72d7c339e1e5872989b1c52d86162aed27d6cc114725bb4d0 + languageName: node + linkType: hard + "css-loader@npm:^6.5.1": version: 6.11.0 resolution: "css-loader@npm:6.11.0" @@ -8010,6 +8019,7 @@ __metadata: eslint-plugin-promise: ^6.0.0 eslint-plugin-react: ^7.34.2 formik: ^2.2.9 + html2canvas: ^1.4.1 i18next: ^25.7.4 i18next-browser-languagedetector: ^8.2.0 i18next-cli: ^1.34.1 @@ -11129,6 +11139,16 @@ __metadata: languageName: node linkType: hard +"html2canvas@npm:^1.4.1": + version: 1.4.1 + resolution: "html2canvas@npm:1.4.1" + dependencies: + css-line-break: ^2.1.0 + text-segmentation: ^1.0.3 + checksum: c134324af57f3262eecf982e436a4843fded3c6cf61954440ffd682527e4dd350e0c2fafd217c0b6f9a455fe345d0c67b4505689796ab160d4ca7c91c3766739 + languageName: node + linkType: hard + "htmlparser2@npm:^6.1.0": version: 6.1.0 resolution: "htmlparser2@npm:6.1.0" @@ -18907,6 +18927,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: ^1.0.2 + checksum: 2e24632d59567c55ab49ac324815e2f7a8043e63e26b109636322ac3e30692cee8679a448fd5d0f0598a345f407afd0e34ba612e22524cf576d382d84058c013 + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -19643,6 +19672,15 @@ __metadata: languageName: node linkType: hard +"utrie@npm:^1.0.2": + version: 1.0.2 + resolution: "utrie@npm:1.0.2" + dependencies: + base64-arraybuffer: ^1.0.2 + checksum: c96fbb7d4d8855a154327da0b18e39b7511cc70a7e4bcc3658e24f424bb884312d72b5ba777500b8858e34d365dc6b1a921dc5ca2f0d341182519c6b78e280a5 + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2"