You've already forked AstralRinth
forked from didirus/AstralRinth
New analytics (#1483)
* [WIP] Transfer analytics to own branch * code style changes * Refactor country name conversion * Clean up api and ssr for settings page * refactor analytics into reusables * Refactor chart tooltip and reset functionality * Update dayjs import and formatTimestamp function * Fix bug in login functionality * Abstract data fetching * Refactor analytics data formatting * Refactor useFetchAllAnalytics function signature * Refactor analytics processing functions * Fix chart data in ChartDisplay.vue * Refactor analytics pages * Refactor delete labrinth.ts test types * Fix import statement for dayjs and update usage of unix function * Fix dropdown select in ChartDisplay.vue and add Analytics link in creations.vue * Update chart colors in ChartDisplay.vue and analytics.js * Update defaultRanges in ChartDisplay.vue * Add colors to chart * Update legend position in ChartDisplay.vue * Refactor color conversion functions in analytics.js * Bug fixes * Use softer colors * Import dayjs unix module for analytics.js * Refactor chart tooltip generation * Fix calculation of total value in generateTooltip function * Fix button-base styling in ChartDisplay.vue * Adopt intl standard rather than iso-3166-1 * Add support for potential flags * Analytics rebased * fix cf pages * now? * try now * now? * Fix this time * address rev * Finish analytics * fix api url --------- Co-authored-by: Carter <safe@fea.st>
This commit is contained in:
530
components/ui/charts/Chart.client.vue
Normal file
530
components/ui/charts/Chart.client.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, DownloadIcon, UpdatedIcon, 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 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])
|
||||
.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 downloadCSV = () => {
|
||||
const csvContent =
|
||||
'data:text/csv;charset=utf-8,' +
|
||||
'Date,' +
|
||||
props.labels.join(',') +
|
||||
'\n' +
|
||||
props.data
|
||||
.map((project) => project.name.replace(',', '-') + ',' + 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.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,
|
||||
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,
|
||||
fill: type === 'area' ? fillOptions : {},
|
||||
}"
|
||||
:series="data"
|
||||
class="chart"
|
||||
/>
|
||||
</div>
|
||||
</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%;
|
||||
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 {
|
||||
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>
|
||||
474
components/ui/charts/ChartDisplay.vue
Normal file
474
components/ui/charts/ChartDisplay.vue
Normal file
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="analytics.error.value">
|
||||
{{ analytics.error.value }}
|
||||
</div>
|
||||
<div v-else class="graphs">
|
||||
<div class="graphs__vertical-bar">
|
||||
<client-only>
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.downloads"
|
||||
ref="tinyDownloadChart"
|
||||
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
color="var(--color-brand)"
|
||||
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
|
||||
:data="analytics.formattedData.value.downloads.chart.sumData"
|
||||
: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>"
|
||||
:class="`clickable button-base ${
|
||||
selectedChart === 'downloads' ? 'button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'downloads')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
<client-only>
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.views"
|
||||
ref="tinyViewChart"
|
||||
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
color="var(--color-blue)"
|
||||
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
|
||||
:data="analytics.formattedData.value.views.chart.sumData"
|
||||
: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>"
|
||||
:class="`clickable button-base ${
|
||||
selectedChart === 'views' ? 'button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'views')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
<client-only>
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.revenue"
|
||||
ref="tinyRevenueChart"
|
||||
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
color="var(--color-purple)"
|
||||
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
|
||||
:data="analytics.formattedData.value.revenue.chart.sumData"
|
||||
:labels="analytics.formattedData.value.revenue.chart.labels"
|
||||
is-money
|
||||
:class="`clickable button-base ${
|
||||
selectedChart === 'revenue' ? 'button-base__selected' : ''
|
||||
}`"
|
||||
:onclick="() => (selectedChart = 'revenue')"
|
||||
role="button"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
<div class="graphs__main-graph">
|
||||
<Card>
|
||||
<div class="graphs__main-graph-control">
|
||||
<DropdownSelect
|
||||
v-model="selectedRange"
|
||||
:options="selectableRanges"
|
||||
name="Time range"
|
||||
:display-name="(o: typeof selectableRanges[number] | undefined) => o?.label || 'Custom'"
|
||||
/>
|
||||
<!-- <DropdownSelect
|
||||
v-model="selectedResolution"
|
||||
:options="selectableResoloutions"
|
||||
:display-name="(o: typeof selectableResoloutions[number] | undefined) => o?.label || 'Custom'"
|
||||
/> -->
|
||||
</div>
|
||||
<client-only>
|
||||
<Chart
|
||||
v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'"
|
||||
ref="downloadsChart"
|
||||
type="line"
|
||||
name="Download data"
|
||||
legend-position="right"
|
||||
: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"
|
||||
>
|
||||
<h2>Downloads</h2>
|
||||
</Chart>
|
||||
<Chart
|
||||
v-if="analytics.formattedData.value.views && selectedChart === 'views'"
|
||||
ref="viewsChart"
|
||||
type="line"
|
||||
name="View data"
|
||||
legend-position="right"
|
||||
: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"
|
||||
>
|
||||
<h2 class="">Views</h2>
|
||||
</Chart>
|
||||
<Chart
|
||||
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
|
||||
ref="revenueChart"
|
||||
type="line"
|
||||
name="Revenue data"
|
||||
legend-position="right"
|
||||
:data="analytics.formattedData.value.revenue.chart.data"
|
||||
: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"
|
||||
>
|
||||
<h2 class="">Revenue</h2>
|
||||
</Chart>
|
||||
</client-only>
|
||||
</Card>
|
||||
<div class="country-data">
|
||||
<Card
|
||||
v-if="
|
||||
analytics.formattedData.value?.downloadsByCountry && selectedChart === 'downloads'
|
||||
"
|
||||
class="country-downloads"
|
||||
>
|
||||
<label>
|
||||
<span class="label__title">Downloads by country</span>
|
||||
</label>
|
||||
<div class="country-values">
|
||||
<div
|
||||
v-for="[name, count] in analytics.formattedData.value.downloadsByCountry.data"
|
||||
:key="name"
|
||||
class="country-value"
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<img
|
||||
:src="
|
||||
name.toLowerCase() === 'xx' || !name
|
||||
? 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
: countryCodeToFlag(name)
|
||||
"
|
||||
alt="Hidden country"
|
||||
class="country-flag"
|
||||
/>
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name"
|
||||
><template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
|
||||
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||
</strong>
|
||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="
|
||||
formatPercent(count, analytics.formattedData.value.downloadsByCountry.sum)
|
||||
"
|
||||
class="percentage-bar"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: formatPercent(
|
||||
count,
|
||||
analytics.formattedData.value.downloadsByCountry.sum
|
||||
),
|
||||
backgroundColor: 'var(--color-brand)',
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card
|
||||
v-if="analytics.formattedData.value?.viewsByCountry && selectedChart === 'views'"
|
||||
class="country-downloads"
|
||||
>
|
||||
<label>
|
||||
<span class="label__title">Page views by country</span>
|
||||
</label>
|
||||
<div class="country-values">
|
||||
<div
|
||||
v-for="[name, count] in analytics.formattedData.value.viewsByCountry.data"
|
||||
:key="name"
|
||||
class="country-value"
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<img
|
||||
:src="`https://flagcdn.com/h240/${name.toLowerCase()}.png`"
|
||||
:alt="name"
|
||||
class="country-flag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="country-text">
|
||||
<strong class="country-name">{{ countryCodeToName(name) }}</strong>
|
||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="
|
||||
`${
|
||||
Math.round(
|
||||
(count / analytics.formattedData.value.viewsByCountry.sum) * 10000
|
||||
) / 100
|
||||
}%`
|
||||
"
|
||||
class="percentage-bar"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: `${(count / analytics.formattedData.value.viewsByCountry.sum) * 100}%`,
|
||||
backgroundColor: 'var(--color-blue)',
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Card, formatMoney, formatNumber, DropdownSelect } from 'omorphia'
|
||||
import dayjs from 'dayjs'
|
||||
import { defineProps, ref, computed } from 'vue'
|
||||
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projects?: any[]
|
||||
/**
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
resoloutions?: Record<string, number>
|
||||
ranges?: Record<number, [string, number] | string>
|
||||
}>(),
|
||||
{
|
||||
projects: undefined,
|
||||
resoloutions: () => defaultResoloutions,
|
||||
ranges: () => defaultRanges,
|
||||
}
|
||||
)
|
||||
|
||||
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
|
||||
label: typeof extra === 'string' ? extra : extra[0],
|
||||
value: Number(duration),
|
||||
res: typeof extra === 'string' ? Number(duration) : extra[1],
|
||||
}))
|
||||
|
||||
const selectedChart = ref('downloads')
|
||||
|
||||
// Chart refs
|
||||
const downloadsChart = ref()
|
||||
const viewsChart = ref()
|
||||
const revenueChart = ref()
|
||||
const tinyDownloadChart = ref()
|
||||
const tinyViewChart = ref()
|
||||
const tinyRevenueChart = ref()
|
||||
|
||||
const analytics = useFetchAllAnalytics(() => {
|
||||
downloadsChart.value?.resetChart()
|
||||
viewsChart.value?.resetChart()
|
||||
revenueChart.value?.resetChart()
|
||||
|
||||
tinyDownloadChart.value?.resetChart()
|
||||
tinyViewChart.value?.resetChart()
|
||||
tinyRevenueChart.value?.resetChart()
|
||||
}, props.projects)
|
||||
|
||||
const { startDate, endDate, timeRange, timeResolution } = analytics
|
||||
|
||||
const selectedRange = computed({
|
||||
get: () => {
|
||||
return (
|
||||
selectableRanges.find((option) => option.value === timeRange.value) || {
|
||||
label: 'Custom',
|
||||
value: timeRange.value,
|
||||
}
|
||||
)
|
||||
},
|
||||
set: (newRange: { label: string; value: number; res?: number }) => {
|
||||
timeRange.value = newRange.value
|
||||
startDate.value = Date.now() - timeRange.value * 60 * 1000
|
||||
endDate.value = Date.now()
|
||||
|
||||
if (newRange?.res) {
|
||||
timeResolution.value = newRange.res
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const defaultResoloutions: Record<string, number> = {
|
||||
'5 minutes': 5,
|
||||
'30 minutes': 30,
|
||||
'An hour': 60,
|
||||
'12 hours': 720,
|
||||
'A day': 1440,
|
||||
'A week': 10080,
|
||||
}
|
||||
|
||||
const defaultRanges: Record<number, [string, number] | string> = {
|
||||
30: ['Last 30 minutes', 1],
|
||||
60: ['Last hour', 5],
|
||||
720: ['Last 12 hours', 15],
|
||||
1440: ['Last day', 60],
|
||||
10080: ['Last week', 720],
|
||||
43200: ['Last month', 1440],
|
||||
129600: ['Last quarter', 10080],
|
||||
525600: ['Last year', 20160],
|
||||
1051200: ['Last two years', 40320],
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.button-base {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.button-base__selected {
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.graphs {
|
||||
// Pages clip so we need to add a margin
|
||||
margin-left: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.graphs__vertical-bar {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
gap: 0.75rem;
|
||||
display: flex;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.graphs__main-graph {
|
||||
// Take up the rest of the width
|
||||
flex-grow: 1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.graphs__main-graph-control {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
justify-content: end;
|
||||
margin-bottom: var(--gap-md);
|
||||
gap: var(--gap-md);
|
||||
|
||||
.animated-dropdown {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 768px) {
|
||||
.graphs {
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.graphs__vertical-bar {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.graphs__main-graph {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.country-flag-container {
|
||||
width: 40px;
|
||||
height: 27px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.country-flag {
|
||||
object-fit: cover;
|
||||
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.spark-data {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.country-data {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.country-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
padding: var(--gap-md);
|
||||
margin-top: var(--gap-md);
|
||||
overflow-y: auto;
|
||||
max-height: 24rem;
|
||||
}
|
||||
|
||||
.country-value {
|
||||
display: grid;
|
||||
grid-template-areas: 'flag text bar';
|
||||
grid-template-columns: auto 1fr 10rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.country-text {
|
||||
grid-area: text;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
.percentage-bar {
|
||||
grid-area: bar;
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
background-color: var(--color-raised-bg);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.country-data {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.country-value {
|
||||
grid-template-columns: auto 1fr 5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
280
components/ui/charts/CompactChart.client.vue
Normal file
280
components/ui/charts/CompactChart.client.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<script setup>
|
||||
import { Card } from 'omorphia'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
// let VueApexCharts
|
||||
// if (process.client) {
|
||||
// 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: '',
|
||||
},
|
||||
isMoney: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'var(--color-brand)',
|
||||
},
|
||||
})
|
||||
|
||||
// no grid lines, no toolbar, no legend, no data labels
|
||||
const chartOptions = {
|
||||
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,
|
||||
},
|
||||
parentHeightOffset: 0,
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2,
|
||||
},
|
||||
fill: {
|
||||
colors: [props.color],
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: [props.color],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: [props.color],
|
||||
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: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const resetChart = () => {
|
||||
chart.value?.updateSeries([...props.data])
|
||||
chart.value?.updateOptions({
|
||||
xaxis: {
|
||||
categories: props.labels,
|
||||
},
|
||||
})
|
||||
chart.value?.resetSeries()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="compact-chart">
|
||||
<h1 class="value">
|
||||
{{ value }}
|
||||
</h1>
|
||||
<div class="subtitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="chart">
|
||||
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
|
||||
</div>
|
||||
</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-top: var(--gap-xl);
|
||||
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>
|
||||
Reference in New Issue
Block a user