feat(chart): 支持辅助线跟随绑定指标显隐

This commit is contained in:
xuwei-fit2cloud
2026-09-30 15:04:39 +08:00
parent 44d0149b15
commit 5b24da72e5
9 changed files with 191 additions and 1 deletions

View File

@@ -1302,6 +1302,8 @@ export default {
load_data: 'Load Data'
},
chart: {
assist_bind_field: 'Bind measure (optional)',
assist_follow_field: 'Follow field visibility',
heatmap_range: 'Color scale range',
heatmap_range_auto: 'Automatic range',
heatmap_range_custom: 'Custom range',

View File

@@ -1247,6 +1247,8 @@ export default {
load_data: '載入資料'
},
chart: {
assist_bind_field: '綁定指標(可選)',
assist_follow_field: '跟隨欄位顯示',
heatmap_range: '色階範圍',
heatmap_range_auto: '自動極值',
heatmap_range_custom: '自訂範圍',

View File

@@ -1265,6 +1265,8 @@ export default {
load_data: '加载数据'
},
chart: {
assist_bind_field: '绑定指标(可选)',
assist_follow_field: '跟随字段显示',
heatmap_range: '色阶范围',
heatmap_range_auto: '自动极值',
heatmap_range_custom: '自定义范围',

View File

@@ -87,6 +87,10 @@ declare interface ChartAssistLineCfg {
* 辅助线
*/
declare interface AssistLine {
/** 跟随绑定指标的图例显隐;历史配置缺省为不跟随 */
followField?: boolean
/** 固定值辅助线的可选绑定指标,不参与数值计算 */
boundFieldId?: string
/**
* 辅助线名称
*/

View File

@@ -44,6 +44,8 @@ const state = reactive({
name: t('chart.assist_line'),
field: '0', // 固定值
fieldId: '',
boundFieldId: '',
followField: true,
summary: 'avg',
axis: 'y', // 主轴
yAxisType: 'left',
@@ -121,6 +123,7 @@ const removeLine = index => {
}
const changeYAxisType = item => {
item.boundFieldId = ''
if (props.useQuotaExt && item.yAxisType === 'right') {
item.fieldId = props.quotaExtFields ? props.quotaExtFields[0]?.id : null
item.curField = getQuotaExtField(item.fieldId)
@@ -328,6 +331,31 @@ onMounted(() => {
</el-icon>
</div>
</el-col>
<el-col :span="24" class="binding-row">
<el-select
v-if="item.field === '0'"
v-model="item.boundFieldId"
clearable
:placeholder="t('chart.assist_bind_field')"
@change="changeAssistLine"
>
<el-option
v-for="quota in useQuotaExt && item.yAxisType === 'right'
? quotaExtFields
: quotaFields"
:key="quota.id"
:label="quota.name"
:value="quota.id"
/>
</el-select>
<el-checkbox
v-model="item.followField"
:disabled="item.field === '0' && !item.boundFieldId"
@change="changeAssistLine"
>
{{ t('chart.assist_follow_field') }}
</el-checkbox>
</el-col>
</el-row>
</div>
<el-button class="circle-button" text style="margin-left: 5px" @click="addLine">
@@ -340,6 +368,16 @@ onMounted(() => {
</template>
<style lang="less" scoped>
.binding-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
.ed-select {
width: 220px;
}
}
.line-item {
width: 100%;
border-radius: 6px;

View File

@@ -710,7 +710,8 @@ export class Bar extends G2ChartView<ViewSpec, G2Column> {
type: 'lineY',
// 辅助线统一退出分类图例,避免后续新增视觉编码时影响图例布局
legend: false,
data: [value],
data: [{ ...item, value }],
encode: { y: 'value' },
style: {
stroke: item.color,
...ASSIST_LINE_STYLE,

View File

@@ -0,0 +1,108 @@
import type { Chart as G2Chart } from '@antv/g2'
import { parseJson } from '../../../util'
/** 指标通过 ID 绑定,图例通过实际数据的 category 映射,兼容别名和子维度。 */
export const createAssistLineVisibility = (chart: Chart) => {
const lines: AssistLine[] = parseJson(chart.senior)?.assistLineCfg?.assistLine ?? []
const series = new Map<string, Set<string>>()
const collect = (data: Record<string, any>[], fields: Axis[], axis: string) => {
data?.forEach(row => {
if (Array.isArray(row.data)) collect(row.data, fields, axis)
if (row.category === undefined || row.category === null) return
const quotas = row.quotaList?.length ? row.quotaList : fields?.length === 1 ? fields : []
quotas?.forEach(field => {
const id = `${axis}:${field.id}`
if (!series.has(id)) series.set(id, new Set())
series.get(id).add(String(row.category))
})
})
}
collect(chart.data?.data, chart.yAxis, 'left')
collect(chart.data?.left?.data, chart.yAxis, 'left')
collect(chart.data?.right?.data, chart.yAxisExt, 'right')
const fields = new Set([
...(chart.yAxis ?? []).map(f => `left:${f.id}`),
...(chart.yAxisExt ?? []).map(f => `right:${f.id}`)
])
let selected: Set<string> | undefined
return {
select(values?: unknown[]) {
selected = values === undefined ? undefined : new Set(values.map(String))
},
visible(datum: AssistLine) {
// 动态计算响应不必携带新配置;显隐始终从持久化的辅助线配置读取。
const line =
datum.field === '0'
? datum
: lines.find(
item =>
item.field === datum.field &&
item.name === datum.name &&
item.color === datum.color &&
item.lineType === datum.lineType &&
item.fieldId === datum.fieldId &&
(item.yAxisType ?? 'left') === (datum.yAxisType ?? 'left') &&
(item.field === '0' || item.summary === datum.summary)
)
if (!line?.followField) return true
const id = line.field === '1' ? line.fieldId : line.boundFieldId
if (!id) return true
// 已绑定的指标被移除后不将其阈值误用于其它指标;用户可清空绑定恢复常显。
if (!fields.has(`${line.yAxisType ?? 'left'}:${id}`)) return false
if (!selected) return true
const categories = series.get(`${line.yAxisType ?? 'left'}:${id}`)
// 无法可靠映射时不使用名称猜测,避免隐藏无关辅助线。
return !categories?.size || [...categories].some(category => selected.has(category))
}
}
}
/** 在首次 render 前接入,与原有轴域裁剪共同决定辅助线及其标签的可见性。 */
export const installG2AssistLineVisibility = (instance: G2Chart, chart: Chart) => {
const lines: AssistLine[] = parseJson(chart.senior)?.assistLineCfg?.assistLine ?? []
if (!lines.some(line => line.followField)) return () => undefined
const state = createAssistLineVisibility(chart)
const visit = spec => {
if (spec.type === 'lineY' && Array.isArray(spec.data) && spec.data[0]?.name !== undefined) {
const previous = spec.style?.visibility
spec.style = {
...spec.style,
visibility: (datum, ...args) =>
state.visible(datum)
? typeof previous === 'function'
? previous(datum, ...args)
: previous ?? 'visible'
: 'hidden'
}
spec.animate = { ...spec.animate, update: { type: null } }
}
if (Array.isArray(spec.children)) spec.children.forEach(visit)
}
const options = instance.options()
visit(options)
instance.options(options)
const filter = event => {
if (event?.data?.channel !== 'color' || !Array.isArray(event.data.values)) return
state.select(event.data.values)
// G2 原生图例在完成筛选后才发布事件;重放一次筛选使辅助线使用最新状态。
// 平铺图例的程序化事件发生在筛选前,无需重放。
if (event.nativeEvent) instance.emit('legend:filter', { data: event.data })
}
const focus = event => {
if (event?.data?.channel !== 'color') return
state.select([event.data.value])
if (event.nativeEvent) instance.emit('legend:focus', { data: event.data })
}
const reset = event => {
state.select()
if (event?.nativeEvent) instance.emit('legend:reset', {})
}
instance.on('legend:filter', filter)
instance.on('legend:focus', focus)
instance.on('legend:reset', reset)
return () => {
instance.off('legend:filter', filter)
instance.off('legend:focus', focus)
instance.off('legend:reset', reset)
}
}

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { installG2AssistLineVisibility } from '@/views/chart/components/js/panel/types/impl/g2-assist-line'
import {
applyG2TiledLegend,
installG2TiledLegendStateAdapter,
@@ -620,6 +621,7 @@ const renderChart = async (view, callback?) => {
let myChart = null
let g2Timer: number
let g2SliderCleanup: (() => void) | undefined
let assistLineCleanup: (() => void) | undefined
let g2TiledLegendCleanup: (() => void) | undefined
let g2LegendPaginationCleanup: (() => void) | undefined
const clearG2SliderAdapter = () => {
@@ -627,6 +629,8 @@ const clearG2SliderAdapter = () => {
g2SliderCleanup = undefined
}
const clearG2LegendPaginationAdapter = () => {
assistLineCleanup?.()
assistLineCleanup = undefined
g2TiledLegendCleanup?.()
g2TiledLegendCleanup = undefined
g2LegendPaginationCleanup?.()
@@ -736,6 +740,7 @@ const renderG2 = async (chart, chartView: G2ChartView<any, any>) => {
if (chart.customStyle?.legend?.displayMode === 'tile') {
g2TiledLegendCleanup = installG2TiledLegendStateAdapter(chartInstance)
}
assistLineCleanup = installG2AssistLineVisibility(chartInstance, chart)
// 等待 G2 完成包含轴边界校正的最终布局
await chartInstance?.render()
installG2SvgCoordinateScaleAdapter(chartInstance)

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from '../../core/core-frontend/node_modules/typescript/lib/typescript.js'
const source = readFileSync(new URL('../../core/core-frontend/src/views/chart/components/js/panel/types/impl/g2-assist-line.ts', import.meta.url), 'utf8').replace("import { parseJson } from '../../../util'", 'const parseJson = value => typeof value === "string" ? JSON.parse(value) : value')
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText
const { createAssistLineVisibility } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
const fixed = {name:'limit', field:'0', boundFieldId:'1', followField:true}
const dynamic = {name:'average',field:'1',fieldId:'1',summary:'avg',followField:true}
const chart = { senior:{assistLineCfg:{assistLine:[fixed,dynamic]}}, yAxis:[{id:'1'},{id:'2'}],data:{data:[{category:'renamed-A',quotaList:[{id:'1'}]},{category:'renamed-B',quotaList:[{id:'1'}]},{category:'other',quotaList:[{id:'2'}]}]} }
const s = createAssistLineVisibility(chart)
assert.equal(s.visible(fixed),true)
s.select(['renamed-A','other']);assert.equal(s.visible(fixed),true)
s.select(['other']);assert.equal(s.visible(fixed),false)
assert.equal(s.visible({...dynamic,followField:undefined,value:'20'}),false)
assert.equal(s.visible({...fixed,followField:false}),true)
assert.equal(s.visible({...fixed,followField:undefined}),true)
assert.equal(s.visible({...fixed,boundFieldId:''}),true)
assert.equal(s.visible({...fixed,boundFieldId:'removed'}),false)
s.select([]);assert.equal(s.visible(fixed),false)
s.select();assert.equal(s.visible(fixed),true)
assert.equal(s.visible(dynamic),true)
const dual = createAssistLineVisibility({...chart, yAxisExt:[{id:'1'}], data:{left:{data:[{category:'left',quotaList:[{id:'1'}]}]},right:{data:[{category:'right',quotaList:[{id:'1'}]}]}}})
dual.select(['left']);assert.equal(dual.visible({...fixed,yAxisType:'right'}),false)
assert.equal(dual.visible({...fixed,yAxisType:'left'}),true)
const nested = createAssistLineVisibility({...chart,data:{left:{data:[{data:[{category:'amount',quotaList:[{id:'1'}]}]}]}}})
nested.select(['other']);assert.equal(nested.visible(fixed),false)
nested.select(['amount']);assert.equal(nested.visible(fixed),true)
console.log('PASS: 15 auxiliary-line visibility assertions')