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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/components/Package/TimelineChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '~/utils/charts'
import type { TimelineVersion, SubEvent } from '~~/server/api/registry/timeline/[...pkg].get'
import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark'
import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition'

import('vue-data-ui/style.css')

Expand Down Expand Up @@ -250,6 +251,8 @@ function buildExportFilename(extension: 'png' | 'csv' | 'svg') {
return `${sanitise(packageName.value)}_${$t('package.links.timeline')}_${metricLabel.value.toLocaleLowerCase().replaceAll(' ', '-')}.${extension}`
}

const tooltipPosition = useChartTooltipPosition(chartRef)

const config = computed<VueUiXyConfig>(() => {
return {
theme: isDarkMode.value ? 'dark' : '',
Expand Down Expand Up @@ -316,6 +319,8 @@ const config = computed<VueUiXyConfig>(() => {
color: colors.value.fg,
},
tooltip: {
position: tooltipPosition.value,
offsetX: 24,
borderColor: colors.value.border,
borderRadius: 6,
backgroundColor: colors.value.bg,
Expand Down
9 changes: 9 additions & 0 deletions app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '~/utils/chart-data-prediction'
import { applyBlocklistCorrection, getAnomaliesForPackages } from '~/utils/download-anomalies'
import { copyAltTextForTrendLineChart, sanitise, loadFile, applyEllipsis } from '~/utils/charts'
import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition'

import('vue-data-ui/style.css')

Expand Down Expand Up @@ -67,6 +68,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef<HTMLElement | null>(null)
const isZoomed = shallowRef(false)

const chartRef = useTemplateRef('chartRef')

function setIsZoom({ isZoom }: { isZoom: boolean }) {
isZoomed.value = isZoom
}
Expand Down Expand Up @@ -1385,6 +1388,8 @@ watch(
{ immediate: true },
)

const tooltipPosition = useChartTooltipPosition(chartRef)

// VueUiXy chart component configuration
const chartConfig = computed<VueUiXyConfig>(() => {
return {
Expand Down Expand Up @@ -1518,6 +1523,9 @@ const chartConfig = computed<VueUiXyConfig>(() => {
legend: { show: false, position: 'top' },
tooltip: {
teleportTo: props.inModal ? '#chart-modal' : undefined,
position: tooltipPosition.value,
offsetX: 24,
offsetY: isMultiPackageMode.value ? undefined : -24,
borderColor: 'transparent',
backdropFilter: false,
backgroundColor: 'transparent',
Expand Down Expand Up @@ -1930,6 +1938,7 @@ const isSparklineLayout = computed({
:aria-labelledby="isMultiPackageMode ? 'combined-chart-layout-tab' : undefined"
>
<VueUiXy
ref="chartRef"
:dataset="normalisedDataset"
:config="chartConfig"
:class="{
Expand Down
26 changes: 26 additions & 0 deletions app/composables/useChartTooltipPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* This composable returns a dynamic position to be fed to vue-data-ui components configugration for the `tooltip.position` attribute. Use it to position tooltips to the right or left side to free the view for datapoints, typically on line charts.
*/
import { computed, toValue } from 'vue'
import { useMouseInElement } from '@vueuse/core'

type TooltipPosition = 'left' | 'right' | 'center'
type TemplateRefValue = HTMLElement | { $el?: HTMLElement } | null | undefined

export function useChartTooltipPosition(
chartRef: MaybeRefOrGetter<TemplateRefValue>,
): ComputedRef<TooltipPosition> {
const target = computed<HTMLElement | null>(() => {
const value = toValue(chartRef)
if (!value) return null
if (value instanceof HTMLElement) return value
return value.$el || null
})

const { elementX, elementWidth, isOutside } = useMouseInElement(target)

return computed<TooltipPosition>(() => {
if (isOutside.value || elementWidth.value === 0) return 'center'
return elementX.value > elementWidth.value / 2 ? 'left' : 'right'
})
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"vite-plugin-pwa": "1.2.0",
"vite-plus": "0.1.16",
"vue": "3.5.34",
"vue-data-ui": "3.19.3",
"vue-data-ui": "3.19.4",
"vue-router": "5.0.4"
},
"devDependencies": {
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 122 additions & 0 deletions test/unit/app/composables/use-chart-tooltip-position.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { computed } from 'vue'
import { ref, shallowRef } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition'

const mouseState = vi.hoisted(() => ({
target: null as unknown,
}))

const elementX = ref(0)
const elementWidth = ref(0)
const isOutside = ref(true)

const MockHTMLElement = class {
public readonly nodeType = 1
}

vi.stubGlobal('HTMLElement', MockHTMLElement)

afterEach(() => {
vi.unstubAllGlobals()
})

vi.mock('@vueuse/core', () => ({
useMouseInElement: vi.fn(target => {
mouseState.target = target

return {
elementX,
elementWidth,
isOutside,
}
}),
}))

describe('useChartTooltipPosition', () => {
beforeEach(() => {
vi.stubGlobal('HTMLElement', MockHTMLElement)
elementX.value = 0
elementWidth.value = 0
isOutside.value = true
mouseState.target = null
})

it('returns center when the mouse is outside', () => {
const element = new MockHTMLElement() as HTMLElement
const position = useChartTooltipPosition(shallowRef(element))

isOutside.value = true
elementWidth.value = 100
elementX.value = 75

expect(position.value).toBe('center')
})

it('returns center when element width is 0', () => {
const element = new MockHTMLElement() as HTMLElement
const position = useChartTooltipPosition(shallowRef(element))
isOutside.value = false
elementWidth.value = 0
elementX.value = 75
expect(position.value).toBe('center')
})

it('returns left when the mouse is on the right half of the element', () => {
const element = new MockHTMLElement() as HTMLElement
const position = useChartTooltipPosition(shallowRef(element))
isOutside.value = false
elementWidth.value = 100
elementX.value = 51
expect(position.value).toBe('left')
})

it('returns right when the mouse is on the left half of the element', () => {
const element = new MockHTMLElement() as HTMLElement
const position = useChartTooltipPosition(shallowRef(element))
isOutside.value = false
elementWidth.value = 100
elementX.value = 49
expect(position.value).toBe('right')
})

it('returns right when the mouse is exactly at the center', () => {
const element = new MockHTMLElement() as HTMLElement
const position = useChartTooltipPosition(shallowRef(element))
isOutside.value = false
elementWidth.value = 100
elementX.value = 50
expect(position.value).toBe('right')
})

it('accepts a Vue component ref exposing $el', () => {
const element = new MockHTMLElement() as HTMLElement
const componentReference = shallowRef({ $el: element })
useChartTooltipPosition(componentReference)
expect((mouseState.target as ReturnType<typeof computed>).value).toBe(element)
})

it('returns null as target when ref value is null', () => {
useChartTooltipPosition(shallowRef(null))
expect((mouseState.target as ReturnType<typeof computed>).value).toBe(null)
})

it('returns null when component ref has no $el', () => {
const componentReference = shallowRef({})
useChartTooltipPosition(componentReference)
expect((mouseState.target as ReturnType<typeof computed>).value).toBe(null)
})

it('uses the HTMLElement directly as target', () => {
const element = new MockHTMLElement() as HTMLElement
useChartTooltipPosition(shallowRef(element))
expect((mouseState.target as ReturnType<typeof computed>).value).toBe(element)
})

it('uses the component $el as target', () => {
const element = new MockHTMLElement() as HTMLElement
const componentReference = shallowRef({ $el: element })
useChartTooltipPosition(componentReference)
expect((mouseState.target as ReturnType<typeof computed>).value).toBe(element)
})
})
Loading