ECharts 最值标签智能防遮挡 Hook

23 阅读6分钟

useMoveExtPoint:ECharts 最值标签智能防遮挡 Hook

效果展示

image.png

问题背景

在使用 ECharts 的 markPoint 展示折线图的最大值、最小值时,常遇到以下问题:

  1. 跨 series 重叠:多条折线的最值标签在 X 轴相近位置会相互遮挡
  2. 量纲差异:不同图表 Y 轴量纲不同(几百 vs 几万),固定偏移量效果不佳
  3. 误分组:X 轴接近但 Y 轴高度差很大的点(如 0 与 560)被错误分组,导致不必要的偏移
  4. 标签过长:长 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 列表,每项需包含 namedata
fixednumber数值小数位数,用于格式化与标签长度估算
optionsUseMoveExtPointOptions可选配置

buildMarkPointsWithCrossSeriesOffset(纯函数用法)

useMemo 等非 Hook 场景下使用:

const markPointMap = buildMarkPointsWithCrossSeriesOffset(seriesList, fixed, options);

UseMoveExtPointOptions

选项类型默认值说明
debugbooleanfalse是否在控制台打印每个标签的 series、X 位置、Y 值、偏移量
excludeSeriesName(name: string) => boolean排除名称含「偏差」自定义排除规则,返回 true 的 series 不参与最值计算
offsetScalenumber1偏移量缩放,>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| <= xThreshold
    • xThreshold 由 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 支持:numbernull{ value: number }(如 ECharts 特殊数据格式)
  • 无有效数值的 series 不会生成 markPoint,对应 key 不会出现在 Map 中

调试建议

  1. 开启 debug: true,查看控制台输出的 X 位置、Y 值、偏移量
  2. 若仍有重叠,可尝试 offsetScale: 1.21.3
  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 };