From 59643795c30f3e6a45da124efe03e7dfa1d91e5a Mon Sep 17 00:00:00 2001 From: jianneng-fit2cloud Date: Sun, 27 Apr 2025 18:04:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=9B=BE=E8=A1=A8):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AD=90=E5=BC=B9=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/panel/charts/g2/bar/bullet-graph.ts | 576 ++++++++++++++++++ .../js/panel/charts/g2/bar/common.ts | 84 +++ .../views/components/ChartComponentG2Plot.vue | 2 +- 3 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/bullet-graph.ts create mode 100644 core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/common.ts diff --git a/core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/bullet-graph.ts b/core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/bullet-graph.ts new file mode 100644 index 0000000000..0a57eb3d3c --- /dev/null +++ b/core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/bullet-graph.ts @@ -0,0 +1,576 @@ +import { Chart as G2Bullet } from '@antv/g2' +import { G2ChartView, G2DrawOptions } from '@/views/chart/components/js/panel/types/impl/g2' +import { + BAR_AXIS_TYPE, + BAR_EDITOR_PROPERTY, + BAR_EDITOR_PROPERTY_INNER +} from '@/views/chart/components/js/panel/charts/g2/bar/common' +import { useI18n } from '@/hooks/web/useI18n' +import { flow, parseJson } from '@/views/chart/components/js/util' +import { RuntimeOptions } from '@antv/g2/lib/api/runtime' +import { valueFormatter } from '@/views/chart/components/js/formatter' +import { getLineDash } from '@/views/chart/components/js/panel/common/common_antv' + +const { t } = useI18n() + +/** + * 子弹图 + */ +export class BulletGraph extends G2ChartView { + constructor() { + super('bullet-graph', []) + } + + axis: AxisType[] = [...BAR_AXIS_TYPE, 'yAxisExt', 'extBubble'] + axisConfig = { + ...this['axisConfig'], + xAxis: { name: `${t('chart.form_type')} / ${t('chart.dimension')}`, type: 'd', limit: 1 }, + yAxis: { name: `${t('chart.progress_current')} / ${t('chart.quota')}`, type: 'q', limit: 1 }, + yAxisExt: { name: `${t('chart.progress_target')} / ${t('chart.quota')}`, type: 'q', limit: 1 }, + extBubble: { + name: `${t('chart.range_bg')} / ${t('chart.quota')}`, + type: 'q', + allowEmpty: true, + limit: 1 + } + } + properties: EditorProperty[] = [ + ...BAR_EDITOR_PROPERTY.filter( + item => !['function-cfg', 'assist-line', 'threshold'].includes(item) + ), + 'bullet-graph-selector' + ] + propertyInner = { + 'basic-style-selector': ['radiusColumnBar', 'layout'], + 'label-selector': ['hPosition', 'fontSize', 'color', 'labelFormatter'], + 'tooltip-selector': ['fontSize', 'color', 'backgroundColor', 'seriesTooltipFormatter', 'show'], + 'x-axis-selector': [ + ...BAR_EDITOR_PROPERTY_INNER['x-axis-selector'].filter(item => item != 'position'), + 'showLengthLimit' + ], + 'y-axis-selector': [ + ...BAR_EDITOR_PROPERTY_INNER['y-axis-selector'].filter( + item => item !== 'axisValue' && item !== 'position' + ), + 'axisLabelFormatter' + ], + 'legend-selector': ['showRange', 'orient', 'fontSize', 'color', 'hPosition', 'vPosition'] + } + + async drawChart(drawOption: G2DrawOptions): Promise { + const { chart, container, action } = drawOption + if (!chart.data?.data?.length) return + const result = mergeBulletData(chart) + // 处理自定义区间 + const { bullet } = parseJson(chart.customAttr).misc + if (bullet.bar.ranges.showType === 'fixed') { + const customRange = bullet.bar.ranges.fixedRange?.map(item => item.fixedRangeValue) || [0] + result.forEach(item => (item.ranges = customRange)) + } else { + result.forEach(item => (item.ranges = item.originalRanges)) + } + // 处理自定义目标值 + if (bullet.bar.target.showType === 'fixed') { + const customTarget = bullet.bar.target.value || 0 + result.forEach(item => (item.target = customTarget)) + } else { + result.forEach(item => (item.target = item.originalTarget)) + } + const initOptions = { + container, + autoFit: true, + data: result + } + const options = this.setupOptions(chart, initOptions) + let newChart = null + const { Chart: BulletClass } = await import('@antv/g2') + newChart = new BulletClass(options) + newChart.on('element:click', ev => { + const pointData = ev?.data?.data + const dimensionList = options.data.find(item => item.title === pointData.title)?.dimensionList + const actionParams = { + x: ev.x, + y: ev.y, + data: { + data: { + ...pointData, + dimensionList + } + } + } + action(actionParams) + }) + return newChart + } + + protected configBasicStyle(chart: Chart, options: RuntimeOptions): RuntimeOptions { + const basicStyle = parseJson(chart.customAttr).basicStyle + const { radiusColumnBar, columnBarRightAngleRadius, layout } = basicStyle + // 获取所有 interval 类型的子元素 + const intervals = options.children?.filter(item => item.type === 'interval') || [] + const intervalCount = intervals.length + // 如果需要设置圆角,计算圆角值 + const radiusValue = + radiusColumnBar === 'roundAngle' || radiusColumnBar === 'topRoundAngle' + ? columnBarRightAngleRadius + : 0 + // 更新每个 interval 的样式 + intervals.forEach((item, index) => { + const updateStyle = (styleUpdates: Record) => { + item.style = { ...item.style, ...styleUpdates } + } + // 根据 radiusColumnBar 的值和索引位置,设置不同的圆角样式 + if (radiusColumnBar === 'topRoundAngle') { + // 顶部圆角 + if (index === intervalCount - 2) { + // 倒数第二个柱子(固定区间背景的最后一个) + updateStyle({ radiusTopLeft: radiusValue, radiusTopRight: radiusValue }) + } + if (index === intervalCount - 1) { + // 最后一个柱子(实际值) + updateStyle({ radiusTopLeft: radiusValue, radiusTopRight: radiusValue }) + } + } else if (radiusColumnBar === 'roundAngle') { + // 四角圆角 + if (index === 0) { + // 第一个柱子 + updateStyle({ radiusBottomLeft: radiusValue, radiusBottomRight: radiusValue }) + } + if (index === intervalCount - 2) { + // 倒数第二个柱子(固定区间背景的最后一个) + updateStyle({ radiusTopLeft: radiusValue, radiusTopRight: radiusValue }) + } + if (index === intervalCount - 1) { + // 最后一个柱子 + updateStyle({ radiusBottomLeft: radiusValue, radiusBottomRight: radiusValue }) + updateStyle({ radiusTopLeft: radiusValue, radiusTopRight: radiusValue }) + } + } + }) + // 如果布局是水平的,转置坐标系 + if (layout === 'horizontal') { + options.coordinate = { transform: [{ type: 'transpose' }] } + } + return options + } + + protected configMisc(chart: Chart, options: RuntimeOptions): RuntimeOptions { + const { basicStyle } = parseJson(chart.customAttr) + const { bullet } = parseJson(chart.customAttr).misc + const isDynamic = bullet.bar.ranges.showType === 'dynamic' + // 背景颜色,固定区间背景时,按大小降序 + const rangeColor = isDynamic + ? chart.extBubble?.length + ? bullet.bar.ranges.fill + : [] + : bullet.bar.ranges.fixedRange + ?.sort((a, b) => (a.fixedRangeValue ?? 0) - (b.fixedRangeValue ?? 0)) + .map(item => item.fill) || [] + const childrens = [] + // 固定区间背景 + const ranges = bullet.bar.ranges.fixedRange || [] + ranges.sort((a, b) => (a.fixedRangeValue ?? 0) - (b.fixedRangeValue ?? 0)) + ranges.forEach((item, index) => { + // 用于配置区间边界, 存储当前区间的上一个区间 + const prev = ranges[index - 1] + const range = { + type: 'interval', + encode: { + x: 'title', + y: [prev ? prev.fixedRangeValue : 0, item.fixedRangeValue], + color: () => item.name + }, + interaction: { + legendFilter: false + }, + style: { + maxWidth: bullet.bar.ranges.size + }, + tooltip: false + } + childrens.push(range) + }) + if (isDynamic) { + childrens.length = 0 + if (chart.extBubble?.length) { + const rangeName = chart.extBubble[0]?.chartShowName || chart.extBubble[0]?.name + childrens.push({ + type: 'interval', + encode: { + x: 'title', + y: 'ranges', + color: () => rangeName + }, + interaction: { + legendFilter: false + }, + style: { + maxWidth: bullet.bar.ranges.size + }, + tooltip: false + }) + } + } + // 实际值与目标值 + const measureName = + chart.yAxis[0]?.chartShowName || bullet.bar.measures.name || chart.yAxis[0]?.name + const measures = { + type: 'interval', + encode: { + x: 'title', + y: 'measures', + color: () => measureName, + shape: 'rect' + }, + interaction: { + legendFilter: false + }, + style: { + maxWidth: bullet.bar.measures.size + }, + tooltip: { + title: d => d.title, + items: [{ channel: 'y' }] + } + } + const targetName = + chart.yAxisExt[0]?.chartShowName || bullet.bar.target.name || chart.yAxisExt[0]?.name + const target = { + type: 'point', + encode: { + x: 'title', + y: 'target', + color: () => targetName, + shape: basicStyle.layout === 'horizontal' ? 'line' : 'hyphen', + size: bullet.bar.target.size + }, + interaction: { + legendFilter: false + }, + tooltip: { + title: false, + items: [{ channel: 'y' }] + } + } + childrens.push(target) + childrens.push(measures) + options = { + ...options, + scale: { + color: { + range: [ + ...[].concat(rangeColor), + ...[].concat(bullet.bar.target.fill), + ...[].concat(bullet.bar.measures.fill) + ] + } + }, + children: childrens + } + return options + } + + protected configAxis(chart: Chart, options: RuntimeOptions): RuntimeOptions { + return { ...options, axis: { x: this.configXAxis(chart), y: this.configYAxis(chart) } } + } + + protected configXAxis(chart: Chart): any { + return this.getAxisConfig(chart, 'xAxis') + } + + protected configYAxis(chart: Chart): any { + return this.getAxisConfig(chart, 'yAxis') + } + + protected getAxisConfig(chart: Chart, axisType: string) { + const customStyle = parseJson(chart.customStyle) + const axis = JSON.parse(JSON.stringify(customStyle[axisType])) + if (customStyle[axisType] && axis.show) { + // 轴线 + const line = { + line: axis.axisLine.show, + lineLineWidth: axis.axisLine.lineStyle.width, + lineStroke: axis.axisLine.lineStyle.color, + lineLineDash: getLineDash(axis.axisLine.lineStyle.style) + } + // 刻度 + const tick = { + tick: axis.axisLine.show, + tickLineWidth: axis.axisLine.lineStyle.width, + tickStroke: axis.axisLine.lineStyle.color + } + // 网格线 + const grid = { + grid: axis.splitLine.show, + gridLineWidth: axis.splitLine.lineStyle.width, + gridStroke: axis.splitLine.lineStyle.color, + gridStrokeOpacity: 1, + gridLineDash: getLineDash(axis.splitLine.lineStyle.style) + } + // 标签(刻度值) + const label = { + label: axis.axisLabel.show, + labelFontSize: axis.axisLabel.fontSize, + labelFill: axis.axisLabel.color, + labelFormatter: value => { + if (axisType === 'yAxis') { + return valueFormatter(value, axis.axisLabelFormatter) + } + return value + } + } + // 刻度值旋转角度 + const labelTransform = { + type: 'rotate', + optionalAngles: [axis.axisLabel.rotate] + } + const x = { + // 标题 + title: axis.nameShow && axis.name ? axis.name : false, + titleFontSize: axis.fontSize, + titleFill: axis.color, + // 轴线 + ...line, + // 刻度线 + ...tick, + // 网格线 + ...grid, + // 刻度值 + ...label, + transform: [labelTransform] + } + return x + } + return false + } + + protected configLegend(chart: Chart, options: RuntimeOptions): RuntimeOptions { + const tmpOptions = { legend: this.getLegend(chart) } + if (!tmpOptions.legend) { + return { ...options, legend: false } + } + const { bullet } = parseJson(chart.customAttr).misc + const { ranges } = bullet.bar + const targetName = + chart.yAxisExt[0]?.chartShowName || bullet.bar.target.name || chart.yAxisExt[0]?.name + const baseLegend = tmpOptions.legend ? (tmpOptions.legend as any) : {} + const tmpLegend = { + color: { + ...baseLegend, + itemMarkerSize: ranges.symbolSize, + itemMarker: d => { + if (d === targetName) { + return 'line' + } + return ranges.symbol + } + } + } + return { ...options, legend: tmpLegend } + } + + protected getLegend = (chart: Chart) => { + let legend = {} + let customStyle: CustomStyle + if (chart.customStyle) { + customStyle = parseJson(chart.customStyle) + // legend + if (customStyle.legend) { + const l = JSON.parse(JSON.stringify(customStyle.legend)) + if (l.show) { + let position + const orient = l.orient + const legendSymbol = l.icon + const legendSize = l.size + const legendFontSize = l.fontSize + const legendColor = l.color + // position 图例布局 + // layoutJustifyContent 图例实例布局 + let layoutJustifyContent = 'center' + // 根据图例方向和位置设置布局和位置 + if (orient === 'horizontal') { + // 水平布局 + position = l.vPosition === 'center' ? 'bottom' : l.vPosition + layoutJustifyContent = + l.hPosition === 'left' && l.vPosition !== 'center' + ? 'flex-start' + : l.hPosition === 'right' && l.vPosition !== 'center' + ? 'flex-end' + : 'center' + } else { + // 垂直布局 + position = l.hPosition === 'center' ? 'left' : l.hPosition + layoutJustifyContent = + l.vPosition === 'top' && l.hPosition !== 'center' + ? 'flex-start' + : l.vPosition === 'bottom' && l.hPosition !== 'center' + ? 'flex-end' + : 'center' + } + legend = { + orientation: orient, + position, + layout: { + justifyContent: layoutJustifyContent + }, + itemMarker: legendSymbol, + itemMarkerSize: legendSize, + itemLabelFontSize: legendFontSize, + itemLabelFill: legendColor, + navPageNumFontSize: legendSize, + navPageNumFill: legendColor, + navButtonSize: legendSize, + navOrientation: position === 'left' || position === 'right' ? 'vertical' : 'horizontal' + } + } else { + legend = false + } + } + } + return legend + } + + protected configLabel(chart: Chart, options: RuntimeOptions): RuntimeOptions { + const customAttr = parseJson(chart.customAttr) + const { label: l } = customAttr + // 如果没有启用标签,直接返回 + if (!l || !l.show) { + return options + } + const basicStyle = parseJson(chart.customAttr).basicStyle + const { layout } = basicStyle + const position = { + position: l.position === 'middle' ? 'inside' : l.position, + textAlign: 'left', + dy: 0, + dx: 0 + } + if (layout !== 'horizontal') { + ;(position.position = + l.position === 'middle' ? 'inside' : l.position === 'left' ? 'bottom' : 'top'), + (position.textAlign = 'center') + position.dy = position.position === 'top' ? -10 : 0 + } + // contrastReverse 标签颜色在图形背景上对比度低的情况下,从指定色板选择一个对比度最优的颜色 + // overlapDodgeY 对位置碰撞的标签在 y 方向上进行调整,防止标签重叠 + // exceedAdjust 自动对标签做溢出检测和矫正,即当标签超出视图区域时,会对标签自动做反方向的位移 + // overlapHide 对位置碰撞的标签进行隐藏,默认保留前一个,隐藏后一个 + const transform = { + transform: [{ type: 'contrastReverse' }, { type: 'exceedAdjust' }, { type: 'overlapHide' }] + } + // 配置标签样式 + const label = { + text: 'measures', + fill: l.color, + fontSize: l.fontSize, + ...position, + formatter: d => valueFormatter(d, l.labelFormatter), + ...(l.fullDisplay ? {} : transform) + } + // 将标签配置应用到最后一个子元素(实际值) + options.children?.forEach((item, index) => { + if (index === options.children.length - 1) { + item.labels = [label] + } + }) + return options + } + + protected configTooltip(_chart: Chart, options: RuntimeOptions): RuntimeOptions { + return { + ...options, + interaction: { + tooltip: { + shared: true, + enterable: true + } + } + } + } + + setupDefaultOptions(chart: ChartObj): ChartObj { + chart.customAttr.label.position = 'middle' + chart.customStyle.yAxis.splitLine.show = false + chart.customAttr.misc.bullet.bar.ranges.symbolSize = 8 + chart.customAttr.misc.bullet.bar.target.symbolSize = 8 + chart.customAttr.misc.bullet.bar.measures.symbolSize = 8 + chart.customAttr.misc.bullet.bar.target.size = 8 + chart.customAttr.misc.bullet.bar.measures.symbol = 'square' + chart.customAttr.misc.bullet.bar.ranges.symbol = 'square' + return super.setupDefaultOptions(chart) + } + protected setupOptions(chart: Chart, options: RuntimeOptions): RuntimeOptions { + return flow( + this.configMisc, + this.configBasicStyle, + this.configAxis, + this.configLabel, + this.configLegend, + this.configTooltip + )(chart, options, {}, this) + } +} + +/** + * 组装子弹图数据 + * @param chart + */ +function mergeBulletData(chart): any[] { + // 先根据维度分组,再根据指标字段组装成子弹图的格式 + const groupedData = chart.data.data.reduce((acc, item) => { + const field = item.field + if (!acc[field]) { + acc[field] = [] + } + acc[field].push(item) + return acc + }, {}) + const result = [] + // 组装子弹图数据,每个维度对应一个子弹图 + Object.keys(groupedData).forEach(field => { + const items = groupedData[field] + // 初始化子弹图条目结构 + const entry = { + title: field, + ranges: [], + measures: [], + target: [], + dimensionList: items[0].dimensionList, + quotaList: [] + } + + // 防止指标相同时无数据有可能会导致数据不一致 + items.forEach(item => { + const quotaId = item.quotaList[0]?.id + const v = item.value || 0 + if (quotaId === chart.yAxis[0]?.id) { + entry.measures.push(v) + } + if (quotaId === chart.yAxisExt[0]?.id) { + entry.target.push(v) + } + if (quotaId === chart.extBubble[0]?.id) { + entry.ranges.push(v) + } + entry.quotaList.push(item.quotaList[0]) + }) + // 对数据进行累加 + const ranges = chart.extBubble[0]?.id + ? [].concat(entry.ranges?.reduce((acc, curr) => acc + curr, 0)) + : [] + const target = [].concat(entry.target?.reduce((acc, curr) => acc + curr, 0)) + const measures = [].concat(entry.measures?.reduce((acc, curr) => acc + curr, 0)) + const bulletData = { + ...entry, + measures: measures, + target: target, + ranges: ranges, + quotaList: [...entry.quotaList], + minRanges: ranges, + originalRanges: ranges, + originalTarget: target + } + result.push(bulletData) + }) + return result +} diff --git a/core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/common.ts b/core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/common.ts new file mode 100644 index 0000000000..6421d1860b --- /dev/null +++ b/core/core-frontend/src/views/chart/components/js/panel/charts/g2/bar/common.ts @@ -0,0 +1,84 @@ +export const BAR_EDITOR_PROPERTY: EditorProperty[] = [ + 'background-overall-component', + 'border-style', + 'basic-style-selector', + 'label-selector', + 'tooltip-selector', + 'x-axis-selector', + 'y-axis-selector', + 'title-selector', + 'legend-selector', + 'function-cfg', + 'assist-line', + 'jump-set', + 'linkage', + 'threshold' +] +export const BAR_RANGE_EDITOR_PROPERTY: EditorProperty[] = [ + 'background-overall-component', + 'border-style', + 'basic-style-selector', + 'label-selector', + 'tooltip-selector', + 'x-axis-selector', + 'y-axis-selector', + 'title-selector', + 'legend-selector', + 'function-cfg', + 'jump-set', + 'linkage', + 'threshold' +] + +export const BAR_EDITOR_PROPERTY_INNER: EditorPropertyInner = { + 'background-overall-component': ['all'], + 'border-style': ['all'], + 'basic-style-selector': ['colors', 'alpha', 'gradient', 'radiusColumnBar', 'columnWidthRatio'], + 'label-selector': ['fontSize', 'color', 'labelFormatter'], + 'tooltip-selector': ['fontSize', 'color', 'tooltipFormatter', 'show'], + 'x-axis-selector': [ + 'name', + 'color', + 'fontSize', + 'axisLine', + 'splitLine', + 'axisForm', + 'axisLabel', + 'position' + ], + 'y-axis-selector': [ + 'name', + 'color', + 'fontSize', + 'axisValue', + 'axisLine', + 'splitLine', + 'axisForm', + 'axisLabel', + 'position' + ], + 'title-selector': [ + 'title', + 'fontSize', + 'color', + 'hPosition', + 'isItalic', + 'isBolder', + 'remarkShow', + 'fontFamily', + 'letterSpace', + 'fontShadow' + ], + 'legend-selector': ['icon', 'orient', 'fontSize', 'color', 'hPosition', 'vPosition'], + 'function-cfg': ['slider', 'emptyDataStrategy'], + threshold: ['lineThreshold'] +} + +export const BAR_AXIS_TYPE: AxisType[] = [ + 'xAxis', + 'yAxis', + 'filter', + 'drill', + 'extLabel', + 'extTooltip' +] diff --git a/core/core-frontend/src/views/chart/components/views/components/ChartComponentG2Plot.vue b/core/core-frontend/src/views/chart/components/views/components/ChartComponentG2Plot.vue index 3e2ba9ca6f..4dfb1dbdf2 100644 --- a/core/core-frontend/src/views/chart/components/views/components/ChartComponentG2Plot.vue +++ b/core/core-frontend/src/views/chart/components/views/components/ChartComponentG2Plot.vue @@ -357,7 +357,7 @@ const renderG2 = async (chart, chartView: G2PlotChartView) => { myChart = await chartView.drawChart({ chartObj: myChart, container: containerId, - chart: chart, + chart: { ...chart, container: containerId }, scale: 1, action, quadrantDefaultBaseline