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

1
assets/icons/palette.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-palette" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>

After

Width:  |  Height:  |  Size: 619 B

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;' })

View File

@@ -1,6 +1,6 @@
<template>
<div>
<ChartDisplay :projects="projects ?? undefined" />
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
</div>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="normal-page__content">
<div class="universal-card">
<h2>Analytics</h2>

View File

@@ -38,7 +38,22 @@ const hashProjectId = (projectId) => {
return projectId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 30
}
export const defaultColors = ['#ff496e', '#ffa347', '#1bd96a', '#4f9cff', '#c78aff']
export const defaultColors = [
'#ff496e', // Original: Bright pink
'#ffa347', // Original: Bright orange
'#1bd96a', // Original: Bright green
'#4f9cff', // Original: Bright blue
'#c78aff', // Original: Bright purple
'#ffeb3b', // Added: Bright yellow
'#00bcd4', // Added: Bright cyan
'#ff5722', // Added: Bright red-orange
'#9c27b0', // Added: Bright deep purple
'#3f51b5', // Added: Bright indigo
'#009688', // Added: Bright teal
'#cddc39', // Added: Bright lime
'#795548', // Added: Bright brown
'#607d8b', // Added: Bright blue-grey
]
/**
* @param {string | number} value
@@ -111,6 +126,7 @@ const emptyAnalytics = {
colors: [],
defaultColors: [],
},
projectIds: [],
}
export const analyticsSetToCSVString = (analytics) => {
@@ -150,7 +166,6 @@ export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, cha
const loadedProjectData = loadedProjectIds.map((id) => category[id])
// Convert each project's data into a list of [unix_ts_str, number] pairs
// Sort, label&map
const projectData = loadedProjectData
.map((data) => Object.entries(data))
.map((data) => data.sort(sortFn))
@@ -183,6 +198,8 @@ export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, cha
b.data.reduce((acc, cur) => acc + cur, 0) - a.data.reduce((acc, cur) => acc + cur, 0)
)
const projectIdsSortedBySum = chartData.map((p) => p.id)
return {
// The total count of all the values across all projects
sum: projectData.reduce((acc, cur) => acc + cur.reduce((a, c) => a + c[1], 0), 0),
@@ -210,6 +227,7 @@ export const processAnalytics = (category, projects, labelFn, sortFn, mapFn, cha
return getDefaultColor(project.id)
}),
},
projectIds: projectIdsSortedBySum,
}
}
@@ -280,7 +298,12 @@ const useFetchAnalytics = (
* @param {Ref<any[]>} projects
* @param {undefined | () => any} onDataRefresh
*/
export const useFetchAllAnalytics = (onDataRefresh, projects) => {
export const useFetchAllAnalytics = (
onDataRefresh,
projects,
selectedProjects,
personalRevenue = false
) => {
const timeResolution = ref(1440) // 1 day
const timeRange = ref(43200) // 30 days
@@ -296,17 +319,27 @@ export const useFetchAllAnalytics = (onDataRefresh, projects) => {
const error = ref(null)
const formattedData = computed(() => ({
downloads: processNumberAnalytics(downloadData.value, selectedProjects.value),
views: processNumberAnalytics(viewData.value, selectedProjects.value),
revenue: processRevAnalytics(revenueData.value, selectedProjects.value),
downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, selectedProjects.value),
viewsByCountry: processCountryAnalytics(viewsByCountry.value, selectedProjects.value),
}))
const totalData = computed(() => ({
downloads: processNumberAnalytics(downloadData.value, projects.value),
views: processNumberAnalytics(viewData.value, projects.value),
revenue: processRevAnalytics(revenueData.value, projects.value),
downloadsByCountry: processCountryAnalytics(downloadsByCountry.value, projects.value),
viewsByCountry: processCountryAnalytics(viewsByCountry.value, projects.value),
}))
const fetchData = async (query) => {
const normalQuery = new URLSearchParams(query)
const revenueQuery = new URLSearchParams(query)
revenueQuery.delete('projects')
if (personalRevenue) {
revenueQuery.delete('project_ids')
}
const qs = normalQuery.toString()
const revenueQs = revenueQuery.toString()
@@ -324,7 +357,12 @@ export const useFetchAllAnalytics = (onDataRefresh, projects) => {
// collect project ids from projects.value into a set
const projectIds = new Set()
projects.value.forEach((p) => projectIds.add(p.id))
if (projects.value) {
projects.value.forEach((p) => projectIds.add(p.id))
} else {
// if projects.value is not set, we assume that we want all project ids
Object.keys(responses[0] || {}).forEach((id) => projectIds.add(id))
}
const filterProjectIds = (data) => {
const filtered = {}
@@ -358,7 +396,7 @@ export const useFetchAllAnalytics = (onDataRefresh, projects) => {
resolution_minutes: timeResolution.value,
}
if (projects?.length) {
if (projects.value?.length) {
q.project_ids = JSON.stringify(projects.value.map((p) => p.id))
}
@@ -385,10 +423,12 @@ export const useFetchAllAnalytics = (onDataRefresh, projects) => {
}
if (revenueData.value) {
// revenue will always have all project ids, but the ids may have an empty object as value.
// revenue will always have all project ids, but the ids may have an empty object or a ton of keys below a cent (0.00...) as values. We want to filter those out
Object.entries(revenueData.value).forEach(([id, data]) => {
if (Object.keys(data).length) {
ids.add(id)
if (Object.values(data).some((v) => v >= 0.01)) {
ids.add(id)
}
}
})
}
@@ -414,6 +454,7 @@ export const useFetchAllAnalytics = (onDataRefresh, projects) => {
// Computed state
validProjectIds,
formattedData,
totalData,
loading,
error,
}