useMoveExtPoint:ECharts 最值标签智能防遮挡 Hook
效果展示
问题背景
在使用 ECharts 的 markPoint 展示折线图的最大值、最小值时,常遇到以下问题:
- 跨 series 重叠:多条折线的最值标签在 X 轴相近位置会相互遮挡
- 量纲差异:不同图表 Y 轴量纲不同(几百 vs 几万),固定偏移量效果不佳
- 误分组:X 轴接近但 Y 轴高度差很大的点(如 0 与 560)被错误分组,导致不必要的偏移
- 标签过长:长 X 轴 + 长数值字符时,相距较远的点也可能重叠
解决方案
useMoveExtPoint 提供一套智能算法,自动计算每个最值标签的纵向偏移,实现:
- 跨 series 统一分组:收集所有 series 的最值点,按 X、Y 双轴接近程度分组
- 动态偏移量:根据相邻点 Y 值差与全局 range 的比例计算步长,适配不同量纲
- 双轴 proximity:仅当两点在 X、Y 上同时接近时才分组,避免误判
安装与引入
import { useMoveExtPoint, buildMarkPointsWithCrossSeriesOffset } from './useMoveExtPoint';
API 说明
useMoveExtPoint(Hook 用法)
const markPointMap = useMoveExtPoint(
seriesList: SeriesItem[],
fixed: number,
options?: UseMoveExtPointOptions
): Map<number, { data: any[]; label: object }>
| 参数 | 类型 | 说明 |
|---|---|---|
seriesList | { name?: string; data: any[] }[] | series 列表,每项需包含 name、data |
fixed | number | 数值小数位数,用于格式化与标签长度估算 |
options | UseMoveExtPointOptions | 可选配置 |
buildMarkPointsWithCrossSeriesOffset(纯函数用法)
在 useMemo 等非 Hook 场景下使用:
const markPointMap = buildMarkPointsWithCrossSeriesOffset(seriesList, fixed, options);
UseMoveExtPointOptions
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
debug | boolean | false | 是否在控制台打印每个标签的 series、X 位置、Y 值、偏移量 |
excludeSeriesName | (name: string) => boolean | 排除名称含「偏差」 | 自定义排除规则,返回 true 的 series 不参与最值计算 |
offsetScale | number | 1 | 偏移量缩放,>1 时增加向上偏移,调试重叠时可试 1.2~1.3 |
使用示例
示例 1:在 useMemo 中与 ECharts 配置结合
const echartsData = useMemo(() => {
const result = DataCalcFunc({ /* ... */ });
if (showExtremeValue && result.seriesData) {
const markPointMap = buildMarkPointsWithCrossSeriesOffset(
result.seriesData.map((s) => ({ name: s.name, data: s.data })),
fixed,
{ debug: false }
);
result.seriesData = result.seriesData.map((series, idx) => {
const markPointConfig = markPointMap.get(idx);
if (!markPointConfig) return series;
return { ...series, markPoint: markPointConfig };
});
}
return result;
}, [/* deps */]);
示例 2:使用 Hook 并开启调试
const seriesList = seriesData.map((s) => ({ name: s.name, data: s.data }));
const markPointMap = useMoveExtPoint(seriesList, 2, {
debug: true,
offsetScale: 1.2,
});
// 将 markPoint 应用到对应 series
seriesData.forEach((series, idx) => {
const config = markPointMap.get(idx);
if (config) series.markPoint = config;
});
示例 3:自定义排除规则
const markPointMap = buildMarkPointsWithCrossSeriesOffset(
seriesList,
fixed,
{
excludeSeriesName: (name) =>
name.includes('偏差') || name.includes('参考线'),
}
);
算法说明
1. 分组逻辑(双轴 Proximity)
两点归入同一「池子」需同时满足:
- X 接近:
|index_i - index_j| <= xThresholdxThreshold由 X 轴长度、标签字符长度动态计算
- Y 接近:
|value_i - value_j| / globalRange <= 0.25- 避免 Y 值相差很大的点(如 0 与 560)被错误分组
2. 偏移量计算
池内按 Y 值升序排序,从第 2 个点起:
- 步长 =
f(deltaY / globalRange):Y 差越小步长越大,Y 差越大步长越小 - 累积偏移:
offsetY = BASE_OFFSET_Y - cumulativeOffset
3. 适配不同量纲
使用 deltaY / globalRange 作为相对差值,使几百和几万的图表都能得到合理的步长。
返回数据结构
返回值为 Map<number, { data: any[]; label: object }>:
- Key:series 在
seriesList中的索引 - Value:ECharts
markPoint配置,可直接赋给series.markPoint
{
data: [
{ name: 'max', type: 'max', coord: [index, value], value, symbolOffset: [0, offsetY], ... },
{ name: 'min', type: 'min', coord: [index, value], value, symbolOffset: [0, offsetY], ... },
],
label: { backgroundColor: 'inherit', borderWidth: 1, borderRadius: 4, padding: [4, 6] },
}
数据格式要求
series.data支持:number、null、{ value: number }(如 ECharts 特殊数据格式)- 无有效数值的 series 不会生成 markPoint,对应 key 不会出现在 Map 中
调试建议
- 开启
debug: true,查看控制台输出的 X 位置、Y 值、偏移量 - 若仍有重叠,可尝试
offsetScale: 1.2或1.3 - 若误分组(不该偏移的被偏移),可调整源码中的
Y_PROXIMITY_RATIO(默认 0.25)
兼容性
- 适用于 ECharts 5.x
- 依赖 React 18+(若使用
useMoveExtPoint) - 纯函数
buildMarkPointsWithCrossSeriesOffset可在任意框架中使用
完整代码
import { useMemo } from 'react';
/** 基础 symbolOffset 的 Y 分量 */
const BASE_OFFSET_Y = -20;
/** 偏移量范围:差值大时最小步长 */
const OFFSET_STEP_MIN = 5;
/** 偏移量范围:差值小时最大步长,需足够大以防 0/28、23482/23717 等相近值标签重叠 */
const OFFSET_STEP_MAX = 20;
/** 相对差值缩放:0.15 表示差值占全局 Y 范围 15% 时步长约减半 */
const OFFSET_RELATIVE_SCALE = 0.15;
/** Y 轴相对接近阈值:两点 Y 值差占全局 range 超过此比例则不归入同一池(避免 0 与 560 等高度差大的点被错误分组) */
const Y_PROXIMITY_RATIO = 0.25;
interface ExtremePoint {
type: 'max' | 'min';
value: number;
index: number;
seriesName: string;
seriesIndex: number;
/** 标签预估字符长度,用于分组判断 */
labelLength: number;
}
export type SeriesItem = { name?: string; data: any[] };
export interface UseMoveExtPointOptions {
/** 是否打印调试信息 */
debug?: boolean;
/** 排除的 series 名称匹配(默认包含「偏差」的排除) */
excludeSeriesName?: (name: string) => boolean;
/** 偏移量缩放,>1 时增加向上偏移、拉大标签间距,调试重叠时可试 1.2~1.3 */
offsetScale?: number;
}
function calcOffsetStep(deltaY: number, globalRange: number): number {
if (globalRange <= 0) return OFFSET_STEP_MAX;
const relativeDelta = Math.abs(deltaY) / globalRange;
const step = OFFSET_STEP_MAX / (1 + relativeDelta / OFFSET_RELATIVE_SCALE);
return Math.max(OFFSET_STEP_MIN, Math.min(OFFSET_STEP_MAX, step));
}
function calcProximityThreshold(totalXLength: number, maxLabelLength: number): number {
const base = Math.max(2, Math.floor(totalXLength * 0.04));
const labelAdd = maxLabelLength >= 6 ? Math.floor((maxLabelLength - 5) / 2) : 0;
return Math.min(base + labelAdd, 10);
}
function extractExtremePoints(
seriesData: any[],
fixed: number,
seriesName: string,
seriesIndex: number,
): ExtremePoint[] {
if (!Array.isArray(seriesData) || seriesData.length === 0) return [];
let maxVal = -Infinity;
let maxIdx = -1;
let minVal = Infinity;
let minIdx = -1;
seriesData.forEach((item: any, index: number) => {
const raw = typeof item === 'object' && item !== null && 'value' in item ? item.value : item;
const val = Number(raw);
if (raw === null || raw === undefined || Number.isNaN(val)) return;
if (val > maxVal) {
maxVal = val;
maxIdx = index;
}
if (val < minVal) {
minVal = val;
minIdx = index;
}
});
const formatValue = (v: number) => {
if (typeof v !== 'number' || Number.isNaN(v)) return String(v);
return fixed >= 0 ? v.toFixed(fixed) : String(v);
};
const getLabelLength = (v: number, type: 'max' | 'min') =>
`${type === 'max' ? '最大' : '最小'}: ${formatValue(v)}`.length;
const points: ExtremePoint[] = [];
if (maxIdx >= 0) {
points.push({
type: 'max',
value: maxVal,
index: maxIdx,
seriesName,
seriesIndex,
labelLength: getLabelLength(maxVal, 'max'),
});
}
if (minIdx >= 0 && minIdx !== maxIdx) {
points.push({
type: 'min',
value: minVal,
index: minIdx,
seriesName,
seriesIndex,
labelLength: getLabelLength(minVal, 'min'),
});
}
return points;
}
/**
* 按 X、Y 双轴接近程度将点归入池子
* 仅当两点同时满足 X 接近且 Y 接近时才合并,避免 566 与 0.49 等高度差大、视觉上不会重叠的点被错误分组
*/
function groupByProximity(
points: ExtremePoint[],
xThreshold: number,
globalRange: number,
): ExtremePoint[][] {
const n = points.length;
const parent = points.map((_, i) => i);
const find = (i: number): number => {
if (parent[i] !== i) parent[i] = find(parent[i]);
return parent[i];
};
const union = (i: number, j: number) => {
const pi = find(i);
const pj = find(j);
if (pi !== pj) parent[pi] = pj;
};
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const xClose = Math.abs(points[i].index - points[j].index) <= xThreshold;
const yClose =
globalRange <= 0 ||
Math.abs(points[i].value - points[j].value) / globalRange <= Y_PROXIMITY_RATIO;
if (xClose && yClose) {
union(i, j);
}
}
}
const groups = new Map<number, ExtremePoint[]>();
for (let i = 0; i < n; i++) {
const root = find(i);
if (!groups.has(root)) groups.set(root, []);
groups.get(root)!.push(points[i]);
}
return Array.from(groups.values());
}
/**
* 跨 series 计算智能 markPoint 配置,解决最值标签相互遮挡
* 收集所有 series 的最值点,按 X 轴接近或标签较长分组,组内按 Y 值升序分配偏移
*/
function buildMarkPointsWithCrossSeriesOffset(
seriesList: SeriesItem[],
fixed: number,
options?: UseMoveExtPointOptions,
): Map<number, { data: any[]; label: object }> {
const {
debug = false,
excludeSeriesName = (n: string) => String(n).includes('偏差'),
offsetScale = 1,
} = options ?? {};
const formatValue = (v: number) => {
if (typeof v !== 'number' || Number.isNaN(v)) return String(v);
return fixed >= 0 ? v.toFixed(fixed) : String(v);
};
const allPoints: ExtremePoint[] = [];
let totalXLength = 0;
seriesList.forEach((series, idx) => {
if (excludeSeriesName(series.name ?? '')) return;
totalXLength = Math.max(totalXLength, series.data?.length ?? 0);
const pts = extractExtremePoints(series.data, fixed, series.name ?? '-', idx);
allPoints.push(...pts);
});
if (allPoints.length === 0) return new Map();
const globalMin = Math.min(...allPoints.map((p) => p.value));
const globalMax = Math.max(...allPoints.map((p) => p.value));
const globalRange = Math.max(globalMax - globalMin, 1);
const maxLabelLength = Math.max(...allPoints.map((p) => p.labelLength), 0);
const xThreshold = calcProximityThreshold(totalXLength, maxLabelLength);
const groups = groupByProximity(allPoints, xThreshold, globalRange);
const offsetMap = new Map<string, number>();
groups.forEach((group) => {
const needOffset = group.length >= 2;
const sorted = [...group].sort((a, b) => a.value - b.value);
let cumulativeOffset = 0;
sorted.forEach((p, rank) => {
if (rank === 0) {
cumulativeOffset = 0;
} else {
const deltaY = p.value - sorted[rank - 1].value;
cumulativeOffset += calcOffsetStep(deltaY, globalRange) * offsetScale;
}
const offsetY = needOffset ? Math.round(BASE_OFFSET_Y - cumulativeOffset) : BASE_OFFSET_Y;
offsetMap.set(`${p.seriesIndex}-${p.type}-${p.index}`, offsetY);
if (debug) {
// eslint-disable-next-line no-console
console.log(
`[markPoint ${p.type}] series: ${p.seriesName}, X轴位置: 第${
p.index + 1
}个点, Y值: ${formatValue(p.value)}, 偏移量: ${offsetY}px`,
);
}
});
});
const result = new Map<number, { data: any[]; label: object }>();
seriesList.forEach((series, idx) => {
if (excludeSeriesName(series.name ?? '')) return;
const pts = extractExtremePoints(series.data, fixed, series.name ?? '-', idx);
if (pts.length === 0) return;
const markPointData = pts.map((p) => {
const offsetY = offsetMap.get(`${idx}-${p.type}-${p.index}`) ?? BASE_OFFSET_Y;
return {
name: p.type,
type: p.type,
coord: [p.index, p.value],
value: p.value,
symbolOffset: [0, offsetY],
symbol: p.type === 'max' ? 'rect' : 'circle',
symbolSize: 1,
};
});
result.set(idx, {
data: markPointData,
label: {
backgroundColor: 'inherit',
borderWidth: 1,
borderRadius: 4,
padding: [4, 6],
},
});
});
return result;
}
/**
* 为 series 列表计算智能最值 markPoint 配置(跨 series 防遮挡)
* @param seriesList - series 列表,每项含 name、data
* @param fixed - 数值小数位数
* @param options - 可选:debug 打印、excludeSeriesName 排除规则
*/
export const useMoveExtPoint = (
seriesList: SeriesItem[],
fixed: number,
options?: UseMoveExtPointOptions,
): Map<number, { data: any[]; label: object }> => {
return useMemo(
() => buildMarkPointsWithCrossSeriesOffset(seriesList, fixed, options),
[seriesList, fixed, options],
);
};
/** 导出纯函数供非 hook 场景使用 */
export { buildMarkPointsWithCrossSeriesOffset };