Fix graph state & data handling (#1578)

* Rip out external color state

* Fix styling errors

* Allow charts to display personal/entity perspectives on routes

* Refactor analytics data processing and selection

* Include custom color icon
This commit is contained in:
Carter
2024-01-12 14:51:03 -08:00
committed by GitHub
parent 0adb7685f6
commit 2fb63dcfb1
6 changed files with 253 additions and 210 deletions

View File

@@ -3,11 +3,6 @@ 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,
@@ -158,120 +153,124 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
return tooltip
}
const chartOptions = {
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)',
const chartOptions = computed(() => {
return {
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,
},
},
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)',
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
axisTicks: {
show: 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: {
enabled: false,
custom: (d) => generateTooltip(d, props),
},
},
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: [],
},
}
fill:
props.type === 'area'
? {
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)
@@ -287,6 +286,7 @@ const flipLegend = (legend, newVal) => {
}
const resetChart = () => {
if (!chart.value) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
@@ -299,31 +299,14 @@ const resetChart = () => {
})
}
const updateColors = (colors) => {
chart.value.updateOptions({
colors,
})
chart.value.resetSeries()
}
defineExpose({
resetChart,
updateColors,
flipLegend,
})
</script>
<template>
<VueApexCharts
ref="chart"
:type="type"
:options="{
...chartOptions,
fill: type === 'area' ? fillOptions : {},
}"
:series="data"
class="chart"
/>
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
</template>
<style scoped lang="scss">

View File

