Skip to content

Commit

Permalink
feat: add download button to dashboard sankey chart
Browse files Browse the repository at this point in the history
closes #36
  • Loading branch information
simonwep committed May 1, 2024
1 parent 97f020c commit 4ef7653
Show file tree
Hide file tree
Showing 11 changed files with 170 additions and 21 deletions.
1 change: 1 addition & 0 deletions src/app/components/base/context-menu/ContextMenu.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<template>
<!-- TODO: Forward click to slot -->
<div ref="reference" v-tooltip="{ text: tooltip, position: tooltipPosition }" :class="classes" @click="toggle">
<slot />
</div>
Expand Down
32 changes: 31 additions & 1 deletion src/app/components/charts/echart/EChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

<script lang="ts" setup>
import * as echarts from 'echarts/core';
import { EChartsType } from 'echarts/core';
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
import { useResizeObserver } from '@composables';
import { ClassNames } from '@utils';
import { getCssVariables } from '../../../../utils/cssVariables';
import { svgToPNG } from '../../../../utils/svgToPNG';
/* eslint-disable @typescript-eslint/no-explicit-any */
const props = defineProps<{
Expand All @@ -17,11 +20,33 @@ const props = defineProps<{
const classes = computed(() => props.class);
const root = ref<HTMLDivElement>();
const rootSize = useResizeObserver(root);
const chart = shallowRef();
const chart = shallowRef<EChartsType>();
const update = () => chart.value?.setOption(props.options);
const resize = () => chart.value?.resize();
const assertSvg = () => {
if (!root.value || !chart.value) {
throw new Error('No SVG string to convert to PNG');
}
// Inject raw css values
const cssVariables = getCssVariables(root.value);
const svgString = chart.value.renderToSVGString();
const variableRegex = /var\((--.*?)\)/g;
let serialized = svgString;
for (const [name, key] of svgString.matchAll(variableRegex)) {
const value = cssVariables.get(key)?.replace(/"/g, "'");
if (value) {
serialized = serialized.replace(name, value);
}
}
return serialized;
};
watch(props, update);
watch(chart, update);
watch(rootSize, resize);
Expand All @@ -34,4 +59,9 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', resize);
});
defineExpose({
toSVG: (): Blob => new Blob([assertSvg()], { type: 'image/svg+xml;charset=utf-8' }),
toPNG: (): Promise<Blob> => svgToPNG(assertSvg(), 3)
});
</script>
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
<template>
<EChart :class="[$style.sankeyChart, classes]" :options="options" />
<div :class="$style.container">
<EChart ref="chart" :class="[$style.sankeyChart, classes]" :options="options" />

<ContextMenu v-if="chart" position="top-end" :class="$style.downloadMenu">
<button type="button" :class="$style.downloadBtn">
<RiDownloadCloud2Line size="18" />
</button>

<template #options>
<ContextMenuButton :icon="RiLandscapeLine" :text="t('page.dashboard.downloadAsPNG')" @click="downloadPNG" />
<ContextMenuButton :icon="RiImageLine" :text="t('page.dashboard.downloadAsSVG')" @click="downloadSVG" />
</template>
</ContextMenu>
</div>
</template>

<script lang="ts" setup>
import { RiDownloadCloud2Line, RiImageLine, RiLandscapeLine } from '@remixicon/vue';
import { SankeySeriesOption } from 'echarts';
import { SankeyChart } from 'echarts/charts';
import * as echarts from 'echarts/core';
import { SVGRenderer } from 'echarts/renderers';
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import ContextMenu from '@components/base/context-menu/ContextMenu.vue';
import ContextMenuButton from '@components/base/context-menu/ContextMenuButton.vue';
import EChart from '@components/charts/echart/EChart.vue';
import { ClassNames } from '@utils';
import { ClassNames, downloadBlob } from '@utils';
import { SankeyChartConfig } from './SankeyChart.types';
echarts.use([SankeyChart, SVGRenderer]);
Expand All @@ -21,6 +38,9 @@ const props = defineProps<{
data: SankeyChartConfig;
}>();
const { t } = useI18n();
const chart = ref<InstanceType<typeof EChart>>();
const classes = computed(() => props.class);
/* eslint-disable @typescript-eslint/no-explicit-any */
Expand Down Expand Up @@ -61,11 +81,51 @@ const options = computed(
}
})
);
const downloadPNG = async () => {
downloadBlob(await chart.value!.toPNG(), 'sankey-chart.png');
};
const downloadSVG = () => {
downloadBlob(chart.value!.toSVG(), 'sankey-chart.svg');
};
</script>

