forked from didirus/AstralRinth
11b2b6e6c0
* 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>
1025 lines
28 KiB
Vue
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>
|