@@ -25,7 +25,7 @@
? 'chart-button-base__selected button-base__selected'
: ''
}`"
:onclick="() => setSelectedChart('downloads')"
:onclick="() => (selectedChart = 'downloads')"
role="button"
/>
</client-only>
@@ -42,7 +42,7 @@
:class="`clickable chart-button-base button-base ${
selectedChart === 'views' ? 'chart-button-base__selected button-base__selected' : ''
}`"
:onclick="() => setSelectedChart('views')"
:onclick="() => (selectedChart = 'views')"
role="button"
/>
</client-only>
@@ -59,7 +59,7 @@
:class="`clickable chart-button-base button-base ${
selectedChart === 'revenue' ? 'chart-button-base__selected button-base__selected' : ''
}`"
:onclick="() => setSelectedChart('revenue')"
:onclick="() => (selectedChart = 'revenue')"
role="button"
/>
</client-only>
@@ -74,7 +74,7 @@
</h2>
<div class="chart-controls__buttons">
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
<EyeIcon />
<PaletteIcon />
</Button>
<Button v-tooltip="'Download this data as CSV'" icon-only @click="onDownloadSetAsCSV">
<DownloadIcon />
@@ -102,7 +102,11 @@
:data="analytics.formattedData.value.downloads.chart.data"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<svg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='currentColor' stroke-width='2'><path stroke-linecap='round' stroke-linejoin='round' d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' /></svg>"
:colors="analytics.formattedData.value.downloads.chart.colors"
:colors="
isUsingProjectColors
? analytics.formattedData.value.downloads.chart.colors
: analytics.formattedData.value.downloads.chart.defaultColors
"
/>
<Chart
v-if="analytics.formattedData.value.views && selectedChart === 'views'"
@@ -113,7 +117,11 @@
:data="analytics.formattedData.value.views.chart.data"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<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'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:colors="analytics.formattedData.value.views.chart.colors"
:colors="
isUsingProjectColors
? analytics.formattedData.value.views.chart.colors
: analytics.formattedData.value.views.chart.defaultColors
"
/>
<Chart
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
@@ -125,22 +133,26 @@
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
suffix="<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'><line x1='12' y1='2' x2='12' y2='22'></line><path d='M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'></path></svg>"
:colors="analytics.formattedData.value.revenue.chart.colors"
:colors="
isUsingProjectColors
? analytics.formattedData.value.revenue.chart.colors
: analytics.formattedData.value.revenue.chart.defaultColors
"
/>
</client-only>
</div>
<div class="legend">
<div class="legend__items">
<template v-for="project in props.projects" :key="project.id">
<template v-for="project in selectedDataSetProjects" :key="project">
<button
v-if="analytics.validProjectIds.value.includes(project.id)"
v-tooltip="project.title"
:class="`legend__item button-base btn-transparent ${
!projectIsOnDisplay(project.id) ? 'btn-dimmed' : ''
}`"
@click="
() =>
projectIsOnDisplay(project.id)
projectIsOnDisplay(project.id) &&
analytics.validProjectIds.value.includes(project.id)
? removeProjectFromDisplay(project.id)
: addProjectToDisplay(project.id)
"
@@ -286,7 +298,6 @@ import {
formatNumber,
DropdownSelect,
formatCategoryHeader,
EyeIcon,
} from 'omorphia'
import dayjs from 'dayjs'
import { defineProps, ref, computed } from 'vue'
@@ -295,6 +306,8 @@ import { analyticsSetToCSVString, intToRgba } from '~/utils/analytics.js'
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg'
const router = useRouter()
const theme = useTheme()
@@ -306,14 +319,18 @@ const props = withDefaults(
*/
resoloutions?: Record<string, number>
ranges?: Record<number, [string, number] | string>
personal?: boolean
}>(),
{
projects: undefined,
resoloutions: () => defaultResoloutions,
ranges: () => defaultRanges,
personal: false,
}
)
const projects = ref(props.projects || [])
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
label: typeof extra === 'string' ? extra : extra[0],
value: Number(duration),
@@ -321,22 +338,24 @@ const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) =>
}))
// const selectedChart = ref('downloads')
const selectedChart = computed(() => {
const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
// if the id is anything but the 3 charts we have or undefined, throw an error
if (!['downloads', 'views', 'revenue'].includes(id)) {
throw new Error(`Unknown chart ${id}`)
}
return id
const selectedChart = computed({
get: () => {
const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
// if the id is anything but the 3 charts we have or undefined, throw an error
if (!['downloads', 'views', 'revenue'].includes(id)) {
throw new Error(`Unknown chart ${id}`)
}
return id
},
set: (chart) => {
router.push({
query: {
...router.currentRoute.value.query,
chart,
},
})
},
})
const setSelectedChart = (chart: string) => {
router.push({
query: {
...router.currentRoute.value.query,
chart,
},
})
}
// Chart refs
const downloadsChart = ref()
@@ -360,25 +379,7 @@ const addProjectToDisplay = (id: string) => {
}
const projectIsOnDisplay = (id: string) => {
return selectedDisplayProjects.value.some((p) => p.id === id)
}
const setChartColors = (updatedVal: Ref<boolean>) => {
downloadsChart.value?.updateColors(
updatedVal.value
? analytics.formattedData.value.downloads.chart.colors
: analytics.formattedData.value.downloads.chart.defaultColors
)
viewsChart.value?.updateColors(
updatedVal.value
? analytics.formattedData.value.views.chart.colors
: analytics.formattedData.value.views.chart.defaultColors
)
revenueChart.value?.updateColors(
updatedVal.value
? analytics.formattedData.value.revenue.chart.colors
: analytics.formattedData.value.revenue.chart.defaultColors
)
return selectedDisplayProjects.value?.some((p) => p.id === id) ?? false
}
const resetCharts = () => {
@@ -389,16 +390,31 @@ const resetCharts = () => {
tinyDownloadChart.value?.resetChart()
tinyViewChart.value?.resetChart()
tinyRevenueChart.value?.resetChart()
setChartColors(isUsingProjectColors)
}
const isUsingProjectColors = ref(true)
watch(() => isUsingProjectColors, setChartColors, {
deep: true,
const isUsingProjectColors = computed({
get: () => {
return (
router.currentRoute.value.query?.colors === 'true' ||
router.currentRoute.value.query?.colors === undefined
)
},
set: (newValue) => {
router.push({
query: {
...router.currentRoute.value.query,
colors: newValue ? 'true' : 'false',
},
})
},
})
const analytics = useFetchAllAnalytics(resetCharts, selectedDisplayProjects)
const analytics = useFetchAllAnalytics(
resetCharts,
projects,
selectedDisplayProjects,
props.personal
)
const { startDate, endDate, timeRange, timeResolution } = analytics
@@ -422,26 +438,28 @@ const selectedRange = computed({
},
})
const selectedDataSet = computed(() => {
switch (selectedChart.value) {
case 'downloads':
return analytics.totalData.value.downloads
case 'views':
return analytics.totalData.value.views
case 'revenue':
return analytics.totalData.value.revenue
default:
throw new Error(`Unknown chart ${selectedChart.value}`)
}
})
const selectedDataSetProjects = computed(() => {
return selectedDataSet.value.projectIds
.map((id) => props.projects?.find((p) => p?.id === id))
.filter(Boolean)
})
const downloadSelectedSetAsCSV = () => {
const selectedChartName = selectedChart.value
let downloadsDataSet
switch (selectedChartName) {
case 'downloads':
downloadsDataSet = analytics.formattedData.value.downloads
break
case 'views':
downloadsDataSet = analytics.formattedData.value.views
break
case 'revenue':
downloadsDataSet = analytics.formattedData.value.revenue
break
default:
throw new Error(`Unknown chart ${selectedChartName}`)
}
const csv = analyticsSetToCSVString(downloadsDataSet)
const csv = analyticsSetToCSVString(selectedDataSet.value)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })