Files
AstralRinth/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-plot/AnalyticsChartEvents.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

1155 lines
34 KiB
Vue

<template>
<div
v-if="canRender"
ref="chartElement"
class="pointer-events-none absolute left-0 top-0"
:style="chartLayerStyle"
>
<div
v-for="group in eventGroups"
:key="`${group.id}:guide`"
aria-hidden="true"
class="absolute left-0 border-0 border-l border-dashed transition-all"
:class="
activeGroup?.id === group.id
? isModrinthEventGroup(group)
? 'border-blue opacity-80'
: 'border-contrast opacity-60'
: isModrinthEventGroup(group)
? 'border-blue opacity-50'
: 'border-secondary opacity-40'
"
:style="getGuideStyle(group)"
/>
<Transition name="analytics-event-range-highlight-fade">
<div
v-if="rangeHighlight"
aria-hidden="true"
class="pointer-events-none absolute left-0 rounded-sm border border-l-0 border-dashed border-blue bg-highlight-blue opacity-40"
:style="rangeHighlight"
/>
</Transition>
<button
v-for="group in eventGroups"
:key="group.id"
type="button"
data-analytics-event-tooltip-trigger
class="pointer-events-auto absolute left-0 top-0 inline-flex h-5 min-w-5 cursor-default items-center justify-center gap-1 rounded-full bg-surface-3 px-1 transition-colors focus-visible:border-brand focus-visible:text-contrast"
:class="
activeGroup?.id === group.id
? isModrinthEventGroup(group)
? 'border-blue text-contrast'
: 'border-brand text-contrast'
: 'text-secondary'
"
:style="getMarkerStyle(group)"
:aria-label="getGroupAriaLabel(group)"
@pointerdown.stop="handleGroupPointerDown(group.id)"
@click.stop="handleGroupClick(group.id)"
@mouseenter="scheduleHoveredGroupOpen(group.id)"
@mouseleave="scheduleHoverClose"
@focus="showHoveredGroup(group.id)"
@blur="scheduleHoverClose"
@wheel="handleGroupWheel($event, group.id)"
@keydown.escape.stop="clearActiveGroup"
>
<TagCategoryFlagIcon
v-if="group.markerIcon === 'flag'"
class="relative top-px size-5"
aria-hidden="true"
/>
<InfoIcon v-else class="size-5 text-blue" aria-hidden="true" />
<span v-if="group.events.length > 1" class="text-xs font-semibold leading-none">
{{ group.events.length }}
</span>
</button>
</div>
<Teleport to="body">
<Transition name="analytics-event-tooltip-fade">
<div
v-if="activeGroup"
ref="tooltipElement"
class="analytics-event-tooltip pointer-events-auto fixed left-0 top-0 flex max-h-[340px] w-[12rem] flex-col overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-3 text-sm shadow-xl"
:style="tooltipStyle"
@mouseenter="onTooltipMouseEnter"
@mouseleave="onTooltipMouseLeave"
@focusin="onTooltipMouseEnter"
@focusout="onTooltipMouseLeave"
@click.stop
>
<div class="relative flex min-h-0 flex-1 flex-col">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTooltipTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 -mt-1 h-6 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="tooltipScrollElement"
class="overflow-y-auto overscroll-contain py-2"
@scroll="checkTooltipScrollState"
>
<div class="flex flex-col gap-2.5">
<div
v-for="event in activeGroup.events"
:key="getEventKey(event)"
class="border-0 border-b border-solid border-surface-5 px-3 pb-2.5 last:border-b-0 last:pb-0"
>
<div
:class="
event.projectName
? 'font-medium leading-snug text-primary'
: 'font-medium leading-snug text-contrast'
"
>
<template v-if="event.projectName">
<IntlFormatted
:message-id="analyticsChartMessages.projectEventTitle"
:values="{ projectName: event.projectName ?? '', title: event.title }"
>
<template #project="{ children }">
<span class="text-contrast">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</template>
<template v-else>
{{ event.title }}
</template>
</div>
<a
v-if="event.announcement_url"
:href="event.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="mt-1.5 inline-flex items-center gap-1 text-sm font-medium text-primary underline !transition-all hover:text-contrast"
>
{{ formatMessage(analyticsChartMessages.seeAnnouncement) }}
<ExternalIcon class="size-3.5" aria-hidden="true" />
</a>
<div class="mt-1 text-xs font-medium text-primary">
{{ event.subtitle ?? formatEventRange(event) }}
</div>
</div>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-8"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-8"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTooltipBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 -mb-1 h-8 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ExternalIcon, InfoIcon, TagCategoryFlagIcon } from '@modrinth/assets'
import { IntlFormatted, useScrollIndicator, useVIntl } from '@modrinth/ui'
import type {
AnalyticsDashboardStat,
AnalyticsGroupByPreset,
} from '~/providers/analytics/analytics'
import { analyticsChartMessages } from '../../analytics-messages.ts'
import { isTimeRelevantForGroupBy } from '../analytics-chart-utils.ts'
import type { AnalyticsChartGeometryPayload } from '../AnalyticsChart.client.vue'
type AnalyticsChartEventMarkerIcon = 'info' | 'flag'
export type AnalyticsChartEvent = {
title: string
starts: string
ends: string
projectId?: string
projectName?: string
subtitle?: string
announcement_url?: string | null
for_metric_kind?: Labrinth.Analytics.v3.AnalyticsEventMetricKind[] | null
markerIcon?: AnalyticsChartEventMarkerIcon
groupKey?: string
}
type PositionedEvent = AnalyticsChartEvent & {
startMs: number
endMs: number
x: number
endX: number
markerIcon: AnalyticsChartEventMarkerIcon
groupKey: string
}
type EventGroup = {
id: string
x: number
xSum: number
markerOffsetX: number
markerIcon: AnalyticsChartEventMarkerIcon
groupKey: string
events: PositionedEvent[]
}
type CollisionClusterLayout = {
groups: EventGroup[]
markerWidths: number[]
left: number
right: number
}
type CollisionClusterPlacement = {
group: EventGroup
markerWidth: number
markerX: number
index: number
}
const props = defineProps<{
events: AnalyticsChartEvent[]
activeStat: AnalyticsDashboardStat
groupBy: AnalyticsGroupByPreset
chartStart: Date | null
chartEnd: Date | null
geometry: AnalyticsChartGeometryPayload | null
markerIcon?: AnalyticsChartEventMarkerIcon
markerOffsetY?: number
}>()
const { formatMessage } = useVIntl()
const GROUP_DISTANCE_PX = 32
const GROUP_MARKER_GAP_PX = 6
const MARKER_ICON_WIDTH_PX = 20
const MARKER_HORIZONTAL_PADDING_PX = 8
const MARKER_COUNT_GAP_PX = 4
const MARKER_COUNT_DIGIT_WIDTH_PX = 7
const MARKER_HEIGHT_PX = 28
const MARKER_TOP_OFFSET_PX = -26
const TOOLTIP_OFFSET_PX = 8
const EDGE_PADDING_PX = 8
const OPEN_DELAY_MS = 300
const CLOSE_DELAY_MS = 120
const WHEEL_DELTA_LINE = 1
const WHEEL_DELTA_PAGE = 2
const WHEEL_LINE_HEIGHT = 16
const EVENT_RANGE_DATE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
const EVENT_RANGE_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
const EVENT_RANGE_MONTH_DAY_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
const EVENT_RANGE_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
})
const hoveredGroupId = ref<string | null>(null)
const isTooltipHovered = ref(false)
const chartElement = ref<HTMLDivElement | null>(null)
const tooltipElement = ref<HTMLDivElement | null>(null)
const tooltipScrollElement = ref<HTMLDivElement | null>(null)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const chartRect = reactive({
left: 0,
top: 0,
})
let closeTimeout: ReturnType<typeof setTimeout> | null = null
let openTimeout: ReturnType<typeof setTimeout> | null = null
let pointerDownGroupId: string | null = null
let wasPointerDownGroupActive = false
const {
showTopFade: showTooltipTopFade,
showBottomFade: showTooltipBottomFade,
checkScrollState: checkTooltipScrollState,
forceCheck: forceCheckTooltipScrollState,
} = useScrollIndicator(tooltipScrollElement)
const chartStartMs = computed(() => props.chartStart?.getTime() ?? null)
const chartEndMs = computed(() => props.chartEnd?.getTime() ?? null)
const chartWidth = computed(() => props.geometry?.width ?? 0)
const chartHeight = computed(() => props.geometry?.height ?? 0)
const chartLayerStyle = computed(() => ({
width: `${chartWidth.value}px`,
height: `${chartHeight.value}px`,
}))
const canRender = computed(
() =>
props.geometry !== null &&
chartWidth.value > 0 &&
chartHeight.value > 0 &&
chartStartMs.value !== null &&
chartEndMs.value !== null &&
chartEndMs.value > chartStartMs.value &&
props.geometry.xPositions.length > 0 &&
eventGroups.value.length > 0,
)
const visibleEvents = computed<PositionedEvent[]>(() => {
const geometry = props.geometry
const startMs = chartStartMs.value
const endMs = chartEndMs.value
if (!geometry || startMs === null || endMs === null || endMs <= startMs) return []
const bucketXByDate = getBucketXByDate(geometry, startMs, endMs)
return props.events
.filter((event) => doesEventMatchActiveStat(event))
.map((event) => {
const eventStartMs = new Date(event.starts).getTime()
const eventEndMs = new Date(event.ends).getTime()
if (!Number.isFinite(eventStartMs) || !Number.isFinite(eventEndMs)) return null
if (eventEndMs < eventStartMs) return null
if (eventEndMs < startMs || eventStartMs > endMs) return null
const x = getDateBucketX(event.starts, eventStartMs, geometry, startMs, endMs, bucketXByDate)
const endX = getDateBucketX(event.ends, eventEndMs, geometry, startMs, endMs, bucketXByDate)
if (x === null || endX === null) return null
return {
...event,
startMs: eventStartMs,
endMs: eventEndMs,
x,
endX,
markerIcon: getEventMarkerIcon(event),
groupKey: getEventGroupKey(event),
}
})
.filter((event): event is PositionedEvent => Boolean(event))
.sort((a, b) => a.x - b.x || a.startMs - b.startMs || a.title.localeCompare(b.title))
})
const eventGroups = computed<EventGroup[]>(() => {
const groups = mergeProjectGroupsForModrinthEventDistance(
mergeNearbyEventGroups(buildInitialEventGroups(visibleEvents.value)),
)
const resolvedGroups = groups.map((group) => ({
...group,
id: `${group.groupKey}:${group.events.map(getEventKey).join('|')}`,
}))
return applyCollisionOffsets(resolvedGroups)
})
function buildInitialEventGroups(events: PositionedEvent[]): EventGroup[] {
const groups: EventGroup[] = []
const previousGroupsByKey = new Map<string, EventGroup>()
for (const event of events) {
const previousGroup = previousGroupsByKey.get(event.groupKey)
const group = createEventGroup(event)
if (previousGroup && shouldMergeEventGroups(previousGroup, group)) {
mergeEventGroup(previousGroup, group)
continue
}
groups.push(group)
previousGroupsByKey.set(event.groupKey, group)
}
return groups
}
function mergeNearbyEventGroups(groups: EventGroup[]): EventGroup[] {
let nextGroups = groups
let didMerge = true
while (didMerge) {
const result = mergeNearbyEventGroupsOnce(nextGroups)
nextGroups = result.groups
didMerge = result.didMerge
}
return nextGroups
}
function mergeNearbyEventGroupsOnce(groups: EventGroup[]) {
const mergedGroups: EventGroup[] = []
const previousGroupsByKey = new Map<string, EventGroup>()
let didMerge = false
for (const group of groups) {
const previousGroup = previousGroupsByKey.get(group.groupKey)
if (previousGroup && shouldMergeEventGroups(previousGroup, group)) {
mergeEventGroup(previousGroup, group)
didMerge = true
continue
}
mergedGroups.push(group)
previousGroupsByKey.set(group.groupKey, group)
}
return {
groups: mergedGroups,
didMerge,
}
}
function mergeProjectGroupsForModrinthEventDistance(groups: EventGroup[]): EventGroup[] {
let nextGroups = groups
while (true) {
const result = mergeProjectGroupsForModrinthEventDistanceOnce(nextGroups)
if (!result.didMerge) return nextGroups
nextGroups = mergeNearbyEventGroups(result.groups).sort(compareEventGroupsByTarget)
}
}
function mergeProjectGroupsForModrinthEventDistanceOnce(groups: EventGroup[]) {
const clusterLayouts = getCollisionClusterLayouts(groups)
for (const layout of clusterLayouts) {
const projectGroupsToMerge = getProjectGroupsToMergeForModrinthEventDistance(layout)
if (projectGroupsToMerge.length <= 1) continue
return {
groups: mergeEventGroupsInList(groups, projectGroupsToMerge),
didMerge: true,
}
}
return {
groups,
didMerge: false,
}
}
function getProjectGroupsToMergeForModrinthEventDistance(layout: CollisionClusterLayout) {
const placements = getCollisionClusterPlacements(layout)
const projectPlacements = placements.filter((placement) => isProjectEventGroup(placement.group))
if (projectPlacements.length <= 1) return []
const distanceLimit = Math.max(...projectPlacements.map((placement) => placement.markerWidth / 2))
const overLimitPlacement = getMostDisplacedModrinthPlacement(placements, distanceLimit)
if (!overLimitPlacement) return []
const displacedModrinthPlacement = overLimitPlacement
const offset = displacedModrinthPlacement.markerX - displacedModrinthPlacement.group.x
const projectPlacementsOnOffsetSide = projectPlacements.filter((placement) =>
offset > 0
? placement.index < displacedModrinthPlacement.index
: placement.index > displacedModrinthPlacement.index,
)
const placementsToMerge =
projectPlacementsOnOffsetSide.length > 1 ? projectPlacementsOnOffsetSide : projectPlacements
return placementsToMerge.map((placement) => placement.group)
}
function getMostDisplacedModrinthPlacement(
placements: CollisionClusterPlacement[],
distanceLimit: number,
) {
let largestOverage = 0
let overLimitPlacement: CollisionClusterPlacement | null = null
for (const placement of placements) {
if (!isModrinthEventGroup(placement.group)) continue
const overage = Math.abs(placement.markerX - placement.group.x) - distanceLimit
if (overage <= largestOverage) continue
largestOverage = overage
overLimitPlacement = placement
}
return overLimitPlacement
}
const activeGroup = computed(
() => eventGroups.value.find((group) => group.id === hoveredGroupId.value) ?? null,
)
const activeRange = computed(() => {
const group = activeGroup.value
const geometry = props.geometry
const startMs = chartStartMs.value
const endMs = chartEndMs.value
if (!group || !geometry || startMs === null || endMs === null || endMs <= startMs) return null
const rangedEvents = group.events.filter((event) => event.startMs !== event.endMs)
if (rangedEvents.length === 0) return null
const rangeStartMs = Math.max(startMs, Math.min(...rangedEvents.map((event) => event.startMs)))
const rangeEndMs = Math.min(endMs, Math.max(...rangedEvents.map((event) => event.endMs)))
if (rangeEndMs <= rangeStartMs) return null
const left = Math.min(...rangedEvents.map((event) => event.x))
const right = Math.max(...rangedEvents.map((event) => event.endX))
return {
left: Math.min(left, right),
right: Math.max(left, right),
}
})
const rangeHighlight = computed(() => {
const range = activeRange.value
const geometry = props.geometry
if (!range || !geometry) return null
return {
top: `${geometry.top}px`,
height: `${geometry.bottom - geometry.top}px`,
transform: `translate(${range.left}px, 0)`,
width: `${Math.max(1, range.right - range.left)}px`,
}
})
const markerTop = computed(() => {
const geometry = props.geometry
if (!geometry) return 0
const preferredTop = geometry.top - MARKER_HEIGHT_PX
const availableHeight =
chartHeight.value - MARKER_HEIGHT_PX - EDGE_PADDING_PX - Math.max(props.markerOffsetY ?? 0, 0)
const maxTop = Math.max(EDGE_PADDING_PX, availableHeight)
return clamp(preferredTop, EDGE_PADDING_PX, maxTop)
})
const markerOffsetTop = computed(() => {
const maxTop = Math.max(EDGE_PADDING_PX, chartHeight.value - MARKER_HEIGHT_PX - EDGE_PADDING_PX)
return clamp(markerTop.value + (props.markerOffsetY ?? 0), EDGE_PADDING_PX, maxTop)
})
const tooltipStyle = computed(() => {
const group = activeGroup.value
if (!group) return {}
const maxTooltipWidth = Math.max(0, chartWidth.value - EDGE_PADDING_PX * 2)
const resolvedTooltipWidth = Math.min(tooltipWidth.value, maxTooltipWidth)
const resolvedTooltipHeight = tooltipHeight.value
const markerX = getClampedMarkerCenterX(group)
const markerViewportLeft = chartRect.left + markerX
const markerViewportTop = chartRect.top + MARKER_TOP_OFFSET_PX + markerOffsetTop.value
const desiredLeft = markerViewportLeft - resolvedTooltipWidth / 2
const maxLeft = Math.max(
chartRect.left + EDGE_PADDING_PX,
chartRect.left + chartWidth.value - resolvedTooltipWidth - EDGE_PADDING_PX,
)
const left = clamp(desiredLeft, chartRect.left + EDGE_PADDING_PX, maxLeft)
const desiredTop = markerViewportTop - resolvedTooltipHeight - TOOLTIP_OFFSET_PX
const viewportHeight = typeof window === 'undefined' ? resolvedTooltipHeight : window.innerHeight
const maxTop = Math.max(EDGE_PADDING_PX, viewportHeight - resolvedTooltipHeight - EDGE_PADDING_PX)
const top = clamp(desiredTop, EDGE_PADDING_PX, maxTop)
return {
transform: `translate3d(${left}px, ${top}px, 0)`,
}
})
function updateChartRect() {
if (!chartElement.value) return
const rect = chartElement.value.getBoundingClientRect()
chartRect.left = rect.left
chartRect.top = rect.top
}
function doesEventMatchActiveStat(event: AnalyticsChartEvent) {
if (!event.for_metric_kind?.length) return true
return event.for_metric_kind.some((metricKind) => {
return metricKind === props.activeStat
})
}
function getEventKey(event: AnalyticsChartEvent) {
return `${event.title}:${event.starts}:${event.ends}:${event.announcement_url ?? ''}:${
event.for_metric_kind?.join(',') ?? ''
}:${event.subtitle ?? ''}:${event.projectId ?? ''}:${event.projectName ?? ''}`
}
function getEventMarkerIcon(event: AnalyticsChartEvent): AnalyticsChartEventMarkerIcon {
return event.markerIcon ?? props.markerIcon ?? 'info'
}
function getEventGroupKey(event: AnalyticsChartEvent): string {
return event.groupKey ?? getEventMarkerIcon(event)
}
function isModrinthEventGroup(group: EventGroup) {
return group.groupKey === 'modrinth'
}
function isProjectEventGroup(group: EventGroup) {
return group.groupKey === 'project'
}
function createEventGroup(event: PositionedEvent): EventGroup {
return {
id: getEventKey(event),
x: event.x,
xSum: event.x,
markerOffsetX: 0,
markerIcon: event.markerIcon,
groupKey: event.groupKey,
events: [event],
}
}
function shouldMergeEventGroups(left: EventGroup, right: EventGroup) {
if (left.groupKey !== right.groupKey) return false
return (
right.x - left.x <= GROUP_DISTANCE_PX || getGroupMarkerGap(left, right) <= GROUP_MARKER_GAP_PX
)
}
function mergeEventGroup(target: EventGroup, source: EventGroup) {
target.events = mergeSortedGroupEvents(target.events, source.events)
target.xSum += source.xSum
target.x = target.xSum / target.events.length
}
function mergeEventGroupsInList(groups: EventGroup[], groupsToMerge: EventGroup[]) {
const targetGroup = groupsToMerge[0]
if (!targetGroup) return groups
const groupsToMergeSet = new Set(groupsToMerge)
for (const sourceGroup of groupsToMerge.slice(1)) {
mergeEventGroup(targetGroup, sourceGroup)
}
return groups
.filter((group) => !groupsToMergeSet.has(group) || group === targetGroup)
.sort(compareEventGroupsByTarget)
}
// Both event arrays are already sorted, so this does the merge step from merge sort.
function mergeSortedGroupEvents(leftEvents: PositionedEvent[], rightEvents: PositionedEvent[]) {
const mergedEvents: PositionedEvent[] = []
let leftIndex = 0
let rightIndex = 0
while (leftIndex < leftEvents.length && rightIndex < rightEvents.length) {
const left = leftEvents[leftIndex]
const right = rightEvents[rightIndex]
if (compareGroupEvents(left, right) <= 0) {
mergedEvents.push(left)
leftIndex++
continue
}
mergedEvents.push(right)
rightIndex++
}
while (leftIndex < leftEvents.length) {
mergedEvents.push(leftEvents[leftIndex])
leftIndex++
}
while (rightIndex < rightEvents.length) {
mergedEvents.push(rightEvents[rightIndex])
rightIndex++
}
return mergedEvents
}
function compareGroupEvents(left: PositionedEvent, right: PositionedEvent) {
return left.x - right.x || left.startMs - right.startMs
}
function compareEventGroupsByTarget(left: EventGroup, right: EventGroup) {
return (
left.x - right.x ||
getMarkerIconOrder(left.markerIcon) - getMarkerIconOrder(right.markerIcon) ||
left.groupKey.localeCompare(right.groupKey)
)
}
function getGroupMarkerGap(left: EventGroup, right: EventGroup) {
return getGroupLeftEdge(right) - getGroupRightEdge(left)
}
function getGroupLeftEdge(group: EventGroup) {
return group.x - getEstimatedMarkerWidth(group) / 2
}
function getGroupRightEdge(group: EventGroup) {
return group.x + getEstimatedMarkerWidth(group) / 2
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function applyCollisionOffsets(groups: EventGroup[]): EventGroup[] {
const offsetByGroupId = new Map<string, number>()
const clusterLayouts = getCollisionClusterLayouts(groups)
for (const layout of clusterLayouts) {
for (const placement of getCollisionClusterPlacements(layout)) {
offsetByGroupId.set(placement.group.id, placement.markerX - placement.group.x)
}
}
return groups.map((group) => ({
...group,
markerOffsetX: offsetByGroupId.get(group.id) ?? 0,
}))
}
function getMarkerIconOrder(markerIcon: AnalyticsChartEventMarkerIcon) {
return markerIcon === 'info' ? 0 : 1
}
function getStableCollisionClusterLayouts(clusters: EventGroup[][]) {
let layouts = clusters.map(getCollisionClusterLayout).sort(compareCollisionClusterLayouts)
while (true) {
const mergedLayouts: CollisionClusterLayout[] = []
let didMerge = false
for (const layout of layouts) {
const previousLayout = mergedLayouts[mergedLayouts.length - 1]
if (!previousLayout) {
mergedLayouts.push(layout)
continue
}
if (layout.left - previousLayout.right <= GROUP_MARKER_GAP_PX) {
mergedLayouts[mergedLayouts.length - 1] = getCollisionClusterLayout([
...previousLayout.groups,
...layout.groups,
])
didMerge = true
continue
}
mergedLayouts.push(layout)
}
if (!didMerge) return mergedLayouts
layouts = mergedLayouts.sort(compareCollisionClusterLayouts)
}
}
function compareCollisionClusterLayouts(
left: CollisionClusterLayout,
right: CollisionClusterLayout,
) {
return left.left - right.left || left.right - right.right
}
function getCollisionClusterLayouts(groups: EventGroup[]) {
return getStableCollisionClusterLayouts(groups.map((group) => [group]))
}
function getCollisionClusterLayout(groups: EventGroup[]): CollisionClusterLayout {
const sortedGroups = [...groups].sort(compareEventGroupsByTarget)
const markerWidths = sortedGroups.map(getEstimatedMarkerWidth)
const totalWidth =
markerWidths.reduce((sum, width) => sum + width, 0) +
Math.max(0, sortedGroups.length - 1) * GROUP_MARKER_GAP_PX
const originalLeft = Math.min(...groups.map(getGroupLeftEdge))
const originalRight = Math.max(...groups.map(getGroupRightEdge))
const center = (originalLeft + originalRight) / 2
const preferredLeft = center - totalWidth / 2
const maxLeft = Math.max(EDGE_PADDING_PX, chartWidth.value - totalWidth - EDGE_PADDING_PX)
const left = clamp(preferredLeft, EDGE_PADDING_PX, maxLeft)
return {
groups: sortedGroups,
markerWidths,
left,
right: left + totalWidth,
}
}
function getCollisionClusterPlacements(
layout: CollisionClusterLayout,
): CollisionClusterPlacement[] {
const placements: CollisionClusterPlacement[] = []
let cursor = layout.left
layout.groups.forEach((group, index) => {
const markerWidth = layout.markerWidths[index]
const markerX = cursor + markerWidth / 2
placements.push({
group,
markerWidth,
markerX,
index,
})
cursor += markerWidth + GROUP_MARKER_GAP_PX
})
return placements
}
function getEstimatedMarkerWidth(group: EventGroup) {
const countWidth =
group.events.length > 1
? MARKER_COUNT_GAP_PX + String(group.events.length).length * MARKER_COUNT_DIGIT_WIDTH_PX
: 0
return MARKER_ICON_WIDTH_PX + MARKER_HORIZONTAL_PADDING_PX + countWidth
}
function getClampedMarkerCenterX(group: EventGroup) {
const markerWidth = getEstimatedMarkerWidth(group)
const minX = markerWidth / 2 + EDGE_PADDING_PX
const maxX = Math.max(minX, chartWidth.value - markerWidth / 2 - EDGE_PADDING_PX)
return clamp(group.x + group.markerOffsetX, minX, maxX)
}
function getBucketXByDate(geometry: AnalyticsChartGeometryPayload, startMs: number, endMs: number) {
if (isTimeRelevantForGroupBy(props.groupBy)) return null
const xPositions = geometry.xPositions
const bucketMs = xPositions.length > 0 ? (endMs - startMs) / xPositions.length : 0
if (bucketMs <= 0) return null
const bucketXByDate = new Map<string, number>()
for (let index = 0; index < xPositions.length; index++) {
const x = xPositions[index]
if (!Number.isFinite(x)) continue
const bucketDate = getBucketDateForEventSnap(index, bucketMs, startMs)
const dateValue = getDateInputValue(bucketDate)
if (!bucketXByDate.has(dateValue)) {
bucketXByDate.set(dateValue, x)
}
}
return bucketXByDate
}
function getDateBucketX(
value: string,
fallbackMs: number,
geometry: AnalyticsChartGeometryPayload,
startMs: number,
endMs: number,
bucketXByDate: Map<string, number> | null,
) {
if (isTimeRelevantForGroupBy(props.groupBy)) {
const clampedMs = Math.max(startMs, Math.min(endMs, fallbackMs))
return getTimeAxisX(clampedMs, geometry, startMs, endMs)
}
const dateInputValue = getEventDateInputValue(value)
if (dateInputValue && bucketXByDate) {
const x = bucketXByDate.get(dateInputValue)
if (x !== undefined) return x
}
const clampedMs = Math.max(startMs, Math.min(endMs, fallbackMs))
return getTimeAxisX(clampedMs, geometry, startMs, endMs)
}
function getBucketDateForEventSnap(index: number, bucketMs: number, startMs: number): Date {
const bucketOffset = isTimeRelevantForGroupBy(props.groupBy) ? index : index + 1
return new Date(startMs + bucketOffset * bucketMs)
}
function getTimeAxisX(
targetMs: number,
geometry: AnalyticsChartGeometryPayload,
startMs: number,
endMs: number,
) {
const xPositions = geometry.xPositions
const bucketMs = xPositions.length > 0 ? (endMs - startMs) / xPositions.length : 0
if (xPositions.length === 0 || bucketMs <= 0) return null
if (xPositions.length === 1) return xPositions[0]
const firstX = xPositions[0]
const lastX = xPositions[xPositions.length - 1]
if (!Number.isFinite(firstX) || !Number.isFinite(lastX)) return null
const firstBucketEndMs = startMs + bucketMs
const clampedTargetMs = Math.min(endMs, Math.max(firstBucketEndMs, targetMs))
const progress = (clampedTargetMs - firstBucketEndMs) / (endMs - firstBucketEndMs)
return firstX + progress * (lastX - firstX)
}
function getMarkerStyle(group: EventGroup) {
return {
transform: `translate(-50%, 0) translate(${getClampedMarkerCenterX(
group,
)}px, ${MARKER_TOP_OFFSET_PX + markerOffsetTop.value}px)`,
}
}
function getGuideStyle(group: EventGroup) {
const geometry = props.geometry
if (!geometry) return {}
return {
top: `${geometry.top}px`,
height: `${geometry.bottom - geometry.top}px`,
transform: `translate(${group.x}px, 0)`,
}
}
function getGroupAriaLabel(group: EventGroup) {
if (group.events.length === 1) {
return group.events[0].title
}
return formatMessage(analyticsChartMessages.analyticsEventsCount, {
count: group.events.length,
})
}
function clearCloseTimeout() {
if (!closeTimeout) return
clearTimeout(closeTimeout)
closeTimeout = null
}
function clearOpenTimeout() {
if (!openTimeout) return
clearTimeout(openTimeout)
openTimeout = null
}
function showHoveredGroup(groupId: string) {
clearOpenTimeout()
clearCloseTimeout()
updateChartRect()
hoveredGroupId.value = groupId
}
function handleGroupPointerDown(groupId: string) {
pointerDownGroupId = groupId
wasPointerDownGroupActive = hoveredGroupId.value === groupId
showHoveredGroup(groupId)
}
function handleGroupClick(groupId: string) {
if (pointerDownGroupId === groupId && wasPointerDownGroupActive) {
clearActiveGroup()
}
pointerDownGroupId = null
wasPointerDownGroupActive = false
}
function handleGroupWheel(event: WheelEvent, groupId: string) {
event.preventDefault()
event.stopPropagation()
if (hoveredGroupId.value !== groupId) {
showHoveredGroup(groupId)
nextTick(() => scrollTooltipByWheel(event))
return
}
scrollTooltipByWheel(event)
}
function getNormalizedWheelDeltaY(event: WheelEvent, element: HTMLElement) {
if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * element.clientHeight
if (event.deltaMode === WHEEL_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT
return event.deltaY
}
function scrollTooltipByWheel(event: WheelEvent) {
const element = tooltipScrollElement.value
if (!element) return
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight)
if (maxScrollTop <= 0) return
const deltaY = getNormalizedWheelDeltaY(event, element)
if (deltaY === 0) return
element.scrollTop = clamp(element.scrollTop + deltaY, 0, maxScrollTop)
checkTooltipScrollState()
}
function scheduleHoveredGroupOpen(groupId: string) {
clearOpenTimeout()
clearCloseTimeout()
if (hoveredGroupId.value === groupId) {
updateChartRect()
return
}
hoveredGroupId.value = null
isTooltipHovered.value = false
openTimeout = setTimeout(() => {
showHoveredGroup(groupId)
openTimeout = null
}, OPEN_DELAY_MS)
}
function scheduleHoverClose() {
clearOpenTimeout()
clearCloseTimeout()
closeTimeout = setTimeout(() => {
if (!isTooltipHovered.value) {
hoveredGroupId.value = null
}
closeTimeout = null
}, CLOSE_DELAY_MS)
}
function clearActiveGroup() {
clearOpenTimeout()
clearCloseTimeout()
hoveredGroupId.value = null
isTooltipHovered.value = false
}
function onTooltipMouseEnter() {
clearOpenTimeout()
clearCloseTimeout()
isTooltipHovered.value = true
}
function onTooltipMouseLeave() {
isTooltipHovered.value = false
scheduleHoverClose()
}
function getDateInputValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getEventDateInputValue(value: string): string | null {
const parsedDate = new Date(value)
if (Number.isNaN(parsedDate.getTime())) return null
return getDateInputValue(parsedDate)
}
function formatEventRange(event: AnalyticsChartEvent) {
const startDate = new Date(event.starts)
const endDate = new Date(event.ends)
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return `${event.starts} - ${event.ends}`
}
const startDateValue = getDateInputValue(startDate)
const endDateValue = getDateInputValue(endDate)
const sameYear = startDate.getFullYear() === endDate.getFullYear()
if (startDate.getTime() === endDate.getTime()) {
return EVENT_RANGE_DATE_TIME_FORMATTER.format(startDate)
}
if (startDateValue === endDateValue) {
return `${EVENT_RANGE_DATE_FORMATTER.format(startDate)}, ${EVENT_RANGE_TIME_FORMATTER.format(
startDate,
)} - ${EVENT_RANGE_TIME_FORMATTER.format(endDate)}`
}
if (sameYear) {
const startLabel = EVENT_RANGE_MONTH_DAY_TIME_FORMATTER.format(startDate)
const endLabel = EVENT_RANGE_MONTH_DAY_TIME_FORMATTER.format(endDate)
return `${startLabel} - ${endLabel}, ${startDate.getFullYear()}`
}
const startLabel = EVENT_RANGE_DATE_TIME_FORMATTER.format(startDate)
const endLabel = EVENT_RANGE_DATE_TIME_FORMATTER.format(endDate)
return `${startLabel} - ${endLabel}`
}
watch(
() => [activeGroup.value, chartWidth.value, chartHeight.value],
() => {
nextTick(() => {
updateChartRect()
if (!tooltipElement.value) return
tooltipWidth.value = tooltipElement.value.offsetWidth
tooltipHeight.value = tooltipElement.value.offsetHeight
forceCheckTooltipScrollState()
})
},
{ deep: true, immediate: true },
)
watch(eventGroups, (groups) => {
const groupIds = new Set(groups.map((group) => group.id))
if (hoveredGroupId.value && !groupIds.has(hoveredGroupId.value)) {
hoveredGroupId.value = null
}
})
onMounted(() => {
updateChartRect()
window.addEventListener('resize', updateChartRect)
window.addEventListener('scroll', updateChartRect, true)
})
onBeforeUnmount(() => {
clearOpenTimeout()
clearCloseTimeout()
window.removeEventListener('resize', updateChartRect)
window.removeEventListener('scroll', updateChartRect, true)
})
</script>
<style scoped>
.analytics-event-tooltip {
opacity: 1;
transition:
opacity 140ms ease-out,
transform 180ms ease-out;
will-change: opacity, transform;
}
.analytics-event-tooltip-fade-enter-from,
.analytics-event-tooltip-fade-leave-to {
opacity: 0;
}
.analytics-event-tooltip-fade-enter-active,
.analytics-event-tooltip-fade-leave-active {
transition:
opacity 140ms ease-out,
transform 180ms ease-out;
}
.analytics-event-range-highlight-fade-enter-active,
.analytics-event-range-highlight-fade-leave-active {
transition: opacity 140ms ease-out;
}
.analytics-event-range-highlight-fade-enter-from,
.analytics-event-range-highlight-fade-leave-to {
opacity: 0;
}
</style>