fix(图表): g2plot提示轮播显示优化

This commit is contained in:
jianneng-fit2cloud
2025-04-06 13:02:56 +08:00
committed by jianneng-fit2cloud
parent 41f46e1300
commit 41c6ddf996
7 changed files with 334 additions and 177 deletions

View File

@@ -243,6 +243,7 @@ const unlock = () => {
const hideComponent = () => {
setTimeout(() => {
layerStore.hideComponent()
layerStore.pausedTooltipCarousel(curComponent.value.id)
snapshotStore.recordSnapshotCache('realTime-hideComponent')
})
}
@@ -250,6 +251,7 @@ const hideComponent = () => {
const showComponent = () => {
setTimeout(() => {
layerStore.showComponent()
layerStore.resumeTooltipCarousel(curComponent.value.id)
snapshotStore.recordSnapshotCache('showComponent')
})
}

View File

@@ -7,6 +7,7 @@
width="70vw"
trigger="click"
class="userViewEnlarge-class"
@close="handleClose"
>
<template #header v-if="!isIframe">
<div class="header-title">
@@ -182,6 +183,7 @@ import { getCanvasStyle } from '@/utils/style'
import { exportPermission } from '@/utils/utils'
import EmptyBackground from '../empty-background/src/EmptyBackground.vue'
import { supportExtremumChartType } from '@/views/chart/components/js/extremumUitl'
import ChartCarouselTooltip from '@/views/chart/components/js/g2plot_tooltip_carousel'
const downLoading = ref(false)
const dvMainStore = dvMainStoreWithOut()
const dialogShow = ref(false)
@@ -456,7 +458,9 @@ const htmlToImage = () => {
const initWatermark = () => {
activeWatermarkCheckUser('enlarge-inner-content', 'canvas-main', state.scale)
}
const handleClose = () => {
ChartCarouselTooltip.closeEnlargeDialogDestroy(viewInfo.value.id)
}
defineExpose({
dialogInit
})

View File

@@ -4,6 +4,7 @@ import { dvMainStoreWithOut } from './dvMain'
import { swap } from '@/utils/utils'
import { useEmitt } from '@/hooks/web/useEmitt'
import { getComponentById, getCurInfo } from '@/store/modules/data-visualization/common'
import ChartCarouselTooltip from '@/views/chart/components/js/g2plot_tooltip_carousel'
const dvMainStore = dvMainStoreWithOut()
const { curComponentIndex, curComponent } = storeToRefs(dvMainStore)
@@ -93,6 +94,20 @@ export const layerStore = defineStore('layer', {
}, 400)
}
}
},
pausedTooltipCarousel(componentId?) {
const targetComponent = getComponentById(componentId)
// 暂停轮播
if (targetComponent) {
ChartCarouselTooltip.paused(componentId)
}
},
resumeTooltipCarousel(componentId?) {
const targetComponent = getComponentById(componentId)
// 恢复轮播
if (targetComponent) {
ChartCarouselTooltip.resume(componentId)
}
}
}
})

View File

