feat(图表): 支持最值显示(基础折线图、面积图、基础柱状图、分组柱状图)

This commit is contained in:
jianneng-fit2cloud 2024-07-21 15:56:35 +08:00
parent afa95790ee
commit 2e7df7f195
6 changed files with 536 additions and 54 deletions

View File

@ -58,6 +58,13 @@ declare interface Chart {
aggregate?: boolean
plugin?: CustomPlugin
isPlugin: boolean
extremumValues?: Map<string, any>
filteredData?: any[]
container?: string
/**
* 针对不是序列字段的图表通过获取分类字段的值作为序列字段
*/
seriesFieldObjs?: any[]
}
declare type CustomAttr = DeepPartial<ChartAttr> | JSONString<DeepPartial<ChartAttr>>
declare type CustomStyle = DeepPartial<ChartStyle> | JSONString<DeepPartial<ChartStyle>>
@ -121,6 +128,10 @@ declare interface SeriesFormatter extends Axis {
* 轴类型
*/
axisType: string
/**
* 显示极值
*/
showExtremum?: boolean
}
declare interface Axis extends ChartViewField {

View File

@ -89,7 +89,8 @@ const initSeriesLabel = () => {
...next,
show: true,
color: COMPUTED_DEFAULT_LABEL.value.color,
fontSize: COMPUTED_DEFAULT_LABEL.value.fontSize
fontSize: COMPUTED_DEFAULT_LABEL.value.fontSize,
showExtremum: false
} as SeriesFormatter
if (seriesAxisMap[next.id]) {
tmp = {
@ -97,7 +98,8 @@ const initSeriesLabel = () => {
formatterCfg: seriesAxisMap[next.id].formatterCfg,
show: seriesAxisMap[next.id].show,
color: seriesAxisMap[next.id].color,
fontSize: seriesAxisMap[next.id].fontSize
fontSize: seriesAxisMap[next.id].fontSize,
showExtremum: seriesAxisMap[next.id].showExtremum
}
}
formatter.push(tmp)
@ -804,7 +806,7 @@ onMounted(() => {
v-model="curSeriesFormatter.show"
label="quota"
>
{{ t('chart.show') }}
{{ t('chart.label') + t('chart.show') }}
</el-checkbox>
</el-form-item>
@ -950,8 +952,34 @@ onMounted(() => {
/>
</el-form-item>
</div>
<el-form-item class="form-item form-item-checkbox" :class="'form-item-' + themes">
<el-checkbox
:effect="themes"
size="small"
@change="changeLabelAttr('seriesLabelFormatter')"
v-model="curSeriesFormatter.showExtremum"
label="quota"
>
{{ t('chart.show') }}最值
</el-checkbox>
</el-form-item>
</template>
</div>
<el-form-item
class="form-item form-item-checkbox"
:class="'form-item-' + themes"
v-if="['bar-group'].includes(chartType)"
>
<el-checkbox
:effect="themes"
size="small"
@change="changeLabelAttr('showExtremum')"
v-model="state.labelForm.showExtremum"
label="quota"
>
{{ t('chart.show') }}最值
</el-checkbox>
</el-form-item>
<el-form-item class="form-item" :class="'form-item-' + themes" v-show="showProperty('showGap')">
<el-checkbox
:effect="themes"

View File

@ -8,6 +8,8 @@ import {
flow,
hexColorToRGBA,
parseJson,
registerExtremumPointEvt,
setExtremumPosition,
setUpGroupSeriesColor,
setUpStackSeriesColor
} from '@/views/chart/components/js/util'
@ -107,7 +109,7 @@ export class Bar extends G2PlotChartView<ColumnOptions, Column> {
const newChart = new Column(container, options)
newChart.on('interval:click', action)
registerExtremumPointEvt(newChart, chart, options, container)
return newChart
}
@ -119,7 +121,7 @@ export class Bar extends G2PlotChartView<ColumnOptions, Column> {
label: false
}
}
const labelAttr = parseJson(chart.customAttr).label
const { label: labelAttr, basicStyle } = parseJson(chart.customAttr)
const formatterMap = labelAttr.seriesLabelFormatter?.reduce((pre, next) => {
pre[next.id] = next
return pre
@ -129,7 +131,7 @@ export class Bar extends G2PlotChartView<ColumnOptions, Column> {
const label = {
fields: [],
...tmpOptions.label,
formatter: (data: Datum) => {
formatter: (data: Datum, point) => {
if (!labelAttr.seriesLabelFormatter?.length) {
return data.value
}
@ -141,20 +143,33 @@ export class Bar extends G2PlotChartView<ColumnOptions, Column> {
return
}
const value = valueFormatter(data.value, labelCfg.formatterCfg)
const group = new G2PlotChartView.engine.Group({})
group.addShape({
type: 'text',
attrs: {
x: 0,
y: 0,
text: value,
textAlign: 'start',
textBaseline: 'top',
fontSize: labelCfg.fontSize,
fill: labelCfg.color
}
})
return group
const showLabel = setExtremumPosition(
data,
point,
chart,
labelCfg,
basicStyle.lineSymbolSize
)
const has = chart.filteredData?.filter(
item => JSON.stringify(item) === JSON.stringify(data)
)
if (has.length > 0 && showLabel) {
const group = new G2PlotChartView.engine.Group({})
group.addShape({
type: 'text',
attrs: {
x: 0,
y: 0,
text: value,
textAlign: 'start',
textBaseline: 'top',
fontSize: labelCfg.fontSize,
fill: labelCfg.color
}
})
return group
}
return null
}
}
return {
@ -334,6 +349,36 @@ export class GroupBar extends StackBar {
}
}
protected configLabel(chart: Chart, options: ColumnOptions): ColumnOptions {
const baseOptions = super.configLabel(chart, options)
if (!baseOptions.label) {
return baseOptions
}
const { label: labelAttr, basicStyle } = parseJson(chart.customAttr)
baseOptions.label.style.fill = labelAttr.color
const label = {
...baseOptions.label,
formatter: function (param: Datum, point) {
const showLabel = setExtremumPosition(
param,
point,
chart,
labelAttr,
basicStyle.lineSymbolSize
)
const has = chart.filteredData?.filter(
item => JSON.stringify(item) === JSON.stringify(param)
)
const value = valueFormatter(param.value, labelAttr.labelFormatter)
return has.length > 0 && showLabel ? value : null
}
}
return {
...baseOptions,
label
}
}
protected configColor(chart: Chart, options: ColumnOptions): ColumnOptions {
return this.configGroupColor(chart, options)
}

View File

@ -9,6 +9,8 @@ import {
flow,
hexColorToRGBA,
parseJson,
registerExtremumPointEvt,
setExtremumPosition,
setUpGroupSeriesColor,
setUpStackSeriesColor
} from '@/views/chart/components/js/util'
@ -113,7 +115,7 @@ export class Area extends G2PlotChartView<AreaOptions, G2Area> {
const newChart = new G2Area(container, options)
newChart.on('point:click', action)
registerExtremumPointEvt(newChart, chart, options, container)
return newChart
}
@ -125,7 +127,7 @@ export class Area extends G2PlotChartView<AreaOptions, G2Area> {
label: false
}
}
const labelAttr = parseJson(chart.customAttr).label
const { label: labelAttr, basicStyle } = parseJson(chart.customAttr)
const formatterMap = labelAttr.seriesLabelFormatter?.reduce((pre, next) => {
pre[next.id] = next
return pre
@ -134,7 +136,7 @@ export class Area extends G2PlotChartView<AreaOptions, G2Area> {
const label = {
fields: [],
...tmpOptions.label,
formatter: (data: Datum) => {
formatter: (data: Datum, point) => {
if (!labelAttr.seriesLabelFormatter?.length) {
return data.value
}
@ -146,20 +148,33 @@ export class Area extends G2PlotChartView<AreaOptions, G2Area> {
return
}
const value = valueFormatter(data.value, labelCfg.formatterCfg)
const group = new G2PlotChartView.engine.Group({})
group.addShape({
type: 'text',
attrs: {
x: 0,
y: -4,
text: value,
textAlign: 'start',
textBaseline: 'top',
fontSize: labelCfg.fontSize,
fill: labelCfg.color
}
})
return group
const showLabel = setExtremumPosition(
data,
point,
chart,
labelCfg,
basicStyle.lineSymbolSize
)
const has = chart.filteredData?.filter(
item => JSON.stringify(item) === JSON.stringify(data)
)
if (has?.length > 0 && showLabel) {
const group = new G2PlotChartView.engine.Group({})
group.addShape({
type: 'text',
attrs: {
x: 0,
y: 0,
text: value,
textAlign: 'start',
textBaseline: 'top',
fontSize: labelCfg.fontSize,
fill: labelCfg.color
}
})
return group
}
return null
}
}
return {

View File

@ -8,6 +8,8 @@ import {
flow,
hexColorToRGBA,
parseJson,
registerExtremumPointEvt,
setExtremumPosition,
setUpGroupSeriesColor
} from '@/views/chart/components/js/util'
import { cloneDeep, isEmpty } from 'lodash-es'
@ -107,7 +109,7 @@ export class Line extends G2PlotChartView<LineOptions, G2Line> {
const newChart = new G2Line(container, options)
newChart.on('point:click', action)
registerExtremumPointEvt(newChart, chart, options, container)
return newChart
}
@ -119,7 +121,7 @@ export class Line extends G2PlotChartView<LineOptions, G2Line> {
label: false
}
}
const labelAttr = parseJson(chart.customAttr).label
const { label: labelAttr, basicStyle } = parseJson(chart.customAttr)
const formatterMap = labelAttr.seriesLabelFormatter?.reduce((pre, next) => {
pre[next.id] = next
return pre
@ -130,7 +132,7 @@ export class Line extends G2PlotChartView<LineOptions, G2Line> {
...tmpOptions.label,
offsetY: -8,
layout: [{ type: 'hide-overlap' }, { type: 'limit-in-plot' }],
formatter: (data: Datum) => {
formatter: (data: Datum, point) => {
if (!labelAttr.seriesLabelFormatter?.length) {
return data.value
}
@ -142,20 +144,33 @@ export class Line extends G2PlotChartView<LineOptions, G2Line> {
return
}
const value = valueFormatter(data.value, labelCfg.formatterCfg)
const group = new G2PlotChartView.engine.Group({})
group.addShape({
type: 'text',
attrs: {
x: 0,
y: 0,
text: value,
textAlign: 'start',
textBaseline: 'top',
fontSize: labelCfg.fontSize,
fill: labelCfg.color
}
})
return group
const showLabel = setExtremumPosition(
data,
point,
chart,
labelCfg,
basicStyle.lineSymbolSize
)
const has = chart.filteredData?.filter(
item => JSON.stringify(item) === JSON.stringify(data)
)
if (has?.length > 0 && showLabel) {
const group = new G2PlotChartView.engine.Group({})
group.addShape({
type: 'text',
attrs: {
x: 0,
y: 0,
text: value,
textAlign: 'start',
textBaseline: 'top',
fontSize: labelCfg.fontSize,
fill: labelCfg.color
}
})
return group
}
return null
}
}
return {

View File

@ -12,6 +12,8 @@ import { ElMessage } from 'element-plus-secondary'
import { useI18n } from '@/hooks/web/useI18n'
import { useLinkStoreWithOut } from '@/store/modules/link'
import { useAppStoreWithOut } from '@/store/modules/app'
import { valueFormatter } from '@/views/chart/components/js/formatter'
import { deepCopy } from '@/utils/utils'
const appStore = useAppStoreWithOut()
const isDataEaseBi = computed(() => appStore.getIsDataEaseBi)
@ -883,3 +885,369 @@ export function setUpStackSeriesColor(
}
return result
}
/**
* 注册极值点事件处理函数
* 该函数用于在新建的图表上注册极值点显示的事件处理逻辑根据图表类型和配置数据处理极值点的显示
*
* @param newChart 新建的图表对象用于绑定事件
* @param chart 原有的图表对象用于存储处理后的数据和配置
* @param options 图表的配置选项包含数据和各种配置项
* @param container 图表的容器用于设置图表的容器
*/
export const registerExtremumPointEvt = (newChart, chart, options, container) => {
chart.container = container
const { label: labelAttr } = parseJson(chart.customAttr)
let seriesFields = []
// 针对不是序列字段的图表通过获取分类字段的值作为序列字段,在标签配置时使用
const seriesFieldObjs = []
// 分组柱状图这种字段分类的图表需要按照分类字段的值作为序列字段
if (['bar-group'].includes(chart.type)) {
const xAxisExt = chart.xAxisExt || []
seriesFields = [...new Set(options.data.map(item => item.category))]
if (xAxisExt.length === 0) {
seriesFields = ['@']
}
seriesFields.forEach(field => {
seriesFieldObjs.push({
dataeaseName:
xAxisExt.length === 0
? 'f_' + chart.xAxis[0].dataeaseName + '_' + field
: chart.xAxisExt[0]?.dataeaseName + '_' + field,
name: field,
showExtremum: labelAttr.showExtremum,
formatterCfg: labelAttr.labelFormatter
})
})
chart.seriesFieldObjs = seriesFieldObjs
} else {
seriesFields = chart.yAxis.map(item => item.name)
}
// 筛选数据区间默认所有数据
let filterDataRange = [0, options.data.length - 1]
const senior = parseJson(chart.senior)
// 高级配置了缩略轴按照缩略轴默认配置进行区间配置
if (senior.functionCfg) {
if (senior.functionCfg.sliderShow) {
const cfg = {
start: senior.functionCfg.sliderRange[0] / 100,
end: senior.functionCfg.sliderRange[1] / 100
}
const dataLength = options.data.length / seriesFields.length
// 使用round方法与antv 内置过滤数据方式一致否则会出现数据区间错误
const startIndex = Math.round(cfg.start * (dataLength - 1))
const endIndex = Math.round(cfg.end * (dataLength - 1))
filterDataRange = [startIndex, endIndex]
}
}
// 通过区间筛选的数据
const filteredData = []
// 如果是根据字段值分类的图表时并且没有子类别时
if (seriesFieldObjs.length > 0 && chart.xAxisExt[0]) {
// 按照字段值分类维度聚合数据
const fieldGroupList = options.data.reduce((groups, item) => {
const field = item.field
if (!groups[field]) {
groups[field] = []
}
groups[field].push(item)
return groups
}, {})
// 需要重新计算数据区间因为数据区间是根据字段值分类维度聚合的数据
if (senior.functionCfg) {
if (senior.functionCfg.sliderShow) {
const cfg = {
start: senior.functionCfg.sliderRange[0] / 100,
end: senior.functionCfg.sliderRange[1] / 100
}
const dataLength = Object.keys(fieldGroupList).length
const startIndex = Math.round(cfg.start * (dataLength - 1))
const endIndex = Math.round(cfg.end * (dataLength - 1))
filterDataRange = [startIndex, endIndex]
Object.keys(fieldGroupList)
.slice(filterDataRange[0], filterDataRange[1] + 1)
.forEach(field => {
filteredData.push(...fieldGroupList[field])
})
}
}
} else {
seriesFields.forEach(field => {
const seriesFieldData = options.data.filter(
item => (item.category === '' ? '@' : item.category) === field
)
filteredData.push(...seriesFieldData.slice(filterDataRange[0], filterDataRange[1] + 1))
})
}
chart.filteredData = filteredData
if (options.legend) {
newChart.on('legend-item:click', ev => {
hideExtremumPoint(ev, chart)
})
}
if (options.slider) {
newChart.once('slider:valuechanged', _ev => {
newChart.on('beforerender', ev => {
sliderHandleExtremumPoint(ev, chart, options)
})
})
}
configExtremum(chart)
}
/**
* 创建极值point
* @param key
* @param value
* @param formatterCfg
* @param chartId
*/
const createExtremumPointDiv = (key, value, formatterCfg, chartId) => {
const id = key.split('@')[1] + '_' + value
const parentElement = document.getElementById(chartId)
if (parentElement) {
const element = document.getElementById(id)
if (!element) {
const div = document.createElement('div')
div.id = id
div.setAttribute(
'style',
`width: auto;
height: auto;
border-radius: 2px;
position: relative;
padding: 2px 5px 2px 5px;
display:none;
transform: translateX(-50%);
white-space:nowrap;`
)
div.textContent = valueFormatter(value, formatterCfg)
const span = document.createElement('span')
span.setAttribute(
'style',
`display: block;
width: 0px;
height: 0px;
border: 4px solid transparent;
border-top-color: red;
position: absolute;
left: calc(50% - 4px);
margin-top:2px;`
)
div.appendChild(span)
parentElement.appendChild(div)
}
}
}
/**
* 根据序列字段以及数据获取极值
* @param seriesLabelFormatter
* @param data
* @param chartId
*/
export const getExtremumValues = (seriesLabelFormatter, data, chartId) => {
const extremumValues = new Map()
seriesLabelFormatter.forEach((item: any) => {
if (!data.length || !item.showExtremum) return
const filteredData = data.filter(d => (d.category == '' ? '@' : d.category) === item.name)
const maxValue = Math.max(...filteredData.map(d => d.value))
const minValue = Math.min(...filteredData.map(d => d.value))
const maxObj = filteredData.find(d => d.value === maxValue)
const minObj = filteredData.find(d => d.value === minValue)
extremumValues.set(item.name + '@' + item.dataeaseName + '_' + chartId, {
cfg: item.formatterCfg,
value: [maxObj, minObj]
})
})
return extremumValues
}
/**
* 配置极值点dom
* @param chart
*/
export const configExtremum = (chart: Chart) => {
let customAttr: DeepPartial<ChartAttr>
// 清除图表标注
const pointElement = document.getElementById('point_' + chart.id)
if (pointElement) {
pointElement.remove()
pointElement.parentNode?.removeChild(pointElement)
}
if (chart.customAttr) {
customAttr = parseJson(chart.customAttr)
// label
if (customAttr.label?.show) {
const label = customAttr.label
let seriesLabelFormatter = []
if (chart.seriesFieldObjs && chart.seriesFieldObjs.length > 0) {
seriesLabelFormatter = chart.seriesFieldObjs
} else {
seriesLabelFormatter = deepCopy(label.seriesLabelFormatter)
}
if (seriesLabelFormatter.length > 0) {
chart.extremumValues = getExtremumValues(
seriesLabelFormatter,
chart.filteredData && chart.filteredData.length > 0
? chart.filteredData
: chart.data.data,
chart.id
)
// 创建标注父元素
const divParent = document.createElement('div')
divParent.id = 'point_' + chart.id
divParent.style.position = 'fixed'
divParent.style.zIndex = '1'
const containerElement = document.getElementById(chart.container)
containerElement.insertBefore(divParent, containerElement.firstChild)
chart.extremumValues?.forEach((value, key) => {
value.value?.forEach(extremumValue => {
if (extremumValue) {
createExtremumPointDiv(key, extremumValue.value, value.cfg, 'point_' + chart.id)
}
})
})
}
}
}
}
/**
* 设置极值位置,并返回是否显示原始标签值
* @param data 数据点数据
* @param point 数据点信息
* @param chart
* @param labelCfg 标签样式
* @param pointSize 数据点大小
*/
export const setExtremumPosition = (data, point, chart, labelCfg, pointSize) => {
if (chart.extremumValues) {
v: for (const [key, value] of chart.extremumValues.entries()) {
for (let i = 0; i < value.value.length; i++) {
if (
value.value[i] &&
data.category === value.value[i].category &&
data.value === value.value[i].value
) {
const id = key.split('@')[1] + '_' + data.value
const element = document.getElementById(id)
if (element) {
element.style.position = 'absolute'
element.style.top =
(point.y[1] ? point.y[1] : point.y) -
(labelCfg.fontSize + (pointSize ? pointSize : 0) + 8) +
'px'
element.style.left = point.x + 'px'
element.style.zIndex = '10'
element.style.fontSize = labelCfg.fontSize + 'px'
element.style.lineHeight = labelCfg.fontSize + 'px'
element.style.backgroundColor = point.color
element.style.color = labelCfg.color
element.children[0]['style'].borderTopColor = point.color
element.style.display = 'table'
return false
}
}
}
}
}
return true
}
/**
* 隐藏图表中的极端数据点
* 根据图例的选中状态动态隐藏或显示图表中对应数据点的详细信息div
* @param ev 图表的事件对象包含图例的选中状态
* @param chart 图表实例用于获取图表的配置和数据
*/
export const hideExtremumPoint = (ev, chart) => {
// 获取图例中被取消选中的项这些项对应的数据点将被隐藏
const hideLegendObj = ev.view
.getController('legend')
.components[0].component.cfg.items.filter(l => l.unchecked)
// 遍历图表数据对每个数据点进行处理
chart.data.data.forEach(item => {
// 根据图表的系列字段配置获取数据点对应的dataeaseName
let dataeaseName = ''
if (chart.seriesFieldObjs && chart.seriesFieldObjs.length > 0) {
dataeaseName = chart.seriesFieldObjs.find(
obj => obj.name === (item.category === '' ? item.field : item.category)
)?.dataeaseName
} else {
dataeaseName = chart.data.fields.find(
field => field.id === item.quotaList[0].id
)?.dataeaseName
}
// 根据数据点的信息生成唯一id用于查找对应的详细信息div
const divElementId = `${dataeaseName}_${chart.id}_${item.value}`
const divElement = document.getElementById(divElementId)
// 如果找到了对应的数据点详细信息div则根据图例的选中状态动态隐藏或显示该div
if (divElement) {
const shouldHide = hideLegendObj?.some(
hide => hide.id === (item.category === '' ? item.field : item.category)
)
divElement.style.display = shouldHide ? 'none' : 'table'
}
})
}
/**
* 根据滑动操作更新图表的显示数据
* 此函数用于处理滑动组件的操作事件根据滑动组件的位置动态更新图表显示的数据范围
* @param ev 滑动操作的事件对象包含当前滑动位置的信息
* @param chart 图表对象用于更新图表的显示数据
* @param options 滑动组件的配置选项包含滑动组件的初始数据等信息
*/
export const sliderHandleExtremumPoint = (ev, chart, options) => {
let seriesFields = []
// 如果chart中存在seriesFieldObjs且不为空则使用seriesFieldObjs中的name作为系列字段
// 否则使用yAxis中的name作为系列字段
if (chart.seriesFieldObjs && chart.seriesFieldObjs.length > 0) {
seriesFields = chart.seriesFieldObjs.map(item => item.name)
} else {
seriesFields = chart.yAxis.map(item => item.name)
}
// 筛选当前视图中已过滤的数据的类别并去重
const filteredDataSeriesFields = [
...new Set(ev.view.filteredData.map(({ category }) => category))
]
// 如果筛选后的数据类别的数量与系列字段的数量相等说明所有数据都被筛选出来了
// 此时直接使用视图中的过滤数据
if (filteredDataSeriesFields.length === seriesFields.length) {
chart.filteredData = ev.view.filteredData
} else {
// 否则找出当前筛选位置的起始和结束数据对象
// 获取筛选后的数据的起止索引
const objList = ev.view.filteredData.filter(
item => item.category === filteredDataSeriesFields[0]
)
const startObj = objList[0]
const endObj = objList[objList.length - 1]
let start = 0
let end = 0
// 遍历options中的数据找到起始和结束索引
options.data
.filter(item => filteredDataSeriesFields[0] === item.category)
.forEach((item, index) => {
if (JSON.stringify(startObj) === JSON.stringify(item)) {
start = index
}
if (JSON.stringify(endObj) === JSON.stringify(item)) {
end = index
}
})
const filteredData = []
// 重新计算被隐藏的序列字段数据
seriesFields
.filter(field => !filteredDataSeriesFields.includes(field))
?.forEach(field => {
const seriesFieldData = options.data.filter(item => item.category === field)
filteredData.push(...seriesFieldData.slice(start, end + 1))
})
// 将筛选出的数据与当前视图中的过滤数据合并更新图表的显示数据
chart.filteredData = [...filteredData, ...ev.view.filteredData]
}
configExtremum(chart)
}