Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
Expand Down
35 changes: 35 additions & 0 deletions apps/frontend/src/components/analytics/AnalyticsDashboard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<template>
<div class="flex flex-col gap-4 pb-20">
<QueryBuilder />
<StatCards />
<AnalyticsGraph />
<AnalyticsTable />
</div>
</template>

<script setup lang="ts">
import { injectProjectPageContext } from '@modrinth/ui'

import {
createAnalyticsDashboardContext,
provideAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import { injectOrganizationContext } from '~/providers/organization-context'

import AnalyticsGraph from './graph/AnalyticsGraph.vue'
import QueryBuilder from './query-builder/QueryBuilder.vue'
import StatCards from './stat-cards/StatCards.vue'
import AnalyticsTable from './table/AnalyticsTable.vue'

const auth = await useAuth()
const projectPageContext = injectProjectPageContext(null)
const organizationContext = injectOrganizationContext(null)

const analyticsDashboardContext = createAnalyticsDashboardContext({
auth,
projectPageContext,
organizationContext,
})

provideAnalyticsDashboardContext(analyticsDashboardContext)
</script>
38 changes: 38 additions & 0 deletions apps/frontend/src/components/analytics/breakdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Labrinth } from '@modrinth/api-client'

import type { AnalyticsBreakdownPreset } from '~/providers/analytics/analytics'

export const ALL_BREAKDOWN_VALUE = 'All'

export function getAnalyticsBreakdownValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
selectedBreakdown: AnalyticsBreakdownPreset,
): string {
switch (selectedBreakdown) {
case 'none':
return ALL_BREAKDOWN_VALUE
case 'country':
return normalizeBreakdownValue('country' in point ? point.country : undefined)
case 'monetization': {
if ('monetized' in point && typeof point.monetized === 'boolean') {
return point.monetized ? 'monetized' : 'unmonetized'
}
return ALL_BREAKDOWN_VALUE
}
case 'download_source':
return normalizeBreakdownValue('domain' in point ? point.domain : undefined)
case 'download_type':
return normalizeBreakdownValue('version_id' in point ? point.version_id : undefined)
case 'loader':
return normalizeBreakdownValue('loader' in point ? point.loader : undefined)
case 'game_version':
return normalizeBreakdownValue('game_version' in point ? point.game_version : undefined)
default:
return ALL_BREAKDOWN_VALUE
}
}

function normalizeBreakdownValue(value: string | undefined): string {
const normalized = value?.trim()
return normalized && normalized.length > 0 ? normalized : ALL_BREAKDOWN_VALUE
}
225 changes: 225 additions & 0 deletions apps/frontend/src/components/analytics/graph/AnalyticsChart.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<template>
<canvas ref="canvasRef" />
</template>

<script setup lang="ts">
import { useCompactNumber } from '@modrinth/ui'
import {
BarController,
BarElement,
CategoryScale,
Chart,
type ChartConfiguration,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js'

import type { AnalyticsDashboardStat } from '~/providers/analytics/analytics'

import { type ChartDataset, formatAxisValue } from './utils'

Chart.register(
LineController,
BarController,
LineElement,
BarElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
Tooltip,
)

export type AnalyticsChartHoverPayload = {
visible: boolean
x: number
y: number
sliceIndex: number | null
}

const props = defineProps<{
type: 'line' | 'bar'
fill: boolean
stacked: boolean
datasets: ChartDataset[]
labels: string[]
activeStat: AnalyticsDashboardStat
}>()

const emit = defineEmits<{
(event: 'hover', payload: AnalyticsChartHoverPayload): void
}>()

const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null

const { formatCompactNumber } = useCompactNumber()

type ExternalTooltipHandler = NonNullable<
NonNullable<NonNullable<ChartConfiguration['options']>['plugins']>['tooltip']
>['external']
type ExternalTooltipContext = Parameters<Exclude<ExternalTooltipHandler, undefined>>[0]

function withAlpha(color: string, alpha: number): string {
const match = /^#([0-9a-f]{6})$/i.exec(color)
if (!match) return color
const r = Number.parseInt(match[1].slice(0, 2), 16)
const g = Number.parseInt(match[1].slice(2, 4), 16)
const b = Number.parseInt(match[1].slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

function buildDatasets() {
return props.datasets.map((dataset, index) => {
const common = {
label: dataset.label,
data: dataset.data,
borderColor: dataset.borderColor,
borderWidth: 2,
}

if (props.type === 'bar') {
return {
...common,
backgroundColor: withAlpha(dataset.backgroundColor, 0.85),
borderWidth: 0,
stack: props.stacked ? 'analytics' : undefined,
}
}

const lineFill: 'origin' | '-1' | false = props.fill ? (index === 0 ? 'origin' : '-1') : false

return {
...common,
backgroundColor: props.fill
? withAlpha(dataset.backgroundColor, 0.3)
: dataset.backgroundColor,
fill: lineFill,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 16,
stack: props.stacked ? 'analytics' : undefined,
}
})
}

function buildConfig(): ChartConfiguration {
return {
type: props.type,
data: {
labels: props.labels,
datasets: buildDatasets() as ChartConfiguration['data']['datasets'],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
enabled: false,
external: handleExternalTooltip,
},
},
scales: {
x: {
stacked: props.stacked && props.type === 'bar',
grid: { display: false },
ticks: {
maxTicksLimit: 8,
autoSkip: true,
color: 'rgba(148, 163, 184, 0.9)',
},
border: { color: 'rgba(148, 163, 184, 0.35)' },
},
y: {
stacked: props.stacked,
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.15)',
},
border: { display: false },
ticks: {
color: 'rgba(148, 163, 184, 0.9)',
callback: (tickValue) => {
const numeric =
typeof tickValue === 'number' ? tickValue : Number.parseFloat(String(tickValue))
if (!Number.isFinite(numeric)) return String(tickValue)
return formatAxisValue(numeric, props.activeStat, formatCompactNumber)
},
},
},
},
},
}
}