<style lang="scss" module>
.container,
.sankeyChart {
width: 100%;
height: 100%;
}
.container {
position: relative;
&:hover .downloadMenu {
opacity: 1;
}
}
.downloadMenu {
position: absolute;
inset: auto 0 10px auto;
transition: opacity var(--transition-m);
opacity: 0;
.downloadBtn {
all: unset;
display: flex;
padding: 6px;
border-radius: var(--border-radius-m);
background: var(--c-primary);
color: var(--c-primary-text);
cursor: pointer;
transition: var(--transition-m) background;
&:hover {
background: var(--c-primary-hover);
color: var(--c-primary-text-hover);
}
}
}
</style>
4 changes: 2 additions & 2 deletions src/app/pages/navigation/tools/export/ExportButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { RiDownloadCloud2Line } from '@remixicon/vue';
import { useI18n } from 'vue-i18n';
import ContextMenuButton from '@components/base/context-menu/ContextMenuButton.vue';
import { useDataStore } from '@store/state';
import { saveFile } from '@utils';
import { downloadFile } from '@utils';
const { serialize } = useDataStore();
const { t } = useI18n();
const save = () => {
saveFile(serialize(), 'ocular-data.json', 'application/json');
downloadFile(serialize(), 'ocular-data.json', 'application/json');
};
</script>
4 changes: 3 additions & 1 deletion src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@
"yoyExpenseGrowth": "Jährliches Ausgabenwachstum",
"allTimeIncome": "Allzeit Einnahmen",
"allTimeExpenses": "Allzeit Ausgaben",
"allTimeSavings": "Allzeit Ersparnisse"
"allTimeSavings": "Allzeit Ersparnisse",
"downloadAsPNG": "Als PNG herunterladen",
"downloadAsSVG": "Als SVG herunterladen"
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@
"yoyExpenseGrowth": "YoY Expense Growth",
"allTimeIncome": "All Time Income",
"allTimeExpenses": "All Time Expenses",
"allTimeSavings": "All Time Savings"
"allTimeSavings": "All Time Savings",
"downloadAsPNG": "Download as PNG",
"downloadAsSVG": "Download as SVG"
}
}
}
26 changes: 26 additions & 0 deletions src/utils/cssVariables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const getCssVariables = (el: HTMLElement) => {
const variableNames = new Set(
Array.from(document.styleSheets)
.flatMap((v) => Array.from(v.cssRules))
.filter((rule) => rule instanceof CSSStyleRule)
.flatMap((rule) => Array.from((rule as CSSStyleRule).style))
.filter((key) => key.startsWith('--'))
);

// Recursively resolve all variables
const elementStyle = getComputedStyle(el);

const resolveVariable = (variable: string): string => {
const value = elementStyle.getPropertyValue(variable).trim();
const innerVariables = value.matchAll(/var\((--[^,)]+)(?:,([^)]+))?\)/g);
let resolved = value;

for (const [match, name, fallback] of innerVariables) {
resolved = resolved.replace(match, resolveVariable(name) || fallback || '');
}

return value;
};

return new Map<string, string>([...variableNames].map((name) => [name, resolveVariable(name)]));
};
15 changes: 15 additions & 0 deletions src/utils/downloadFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const downloadFile = (content: string, fileName: string, contentType: string): void => {
downloadBlob(new Blob([content], { type: contentType }), fileName);
};

export const downloadBlob = (data: Blob, fileName: string): void => {
const link = document.createElement('a');
link.style.display = 'none';
document.body.appendChild(link);

link.href = URL.createObjectURL(data);
link.download = fileName;
link.click();

document.body.removeChild(link);
};
2 changes: 1 addition & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from './debounce';
export * from './logger';
export * from './readFile';
export * from './remove';
export * from './saveFile';
export * from './downloadFile';
export * from './selectFile';
export * from './types';
export * from './uuid';
12 changes: 0 additions & 12 deletions src/utils/saveFile.ts

This file was deleted.

25 changes: 25 additions & 0 deletions src/utils/svgToPNG.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const svgToPNG = (svgString: string, scale = 1) => {
const img = new Image();
const svg = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svg);

return new Promise<Blob>((resolve, reject) => {
img.onload = () => {
const canvas = document.createElement('canvas');
const width = img.width * scale;
const height = img.height * scale;

canvas.width = width;
canvas.height = height;

const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);

canvas.toBlob((v) => (v ? resolve(v) : reject(new Error('Failed to convert canvas to PNG blob'))), 'image/png');
};

img.onerror = () => reject(new Error('Failed to load image for PNG conversion'));
img.src = url;
});
};

0 comments on commit 4ef7653

Please sign in to comment.