* Fix donation link submission * Refactor Charts to fit edgecase bugs in design * edge and mobile bug fixes * remove dead code * fix width on mobile * Update omorphia version to 0.7.3 * Refactor legend item styling in ChartDisplay.vue * Update package dependencies
501 lines
10 KiB
Vue
501 lines
10 KiB
Vue
<script setup>
|
|
import dayjs from 'dayjs'
|
|
import { formatNumber, formatMoney } from 'omorphia'
|
|
import VueApexCharts from 'vue3-apexcharts'
|
|
|
|
// let VueApexCharts
|
|
// if (process.client) {
|
|
// 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: () => [],
|
|
},
|
|
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,
|
|
},
|
|
isMoney: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
legendPosition: {
|
|
type: String,
|
|
default: 'right',
|
|
},
|
|
xAxisType: {
|
|
type: String,
|
|
default: 'datetime',
|
|
},
|
|
percentStacked: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
horizontalBar: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
disableAnimations: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
})
|
|
|
|
function formatTooltipValue(value, props) {
|
|
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false)
|
|
}
|
|
|
|
function generateListEntry(value, index, _, w, props) {
|
|
const color = props.colors[index % props.colors.length]
|
|
|
|
return `<div class="list-entry">
|
|
<span class="circle" style="background-color: ${color}"></span>
|
|
<div class="label">
|
|
${w.globals.seriesNames[index]}
|
|
</div>
|
|
<div class="value">
|
|
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
|
|
const label = w.globals.lastXAxis.categories[dataPointIndex]
|
|
|
|
const formattedLabel = props.formatLabels(label)
|
|
|
|
let tooltip = `<div class="bar-tooltip">
|
|
<div class="seperated-entry title">
|
|
<div class="label">${formattedLabel}</div>`
|
|
|
|
// Logic for total and percent stacked
|
|
if (!props.hideTotal) {
|
|
if (props.percentStacked) {
|
|
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
|
|
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
|
|
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
|
|
props.suffix
|
|
}</div>`
|
|
} else {
|
|
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
|
|
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
|
|
props.suffix
|
|
}</div>`
|
|
}
|
|
}
|
|
|
|
tooltip += '</div><hr class="card-divider" />'
|
|
|
|
// Logic for generating list entries
|
|
if (props.percentStacked) {
|
|
tooltip += generateListEntry(
|
|
series[seriesIndex][dataPointIndex],
|
|
seriesIndex,
|
|
seriesIndex,
|
|
w,
|
|
props
|
|
)
|
|
} else {
|
|
const returnTopN = 5
|
|
|
|
const listEntries = series
|
|
.map((value, index) => [
|
|
value[dataPointIndex],
|
|
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
|
|
])
|
|
.filter((value) => value[0] > 0)
|
|
.sort((a, b) => b[0] - a[0])
|
|
.slice(0, returnTopN) // Return only the top X entries
|
|
.map((value) => value[1])
|
|
.join('')
|
|
|
|
tooltip += listEntries
|
|
}
|
|
|
|
tooltip += '</div>'
|
|
return tooltip
|
|
}
|
|
|
|
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,
|
|
stackType: props.percentStacked ? '100%' : 'normal',
|
|
zoom: {
|
|
autoScaleYaxis: true,
|
|
},
|
|
animations: {
|
|
enabled: props.disableAnimations,
|
|
},
|
|
},
|
|
xaxis: {
|
|
type: props.xAxisType,
|
|
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: !props.hideLegend,
|
|
position: props.legendPosition,
|
|
showForZeroSeries: false,
|
|
showForSingleSeries: false,
|
|
showForNullSeries: false,
|
|
fontSize: 'var(--font-size-nm)',
|
|
fontFamily:
|
|
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
|
onItemClick: {
|
|
toggleDataSeries: true,
|
|
},
|
|
},
|
|
markers: {
|
|
size: 0,
|
|
strokeColor: 'var(--color-contrast)',
|
|
strokeWidth: 3,
|
|
strokeOpacity: 1,
|
|
fillOpacity: 1,
|
|
hover: {
|
|
size: 6,
|
|
},
|
|
},
|
|
plotOptions: {
|
|
bar: {
|
|
horizontal: props.horizontalBar,
|
|
columnWidth: '80%',
|
|
endingShape: 'rounded',
|
|
borderRadius: 5,
|
|
borderRadiusApplication: 'end',
|
|
borderRadiusWhenStacked: 'last',
|
|
},
|
|
},
|
|
stroke: {
|
|
curve: 'smooth',
|
|
width: 2,
|
|
},
|
|
tooltip: {
|
|
custom: (d) => generateTooltip(d, props),
|
|
},
|
|
})
|
|
|
|
const fillOptions = {
|
|
colors: props.colors,
|
|
type: 'gradient',
|
|
opacity: 1,
|
|
gradient: {
|
|
shade: 'light',
|
|
type: 'vertical',
|
|
shadeIntensity: 0,
|
|
gradientToColors: props.colors,
|
|
inverseColors: true,
|
|
opacityFrom: 0.5,
|
|
opacityTo: 0,
|
|
stops: [0, 100],
|
|
colorStops: [],
|
|
},
|
|
}
|
|
|
|
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 resetChart = () => {
|
|
chart.value.updateSeries([...props.data])
|
|
chart.value.updateOptions({
|
|
xaxis: {
|
|
categories: props.labels,
|
|
},
|
|
colors: props.colors,
|
|
})
|
|
chart.value.resetSeries()
|
|
legendValues.value.forEach((legend) => {
|
|
legend.visible = true
|
|
})
|
|
}
|
|
|
|
defineExpose({
|
|
resetChart,
|
|
flipLegend,
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<VueApexCharts
|
|
ref="chart"
|
|
:type="type"
|
|
:options="{
|
|
...chartOptions,
|
|
fill: type === 'area' ? fillOptions : {},
|
|
}"
|
|
:series="data"
|
|
class="chart"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.chart {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.btn {
|
|
svg {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
}
|
|
}
|
|
|
|
.bar-chart {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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 {
|
|
margin-right: var(--gap-xl);
|
|
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;
|
|
}
|
|
}
|
|
|
|
.circle {
|
|
width: 0.75rem;
|
|
height: 0.75rem;
|
|
border-radius: 50%;
|
|
display: inline-block;
|
|
margin-right: var(--gap-sm);
|
|
border: 2px solid var(--color-base);
|
|
}
|
|
|
|
svg {
|
|
height: 1em;
|
|
width: 1em;
|
|
}
|
|
}
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: var(--gap-lg);
|
|
justify-content: center;
|
|
}
|
|
|
|
:deep(.checkbox) {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.legend-checkbox :deep(.checkbox.checked) {
|
|
background-color: var(--color);
|
|
}
|
|
</style>
|