diff --git a/core/core-frontend/src/models/chart/chart-attr.d.ts b/core/core-frontend/src/models/chart/chart-attr.d.ts index 50fd49a5d3..d1bbd37b84 100644 --- a/core/core-frontend/src/models/chart/chart-attr.d.ts +++ b/core/core-frontend/src/models/chart/chart-attr.d.ts @@ -858,6 +858,11 @@ declare interface ChartTooltipAttr { * 自定义显示内容 */ customContent?: string + + /** + * 轮播设置 + */ + carousel: CarouselAttr } /** @@ -1057,3 +1062,21 @@ declare interface ChartIndicatorNameStyle { */ nameValueSpacing: number } + +/** + * 轮播属性 + */ +declare interface CarouselAttr { + /** + * 是否启用 + */ + enable: boolean + /** + * 停留时间 秒 + */ + stayTime: number + /** + * 轮播间隔时间 秒 + */ + intervalTime: number +} diff --git a/core/core-frontend/src/views/chart/components/editor/editor-style/components/TooltipSelector.vue b/core/core-frontend/src/views/chart/components/editor/editor-style/components/TooltipSelector.vue index 2a54ab3a07..e973506d54 100644 --- a/core/core-frontend/src/views/chart/components/editor/editor-style/components/TooltipSelector.vue +++ b/core/core-frontend/src/views/chart/components/editor/editor-style/components/TooltipSelector.vue @@ -808,6 +808,57 @@ onMounted(() => { {{ t('chart.show_gap') }} + diff --git a/core/core-frontend/src/views/chart/components/editor/util/chart.ts b/core/core-frontend/src/views/chart/components/editor/util/chart.ts index 184b0edd7c..465c81107f 100644 --- a/core/core-frontend/src/views/chart/components/editor/util/chart.ts +++ b/core/core-frontend/src/views/chart/components/editor/util/chart.ts @@ -334,7 +334,12 @@ export const DEFAULT_TOOLTIP: ChartTooltipAttr = { color: '#909399', tooltipFormatter: formatterItem, backgroundColor: '#ffffff', - seriesTooltipFormatter: [] + seriesTooltipFormatter: [], + carousel: { + enable: false, + stayTime: 3, + intervalTime: 0 + } } export const DEFAULT_TABLE_TOTAL: ChartTableTotalAttr = { row: { diff --git a/core/core-frontend/src/views/chart/components/js/panel/charts/map/bubble-map.ts b/core/core-frontend/src/views/chart/components/js/panel/charts/map/bubble-map.ts index d00b9f9ea8..6907d8db58 100644 --- a/core/core-frontend/src/views/chart/components/js/panel/charts/map/bubble-map.ts +++ b/core/core-frontend/src/views/chart/components/js/panel/charts/map/bubble-map.ts @@ -21,6 +21,7 @@ import { } from '@/views/chart/components/js/panel/common/common_antv' import { valueFormatter } from '@/views/chart/components/js/formatter' import { deepCopy } from '@/utils/utils' +import { configCarouselTooltip } from '@/views/chart/components/js/panel/charts/map/tooltip-carousel' const { t } = useI18n() @@ -29,7 +30,10 @@ const { t } = useI18n() */ export class BubbleMap extends L7PlotChartView { properties: EditorProperty[] = [...MAP_EDITOR_PROPERTY, 'bubble-animate'] - propertyInner = MAP_EDITOR_PROPERTY_INNER + propertyInner = { + ...MAP_EDITOR_PROPERTY_INNER, + 'tooltip-selector': [...MAP_EDITOR_PROPERTY_INNER['tooltip-selector'], 'carousel'] + } axis = MAP_AXIS_TYPE axisConfig: AxisConfig = { xAxis: { @@ -100,7 +104,7 @@ export class BubbleMap extends L7PlotChartView { options = this.setupOptions(chart, options, context) const tooltip = deepCopy(options.tooltip) - options = { ...options, tooltip: false } + options = { ...options, tooltip: { ...tooltip, showComponent: false } } const view = new Choropleth(container, options) const dotLayer = this.getDotLayer(chart, geoJson, drawOption) dotLayer.options = { ...dotLayer.options, tooltip } @@ -128,6 +132,10 @@ export class BubbleMap extends L7PlotChartView { } }) }) + dotLayer.once('loaded', () => { + chart.container = container + configCarouselTooltip(chart, view, chart.data?.data || [], null) + }) }) return view } @@ -179,7 +187,7 @@ export class BubbleMap extends L7PlotChartView { opacity: 1 }, state: { - active: true + active: { color: 'rgba(30,90,255,1)' } }, tooltip: {} } diff --git a/core/core-frontend/src/views/chart/components/js/panel/charts/map/map.ts b/core/core-frontend/src/views/chart/components/js/panel/charts/map/map.ts index bd13c74e37..854ee7e06d 100644 --- a/core/core-frontend/src/views/chart/components/js/panel/charts/map/map.ts +++ b/core/core-frontend/src/views/chart/components/js/panel/charts/map/map.ts @@ -35,6 +35,7 @@ import { LIST_CLASS } from '@antv/l7plot-component/dist/esm/legend/category/constants' import substitute from '@antv/util/esm/substitute' +import { configCarouselTooltip } from '@/views/chart/components/js/panel/charts/map/tooltip-carousel' const { t } = useI18n() @@ -46,7 +47,8 @@ export class Map extends L7PlotChartView { propertyInner: EditorPropertyInner = { ...MAP_EDITOR_PROPERTY_INNER, 'basic-style-selector': ['colors', 'alpha', 'areaBorderColor', 'zoom', 'gradient-color'], - 'legend-selector': ['icon', 'fontSize', 'color'] + 'legend-selector': ['icon', 'fontSize', 'color'], + 'tooltip-selector': [...MAP_EDITOR_PROPERTY_INNER['tooltip-selector'], 'carousel'] } axis = MAP_AXIS_TYPE axisConfig: AxisConfig = { @@ -161,6 +163,8 @@ export class Map extends L7PlotChartView { } }) }) + chart.container = container + configCarouselTooltip(chart, view, data, null) }) return view } diff --git a/core/core-frontend/src/views/chart/components/js/panel/charts/map/symbolic-map.ts b/core/core-frontend/src/views/chart/components/js/panel/charts/map/symbolic-map.ts index abe9b6c031..da57787b44 100644 --- a/core/core-frontend/src/views/chart/components/js/panel/charts/map/symbolic-map.ts +++ b/core/core-frontend/src/views/chart/components/js/panel/charts/map/symbolic-map.ts @@ -13,6 +13,7 @@ import { Scene } from '@antv/l7-scene' import { PointLayer } from '@antv/l7-layers' import { LayerPopup } from '@antv/l7' import { mapRendered, mapRendering } from '@/views/chart/components/js/panel/common/common_antv' +import { configCarouselTooltip } from '@/views/chart/components/js/panel/charts/map/tooltip-carousel' const { t } = useI18n() /** @@ -36,7 +37,8 @@ export class SymbolicMap extends L7ChartView { 'showFields', 'customContent', 'show', - 'backgroundColor' + 'backgroundColor', + 'carousel' ] } axis: AxisType[] = ['xAxis', 'xAxisExt', 'extBubble', 'filter', 'extLabel', 'extTooltip'] @@ -102,6 +104,10 @@ export class SymbolicMap extends L7ChartView { } this.buildLabel(chart, configList) this.configZoomButton(chart, scene) + symbolicLayer.on('inited', ev => { + chart.container = container + configCarouselTooltip(chart, symbolicLayer, symbolicLayer.sourceOption.data, scene) + }) symbolicLayer.on('click', ev => { const data = ev.feature const dimensionList = [] @@ -251,10 +257,19 @@ export class SymbolicMap extends L7ChartView { ] } // 修改背景色 + const styleId = 'tooltip-' + container + const styleElement = document.getElementById(styleId) + if (styleElement) { + styleElement.remove() + styleElement.parentNode?.removeChild(styleElement) + } const style = document.createElement('style') + style.id = styleId style.innerHTML = ` #${container} .l7-popup-content { background-color: ${tooltip.backgroundColor} !important; + padding: 6px 10px 6px; + line-height: 1.6; } #${container} .l7-popup-tip { border-top-color: ${tooltip.backgroundColor} !important; @@ -299,8 +314,9 @@ export class SymbolicMap extends L7ChartView { }) } else { showFields.forEach(field => { - //const value = ${fieldData[field.split('@')[0]] as string - content += `${field.split('@')[1]}: ${fieldData[field.split('@')[0]]}
` + content += `${field.split('@')[1]}: ${ + fieldData[field.split('@')[0]] + }
` }) } return content diff --git a/core/core-frontend/src/views/chart/components/js/panel/charts/map/tooltip-carousel.ts b/core/core-frontend/src/views/chart/components/js/panel/charts/map/tooltip-carousel.ts new file mode 100644 index 0000000000..c70615e297 --- /dev/null +++ b/core/core-frontend/src/views/chart/components/js/panel/charts/map/tooltip-carousel.ts @@ -0,0 +1,568 @@ +import { Popup } from '@antv/l7' +import { Plot } from '@antv/l7plot/dist/lib/core/plot' +import isEmpty from 'lodash-es/isEmpty' +import { valueFormatter } from '@/views/chart/components/js/formatter' +import { parseJson } from '@/views/chart/components/js/util' +import { Scene } from '@antv/l7-scene' +import { deepCopy } from '@/utils/utils' + +export const configCarouselTooltip = (chart, view, data, scene) => { + if (carouselManagerInstances[chart.container]) { + const instances = carouselManagerInstances[chart.container] + instances.update(scene, chart, view, data) + } else { + new CarouselManager(scene, chart, view, data) + } +} +export const carouselManagerInstances: { [key: string]: CarouselManager } = {} + +/** + * 轮播管理类 + */ +export class CarouselManager { + /** + * 停留时长定时器 + * @private + */ + private popupTimeoutId: number | null = null + /** + * 轮播间隔定时器 + * @private + */ + private popupIntervalId: number | null = null + /** + * 是否暂停轮播 + * @private + */ + private isPaused = false + /** + * 当前显示的数据索引 + * @private + */ + private currentIndex = 0 + /** + * 地图实例,气泡地图用 + * @private + */ + private scene: Scene + private chart: Chart + /** + * 轮播弹窗的位置数据 + * @private + */ + private view: Plot + private data: any[] + /** + * 停留时长 + * @private + */ + private stayTime: number + /** + * 轮播间隔 + * @private + */ + private intervalTime: number + /** + * 轮播弹窗 + * @private + */ + private popup: Popup + + // 保存事件监听函数的引用 + private onMouseEnterHandler: () => void + private onMouseLeaveHandler: () => void + private onVisibilityChangeHandler: () => void + + constructor(scene, chart, view, data: any[]) { + // 绑定事件处理函数 + this.onMouseEnterHandler = this.pauseCarouselPopups.bind(this) + this.onMouseLeaveHandler = this.resumeCarouselPopups.bind(this) + this.onVisibilityChangeHandler = this.handleVisibilityChange.bind(this) + this.clearExistingTimers = this.clearExistingTimers.bind(this) + this.init(scene, chart, view, data) + } + + /** + * 更新轮播弹窗对象内容 + * @param scene + * @param chart + * @param view + * @param data + */ + public update(scene, chart, view, data: any[]) { + this.init(scene, chart, view, data) + } + + /** + * 初始化轮播弹窗 + * @param scene + * @param chart + * @param view + * @param data + * @private + */ + private init(scene, chart, view, data: any[]) { + this.view = view + this.chart = chart + this.scene = scene + this.data = data + this.popup = null + this.currentIndex = 0 + this.clearPreviousInstance(this.chart.container) + if ( + this.chart.customAttr?.tooltip?.show && + this.chart.customAttr?.tooltip?.carousel?.enable && + this.data.length > 0 + ) { + this.popup = new Popup({ closeButton: false, maxWidth: 600 }) + const carousel = this.chart.customAttr?.tooltip?.carousel + this.stayTime = carousel.stayTime * 1000 + this.intervalTime = carousel.intervalTime * 1000 + this.startCarouselPopups() + const divElement = document.getElementById(this.chart.container) + divElement.addEventListener('mouseenter', this.pauseCarouselPopups) + divElement.addEventListener('mouseleave', this.resumeCarouselPopups) + // 监听页面可见性变化 + document.addEventListener('visibilitychange', this.handleVisibilityChange) + carouselManagerInstances[this.chart.container] = this + } + } + + private handleVisibilityChange = (): void => { + if (document.hidden) { + this.clearPreviousInstance(this.chart.container) + } else { + this.startCarouselPopups() + } + } + + /** + * 清除之前的实例数据 + * @param containerId + * @private + */ + private clearPreviousInstance(containerId: string): void { + if (carouselManagerInstances[containerId]) { + const instance = carouselManagerInstances[containerId] + this.clearExistingTimers() + instance.popup?.remove() + instance.removeStyle() + } + } + + /** + * 开始轮播 + * @private + */ + private startCarouselPopups(): void { + this.clearExistingTimers() + this.carouselPopups() + } + + /** + * 鼠标移入暂停轮播 + */ + private pauseCarouselPopups = (): void => { + if (this.popup) { + this.popup?.remove() + } + this.removeStyle() + this.isPaused = true + this.clearExistingTimers() + } + + /** + * 鼠标移出开始轮播 + */ + private resumeCarouselPopups = (): void => { + if (this.isPaused) { + this.isPaused = false + this.startCarouselPopups() + } + } + + /** + * 管理轮播弹窗的显示 + * + * 此方法用于处理轮播弹窗的显示逻辑它会根据当前的索引显示对应的弹窗, + * 并在一定时间后自动移除当前弹窗并显示下一个弹窗 + * + * @private + */ + private carouselPopups(): void { + const showPopup = (index: number): void => { + this.removeStyle() + const containerElement = document.getElementById(this.chart.container) + if (containerElement) { + if (this.chart.type === 'symbolic-map') { + this.createSymbolicMapPopup(index) + } else { + this.createPopup(index) + } + this.clearExistingTimers() + this.popupTimeoutId = window.setTimeout(() => { + this.currentIndex++ + this.popup?.remove() + this.cancelHighlightLayer(index) + if (this.currentIndex >= this.data.length) { + this.currentIndex = 0 + } + this.popupIntervalId = window.setTimeout(() => { + showPopup(this.currentIndex) + }, this.intervalTime) + }, this.stayTime) + } else { + this.clearExistingTimers() + } + } + + showPopup(this.currentIndex) + } + + /** + * 清除定时器 + * @private + */ + private readonly clearExistingTimers = (): void => { + if (this.popupTimeoutId !== null) { + clearTimeout(this.popupTimeoutId) + this.popupTimeoutId = 0 + } + if (this.popupIntervalId !== null) { + clearInterval(this.popupIntervalId) + this.popupIntervalId = 0 + } + } + + /** + * 移除样式 + * 每次创建弹窗前移除之前的样式 + * @private + */ + private removeStyle(): void { + const styleToRemove = document.getElementById('style-' + this.chart.container) + if (styleToRemove) { + styleToRemove.remove() + styleToRemove.parentNode?.removeChild(styleToRemove) + } + } + + /** + * 创建弹窗信息 + * @param index + * @private + */ + private createPopup(index: number): void { + const tooltipStyle = this.view.tooltip.options.domStyles + const tooltipBackgroundColor = tooltipStyle['l7plot-tooltip']['background-color'] + const tooltipFontSize = tooltipStyle['l7plot-tooltip']['font-size'] + const style = document.createElement('style') + style.id = 'style-' + this.chart.container + style.innerHTML = ` + #${this.chart.container} .l7-popup-content { + background-color: ${tooltipBackgroundColor} !important; + font-size: ${tooltipFontSize}; + padding: 10px 10px 6px; + line-height: 1.6; + } + #${this.chart.container} .l7-popup-tip { + border-top-color: ${tooltipBackgroundColor} !important; + } + ` + document.head.appendChild(style) + + const popupData = this.getPopupData(index) + if (popupData.data) { + let tooltipItem = '' + this.getTooltipItems(popupData.data).forEach(fieldData => { + tooltipItem += ` +
  • + ${fieldData.name} + ${fieldData.value} +
  • ` + }) + + const html = ` +
    +
    ${popupData.data.name}
    +
      + ${tooltipItem} +
    +
    + ` + + this.popup.setLngLat({ lng: popupData.centroid[0], lat: popupData.centroid[1] }) + this.popup.setHTML(html) + this.popup.closeButton = false + this.view.addLayer(this.popup) + // 地图层高亮 + this.view.scene + .getLayers() + ?.find(i => i.name === 'highlightLayer') + ?.setData(this.getActiveData(index)) + if (this.chart.type === 'bubble-map') { + // 气泡地图高亮 + const { _id } = this.view.scene + .getLayers() + ?.find(i => i.name === 'bubbleLayer') + ?.layerSource.data.dataArray.find(i => i.name === this.data[index].name) + this.view.scene + .getLayers() + ?.find(i => i.name === 'bubbleLayer' && i.coordCenter) + ?.setActive(_id, { color: 'rgba(30,90,255,1)' }) + } + } + } + + private getActiveData(index): any { + return { + type: 'FeatureCollection', + features: [ + this.view.currentDistrictData.features.find( + i => i.properties.name === this.data[index].name + ) + ] + } + } + + /** + * 获取弹窗信息,包括原始数据及位置信息 + * @param index + * @private + */ + private getPopupData(index: number): any { + return { + data: this.data[index], + centroid: this.view.currentDistrictData.features.find( + i => i.properties.name === this.data[index].name + )?.properties.centroid + } + } + + /** + * 将对象转换为 CSS 属性 + * @param obj + * @private + */ + private objectToSemicolonSeparated(obj: any): string { + let result = '' + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + result += `${this.convertToSnakeCase(key)}:${obj[key]};` + } + } + return result + } + + private cancelHighlightLayer(index?: number): void { + this.view.scene + ?.getLayers() + ?.find(i => i.name === 'highlightLayer') + ?.setData({ type: 'FeatureCollection', features: [] }) + if (this.chart.type === 'bubble-map') { + const { _id } = this.view.scene + ?.getLayers() + ?.find(i => i.name === 'bubbleLayer') + ?.layerSource.data.dataArray.find(i => i.name === this.data[index].name) + this.view.scene + .getLayers() + ?.find(i => i.name === 'bubbleLayer' && i.coordCenter) + ?.setActive(_id, { + color: this.view.scene + .getLayers() + .find(i => i.name === 'bubbleLayer') + .styleAttributeService.getLayerStyleAttribute('color').scale.field + }) + } + if (this.chart.type === 'symbolic-map') { + const lngField = this.chart.xAxis[0].dataeaseName + const latField = this.chart.xAxis[1].dataeaseName + const { _id } = this.scene + ?.getLayers() + ?.find(i => i.type === 'PointLayer') + ?.layerSource.data.dataArray.find(i => { + const targetLng = this.data[index][lngField] + const targetLat = this.data[index][latField] + return i[lngField] === targetLng && i[latField] === targetLat + }) + this.scene + .getLayers() + ?.find(i => i.type === 'PointLayer' && i.coordCenter) + ?.setActive(_id, { + color: this.scene + .getLayers() + .find(i => i.type === 'PointLayer') + .styleAttributeService.getLayerStyleAttribute('color').scale.field + }) + } + } + + /** + * 将驼峰式命名转换为蛇形命名 + * @param str + * @private + */ + private convertToSnakeCase(str: string): string { + return str.replace(/([A-Z])/g, match => '-' + match.toLowerCase()) + } + + /** + * 获取弹窗字段信息 + * 与tooltip要显示的内容一致 + * @param data + * @private + */ + private getTooltipItems(data) { + const result = [] + const customAttr = parseJson(this.chart.customAttr) + const tooltip = customAttr.tooltip + const formatterMap = tooltip.seriesTooltipFormatter + ?.filter(i => i.show) + .reduce((pre, next) => { + pre[next.id] = next + return pre + }, {}) as Record + if (isEmpty(formatterMap)) { + return result + } + const head = data + const formatter = formatterMap[head.quotaList?.[0]?.id] + if (!isEmpty(formatter)) { + const originValue = parseFloat(head.value as string) + const value = valueFormatter(originValue, formatter.formatterCfg) + const name = isEmpty(formatter.chartShowName) ? formatter.name : formatter.chartShowName + result.push({ ...head, name, value: `${value ?? ''}` }) + } + head.dynamicTooltipValue?.forEach(item => { + const formatter = formatterMap[item.fieldId] + if (formatter) { + const value = valueFormatter(parseFloat(item.value), formatter.formatterCfg) + const name = isEmpty(formatter.chartShowName) ? formatter.name : formatter.chartShowName + result.push({ color: 'grey', name, value: `${value ?? ''}` }) + } + }) + return result + } + + /** + * 符号地图特殊处理,tooltip的配置可自定义显示内容 + * @param index + * @private + */ + private createSymbolicMapPopup(index): void { + const buildTooltip = () => { + const customAttr = this.chart.customAttr ? parseJson(this.chart.customAttr) : null + if (customAttr?.tooltip?.show) { + const { tooltip } = deepCopy(customAttr) + let showFields = tooltip.showFields || [] + if (!tooltip.showFields || tooltip.showFields.length === 0) { + showFields = [ + ...this.chart.xAxisExt.map(i => `${i.dataeaseName}@${i.name}`), + ...this.chart.xAxis.map(i => `${i.dataeaseName}@${i.name}`) + ] + } + const style = document.createElement('style') + style.id = 'style-' + this.chart.container + style.innerHTML = ` + #${this.chart.container} .l7-popup-content { + background-color: ${tooltip.backgroundColor} !important; + padding: 6px 10px 6px; + line-height: 1.6; + } + #${this.chart.container} .l7-popup-tip { + border-top-color: ${tooltip.backgroundColor} !important; + } + ` + document.head.appendChild(style) + const lngField = this.chart.xAxis[0].dataeaseName + const latField = this.chart.xAxis[1].dataeaseName + const htmlPrefix = `
    ` + const htmlSuffix = '
    ' + const data = this.view.sourceOption.data[index] + if (data && data.details?.length) { + const fieldData = { + ...data, + ...Object.fromEntries(mergeDetailsToMap(data.details)) + } + const content = buildTooltipContent(tooltip, fieldData, showFields) + const html = `${htmlPrefix}${content}${htmlSuffix}` + this.popup.setLngLat({ + lng: data[lngField], + lat: data[latField] + }) + this.popup.setHTML(html) + this.popup.closeButton = false + this.scene.addPopup(this.popup) + this.popup.addTo(this.scene) + const { _id } = this.scene + .getLayers() + ?.find(i => i.type === 'PointLayer') + ?.layerSource.data.dataArray.find(i => { + const targetLng = this.data[index][lngField] + const targetLat = this.data[index][latField] + return i[lngField] === targetLng && i[latField] === targetLat + }) + this.scene + .getLayers() + ?.find(i => i.type === 'PointLayer' && i.coordCenter) + ?.setActive(_id, { color: 'rgba(30,90,255,1)' }) + } + } + return undefined + } + + /** + * 构建 tooltip 内容 + * @param tooltip + * @param fieldData + * @param showFields + * @returns {string} + */ + const buildTooltipContent = (tooltip, fieldData, showFields) => { + let content = '' + if (tooltip.customContent) { + content = tooltip.customContent + showFields.forEach(field => { + content = content.replace(`\${${field.split('@')[1]}}`, fieldData[field.split('@')[0]]) + }) + } else { + showFields.forEach(field => { + content += `${field.split('@')[1]}: ${ + fieldData[field.split('@')[0]] + }
    ` + }) + } + return content + } + /** + * 合并详情到 map + * @param details + * @returns {Map} + */ + const mergeDetailsToMap = details => { + const resultMap = new Map() + details.forEach(item => { + Object.entries(item).forEach(([key, value]) => { + if (resultMap.has(key)) { + const existingValue = resultMap.get(key) + if (existingValue !== value) { + resultMap.set(key, `${existingValue}, ${value}`) + } + } else { + resultMap.set(key, value) + } + }) + }) + return resultMap + } + buildTooltip() + } +} diff --git a/core/core-frontend/src/views/chart/components/js/panel/common/common_antv.ts b/core/core-frontend/src/views/chart/components/js/panel/common/common_antv.ts index 46e21b8682..113b6755ce 100644 --- a/core/core-frontend/src/views/chart/components/js/panel/common/common_antv.ts +++ b/core/core-frontend/src/views/chart/components/js/panel/common/common_antv.ts @@ -893,7 +893,8 @@ export function configL7Tooltip(chart: Chart): TooltipOptions { domStyles: { 'l7plot-tooltip': { 'background-color': tooltip.backgroundColor, - 'font-size': `${tooltip.fontSize}px` + 'font-size': `${tooltip.fontSize}px`, + 'line-height': 1.6 }, 'l7plot-tooltip__name': { color: tooltip.color