Files
Rocketmc/apps/frontend/src/components/analytics-dashboard/analytics-chart/AnalyticsChart.client.vue
T
Truman Gao 11b2b6e6c0 feat: improve analytics dashboard (#5897)
* feat: implement cancel/apply for custom timeframe range picker

* feat: implement dot for showing todays date

* feat: add max date to be today and show todays date

* feat: if ratio mode, dont show total

* feat: implement show more batching excess lines into "Other" bucket

* refactor: pnpm prepr

* feat: add pick and plop for date range start/end dates

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

* fix: dont show project status filter when from project settings/analytics

* fix: clear selected X above number when appropriate

* feat: graph style updates and dont show year in x axis unless more than 2 year timeframe

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

* feat: implement multiselect sections headers, project select org sections, and project options icons

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

* revert: no longer switch breakdown/filter option if same category

* feat: implement showing project for project version breakdown/filter when there are multiple projects

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

* fix: legend colors moving between metric by determining color based on only downloads metric index

* feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

* fix: defer commit in query builder/filter and style fixes

* feat: more performance optimization to analytics dashboard state, chart, and table

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

* feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f11f94660872427796cdcf6fc93bb21d.

* fix: query filter project version lag and borked virtualization

* feat: rename breakdown "none" to "project"

* feat: implement right side checkmark for multiselect

* feat: keep crossed out legend items still shown in tooltip but also crossed out

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

* fix: preview is incorrect when selecting same date in range date picker

* feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip

* feat (playtest): table component controls what graph shows

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

* feat: add hover legend item to highlight line in tooltip

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

* performance: reduce memory usage from project versions query by keeping only whats necessary

* fix: table checked items not in graph if 0

* feat: add shift click to select a range in table

* performance: add caching for metric types so switching between them is snappy

* performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy

* feat: add analytics table search

* refactor: pnpm prepr

* fix: query filter options not coming in from analytics fetch

* feat: remove breakdown = none when there are multiple projects

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

* performance: use precomputed map for project version options to remove first hover lag

* feat: dropdown filter always open on one side and improve styles

* fix: custom time range picker being weird

* refactor: pnpm prepr

* fix: add back in batch with 300ms interval for projects to prevent backend rate limiting

* performance: only do queries to populate graph first before other analytics queries

* fix: QA polish issues around style and copy

* feat: dont show select all when its just one item in section

* fix: bugs with ratio mode and hiding chart lines

* fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect

* fix: small styles

* fix: polish admin analytic events

* fix: keep scroll position with selection action row appearing when selecting one

* feat: add subheading in graph for showing N items from table

* feat: add unmonetized explaination tooltip

* performance: implement limit on how many lines can be shown in graph

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

* feat: add time in analytics event and normalize date/time so its correct to timezones

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

* feat: change project selection to reset to show top 8 only if reconciled down to 0 items

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

* fix: show top 8 button in graph not defaulting to top 8 for other metrics

* feat: implement prev period analytics fetch into the same current period fetch by shifting start date

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

* feat: enabling prev period will cross out prev for current ones already crossed out

* feat-mobile: remove drag to select time frame in graph

* feat-mobile: dropdown filter to replace dropdown for submenus on small screen

* feat-mobile: time frame picker to use different start and end date pickers for mobile

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

* fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

* feat: default hide project events if there are multiple projects, and default show if only 1 project

* feat: implement analytics performance updates, including facets, and v3 user projects

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

* feat: add close on select for time frame picker mobile

* feat: date picker default read only

* refactor: pnpm prepr

* feat: default to projects breakdown instead of no breakdown with multiple projects

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

* feat: deduplicate version uploads that have same version number and are uploaded on same day

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

* fix: remove project breakdownoption as it is default breakdown when none are selected

* fix: dropdown filter bar briefly empty when switching pages in mobile

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

* fix: date picker scrolling page after picking date in mobile by suppressing focus

* fix: callback to Organization instead of org id

* feat: improve chart tooltip date range label formatter to be much more consistent

* feat: tap to toggle event tooltip

* fix: add user select none on graph and fix zoom into download threshold input

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

* performance: deter non-critical fetches to after analytics is in

* fix: refreshing causes multiple projects to do breakdown=none

* performance: cache project version options to fix lag on open sub menu

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

* fix: project events incorrectly toggling on for first page load

* feat: remove show more and show less in legend, always show all

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

* feat: use tabs for game version select, and remove prev period when change breakdown or project selection

* refactor: pnpm prepr

* feat: change hidden legend items to not contribute to ratio percentages

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

* feat: improve analytics events grouping logic for modrinth events to be close to target

* pnpm prepr

* fix: cant click in gap in toggle

* fix: bugs around selected series from table not persisting with timeframe or filter changes

* refactor: kabab case

* refactor: split up large analytics chart and table component files into smaller components and ts modules

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

* fix: playtime was choosing y axis ticks in seconds instead of hours

* refactor: rename folder that with components to match main component name

* refactor: same rename for analytics table for consistency

* refactor: name main components to index.vue and keep folder name as component name

* refactor: pnpm prepr

* refactor: rename types

* refactor: move query builder types into types file and move components into components/analytics-dashboard

* refactor: colocate query builder url with analytics-dashboard component

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

* fix: no option to see release/all game versions in selected filter dropdown

* fix: game version dropdown width

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-29 19:39:55 +00:00

1025 lines
28 KiB
Vue

<template>
<div class="relative h-full">
<canvas
ref="canvasRef"
class="h-full w-full"
:style="{ touchAction: props.pinnedSliceIndex === null ? 'pan-y' : 'none' }"
/>
<div
v-if="rangeSelection.visible"
aria-hidden="true"
class="pointer-events-none absolute z-10 rounded-sm border border-dashed border-brand bg-brand-highlight opacity-20"
:style="rangeSelectionStyle"
/>
</div>
</template>
<script setup lang="ts">
import { useCompactNumber, useVIntl } from '@modrinth/ui'
import {
BarController,
BarElement,
CategoryScale,
Chart,
type ChartConfiguration,
Filler,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js'
import {
type AnalyticsDashboardStat,
injectAnalyticsDashboardContext,
} from '~/providers/analytics/analytics'
import {
type ChartDataset,
DEFAULT_X_AXIS_TICK_LIMIT,
formatAxisValue,
} from './analytics-chart-utils'
Chart.register(
LineController,
BarController,
LineElement,
BarElement,
PointElement,
CategoryScale,
LinearScale,
Filler,
Tooltip,
)
export type AnalyticsChartHoverPayload = {
visible: boolean
x: number
y: number
sliceIndex: number | null
}
export type AnalyticsChartRangeSelectPayload = {
startSliceIndex: number
endSliceIndex: number
}
export type AnalyticsChartGeometryPayload = {
left: number
right: number
top: number
bottom: number
width: number
height: number
xPositions: number[]
}
const props = defineProps<{
type: 'line' | 'bar'
fill: boolean
stacked: boolean
ratioMode: boolean
datasets: ChartDataset[]
labels: string[]
xAxisTickLimit?: number
activeStat: AnalyticsDashboardStat
pinnedSliceIndex: number | null
highlightedDatasetId: string | null
}>()
const emit = defineEmits<{
(event: 'hover' | 'pinned-drag', payload: AnalyticsChartHoverPayload): void
(event: 'range-select', payload: AnalyticsChartRangeSelectPayload): void
(event: 'geometry', payload: AnalyticsChartGeometryPayload): void
(event: 'touch-drag'): void
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null
const { formatCompactNumber } = useCompactNumber()
const { formatMessage } = useVIntl()
type ExternalTooltipHandler = NonNullable<
NonNullable<NonNullable<ChartConfiguration['options']>['plugins']>['tooltip']
>['external']
type ExternalTooltipContext = Parameters<Exclude<ExternalTooltipHandler, undefined>>[0]
type ChartEvents = NonNullable<NonNullable<ChartConfiguration['options']>['events']>
const chartInteractionEvents: ChartEvents = ['mousemove', 'mouseout', 'click']
const PINNED_DRAG_THRESHOLD_PX = 6
const RANGE_SELECT_THRESHOLD_PX = 8
const EMPTY_DATA_Y_AXIS_MAX = 10
const EMPTY_DATA_Y_AXIS_STEP = 2
const Y_AXIS_WIDTH = 56
const SECONDS_PER_HOUR = 60 * 60
const DIMMED_SERIES_OPACITY = 0.5
const DIMMED_SERIES_COLOR = 'var(--color-text-tertiary)'
const BAR_BACKGROUND_OPACITY = 0.85
const AREA_BACKGROUND_OPACITY = 0.3
const SERIES_OPACITY_TRANSITION_MS = 150
const MOBILE_X_AXIS_TICK_LIMIT = 5
const CSS_VARIABLE_COLOR_PATTERN = /^var\(\s*(--[a-z0-9-_]+)\s*\)$/i
const HSL_COLOR_PATTERN = /^hsl\(\s*([0-9.]+)(?:deg)?\s*,\s*([0-9.]+)%\s*,\s*([0-9.]+)%\s*\)$/i
let pinnedDragPointerId: number | null = null
let pinnedDragStartX = 0
let pinnedDragStartY = 0
let isPinnedDragging = false
let rangeSelectPointerId: number | null = null
let rangeSelectStartX = 0
let rangeSelectStartY = 0
let rangeSelectStartSliceIndex: number | null = null
let rangeSelectLastSliceIndex: number | null = null
let rangeSelectPointerType: string | null = null
let isRangeSelecting = false
let seriesOpacityAnimationFrame: number | null = null
let chartRefreshAnimationFrame: number | null = null
let currentDatasetOpacities: number[] = []
let suppressGeometryEmit = false
let lastGeometryPayload: AnalyticsChartGeometryPayload | null = null
let suppressNextChartClick = false
let clearSuppressedChartClickTimeout: ReturnType<typeof setTimeout> | null = null
const { isMobileLayout } = injectAnalyticsDashboardContext()
const geometryPlugin = {
id: 'analytics-chart-geometry',
afterLayout(chart: Chart) {
if (suppressGeometryEmit) return
emitChartGeometry(chart)
},
}
const rangeSelection = reactive({
visible: false,
startX: 0,
currentX: 0,
top: 0,
bottom: 0,
})
const rangeSelectionStyle = computed(() => {
const left = Math.min(rangeSelection.startX, rangeSelection.currentX)
const width = Math.max(1, Math.abs(rangeSelection.currentX - rangeSelection.startX))
return {
top: `${rangeSelection.top}px`,
bottom: `${rangeSelection.bottom}px`,
transform: `translate(${left}px, 0)`,
width: `${width}px`,
}
})
function getChartEvents(): ChartEvents {
return props.pinnedSliceIndex === null ? [...chartInteractionEvents] : []
}
function emitChartGeometry(chart: Chart | null = chartInstance) {
if (!chart || !canvasRef.value) return
const chartArea = chart.chartArea
const rect = canvasRef.value.getBoundingClientRect()
if (
!Number.isFinite(chartArea.left) ||
!Number.isFinite(chartArea.right) ||
!Number.isFinite(chartArea.top) ||
!Number.isFinite(chartArea.bottom) ||
chartArea.right <= chartArea.left ||
chartArea.bottom <= chartArea.top
) {
return
}
const payload = {
left: chartArea.left,
right: chartArea.right,
top: chartArea.top,
bottom: chartArea.bottom,
width: rect.width,
height: rect.height,
xPositions: props.labels
.map((_, index) => chart.scales.x.getPixelForValue(index))
.filter((x) => Number.isFinite(x)),
}
if (areChartGeometryPayloadsEqual(lastGeometryPayload, payload)) {
return
}
lastGeometryPayload = payload
emit('geometry', payload)
}
function getPinnedActiveElements(sliceIndex: number) {
if (!chartInstance) return []
const activeElements: { datasetIndex: number; index: number }[] = []
for (let datasetIndex = 0; datasetIndex < chartInstance.data.datasets.length; datasetIndex++) {
const dataset = chartInstance.data.datasets[datasetIndex]
if (!dataset) continue
const dataLength = Array.isArray(dataset.data) ? dataset.data.length : 0
if (sliceIndex >= dataLength) continue
activeElements.push({
datasetIndex,
index: sliceIndex,
})
}
return activeElements
}
function getNearestSliceIndex(clientX: number) {
if (!chartInstance || !canvasRef.value || props.labels.length === 0) return null
const rect = canvasRef.value.getBoundingClientRect()
const x = clientX - rect.left
const xScale = chartInstance.scales.x
const rawIndex = xScale.getValueForPixel(x)
if (typeof rawIndex !== 'number' || !Number.isFinite(rawIndex)) return null
return Math.min(props.labels.length - 1, Math.max(0, Math.round(rawIndex)))
}
function getSliceChartPosition(sliceIndex: number) {
if (!chartInstance || !canvasRef.value) return null
const rect = canvasRef.value.getBoundingClientRect()
const chartArea = chartInstance.chartArea
const xScale = chartInstance.scales.x
const x = xScale.getPixelForValue(sliceIndex)
if (!Number.isFinite(x)) return null
return {
x: Math.min(chartArea.right, Math.max(chartArea.left, x)),
top: chartArea.top,
bottom: rect.height - chartArea.bottom,
}
}
function updateRangeSelection(sliceIndex: number) {
const chartPosition = getSliceChartPosition(sliceIndex)
if (!chartPosition) return
rangeSelection.visible = true
rangeSelection.currentX = chartPosition.x
rangeSelection.top = chartPosition.top
rangeSelection.bottom = chartPosition.bottom
}
function clearRangeSelection() {
rangeSelection.visible = false
}
function areChartGeometryPayloadsEqual(
left: AnalyticsChartGeometryPayload | null,
right: AnalyticsChartGeometryPayload,
): boolean {
if (!left) return false
if (
left.left !== right.left ||
left.right !== right.right ||
left.top !== right.top ||
left.bottom !== right.bottom ||
left.width !== right.width ||
left.height !== right.height ||
left.xPositions.length !== right.xPositions.length
) {
return false
}
for (let index = 0; index < left.xPositions.length; index++) {
if (left.xPositions[index] !== right.xPositions[index]) {
return false
}
}
return true
}
function getPinnedTooltipPosition(sliceIndex: number) {
if (!chartInstance) return null
const activeElements = getPinnedActiveElements(sliceIndex)
if (activeElements.length === 0) return null
const positions = activeElements
.map(({ datasetIndex, index }) => {
const element = chartInstance?.getDatasetMeta(datasetIndex).data[index]
if (!element) return null
const point = element.getProps(['x', 'y'], true) as { x: number; y: number }
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null
return point
})
.filter((position): position is { x: number; y: number } => Boolean(position))
if (positions.length === 0) return null
const x = positions.reduce((sum, position) => sum + position.x, 0) / positions.length
const y = positions.reduce((sum, position) => sum + position.y, 0) / positions.length
return { x, y }
}
function emitPinnedDragHover(sliceIndex: number) {
const position = getPinnedTooltipPosition(sliceIndex)
if (!position) return
emit('pinned-drag', {
visible: true,
x: position.x,
y: position.y,
sliceIndex,
})
}
function emitRangeDragHover(sliceIndex: number) {
const position = getPinnedTooltipPosition(sliceIndex)
const fallbackPosition = getSliceChartPosition(sliceIndex)
if (!position && !fallbackPosition) return
emit('hover', {
visible: true,
x: position?.x ?? fallbackPosition?.x ?? 0,
y: position?.y ?? fallbackPosition?.top ?? 0,
sliceIndex,
})
}
function withAlpha(color: string, alpha: number): string {
const hexMatch = /^#([0-9a-f]{6})$/i.exec(color)
if (hexMatch) {
const r = Number.parseInt(hexMatch[1].slice(0, 2), 16)
const g = Number.parseInt(hexMatch[1].slice(2, 4), 16)
const b = Number.parseInt(hexMatch[1].slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const hslMatch = HSL_COLOR_PATTERN.exec(color)
if (!hslMatch) return color
const hue = Number.parseFloat(hslMatch[1])
const saturation = Number.parseFloat(hslMatch[2])
const lightness = Number.parseFloat(hslMatch[3])
if (![hue, saturation, lightness].every(Number.isFinite)) return color
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`
}
function resolveCssColor(color: string): string {
const match = CSS_VARIABLE_COLOR_PATTERN.exec(color)
if (!match || typeof document === 'undefined') {
return color
}
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(match[1]).trim()
return resolvedColor || color
}
function isDatasetDimmed(index: number) {
const dataset = props.datasets[index]
return props.highlightedDatasetId !== null && dataset?.projectId !== props.highlightedDatasetId
}
function getTargetDatasetOpacity(index: number) {
return isDatasetDimmed(index) ? DIMMED_SERIES_OPACITY : 1
}
function getDatasetOpacity(index: number) {
return currentDatasetOpacities[index] ?? getTargetDatasetOpacity(index)
}
function getDatasetColors(dataset: ChartDataset, index: number) {
const opacity = getDatasetOpacity(index)
const baseBorderColor = isDatasetDimmed(index) ? DIMMED_SERIES_COLOR : dataset.borderColor
const baseBackgroundColor = isDatasetDimmed(index) ? DIMMED_SERIES_COLOR : dataset.backgroundColor
return {
borderColor: withAlpha(resolveCssColor(baseBorderColor), opacity),
backgroundColor: resolveCssColor(baseBackgroundColor),
opacity,
}
}
function getChartDataValue(value: number) {
if (props.activeStat === 'playtime' && !props.ratioMode) {
return value / SECONDS_PER_HOUR
}
return value
}
function buildDatasets() {
return props.datasets.map((dataset, index) => {
const colors = getDatasetColors(dataset, index)
const common = {
label: dataset.label,
data: dataset.data.map(getChartDataValue),
borderColor: colors.borderColor,
borderDash: dataset.borderDash,
borderWidth: 2,
}
if (props.type === 'bar') {
return {
...common,
backgroundColor: withAlpha(colors.backgroundColor, BAR_BACKGROUND_OPACITY * colors.opacity),
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(colors.backgroundColor, AREA_BACKGROUND_OPACITY * colors.opacity)
: withAlpha(colors.backgroundColor, colors.opacity),
fill: lineFill,
tension: 0.35,
pointRadius: 0,
pointBackgroundColor: colors.borderColor,
pointBorderWidth: 0,
pointHoverRadius: 4,
pointHoverBackgroundColor: colors.borderColor,
pointHoverBorderWidth: 0,
pointHitRadius: 16,
stack: props.stacked ? 'analytics' : undefined,
}
})
}
function cancelSeriesOpacityAnimation() {
if (seriesOpacityAnimationFrame === null) return
cancelAnimationFrame(seriesOpacityAnimationFrame)
seriesOpacityAnimationFrame = null
}
function cancelScheduledChartRefresh() {
if (chartRefreshAnimationFrame === null) return
cancelAnimationFrame(chartRefreshAnimationFrame)
chartRefreshAnimationFrame = null
}
function getTargetDatasetOpacities() {
return props.datasets.map((_, index) => getTargetDatasetOpacity(index))
}
function syncDatasetOpacitiesToTargets() {
cancelSeriesOpacityAnimation()
currentDatasetOpacities = getTargetDatasetOpacities()
}
function updateChartWithoutGeometry() {
if (!chartInstance) return
suppressGeometryEmit = true
try {
chartInstance.update('none')
} finally {
suppressGeometryEmit = false
}
}
function applySeriesHoverState() {
if (!chartInstance) return
chartInstance.data.datasets.forEach((chartDataset, index) => {
const dataset = props.datasets[index]
if (!dataset) return
const colors = getDatasetColors(dataset, index)
chartDataset.borderColor = colors.borderColor
chartDataset.backgroundColor =
props.type === 'bar'
? withAlpha(colors.backgroundColor, BAR_BACKGROUND_OPACITY * colors.opacity)
: props.fill
? withAlpha(colors.backgroundColor, AREA_BACKGROUND_OPACITY * colors.opacity)
: withAlpha(colors.backgroundColor, colors.opacity)
Object.assign(chartDataset, {
pointBackgroundColor: colors.borderColor,
pointHoverBackgroundColor: colors.borderColor,
})
})
updateChartWithoutGeometry()
}
function easeSeriesOpacityTransition(progress: number) {
return 1 - Math.pow(1 - progress, 3)
}
function animateSeriesHoverState() {
if (!chartInstance) return
if (typeof requestAnimationFrame === 'undefined') {
syncDatasetOpacitiesToTargets()
applySeriesHoverState()
return
}
cancelSeriesOpacityAnimation()
const from = props.datasets.map((_, index) => getDatasetOpacity(index))
const to = getTargetDatasetOpacities()
if (from.every((opacity, index) => Math.abs(opacity - (to[index] ?? 1)) < 0.001)) {
currentDatasetOpacities = to
applySeriesHoverState()
return
}
const start = performance.now()
const tick = (now: number) => {
const progress = Math.min(1, (now - start) / SERIES_OPACITY_TRANSITION_MS)
const easedProgress = easeSeriesOpacityTransition(progress)
currentDatasetOpacities = to.map(
(targetOpacity, index) =>
(from[index] ?? targetOpacity) +
(targetOpacity - (from[index] ?? targetOpacity)) * easedProgress,
)
applySeriesHoverState()
if (progress < 1) {
seriesOpacityAnimationFrame = requestAnimationFrame(tick)
return
}
seriesOpacityAnimationFrame = null
currentDatasetOpacities = to
applySeriesHoverState()
}
seriesOpacityAnimationFrame = requestAnimationFrame(tick)
}
function getVisibleXAxisLabelIndexes(labelCount: number, limit: number): Set<number> {
if (limit <= 0 || labelCount <= limit) {
return new Set(Array.from({ length: labelCount }, (_, index) => index))
}
const indexes = new Set<number>()
for (let i = 0; i < limit; i++) {
indexes.add(Math.floor((i * labelCount) / limit))
}
return indexes
}
function getEffectiveXAxisTickLimit() {
const tickLimit = props.xAxisTickLimit ?? DEFAULT_X_AXIS_TICK_LIMIT
return isMobileLayout.value ? Math.min(tickLimit, MOBILE_X_AXIS_TICK_LIMIT) : tickLimit
}
function hasMetricData() {
return props.datasets.some((dataset) =>
dataset.data.some((value) => Number.isFinite(value) && value > 0),
)
}
function getEmptyDataYAxisMax() {
return EMPTY_DATA_Y_AXIS_MAX
}
function getEmptyDataYAxisStepSize() {
return EMPTY_DATA_Y_AXIS_STEP
}
function buildConfig(): ChartConfiguration {
const hasData = hasMetricData()
const effectiveXAxisTickLimit = getEffectiveXAxisTickLimit()
const visibleXAxisLabelIndexes =
props.xAxisTickLimit === undefined && !isMobileLayout.value
? null
: getVisibleXAxisLabelIndexes(props.labels.length, effectiveXAxisTickLimit)
return {
type: props.type,
plugins: [geometryPlugin],
data: {
labels: props.labels,
datasets: buildDatasets() as ChartConfiguration['data']['datasets'],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
normalized: true,
events: getChartEvents(),
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
enabled: false,
external: handleExternalTooltip,
},
},
scales: {
x: {
stacked: props.stacked && props.type === 'bar',
offset: props.type === 'bar',
grid: { display: false },
ticks: {
align: 'inner',
maxTicksLimit: effectiveXAxisTickLimit,
autoSkip: !props.xAxisTickLimit && !isMobileLayout.value,
color: 'rgba(148, 163, 184, 0.9)',
callback: (tickValue, index) => {
if (visibleXAxisLabelIndexes && !visibleXAxisLabelIndexes.has(index)) {
return ''
}
return props.labels[Number(tickValue)] ?? ''
},
},
border: { color: 'rgba(148, 163, 184, 0.35)' },
},
y: {
stacked: props.stacked,
beginAtZero: true,
afterFit: (scale) => {
scale.width = Y_AXIS_WIDTH
},
...(props.ratioMode
? { max: 100, min: 0 }
: hasData
? {}
: { max: getEmptyDataYAxisMax(), min: 0 }),
grid: {
color: 'rgba(148, 163, 184, 0.15)',
},
border: { display: false },
ticks: {
color: 'rgba(148, 163, 184, 0.9)',
...(props.ratioMode
? { stepSize: 25 }
: hasData
? {}
: { stepSize: getEmptyDataYAxisStepSize() }),
callback: (tickValue, _index, ticks) => {
const numeric =
typeof tickValue === 'number' ? tickValue : Number.parseFloat(String(tickValue))
if (!Number.isFinite(numeric)) return String(tickValue)
if (props.ratioMode) return `${numeric}%`
const tickValues = ticks
.map((tick) => tick.value)
.filter((value) => Number.isFinite(value))
return formatAxisValue(
numeric,
props.activeStat,
formatCompactNumber,
formatMessage,
tickValues,
)
},
},
},
},
},
}
}
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
syncDatasetOpacitiesToTargets()
chartInstance = new Chart(canvasRef.value, buildConfig())
emitChartGeometry()
}
function refreshChart() {
if (!chartInstance) return
syncDatasetOpacitiesToTargets()
const config = buildConfig()
chartInstance.data = config.data
chartInstance.options = config.options ?? {}
clearChartActiveState()
chartInstance.update('none')
syncPinnedSliceState()
if (props.pinnedSliceIndex !== null) {
updateChartWithoutGeometry()
}
emitChartGeometry()
}
function scheduleChartRefresh() {
if (typeof requestAnimationFrame === 'undefined') {
refreshChart()
return
}
if (chartRefreshAnimationFrame !== null) {
return
}
chartRefreshAnimationFrame = requestAnimationFrame(() => {
chartRefreshAnimationFrame = null
refreshChart()
})
}
function syncPinnedSliceState() {
if (!chartInstance) return
const activeElements =
props.pinnedSliceIndex === null ? [] : getPinnedActiveElements(props.pinnedSliceIndex)
const tooltipPosition =
props.pinnedSliceIndex === null
? { x: 0, y: 0 }
: (getPinnedTooltipPosition(props.pinnedSliceIndex) ?? { x: 0, y: 0 })
chartInstance.options.events = getChartEvents()
chartInstance.setActiveElements(activeElements)
chartInstance.tooltip?.setActiveElements(activeElements, tooltipPosition)
}
function clearChartActiveState() {
if (!chartInstance) return
chartInstance.setActiveElements([])
chartInstance.tooltip?.setActiveElements([], { x: 0, y: 0 })
}
function clearSuppressNextChartClickTimeout() {
if (!clearSuppressedChartClickTimeout) return
clearTimeout(clearSuppressedChartClickTimeout)
clearSuppressedChartClickTimeout = null
}
function suppressUpcomingChartClick() {
suppressNextChartClick = true
clearSuppressNextChartClickTimeout()
clearSuppressedChartClickTimeout = setTimeout(() => {
suppressNextChartClick = false
clearSuppressedChartClickTimeout = null
}, 350)
}
function clearUnpinnedTouchInteraction(ignoreClick: boolean) {
if (props.pinnedSliceIndex !== null) return
clearChartActiveState()
updateChartWithoutGeometry()
if (ignoreClick) {
suppressUpcomingChartClick()
emit('touch-drag')
return
}
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
}
function handleCanvasClickCapture(event: MouseEvent) {
if (!suppressNextChartClick) return
suppressNextChartClick = false
clearSuppressNextChartClickTimeout()
event.preventDefault()
event.stopImmediatePropagation()
clearChartActiveState()
updateChartWithoutGeometry()
}
function applyPinnedSliceState() {
if (!chartInstance) return
syncPinnedSliceState()
updateChartWithoutGeometry()
}
function handleCanvasLeave() {
emit('hover', { visible: false, x: 0, y: 0, sliceIndex: null })
if (props.pinnedSliceIndex !== null) {
requestAnimationFrame(() => applyPinnedSliceState())
}
}
function handlePinnedPointerDown(event: PointerEvent) {
if (props.pinnedSliceIndex === null || event.pointerType !== 'touch' || !canvasRef.value) return
pinnedDragPointerId = event.pointerId
pinnedDragStartX = event.clientX
pinnedDragStartY = event.clientY
isPinnedDragging = false
canvasRef.value.setPointerCapture(event.pointerId)
}
function handlePinnedPointerMove(event: PointerEvent) {
if (props.pinnedSliceIndex === null || event.pointerId !== pinnedDragPointerId) return
const distance = Math.hypot(event.clientX - pinnedDragStartX, event.clientY - pinnedDragStartY)
if (!isPinnedDragging && distance < PINNED_DRAG_THRESHOLD_PX) return
const sliceIndex = getNearestSliceIndex(event.clientX)
if (sliceIndex === null) return
isPinnedDragging = true
event.preventDefault()
emitPinnedDragHover(sliceIndex)
}
function handlePinnedPointerEnd(event: PointerEvent) {
if (event.pointerId !== pinnedDragPointerId) return
canvasRef.value?.releasePointerCapture(event.pointerId)
pinnedDragPointerId = null
isPinnedDragging = false
}
function handleRangePointerDown(event: PointerEvent) {
if (rangeSelectPointerId !== null) return
if (!canvasRef.value || props.labels.length === 0) return
if (event.pointerType === 'mouse' && event.button !== 0) return
if (props.pinnedSliceIndex !== null && event.pointerType === 'touch') return
const sliceIndex = getNearestSliceIndex(event.clientX)
if (sliceIndex === null) return
const chartPosition = getSliceChartPosition(sliceIndex)
if (!chartPosition) return
rangeSelectPointerId = event.pointerId
rangeSelectStartX = event.clientX
rangeSelectStartY = event.clientY
rangeSelectStartSliceIndex = sliceIndex
rangeSelectLastSliceIndex = sliceIndex
rangeSelectPointerType = event.pointerType
isRangeSelecting = false
rangeSelection.startX = chartPosition.x
rangeSelection.currentX = chartPosition.x
rangeSelection.top = chartPosition.top
rangeSelection.bottom = chartPosition.bottom
canvasRef.value.setPointerCapture(event.pointerId)
}
function handleRangePointerMove(event: PointerEvent) {
if (event.pointerId !== rangeSelectPointerId) return
const deltaX = event.clientX - rangeSelectStartX
const deltaY = event.clientY - rangeSelectStartY
if (!isRangeSelecting) {
if (rangeSelectPointerType === 'touch') {
const horizontalDistance = Math.abs(deltaX)
const verticalDistance = Math.abs(deltaY)
if (
horizontalDistance < RANGE_SELECT_THRESHOLD_PX ||
horizontalDistance <= verticalDistance
) {
return
}
} else if (Math.hypot(deltaX, deltaY) < RANGE_SELECT_THRESHOLD_PX) {
return
}
}
const sliceIndex = getNearestSliceIndex(event.clientX)
if (sliceIndex === null) return
isRangeSelecting = true
rangeSelectLastSliceIndex = sliceIndex
event.preventDefault()
if (rangeSelectPointerType === 'touch') {
emitRangeDragHover(sliceIndex)
return
}
updateRangeSelection(sliceIndex)
emitRangeDragHover(sliceIndex)
}
function handleRangePointerEnd(event: PointerEvent) {
if (event.pointerId !== rangeSelectPointerId) return
canvasRef.value?.releasePointerCapture(event.pointerId)
const startSliceIndex = rangeSelectStartSliceIndex
const endSliceIndex = rangeSelectLastSliceIndex
if (isRangeSelecting && rangeSelectPointerType === 'touch') {
event.preventDefault()
clearUnpinnedTouchInteraction(true)
} else if (isRangeSelecting && startSliceIndex !== null && endSliceIndex !== null) {
event.preventDefault()
emit('range-select', { startSliceIndex, endSliceIndex })
} else if (rangeSelectPointerType === 'touch') {
clearUnpinnedTouchInteraction(false)
}
rangeSelectPointerId = null
rangeSelectStartSliceIndex = null
rangeSelectLastSliceIndex = null
rangeSelectPointerType = null
isRangeSelecting = false
clearRangeSelection()
}
function handleRangePointerCancel(event: PointerEvent) {
if (event.pointerId !== rangeSelectPointerId) return
canvasRef.value?.releasePointerCapture(event.pointerId)
if (rangeSelectPointerType === 'touch') {
clearUnpinnedTouchInteraction(isRangeSelecting)
}
rangeSelectPointerId = null
rangeSelectStartSliceIndex = null
rangeSelectLastSliceIndex = null
rangeSelectPointerType = null
isRangeSelecting = false
clearRangeSelection()
}
onMounted(() => {
canvasRef.value?.addEventListener('click', handleCanvasClickCapture, true)
createChart()
canvasRef.value?.addEventListener('mouseleave', handleCanvasLeave)
canvasRef.value?.addEventListener('pointerdown', handleRangePointerDown)
canvasRef.value?.addEventListener('pointermove', handleRangePointerMove)
canvasRef.value?.addEventListener('pointerup', handleRangePointerEnd)
canvasRef.value?.addEventListener('pointercancel', handleRangePointerCancel)
canvasRef.value?.addEventListener('pointerdown', handlePinnedPointerDown)
canvasRef.value?.addEventListener('pointermove', handlePinnedPointerMove)
canvasRef.value?.addEventListener('pointerup', handlePinnedPointerEnd)
canvasRef.value?.addEventListener('pointercancel', handlePinnedPointerEnd)
})
onBeforeUnmount(() => {
canvasRef.value?.removeEventListener('click', handleCanvasClickCapture, true)
canvasRef.value?.removeEventListener('mouseleave', handleCanvasLeave)
canvasRef.value?.removeEventListener('pointerdown', handleRangePointerDown)
canvasRef.value?.removeEventListener('pointermove', handleRangePointerMove)
canvasRef.value?.removeEventListener('pointerup', handleRangePointerEnd)
canvasRef.value?.removeEventListener('pointercancel', handleRangePointerCancel)
canvasRef.value?.removeEventListener('pointerdown', handlePinnedPointerDown)
canvasRef.value?.removeEventListener('pointermove', handlePinnedPointerMove)
canvasRef.value?.removeEventListener('pointerup', handlePinnedPointerEnd)
canvasRef.value?.removeEventListener('pointercancel', handlePinnedPointerEnd)
cancelSeriesOpacityAnimation()
cancelScheduledChartRefresh()
clearSuppressNextChartClickTimeout()
chartInstance?.destroy()
chartInstance = null
})
watch(
() => [props.type, props.fill, props.stacked],
() => {
cancelScheduledChartRefresh()
chartInstance?.destroy()
chartInstance = null
nextTick(() => {
createChart()
applyPinnedSliceState()
})
},
)
watch(
() => [
props.datasets,
props.labels,
props.xAxisTickLimit,
props.activeStat,
props.ratioMode,
isMobileLayout.value,
],
() => {
scheduleChartRefresh()
},
)
watch(
() => props.pinnedSliceIndex,
() => {
applyPinnedSliceState()
},
)
watch(
() => props.highlightedDatasetId,
() => {
animateSeriesHoverState()
},
)
</script>