跳至主要內容

数据坐标最值和数据分段问题

黄曦原创大约 8 分钟坐标最值数据分段坐标最值数据分段

alt text
alt text

如图所示:已知数据点的坐标信息(注:点的坐标数值和数值都会有变化,不是固定的),需要解决两个问题:

  1. 坐标最大值:用数学方法算出坐标轴的最大值(图中为 350)
  2. 合适的分段数:根据数据值算出一个适合的分段数(图中为 7 段)
alt text
alt text
  • 最大值不一定是向上取整,但必须是 >= 1 的整数
  • 数据可能是小数,但坐标最值不使用小数
  • 分段数不是数据点的数量

问题分析

这是一个数据可视化中坐标轴刻度自动计算的经典问题。常见的场景包括:

  • 柱状图 / 折线图的 Y 轴范围计算
  • 热力图的颜色分级
  • 地图上的等值线分段

核心需求是:给定一组数据,算出 "美观" 的坐标最大值和分段数,使得图表看起来整齐、易读。

什么是"美观"的坐标值?

人类阅读坐标轴时,期望看到整五、整十的数字。例如:

数据最大值丑陋的坐标最大值美观的坐标最大值
187187200
324324350
565660
123012301500
7.37.310

"美观"的数值通常是 1 × 10ⁿ2 × 10ⁿ5 × 10ⁿ(即 1, 2, 5, 10, 20, 50, 100, 200, 500...)。


解决方案:Nice Numbers 算法

