feat(chart): 支持地图图例数值格式化及分界点编辑

This commit is contained in:
xuwei-fit2cloud
2026-09-30 16:34:29 +08:00
parent f89bfbf6a3
commit abc04c9584
14 changed files with 623 additions and 148 deletions

View File

@@ -1314,6 +1314,17 @@ export default {
heatmap_range_invalid: 'Enter finite numbers with minimum less than maximum',
heatmap_min: 'Minimum',
heatmap_max: 'Maximum',
map_boundary: 'Boundary',
map_boundary_add: 'Add boundary',
map_boundary_delete: 'Delete boundary',
map_range_count_auto: 'Automatic',
map_boundary_invalid: 'Boundaries must be finite numbers in strictly increasing order.',
map_boundary_cannot_insert: 'Cannot insert. Check the boundaries or increase the gap.',
map_boundary_hint: 'Up to 9 ranges. The minimum and maximum cannot be deleted.',
map_format_legacy: 'Original format (integer)',
map_format_sync_tooltip: 'Use legend format for tooltip metric',
map_range_label: 'Range label',
map_range_label_auto: 'Leave blank to show numeric range',
heatmap_format: 'Legend number format',
heatmap_format_inherit: 'Follow measure',
heatmap_format_custom: 'Custom format',

View File

@@ -1259,6 +1259,17 @@ export default {
heatmap_range_invalid: '最小值必須小於最大值,且均為有效數值',
heatmap_min: '最小值',
heatmap_max: '最大值',
map_boundary: '分界點',
map_boundary_add: '新增分界點',
map_boundary_delete: '刪除分界點',
map_range_count_auto: '自動計算',
map_boundary_invalid: '邊界必須是有效數值,且嚴格遞增。',
map_boundary_cannot_insert: '無法插入,請檢查邊界數值或增大間隔。',
map_boundary_hint: '最多 9 個區間,最小值和最大值不可刪除。',
map_format_legacy: '原始格式(整數)',
map_format_sync_tooltip: '提示框指標同步圖例格式',
map_range_label: '區間顯示名稱',
map_range_label_auto: '留空顯示數值範圍',
heatmap_format: '圖例數值格式',
heatmap_format_inherit: '跟隨指標',
heatmap_format_custom: '自訂格式',

View File

@@ -1277,6 +1277,17 @@ export default {
heatmap_range_invalid: '最小值必须小于最大值,且均为有效数值',
heatmap_min: '最小值',
heatmap_max: '最大值',
map_boundary: '分界点',
map_boundary_add: '添加分界点',
map_boundary_delete: '删除分界点',
map_range_count_auto: '自动计算',
map_boundary_invalid: '边界必须是有效数值,且严格递增。',
map_boundary_cannot_insert: '无法插入,请检查边界数值或增大间隔。',
map_boundary_hint: '最多 9 个区间,最小值和最大值不可删除。',
map_format_legacy: '原始格式(整数)',
map_format_sync_tooltip: '提示框指标同步图例格式',
map_range_label: '区间显示名称',
map_range_label_auto: '留空显示数值范围',
heatmap_format: '图例数值格式',
heatmap_format_inherit: '跟随指标',
heatmap_format_custom: '自定义格式',

View File

@@ -106,7 +106,16 @@ declare interface HeatmapLegendOptions {
prefix?: string
}
declare interface MapLegendOptions {
formatMode?: 'legacy' | 'inherit' | 'custom'
formatterCfg?: BaseFormatter
prefix?: string
syncTooltip?: boolean
rangeLabels?: Record<string, string>
}
declare interface ChartLegendStyle {
map?: MapLegendOptions
heatmap?: HeatmapLegendOptions
displayMode?: 'pagination' | 'tile'
tileOverflow?: 'scroll' | 'adaptive'

View File

@@ -20,6 +20,9 @@ import CustomSortEdit from '@/views/chart/components/editor/drag-item/components
import { dvMainStoreWithOut } from '@/store/modules/data-visualization/dvMain'
import { storeToRefs } from 'pinia'
import chartViewManager from '@/views/chart/components/js/panel'
import { mapRangeKey } from '@/views/chart/components/js/panel/charts/map/mapLegend'
import MapRangeEditor from './MapRangeEditor.vue'
import MapLegendEditor from './MapLegendEditor.vue'
import HeatmapLegendEditor from './HeatmapLegendEditor.vue'
const dvMainStore = dvMainStoreWithOut()
const { batchOptStatus } = storeToRefs(dvMainStore)
@@ -121,6 +124,11 @@ const changeHeatmapLegend = (value: HeatmapLegendOptions) => {
changeLegendStyle('heatmap')
}
const changeMapLegend = (value: MapLegendOptions) => {
state.legendForm.map = value
changeLegendStyle('map')
}
const changeMisc = prop => {
// 仅对子弹图区间图例字段做合并保护,避免覆盖 fixedRange/showType。
if (typeof prop === 'string' && prop.startsWith('bullet.')) {
@@ -163,103 +171,60 @@ const init = () => {
}
}
}
// 存储地图默认的最大最小值
const mapLegendDefaultRange = {
max: 0,
min: 0
}
// 缓存原始的区间数据
let mapLegendCustomRangeCacheList = []
const showProperty = prop => props.propertyInner?.includes(prop)
const mapDefaultRange = args => {
if (args.from === 'map') {
const rangeCustom = state.legendForm.miscForm.mapLegendRangeType === 'custom'
if (!rangeCustom) {
state.legendForm.miscForm.mapLegendMax = cloneDeep(args.data.max)
state.legendForm.miscForm.mapLegendMin = cloneDeep(args.data.min)
}
state.legendForm.miscForm.mapLegendNumber = cloneDeep(args.data.legendNumber)
mapLegendCustomRangeCacheList = []
mapLegendDefaultRange.max = cloneDeep(args.data.max)
mapLegendDefaultRange.min = cloneDeep(args.data.min)
const customRange = getDynamicColorScale(
mapLegendDefaultRange.min,
mapLegendDefaultRange.max,
args.data.legendNumber
)
customRange.forEach((item, index) => {
if (index === 0) {
mapLegendCustomRangeCacheList.push(...item.value)
} else {
mapLegendCustomRangeCacheList.push(item.value[1])
}
})
if (args.from !== 'map' || String(args.chartId) !== String(props.chart.id)) return
if (state.legendForm.miscForm.mapLegendRangeType !== 'custom') {
state.legendForm.miscForm.mapLegendMax = args.data.max
state.legendForm.miscForm.mapLegendMin = args.data.min
}
}
const initMapCustomRange = () => {
const legendCustom = state.legendForm.miscForm.mapAutoLegend
const rangeCustom = state.legendForm.miscForm.mapLegendRangeType === 'custom'
const rangeCustomValue = state.legendForm.miscForm.mapLegendCustomRange
// 是自定义,并且自定义类型是自定义区间以及rangeCustomValue长度为0时,根据默认最大最小值计算区间值
if (legendCustom && rangeCustom && rangeCustomValue.length === 0) {
calcMapCustomRange()
const misc = state.legendForm.miscForm
if (!misc.mapAutoLegend && misc.mapLegendRangeType === 'custom') {
if (misc.mapLegendCustomRange.length < 2) calcMapCustomRange()
misc.mapLegendNumber = misc.mapLegendCustomRange.length - 1
}
}
/**
* 计算自定义区间
* 最大最小值取等分区间的最大最小值
*/
const calcMapCustomRange = () => {
const customRange = getDynamicColorScale(
state.legendForm.miscForm.mapLegendMin,
state.legendForm.miscForm.mapLegendMax,
state.legendForm.miscForm.mapLegendNumber
)
state.legendForm.miscForm.mapLegendCustomRange = []
customRange.forEach((item, index) => {
if (index === 0) {
state.legendForm.miscForm.mapLegendCustomRange.push(...item.value)
} else {
state.legendForm.miscForm.mapLegendCustomRange.push(item.value[1])
}
})
const misc = state.legendForm.miscForm
const min = Number.isFinite(misc.mapLegendMin) ? misc.mapLegendMin : 0
const max =
Number.isFinite(misc.mapLegendMax) && misc.mapLegendMax > min ? misc.mapLegendMax : min + 1
const count = Math.min(9, Math.max(1, misc.mapLegendNumber || DEFAULT_MISC.mapLegendNumber))
const ranges = getDynamicColorScale(min, max, count)
misc.mapLegendCustomRange = [ranges[0].value[0], ...ranges.map(item => item.value[1])]
}
/**
* 改变自定义区间类型
* @param prop
*/
const changeLegendCustomType = (prop?) => {
const type = state.legendForm.miscForm.mapLegendRangeType
if (type === 'custom') {
calcMapCustomRange()
} else {
state.legendForm.miscForm.mapLegendCustomRange = []
}
// Retain the manual boundaries while another mode is active.
initMapCustomRange()
prop ? changeMisc(prop) : ''
}
/**
* 改变自定义区间个数
* @param prop
*/
const changeLegendNumber = (prop?) => {
if (!state.legendForm.miscForm.mapLegendNumber) {
state.legendForm.miscForm.mapLegendNumber = DEFAULT_MISC.mapLegendNumber
}
calcMapCustomRange()
prop ? changeMisc(prop) : ''
}
const changeRangeItem = (prop, index) => {
if (state.legendForm.miscForm.mapLegendCustomRange[index] === null) {
state.legendForm.miscForm.mapLegendCustomRange[index] = cloneDeep(
mapLegendCustomRangeCacheList[index]
const changeMapBoundaries = (values: number[]) => {
state.legendForm.miscForm.mapLegendCustomRange = values
state.legendForm.miscForm.mapLegendNumber = values.length - 1
const labels = state.legendForm.map?.rangeLabels
if (labels) {
const keys = new Set(
values.slice(0, -1).map((value, index) => mapRangeKey([value, values[index + 1]]))
)
const rangeLabels = Object.fromEntries(Object.entries(labels).filter(([key]) => keys.has(key)))
if (Object.keys(rangeLabels).length !== Object.keys(labels).length) {
state.legendForm.map = { ...state.legendForm.map, rangeLabels }
changeLegendStyle('map')
}
}
changeMisc(prop)
}
const getMapCustomRange = index => {
if (index === 0) return t('chart.min')
if (index === state.legendForm.miscForm.mapLegendNumber) return t('chart.max')
return ''
emit(
'onMiscChange',
{ data: state.legendForm.miscForm, requestData: false },
'mapLegendCustomRange'
)
}
const customSort = []
const changeLegendSort = sort => {
@@ -463,7 +428,7 @@ onMounted(() => {
:effect="themes"
v-model="state.legendForm.miscForm.mapAutoLegend"
:value="true"
@change="changeMisc('mapAutoLegend')"
@change="changeLegendCustomType('mapAutoLegend')"
style="width: 80px"
>
{{ t('chart.margin_model_auto') }}
@@ -473,7 +438,7 @@ onMounted(() => {
:effect="themes"
v-model="state.legendForm.miscForm.mapAutoLegend"
:value="false"
@change="changeMisc('mapAutoLegend')"
@change="changeLegendCustomType('mapAutoLegend')"
>
{{ t('chart.custom_case') }}
</el-radio>
@@ -511,7 +476,7 @@ onMounted(() => {
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-row v-if="state.legendForm.miscForm.mapLegendRangeType === 'quantize'">
<el-col>
<el-form-item
class="form-item"
@@ -532,33 +497,12 @@ onMounted(() => {
</el-form-item>
</el-col>
</el-row>
<div v-if="state.legendForm.miscForm.mapLegendRangeType === 'custom'">
<el-row
:gutter="8"
:key="index"
v-for="(_value, index) in state.legendForm.miscForm.mapLegendCustomRange"
>
<el-col :span="8">
<label class="ed-form-item__label text_ellipsis" :title="getMapCustomRange(index)">
{{ getMapCustomRange(index) }}
</label>
</el-col>
<el-col :span="16">
<el-form-item class="form-item" :class="'form-item-' + themes">
<el-input-number
:effect="themes"
v-model="state.legendForm.miscForm.mapLegendCustomRange[index]"
clearable
:value-on-clear="mapLegendCustomRangeCacheList[index]"
controls-position="right"
@change="changeRangeItem('mapLegendCustomRange', index)"
style="margin-bottom: 4px"
:step="1"
/>
</el-form-item>
</el-col>
</el-row>
</div>
<MapRangeEditor
v-if="state.legendForm.miscForm.mapLegendRangeType === 'custom'"
:model-value="state.legendForm.miscForm.mapLegendCustomRange"
:themes="themes"
@update:model-value="changeMapBoundaries"
/>
<el-row :gutter="8" v-if="state.legendForm.miscForm.mapLegendRangeType === 'quantize'">
<el-col :span="12">
<el-form-item
@@ -594,6 +538,13 @@ onMounted(() => {
</div>
</div>
</el-space>
<MapLegendEditor
v-if="chartType === 'map'"
:model-value="state.legendForm.map"
:chart="chart"
:themes="themes"
@update:model-value="changeMapLegend"
/>
<el-form-item
:label="t('chart.orient')"

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue'
import { cloneDeep } from 'lodash-es'
import { useI18n } from '@/hooks/web/useI18n'
import { formatterItem } from '@/views/chart/components/js/formatter'
import { mapRangeKey } from '@/views/chart/components/js/panel/charts/map/mapLegend'
import { parseJson } from '@/views/chart/components/js/util'
import ValueFormatterEdit from '../../drag-item/components/ValueFormatterEdit.vue'
const props = defineProps<{ modelValue?: MapLegendOptions; chart: Chart; themes: EditorTheme }>()
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const normalize = (value?: MapLegendOptions) => ({
formatMode: 'legacy',
prefix: '',
syncTooltip: false,
...cloneDeep(value),
rangeLabels: { ...value?.rangeLabels },
formatterCfg: { ...formatterItem, ...cloneDeep(value?.formatterCfg) }
})
const draft = reactive(normalize(props.modelValue))
watch(
() => props.modelValue,
value => Object.assign(draft, normalize(value)),
{ deep: true }
)
const save = () => {
if (JSON.stringify(draft) !== JSON.stringify(normalize(props.modelValue))) {
emit('update:modelValue', cloneDeep(draft))
}
}
watch(() => draft.formatterCfg, save, { deep: true })
const ranges = computed(() => {
const misc = parseJson(props.chart.customAttr)?.misc
if (misc?.mapAutoLegend || misc?.mapLegendRangeType !== 'custom' || props.chart.drill) return []
const values = misc.mapLegendCustomRange || []
return values.slice(0, -1).map((value, index) => [value, values[index + 1]])
})
</script>
<template>
<el-form-item :label="t('chart.heatmap_format')" class="form-item" :class="'form-item-' + themes">
<el-select v-model="draft.formatMode" :effect="themes" @change="save">
<el-option value="legacy" :label="t('chart.map_format_legacy')" />
<el-option value="inherit" :label="t('chart.heatmap_format_inherit')" />
<el-option value="custom" :label="t('chart.heatmap_format_custom')" />
</el-select>
</el-form-item>
<el-form-item
v-if="draft.formatMode === 'custom'"
class="form-item"
:class="'form-item-' + themes"
>
<el-popover trigger="click" :width="320" placement="left" :effect="themes">
<template #reference
><el-button
class="format-button"
:class="{ 'format-button--dark': themes === 'dark' }"
:secondary="themes !== 'dark'"
>{{ t('chart.heatmap_format_custom') }}</el-button
></template
>
<el-form
class="prefix-form"
:class="{ 'prefix-form--dark': themes === 'dark' }"
label-position="top"
>
<el-form-item :label="t('chart.heatmap_prefix')">
<el-input
:effect="themes"
v-model="draft.prefix"
maxlength="30"
clearable
@change="save"
/>
</el-form-item>
</el-form>
<ValueFormatterEdit
:themes="themes"
:formatter-item="draft"
:chart="chart"
:example-prefix="draft.prefix"
/>
</el-popover>
</el-form-item>
<el-form-item class="form-item" :class="'form-item-' + themes">
<el-checkbox v-model="draft.syncTooltip" :effect="themes" @change="save">
{{ t('chart.map_format_sync_tooltip') }}
</el-checkbox>
</el-form-item>
<el-form-item
v-for="range in ranges"
:key="mapRangeKey(range)"
:label="`${t('chart.map_range_label')} (${range.join(' - ')})`"
class="form-item"
:class="'form-item-' + themes"
>
<el-input
v-model="draft.rangeLabels[mapRangeKey(range)]"
:effect="themes"
:placeholder="t('chart.map_range_label_auto')"
maxlength="100"
clearable
@change="save"
/>
</el-form-item>
</template>
<style lang="less" scoped>
.prefix-form--dark :deep(.ed-form-item__label) {
color: #ebebeb;
}
.format-button--dark {
--ed-button-text-color: #ebebeb;
--ed-button-bg-color: transparent;
--ed-button-border-color: #505050;
--ed-button-hover-text-color: #ebebeb;
--ed-button-hover-bg-color: #414141;
--ed-button-hover-border-color: #666666;
--ed-button-active-text-color: #ebebeb;
--ed-button-active-bg-color: #505050;
--ed-button-active-border-color: #666666;
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import icon_deleteTrash_outlined from '@/assets/svg/icon_delete-trash_outlined.svg'
import {
MAX_MAP_RANGES,
insertMapBoundary,
removeMapBoundary,
validMapBoundaries
} from './mapRange'
const props = defineProps<{ modelValue: number[]; themes: EditorTheme }>()
const emit = defineEmits<{ (event: 'update:modelValue', value: number[]): void }>()
const { t } = useI18n()
const draft = ref<(number | undefined)[]>([...props.modelValue])
const host = ref<HTMLElement>()
const error = ref('')
watch(
() => props.modelValue,
value => {
if (JSON.stringify(value) !== JSON.stringify(draft.value)) {
draft.value = [...value]
error.value = ''
}
},
{ deep: true }
)
const save = () => {
if (!validMapBoundaries(draft.value)) {
error.value = t('chart.map_boundary_invalid')
return
}
error.value = ''
emit('update:modelValue', [...draft.value])
}
const insert = async (index: number) => {
const next = insertMapBoundary(draft.value as number[], index)
if (!next) {
error.value = t('chart.map_boundary_cannot_insert')
return
}
draft.value = next
save()
await nextTick()
const input = host.value?.querySelectorAll<HTMLInputElement>('.map-boundary-row input')[index + 1]
input?.focus()
input?.select()
}
const remove = (index: number) => {
const next = removeMapBoundary(draft.value as number[], index)
if (!next) return
draft.value = next
save()
}
</script>
<template>
<div ref="host" class="map-boundaries">
<div class="map-range-count">
<span>{{ t('chart.legend_num') }}</span>
<span>{{ draft.length - 1 }}</span>
<span class="map-range-hint">{{ t('chart.map_range_count_auto') }}</span>
</div>
<template v-for="(_, index) in draft" :key="index">
<div class="map-boundary-row">
<label :for="`map-boundary-${index}`">{{
index === 0
? t('chart.min')
: index === draft.length - 1
? t('chart.max')
: t('chart.map_boundary')
}}</label>
<el-input-number
:id="`map-boundary-${index}`"
v-model="draft[index]"
:effect="themes"
controls-position="right"
@change="save"
/>
<el-button
v-if="index > 0 && index < draft.length - 1"
text
class="map-boundary-delete"
:title="t('chart.map_boundary_delete')"
:aria-label="t('chart.map_boundary_delete')"
@click="remove(index)"
>
<el-icon
><Icon><icon_deleteTrash_outlined class="svg-icon" /></Icon
></el-icon>
</el-button>
<span v-else class="map-boundary-delete" />
</div>
<el-button
v-if="index < draft.length - 1"
class="map-boundary-add"
text
type="primary"
:disabled="draft.length - 1 >= MAX_MAP_RANGES"
@click="insert(index)"
>+ {{ t('chart.map_boundary_add') }}</el-button
>
</template>
<div v-if="error" class="map-range-error" role="alert">{{ error }}</div>
<div class="map-range-hint">{{ t('chart.map_boundary_hint') }}</div>
</div>
</template>
<style scoped lang="less">
.map-boundaries {
width: 100%;
margin-bottom: 16px;
}
.map-range-count {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.map-boundary-row {
display: flex;
align-items: center;
gap: 8px;
label {
flex: 0 0 48px;
}
:deep(.ed-input-number) {
flex: 1;
width: 0;
min-width: 0;
}
}
.map-boundary-delete {
flex: 0 0 24px;
width: 24px;
padding: 0;
color: inherit;
}
.map-boundary-add {
display: flex;
margin: 2px auto;
height: 28px;
}
.map-range-hint {
font-size: 12px;
color: var(--ed-text-color-secondary, #8f959e);
line-height: 18px;
}
.map-boundaries > .map-range-hint {
margin-top: 8px;
}
.map-range-error {
color: var(--ed-color-danger);
font-size: 12px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,38 @@
export const MAX_MAP_RANGES = 9
export const validMapBoundaries = (values: unknown[]): values is number[] =>
values.length >= 2 &&
values.length <= MAX_MAP_RANGES + 1 &&
values.every(
(value, index) =>
typeof value === 'number' &&
Number.isFinite(value) &&
(index === 0 || value > (values[index - 1] as number))
)
export const mapBoundaryMidpoint = (min: number, max: number): number | undefined => {
if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max) return
const midpoint = min / 2 + max / 2
const rounded = Number(midpoint.toPrecision(15))
if (rounded > min && rounded < max) return rounded
if (midpoint > min && midpoint < max) return midpoint
}
export const insertMapBoundary = (values: number[], index: number): number[] | undefined => {
if (
!validMapBoundaries(values) ||
values.length > MAX_MAP_RANGES ||
index < 0 ||
index >= values.length - 1
)
return
const midpoint = mapBoundaryMidpoint(values[index], values[index + 1])
if (midpoint === undefined) return
return [...values.slice(0, index + 1), midpoint, ...values.slice(index + 1)]
}
export const removeMapBoundary = (values: number[], index: number): number[] | undefined => {
if (index <= 0 || index >= values.length - 1) return
const next = values.filter((_, i) => i !== index)
return validMapBoundaries(next) ? next : undefined
}

View File

@@ -35,11 +35,16 @@ import createDom from '@antv/dom-util/esm/create-dom'
import {
CONTAINER_TPL,
ITEM_TPL,
LIST_CLASS
LIST_CLASS,
VALUE_CLASS
} from '@antv/l7plot-component/dist/esm/legend/category/constants'
import { configCarouselTooltip } from '@/views/chart/components/js/panel/charts/map/tooltip-carousel'
import {
configCarouselTooltip,
escapeTooltipHtml
} from '@/views/chart/components/js/panel/charts/map/tooltip-carousel'
import { getCustomGeoArea } from '@/api/map'
import { centroid } from '@turf/centroid'
import { mapLegendFormatter, mapLegendLabel, mapTooltipValue } from './mapLegend'
import { attachMapLabels, geometryPolygons } from './label-layout'
import {
drawPointFallbackChart,
@@ -187,7 +192,7 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
const min = Math.min(...values)
const max = Math.max(...values)
const step = values.length > 1 ? (max - min) / Math.min(colors.length, 5) : 1
const items: { value: number[]; color: string }[] = []
const items: CategoryLegendListItem[] = []
if (values.length === 1) {
items.push({ value: [min, max], color: colors[0] })
} else {
@@ -199,7 +204,7 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
}
opts.legend.customContent = () => {
if (items.length) {
return this.createLegendCustomContent(items)
return this.createLegendCustomContent(c, items)
}
return ''
}
@@ -215,17 +220,17 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
if (!misc.mapAutoLegend && legend.show) {
let minValue = misc.mapLegendMin
let maxValue = misc.mapLegendMax
let legendNumber = 9
const legendNumber = misc.mapLegendNumber || 9
if (misc.mapLegendRangeType === 'custom') {
maxValue = 0
minValue = 0
legendNumber = misc.mapLegendNumber
}
getMaxAndMinValueByData(sourceData, 'value', maxValue, minValue, (max, min) => {
maxValue = max
minValue = min
action({
from: 'map',
chartId: chart.id,
data: {
max: maxValue,
min: minValue ?? filterEmptyMinValue(sourceData, 'value'),
@@ -377,22 +382,18 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
let colorScale = []
let minValue = misc.mapAutoLegend ? 0 : misc.mapLegendMin
let maxValue = misc.mapAutoLegend ? 0 : misc.mapLegendMax
let mapLegendNumber = misc.mapLegendNumber
const mapLegendNumber = misc.mapAutoLegend
? this.calculateAutoLegendNumber(sourceData)
: misc.mapLegendNumber || 9
if (legend.show) {
getMaxAndMinValueByData(sourceData, 'value', maxValue, minValue, (max, min) => {
maxValue = max
minValue = min
mapLegendNumber = 9
})
// 非自动,过滤数据
if (!misc.mapAutoLegend) {
data = filterChartDataByRange(sourceData, maxValue, minValue)
} else {
mapLegendNumber = 9
}
mapLegendNumber = misc.mapAutoLegend
? this.calculateAutoLegendNumber(sourceData)
: mapLegendNumber
// 定义最大值、最小值、区间数量和对应的颜色
colorScale = getDynamicColorScale(minValue, maxValue, mapLegendNumber, colors)
} else {
@@ -474,35 +475,43 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
}
// 内部函数 创建自定义图例的内容
private createLegendCustomContent = showItems => {
private createLegendCustomContent = (chart: Chart, showItems: CategoryLegendListItem[]) => {
const containerDom = createDom(CONTAINER_TPL) as HTMLElement
const listDom = containerDom.getElementsByClassName(LIST_CLASS)[0] as HTMLElement
showItems.forEach(item => {
let value = '-'
if (item.value !== '') {
if (Array.isArray(item.value)) {
const arr = item.value.every(Number.isNaN) ? item.color.value || [] : item.value
value = arr
.map(v => (Number.isNaN(v) || String(v) === 'NaN' ? 'NaN' : parseFloat(v).toFixed(0)))
.join('-')
} else {
const tmp = item.value as string
value = Number.isNaN(tmp) || tmp === 'NaN' ? 'NaN' : parseFloat(tmp).toFixed(0)
}
}
if (value && value !== '') {
const substituteObj = { ...item, value }
const domStr = substitute(ITEM_TPL, substituteObj)
const itemDom = createDom(domStr)
// 给 legend 形状用的
itemDom.style.setProperty('--bgColor', item.color)
listDom.appendChild(itemDom)
}
showItems.forEach((item, index) => {
const raw =
Array.isArray(item.value) && item.value.every(Number.isNaN)
? (item.color as unknown as { value?: unknown })?.value || item.value
: item.value
const value = mapLegendLabel(chart, raw)
const itemDom = createDom(substitute(ITEM_TPL, { index, color: '', value: '' }))
// Prefixes, suffixes and range names are plain text, never interpolated into HTML.
const valueDom = itemDom.getElementsByClassName(VALUE_CLASS)[0] as HTMLElement
valueDom.textContent = value
valueDom.title = value
itemDom.style.setProperty('--bgColor', item.color as string)
const marker = itemDom.firstElementChild as HTMLElement
marker.style.backgroundColor = item.color as string
listDom.appendChild(itemDom)
})
return listDom
}
protected configTooltip(chart: Chart, options: ChoroplethOptions): ChoroplethOptions {
const result = super.configTooltip(chart, options)
if (!parseJson(chart.customStyle)?.legend?.map?.syncTooltip || !result.tooltip) return result
const customItems = result.tooltip.customItems
if (!customItems) return result
const format = mapLegendFormatter(chart)
result.tooltip.customItems = originalItem =>
customItems(originalItem).map(item =>
item.quotaList?.[0]?.id === originalItem.properties?.quotaList?.[0]?.id && item.quotaList
? { ...item, value: escapeTooltipHtml(format(originalItem.properties.value)) }
: item
)
return result
}
private customConfigLegend(chart: Chart, options: ChoroplethOptions): ChoroplethOptions {
const { basicStyle, misc } = parseJson(chart.customAttr)
const colors = basicStyle.colors.map(item => hexColorToRGBA(item, basicStyle.alpha))
@@ -587,7 +596,7 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
})
customLegend['customContent'] = () => {
if (items?.length) {
return this.createLegendCustomContent(items)
return this.createLegendCustomContent(chart, items)
}
return ''
}
@@ -633,7 +642,7 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
}
}
}
return this.createLegendCustomContent(showItems)
return this.createLegendCustomContent(chart, showItems)
}
return ''
}
@@ -752,7 +761,9 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
const formatter = formatterMap[valItem.quotaList?.[0]?.id]
if (!isEmpty(formatter)) {
const originValue = parseFloat(valItem.value as string)
const value = valueFormatter(originValue, formatter.formatterCfg)
const value = escapeTooltipHtml(
mapTooltipValue(chart, originValue, formatter.formatterCfg)
)
const name = isEmpty(formatter.chartShowName) ? formatter.name : formatter.chartShowName
result.push({ ...valItem, name, value: `${value ?? ''}` })
}
@@ -771,6 +782,7 @@ export class Map extends L7PlotChartView<ChoroplethOptions, Choropleth> {
}
setupDefaultOptions(chart: ChartObj): ChartObj {
chart.customStyle.legend.map = { formatMode: 'inherit' }
chart.customAttr.basicStyle.areaBaseColor = '#f4f4f4'
chart.senior.useGlobalAreaMapping = true
return chart

View File

@@ -0,0 +1,48 @@
import { formatterItem, valueFormatter } from '../../../formatter'
import { parseJson } from '@/views/chart/components/js/util'
export const mapRangeKey = (range: number[]) => JSON.stringify(range)
export const mapLegendFormatter = (chart: Chart) => {
const config: MapLegendOptions = parseJson(chart.customStyle)?.legend?.map || {}
const custom = config.formatMode === 'custom'
const formatter = {
...formatterItem,
...(custom ? config.formatterCfg : chart.yAxis?.[0]?.formatterCfg)
}
return (value: unknown): string => {
if (
(typeof value !== 'number' && typeof value !== 'string') ||
(typeof value === 'string' && !value.trim()) ||
!Number.isFinite(Number(value))
) {
return '-'
}
const number = Number(value)
// Existing maps retain their integer labels until a formatting mode is explicitly selected.
if (!config.formatMode || config.formatMode === 'legacy') return number.toFixed(0)
return `${custom ? config.prefix || '' : ''}${valueFormatter(number, { ...formatter })}`
}
}
export const mapLegendLabel = (chart: Chart, value: unknown): string => {
const format = mapLegendFormatter(chart)
if (!Array.isArray(value)) return format(value)
const misc = parseJson(chart.customAttr)?.misc
if (!chart.drill && !misc?.mapAutoLegend && misc?.mapLegendRangeType === 'custom') {
const label = parseJson(chart.customStyle)?.legend?.map?.rangeLabels?.[mapRangeKey(value)]
if (label?.trim()) return label
}
const mode = parseJson(chart.customStyle)?.legend?.map?.formatMode
if (!mode || mode === 'legacy') return value.map(format).join('-')
const [min, max] = value
if (min === -Infinity && Number.isFinite(max)) return `< ${format(max)}`
if (max === Infinity && Number.isFinite(min)) return `≥ ${format(min)}`
if (min === max) return format(min)
return `${format(min)} - ${format(max)}`
}
export const mapTooltipValue = (chart: Chart, value: number, formatter: BaseFormatter) =>
chart.type === 'map' && parseJson(chart.customStyle)?.legend?.map?.syncTooltip
? mapLegendFormatter(chart)(value)
: valueFormatter(value, formatter)

View File

@@ -1,3 +1,4 @@
import { mapTooltipValue } from './mapLegend'
import type { ChoroplethOptions } from '@antv/l7plot/dist/esm/plots/choropleth'
import type { FeatureCollection } from '@antv/l7plot/dist/esm/plots/choropleth/types'
import type { DotOptions } from '@antv/l7plot'
@@ -11,7 +12,10 @@ import {
mapRendering
} from '@/views/chart/components/js/panel/common/common_antv'
import { valueFormatter } from '@/views/chart/components/js/formatter'
import { configCarouselTooltip } from '@/views/chart/components/js/panel/charts/map/tooltip-carousel'
import {
configCarouselTooltip,
escapeTooltipHtml
} from '@/views/chart/components/js/panel/charts/map/tooltip-carousel'
import type { L7PlotDrawOptions } from '@/views/chart/components/js/panel/types/impl/l7plot'
import type { Choropleth } from '@antv/l7plot/dist/esm/plots/choropleth'
import type { MapMouseEvent } from '@/views/chart/components/js/panel/charts/map/common'
@@ -278,7 +282,7 @@ export async function drawPointFallbackChart(
const formatter = formatterMap[head.quotaList?.[0]?.id]
if (!isEmpty(formatter)) {
const originValue = parseFloat(head.value as string)
const value = valueFormatter(originValue, formatter.formatterCfg)
const value = escapeTooltipHtml(mapTooltipValue(chart, originValue, formatter.formatterCfg))
const name = isEmpty(formatter.chartShowName) ? formatter.name : formatter.chartShowName
result.push({ ...head, name, value: `${value ?? ''}` })
}

View File

@@ -0,0 +1,8 @@
# 地图图例格式回归
在仓库根目录运行 `node tests/map-legend/verify.mjs`。需要已有前端 TypeScript/lodash-es 依赖。
测试使用真实数值格式化实现,仅隔离语言和配置解析环境依赖。覆盖旧图兼容、继承/自定义、单位/百分比/千分符、区间别名、钻取和自动区间隔离、空值/非有限值及提示框同步边界。
实际渲染与保存重开另在本地 DataEase 合成大屏验证:`1302701735000805376`。
`node tests/map-legend/verify-count.mjs` 验证实际地图渲染方法在初次推算范围(0/0、null)及已初始化范围下始终采用手动图例数,覆盖1/3/5/9段。

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from '../../core/core-frontend/node_modules/typescript/lib/typescript.js'
const base = new URL('../../core/core-frontend/src/views/chart/components/js/', import.meta.url)
const mapText = readFileSync(new URL('panel/charts/map/map.ts', base), 'utf8')
const utilText = readFileSync(new URL('util.ts', base), 'utf8')
const mapAst = ts.createSourceFile('map.ts', mapText, ts.ScriptTarget.Latest, true)
const utilAst = ts.createSourceFile('util.ts', utilText, ts.ScriptTarget.Latest, true)
const mapClass = mapAst.statements.find(n => ts.isClassDeclaration(n) && n.name.text === 'Map')
const members = ['configBasicStyle', 'calculateAutoLegendNumber'].map(name => mapClass.members.find(n => n.name?.getText(mapAst) === name).getText(mapAst)).join('\n')
const helpers = ['getDynamicColorScale','getMaxAndMinValueByData','filterChartDataByRange'].map(name => utilAst.statements.find(n => ts.isVariableStatement(n) && n.declarationList.declarations.some(d => d.name.getText(utilAst) === name)).getText(utilAst)).join('\n')
const wrapper = mapAst.statements.find(n => ts.isClassDeclaration(n) && n.name.text === 'ColorWrapper').getText(mapAst)
// Isolate DOM/geographic styling; retain the actual renderer method and actual range calculations.
const source = `const parseJson = x => x; const handleGeoJson = () => {}; const hexColorToRGBA = x => x;
${helpers}\n${wrapper}\nexport class TestMap { ${members} }`
const js = ts.transpileModule(source, {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText
const {TestMap} = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`)
const renderer = new TestMap()
const data = [12500.25,25600.75,42500.25,78900.5].map((value,i)=>({value,field:String(i)}))
for (const [min,max] of [[0,0],[null,null],[null,100000],[0,null],[12500.25,78900.5]]) {
for (const count of [1,3,5,9]) {
const misc = {mapAutoLegend:false,mapLegendNumber:count,mapLegendMin:min,mapLegendMax:max,mapLegendRangeType:'quantize'}
const chart = {data:{data},senior:{},customStyle:{legend:{show:true}},customAttr:{misc,basicStyle:{colors:Array(9).fill('#123456')},label:{show:false}}}
const result=renderer.configBasicStyle(chart,{source:{data}},{drawOption:{areaId:'156'},geoJson:{features:[{properties:{name:'0'}}]}})
assert.equal(result.color.value.length,count,`range ${min},${max}, expected ${count}`)
assert.equal(misc.mapLegendNumber,count)
}
}
console.log('PASS: 20 map renderer count cases, including first inferred range and initialized range')

View File

@@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import ts from '../../core/core-frontend/node_modules/typescript/lib/typescript.js'
const root = new URL('../../core/core-frontend/src/views/chart/components/js/', import.meta.url)
const moduleUrl = source => `data:text/javascript;base64,${Buffer.from(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }
}).outputText).toString('base64')}`
// Use the real formatter; only replace environment hooks so the regression can run without Vue.
const formatter = readFileSync(new URL('formatter.ts', root), 'utf8')
.replace("from 'lodash-es'", `from '${new URL('../../core/core-frontend/node_modules/lodash-es/lodash.js', import.meta.url)}'`)
.replace("import { useI18n } from '@/hooks/web/useI18n'", "const useI18n = () => ({t: key => ({'chart.unit_ten_thousand':'万'}[key] || key)})")
.replace("import { getLocale } from '@/utils/utils'", "const getLocale = () => 'zh-CN'")
const source = readFileSync(new URL('panel/charts/map/mapLegend.ts', root), 'utf8')
.replace("'../../../formatter'", JSON.stringify(moduleUrl(formatter)))
.replace("import { parseJson } from '@/views/chart/components/js/util'", 'const parseJson = value => typeof value === "string" ? JSON.parse(value) : value')
const { mapLegendLabel: label, mapLegendFormatter, mapTooltipValue } = await import(moduleUrl(source))
let checks = 0
const eq = (actual, expected) => { assert.equal(actual, expected); checks++ }
const cfg = {type:'value',unitLanguage:'ch',unit:10000,suffix:'元',decimalCount:2,thousandSeparator:true}
const chart = {type:'map',yAxis:[{formatterCfg:cfg}],customStyle:{legend:{}},customAttr:{misc:{mapAutoLegend:false,mapLegendRangeType:'custom'}}}
eq(label(chart, [12500.25,25600.75]), '12500-25601')
chart.customStyle.legend.map = {formatMode:'inherit'}
eq(label(chart, [12500.25,25600.75]), '1.25万元 - 2.56万元')
const snapshot = JSON.stringify(chart)
label(chart,[1,2]);eq(JSON.stringify(chart),snapshot)
const custom = chart.customStyle.legend.map = {formatMode:'custom',prefix:'¥',formatterCfg:{...cfg,decimalCount:1},rangeLabels:{'[0,20000]':'低风险'}}
eq(label(chart,[20000,50000]), '¥2.0万元 - ¥5.0万元')
eq(label(chart,[0,20000]),'低风险')
eq(label(chart,[0,30000]),'¥0.0万元 - ¥3.0万元')
chart.drill=true;eq(label(chart,[0,20000]),'¥0.0万元 - ¥2.0万元');chart.drill=false
chart.customAttr.misc.mapAutoLegend=true;eq(label(chart,[0,20000]),'¥0.0万元 - ¥2.0万元')
eq(label(chart,[-Infinity,20000]),'< ¥2.0万元')
eq(label(chart,[20000,Infinity]),'≥ ¥2.0万元')
eq(label(chart,[20000,20000]),'¥2.0万元')
for (const v of [NaN, Infinity, null, undefined, '', ' ', true, {}, 'invalid']) eq(mapLegendFormatter(chart)(v),'-')
eq(label(chart,[-25000,-10000]),'¥-2.5万元 - ¥-1.0万元')
eq(mapTooltipValue(chart,12500.25,cfg),'1.25万元')
custom.syncTooltip=true;eq(mapTooltipValue(chart,12500.25,cfg),'¥1.3万元')
chart.type='bubble-map';eq(mapTooltipValue(chart,12500.25,cfg),'1.25万元')
custom.formatterCfg={...cfg,unit:1,decimalCount:2};eq(label(chart,1234567.89),'¥1,234,567.89元')
custom.formatterCfg={...cfg,type:'percent',unit:1,decimalCount:1,suffix:''};eq(label(chart,0.125),'¥12.5%')
console.log(`PASS: ${checks} map legend assertions`)
const rangeSource = readFileSync(new URL('editor/editor-style/components/mapRange.ts', new URL('../../core/core-frontend/src/views/chart/components/', import.meta.url)), 'utf8')
const {validMapBoundaries, mapBoundaryMidpoint, insertMapBoundary, removeMapBoundary} = await import(moduleUrl(rangeSource))
const bounds = [0,20,50,100]
assert.deepEqual(insertMapBoundary(bounds,1),[0,20,35,50,100])
assert.deepEqual(bounds,[0,20,50,100])
assert.deepEqual(removeMapBoundary([0,20,35,50,100],1),[0,35,50,100])
eq(removeMapBoundary(bounds,0),undefined)
eq(removeMapBoundary(bounds,3),undefined)
eq(validMapBoundaries([0,0,1]),false)
eq(validMapBoundaries([0,undefined,1]),false)
eq(validMapBoundaries([-5,0,1.5]),true)
eq(validMapBoundaries([0,Infinity]),false)
eq(mapBoundaryMidpoint(0.1,0.2),0.15)
eq(mapBoundaryMidpoint(-20,-10),-15)
eq(mapBoundaryMidpoint(Number.MAX_VALUE / 2,Number.MAX_VALUE)>0,true)
eq(mapBoundaryMidpoint(1,1+Number.EPSILON),undefined)
eq(insertMapBoundary([0,1,2,3,4,5,6,7,8,9],0),undefined)
eq(insertMapBoundary(bounds,4),undefined)
eq(removeMapBoundary([0,100],1),undefined)
console.log('PASS: boundary insertion, deletion, ordering, limits and numeric precision checks')