function handleExternalTooltip(context: ExternalTooltipContext) {
const tooltip = context.tooltip
if (!tooltip || tooltip.opacity === 0) {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
return
}
const sliceIndex = tooltip.dataPoints?.[0]?.dataIndex ?? null
emit('hover', {
visible: true,
x: tooltip.caretX,
y: tooltip.caretY,
sliceIndex,
})
}

function createChart() {
if (!canvasRef.value) return
chartInstance = new Chart(canvasRef.value, buildConfig())
}

function refreshChart() {
if (!chartInstance) return
const config = buildConfig()
chartInstance.data = config.data
chartInstance.options = config.options ?? {}
chartInstance.update('none')
}

function handleCanvasLeave() {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
}

onMounted(() => {
createChart()
canvasRef.value?.addEventListener('mouseleave', handleCanvasLeave)
})

onBeforeUnmount(() => {
canvasRef.value?.removeEventListener('mouseleave', handleCanvasLeave)
chartInstance?.destroy()
chartInstance = null
})

watch(
() => [props.type, props.fill, props.stacked],
() => {
chartInstance?.destroy()
chartInstance = null
nextTick(() => createChart())
},
)

watch(
() => [props.datasets, props.labels, props.activeStat],
() => {
refreshChart()
},
{ deep: true },
)
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<template>
<div
v-show="visible"
ref="tooltipElement"
class="analytics-chart-tooltip pointer-events-none absolute left-0 top-0 z-10 min-w-[14rem] rounded-lg border border-solid border-surface-5 bg-surface-3 px-3 py-2 text-sm shadow-lg"
:style="positionStyle"
>
<div class="mb-1 font-medium text-contrast">{{ rangeLabel }}</div>
<div class="flex flex-col gap-1">
<div
v-for="entry in entries"
:key="entry.projectId"
class="flex items-center justify-between gap-4"
>
<div class="inline-flex items-center gap-1.5 text-primary">
<span class="size-2 rounded-full" :style="{ backgroundColor: entry.color }" />
<span>{{ entry.name }}</span>
</div>
<span class="font-semibold text-contrast">{{ entry.formattedValue }}</span>
</div>
</div>
</div>
</template>

<script setup lang="ts">
export type AnalyticsChartTooltipEntry = {
projectId: string
name: string
color: string
formattedValue: string
}

const props = defineProps<{
visible: boolean
x: number
y: number
rangeLabel: string
entries: AnalyticsChartTooltipEntry[]
containerWidth: number
containerHeight: number
}>()

const tooltipElement = ref<HTMLDivElement | null>(null)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)

const CURSOR_OFFSET = 12
const EDGE_PADDING = 8

watch(
() => [props.visible, props.entries, props.rangeLabel],
() => {
nextTick(() => {
if (!tooltipElement.value) return
tooltipWidth.value = tooltipElement.value.offsetWidth
tooltipHeight.value = tooltipElement.value.offsetHeight
})
},
{ deep: true, immediate: true },
)

const positionStyle = computed(() => {
const desiredLeft = props.x + CURSOR_OFFSET
const maxLeft = Math.max(EDGE_PADDING, props.containerWidth - tooltipWidth.value - EDGE_PADDING)
const clampedLeft =
desiredLeft + tooltipWidth.value > props.containerWidth - EDGE_PADDING
? Math.max(EDGE_PADDING, props.x - tooltipWidth.value - CURSOR_OFFSET)
: Math.min(maxLeft, desiredLeft)

const desiredTop = props.y - tooltipHeight.value / 2
const maxTop = Math.max(EDGE_PADDING, props.containerHeight - tooltipHeight.value - EDGE_PADDING)
const clampedTop = Math.min(maxTop, Math.max(EDGE_PADDING, desiredTop))

return {
transform: `translate3d(${clampedLeft}px, ${clampedTop}px, 0)`,
}
})
</script>

<style scoped>
.analytics-chart-tooltip {
transition: transform 750ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
</style>
Loading
Loading