You've already forked 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>
1155 lines
34 KiB
Vue
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>
|