Improved graphs (#120)

* Complete chart overhaul

* Update package.json

* Update pnpm-lock.yaml

* run lint

* whoops

* Update pnpm-lock.yaml

* Update analytics.md

* Try again?

* Update Chart.vue

* Added Compact/Spark charts

* Added number formatting and cleanup

* Touch ups

* improve default colors

* removed unnecessary tooltip
This commit is contained in:
Adrian O.V
2023-10-26 14:39:26 -04:00
committed by GitHub
parent 16a39b364c
commit c056c4e79e
12 changed files with 954 additions and 493 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-zoom-in"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/></svg>

After

Width:  |  Height:  |  Size: 369 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-zoom-out"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@@ -165,6 +165,13 @@ svg {
width: 1em;
}
.chart {
svg {
height: 100%;
width: 100%;
}
}
.button-animation {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
outline 0.2s ease-in-out;

View File

@@ -1,115 +0,0 @@
<script setup>
import { ref } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
} from 'chart.js'
import dayjs from 'dayjs'
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
const props = defineProps({
data: {
type: Object,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format('MMM D'),
},
})
const decimalToRgba = (decimalColor, alpha = 0.75) => {
const red = (decimalColor >> 16) & 255
const green = (decimalColor >> 8) & 255
const blue = decimalColor & 255
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
const chartData = ref({
labels: props.data.labels.map((date) => props.formatLabels(date)),
datasets: props.data.data.map((project) => ({
label: project.title,
borderColor: decimalToRgba(project.color, 1),
borderWidth: 2,
borderSkipped: 'bottom',
backgroundColor: decimalToRgba(project.color, 0.5),
data: project.data,
})),
})
const chartOptions = ref({
responsive: true,
scales: {
x: {
stacked: true,
grid: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
},
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
},
},
y: {
stacked: true,
grid: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
},
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
},
},
},
interaction: {
mode: 'index',
},
plugins: {
legend: {
position: 'right',
align: 'start',
labels: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
font: {
size: 12,
family: 'Inter',
},
},
},
tooltip: {
position: 'nearest',
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue(
'--color-raised-bg'
),
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
borderWidth: 1,
titleColor: getComputedStyle(document.documentElement).getPropertyValue('--color-contrast'),
titleFont: {
size: 16,
family: 'Inter',
},
bodyColor: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
bodyFont: {
size: 12,
family: 'Inter',
},
boxPadding: 8,
intersect: false,
padding: 12,
displayColors: false,
},
},
})
</script>
<template>
<Bar id="my-chart-id" :options="chartOptions" :data="chartData" />
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,417 @@
<script setup>
import dayjs from 'dayjs'
import { Button, DownloadIcon, UpdatedIcon, Checkbox, formatNumber } from '@'
import { defineAsyncComponent, ref } from '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: function ({ 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>

View File

@@ -0,0 +1,278 @@
<script setup>
import { Card, formatNumber } from '@'
import { defineAsyncComponent, ref } from 'vue'
import dayjs from 'dayjs'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const props = defineProps({
value: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
})
//no grid lines, no toolbar, no legend, no data labels
const chartOptions = ref({
chart: {
id: props.title,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
},
stroke: {
curve: 'straight',
},
fill: {
colors: ['var(--color-brand)'],
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: ['var(--color-brand)'],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: ['var(--color-brand)'],
dataLabels: {
enabled: false,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
custom: function ({ series, seriesIndex, dataPointIndex, w }) {
console.log(seriesIndex, w)
return (
'<div class="bar-tooltip">' +
series
.map((value) =>
value[dataPointIndex] > 0
? `<div class="list-entry">
<div class="label">
<span class="circle" style="background-color: ${w.globals.colors[0]}"> </span>
${dayjs(w.globals.lastXAxis.categories[dataPointIndex]).format('MMM D')}
</div>
<div class="divider">
|
</div>
<div class="value">
${props.prefix}
${formatNumber(value[dataPointIndex], false)}
${props.suffix}
</div>
</div>`
: ''
)
.reverse()
.reduce((a, b) => a + b) +
'</div>'
)
},
},
})
</script>
<template>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<VueApexCharts
ref="chart"
type="area"
height="120"
:options="chartOptions"
:series="data"
class="chart"
/>
</Card>
</template>
<style scoped lang="scss">
.compact-chart {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
color: var(--color-base);
font-size: var(--font-size-nm);
width: 100%;
padding-bottom: 0;
.value {
margin: 0;
}
}
.chart {
width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
}
svg {
width: 100%;
height: 100%;
}
: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-graphical) {
width: 100%;
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.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;
justify-content: space-between;
gap: var(--gap-md);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-xaxis) {
line {
stroke: none;
}
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>

View File

@@ -1,127 +0,0 @@
<template>
<Line :options="chartOptions" :data="chartData" />
</template>
<script setup>
import { ref } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
PointElement,
LineElement,
CategoryScale,
LinearScale,
Filler,
} from 'chart.js'
import dayjs from 'dayjs'
ChartJS.register(Title, Tooltip, PointElement, LineElement, CategoryScale, LinearScale, Filler)
const props = defineProps({
data: {
type: Object,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format('MMM D'),
},
})
const decimalToRgba = (decimalColor, alpha = 0.75) => {
const red = (decimalColor >> 16) & 255
const green = (decimalColor >> 8) & 255
const blue = decimalColor & 255
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
const chartData = ref({
labels: props.data.labels.map((date) => props.formatLabels(date)),
datasets: props.data.data.map((project) => ({
label: project.title,
backgroundColor: decimalToRgba(project.color, 0.5),
borderColor: decimalToRgba(project.color),
data: project.data,
})),
})
const chartOptions = ref({
responsive: true,
scales: {
x: {
grid: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
},
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
},
},
y: {
grid: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
},
ticks: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
},
},
},
interaction: {
mode: 'index',
intersect: false,
axis: 'xy',
},
plugins: {
legend: {
position: 'right',
align: 'start',
labels: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
font: {
size: 12,
family: 'Inter',
},
},
},
tooltip: {
position: 'nearest',
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue(
'--color-raised-bg'
),
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
borderWidth: 1,
titleColor: getComputedStyle(document.documentElement).getPropertyValue('--color-contrast'),
titleFont: {
size: 14,
family: 'Inter',
},
bodyColor: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
bodyFont: {
size: 12,
family: 'Inter',
},
boxPadding: 8,
intersect: false,
padding: 12,
},
},
})
/*
The data for the graph should look like this
downloads, views, likes = {
dates: [ '2021-01-01', '2021-01-02', '2021-01-03' ], // Last 2 weeks
data: [
{
title: projectName,
color: projectColor,
data: [ ... ],
},
...
]
}
*/
</script>

