云计算百科
云计算领域专业知识百科平台

《ECharts入门》第 10 章 数据怎么喂进去:dataset、维度与 encode

【本章目标】

学完这一章,你能:

  • 认得出四种喂数据的写法,并说清它们内部其实是同一张表:series.data 的纯数字数组、二维数组、对象数组,以及 dataset.source。你会知道 ECharts 把来源归成哪六种形状(sourceFormat),以及哪种写法必须额外补 xAxis.data。
  • 分清三个词:维度(表的一列)、系列(series[i])、通道(x/y/tooltip/label/itemName/value)。这是初学者最大的一道坎,本章用四张图把它拆开。
  • 看懂「自动排队」并知道它什么时候害你:不写 encode 时 ECharts 替你选列的规则是实测出来的——两个系列拿到 sales 和 cups,第四个系列开始重复用列,纵轴刻度会被最大那一列撑满。
  • 用 encode 把列钉死,包括按下标写、给一个通道写两列、写了一个不存在的列名(实测:不报错,静默回退到默认列)。
  • 给维度起名的两种做法:dataset.dimensions 与 {name, displayName}。你会知道为什么在对象数组上直接改成中文名会画出一张 446 字节的空图,而 displayName 不会。
  • 用 dataset 的链式引用和 transform 做数据整形:一份结果喂多个系列、filter 的六种比较符与 and/or/not、sort 的配置,以及「多层链的输入默认是第 0 份而不是上一层」这条最容易踩的规则。
  • 报出脏值的处理结果:null、undefined、'-'、空字符串、字符串数字、true/false、0、负数分别画成什么(实测对照表),折线在哪里断开、connectNulls 改的是哪一个字符。
  • 从本章起,本书的图都用 data/ 里的真实数据,你会拿到那张「8 个文件的分层地图」,知道后面每一章该取哪一层。

  • 在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    【原理先行】

    一、同一张图,七种喂法

    先约定一件小事:本章所有实测都用同一份 12 行 × 3 列的月表——2025 年逐月的销售额、杯数、订单数(01 月 / 3126085.4 / 110552 / 69039……)。这份表由 data/sales-daily.json 现场聚合出来,聚合代码在本章工程里,你运行一次就能打印全部 12 行。

    同一张「柱 + 折线」的图,可以把数据写成七种样子:

    // 写法 1:series.data 纯数字数组(类目另写给 xAxis.data)
    xAxis: { type: 'category', data: monthly.map((r) => r.month) },
    series: [{ type: 'bar', data: monthly.map((r) => r.sales) },
    { type: 'line', data: monthly.map((r) => r.cups) }]

    // 写法 4:dataset.source 对象数组,不写 encode
    dataset: { source: monthly }, // [{month, sales, cups}, …]
    series: [{ type: 'bar' }, { type: 'line' }]

    // 写法 5:dataset.source 二维数组带表头
    dataset: { source: [['month', 'sales', 'cups'], …] },
    series: [{ type: 'bar', encode: { x: 'month', y: 'sales' } },
    { type: 'line', encode: { x: 'month', y: 'cups' } }]

    // 写法 6:dataset.source 按列对象
    dataset: { source: { month: […], sales: […], cups: […] } }

    实测结果(lib/echarts.min.js 6.1.0,SSR 渲染,680×380):

    写法SVG 字节图形指纹和写法 1 一样吗
    1 series.data 纯数字数组 14,395 39 条 d + 20 段文字,共 1,681 字符 —
    2 series.data 二维数组 14,436 1,681 是
    3 series.data 对象数组 14,126 1,647 否
    4 dataset.source 对象数组 14,436 1,681 是
    5 dataset.source 二维带表头 14,436 1,681 是
    6 dataset.source 按列对象 14,476 1,681 是
    7 dataset.source 二维无表头 + dimensions 14,507 1,681 是

    两点要解释。

    为什么不用字节数判断? 字节数会漂。zrender 给每个图形挂的 class 名是全局递增计数器(zr0-cls-3、zr1-cls-8……),同一张图渲染两次就可能差出 39 字节。可靠的办法是把 SVG 里所有 path 的 d 属性和所有文字按出现顺序串起来,得到一个「图形指纹」——形状没变,指纹就不变。这张表里七个指纹中六个完全相同:这些写法画出来的是同一张图。

    写法 3 为什么不一样? 对象数组喂进 series.data 时,ECharts 不认识你的字段名,类目轴只能按下标编号:实测 x 轴文字变成了 0~11 而不是 01 月~12 月。补上 xAxis.data 之后,写法 3b 的图形又和写法 1 逐字符相同了。结论:series.data 用对象数组必须自己给类目轴补 data;dataset 不用,因为表里第一列就是类目。

    这七种写法不是「都能用,随便挑」。它们的区别在第 3、4、6 节里会一条条暴露出来:能不能一份数据喂多个系列、能不能改名、能不能整形。选型的完整口诀放在参数速查表五。

    二、ECharts 内部只认一张表

    不管你从哪扇门进去,数据最后都被摊成「一张表 + 一份列名」。发布包源码 node_modules/echarts/lib/util/types.js 第 50–55 行把这几种来源形状写死成六个常量:

    内部名什么时候是它实测读到的列
    original 一切 series.data ["x","y"](generic)
    arrayRows 二维数组,有无表头都算它 有表头 → ["date","sales","cups"];无表头且不声明 → ["x","y","value"]
    objectRows 对象数组 表头取自对象的键
    keyedColumns 按列对象 键名即列名
    typedArray Int32Array 之类的定型数组 只在源码判断里出现
    unknown 兜底 —

    typedArray 和 unknown 本章不演示:前者由 sourceManager.js 第 210 行的 isTypedArray(data) 判定,后者是认不出来时的兜底。读者要记住的是前三行加 keyedColumns——四个能用的门,其中 series.data 那一扇门后面没有列名可言。

    这三个字「列名」很重要,因为它决定了第三节那件事:谁是维度。

    三、维度、系列、通道——三个词必须分清

    维度(dimension)= 表的一列。 一份 dataset.source 有 3 列,就是 3 个维度。系列(series)= series 数组的一项。 通道(channel)= 某个图表类型需要的「输入槽位」:柱状图和折线图要 x 和 y,散点图要 x/y,饼图要 itemName 和 value。

    月表(dataset.source) 系列与通道
    ┌────────┬───────────┬────────┐
    │ month │ sales │ cups │ ← 3 个维度(列)
    ├────────┼───────────┼────────┤
    │ 01 月 │ 3126085.4 │ 110552 │
    │ 02 月 │ 3099878.3 │ 109998 │
    │ … │ … │ … │
    └────────┴───────────┴────────┘
    │ │ │
    x 通道 y 通道 y 通道
    ↓ ↓ ↓
    series[0] 柱状图 series[1] 折线图

    你不写 encode 时,ECharts 会替你排队。 规则是实测出来的,一条一条列清楚:

    表(month/sales/cups/orders 四列)系列 0系列 1系列 2系列 3系列 4
    不写 encode,模型层实际读到的 y 列 sales cups orders sales sales
    • 第一列(month)被当作类目,不进队列;剩下三列数值排队发给各系列。
    • 队列表里有三个数值列,但你可以写无限多个系列:第四个系列重新从队头拿 sales,第五个还是 sales(实测读数见上表)。系列数超过数值列数之后是重复,不是报错。
    • 队列按「列在表里的顺序」发,不按列名、不按数值大小。

    排队有两个当场可见的副作用,都在本章页面的模式①上:

    模式① 图例文字 = ["sales","cups"] ← 列名直接成了系列名
    模式① y 轴刻度 = 0 ~ 5,000,000 ← 被最大那一列(销售额)撑满
    模式② 图例文字 = ["杯数","订单数"] ← 写了 name/encode 之后
    模式② y 轴刻度 = 0 ~ 180,000

    第二条最能害人的是:你想看杯数趋势,折线却和销售额共用一把 0~5,000,000 的尺子,杯数那 11 万点上去就是一根贴地的直线。这就是「不写 encode 也能跑」这句话的代价。

    不写 encode:ECharts 替你选 写 encode:你钉死
    ┌──────────┐ ┌────────┐ ┌──────────┐ 指定 ┌────────┐
    │ 系列 bar │◄──┤ sales │ │ 系列 bar │◄──────┤ cups │
    │ 系列 line│◄──┤ cups │ │ 系列 line│◄──────┤ orders │
    │ (队列第1)│ ├────────┤ └──────────┘ 钉住 └────────┘
    │ │ │ orders │ ▲ 谁都不再去排队
    └──────────┘ └────────┘

    四、encode:把列钉到通道上

    encode 写在系列上(不是写在 dataset 上),值是「通道名 → 维度」的映射:

    series: [{ type: 'bar', encode: { x: 'month', y: 'cups' } }]

    维度可以用列名,也可以用下标,实测等价:

    写法实际用的 y 列y 轴刻度SVG 字节
    encode: { x: 'date', y: 'avgTicket' } avgTicket 0 ~ 60 6,119
    encode: { x: 0, y: 4 }(avgTicket 是第 4 列) avgTicket 0 ~ 60 同上
    encode: { x: 'date', y: 'orders' } orders 0 ~ 1,500 5,833
    encode: { x: 0, y: 3 } orders 0 ~ 1,500 5,833

    除了 x/y,还有三个非几何通道,它们决定「显示什么数字」而不是「画多高」:

    encode: { x: 'date', y: 'sales', label: ['cups', 'orders'], tooltip: 'avgTicket' }

    实测规则(都用同一份 8 列日表,12 行):

  • 不写 encode 时,默认标签和默认提示框都只显示 y 那一维:模型层读数 defaultedLabel = ["sales"]、defaultedTooltip = ["sales"]。
  • encode.label = 'cups' → defaultedLabel 变成 ["cups"],柱顶标签打印 2358。
  • encode.label = ['cups','orders'] → 两个值用空格连起来打印:2358 1324。
  • encode.tooltip = ['cups','orders'] → 只有提示通道变了,标签仍是 ["sales"]。两条通道各管各的。
  • encode.y = ['sales','cups'] → 柱高取第一列(轴 0~70,000 没变),但默认标签取了 cups(打印 2358)。给一个通道写多列时,几何取第一列,默认标签取你写的那几列。
  • encode.tooltip 写一个不存在的列名,不报错、不警告:defaultedTooltip 回退成 ["sales"],图画得和没写一样。这类静默回退是本章坑 6 的主题。
  • 饼图的通道叫 itemName 和 value(第 16 章细讲):

    dataset: { source: [{ cat: '甲', amt: 12 }, { cat: '乙', amt: 8 }] },
    series: [{ type: 'pie', encode: { itemName: 'cat', value: 'amt' } }] // 实测正常,1,905 字节

    五、给维度起名字:dimensions 与 displayName

    列名会直接变成图例文字和默认系列名(第三节实测 ["sales","cups"]),所以人人都想改名。有两种写法,只有一种是安全的:

    // 写法 A:重新定义列名(危险)
    dataset: { source: monthly, dimensions: ['月份', '销售额', '杯数', '订单数'] }

    // 写法 B:保留列名,只加展示名(安全)
    dataset: { source: monthly, dimensions: [
    { name: 'month', displayName: '月份' },
    { name: 'sales', displayName: '销售额' }
    ] }

    实测(同一个对象数组源、一个柱状图系列):

    写法结果
    A:对象数组 + dimensions 改成中文名 空图,446 字节,<text> 数量 0,柱子 0 根,不报错
    A’:同上,但 encode 改用下标 {x:0, y:1} 同样空图,446 字节
    B:{name, displayName} 正常 4,916 字节;图例显示 销售额/杯数
    C:二维数组带表头 + dimensions 只给 2 个名字(源有 3 列) 正常,4,916 字节
    D:按列对象 + dimensions: ['日期','销售额'](键名本来就是中文) 正常

    差异的根因是「dimensions 是重新定义列名,不是贴标签」:对象数组的取值靠列名去 row[列名] 里拿,你把列名改成 销售额 之后,源里没有这个键,每一列都取不到值——连改用下标编码都救不回来(A’ 那行)。而二维数组和按列对象上 dimensions 是安全的,因为前者本来就没有自带列名(表头被覆盖是应该的),后者的键名就是你写的名字。

    displayName 只影响「显示」,取值仍用 name。还有一个不对称的细节值得记:

    // 轴标签和图例用的是 displayName,但回调参数里给的是原始 name
    tooltip: { formatter: (p) => p.dimensionNames.join(',') } // 实测打印 "date,sales",不是 "日期,销售额"

    所以 dimensions 数组请按源里的键名原样抄一遍再改名,或者直接用它更安全的形式 {name, displayName}——本书后面所有章节都只用后者。

    六、seriesLayoutBy:把表转置过来读

    默认读法是「一列一个系列」('column')。Excel 里更常见的表是「一行一个指标」:

    01 月 02 月 03 月 … 12 月
    销售额 3126085 3099878 3091234 … 4068117
    杯数 110552 109998 109590 … 141106
    订单数 69039 69696 69374 … 88282

    这种表只要给 dataset 加一行 seriesLayoutBy: 'row':

    dataset: { source: transposed, seriesLayoutBy: 'row' }, // transposed 是上面那张表的二维数组
    series: [{ type: 'bar' }, { type: 'line' }]

    实测(本章页面模式④):图例文字是 销售额 和 杯数——系列名自动取了每行的第一个格子;类目轴仍是 01 月~12 月。也就是说行布局下「第一行 = 类目名、第一列 = 系列名」,和列布局正好交换。

    三条边界:

    • series: [](一个系列都不写)在行布局下不会自动展开成三行:实测系列数 0,图上没有任何数据图形。行布局只改变读法,不替你造系列。
    • 对象数组上写 seriesLayoutBy: 'row' 没有任何作用:实测 undefined/'column'/'row' 三种写法得到逐字节相同的 6,468 字节 SVG。对象数组没有「行/列」可转,这条规则写在源码的读表逻辑里,不在配置层。
    • 这个字段 dataset 上和 series 上都能写,写在系列上表示「只有这个系列按行读」(第 33 章会用到混合布局)。

    七、一份数据喂多个系列:datasetIndex、链式引用与 transform

    dataset 写成数组就有了「多份表」,这时必须讲清楚谁吃哪一份:

    dataset: [
    { id: 'raw', source: monthly }, // 第 0 份
    { id: 'big', fromDatasetId: 'raw', transform: [ // 第 1 份:吃第 0 份,先过滤再排序
    { type: 'filter', config: { dimension: 'sales', '>': 4000000 } },
    { type: 'sort', config: { dimension: 'sales', order: 'desc' } }
    ] }
    ],
    series: [{ type: 'bar', datasetId: 'big', encode: { x: 'month', y: 'sales' } }]

    四条实测规则:

  • 系列默认只吃第 0 份。 两份 dataset、两个系列都不写 datasetIndex/datasetId 时,实测两个系列读到的都是第 0 份(各 12 行,month/sales/cups/orders 四列齐全),第 2 份根本没被看过一眼。认领靠 series.datasetIndex(下标)或 series.datasetId(名字),实测后者生效:柱吃 d/v、线吃 avgTicket。
  • transform 只能写在 dataset 上。 把它写到系列上是合法的键名、无效的写法:实测 12 行全在,一条都没被过滤掉。
  • 多层链的输入默认是第 0 份,不是它的上一层。 这条最容易错:
  • dataset: [
    { source: src }, // 4 行
    { transform: { type: 'filter', config: { dimension: 'sales', '>': 40000 } } }, // 应剩 2 行
    { transform: { type: 'sort', config: { dimension: 'sales', order: 'desc' } } } // ← 输入是谁?
    ]
    // series: [{ type: 'bar', datasetIndex: 2 }]

    实测:系列读到 4 行(66005, 46883, 39529, 32359,全表降序)——第 2 层根本没吃到过滤结果,因为没声明输入时默认从第 0 份取。加上 fromDatasetIndex: 1 之后立刻变成 2 行(66005, 46883),写成 fromDatasetId 也一样。

    所以:能用一层管道就别拆成多层。 一个 dataset 里写 transform: [filter, sort] 是数组顺序执行的(实测同样得 2 行、同样的顺序);反过来写 [sort, filter] 得到的是「先排序再挑」的结果,顺序变了输出行的次序就变(实测 6 行月份从 01 07 06 08 11 10 变成 10 11 08 06 07 01)。transform 数组就是流水线,书写顺序即执行顺序。

  • 一份整形结果可以同时喂多个系列,这正是 dataset 的价值:
  • dataset: [{ source: monthly },
    { transform: [{ type: 'filter', config: { dimension: 'sales', '>': 4000000 } }] }],
    series: [{ type: 'bar', datasetIndex: 1, encode: { x: 'month', y: 'sales' } },
    { type: 'line', datasetIndex: 1, encode: { x: 'month', y: 'cups' } }]

    实测两个系列各 6 行,过滤只做了一次。写三份链、让两个系列分别认领第 1 层和第 2 层也合法,但那两个系列的类目顺序不同,会共用一根类目轴——实测轴上冒出 12 个类目文字(前 6 个原序、后 6 个降序)。同一张图上的多个系列应当吃同一份结果。

    filter 与 sort 是 6.1.0 唯一的两个内置整形器(源码 node_modules/echarts/lib/component/transform/filterTransform.js 第 48 行、同目录的 sortTransform.js 第 54 行)。它们的注册名带 echarts: 前缀,但配置里必须写不带前缀的名字:

    你写结果
    type: 'filter' / type: 'sort' 正常
    type: 'echarts:filter' 抛错。生产版 Error:(消息是空的),开发版 Can not find transform on type "echarts:filter".
    type: 'aggregate'、type: 'print' 同样抛错——聚合不是内置 transform,得自己写(第 33 章)

    filter 的配置是一个「关系条件对象」,运算符有 >、>=、<、<=、=、!=、<>、value、reg 这些(实测 >、>=、=、reg 都可用),复合条件用 and/or/not 嵌套:

    transform: { type: 'filter', config: {
    or: [{ dimension: 'sales', '>': 6000000 }, { not: { dimension: 'openStores', '>=': 5 } }]
    } }

    sort 只要 {dimension, order: 'asc'|'desc'},也可以给一个数组按多列排。sort 只吃二维数组和对象数组:源是按列对象时实测抛错(开发版消息 Can not find dimension info via: sales. Existing dimensions: [{"index":0},{"index":1}])。

    八、缺失值与脏值:ECharts 怎么判空

    这是本章最后一块,也是主线数据故意埋的雷。data/sales-daily.json 里有四个显式保留的缺失日(2025-03-14、2025-03-15、2025-07-22、2025-11-03),它们的数值字段是 null。

    2025-03 逐日销售额(31 天,第 14、15 日是 null)
    ┌──┬──┬──┬──┬──┬──┬──┬──┬──┐
    │13│ │ │ │16│17│18│19│20│ ← 实测 29 根柱子,不是 31 根
    └──┴──┴──┴──┴──┴──┴──┴──┴──┘
    ▲ ▲
    null null 空位不画,但类目还在(轴上仍占一格)

    折线断在哪里,connectNulls 改的又是什么:

    connectNulls 不写 / false connectNulls: true
    M110.2 156.4 L… M110.2 156.4 L…
    …L603.8 201 …L603.8 201
    ┌─ M 出现 2 次 ─┐ ┌─ M 出现 1 次 ─┐
    一条 path,两段线,中间断开放大 一条 path,一段线,直接跨过去

    实测细节(d 属性长度两种情况都是 331 字符,SVG 都是 13,227 字节):M(移到某点)和 L(画到某点)都是一个字母,断点只多一个 M,字节数完全一样。 所以「看字节有没有变」判断不了断线,得数 M 的个数——本章页面右下角那个读数就是在数它。

    完整的脏值对照表(值放在 series.data 里和放在 dataset 的一列里,行为完全相同;4 个数据点 10, <脏值>, 30, 40,数 fill="#5070dd" 的 path 得柱数):

    输入解析出的第 2 个值画出的柱子说明
    正常数字 20 20 4 —
    null NaN 3 不画,也不报错
    undefined NaN 3 同上
    字符串 '-' NaN 3 官方文档推荐的「无数据」写法,等价于 null
    空字符串 '' NaN 3
    字符串数字 '20' 20 4 会被转成数字,CSV 直接喂进来能用
    字符串小数 '22.75' 22.75 4 同上
    带单位 '20 元' NaN 3 单位必须自己剥(第 33 章)
    NaN NaN 3
    0 0 4 0 是有效值,画出一根高度为 0 的柱(实测 d 里是 l0 0)
    false 0 4 被当 0
    true 1 4 被当 1
    空对象 {} NaN 3
    字符串日期 '2023-01-01' NaN 3 数值轴不吃日期字符串,轴请用 type: 'time'

    类目轴上的脏值是另一回事——它们不丢数据,只是变成难看的标签文字:['a', null, 'c', 'd'] 实测轴上印出 a null c d(null 被转成了字符串),['a', '-', 'c', 'd'] 印 -,['a', '', 'c', 'd'] 那一格什么都不印。

    饼图对这些值更不宽容(每项写成 {name, value}):

    饼图的数据实测扇形 path 数标签文字
    12, 8 2 项0、项1
    12, null / 12, '-' 1 只有项0
    12, 0 1 项0、项1(0 值项有标签但没有角度)
    12, -5 1 只有项0
    12, '8' 2 项0、项1(字符串数字照算)

    九、一维数据与二维数据:图形适配规则

    同一个源,喂给不同的图表类型需要的维数不一样。实测适配表:

    你给的形状柱状图 / 折线图散点图饼图
    一维数字数组(series.data) ✅ 按下标当类目 ⚠️ 只有 x 有值,y 是 null(实测符号数 0) ✅ 值即角度,无名字
    二维数组 [[12],[8],[20]] ⚠️ 类目轴上 0 根柱(实测 949 字节) ✅ 两个数当 x/y ✅ 3 个扇形
    二维数组 [['甲',12],['乙',8]] ✅ 第一维当类目 ✅ ❌ 有元素无几何(见坑 4)
    对象数组 ✅ 需要 encode 或排队 ✅ 两个数值维自动成 x/y ✅ 字段名是 name/value 时自动,否则要 encode
    dataset 只有一列 ❌ 0 根柱(958 字节) ❌ —
    二维无表头且不写 dimensions ✅ 但列名退化成 x/y/value ✅ —

    两条需要记住的机制:

    单列表会被补一个空的 y 维度。 实测 dataset: { source: { v: [1,2,3] } } 的模型层读数是 dims: ["v","y"]——ECharts 给缺的那个槽位造了个名叫 y 的维度,但里面一个值都没有,于是柱高/线高全部取不到,图上只有轴和文字(958 字节、0 根柱)。折线也一样:1,503 字节、0 条线。 修法就是「补一列,或者把同一列同时编码给 y」。

    二维数组喂给类目轴时,第一维会被当成 x 值。 [[12],[8],[20]] 里的 12 会被拿去类目表里找「类目 12」,找不到就一根柱都不画(实测系列读数 count: 3 但 y 全为 null)。类目轴 + 二维数据,第一维必须是类目名。


    【最小可跑】

    本章的工程是一张「喂法对比台」:上面一张图配五个模式按钮,中间两张小图专管缺失值,最下面一段文字把 data/sales-daily.json 的体检结果和聚合表打印出来。建 ch10 文件夹,两个文件。

    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
    <meta charset="UTF-8">
    <title>第 10 章 数据怎么喂进去</title>
    <style>
    body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; }
    h1 { font-size: 20px; margin: 0 0 6px; }
    h2 { font-size: 15px; margin: 26px 0 6px; border-left: 4px solid #8a5a2b; padding-left: 8px; }
    #status { background: #eef5fb; border-left: 4px solid #3d7ab8; padding: 8px 12px; margin: 10px 0 14px; }
    #modeInfo { background: #fff8ea; border-left: 4px solid #b6902f; padding: 8px 12px; margin: 8px 0 0;
    font-size: 13px; min-height: 3.4em; }
    #report { background: #f6f4f1; border: 1px solid #d9d2c7; padding: 12px; margin: 0;
    white-space: pre; overflow: auto; font: 12px/1.7 Consolas, monospace; }
    button { padding: 6px 12px; margin: 0 8px 8px 0; cursor: pointer; }
    p.tip { font-size: 12px; color: #6f675c; margin: 6px 0 0; }
    </style>
    </head>
    <body>
    <h1>同一份月表,五种喂法——看清楚 ECharts 替你选了哪一列</h1>
    <p id="status">正在读 data/sales-daily.json ……(本页用了 fetch,必须用 node tools/serve.mjs 打开)</p>

    <h2>一、喂法对比台</h2>
    <div id="chart" style="width:680px; height:380px;"></div>
    <p>
    <button id="btnAuto">① 自动排队(不写 encode)</button>
    <button id="btnEncode">② encode 钉住(杯数 / 订单数)</button>
    <button id="btnFilter">③ transform(filter + sort 链)</button>
    <button id="btnRow">④ 行布局(表转置)</button>
    <button id="btnName">⑤ displayName 改名</button>
    </p>
    <p id="modeInfo"></p>

    <h2>二、缺失日:31 天里有两天是 null</h2>
    <div id="dirty" style="width:680px; height:300px;"></div>
    <p class="tip" id="dirtyInfo"></p>

    <h2>三、断点:折线在 null 处怎么断</h2>
    <div id="gap" style="width:680px; height:300px;"></div>
    <p><button id="btnGap">切换 connectNulls</button></p>
    <p class="tip" id="gapInfo"></p>

    <h2>四、体检报告与聚合表</h2>
    <pre id="report"></pre>

    <script src="../../lib/echarts.min.js"></script>
    <script type="module" src="app.js"></script>
    </body>
    </html>

    // 第 10 章:主线数据登场。一份 1096 条的逐日表,先聚合成月表,再用五种喂法画同一张图。
    async function loadDaily() {
    const resp = await fetch('../../data/sales-daily.json');
    if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
    return resp.json();
    }

    // 聚合:先求和再四舍五入到分。本章页面上打印的月表、正文里引用的每个数字都出自这两行。
    const sum = (rows, key) => rows.reduce((a, r) => a + (r[key] || 0), 0);
    const round2 = (v) => Math.round(v * 100) / 100;

    function toMonthly(rows, year) {
    const out = [];
    for (let m = 1; m <= 12; m++) {
    const tag = year + '-' + String(m).padStart(2, '0');
    const sub = rows.filter((r) => r.date.startsWith(tag));
    out.push({ month: tag.slice(5) + ' 月', sales: round2(sum(sub, 'sales')),
    cups: sum(sub, 'cups'), orders: sum(sub, 'orders') });
    }
    return out;
    }

    function grouped(v, digits) {
    const parts = Math.abs(v).toFixed(digits || 0).split('.');
    parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
    return (v < 0 ? '-' : '') + parts.join('.');
    }

    const chart = echarts.init(document.getElementById('chart'), null, { renderer: 'svg' });
    const dirtyChart = echarts.init(document.getElementById('dirty'), null, { renderer: 'svg' });
    const gapChart = echarts.init(document.getElementById('gap'), null, { renderer: 'svg' });
    const modeInfo = document.getElementById('modeInfo');

    // 数一数图上真画出了几个图形。SVG 渲染器才有 DOM 可数,canvas 渲染下返回 null。
    function countBars(el) {
    const svg = el.querySelector('svg');
    return svg ? svg.querySelectorAll('path[fill="#5070dd"]').length : null;
    }

    // 折线断开这件事只有 M 的个数能证明:一条 path 里出现两次 M 就是断开了
    function readLine(el) {
    const svg = el.querySelector('svg');
    if (!svg) return null;
    const paths = Array.from(svg.querySelectorAll('path')).filter(
    (p) => p.getAttribute('stroke') === '#5070dd' && p.getAttribute('stroke-width') === '2'
    );
    return {
    paths: paths.length,
    moves: paths.map((p) => ((p.getAttribute('d') || '').match(/M/g) || []).length),
    symbols: Array.from(svg.querySelectorAll('path')).filter(
    (p) => (p.getAttribute('d') || '').indexOf('M1 0A1') === 0
    ).length
    };
    }

    let monthly = [];
    let transposed = [];
    let marchDays = [];
    let marchSales = [];
    let marchSource = [];
    let connectNulls = false;

    function showAuto() {
    chart.setOption({
    title: { text: '模式① 自动排队:两个系列都不写 encode', subtext: '图例是列名,刻度被最大那一列撑满' },
    legend: {},
    tooltip: {},
    xAxis: { type: 'category' },
    yAxis: { type: 'value' },
    dataset: { source: monthly },
    series: [{ type: 'bar' }, { type: 'line' }]
    }, { replaceMerge: ['dataset', 'series'] });
    modeInfo.textContent = '看图例:sales / cups——这就是列名当系列名的样子。看纵轴:0~5,000,000,'
    + '杯数(十万级)被销售额(百万级)压成贴地的直线。不写 encode,ECharts 就替你排队选了前两个数值列。';
    }

    function showEncode() {
    chart.setOption({
    title: { text: '模式② encode 钉住:柱=杯数、线=订单数', subtext: '同一份 dataset,只多了 encode 和 name' },
    legend: {},
    tooltip: {},
    xAxis: { type: 'category' },
    yAxis: { type: 'value' },
    dataset: { source: monthly },
    series: [
    { name: '杯数', type: 'bar', encode: { x: 'month', y: 'cups' } },
    { name: '订单数', type: 'line', encode: { x: 'month', y: 'orders' } }
    ]
    }, { replaceMerge: ['dataset', 'series'] });
    modeInfo.textContent = '同一份表,图例变成杯数 / 订单数,纵轴回到 0~180,000。'
    + 'encode 不改变数据,只改变「哪一列接到哪个通道」——这就是钉住的价值。';
    }

    function showFilter() {
    chart.setOption({
    title: { text: '模式③ transform:先筛出月销售额 > 400 万,再降序', subtext: '第 1 份 dataset 吃第 0 份,系列用 datasetId 认领' },
    tooltip: {},
    xAxis: { type: 'category' },
    yAxis: { type: 'value' },
    dataset: [
    { id: 'raw', source: monthly },
    { id: 'big', fromDatasetId: 'raw', transform: [
    { type: 'filter', config: { dimension: 'sales', '>': 4000000 } },
    { type: 'sort', config: { dimension: 'sales', order: 'desc' } }
    ] }
    ],
    series: [{ type: 'bar', datasetId: 'big', encode: { x: 'month', y: 'sales' } }]
    }, { replaceMerge: ['dataset', 'series'] });
    const n = monthly.filter((r) => r.sales > 4000000).length;
    const hit = countBars(document.getElementById('chart'));
    modeInfo.textContent = '月销售额超过 400 万的有 ' + n + ' 个月,图上画了 ' + (hit === null ? '(请用浏览器打开)' : hit) +
    ' 根柱子,顺序是降序。注意 series 上的 datasetId:不认领的话系列仍然吃第 0 份、过滤等于白做。';
    }

    function showRowLayout() {
    chart.setOption({
    title: { text: '模式④ 行布局:一行一个指标', subtext: 'seriesLayoutBy: row,系列名自动取每行第一个格子' },
    legend: {},
    tooltip: {},
    xAxis: { type: 'category' },
    yAxis: { type: 'value' },
    dataset: { source: transposed, seriesLayoutBy: 'row' },
    series: [{ type: 'bar' }, { type: 'line' }]
    }, { replaceMerge: ['dataset', 'series'] });
    modeInfo.textContent = '这张表被转置成「月份当第一行」,seriesLayoutBy: row 让 ECharts 横着读。'
    + '图例是 销售额 / 杯数——它们来自每行的第一个格子,不是你以为的 displayName。';
    }

    function showDisplayName() {
    chart.setOption({
    title: { text: '模式⑤ displayName:列名保持英文,显示用中文', subtext: 'dimensions: [{name, displayName}]' },
    legend: {},
    tooltip: {},
    xAxis: { type: 'category' },
    yAxis: { type: 'value' },
    dataset: {
    source: monthly,
    dimensions: [
    { name: 'month', displayName: '月份' },
    { name: 'sales', displayName: '销售额' },
    { name: 'cups', displayName: '杯数' },
    { name: 'orders', displayName: '订单数' }
    ]
    },
    series: [{ type: 'bar' }, { type: 'line' }]
    }, { replaceMerge: ['dataset', 'series'] });
    modeInfo.textContent = '和模式①同一份数据、同样不写 encode,图例却是中文——因为 displayName 只改显示、不改取值用的键名。'
    + '把 dimensions 直接写成中文名会得到一张空图(本章坑 2)。';
    }

    function drawMarch() {
    dirtyChart.setOption({
    title: { text: '2025 年 3 月逐日销售额(dataset + encode)', subtext: '第 14、15 日是 null' },
    tooltip: { trigger: 'axis' },
    grid: { left: 74, right: 20, top: 60, bottom: 60 },
    xAxis: { type: 'category' },
    yAxis: { type: 'value' },
    dataset: { source: marchSource },
    series: [{ type: 'bar', encode: { x: 'day', y: 'sales' } }]
    });
    gapChart.setOption({
    title: { text: '同一份数据画折线', subtext: 'connectNulls: false' },
    tooltip: { trigger: 'axis' },
    grid: { left: 74, right: 20, top: 60, bottom: 60 },
    xAxis: { type: 'category', data: marchDays },
    yAxis: { type: 'value' },
    series: [{ type: 'line', data: marchSales }]
    });
    const hit = countBars(document.getElementById('dirty'));
    document.getElementById('dirtyInfo').textContent =
    '31 天里画出的柱子:' + (hit === null ? '(请用浏览器打开这一页才数得到)' : hit) +
    ' 根。null 的那两天类目格子还在,只是没有柱——所以轴上仍然占位。';
    readGapInfo();
    }

    function readGapInfo() {
    const r = readLine(document.getElementById('gap'));
    if (!r) {
    document.getElementById('gapInfo').textContent = '(这一段要在浏览器里看:canvas 渲染或没有 DOM 时数不出来。)';
    return;
    }
    document.getElementById('gapInfo').textContent =
    'connectNulls=' + connectNulls + ' → 线条 path 数 ' + r.paths + ',每条里的 M 个数 ' + JSON.stringify(r.moves) +
    ',符号(小圆点)数 ' + r.symbols + '。M 出现两次就是断成了两段;把字节数拿去对比是看不出区别的。';
    }

    document.getElementById('btnGap').addEventListener('click', () => {
    connectNulls = !connectNulls;
    gapChart.setOption({
    title: { text: '同一份数据画折线', subtext: 'connectNulls: ' + connectNulls },
    series: [{ type: 'line', data: marchSales, connectNulls: connectNulls }]
    });
    readGapInfo();
    });

    document.getElementById('btnAuto').addEventListener('click', showAuto);
    document.getElementById('btnEncode').addEventListener('click', showEncode);
    document.getElementById('btnFilter').addEventListener('click', showFilter);
    document.getElementById('btnRow').addEventListener('click', showRowLayout);
    document.getElementById('btnName').addEventListener('click', showDisplayName);

    function report(rows) {
    const missing = rows.filter((r) => r.missing).map((r) => r.date);
    const promoDays = rows.filter((r) => r.promotion).length;
    const promoNames = new Set(rows.map((r) => r.promotion).filter(Boolean));
    const keys = Object.keys(rows[0]);
    const nulls = keys.map((k) => k + ' ' + rows.filter((r) => r[k] === null || r[k] === undefined).length);
    const keySets = new Set(rows.map((r) => Object.keys(r).join(',')));
    const lines = [];
    lines.push('条数 ' + rows.length + ',起 ' + rows[0].date + ',止 ' + rows[rows.length – 1].date +
    ',覆盖月份 ' + new Set(rows.map((r) => r.date.slice(0, 7))).size + ' 个');
    lines.push('显式缺失日 ' + missing.length + ' 个:' + missing.join('、'));
    lines.push('促销日 ' + promoDays + ' 个,促销名 ' + promoNames.size + ' 种');
    lines.push('各列空值(null/undefined)数:' + nulls.join(','));
    lines.push('键集合种类 ' + keySets.size + ' 种(缺失日多了 missing 键,所以不统一)');
    lines.push('');
    lines.push('2025 逐月聚合(本页正文引用的月表)');
    lines.push('月份 销售额 杯数 订单数');
    for (const r of monthly) {
    lines.push(r.month + ' ' + grouped(r.sales, 2).padStart(14) + ' ' +
    grouped(r.cups).padStart(8) + ' ' + grouped(r.orders).padStart(8));
    }
    lines.push('');
    lines.push('月销售额 > 4,000,000 的月份:' + monthly.filter((r) => r.sales > 4000000).map((r) => r.month).join('、'));
    document.getElementById('report').textContent = lines.join('\\n');
    }

    try {
    const rows = await loadDaily();
    monthly = toMonthly(rows, '2025');
    transposed = [
    ['月份', …monthly.map((r) => r.month)],
    ['销售额', …monthly.map((r) => r.sales)],
    ['杯数', …monthly.map((r) => r.cups)],
    ['订单数', …monthly.map((r) => r.orders)]
    ];
    const march = rows.filter((r) => r.date.startsWith('2025-03'));
    marchDays = march.map((r) => r.date.slice(8) + ' 日');
    marchSales = march.map((r) => r.sales);
    marchSource = marchDays.map((d, i) => ({ day: d, sales: marchSales[i] }));

    const cities = rows.length;
    document.getElementById('status').textContent =
    '读到了 ' + cities + ' 条逐日记录,聚合成 2025 年的 12 行月表,本章下面五张图都来自它。';
    showAuto();
    drawMarch();
    report(rows);
    } catch (err) {
    document.getElementById('status').textContent = '加载失败:' + err.message +
    '。请确认已经运行 node tools/serve.mjs,并用 http://127.0.0.1:18000/code/ch10/index.html 打开本页。';
    }

    window.addEventListener('resize', () => {
    chart.resize();
    dirtyChart.resize();
    gapChart.resize();
    });

    跑起来应当看到(这台机器上刷出来的数字,你跑一遍应当一致):

    • 状态条:读到了 1096 条逐日记录,聚合成 2025 年的 12 行月表,本章下面五张图都来自它。
    • 模式①:图例 sales / cups,纵轴最高 5,000,000,折线贴地。
    • 2025-03 那张柱状图:29 根柱子(31 天里画出的柱子:29 根),第 14、15 日的格子空着。
    • #gapInfo 初始:connectNulls=false → 线条 path 数 1,每条里的 M 个数 [2],符号(小圆点)数 29;点一次按钮变成 [1],符号数仍是 29。
    • 报告区的月表 12 行,第一行 01 月 3,126,085.40 110,552 69,039,最后一行 12 月 4,068,117.30 141,106 88,282。

    【逐步加料】

    第一步:先读体检报告,再画图

    node tools/serve.mjs 之后打开 code/ch10/index.html,先把目光放在最下面那段报告上——它是本章后面所有数字的出处:

    条数 1096,起 2023-01-01,止 2025-12-31,覆盖月份 36 个
    显式缺失日 4 个:2025-03-14、2025-03-15、2025-07-22、2025-11-03
    促销日 48 个,促销名 11 种
    各列空值(null/undefined)数:date 0,sales 4,cups 4,orders 4,avgTicket 4,newMembers 4,openStores 0,promotion 1048
    键集合种类 2 种(缺失日多了 missing 键,所以不统一)

    最后一行值得停一下:这份 JSON 里所有对象的键并不完全一致——正常记录 8 个键,四个缺失日多一个 missing: true。这对 dataset 无害(列名取自第一条记录,后面多出来的键被忽略),但如果你的代码写成 rows.map((r) => r.missing),就会得到一堆 undefined。data/README.md 里对这件事有登记。

    openStores 那一列的空值是 0——因为四个缺失日那天它的值是数字 0(没营业),不是 null。这两个「零」在图上长得一模一样,但含义完全不同(第 13 章讲怎么分开处理)。

    第二步:按模式①,看清排队

    打开页面时就是模式①。看图例(sales / cups),看纵轴上限(5,000,000)。现在做一件有破坏性的事:把 series 里那两个系列改成三个({type:'bar'}、{type:'line'}、{type:'bar'}),刷新。实测第三个系列拿到 orders,图例变成 sales / cups / orders 三项——因为月表里正好还有第三个数值列。再加第四个({type:'line'}),它重新拿 sales。

    这就是原理先行第三节那张队列表。加完记得删掉——本章后面的对比都建立在「两个系列」上。

    第三步:按模式②,把列钉住

    对比模式①和②的两处变化:图例从列名变成 杯数 / 订单数(这是 name 给的,不是 encode 给的);纵轴从 5,000,000 变成 180,000(这是 encode 给的)。两件事各自独立:只写 name 不写 encode,刻度还是被销售额撑满;只写 encode 不写 name,图例显示的是你钉住的那个列名。

    打开 Console 抄一句进去,能直接看到模型层的读数(getModel() 是内部 API,生产版包里也在):

    const sm = chart.getModel().getSeries()[0];
    sm.getSource().sourceFormat; // 'objectRows'
    sm.getData().mapDimension('x'); // 'month'
    sm.getData().mapDimension('y'); // 'sales'(模式①)/ 'cups'(模式②)
    sm.getData().mapDimensionsAll('defaultedLabel'); // ['sales'] —— 默认标签也听 y 的

    第四步:按模式③,看 transform 与认领

    模式③画 6 根柱子,顺序 08 月、10 月、07 月、12 月、06 月、09 月(销售额降序)。报告区最后一行列出的月份就是这 6 个,两者必须一致。

    现在把 series 里的 datasetId: 'big' 删掉,刷新:柱子立刻变成 12 根、月份恢复原序——系列退回吃第 0 份,transform 全白做。这就是原理先行第七节第一条。想按顺序验证链式引用,再把 fromDatasetId: 'raw' 也删掉,第 1 份 dataset 的输入会缺,实测抛 Error:(生产版消息为空)。

    第五步:按模式④,摸一下行布局

    模式④的图和模式②几乎一样,但数据形状换了:transposed 是一个「月份在第一行、每行一个指标」的二维数组,靠 dataset.seriesLayoutBy: 'row' 横着读。图例 销售额 / 杯数 来自每行的第一个格子。

    做个实验:把 series 改成 [](一个系列都不写)。实测图上没有任何数据图形、系列数 0——行布局不会把「有几行」自动变成「有几个系列」,系列仍要你自己写出来。

    第六步:按模式⑤,比较两种改名

    模式⑤和模式①是同一份数据、同样的两个不写 encode 的系列,唯一区别是多了 dimensions: [{name, displayName}]。图例从英文变中文,排队规则一点没变(柱仍是 sales、线仍是 cups)。

    想验证「直接改列名会空图」,把这段替进去刷新:

    dataset: { source: monthly, dimensions: ['月份', '销售额', '杯数', '订单数'] },
    series: [{ type: 'bar' }]

    实测 446 字节、一根柱子都没有、Console 里一条警告都没有。把 encode 改成下标 {x: 0, y: 1} 也一样是空图——因为取不到值不是编码找不到列,是列本身已经和源里的键脱钩了。

    第七步:把缺失日单独拎出来看

    #dirty 用的是 dataset + encode,#gap 用的是 series.data——同一个 marchSales 数组、同样两个 null,两条路都画出 29 根柱子。null 的行为不随喂法改变。

    数一下轴上的格子:01 日 到 31 日 全在,14 日 15 日 两格只是没有柱。「数据丢了」在图上表现为空格子,不是少两个格子——这就是主线数据为什么保留 null 行而不直接删掉它们。

    第八步:数 M,把 connectNulls 钉死

    点 切换 connectNulls 按钮,#gapInfo 会打印:

    connectNulls=false → 线条 path 数 1,每条里的 M 个数 [2],符号(小圆点)数 29
    connectNulls=true → 线条 path 数 1,每条里的 M 个数 [1],符号(小圆点)数 29

    M 的个数是判据(实测两种情况的 SVG 字节数都是 13,227、d 字符串长度都是 331,看不出区别)。符号数一直是 29——connectNulls 只连线,不会补点:跨过去的那一段两头有线连着,但中间那两个空位仍然没有圆点。


    【参数速查】

    表一:dataset 的键

    键作用实测注意
    dataset.source 数据本体,四种形状都吃 写成数组(dataset: rows)当场抛 TypeError: Bind must be called on a function
    dataset.id 给这份表起名 配合 series.datasetId / dataset.fromDatasetId
    dataset.sourceHeader 二维数组首行是不是表头 'auto'(≡true≡1)/false(≡0)/2 表示前两行都是表头
    dataset.dimensions 定义列名 对象数组上改名会让取值全部落空;安全写法是 {name, displayName}
    dataset.seriesLayoutBy 'column'(默认)/'row' 对对象数组无作用
    dataset.transform 整形器,对象或数组 数组按书写顺序执行;写在 series 上会被忽略
    dataset.fromDatasetIndex 这一份的输入是第几份 缺省是 0,不是上一层
    dataset.fromDatasetId 同上,按名字 和 fromDatasetIndex 二选一
    dataset.name / dataset.type 组件通用字段 一般不写

    表二:系列侧吃数据的键

    键作用实测注意
    series.data 直接给这个系列的数据 一旦写了它,dataset 对这个系列就不起作用(实测读到 3 行而非表里的 8 行)
    series.encode 通道 → 维度 列名不存在时静默回退(坑 6)
    series.datasetIndex 认领第几份 dataset 缺省 0
    series.datasetId 按名字认领 实测生效
    series.seriesLayoutBy 只有这个系列按行读 覆盖 dataset 上的同名设置
    series.sourceHeader 系列级表头设置 同上,粒度和 seriesLayoutBy 一样
    series.dimensions 系列级列名 极少用,第 33 章

    (series.source 不是合法字段:实测写了等于没写,系列读到 0 行、448 字节空图。)

    表三:通道与 encode 取值

    通道用在哪些图实测
    series.encode.x 直角坐标系的横轴 列名或下标都可
    series.encode.y 纵轴 写数组时几何取第一列
    series.encode.label 柱顶/点上标签 多列用空格连接(2358 1324)
    series.encode.tooltip 提示框 与 label 各管各的,互不影响
    series.encode.value 饼/环、热力等 饼图必须它或字段名恰好是 value
    series.encode.itemName 饼/环的扇区名 配合 value 使用
    series.encode.latitude / longitude 地理散点 第 25 章

    不写 encode 时的默认:defaultedLabel 与 defaultedTooltip 都等于 y 那一维(实测 ["sales"])。

    表四:内置 transform

    写法配置实测
    'filter' {dimension: 'sales', '>': 4000000} 运算符 > >= < <= = != <> value reg
    'filter'(复合) {and: […]} / {or: […]} / {not: {…}} 可嵌套
    'sort' {dimension: 'sales', order: 'desc'} 也可为数组按多列排;incomparable 取 'min'/'max'

    两个必须写的边界:type 前面不要加 echarts:(实测加了抛错,生产版消息是空的 Error:);filter 少写 dimension、sort 少写 order 都抛错,开发版分别给 Relation condition must has prop "dimension" specified. 和 Sort transform config must has "order" specified.。

    表五:四种喂法的选型口诀

    数据在哪用什么为什么
    就三四行、写在代码里 series.data 纯数字数组 最短,一眼看完
    每个点要单独配色/样式 series.data 对象数组 只有它能写 {value, itemStyle}
    多个系列共用一张表 dataset.source 对象数组 + encode 一份源、N 个系列,只维护一张表
    后端直接返回 CSV 转的二维数组 dataset.source 二维数组 + sourceHeader 不用自己 map
    需要过滤/排序后再喂 dataset + transform 整形跟着 option 走,切换筛选不用重算数据

    表六:data/ 的分层地图(8 个文件,本章起全书共用)

    层文件条数 / 大小列后面主要喂给谁
    主数据 stores.json 12 条 / 2.7 KB storeId name city province tier lat lng area seats openDate 地图(第 25 章)、条形排行
    主数据 categories.json 8 条 / 2.1 KB categoryId name group price season 饼/环(第 16 章)、桑基、树图
    明细层 sales-daily-store.json 4,234 条 / 944.1 KB date storeId city sales cups orders avgTicket newMembers weather promotion 日历热力(第 30 章)、箱线图、第 33 章的数据整形
    汇总层 sales-daily.json 1,096 条 / 199.0 KB 上面 8 列,见本章体检 折线趋势(第 13 章)、dataZoom(第 20 章)
    汇总层 sales-store-month.json 332 条 / 59.4 KB month storeId sales cups orders avgTicket newMembers openDays 堆叠面积、热力图、visualMap
    汇总层 sales-category-month.json 288 条 / 28.2 KB month categoryId sales units 桑基、雷达的时间维度
    指标层 store-metrics.json 12 条 / 4.4 KB 16 个门店年度指标列 雷达(第 17 章)、平行坐标(第 31 章)
    独立 demo-stock.json 261 条 / 53.6 KB date open close lowest highest volume ohlc K 线(第 30 章)

    分层思路是真实数仓的思路:明细层能聚合出汇总层,汇总层能算出指标层,任何一层对得上账。本书后面每章取数都从这张表里选,不再临时造数据。


    【常见坑】

    坑 1:不写 encode,ECharts 替你选错了列

    现象:图能出来,但折线贴地、图例显示英文列名、纵轴刻度比预想大十倍。

    根因:实测排队规则——第一列当类目,其余数值列按列序发给各系列,列用完了从头重复。你想看的杯数被排在第二个,而纵轴是按最大值(销售额)定的。

    修法:所有进入正式项目的 dataset 都显式写 encode,把「哪列接哪个通道」这件事从库里夺回来。顺带写 name,图例才会显示你要的字。

    坑 2:在对象数组上把 dimensions 改成中文列名,画出一张空图

    现象:不报错、不警告,图上只有坐标轴(实测 446 字节、0 个 <text>、0 根柱)。把 encode 改成按下标写也一样空。

    根因:dimensions 数组是重新定义列名。对象数组取值靠 row[列名],列名一改,源里的 sales/cups 再也取不到,读到的每一列都是空的——这和编码方式无关,所以按下标也救不回来。

    修法:改名一律用 dimensions: [{ name: 'sales', displayName: '销售额' }](实测正常,图例显示中文而取值仍用 sales)。要写 dimensions 数组,就得把源里的键名原样抄一遍,或者改用二维数组喂法。

    坑 3:只给一列数据,图画不出来

    现象:dataset 里只有一列({source: {v: [1,2,3]}} 或 [{sales: 1}, {sales: 2}]),柱状图 0 根柱(958 字节)、折线 0 条线(1,503 字节),轴和文字都正常。

    根因:ECharts 发现缺维度,会给它补一个名叫 y 的空维度(实测模型读数 dims: ["v","y"]、y 列全为 null)。补上来的这列没有任何值。

    修法:类目 + 数值,至少两列。真只有一列数据,就自己补上类目列,或者改走 series.data: [1,2,3](那会让下标当类目)。

    坑 4:饼图不吃 [['甲', 12], ['乙', 8]]

    现象:容器高度正常、Console 干净,但饼图是一片空白。

    根因:实测这种形状给饼图得到 684 字节的 SVG,里面有三个 <path d="" fill="#000">——元素建了,几何是空的。模型层读数 dims: ["value","value0"]、defaultedLabel: []。同样的数组喂柱状图则会把 甲 当 x 值、12 当 y 值。

    修法:饼图的数据用 {name, value} 对象数组,或者用 dataset + encode: {itemName, value}。上面两种实测都是 1,905 字节、两个扇形、标签正常。

    坑 5:dataset 写成数组、transform 写到系列上

    现象:dataset: rows(直接把数组给它)当场抛 TypeError: Bind must be called on a function;dataset: {} 抛同一个错。

    根因:dataset 只能是「一个带 source 的对象」或「这样的对象组成的数组」。裸数组被当成多份 dataset,于是每一项都没有 source。

    修法:写成 dataset: { source: rows }。另一例:transform 写在系列上是合法键名、无效写法(实测 12 行全在、一条都没过滤)——它只属于 dataset。

    坑 6:encode 写了不存在的维度名,静默用默认列

    现象:图照画,但显示的数字不是你指定的那列。什么错都不报。

    根因:实测 encode.tooltip: ['不存在'] 之后 defaultedTooltip 回退成 ["sales"],输出和不写一模一样。

    修法:用第三步那句 Console 诊断——sm.getData().mapDimension('y') 直接告诉你这个系列真正用了哪一列。这一行比盯着 option 数一遍可靠。

    坑 7:多层 transform 链不写输入,过滤白做

    现象:dataset 数组里第 1 份过滤、第 2 份排序,系列吃第 2 份,结果排序生效了但过滤没生效(实测 4 行全在而不是 2 行)。

    根因:没写 fromDatasetIndex/fromDatasetId 时,每份 dataset 的输入默认都是第 0 份,不是它的上一层。

    修法:要么每层都显式声明输入,要么干脆合并成一层 transform: [filter, sort](实测结果相同,且不会漏)。更狠的一种组合是过滤条件写错导致 0 行,然后再链一层 sort:实测抛 Error:(开发版 Can not find dimension info via: sales. Existing dimensions: [])——空表上没有维度可排。

    坑 8:两份 dataset 都不认领,或者换了筛选条件旧系列还赖着

    现象:两个系列画的是同一份数据;或者点按钮换了筛选条件之后,图上系列数没变少。

    根因:两条独立规则。前者是「系列默认吃 datasetIndex 0」;后者是第 9 章讲过的默认合并不删数组成员。实测:模式①(2 个系列、1 份 dataset)用默认合并切到模式③(1 个系列、2 份 dataset),结果是 seriesCount 仍是 2、datasetCount 变成 2,图上还是 12 个月。

    修法:换数据集时同时写认领关系(series.datasetId),并且用 chart.setOption(option, { replaceMerge: ['dataset', 'series'] })。本章页面五个模式按钮全都这么写。

    坑 9:提示框模板里的 {c} 在 dataset 上打印 [object Object]

    现象:tooltip.formatter: '{a} | {b} | {c}' 打印出 销售额 | 2023-01-01 | [object Object]。

    根因:对象数组源里,一个数据点就是整行对象,{c}(第三个参数=值)打不出标量。换成二维数组源,同一句模板打印的是整行 2023-01-01,66005,2358。

    修法:吃 dataset 时用 encode 指定通道,模板改用 p.value 回调(实测 p.value.sales/p.value.cups 可直接取,输出 66005.33/2358),或者用 @ 语法按维度下标取(实测 {@[1]} 打印 66005.33)。series.data 纯数字数组才适合 {a}{b}{c}(实测输出 销售额 | 甲 | 100)。


    【工程文件】

    ch10/
    ├── index.html ← 五按钮对比台 + 两张缺失值小图 + 体检报告区
    └── app.js ← fetch → 聚合 → 五种喂法 → 图形计数,约 220 行

    跑法(本章必须有服务器,判据见第 6 章):

    node tools/extract.mjs –write # 把本章代码重建成 code/ch10/
    node tools/serve.mjs # 打开 http://127.0.0.1:18000/code/ch10/index.html

    双击 file:// 打开会停在状态条那句「加载失败」上:fetch 在 file:// 下被浏览器拦掉,这是第 6 章实验过的行为,不是本章代码坏了。

    app.js 里四个值得单独指出来的写法:

    • toMonthly() 是全书月表的唯一来源。正文里每个「3,126,085.40」都是它跑出来的;sum 里的 r[key] || 0 顺手把四个 null 当 0 加进去了——这对月表无害(缺失月的其它 29 天仍在),但第 13 章讲日均值时必须换成「跳过并计数」。
    • countBars() / readLine() 数的是 DOM 里的真图形,所以只在 renderer: 'svg' 下有结果;canvas 渲染下这两个函数返回 null,页面上会打印「请用浏览器打开」。这是有意的:别用 getOption() 判断图画没画出来(第 9 章),要判断图形就数形状。
    • 五个模式函数都以 setOption(…, { replaceMerge: ['dataset', 'series'] }) 收尾,因为模式间系列数和 dataset 份数都会变(坑 8)。
    • option 字面量全部直接写在 setOption( 的括号里。先存进 const 再传,type: 'bar' 会被 TypeScript 拓宽成 string,第 39 章会报 TS2769。

    【练习】

    练习 1:把默认标签改成杯数

    只允许动 series[0] 一个地方,让模式②的柱子顶上显示杯数而不是销售额。改完用 Console 验证 defaultedLabel。

    答案:`series: [{ name: '杯数', type: 'bar', encode: { x: 'month', y: 'cups', label: 'cups' } }, …]`——其实只写 `encode: {x:'month', y:'cups'}` 就够了,因为实测 `defaultedLabel` 恒等于 y 那一维(模式②钉住 `cups` 之后 `defaultedLabel` 自己就变成 `["cups"]`)。要显示两列才需要显式写 `label: ['cups','orders']`,实测标签打印 `110552 69039`(空格分隔)。别忘了 `label: { show: true }`:标签默认不显示,`encode.label` 只决定显示哪一维(实测不写 `show` 时 11,175 字节、写 `show` 后 11,214 字节)。

    练习 2:把月表换成日表,看看 openStores 那一列

    把 toMonthly(rows, '2025') 换成直接喂 rows 的前 40 条(rows.slice(0, 40)),模式①与模式②各自跑一遍,然后把 encode.y 指向 openStores。

    答案:模式①会先给 `sales`、再给 `cups`(第一列 `date` 当类目,与月表同规则);`encode.y: 'openStores'` 之后折线会变成一条几乎水平的带子——`openStores` 只有 5~12 这一点点范围,而纵轴仍按其它列的数据范围算刻度吗?不:实测**只画 `openStores` 时纵轴上限是 12**(`0, 300, 600, 900, 1200, 1500` 那类刻度是 `orders` 时的)。这题真正要练的是发现「刻度跟着实际编码进来的列走」,所以刻度突变说明编码改对了。四个缺失日会把线断成几段(`connectNulls` 第八步),这是同一个坑的另一副面孔。

    练习 3:写一个「只筛促销日」的按钮

    加第六个按钮:图上只画 2025 年有促销的那几天(data/sales-daily.json 里 promotion 非 null 的记录),类目轴显示日期,柱高显示 sales,并在 #modeInfo 里打印出记录条数。不许在 JS 里 filter。

    答案:要点有三条。① 整形必须交给 `transform`:`dataset: [{ id: 'raw', source: daily2025 }, { id: 'hit', fromDatasetId: 'raw', transform: { type: 'filter', config: { dimension: 'promotion', value: (v) => !!v } } }]`——`value` 运算符收一个谓词函数,这是六种比较符里唯一能写逻辑的那个。② 系列要认领:`{ type: 'bar', datasetId: 'hit', encode: { x: 'date', y: 'sales' } }`,不写就是坑 8。③ 收尾用 `replaceMerge: ['dataset', 'series']`,否则上一个模式的系列赖着不走。条数用 JS 数一遍即可(2025 全年的促销日在报告区那 48 个里,自己按 `date.startsWith('2025')` 筛一遍)。进阶:把 `value` 换成 `reg`——`{dimension: 'promotion', reg: '.'}` 实测也能选出非空项,但 `null` 参与正则匹配的行为不如谓词清楚。


    【小结】

    • 数据只有两扇门:series.data(内部形状 original,没有列名可言)和 dataset.source(arrayRows / objectRows / keyedColumns)。七种写法里六种画出逐字符相同的图(图形指纹 1,681 字符),唯一例外是 series.data 的对象数组——它必须额外补 xAxis.data。
    • 三个词分清就不乱:维度是列,系列是 series[i],通道是图表类型要的槽位。不写 encode 就是让库里替你排队:第一列当类目,其余数值列按列序发,用完就重复。
    • encode 是本章性价比最高的一行配置:它不改数据,只改「哪列接哪条通道」,顺带把纵轴刻度和默认标签/提示框的内容一起定下来。label 和 tooltip 是两条互不相干的通道,写错列名不会报错——用 sm.getData().mapDimension('y') 验。
    • 改名请用 displayName,不要用 dimensions 直接改列名(对象数组上会得到一张 446 字节的空图)。
    • dataset 的价值在一份表喂多个系列与 transform 整形。记四条:系列默认只吃第 0 份;transform 只属于 dataset;多层链的输入必须用 fromDatasetIndex/fromDatasetId 显式声明(缺省是第 0 份,不是上一层);transform 数组按书写顺序执行。内置整形器只有 filter 和 sort,写 type 时不要加 echarts: 前缀。
    • 脏值有一条统一规则和三个例外:规则是「转不出数字的一律当空,不画、不报错」(null/undefined/'-'/空串/带单位字符串全一样,且 series.data 与 dataset 行为一致)。例外是:字符串数字会被当真数字用;0/false/true 会被画出来(0、0、1);饼图对空值的容忍度更低。折线断没断只看 d 里 M 的个数,字节数一点也不会变。
    • 主线数据从本章起正式上岗。data/ 的 8 个文件按主数据/明细层/汇总层/指标层分好,sales-daily.json 那 1,096 条是全书最常用的那份——它的四个缺失日、48 个促销日和 openStores = 0 的故障日,第 13 章和第 33 章还会回来。

    数据能喂进去了,但每次 fetch 回来都要 init 一个新实例吗?一个页面放四张图要不要各管各的销毁?下一章处理实例本身:echarts.init 的参数、重复 init 的警告、resize 的正确姿势,以及单页应用里最容易出事的那条——忘记 dispose()。


    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 《ECharts入门》第 10 章 数据怎么喂进去:dataset、维度与 encode
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!