diff --git a/app/scripts/components/analysis/results/chart-card.tsx b/app/scripts/components/analysis/results/chart-card.tsx index edd7b6694..a47520eb6 100644 --- a/app/scripts/components/analysis/results/chart-card.tsx +++ b/app/scripts/components/analysis/results/chart-card.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useRef, useMemo, useState } from 'react'; +import React, { + useCallback, + useRef, + useMemo, + useState, + MouseEvent +} from 'react'; import { format } from 'date-fns'; import { reverse } from 'd3'; import { useTheme } from 'styled-components'; @@ -11,6 +17,7 @@ import { CollecticonCircleInformation, CollecticonDownload2 } from '@devseed-ui/collecticons'; +import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; import { TimeseriesData } from './timeseries-data'; import { @@ -32,6 +39,8 @@ import { ChartLoading } from '$components/common/loading-skeleton'; import { dateFormatter } from '$components/common/chart/utils'; import { Tip } from '$components/common/tip'; import { composeVisuallyDisabled } from '$utils/utils'; +import { exportCsv } from '$components/common/chart/analysis/utils'; +import DropMenuItemButton from '$styles/drop-menu-item-button'; interface ChartCardProps { title: React.ReactNode; @@ -64,22 +73,37 @@ export default function ChartCard(props: ChartCardProps) { const [brushIndex, setBrushIndex] = useState({ startIndex: 0, endIndex: 0 }); const noDownloadReason = getNoDownloadReason(chartData); - const onExportClick = useCallback(() => { - if (!chartData.data?.timeseries.length) { - return; - } + const onExportClick = useCallback( + (e: MouseEvent, type: 'image' | 'text') => { + e.preventDefault(); + if (!chartData.data?.timeseries.length) { + return; + } - const { startIndex, endIndex } = brushIndex; - // The indexes expect the data to be ascending, so we have to reverse the - // data. - const data = reverse(chartData.data.timeseries); - const dFormat = 'yyyy-MM-dd'; - const startDate = format(new Date(data[startIndex].date), dFormat); - const endDate = format(new Date(data[endIndex].date), dFormat); + const { startIndex, endIndex } = brushIndex; + // The indexes expect the data to be ascending, so we have to reverse the + // data. + const data = reverse(chartData.data.timeseries); + const dFormat = 'yyyy-MM-dd'; + const startDate = format(new Date(data[startIndex].date), dFormat); + const endDate = format(new Date(data[endIndex].date), dFormat); - const filename = `chart.${id}.${startDate}-${endDate}`; - chartRef.current?.saveAsImage(filename); - }, [id, chartData.data, brushIndex]); + const filename = `chart.${id}.${startDate}-${endDate}`; + + if (type === 'image') { + chartRef.current?.saveAsImage(filename); + } else { + exportCsv( + filename, + data, + data[startIndex].date, + data[endIndex].date, + activeMetrics + ); + } + }, + [id, chartData.data, brushIndex, activeMetrics] + ); const theme = useTheme(); @@ -109,19 +133,41 @@ export default function ChartCard(props: ChartCardProps) { </CardHeadline> <CardActions> <Toolbar size='small'> - <Tip - content={noDownloadReason} - disabled={!noDownloadReason} - hideOnClick={false} + <Dropdown + alignment='right' + triggerElement={(props) => ( + <Tip + content={noDownloadReason} + disabled={!noDownloadReason} + hideOnClick={false} + > + <ChartDownloadButton + {...props} + variation='base-text' + visuallyDisabled={!!noDownloadReason} + > + <CollecticonDownload2 title='Download' meaningful /> + </ChartDownloadButton> + </Tip> + )} > - <ChartDownloadButton - variation='base-text' - onClick={onExportClick} - visuallyDisabled={!!noDownloadReason} - > - <CollecticonDownload2 title='Download' meaningful /> - </ChartDownloadButton> - </Tip> + <DropTitle>Select a file format</DropTitle> + <DropMenu> + <li> + <DropMenuItemButton + onClick={(e) => onExportClick(e, 'image')} + > + Image (JPG) + </DropMenuItemButton> + </li> + <li> + <DropMenuItemButton onClick={(e) => onExportClick(e, 'text')}> + Text (CSV) + </DropMenuItemButton> + </li> + </DropMenu> + </Dropdown> + <VerticalDivider variation='dark' /> <ToolbarIconButton variation='base-text'> <CollecticonCircleInformation title='More info' meaningful /> diff --git a/app/scripts/components/common/chart/analysis/utils.ts b/app/scripts/components/common/chart/analysis/utils.ts index d87046886..2d80734de 100644 --- a/app/scripts/components/common/chart/analysis/utils.ts +++ b/app/scripts/components/common/chart/analysis/utils.ts @@ -1,8 +1,12 @@ import { RefObject, Component } from 'react'; +import FileSaver from 'file-saver'; +import { unparse } from 'papaparse'; import { chartAspectRatio, brushHeight } from '$components/common/chart/constant'; +import { TimeseriesDataUnit } from '$components/analysis/results/timeseries-data'; +import { DataMetric } from '$components/analysis/results/analysis-head-actions'; const URL = window.URL; @@ -202,3 +206,25 @@ export async function exportImage({ throw Error('No SVG specified'); } } + +export function exportCsv( + filename: string, + data: TimeseriesDataUnit[], + startDate: string, + endDate: string, + activeMetrics: DataMetric[] +) { + const startTimestamp = +new Date(startDate); + const endTimestamp = +new Date(endDate); + const filtered = data.filter((row) => { + const timestamp = +new Date(row.date); + return timestamp >= startTimestamp && timestamp <= endTimestamp; + }); + const csv = unparse(filtered, { + columns: ['date', ...activeMetrics.map((m) => m.id)] + }); + FileSaver.saveAs( + new Blob([csv], { type: 'text/csv;charset=utf-8' }), + `${filename}.csv` + ); +} diff --git a/package.json b/package.json index 499d6f48b..705600375 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "mapbox-gl": "^2.9.2", "mapbox-gl-compare": "^0.4.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", + "papaparse": "^5.3.2", "polished": "^4.1.3", "prop-types": "^15.7.2", "pure-react-carousel": "^1.28.1", diff --git a/yarn.lock b/yarn.lock index 5974d4011..f60f500cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9533,6 +9533,11 @@ p-try@^2.0.0: resolved "http://ec2-3-82-125-171.compute-1.amazonaws.com:4873/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +papaparse@^5.3.2: + version "5.3.2" + resolved "http://ec2-3-82-125-171.compute-1.amazonaws.com:4873/papaparse/-/papaparse-5.3.2.tgz#d1abed498a0ee299f103130a6109720404fbd467" + integrity sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw== + "parcel-resolver-thematics@link:./parcel-resolver-thematics": version "0.0.0" uid ""