@@ -949,7 +949,8 @@ onMounted(() => {
:effect="themes"
controls-position="right"
size="middle"
:min="0"
precision="0"
:min="1"
:max="600"
:disabled="!state.tooltipForm.carousel.enable"
@change="changeTooltipAttr('carousel')"
@@ -968,7 +969,8 @@ onMounted(() => {
:effect="themes"
controls-position="right"
size="middle"
:min="0"
precision="0"
:min="1"
:max="600"
:disabled="!state.tooltipForm.carousel.enable"
@change="changeTooltipAttr('carousel')"

View File

@@ -3,22 +3,19 @@ import { DualAxes, Plot } from '@antv/g2plot'
/**
* 使用 Map 来存储实例,键为 chart.container 对象
*/
export const carouselManagerInstances = new Map<unknown, ChartCarouselTooltip>()
// 支持的图表类型集合
const SUPPORT_CHART_TYPES = new Set([
'line',
'area',
'area-stack',
'bar',
'bar-stack',
'bar-group',
'bar-group-stack',
'chart-mix',
'chart-mix-group',
'chart-mix-stack',
'chart-mix-dual-line',
'pie'
])
export const CAROUSEL_MANAGER_INSTANCES = new Map<string, ChartCarouselTooltip>()
/**
* 支持的图表类型
*/
const CHART_CATEGORY = {
COLUMN: ['bar', 'bar-stack', 'bar-group', 'bar-group-stack'],
LINE: ['line', 'area', 'area-stack'],
MIX: ['chart-mix', 'chart-mix-group', 'chart-mix-stack', 'chart-mix-dual-line'],
PIE: ['pie']
}
const isSupport = (chartType: string) => {
return Object.values(CHART_CATEGORY).some(category => category.includes(chartType))
}
// 轮播配置默认值
const DEFAULT_CAROUSEL_CONFIG: Required<CarouselConfig> = {
@@ -46,12 +43,12 @@ class ChartCarouselTooltip {
// 合并定时器管理
private timers = { interval: null, carousel: null }
private states = { paused: false, destroyed: false }
// 图表可视性变化
private observers: Map<Element, IntersectionObserver> = new Map()
// 滚动事件计时器
private scrollTimeout: number | null = null
// 图表元素大小变化
private resizeObservers: Map<Element, ResizeObserver> = new Map()
// 图表是否在可视范围内
private chartIsVisible: boolean
private tooltipContainer: HTMLElement
private constructor(plot: Plot | DualAxes, private chart: Chart, config: CarouselConfig) {
this.plot = plot
@@ -64,49 +61,117 @@ class ChartCarouselTooltip {
* */
static manage(plot: Plot | DualAxes, chart: Chart, config: CarouselConfig) {
const container = chart.container
const exists = carouselManagerInstances.get(container)
let instance = CAROUSEL_MANAGER_INSTANCES.get(container)
if (exists) {
// 切换到不支持的图表时
if (!SUPPORT_CHART_TYPES.has(chart.type)) {
this.destroyByContainer(container)
return null
CAROUSEL_MANAGER_INSTANCES.forEach((instance, _key) => {
if (container.includes('viewDialog')) {
instance.paused()
}
exists.update(plot, chart, config)
return exists
}
})
if (SUPPORT_CHART_TYPES.has(chart.type)) {
const instance = new this(plot, chart, config)
carouselManagerInstances.set(container, instance)
return instance
}
return null
}
/**
* 通过容器DOM销毁对应实例外部调用接口
* */
static destroyByContainer(container: string) {
const instance = carouselManagerInstances.get(container)
if (instance) {
instance.destroy()
// 弱引用会自动清除,这里显式删除确保及时清理
carouselManagerInstances.delete(container)
}
if (isSupport(chart.type)) {
instance = new this(plot, chart, config)
CAROUSEL_MANAGER_INSTANCES.set(container, instance)
}
return instance
}
/**
* 销毁实例
* @param container
*/
static destroyByContainer(container: string) {
const instance = CAROUSEL_MANAGER_INSTANCES.get(container)
if (instance) {
instance.destroy()
CAROUSEL_MANAGER_INSTANCES.delete(container)
}
}
/**
* 通过容器DOM获取对应实例(外部调用接口)
* 通过容器DOM获取对应实例
* */
static getInstanceByContainer(container: string) {
const instance = carouselManagerInstances.get(container)
const instance = CAROUSEL_MANAGER_INSTANCES.get(container)
if (instance) {
return instance
}
return null
}
/**
* 通过chart.id销毁对应实例
* 关闭放大图表弹窗,销毁对应实例
* 重启图表自身轮播
* */
static closeEnlargeDialogDestroy(id: string) {
// 首先,暂停并删除包含 'viewDialog' 的实例
CAROUSEL_MANAGER_INSTANCES?.forEach((instance, key) => {
if (instance.chart.id === id && instance.chart.container.includes('viewDialog')) {
CAROUSEL_MANAGER_INSTANCES.delete(key)
}
if (instance.chart.id === id) {
instance.resume()
}
})
// 然后,恢复
CAROUSEL_MANAGER_INSTANCES?.forEach(instance => {
instance.resume()
})
}
/**
* 判断是否为柱状图
* @param chartType
*/
static isColumn(chartType: string) {
return CHART_CATEGORY.COLUMN.includes(chartType)
}
/**
* 判断是否为折线图
* @param chartType
*/
static isLine(chartType: string) {
return CHART_CATEGORY.LINE.includes(chartType)
}
/**
* 判断是否为饼图
* @param chartType
*/
static isPie(chartType: string) {
return CHART_CATEGORY.PIE.includes(chartType)
}
/**
* 暂停轮播
* @param id
*/
static paused(id: string) {
CAROUSEL_MANAGER_INSTANCES?.forEach(instance => {
if (instance.chart.id === id) {
setTimeout(() => instance.paused(), 200)
}
})
}
/**
* @param id
*/
static resume(id: string) {
CAROUSEL_MANAGER_INSTANCES?.forEach(instance => {
if (instance.chart.id === id) {
instance.paused()
setTimeout(() => instance.resume(), 500)
}
})
}
/**
* 初始化核心逻辑
* */
@@ -149,54 +214,33 @@ class ChartCarouselTooltip {
this.currentIndex = 0
// 定义递归处理数据数组的函数
const processArray = () => {
// 设置定时器,等待 duration 毫秒后执行
this.timers.carousel = window.setTimeout(
async () => {
if (this.states.paused || this.states.destroyed) return
// 获取当前需要显示的值
const currentValue = this.values[this.currentIndex]
// 高亮当前数据点
this.highlightElement(currentValue)
// 计算 Tooltip 显示的位置
const point = this.calculatePosition(currentValue)
if (point) {
// 显示 Tooltip并设置其位置为顶部
this.plot.chart.showTooltip(point)
this.plot.chart.getController('tooltip').update()
}
// 更新索引,指向下一个数据点
this.currentIndex++
// 如果已经遍历完所有数据点
if (this.currentIndex >= this.values.length) {
// 等待当前提示显示完成
await new Promise(resolve => setTimeout(resolve, this.config.duration))
// 取消所有数据点的高亮状态
this.unHighlightPoint()
// 隐藏 Tooltip
this.hideTooltip()
// 清除当前定时器
clearTimeout(this.timers.carousel)
// 等待配置的轮播间隔
await new Promise(resolve => setTimeout(resolve, this.config.interval))
// 重置索引,重新开始轮播
this.currentIndex = 0
processArray()
} else {
// 如果未遍历完,继续处理下一个数据点
processArray()
}
},
this.currentIndex === 0 ? 0 : this.config.duration
)
if (this.states.paused || this.states.destroyed || !this.isElementFullyVisible()) return
// 获取当前需要显示的值
const currentValue = this.values[this.currentIndex]
// 计算 Tooltip 显示的位置
const point = this.calculatePosition(currentValue)
// 高亮当前数据点
this.highlightElement(currentValue)
if (point) {
// 显示 Tooltip,并设置其位置为顶部
this.plot.chart.showTooltip(point)
this.plot.chart.getController('tooltip').update()
}
// 更新索引,指向下一个数据点
this.currentIndex++
if (this.currentIndex > this.values.length) {
this.currentIndex = 0
this.hideTooltip()
this.timers.interval = setTimeout(() => processArray(), this.config.interval)
} else {
// 如果未遍历完,继续处理下一个数据点
this.timers.carousel = setTimeout(() => processArray(), this.config.duration)
}
}
// 开始处理数据数组
processArray()
}
// 启动嵌套定时器
this.debounce(() => {
this.stop()
startNestedTimers()
}, 300)()
this.stop()
startNestedTimers()
}
/**
@@ -210,6 +254,29 @@ class ChartCarouselTooltip {
)
}
/**
* 判断图表是否在可视范围内
* */
private isElementFullyVisible(): boolean {
// 全屏
const isFullscreen = document.fullscreenElement !== null
// 新页面或公共连接
const isNewPagePulicLink = document
.getElementById('enlarge-inner-content-' + this.chart.id)
?.getBoundingClientRect()
const isMobileEdit = document.getElementsByClassName('panel-mobile')?.length > 0
const isMobileList = document.getElementsByClassName('mobile-com-list')?.length > 0
if (isMobileList) {
return false
}
const rect = this.plot.chart.ele.getBoundingClientRect()
return (
rect.top >= (isFullscreen || isNewPagePulicLink || isMobileEdit ? 0 : 64) &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
)
}
/**
* 计算元素位置(核心定位逻辑)
* */
@@ -237,7 +304,9 @@ class ChartCarouselTooltip {
.elements.find(item => item.data.field === value && (item.model.x || item.model.y))?.model
}
})
return { x: [].concat(point?.x)?.[0], y: 60 }
// 处理柱状图和折线图,柱状图固定y轴位置
const y = CHART_CATEGORY.COLUMN.includes(this.chart.type) ? 0 : [].concat(point?.y)?.[0]
return { x: [].concat(point?.x)?.[0], y: y }
}
/**
@@ -278,6 +347,12 @@ class ChartCarouselTooltip {
private getDualAxesTooltipPosition(view, value: string) {
const xScale = view.getXScale()
const values = xScale.values
if (values.length < 2) {
const point = view
.getGeometries()?.[0]
.elements[view.getGeometries()?.[0].elements?.length - 1].getModel()
return point || { x: 0, y: 0 }
}
const [rangeStart, rangeEnd] = xScale.range
const totalMonths = values.length
const bandWidth = (rangeEnd - rangeStart) / totalMonths
@@ -328,78 +403,69 @@ class ChartCarouselTooltip {
* 绑定事件监听
* */
private bindEventListeners() {
// 用于监听在不同的浏览页面的滚动事件
const elementIds = ['de-canvas-canvas-main', 'preview-canvas-main', 'canvas-mark-line']
let deCanvasElement = null
for (const id of elementIds) {
deCanvasElement = document.getElementById(id)
if (deCanvasElement) break
// 定义图表元素ID前缀数组
// 图表在不同的显示页面可能有不同的ID前缀
const chartElementIds = ['enlarge-inner-content-', 'enlarge-inner-shape-']
let chartElement = null
// 查找图表元素
for (const idPrefix of chartElementIds) {
chartElement = document.getElementById(idPrefix + this.chart.id)
if (chartElement) break
}
if (!deCanvasElement) {
this.unHighlightPoint()
this.hideTooltip()
this.setPaused(true)
}
deCanvasElement?.addEventListener('scroll', this.handleScroll.bind(this))
const chartElement = document.getElementById(this.chart.container)
chartElement.addEventListener('mouseenter', () => {
this.unHighlightPoint()
this.hideTooltip()
this.setPaused(true)
// 绑定鼠标进入和离开事件
chartElement?.addEventListener('mouseenter', () => this.paused())
chartElement?.addEventListener('mouseleave', () => {
this.paused()
this.resume()
})
// 当鼠标离开 chart 时,检查状态
chartElement.addEventListener('mouseleave', () => {
if (deCanvasElement) {
this.setPaused(false)
// 定义鼠标滚轮事件处理函数
const handleMouseWheel = this.debounce(() => {
CAROUSEL_MANAGER_INSTANCES?.forEach(instance => {
instance.paused()
instance.resume()
})
}, 50)
// 获取目标元素,优先全屏预览
const targetDiv =
document.getElementById('de-preview-content') ||
document.getElementById('preview-canvas-main') ||
document.getElementById('dv-main-center') ||
document.getElementById('edit-canvas-main') ||
document.getElementById('canvas-mark-line') ||
document.getElementById('de-canvas-canvas-main')
// 绑定目标元素的事件
if (targetDiv) {
targetDiv.removeEventListener('wheel', handleMouseWheel)
targetDiv.addEventListener('wheel', handleMouseWheel)
}
// 页面可见性控制
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
CAROUSEL_MANAGER_INSTANCES?.forEach(instance => {
instance.paused()
})
} else if (this.chartIsVisible) {
CAROUSEL_MANAGER_INSTANCES?.forEach(instance => {
instance.resume()
})
}
})
if (deCanvasElement) {
// 页面可见性控制
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.unHighlightPoint()
this.hideTooltip()
this.setPaused(true)
} else if (this.chartIsVisible) {
this.setPaused(false)
}
})
// 元素可视性观察(交叉观察器)
this.setupIntersectionObserver()
}
}
private handleScroll() {
this.hideTooltip()
this.unHighlightPoint()
this.stop()
this.debounce(() => {
clearTimeout(this.scrollTimeout)
// 设置新的定时器,如果在 200 毫秒内没有新的滚动事件,就认为滚动停止
this.scrollTimeout = setTimeout(() => {
this.hideTooltip()
this.unHighlightPoint()
// 可视范围内才恢复轮播
if (this.chartIsVisible) {
this.setPaused(false)
}
}, 200)
}, 300)()
// 元素可视性观察(交叉观察器)
this.setupIntersectionObserver()
// 元素大小观察(大小观察器)
this.setupResizeObserver()
}
/**
* 设置暂停状态
* */
private setPaused(state: boolean) {
this.debounce(() => {
this.states.paused = state
state ? this.stop() : this.startCarousel()
this.plot.chart.render(true)
}, 300)()
this.states.paused = state
state ? this.stop() : this.startCarousel()
}
/**
* 设置交叉观察器
* */
@@ -412,23 +478,49 @@ class ChartCarouselTooltip {
entries => {
entries.forEach(entry => {
if (entry.intersectionRatio < 1) {
this.hideTooltip()
this.unHighlightPoint()
this.paused()
this.chartIsVisible = false
this.setPaused(true)
} else {
this.paused()
this.chartIsVisible = true
this.setPaused(false)
this.resume()
}
})
},
{ threshold: 1 }
{ threshold: [0, 0.1, 0.5, 1] }
)
)
this.observers.get(this.plot.chart.ele.id).observe(this.plot.chart.ele)
}
}
/**
* 设置元素大小观察器
* 当元素全部可见时
* 图表的最外层元素
* @private
*/
private setupResizeObserver() {
// 放大图表弹窗不需要监听
if (this.plot.chart.ele.id.includes('viewDialog')) return
// 创建防抖回调函数
const debouncedCallback = (entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
if (entry.target) {
this.debounce(() => {
this.paused()
this.resume()
}, 200)
}
}
}
// 监听元素大小, 发生变化时重新轮播
if (!this.resizeObservers.get(this.plot.chart.ele.id)) {
this.resizeObservers.set(this.plot.chart.ele.id, new ResizeObserver(debouncedCallback))
this.resizeObservers.get(this.plot.chart.ele.id).observe(this.plot.chart.ele)
}
}
/**
* 更新配置
* */

View File

@@ -306,7 +306,7 @@ export class StackArea extends Area {
propertyInner = {
...this['propertyInner'],
'label-selector': ['vPosition', 'fontSize', 'color', 'labelFormatter'],
'tooltip-selector': ['fontSize', 'color', 'tooltipFormatter', 'show']
'tooltip-selector': ['fontSize', 'color', 'tooltipFormatter', 'show', 'carousel']
}
axisConfig = {
...this['axisConfig'],

View File

@@ -1704,6 +1704,41 @@ function configCarouselTooltip(plot, chart) {
})
})
}
/**
* 计算 Tooltip 的位置
* @param {Chart} chart - 图表实例
* @param {boolean} isCarousel - 是否为轮播模式
* @param {object} tooltipCtl - Tooltip 控制器
* @param {HTMLElement} chartElement - 图表元素
* @param {Event} event - 事件对象
* @param {boolen} enlargeElement - 放大弹窗
* @returns {{x: number, y: number}} - 计算后的 x 和 y 坐标
*/
function calculateTooltipPosition(
chart,
isCarousel,
tooltipCtl,
chartElement,
event,
enlargeElement
) {
// 辅助函数: 根据不同图表类型计算 Tooltip 的y位置
const getTooltipY = () => {
return (
Number(chartElement.getBoundingClientRect().top) +
chartElement.getBoundingClientRect().height / 2
)
// return tooltipCtl.point.y + Number(chartElement.getBoundingClientRect().top)
}
if (isCarousel) {
return {
x: tooltipCtl.point.x + Number(chartElement.getBoundingClientRect().left),
y: getTooltipY()
}
} else {
return { x: event.clientX, y: event.clientY }
}
}
export function configPlotTooltipEvent<O extends PickOptions, P extends Plot<O>>(
chart: Chart,
plot: P
@@ -1714,10 +1749,16 @@ export function configPlotTooltipEvent<O extends PickOptions, P extends Plot<O>>
return
}
// 图表容器,用于计算 tooltip 的位置
// 编辑时
let chartElement = document.getElementById('shape-id-' + chart.id)
// 公共连接页面
chartElement = chartElement || document.getElementById('enlarge-inner-content-' + chart.id)
// 获取图表元素,优先顺序:放大 > 预览 > 公共连接页面 > 默认
const chartElement =
document.getElementById('container-viewDialog-' + chart.id + '-common') ||
document.getElementById('container-preview-' + chart.id + '-common') ||
document.getElementById('enlarge-inner-content-' + chart.id) ||
document.getElementById('shape-id-' + chart.id)
// 是否是放大弹窗
const enlargeElement = chartElement?.id.includes('viewDialog')
// 轮播时tooltip的zIndex
const carousel_zIndex = enlargeElement ? '9999' : '1002'
configCarouselTooltip(plot, chart)
// 鼠标可移入, 移入之后保持显示, 移出之后隐藏
plot.options.tooltip.container.addEventListener('mouseenter', e => {
@@ -1736,13 +1777,15 @@ export function configPlotTooltipEvent<O extends PickOptions, P extends Plot<O>>
return
}
const event = plot.chart.interactions.tooltip?.context?.event
// 是否时轮播模式
const isCarousel =
!event ||
event?.type === 'plot:leave' ||
['pie', 'pie-rose', 'pie-donut'].includes(chart.type)
chart.customAttr?.tooltip?.carousel &&
(!event || // 事件触发时使用event的client坐标
['plot:leave', 'plot:mouseleave'].includes(event?.type) || //鼠标离开时使用tooltipCtl.point
['pie', 'pie-rose', 'pie-donut'].includes(chart.type)) // 饼图时使用tooltipCtl.point
plot.options.tooltip.showMarkers = isCarousel ? true : false
const wrapperDom = document.getElementById(G2_TOOLTIP_WRAPPER)
wrapperDom.style.zIndex = isCarousel && wrapperDom ? '1' : '9999'
wrapperDom.style.zIndex = isCarousel && wrapperDom ? carousel_zIndex : '9999'
if (tooltipCtl.tooltip) {
// 处理视图放大后再关闭 tooltip 的 dom 被清除
const container = tooltipCtl.tooltip.cfg.container
@@ -1761,15 +1804,14 @@ export function configPlotTooltipEvent<O extends PickOptions, P extends Plot<O>>
plot.chart.getOptions().tooltip.follow = false
tooltipCtl.title = Math.random().toString()
// 当显示提示为事件触发时使用event的client坐标否则使用tooltipCtl.point 数据点的位置,在图表中,需要加上图表在绘制区的位置
const { x, y } = isCarousel
? {
x: tooltipCtl.point.x + Number(chartElement.getBoundingClientRect().left),
y:
60 +
Number(chartElement.getBoundingClientRect().top) +
Number(chartElement.style.height.split('px')[0]) / 2
}
: { x: event.clientX, y: event.clientY }
const { x, y } = calculateTooltipPosition(
chart,
isCarousel,
tooltipCtl,
chartElement,
event,
enlargeElement
)
plot.chart.getTheme().components.tooltip.x = x
plot.chart.getTheme().components.tooltip.y = y
})