2025-08-05 11:51:07 +01:00

418 lines
8.9 KiB
Vue

<!-- eslint-disable no-console -->
<script setup>
import { formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import { defineAsyncComponent, ref } from 'vue'
import Button from '../base/Button.vue'
import Checkbox from '../base/Checkbox.vue'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const props = defineProps({
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format('MMM D'),
},
colors: {
type: Array,
default: () => [
'var(--color-brand)',
'var(--color-blue)',
'var(--color-purple)',
'var(--color-red)',
'var(--color-orange)',
],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'bar',
},
hideTotal: {
type: Boolean,
default: false,
},
})
const chartOptions = ref({
chart: {
id: props.name,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: false,
},
markers: {
size: 0,
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
tooltip: {
custom({ series, seriesIndex, dataPointIndex, w }) {
console.log(seriesIndex, w)
return (
`<div class="bar-tooltip">` +
`<div class="seperated-entry title">` +
`<div class="label">${props.formatLabels(
w.globals.lastXAxis.categories[dataPointIndex],
)}</div>${
!props.hideTotal
? `<div class="value">
${props.prefix}
${formatNumber(series.reduce((a, b) => a + b[dataPointIndex], 0).toString(), false)}
${props.suffix}
</div>`
: ``
}</div><hr class="card-divider" />${series
.map((value, index) =>
value[dataPointIndex] > 0
? `<div class="list-entry">
<span class="circle" style="background-color: ${w.globals.colors[index]}"> </span>
<div class="label">
${w.globals.seriesNames[index]}
</div>
<div class="value">
${props.prefix}
${formatNumber(value[dataPointIndex], false)}
${props.suffix}
</div>
</div>`
: '',
)
.reverse()
.reduce((a, b) => a + b)}</div>`
)
},
},
})
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] }
}),
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const downloadCSV = () => {
const csvContent = `data:text/csv;charset=utf-8,${props.labels.join(',')}\n${props.data
.map((project) => project.data.join(','))
.reduce((a, b) => `${a}\n${b}`)}`
const encodedUri = encodeURI(csvContent)
const link = document.createElement('a')
link.setAttribute('href', encodedUri)
link.setAttribute('download', `${props.name}.csv`)
document.body.appendChild(link) // Required for FF
link.click()
}
const resetChart = () => {
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true
})
}
defineExpose({
resetChart,
downloadCSV,
flipLegend,
})
</script>
<template>
<div class="bar-chart">
<div class="title-bar">
<slot />
<div v-if="!hideToolbar" class="toolbar">
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV">
<!-- <DownloadIcon /> -->
</Button>
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart">
<!-- <UpdatedIcon /> -->
</Button>
<slot name="toolbar" />
</div>
</div>
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
<div v-if="!hideLegend" class="legend">
<Checkbox
v-for="legend in legendValues"
:key="legend.name"
class="legend-checkbox"
:style="`--color: ${legend.color};`"
:model-value="legend.visible"
@update:model-value="(newVal) => flipLegend(legend, newVal)"
>
{{ legend.name }}
</Checkbox>
</div>
</div>
</template>
<style scoped lang="scss">
.chart {
width: 100%;
height: 100%;
}
svg {
width: 100%;
height: 100%;
}
.bar-chart {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.title-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
}
.toolbar {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-yaxistooltip),
:deep(.apexcharts-xaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
}
:deep(.apexcharts-yaxistooltip-left:after) {
border-left-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-yaxistooltip-left:before) {
border-left-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:after) {
border-bottom-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:before) {
border-bottom-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-menu-item) {
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: bolder;
}
.label {
color: var(--color-contrast);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.value {
margin-left: auto;
}
.label {
margin-right: var(--gap-xl);
}
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>