View File

@@ -1,93 +0,0 @@
<script setup>
import { ref } from 'vue'
import { Pie } from 'vue-chartjs'
import {
Chart as ChartJS,
Title,
Tooltip,
PieController,
ArcElement,
Legend,
CategoryScale,
LinearScale,
} from 'chart.js'
ChartJS.register(Title, Tooltip, PieController, ArcElement, Legend, CategoryScale, LinearScale)
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const decimalToRgba = (decimalColor, alpha = 1) => {
const red = (decimalColor >> 16) & 255
const green = (decimalColor >> 8) & 255
const blue = decimalColor & 255
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
const chartData = ref({
labels: props.data.data.map((project) => project.title),
datasets: [
{
label: props.data.title,
backgroundColor: props.data.data.map((project) => decimalToRgba(project.color, 0.5)),
borderColor: props.data.data.map((project) => decimalToRgba(project.color)),
data: props.data.data.map((project) => project.data),
fill: true,
},
],
})
const chartOptions = ref({
responsive: true,
elements: {
point: {
radius: 0,
},
},
plugins: {
legend: {
position: 'right',
labels: {
color: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
font: {
size: 12,
family: 'Inter',
},
},
},
tooltip: {
position: 'nearest',
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue(
'--color-raised-bg'
),
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--color-button-bg'),
borderWidth: 1,
titleColor: getComputedStyle(document.documentElement).getPropertyValue('--color-contrast'),
titleFont: {
size: 16,
family: 'Inter',
},
bodyColor: getComputedStyle(document.documentElement).getPropertyValue('--color-base'),
bodyFont: {
size: 12,
family: 'Inter',
},
boxPadding: 8,
intersect: false,
padding: 12,
displayColors: false,
},
},
})
</script>
<template>
<Pie :options="chartOptions" :data="chartData" />
</template>
<style scoped lang="scss"></style>

View File

@@ -30,9 +30,8 @@ export { default as TextLogo } from './brand/TextLogo.vue'
export { default as FourOhFourNotFound } from '@/assets/branding/404.svg?component'
// Charts
export { default as BarChart } from './chart/BarChart.vue'
export { default as LineChart } from './chart/LineChart.vue'
export { default as PieChart } from './chart/PieChart.vue'
export { default as Chart } from './chart/Chart.vue'
export { default as CompactChart } from './chart/CompactChart.vue'
// Modals
export { default as Modal } from './modal/Modal.vue'
@@ -185,6 +184,8 @@ export { default as VersionIcon } from '@/assets/icons/version.svg?component'
export { default as WikiIcon } from '@/assets/icons/wiki.svg?component'
export { default as XIcon } from '@/assets/icons/x.svg?component'
export { default as XCircleIcon } from '@/assets/icons/x-circle.svg?component'
export { default as ZoomInIcon } from '@/assets/icons/zoom-in.svg?component'
export { default as ZoomOutIcon } from '@/assets/icons/zoom-out.svg?component'
// Editor Icons
export { default as BoldIcon } from '@/assets/icons/bold.svg?component'