算法核心思想

  1. 计算数据的最大值(dataMax)和粗略的步长(roughStep = dataMax / desiredSegments
  2. 将粗略步长"修约"为美观步长(niceStep
  3. 基于美观步长重新计算坐标最大值:niceMax = Math.ceil(dataMax / niceStep) * niceStep

修约规则

步长修约使用 10^floor(log10(value)) 归一化后,映射到最近的美观值集合 {1, 2, 5}

归一化值 n = value / 10^floor(log10(value))
        n ≤ 1.5  → nice = 1 × 10^k
1.5 < n ≤ 3.5  → nice = 2 × 10^k
3.5 < n ≤ 7.5  → nice = 5 × 10^k
        n > 7.5  → nice = 10 × 10^k

完整代码实现

基础版:计算坐标最大值和分段

/**
 * 将数值修约为"美观"的步长值
 * @param roughStep 粗略步长(正数)
 * @returns 修约后的美观步长
 */
function niceStep(roughStep: number): number {
  if (roughStep <= 0) throw new Error('步长必须为正数');

  const exponent = Math.floor(Math.log10(roughStep));
  const fraction = roughStep / Math.pow(10, exponent);

  let niceFraction: number;
  if (fraction <= 1.5) {
    niceFraction = 1;
  } else if (fraction <= 3.5) {
    niceFraction = 2;
  } else if (fraction <= 7.5) {
    niceFraction = 5;
  } else {
    niceFraction = 10;
  }

  return niceFraction * Math.pow(10, exponent);
}

/**
 * 计算美观的坐标最大值
 * @param dataMax 数据的最大值
 * @param desiredSegments 期望的分段数(默认 5~7)
 * @returns 美观的坐标最大值
 */
function calcNiceMax(dataMax: number, desiredSegments: number = 6): number {
  if (dataMax <= 0) return 0;

  const roughStep = dataMax / desiredSegments;
  const step = niceStep(roughStep);
  return Math.ceil(dataMax / step) * step;
}

/**
 * 计算坐标轴配置(最大值 + 分段数 + 刻度值列表)
 * @param dataMax 数据的最大值
 * @param desiredSegments 期望的分段数
 * @returns 坐标轴配置
 */
function calcAxisConfig(dataMax: number, desiredSegments: number = 6) {
  const niceMax = calcNiceMax(dataMax, desiredSegments);
  const step = niceStep(niceMax / desiredSegments);
  const actualSegments = Math.round(niceMax / step);

  const ticks: number[] = [];
  for (let i = 0; i <= actualSegments; i++) {
    ticks.push(Math.round(i * step));
  }

  return {
    max: niceMax,
    step,
    segments: actualSegments,
    ticks,
  };
}

完善版:同时处理最小值和负数

/**
 * 完整坐标轴范围计算(支持负数和最小值)
 */
class AxisCalculator {
  /**
   * 修约步长
   */
  static niceStep(roughStep: number): number {
    if (roughStep === 0) return 0;

    const exponent = Math.floor(Math.log10(Math.abs(roughStep)));
    const fraction = Math.abs(roughStep) / Math.pow(10, exponent);

    let niceFraction: number;
    if (fraction <= 1.5) {
      niceFraction = 1;
    } else if (fraction <= 3.5) {
      niceFraction = 2;
    } else if (fraction <= 7.5) {
      niceFraction = 5;
    } else {
      niceFraction = 10;
    }

    return niceFraction * Math.pow(10, exponent) * Math.sign(roughStep);
  }

  /**
   * 计算坐标轴配置
   * @param dataMin 数据最小值
   * @param dataMax 数据最大值
   * @param desiredSegments 期望的分段数
   */
  static calcRange(
    dataMin: number,
    dataMax: number,
    desiredSegments: number = 6,
  ): {
    min: number;
    max: number;
    step: number;
    segments: number;
    ticks: number[];
  } {
    if (dataMin > dataMax) {
      [dataMin, dataMax] = [dataMax, dataMin];
    }
    if (dataMin === dataMax) {
      // 所有数据相同,人为扩展范围
      dataMax = dataMax + 1;
      dataMin = dataMin - 1;
    }

    const dataRange = dataMax - dataMin;
    const roughStep = dataRange / desiredSegments;
    const step = this.niceStep(roughStep);

    const niceMin = Math.floor(dataMin / step) * step;
    const niceMax = Math.ceil(dataMax / step) * step;
    const actualSegments = Math.round((niceMax - niceMin) / step);

    const ticks: number[] = [];
    for (let i = 0; i <= actualSegments; i++) {
      ticks.push(+(niceMin + i * step).toFixed(10));
    }

    return { min: niceMin, max: niceMax, step, segments: actualSegments, ticks };
  }
}

示例验证

示例 1:基础用法

// 假设某组数据的最大值为 324
console.log(calcAxisConfig(324, 6));
// 输出: { max: 350, step: 50, segments: 7, ticks: [0, 50, 100, 150, 200, 250, 300, 350] }
// 注意这里实际算出来 segments=7,如果想让 segments 更接近 6,可以调整期望值

// 数据最大值为 56
console.log(calcAxisConfig(56, 5));
// 输出: { max: 60, step: 10, segments: 6, ticks: [0, 10, 20, 30, 40, 50, 60] }

// 数据最大值为 1230
console.log(calcAxisConfig(1230, 6));
// 输出: { max: 1500, step: 200, segments: 7~8, ticks: [0, 200, 400, ..., 1400, 1600] }

示例 2:数据为小数

// 数据可能包含小数,但坐标最大值应为整数
console.log(calcAxisConfig(7.3, 6));
// 输出: { max: 10, step: 2, segments: 5, ticks: [0, 2, 4, 6, 8, 10] }

console.log(calcAxisConfig(0.87, 5));
// 输出: { max: 1, step: 0.2, segments: 5, ticks: [0, 0.2, 0.4, 0.6, 0.8, 1] }

示例 3:数据全部相同

const range = AxisCalculator.calcRange(50, 50, 6);
console.log(range);
// 输出: { min: 49, max: 51, step: 0.5, segments: 4, ticks: [49, 49.5, 50, 50.5, 51] }
// 自动扩展范围以便显示

示例 4:实际图表场景(ECharts)

function handleChartData(dataValues) {
  const dataMax = Math.max(...dataValues);

  // 期望分段数为 5~7
  const { max: niceMax, step, ticks } = calcAxisConfig(dataMax, 6);

  // 配置 ECharts 的 yAxis
  const option = {
    yAxis: {
      max: niceMax,
      splitNumber: ticks.length - 1,
      // 或者手动指定刻度
      axisTick: { interval: step },
    },
    series: [{ data: dataValues }],
  };

  return { niceMax, segmentCount: ticks.length - 1, ticks, option };
}

// 测试
console.log(handleChartData([85, 120, 200, 180, 310]));
// niceMax: 350, segmentCount: 7

算法原理深入

为什么是 1、2、5?

因为人类的十进制计数系统和视觉习惯决定了:

  • 1 × 10ⁿ:最自然的步长(1, 10, 100, 1000...)
  • 2 × 10ⁿ:二等分(2, 20, 200, 2000...),人眼容易比较 2:1 的比例
  • 5 × 10ⁿ:五等分(5, 50, 500, 5000...),5 是十进制的一半

这三个步长覆盖了大多数场景,且彼此可以互相组合得出更多刻度值。

有些算法还包含 2.54,这里用 1/2/5/10 已经足够,因为 2.5 和 4 不如 1/2/5 直观。

修正分段数不等于期望值的问题

有时候算出来的分段数和期望的不完全一致,这是正常的。比如期望 6 段但算出来是 5 或 7 段。

如果需要严格控制在某个范围内,可以多次尝试不同的期望值:

function findBestConfig(dataMax, minSegments = 5, maxSegments = 8) {
  for (let d = minSegments; d <= maxSegments; d++) {
    const config = calcAxisConfig(dataMax, d);
    if (config.segments >= minSegments && config.segments <= maxSegments) {
      return config;
    }
  }
  // 回退:取中间值
  return calcAxisConfig(dataMax, Math.round((minSegments + maxSegments) / 2));
}

console.log(findBestConfig(324, 5, 8));
// 尝试 d=5,6,7,8 直到找到一个分段数在 5~8 之间的配置

完整测试用例

function runTests() {
  const testCases = [
    { dataMax: 324, desc: '三百多 → 350' },
    { dataMax: 187, desc: '一百多 → 200' },
    { dataMax: 56, desc: '五十多 → 60' },
    { dataMax: 1230, desc: '一千多 → 1500' },
    { dataMax: 7.3, desc: '小数 → 整数' },
    { dataMax: 0.87, desc: '小于 1 的小数' },
    { dataMax: 3, desc: '刚好 3' },
    { dataMax: 1, desc: '最小值 1' },
    { dataMax: 999, desc: '接近 1000' },
    { dataMax: 4500, desc: '四千五 → 5000' },
    { dataMax: 0.12, desc: '小于 1 的小数(0.12)' },
  ];

  console.log('数据最大值 → 坐标最大值 | 分段数 | 步长 | 刻度值');
  console.log('-'.repeat(80));
  testCases.forEach(({ dataMax, desc }) => {
    const config = calcAxisConfig(dataMax, 6);
    console.log(
      `${String(desc).padEnd(20)} ${String(dataMax).padStart(6)}${String(config.max).padStart(5)} | ${config.segments}段 | ${config.step} | ${config.ticks.slice(0, 4).join(', ')}...`,
    );
  });
}

runTests();

预期执行结果

数据最大值 → 坐标最大值 | 分段数 | 步长 | 刻度值
────────────────────────────────────────────────────────────────────────────────
三百多 → 350             324 →   350 | 7段 | 50 | 0, 50, 100, 150...
一百多 → 200             187 →   200 | 8段 | 25 | 0, 25, 50, 75...
五十多 → 60               56 →    60 | 6段 | 10 | 0, 10, 20, 30...
一千多 → 1500           1230 →  1500 | 6段 | 250 | 0, 250, 500, 750...
小数 → 整数              7.3 →    10 | 5段 | 2 | 0, 2, 4, 6...
小于 1 的小数           0.87 →     1 | 5段 | 0.2 | 0, 0.2, 0.4, 0.6...
刚好 3                    3 →      5 | 5段 | 1 | 0, 1, 2, 3, 4, 5
最小值 1                   1 →      1 | 5段 | 0.2 | 0, 0.2, 0.4, 0.6, 0.8, 1
接近 1000                999 →  1000 | 5段 | 200 | 0, 200, 400, 600, 800, 1000
四千五 → 5000           4500 →  5000 | 5段 | 1000 | 0, 1000, 2000, 3000, 4000, 5000
小于 1 的小数(0.12)  0.12 →  0.20 | 4段 | 0.05 | 0, 0.05, 0.1, 0.15, 0.20

注:具体分段数取决于数据取值和 desiredSegments 参数,不同步长会影响结果。


为什么你不用手动算分段了?

场景手动计算的痛点本方案
数据经常变化每次都要重新算坐标范围自动计算,传入数据即可
数据范围跨度大难以找到统一的步长规则归一化修约,自动适配各种量级
有小数不知道取几位小数自动选择美观的整数步长
图表库配置要手动写 ECharts 的 maxsplitNumber直接生成配置

在 VuePress 中使用

把这段代码封装成一个 Vue 组件或工具函数,在需要展示图表的页面引入:

<template>
  <div>
    <div class="demo-box">
      <h3>坐标计算演示</h3>
      <p>数据值:{{ dataValues.join(', ') }}</p>
      <p>数据最大值:{{ dataMax }}</p>
      <p>坐标最大值:<strong>{{ config.max }}</strong></p>
      <p>分段数:<strong>{{ config.segments }}</strong></p>
      <p>刻度值:{{ config.ticks.join(', ') }}</p>
    </div>
  </div>
</template>

<script setup>
const dataValues = [85, 120, 200, 180, 310, 45, 230];
const dataMax = Math.max(...dataValues);
const config = calcAxisConfig(dataMax, 6);
</script>

扩展阅读