本篇是第 20 章的第 3/3 篇,接上篇《第 20 章 缩放与时间:dataZoom 与 timeline(中):实验台(上):index.html、app.js》。

(代码接上篇)
/* ===== 面板八:slider 的两条几何定律(第十四节) ===== */
function initPanel8() {
var p = makePanel('p8');
/** @type {any[][]} */
var V = [
['① 默认(B0)', baseOpt()],
['② brushSelect:false ⇒ 没有拖动条,整组下移 7(轨道顶 208→215)', dzOpt([slider(25, 75, { brushSelect: false })])],
['③ moveHandleSize:0', dzOpt([slider(25, 75, { moveHandleSize: 0 })])],
['④ moveHandleSize:8', dzOpt([slider(25, 75, { moveHandleSize: 8 })])],
['⑤ moveHandleSize:20', dzOpt([slider(25, 75, { moveHandleSize: 20 })])],
['⑥ moveHandleSize:30(轨道顶出画布)', dzOpt([slider(25, 75, { moveHandleSize: 30 })])],
['⑦ height:12 + bottom:4 + left:20 + right:20',
dzOpt([slider(25, 75, { height: 12, bottom: 4, left: 20, right: 20 })])],
['⑧ 竖条 orient:vertical', /** @type {any} */ ({
animation: false, xAxis: { type: 'value' },
yAxis: { type: 'category', data: CAT12.slice() },
series: [{ type: 'bar', data: A12.slice() }],
dataZoom: [{ type: 'slider', yAxisIndex: 0, orient: 'vertical', start: 25, end: 75 }]
})]
];
function std(svg, c) {
return [geometryRow(svg), shadowRow(svg), gridRow(c), dzRow(c, 0)];
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b8' + String.fromCharCode(97 + idx), function () { p.run(V[idx][0], V[idx][1], std); });
})(i);
}
bind('b8i', function () {
p.run('⑨ 两条定律逐档对账:moveHandleSize 从 0 扫到 33', baseOpt(), function (svg, c) {
var out = [' 定律一(纵向)组顶 = 画布高 − 30 − edgeGap(15) − (brushSelect ? 7 : 0),那个 7 写死在源码里,不读你的 moveHandleSize',
' 定律二(横向)轨道起 x = grid.x + max(2.5, 0.4 × moveHandleSize);厚度上沿 = 组顶 + max(0, moveHandleSize − 0.5)'];
var mhs = [null, 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 15, 20, 25, 30, 33];
var inst = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 400, height: 260 });
var bad1 = 0, bad2 = 0;
try {
for (var j = 0; j < mhs.length; j++) {
var extra = mhs[j] == null ? {} : { moveHandleSize: mhs[j] };
inst.setOption(/** @type {any} */ (dzOpt([slider(25, 75, extra)])), true);
var svg2 = svgOf(inst);
var rs = rectShapes(svg2);
var track = null;
for (var t = 0; t < rs.length; t++) {
if (rs[t].沿轴 && String(rs[t].op) !== '0.2') track = rs[t];
}
if (!track) { out.push(' ' + JSON.stringify(mhs[j]) + ' ⇒ 轨道读不到'); continue; }
var mh = mhs[j] == null ? 7 : mhs[j];
var handX = 60 + Math.max(2.5, 0.4 * mh);
var handY = 208 + Math.max(0, mh – 0.5);
var okX = Math.abs(track.沿轴[0] – handX) < 0.011;
var okY = Math.abs(track.厚度[0] – handY) < 0.011;
if (!okX) bad2++;
if (!okY) bad1++;
out.push(' moveHandleSize=' + JSON.stringify(mhs[j]) + ' 轨道沿轴=' + frs(track.沿轴) +
' 厚度=' + frs(track.厚度) + ' 手算起x=' + r2(handX) + ' ' + (okX ? '✓' : '✗') +
' 手算上沿=' + r2(handY) + ' ' + (okY ? '✓' : '✗') +
' 拖动条=' + (moveHandleBox(svg2) ? '在' : '不在') + ' 字节=' + svg2.length);
}
out.push(' ⇒ ' + mhs.length + ' 档:纵向不符 ' + bad1 + ' 档,横向不符 ' + bad2 + ' 档'
+ '(null 那一档就是「不写这个键」,库里那份 defaultOption 写的是 7)');
} catch (e) {
out.push(' ⇒ 扫描中途抛错:' + e.message);
} finally { try { inst.dispose(); } catch (e2) { } }
return out;
});
});
p.run(V[0][0], V[0][1], std);
}
/* ===== 面板九:手柄十档、图标、三个填充色、SSR 读不到的 cursor(第十五节) ===== */
// 模型里的 handleIcon 原串(黄框要印它,别靠记忆)
function handleStyleRow(c) {
var m = modelOf(c), out = [];
if (!m) return ' 手柄配置 = ' + NO_MODEL;
for (var i = 0; i < 4; i++) {
var zm = null;
try { zm = m.getComponent('dataZoom', i); } catch (e) { break; }
if (!zm) break;
var g = function (k) { try { return zm.get(k); } catch (e) { return '读不到'; } };
out.push(' dz' + i + ' handleIcon=' + dash(g('handleIcon')) + '\\n moveHandleIcon=' + dash(g('moveHandleIcon')) +
' handleSize=' + JSON.stringify(g('handleSize')) + ' showDetail=' + JSON.stringify(g('showDetail')) +
' labelPrecision=' + JSON.stringify(g('labelPrecision')) + ' labelFormatter=' + JSON.stringify(g('labelFormatter')) +
'\\n backgroundColor=' + dash(g('backgroundColor')) + ' fillerColor=' + dash(g('fillerColor')) +
' borderColor=' + dash(g('borderColor')) + ' brushStyle=' + JSON.stringify(g('brushStyle')));
}
return out.join('\\n') || ' (没有 dataZoom 组件)';
}
// cursor 只活在 zrender 的显示列表里:SVG 串一个字符都不写(面板九 ⑦)
function cursorRow(chart) {
var out = [], by = {};
try {
var list = chart.getZr().storage.getDisplayList(true) || [];
out.push(' 显示列表图元=' + list.length + ' 个');
for (var i = 0; i < list.length; i++) {
var el = list[i], cur = el.cursor;
if (cur == null || cur === 'pointer') continue;
var lb = null;
try { lb = el.getBoundingRect(); } catch (e) { }
by[cur] = (by[cur] || 0) + 1;
if ((by[cur] || 0) <= 3) {
out.push(' cursor=' + cur + ' 类型=' + (el.type || el.constructor.name) +
(lb ? ' 局部=' + [r2(lb.x), r2(lb.y), r2(lb.width), r2(lb.height)].join(',') : ''));
}
}
} catch (e) { out.push(' 显示列表读不到:' + e.message); }
out.push(' 计数=' + JSON.stringify(by));
out.push(' ⇒ 对照源码:手柄 cursor=getCursor(orient)(SliderZoomView.js:860,横 ew-resize / 竖 ns-resize)、'
+ '拖动条 cursor:"grab"(:527)、拖动中改 "grabbing"(:686)、点击面板 "crosshair"(:247)。');
out.push(' ⇒ inside 那两根:roams.js:156-157 先声明成 undefined,:169-170 用 retrieve2 逐根覆盖 ⇒ '
+ '同一根轴被多根 dataZoom 盖住时生效的是**最后声明**的那一根(:167-168 的注释原话)——跟 hostedBy 的「先来者称王」相反;'
+ '两根都不写时由 component/helper/RoamController.js:105-106 兜底成 grab / grabbing。');
out.push(' ⇒ 光标不落盘的机制:zrender/lib/Handler.js:95 只在鼠标移动那一刻把 hoveredTarget.cursor 交给 '
+ 'zrender/lib/dom/HandlerProxy.js:277,那句是 dom.style.cursor = … ⇒ 它改的是画布 DOM 的行内样式,'
+ 'SVG 串里没有鼠标,所以一个光标属性都写不进去。');
return out.join('\\n');
}
// 两层影子的落盘笔色:polyline / polygon 的 stroke 与 fill
function shadowColorRow(svg) {
var b = bodyOf(svg), out = [];
var re = /<(polyline|polygon) points="[^"]*"[^>]*/g, m;
while ((m = re.exec(b))) {
var a = attrOf(m[0]);
out.push(' ' + m[1] + ' stroke=' + dash(a.stroke) + ' fill=' + dash(a.fill) +
' width=' + dash(a['stroke-width']) + ' opacity=' + dash(a['fill-opacity']));
}
return ' 影子笔色(默认:未选层线 #a1aed9 / 0.5、面 #c0c9e6;选中层线 #8292cc)\\n' +
(out.length ? out.join('\\n') : ' (一条影子都不画)');
}
function initPanel9() {
var p = makePanel('p9');
var firstSvg = '';
/** @type {any[][]} */
var V = [
['① 默认:两手柄 + 拖动条 + 两层影子', baseOpt()],
['② handleIcon:"circle"', dzOpt([slider(25, 75, { handleIcon: 'circle' })])],
['③ handleIcon 自绘一条 path', dzOpt([slider(25, 75, {
handleIcon: 'path://M0,0 L2,0 L2,2 L0,2 Z',
handleStyle: { color: '#ffde99', borderColor: '#d29906', borderWidth: 1 }
})])],
['④ fillerColor / borderColor / backgroundColor 三件套', dzOpt([slider(25, 75, {
fillerColor: 'rgba(0,120,255,0.35)', borderColor: '#ff0000', backgroundColor: '#eeeeee'
})])],
['⑤ handleSize 十档扫描', baseOpt()],
['⑥ showDetail + labelPrecision + labelFormatter ⇒ 逐字不变', dzOpt([slider(25, 75, {
showDetail: true, labelPrecision: 2, labelFormatter: '{value}'
})])],
['⑦ cursor 族(浏览器里才读得到)', dzOpt([slider(25, 75, {
brushSelect: true, handleIcon: 'rect'
})])],
['⑧ dataBackground / selectedDataBackground 两层线色', dzOpt([slider(25, 75, {
dataBackground: { lineStyle: { color: '#ff0000', width: 3 }, areaStyle: { color: 'rgba(0,255,0,0.5)' } },
selectedDataBackground: { lineStyle: { color: '#0000ff' }, areaStyle: { color: '#ffff00' } }
})])]
];
function std(svg, c) {
return [sliderPartsRow(svg), geometryRow(svg), shadowColorRow(svg), handleStyleRow(c), dzRow(c, 0)];
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b9' + String.fromCharCode(97 + idx), function () {
if (idx === 4) { // ⑤ 十档扫描
p.run(V[idx][0], baseOpt(), function () {
var out = [' 定律一:落盘 matrix 的缩放分量 = 手柄高 ÷ 2;手柄高 = handleSize 的数,百分数按轨道厚 30 折算',
' 定律二:手柄那一条的沿轴 x(138.8 / 286.8)一动不动,改的只是垂直于轨道的方向;'
+ '中心 y = 轨道下沿 244.5 +(手柄高 ÷ 2 − 15)',
' 手柄图标自己的盒子是 2×2,所以屏幕上高 = 2 × 缩放分量'], hs = [undefined, 10, 15, 18, 30, '50%', '100%', '120%', 60, 120];
var inst = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 400, height: 260 });
try {
for (var k = 0; k < hs.length; k++) {
var hk = /** @type {any} */ (hs[k]);
inst.setOption(/** @type {any} */(dzOpt([slider(25, 75, hk === undefined ? {} : { handleSize: hk })])), true);
var h = handleShapes(svgOf(inst));
var want = typeof hk === 'number' ? hk
: hk === undefined ? 30 : 30 * parseFloat(hk) / 100;
out.push(' handleSize=' + JSON.stringify(hk) + ' 缩放=' + (h.length ? JSON.stringify(h.map(function (q) { return q.缩放; })) : '(读不到)') +
' 屏幕y=' + (h.length ? h.map(function (q) { return (q.位置[1] – q.缩放 * 2) + '..' + q.位置[1]; }).join(' | ') : '-') +
' 沿轴x=' + (h.length ? JSON.stringify(h.map(function (q) { return q.位置[0]; })) : '-') +
' 手算高=' + fr(want) + ' ' + (h.length && Math.abs(2 * h[0].缩放 – want) < 1e-9 ? '✓' : '✗') +
' 手算中心y=' + fr(229.5 + want / 2) + ' ' + (h.length && Math.abs(h[0].位置[1] – (229.5 + want / 2)) < 1e-9 ? '✓' : '✗') +
' 字节=' + svgOf(inst).length);
}
out.push(' ⇒ 120 那一档:手柄从 169.5 画到 289.5,画布只有 260 高 ⇒ 越过下沿,屏幕上被切掉');
} catch (e) { out.push(' ⇒ 扫描抛错:' + e.message); }
finally { try { inst.dispose(); } catch (e2) { } }
return out;
});
return;
}
if (idx === 5) { // ⑥ 与①逐字比
p.runMany([
{ label: '⑥-a 基准(不写这三键)', o: baseOpt(), build: function (svg) { firstSvg = svg; return [' 已存下基准串,长度=' + svg.length]; } },
{
label: '⑥-b showDetail:true + labelPrecision:2 + labelFormatter:"{value}"', o: V[5][1],
build: function (svg) {
var same1 = bare(svg) === bare(firstSvg), same2 = bare2(svg) === bare2(firstSvg);
var out = [' 只剥类名 zrN-cls-K ⇒ ' + (same1 ? '逐字相同' : '仍不同') +
';再把每渲染重编的裁剪板 id zrN-cK 一起归零 ⇒ ' + (same2 ? '逐字相同 ⇒ 这三键在静态图里一个字节都不改' : '仍不同 ⇒ 下面这行指出差在哪'),
' 原始字节差=' + (svg.length – firstSvg.length) + (svg.length === firstSvg.length
? '(连长度都没变 ⇒ 「仍不同」只是 zr0/zr1 这种序号换了一轮)'
: '(正的是后一本长 ⇒ 长出来的只是 id 序号的位数)')];
if (!same2) {
var a = bare2(svg), b = bare2(firstSvg), k = 0;
while (k < Math.min(a.length, b.length) && a.charAt(k) === b.charAt(k)) { k++; }
out.push(' 第一处不同 @' + k + ':本档 ' + JSON.stringify(a.slice(k, k + 60)) +
' / 基准 ' + JSON.stringify(b.slice(k, k + 60)));
}
out.push(' ⇒ 数字那两只 Text 建出来就带 invisible:!handleLabelShow(SliderZoomView.js:473-477),'
+ '而默认只有 emphasis.handleLabel.show 为真(SliderZoomModel.js:117-120);'
+ '_showDataInfo(:667-676) 判 toShow = 悬停或拖动中 ? emphasisShow : normalShow,'
+ '静态图里两样都没发生 ⇒ 一直 invisible ⇒ 改了等于没改');
out.push(shadowRow(svg));
return out;
}
}
]);
return;
}
if (idx === 6) { // ⑦ cursor
p.run(V[idx][0], V[idx][1], function (svg, c) {
var css = (svg.match(/<style[^>]*>([\\s\\S]*?)<\\/style>/) || ['', ''])[1];
var total = (svg.match(/cursor/g) || []).length, inCss = (css.match(/cursor/g) || []).length;
var vals = [], rx = /cursor:([^;}]+)/g, m2;
while ((m2 = rx.exec(css))) { vals.push(m2[1].trim()); }
return [cursorRow(p.chart),
' 串里 cursor 字样=' + total + ' 次:其中 <style> 块里 ' + inCss + ' 次、块外 ' + (total – inCss) + ' 次'
+ '(块外必须是 0 ⇒ 图元身上一个光标属性都不写)',
' <style> 里的光标的值=' + JSON.stringify(vals) +
' ⇒ 全是 :hover 选中层的 cursor:pointer(zrender/lib/svg/cssEmphasis.js:35-37),'
+ '跟滑块的 grab / ew-resize / crosshair 没有一个是同一处写的',
handleStyleRow(c)];
});
return;
}
p.run(V[idx][0], V[idx][1], std);
});
})(i);
}
p.run(V[0][0], V[0][1], std);
}
/* ===== 面板十:缩略图(dataShadow)的四条定律与一处抛错(第十六节) ===== */
function seqData(n) { var a = []; for (var i = 0; i < n; i++) a.push(10 + 40 * Math.abs(Math.sin(i * 0.37))); return a; }
function catNames(n) { var a = []; for (var i = 0; i < n; i++) a.push('d' + i); return a; }
function shadowOpt(n, dz) {
return /** @type {any} */({
animation: false,
xAxis: { type: 'category', data: catNames(n) }, yAxis: {},
series: [{ type: 'line', data: seqData(n), showSymbol: false }],
dataZoom: [mix({ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }, dz || {})]
});
}
// 一批 option 各自渲染一次,交给同一个读数函数——比在同一个容器里连按四次更不容易看错
function ssrSweep(cases, mkOpt, read, w, h) {
var out = [], inst = echarts.init(null, null, {
renderer: 'svg', ssr: true, width: w || 400, height: h || 260
});
try {
for (var i = 0; i < cases.length; i++) {
inst.setOption(/** @type {any} */(mkOpt(cases[i])), true);
out = out.concat(flat(read(cases[i], svgOf(inst), i)));
}
} catch (e) { out.push(' ⇒ 扫描抛错:' + e.message); }
finally { try { inst.dispose(); } catch (e2) { } }
return out;
}
function initPanel10() {
var p = makePanel('p10');
/** @type {any[][]} */
var V = [
['① 12 个类目(顶点 12)', baseOpt()],
['② 1096 天(stride=4 ⇒ 保留 274 个下标,落盘 275 个顶点)', dailyOpt([{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }])],
['③ 149 / 150 天', baseOpt()],
['④ 449 / 450 / 451 天', baseOpt()],
['⑤ 影子吃不吃窗口?三档 + 四种 filterMode', baseOpt()],
['⑥ 缺失日窗口:断口补 0', baseOpt()],
['⑦ 容器宽 200/400/600/800', baseOpt()],
['⑧ 白名单:11 种系列谁有影子,再强制 showDataShadow:true 反证', baseOpt()],
['⑨ themeRiver:先归因抛错,再看 singleAxis 下挂 slider', /** @type {any} */({
animation: false,
singleAxis: { type: 'category' },
series: [{ type: 'themeRiver', data: [[0, 'a', 12], [1, 'a', 31], [2, 'b', 22], [3, 'a', 40]] }],
dataZoom: [{ type: 'slider', start: 0, end: 100 }]
})]
];
function strideRead(c, svg) {
var s = shadowShape(svg), stride = Math.round(c / 300);
var pred = stride > 0 ? Math.ceil(c / stride) : c;
return [' n=' + c + ' stride=Math.round(' + c + '/300)=' + stride + ' 预测顶点=' + pred +
' 实测=' + s.顶点 + ' ' + (pred === s.顶点 ? '✓' : '✗') + ' 步长=' + fr(s.步长) +
' y上下=' + frs(s.y) + ' 末点=' + JSON.stringify(s.末点) + ' 字节=' + svg.length];
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b10' + String.fromCharCode(97 + idx), function () {
if (idx === 1) {
p.run(V[idx][0], V[idx][1], function (svg, c) {
var sv = shadowShape(svg), n = DAILY.length, stride = Math.round(n / 300);
var kept = 0, nullKept = [];
for (var k = 0; k < n; k += stride) { kept++; if (DAILY[k].sales == null) { nullKept.push(k); } }
var allNull = [];
for (var m = 0; m < n; m++) { if (DAILY[m].sales == null) { allNull.push(m); } }
var want = kept – nullKept.length + 2 * nullKept.length;
return [shadowRow(svg),
' 抽稀账:n=' + n + ',影子宽(=轨道长)=300 ⇒ stride=Math.round(' + n + '/300)=' + stride +
' ⇒ 只留 k % ' + stride + ' === 0 的下标,共 ' + kept + ' 个',
' 全表 sales 为 null 的下标=' + JSON.stringify(allNull) + ',其中落在保留下标里的=' +
JSON.stringify(nullKept) + '(另 ' + (allNull.length – nullKept.length) +
' 个正好被抽稀跳过了,一次断口都不触发)',
' ⇒ 顶点 = 保留 ' + kept + ' − 空值不写真点 ' + nullKept.length + ' + 每处断口进出各补一个 0 点 '
+ 2 * nullKept.length + ' = ' + want + ',实测 ' + sv.顶点 + (want === sv.顶点 ? ' ✓' : ' ✗'),
' 落在 0 线上的 x=' + JSON.stringify(sv.零值点) + '(就是下标 804 那一处断口的两侧;'
+ '跳过的行也照加 step,所以补点仍落在 804 前后各一格的间距上)',
geometryRow(svg), dzRow(c, 0)];
});
return;
}
if (idx === 2) {
p.run(V[idx][0], baseOpt(), function () {
return [' 定律一:stride = Math.round(条数 ÷ 影子宽px),跳过条件是 index % stride(SliderZoomView.js:290-293)',
' ⇒ stride 为 0 与 1 都不跳点:149 天 stride 0、150 天 stride 1,两档顶点数都等于天数本身']
.concat(ssrSweep([148, 149, 150, 151, 299, 300], function (n) { return shadowOpt(n); }, strideRead));
});
return;
}
if (idx === 3) {
p.run(V[idx][0], baseOpt(), function () {
return [' 真正的分界在 stride 从 1 涨到 2 的那一档:n=450(Math.round(450/300)=2)']
.concat(ssrSweep([400, 449, 450, 451, 601, 900, 1096], function (n) { return shadowOpt(n); }, strideRead));
});
return;
}
if (idx === 4) {
p.run(V[idx][0], baseOpt(), function () {
var out = [' 定律三:影子恒用全量。三档窗口的 <polyline points> 应当逐字符相同'], last = '';
out = out.concat(ssrSweep([[0, 100], [25, 75], [50, 100]], function (we) {
return shadowOpt(12, { start: we[0], end: we[1] });
}, function (we, svg) {
var s = shadowShape(svg), same = (last === s.points);
var r = [' ' + we[0] + '~' + we[1] + ' 顶点=' + s.顶点 + ' 首点=' + JSON.stringify(s.首点) +
' 末点=' + JSON.stringify(s.末点) + (last ? ' 与上一档' + (same ? '逐字相同 ✓' : '不同 ✗') : '')];
last = s.points;
return r;
}));
out.push(' filterMode 四种各试(每种的三档相同判定)');
var modes = ['filter', 'weakFilter', 'empty', 'none'];
for (var k = 0; k < modes.length; k++) {
(function (fm) {
var ps = [];
ssrSweep([[0, 100], [25, 75], [50, 100]], function (we) {
return shadowOpt(12, { start: we[0], end: we[1], filterMode: fm });
}, function (we, svg) { ps.push((shadowShape(svg).points || '').slice(0, 60)); });
out.push(' filterMode=' + fm + ' 三档' + (ps[0] === ps[1] && ps[1] === ps[2] ? '相同 ✓' : '不同 ✗') +
'(画面上条数照样被裁,只是影子不裁)');
})(modes[k]);
}
return out;
});
return;
}
if (idx === 5) {
p.run(V[idx][0], baseOpt(), function () {
var miss = { '2025-03-14': 1, '2025-03-15': 1, '2025-07-22': 1, '2025-11-03': 1 };
var centers = [933, 803, 1037];
return [' 定律四:空值按 0 落底,且只在「进断口 / 出断口」各补一个点(:305-310)']
.concat(ssrSweep(centers, function (ct) {
var rows = DAILY.slice(ct – 6, ct + 7);
return /** @type {any} */({
animation: false,
xAxis: { type: 'category', data: rows.map(function (r) { return r.date; }) }, yAxis: {},
series: [{
type: 'line', showSymbol: false,
data: rows.map(function (r) { return miss[r.date] ? null : r.sales; })
}],
dataZoom: [{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }]
});
}, function (ct, svg) {
var s = shadowShape(svg), rows = DAILY.slice(ct – 6, ct + 7);
return ' 中心下标 ' + ct + ' 天数=' + rows.length +
' 缺的日=' + JSON.stringify(rows.filter(function (r) { return miss[r.date]; }).map(function (r) { return r.date; })) +
' 顶点=' + s.顶点 + ' 落 0 线的 x=' + JSON.stringify(s.零值点) + ' y上下=' + frs(s.y);
}))
.concat([' ⇒ 单缺一天 13→14 顶点;连缺两天仍 13 顶点(中间那段不补)']);
});
return;
}
if (idx === 6) {
p.run(V[idx][0], baseOpt(), function () {
var widths = [200, 400, 600, 800], out = [
' 分母 = 影子宽 = 轨道长,所以它随容器宽度搬:同一份 1000 条数据,四种宽度各起一个 ssr 实例'], bad = 0;
for (var k = 0; k < widths.length; k++) {
var inst = echarts.init(null, null, { renderer: 'svg', ssr: true, width: widths[k], height: 260 });
try {
inst.setOption(/** @type {any} */(shadowOpt(1000)), true);
var s = shadowShape(svgOf(inst));
var span = s.末点 && s.首点 ? +(s.末点[0] – s.首点[0]).toFixed(2) : null;
var st = Math.round(1000 / span), want = Math.ceil(1000 / st);
if (want !== s.顶点) bad++;
out.push(' 容器宽=' + widths[k] + ' 顶点=' + s.顶点 + ' 步长=' + fr(s.步长) +
' x跨度=' + fr(span) + ' 反算stride=' + (s.顶点 ? Math.round(1000 / s.顶点) : '?') +
' 手算stride=Math.round(1000/' + fr(span) + ')=' + st +
' 手算顶点=Math.ceil(1000/' + st + ')=' + want + (want === s.顶点 ? ' ✓' : ' ✗'));
} catch (e) { out.push(' 宽=' + widths[k] + ' 抛错:' + e.message); }
finally { try { inst.dispose(); } catch (e2) { } }
}
return out.concat([' ⇒ 顶点数 = ceil(条数 ÷ stride),stride 又由影子宽(=轨道长)定 ⇒ '
+ bad + ' 档与手算顶点不符;宽 600 与 800 都是 stride 2,因为 Math.round(1000/449.5)=Math.round(1000/599.4)=2 ⇒ 影子稀到一定程度就不再变稀']);
});
return;
}
if (idx === 7) {
var mk = function (t) {
var o = {
animation: false,
xAxis: { type: 'category', data: CAT12.slice() }, yAxis: {},
series: null,
dataZoom: [{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 }]
};
var d = A12.slice();
switch (t) {
case 'line': o.series = [{ type: 'line', data: d, showSymbol: false }]; break;
case 'bar': o.series = [{ type: 'bar', data: d }]; break;
case 'scatter': o.series = [{ type: 'scatter', data: d.map(function (v, k) { return [k, v]; }) }]; break;
case 'effectScatter': o.series = [{ type: 'effectScatter', data: d.map(function (v, k) { return [k, v]; }) }]; break;
case 'candlestick': o.series = [{ type: 'candlestick', data: d.map(function (v) { return [v, v + 2, v – 3, v + 4]; }) }]; break;
case 'boxplot': o.series = [{ type: 'boxplot', data: d.map(function (v) { return [v – 2, v – 1, v, v + 1, v + 2]; }) }]; break;
case 'custom': o.series = [{
type: 'custom', renderItem: function () { return { type: 'rect', shape: { x: 0, y: 0, width: 4, height: 4 } }; },
data: d
}]; break;
case 'area': o.series = [{ type: 'line', data: d, areaStyle: {} }]; break;
case 'pie': o.xAxis = undefined; o.yAxis = undefined;
o.series = [{ type: 'pie', radius: '60%', data: d.map(function (v, k) { return { name: 'n' + k, value: v }; }) }]; break;
case 'graph': o.xAxis = undefined; o.yAxis = undefined;
o.series = [{
type: 'graph', layout: 'none',
data: [{ x: 1, y: 1, name: 'a' }, { x: 2, y: 2, name: 'b' }], links: [{ source: 0, target: 1 }]
}]; break;
case 'heatmap': o.yAxis = { type: 'category', data: ['x', 'y', 'z'] };
o.series = [{ type: 'heatmap', data: d.map(function (v, k) { return [k, k % 3, v]; }) }]; break;
default: o.series = [{ type: 'line', data: d, showSymbol: false }];
}
return /** @type {any} */(o);
};
p.run(V[idx][0], baseOpt(), function () {
var TYPES = ['line', 'bar', 'scatter', 'effectScatter', 'candlestick', 'boxplot',
'custom', 'area', 'pie', 'graph', 'heatmap'], zero = [], noAxis = [];
var rows = ssrSweep(TYPES, mk, function (t, svg) {
var sh = shadowShape(svg), tr = rectShapes(svg).length, pg = countTag(svg, 'polygon');
if (!tr) { noAxis.push(t); } else if (!pg) { zero.push(t); }
return ' ' + t + ':轨道块=' + tr + ' polygon(影子面)=' + pg +
' polyline=' + countTag(svg, 'polyline') + ' 首条 polyline 顶点=' + (sh.顶点 || 0) +
' 字节=' + svg.length +
(tr ? '' : '(轨道块=0 ⇒ 滑块整条没画,这一档不能当白名单证据)');
});
var force = ssrSweep(zero, function (t) {
var o = mk(t);
o.dataZoom = [{ type: 'slider', xAxisIndex: 0, start: 25, end: 75, showDataShadow: true }];
return o;
}, function (t, svg) {
var sh = shadowShape(svg);
return ' 强制 true ⇒ ' + t + ':轨道块=' + rectShapes(svg).length +
' polygon(影子面)=' + countTag(svg, 'polygon') + ' polyline=' + countTag(svg, 'polyline') +
' 影子顶点=' + (sh.顶点 || 0) + ' 字节=' + svg.length;
});
return [' 名单写死在 SliderZoomView.js:68 SHOW_DATA_SHADOW_SERIES_TYPE=[line,bar,candlestick,scatter];'
+ 'showDataShadow 默认 auto ⇒ 名单外一律没影子。',
' 判定看两列:轨道块(滑块画没画,画了是 3 个 path:轨道底 + 点击面板 + 选中段;'
+ '真正画边框的那条内缩了半像素,这条正则不数它,面板九 ① 逐条点过名)与 polygon(影子的面,'
+ '有影子是 3 层:未选左段 + 选中段 + 未选右段,每段一面一线(:331-351 建一对、:354-358 建三段)⇒ polygon=3 配 polyline=3;'
+ '系列自己那一折落盘是 path,不占 polyline。'
+ '首条 polyline 的顶点数不可靠——饼图的引导线也是 polyline。',
' 同一份数据(12 类目 + 12 个数)、同一个窗口 25~75,只换系列:']
.concat(rows)
.concat([' ⇒ 轨道块=0 的是 ' + JSON.stringify(noAxis) +
':这两个 option 里没有坐标轴(mk 里把 xAxis/yAxis 置了 undefined),'
+ 'dataZoom 一根轴也认领不到(探针读 eachTargetAxis 得 []),于是 SliderZoomView 整个不渲染——'
+ '它们「没影子」是因为根本没有滑块,不是因为白名单。',
' 滑块画了而 polygon=0 的是 ' + JSON.stringify(zero) + ':轨道块在,只是系列不在名单里。'
+ '(custom 那档轨道块=9 是这条正则把 renderItem 画的 4×4 方块也数了进去:12 行被 filterMode '
+ '裁到 6 行 ⇒ 3 个轨道块 + 6 个方块,第十二节那本账)',
' ⇒ 把 showDataShadow 从 auto 改成 true,:375 那句白名单判定就被绕过:'])
.concat(force);
});
return;
}
if (idx === 8) {
p.run(V[idx][0], V[idx][1], function (svg, c) {
var out = [shadowRow(svg), geometryRow(svg), dzRow(c, 0)];
var RIVER = [[0, 'a', 12], [1, 'a', 31], [2, 'b', 22], [3, 'a', 40]];
var CASES = [
['themeRiver + singleAxis + slider', true, true],
['themeRiver + singleAxis,一根 dz 都不写', true, false],
['themeRiver 无 singleAxis,也不写 dz', false, false],
['themeRiver 无 singleAxis + slider(xAxisIndex:0)', false, true]
];
out.push(' 归因四档(每档另起一个 ssr 实例,只看抛不抛):');
for (var k = 0; k < CASES.length; k++) {
var inst = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 400, height: 260 });
var o = /** @type {any} */({ animation: false, series: [{ type: 'themeRiver', data: RIVER }] });
if (CASES[k][1]) { o.singleAxis = { type: 'category' }; }
if (CASES[k][2]) {
o.dataZoom = CASES[k][1] ? [{ type: 'slider', start: 0, end: 100 }]
: [{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }];
}
try {
inst.setOption(o, true);
var s2 = svgOf(inst), sh2 = shadowShape(s2);
out.push(' ' + CASES[k][0] + ' ⇒ 没抛 字节=' + s2.length + ' polyline=' +
countTag(s2, 'polyline') + ' 影子顶点=' + (sh2.顶点 || 0));
} catch (e) {
out.push(' ' + CASES[k][0] + ' ⇒ 抛:' + e.message);
} finally { try { inst.dispose(); } catch (e2) { } }
}
out.push(' ⇒ 抛的是 themeRiver 自己的 getInitialData(它要 singleAxis),把 dataZoom 整条删掉照样抛 ⇒ 与缩放无关;');
out.push(' 配上 singleAxis 之后挂 slider 完全正常,只是仍没影子——名单里没有它(:68),影子顶点=0。');
out.push(' 上面 dzRow 里那行 值=[NaN, NaN] 不是缩放坏了,是"读数器把 null 印成了 NaN":');
[['不写 data', null], ['data=[0,1,2,3]', [0, 1, 2, 3]], ['data=["a","b","c","d"]', ['a', 'b', 'c', 'd']]]
.forEach(function (one) {
var it = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 400, height: 260 });
var sa = /** @type {any} */({ type: 'category' });
if (one[1]) { sa.data = one[1]; }
try {
it.setOption(/** @type {any} */({
animation: false, singleAxis: sa,
series: [{ type: 'themeRiver', data: RIVER }],
dataZoom: [{ type: 'slider', singleAxisIndex: 0, start: 25, end: 75 }]
}), true);
var am = /** @type {any} */ (it).getModel().getComponent('singleAxis', 0);
var px = /** @type {any} */ (it).getModel().getComponent('dataZoom', 0).getAxisProxy('single', 0);
var sv = svgOf(it);
out.push(' singleAxis ' + one[0] + ' ⇒ scale.getExtent()=' + JSON.stringify(am.axis.scale.getExtent()) +
' proxy.getWindow 百分比=' + JSON.stringify(px.getWindow().percent) +
' 值=' + JSON.stringify(px.getWindow().value) +
' 字节=' + sv.length + ' polyline=' + countTag(sv, 'polyline'));
} catch (e) { out.push(' singleAxis ' + one[0] + ' ⇒ 抛:' + e.message); }
finally { try { it.dispose(); } catch (e3) { } }
});
out.push(' ⇒ 轴上没有 data 时 ordinal 轴的 scale.getExtent() 本身就是 [null,null],'
+ '窗口值只能跟着是 null(黄框按老规矩把 null 印成 NaN);百分比那一栏 [25,75] 一直是好的。'
+ '给轴补上 data,值窗立刻变真数,且 startValue/endValue 写的就是这份 data 的下标。');
return out;
});
return;
}
p.run(V[idx][0], V[idx][1], function (svg, c) {
return [shadowRow(svg), geometryRow(svg), dzRow(c, 0)];
});
});
})(i);
}
p.run(V[0][0], V[0][1], function (svg, c) { return [shadowRow(svg), shadowColorRow(svg), dzRow(c, 0)]; });
}
/* ===== 面板十一:多根轴、多根滑块(第十七节:先来者称王,事件计数在面板十二 ⑥) ===== */
function twoAxisOpt(dz, grids, extra) {
var two = !!grids; // 真 ⇒ 两块 grid 各挂一轴;假 ⇒ 一块 grid 里叠两条 x 轴(第二条压到 top)
var o = /** @type {any} */({
animation: false,
grid: two ? [{ left: 60, right: 20, top: 40, height: 70 }, { left: 60, right: 20, top: 130, height: 70 }]
: [{ left: 60, right: 20, top: 65, bottom: 80 }],
xAxis: [{ type: 'category', data: CAT12.slice() },
{ type: 'category', data: CAT12.slice(), gridIndex: two ? 1 : 0, position: two ? 'bottom' : 'top' }],
yAxis: two ? [{}, { gridIndex: 1 }] : [{}],
series: [{ type: 'line', data: A12.slice(), showSymbol: false },
{ type: 'line', data: A12.slice(), showSymbol: false, xAxisIndex: two ? 1 : 0, yAxisIndex: two ? 1 : 0 }],
dataZoom: dz
});
return mix(o, extra || {});
}
function dzListModel(c) {
var m = modelOf(c), out = [];
if (!m) return ' 组件清单 = ' + NO_MODEL;
var all = [];
try {
m.eachComponent('dataZoom', function (zm) { all.push(zm); });
} catch (e) { return ' 组件清单读不到:' + e.message; }
for (var i = 0; i < all.length; i++) {
var t = [];
try { all[i].eachTargetAxis(function (dim, idx) { t.push(dim + idx); }); } catch (e) { }
out.push(' [' + i + '] id=' + JSON.stringify(all[i].id) + ' type=' + all[i].subType +
' targets=' + JSON.stringify(t) + ' $fromToolbox=' + JSON.stringify(all[i].option.$fromToolbox == null ? null : all[i].option.$fromToolbox) +
' 回读窗(option 那份,processor 刷过)=' + JSON.stringify([all[i].option.start, all[i].option.end, all[i].option.startValue, all[i].option.endValue]));
}
return ' dataZoom 组件清单(' + all.length + ' 根,顺序就是声明顺序)\\n' + (out.join('\\n') || ' (0 根)');
}
function initPanel11() {
var p = makePanel('p11');
var SL = slider(25, 75), IN = inside(60, 100);
/** @type {any[][]} */
var V = [
['① 双 x 轴共用一个 grid + 一根 dz', twoAxisOpt([{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 }], false)],
['② 两根独立 grid,一根 dz 管两轴', twoAxisOpt([{ type: 'slider', xAxisIndex: [0, 1], start: 25, end: 75 }], true)],
['③ xAxisIndex:"all"(与 ② 等价)', twoAxisOpt([{ type: 'slider', xAxisIndex: 'all', start: 25, end: 75 }], true)],
['④ xAxisIndex:[] 空数组(合法的死键)', twoAxisOpt([{ type: 'slider', xAxisIndex: [], start: 25, end: 75 }], true)],
['⑤ 同轴两根:slider 在前', twoAxisOpt([
{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 },
{ type: 'inside', xAxisIndex: 0, start: 60, end: 100 }
], false)],
['⑥ 同轴两根:inside 在前', twoAxisOpt([
{ type: 'inside', xAxisIndex: 0, start: 60, end: 100 },
{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 }
], false)],
['⑦ x 与 y 同时交给一根 dz', dzOpt([mix({ type: 'slider', xAxisIndex: 0, yAxisIndex: 0, start: 25, end: 75 }, {})],
{ yAxis: { type: 'value' } })],
['⑧ dispatchAction 的 batch 一次动两根', twoAxisOpt([
{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 },
{ type: 'slider', xAxisIndex: 1, start: 10, end: 40 }
], true)],
['⑨ 给 ⑤ 的后来者发动作(dataZoomIndex:1)', twoAxisOpt([
{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 },
{ type: 'inside', xAxisIndex: 0, start: 60, end: 100 }
], false)]
];
function both(svg, c) {
return [dzListModel(c), dzRow(c, 0), dzCount(c) > 1 ? dzRow(c, 1) : ' dz1 =(只有一根)',
axisRow(c, 'x', 0), axisRow(c, 'x', 1), gridRow(c), countRow(c)];
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b11' + String.fromCharCode(97 + idx), function () {
if (idx === 6) {
p.run(V[idx][0], V[idx][1], function (svg, c) {
return both(svg, c).concat([axisRow(c, 'y', 0),
' ⇒ 两根轴共用这一根 dz,但 proxy 是按轴建的:x 窗与 y 窗各算各的,'
+ '用的是同一对百分比(DataZoomModel.js:343-348 取的是这根 dz 自己的 start/end)']);
});
return;
}
if (idx === 7) {
var H = [];
p.runMany([{
label: V[idx][0], o: V[idx][1], build: both,
act: function (chart, reader, arr) {
H.length = 0;
try { reader.on('datazoom', function () { H.push('hit'); }); } catch (e0) { }
try {
reader.dispatchAction({
type: 'dataZoom',
batch: [{ dataZoomIndex: 0, start: 0, end: 20 }, { dataZoomIndex: 1, start: 50, end: 70 }]
});
} catch (e) { arr.push(' ⇒ batch 动作抛错:' + e.message); }
try { reader.off('datazoom'); } catch (e2) { }
try { chart.off('datazoom'); } catch (e3) { }
try {
chart.dispatchAction({
type: 'dataZoom',
batch: [{ dataZoomIndex: 0, start: 0, end: 20 }, { dataZoomIndex: 1, start: 50, end: 70 }]
});
} catch (e4) { }
arr.push(' ⇒ 一次 batch 两个元素:动作处理器按元素各跑一遍(echarts.js:1496-1505 把 batch 摊平成 payloads),'
+ '但事件只发一个、e.batch 是长度 2 的数组(:1560-1565)');
},
after: function (svg, c) {
return [' 事件命中=' + H.length + ' 次(e.batch 长度见探针 D25),可两端的窗口确实都动了:看下面 dz0/dz1 两条 settledOption',
dzRow(c, 0), dzRow(c, 1), axisRow(c, 'x', 0), axisRow(c, 'x', 1)];
}
}]);
return;
}
if (idx === 8) {
p.runMany([{
label: V[idx][0], o: V[idx][1], build: both,
act: function (chart, reader, arr) {
try {
reader.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 5, end: 15 });
arr.push(' ⇒ 已给 dataZoomIndex:1(那根 inside,声明在后)发窗口 5~15');
} catch (e) { arr.push(' ⇒ 动作抛错:' + e.message); }
try { chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 5, end: 15 }); } catch (e2) { }
},
after: function (svg, c) {
return [dzRow(c, 0), dzRow(c, 1), axisRow(c, 'x', 0),
' ⇒ 给后来者发动作,照样改得动轴:findEffectedDataZooms 会顺着「同轴即联动」把同一条轴上的组件全刷一遍'
+ '(helper.js:93-105),而窗口的算法只认主人(AxisProxy.js:253 早退、:271 读主人的 settledOption)'];
}
}]);
return;
}
p.run(V[idx][0], V[idx][1], both);
});
})(i);
}
p.run(V[4][0], V[4][1], both);
}
/* ===== 面板十二:toolbox 的框选(第十八节:一根都不写,建图就两根) ===== */
function tbOpt(feature, extra) {
return /** @type {any} */(mix({
animation: false,
xAxis: { type: 'category', data: CAT12.slice() }, yAxis: {},
series: [{ type: 'line', data: A12.slice(), showSymbol: false }],
toolbox: { feature: feature || { dataZoom: {} } }
}, extra));
}
function selectFeatureRow(c) {
var m = modelOf(c);
if (!m) return ' toolbox 特征 = ' + NO_MODEL;
var out = [];
try {
var tm = m.getComponent('toolbox', 0);
var f = tm && tm.getModel ? tm.getModel('feature') : null;
out.push(' feature 键=' + JSON.stringify(f ? Object.keys(f.option || {}) : null) +
' dataZoom 默认值=' + JSON.stringify(f && f.get ? {
yAxisIndex: f.get('yAxisIndex'), filterMode: f.get('filterMode'), zoomLock: f.get('zoomLock'),
brushSelect: f.get('brushSelect'), icon: f.get('icon'), title: f.get('title')
} : null));
} catch (e) { out.push(' feature 读不到:' + e.message); }
try {
var ids = [];
m.eachComponent('dataZoom', function (zm) { ids.push(String(zm.id)); });
out.push(' 真 dataZoom 组件 id=' + JSON.stringify(ids));
} catch (e2) { out.push(' 组件清单读不到(桩件)'); }
return out.join('\\n');
}
function initPanel12() {
var p = makePanel('p12');
/** @type {any[][]} */
var V = [
['① 只挂 toolbox,不点', tbOpt()],
['② 点下 dataZoom 图标(takeGlobalCursor)', tbOpt()],
['③ 模拟一次框选', tbOpt()],
['④ 框选之后再点 restore', tbOpt()],
['⑤ yAxis 型框选', tbOpt({ dataZoom: { yAxisIndex: 0 } })],
['⑥ 数一次 dispatch 触发几个 datazoom 事件', dzOpt([slider(25, 75), inside(25, 75)])],
['⑦ brush 与 dataZoom 的图标同时挂', tbOpt({ dataZoom: {}, brush: { toolbox: ['rect', 'clear'] } })],
['⑧ 只挂 toolbox,建图之后再 setOption 一根用户 slider', tbOpt()]
];
function std(svg, c) {
return [dzListModel(c), selectFeatureRow(c), ' getOption().dataZoom=' +
JSON.stringify((function () { try { return c.getOption().dataZoom || null; } catch (e) { return '读不到'; } })()),
geometryRow(svg), axisRow(c, 'x', 0), countRow(c)];
}
// 那两根 select 型 dataZoom 的 id(DATA_ZOOM_ID_BASE + 轴名 + 下标,见 DataZoom.js:251)
var TB_X = '\\u0000_ec_\\u0000toolbox-dataZoom_xAxis0';
var TB_Y = '\\u0000_ec_\\u0000toolbox-dataZoom_yAxis0';
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b12' + String.fromCharCode(97 + idx), function () {
if (idx === 1) {
p.run(V[idx][0], V[idx][1], std, function (chart, reader, arr) {
var p1 = { type: 'takeGlobalCursor', key: 'dataZoomSelect', dataZoomSelectActive: true };
var err = fire(reader, p1);
fire(chart, p1);
arr.push(err ? ' ⇒ takeGlobalCursor 抛错:' + err
: ' ⇒ 已 dispatch takeGlobalCursor(key:"dataZoomSelect"):这一步只做两件事——brush 面板 setPanels + enableBrush(DataZoom.js:216-225),图标进 emphasis 态(:215)');
try {
arr.push(' dispatch 之后 getOption().dataZoom 根数=' + ((reader.getOption().dataZoom || []).length) +
',组件树上的 dataZoom 根数=' + dzCount(reader));
} catch (e2) { }
}, function (svg, c) {
return [' ⇒ 那两根 select 不是点出来的:option creator 建图时就生成了(DataZoom.js:243-255,'
+ '每根被 finder 指到的轴一根,type:"select"、$fromToolbox:true、id=DATA_ZOOM_ID_BASE + 轴名 + 下标)',
dzRow(c, 0), axisRow(c, 'x', 0)];
});
return;
}
if (idx === 2 || idx === 3) {
p.run(V[idx][0], V[idx][1], std, function (chart, reader, arr) {
var p1 = { type: 'takeGlobalCursor', key: 'dataZoomSelect', dataZoomSelectActive: true };
// 照 DataZoom.js:123-127 的 snapshot 形状发:batch 元素带 dataZoomId,只写 startValue/endValue
var p2 = { type: 'dataZoom', batch: [{ dataZoomId: TB_X, startValue: 2, endValue: 6 }] };
var e1 = fire(reader, p1);
fire(chart, p1);
arr.push(e1 ? ' ⇒ takeGlobalCursor 抛错:' + e1 : ' ⇒ 已 takeGlobalCursor(框选态开)');
var err = fire(reader, p2);
fire(chart, p2);
arr.push((err ? ' ⇒ 框选动作抛错:' + err
: ' ⇒ 已按 _onBrush 的形状发 batch(一条元素,dataZoomId 指向 x 轴那根 select,只写 startValue/endValue)') +
'(这一步就是「框完之后松手」:_onBrush 只在 isEnd 且 areas 非空时才发,DataZoom.js:82-89)');
if (idx === 3) {
var e3 = fire(reader, { type: 'restore' });
fire(chart, { type: 'restore' });
arr.push((e3 ? ' ⇒ restore 抛错:' + e3 : ' ⇒ 又 dispatch restore:窗口回到 option 声明值') +
'(toolbox 那个 back 按钮走的是 history.pop,不是 restore:DataZoom.js:183-185)');
}
}, function (svg, c) {
return [dzListModel(c), dzRow(c, 0), dzCount(c) > 1 ? dzRow(c, 1) : ' dz1 =(只有一根)',
axisRow(c, 'x', 0), backRow(c, 0),
' ⇒ 框选的取整在 DataZoom.js:122-126:getAcceptableTickPrecision(scaleExtent, grid 宽, 0.5) 算出 precision,'
+ '再把 coordRange 的两端 round 一下——和第十节那条取整定律是同一个思路(先保证「显示得出来」)'];
});
return;
}
if (idx === 5) {
// 每个监听名单独测一遍:chart.on 大小写不敏感,混在一起测会互相污染
var acts = [
['单个动作 dataZoomIndex:0', { type: 'dataZoom', dataZoomIndex: 0, start: 10, end: 30 }],
['不给 index 的动作', { type: 'dataZoom', start: 10, end: 30 }],
['batch 两个元素', { type: 'dataZoom', batch: [{ dataZoomIndex: 0, start: 10, end: 30 }, { dataZoomIndex: 1, start: 40, end: 60 }] }]
];
var names = ['datazoom', 'dataZoom', 'DataZoom', 'DATAZOOM'];
var tbl = {};
p.run(V[idx][0], V[idx][1], std, function (chart, reader, arr) {
for (var a = 0; a < acts.length; a++) {
for (var k = 0; k < names.length; k++) {
var n = 0, last = /** @type {any} */ (null);
try {
reader.on(names[k], function (e) { n++; last = { type: e.type, start: e.start, end: e.end, batch: e.batch ? e.batch.length : 0 }; });
fire(reader, acts[a][1]);
} catch (e) { arr.push(' ⇒ ' + acts[a][0] + ' 抛错:' + e.message); }
try { reader.off(names[k]); } catch (e2) { }
tbl[acts[a][0] + '|on(' + names[k] + ')'] = n + (last ? ' 次,e.type=' + last.type + ' e.batch=' + last.batch : ' 次');
}
}
arr.push(' 命中表=' + JSON.stringify(tbl, null, 1));
fire(chart, acts[acts.length – 1][1]);
try {
arr.push(' 最后一档 dispatch 之后 getOption().dataZoom 两根的窗口=' +
JSON.stringify((reader.getOption().dataZoom || []).map(function (d) { return [d.start, d.end, d.startValue, d.endValue]; })) +
'(两根共用同一个 axis proxy:动作经 findEffectedDataZooms 联动到所有同轴的组件,helper.js:93-105)');
} catch (e3) { }
}, function (svg, c) {
return [dzRow(c, 0), dzRow(c, 1), axisRow(c, 'x', 0), countRow(c),
' ⇒ 一次 dispatch 只发 1 个 datazoom 事件(四种大小写写法各命中 1);batch 两个元素也只 1 次,'
+ '但 e.batch 是个数组、长度 2(echarts.js:1560-1565「Follow the rule of action batch」)'];
});
return;
}
if (idx === 4) {
p.run(V[idx][0], V[idx][1], std, function (chart, reader, arr) {
var p1 = { type: 'dataZoom', batch: [{ dataZoomId: TB_Y, startValue: 10, endValue: 30 }] };
var err = fire(reader, p1);
fire(chart, p1);
arr.push(err ? ' ⇒ 给 y 轴那根 select 发动作抛错:' + err
: ' ⇒ 只给 dataZoomId 指向 y 的那根 select 发 value 窗口 10~30(x 轴那根不在 batch 里)');
}, function (svg, c) {
return [dzRow(c, 0), dzRow(c, 1), axisRow(c, 'x', 0), axisRow(c, 'y', 0),
' ⇒ 看 dz1(y 那根)的 host 与生效窗:x/y 两根 select 各管一根轴,互不联动(不同轴就不算 linked,helper.js:111-120)'];
});
return;
}
if (idx === 7) {
p.run(V[idx][0], V[idx][1], function (svg, c) {
var o = null, readErr = '';
try { o = c.getOption().dataZoom; } catch (e) { readErr = e.message; }
var shape = [];
if (o) {
for (var q = 0; q < o.length; q++) {
shape.push(q + ':' + (o[q] === null ? 'null(洞)'
: (o[q].id != null ? 'id=' + JSON.stringify(String(o[q].id)) : String(o[q].type))));
}
}
return [selectFeatureRow(c), dzRow(c, 0), dzRow(c, 1), dzRow(c, 2),
' getOption().dataZoom 长度=' + (o ? o.length : '读不到 ' + readErr),
' 逐槽=' + JSON.stringify(shape),
geometryRow(svg), axisRow(c, 'x', 0), countRow(c),
' ⇒ 槽位归 getOption 自己管(Global.js:374-392):倒着扫,撞上内部 id 就把这一槽置 null(:387),'
+ '但只要它后面已经见过非内部组件(metNonInner,:385)就不缩 realLen(:388)——'
+ '内部那两根排在末尾时整段被剪短(①②③档读到 []),夹在用户组件中间时留下一个 null 洞'];
}, function (chart, reader, arr) {
var err = null;
try {
reader.setOption({ dataZoom: [{ type: 'slider', xAxisIndex: 0, start: 20, end: 80 }] });
} catch (e) { err = e.message; }
arr.push(err ? ' ⇒ 第二次 setOption 抛错:' + err
: ' ⇒ 又 setOption 一根用户 slider(merge、不 replaceMerge):看下面它落在第几槽、内部两根去哪');
});
return;
}
p.run(V[idx][0], V[idx][1], std);
});
})(i);
}
p.run(V[0][0], V[0][1], std);
}
/* ===== 面板十三:时间轴上的缩放(第十九、二十节) ===== */
function stockTimeOpt(dz, count) {
var rows = (STOCK || []).slice(0, count || STOCK.length);
return /** @type {any} */({
animation: false,
xAxis: { type: 'time' }, yAxis: { scale: true },
series: [{
type: 'candlestick',
data: rows.map(function (r) {
return [+new Date(r.date + 'T00:00:00Z'), r.open, r.close, r.lowest, r.highest];
})
}],
dataZoom: dz || []
});
}
function msRow(v) {
if (typeof v !== 'number' || !isFinite(v)) return String(v);
var d = new Date(v);
return v + '(UTC ' + d.toISOString().slice(0, 10) + ')';
}
function initPanel13() {
var p = makePanel('p13');
var rows6 = [
['2023-01-01', 10], ['2023-01-02', 12], ['2023-01-03', 11],
['2023-02-01', 30], ['2023-02-02', 28], ['2023-03-01', 40]
];
function sparseOpt(dz, asCategory, list) {
var rows = list || rows6;
var ms = rows.map(function (r) { return +new Date(r[0] + 'T00:00:00Z'); });
return /** @type {any} */({
animation: false,
xAxis: asCategory ? { type: 'category', data: rows.map(function (r) { return r[0]; }) } : { type: 'time' },
yAxis: {},
series: [{
type: 'line', showSymbol: false,
data: rows.map(function (r, i) { return asCategory ? r[1] : [ms[i], r[1]]; })
}],
dataZoom: dz || []
});
}
/** @type {any[][]} */
var V = [
['① time 轴 + 25% ~ 75%', timeOpt([slider(25, 75)])],
['② 同数据换 category 轴(对照取点)', dailyOpt([{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 }])],
['③ 故意不等距:time 轴的 x 列', sparseOpt([{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }])],
['④ startValue/endValue 写成日期串', timeOpt([{ type: 'slider', xAxisIndex: 0, startValue: '2023-06-01', endValue: '2024-06-01' }])],
['⑤ 同一档改成毫秒数', (function () {
var a = +new Date('2023-06-01T00:00:00Z'), b = +new Date('2024-06-01T00:00:00Z');
return timeOpt([{ type: 'slider', xAxisIndex: 0, startValue: a, endValue: b }]);
})()],
['⑥ useUTC:false vs true', timeOpt([slider(25, 75)], null, { useUTC: false })],
['⑦ 261 天 K 线缩到 5% 窗口', stockTimeOpt([{ type: 'slider', xAxisIndex: 0, start: 5, end: 100 }])],
['⑧ minValueSpan / maxValueSpan 按毫秒夹', timeOpt([{
type: 'slider', xAxisIndex: 0, start: 0, end: 100,
minValueSpan: 86400000 * 30
}])],
['⑨ 只有一天:time 轴零跨度,影子整条 NaN', sparseOpt(
[{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }], false, [['2023-01-01', 10]])]
];
function std(svg, c) {
var d = dzInfo(c, 0) || {};
return [dzRow(c, 0),
' 生效窗换回日期=' + (d.wv ? msRow(d.wv[0]) + ' ~ ' + msRow(d.wv[1]) : 'null'),
' 你写的四键=' + JSON.stringify(d.写),
backRow(c, 0), axisRow(c, 'x', 0), shadowXRow(svg), shadowRow(svg), countRow(c)];
}
// 刻度的 UTC 时刻(带到小时,日界差几天就一眼看得见)
function tickUtcRow(c) {
var a = axisInfo(c, 'x', 0);
if (!a || !a.ticks) return ' 刻度换 UTC 时刻=(读不到)';
return ' 刻度换 UTC 时刻=' + a.ticks.map(function (v) {
return new Date(v).toISOString().slice(0, 10) + 'T' + new Date(v).toISOString().slice(11, 13) + '时';
}).join(' ');
}
function stdUtc(svg, c) {
return std(svg, c).concat([tickUtcRow(c)]);
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b13' + String.fromCharCode(97 + idx), function () {
if (idx === 2) {
p.runMany([
{
label: '③-a time 轴(按真实时间差摆)', o: V[2][1],
build: function (svg) {
var xs = shadowXs(svg), g = [];
for (var k = 1; k < xs.length; k++) g.push(r2(xs[k] – xs[k – 1]));
return [shadowXRow(svg),
' 相邻两点之间的 px 间隔=' + JSON.stringify(g) +
'(影子宽 300 ÷ 全跨度 59 天 = 一天 5.08px;01-03→02-01 那一跳 29 天 ⇒ 147.4px)',
' ⇒ time 轴摆点看的是毫秒差,不是「第几个点」'];
}
},
{
label: '③-b category 轴(永远等距)', o: sparseOpt([{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }], true),
build: function (svg) {
var xs = shadowXs(svg), g = [];
for (var k = 1; k < xs.length; k++) g.push(r2(xs[k] – xs[k – 1]));
return [shadowXRow(svg),
' 相邻两点之间的 px 间隔=' + JSON.stringify(g) + ' ⇒ 类目轴步长 = 影子宽 ÷ (条数−1),跟日期差无关'];
}
}
]);
return;
}
if (idx === 4) {
p.run(V[idx][0], V[idx][1], function (svg, c) {
var d = dzInfo(c, 0) || {};
var utcMs = +new Date('2023-06-01T00:00:00Z');
var localMs = +new Date(2023, 5, 1);
return std(svg, c).concat([tickUtcRow(c),
' 本档 startValue=' + (d.写 ? d.写.startValue : '?') + '(自己 new Date("2023-06-01T00:00:00Z") 拿的毫秒,带 Z ⇒ UTC 零点);'
+ '④ 档把同一个日期写成串 ⇒ ECharts 的 parseDate 走「没有时区后缀就按本地」那条分支(util/number.js:442-446),'
+ '落出来是 ' + localMs + ',两档差 ' + (utcMs – localMs) + ' ms(本机时区偏移 ' + (–new Date().getTimezoneOffset() / 60) + ' 小时)',
' ⇒ 数字(rangePropMode 里的 value)一旦进了 proxy,后面所有账都按这个毫秒数算,串只是进门时被翻译了一次']);
});
return;
}
if (idx === 5) {
p.runMany([
{ label: '⑥-a useUTC:false(默认:月界按本机时区切)', o: timeOpt([slider(25, 75)], null, { useUTC: false }), build: stdUtc },
{ label: '⑥-b useUTC:true(月界按 UTC 切)', o: timeOpt([slider(25, 75)], null, { useUTC: true }), build: stdUtc }
]);
return;
}
if (idx === 7) {
p.runMany([
{
label: '⑧-a minValueSpan:30 天,窗口写 0~0.1%(比 30 天窄)',
o: timeOpt([{ type: 'slider', xAxisIndex: 0, start: 0, end: 0.1, minValueSpan: 86400000 * 30 }]),
build: std
},
{
label: '⑧-b maxValueSpan:60 天,窗口写 0~100%(比 60 天宽)',
o: timeOpt([{ type: 'slider', xAxisIndex: 0, start: 0, end: 100, maxValueSpan: 86400000 * 60 }]),
build: std
}
]);
return;
}
if (idx === 8) {
p.runMany([
{
label: '⑨-a time 轴,只有 2023-01-01 这一天', o: V[8][1],
build: function (svg) {
return [nanTagRow(svg), shadowRow(svg),
' 影子 x 的闭式(SliderZoomView.js:286,299):(v − x最小) × (影子宽 ÷ (x最大 − x最小))',
' ⇒ 零跨度 ⇒ 除数是 0 ⇒ 系数 Infinity ⇒ 0×Infinity = NaN',
' ⇒ 点数 1 × (polygon + polyline) × 3 组 = 6 个 NaN;折线本体、轴、手柄全都正常,只有影子坏',
' ⇒ 补一招:`showDataShadow:false` 或给 x 轴写 min/max,让它有跨度']
}
},
{
label: '⑨-b 同一份数据换 category 轴(下标有跨度)',
o: sparseOpt([{ type: 'slider', xAxisIndex: 0, start: 0, end: 100 }], true, [['2023-01-01', 10]]),
build: function (svg) { return [nanTagRow(svg), shadowRow(svg)]; }
}
]);
return;
}
p.run(V[idx][0], V[idx][1], std);
});
})(i);
}
p.run(V[0][0], V[0][1], std);
}
/* ===== 面板十四:timeline 另一根轴、另一套状态(第二十一节) ===== */
function initPanel14() {
var p = makePanel('p14');
/** @type {any[][]} */
var V = [
['① 四帧(category,最小可跑)', tlOpt({ currentIndex: 1 }, null, ['2023', '2024', '2025', '2026'])],
['② currentIndex:7(帧只有 3 个)', tlOpt({ currentIndex: 7 })],
['③ controlPosition:"none"', tlOpt({ controlPosition: 'none' })],
['④ controlPosition:"right"', tlOpt({ controlPosition: 'right' })],
['⑤ axisType:"value":千分位毁帧名 / 整条 NaN', tlOpt({ axisType: 'value', data: ['2023', '2024', '2025'] })],
['⑥ axisType:"time"(默认):ISO 帧 / 中文帧', tlOpt({ axisType: 'time', data: ['2023-01-01', '2023-07-01', '2024-01-01'] })],
['⑦ timeline + slider 共存', (function () {
var o = tlOpt();
o.baseOption.dataZoom = [{ type: 'slider', xAxisIndex: 0, start: 25, end: 75 }];
return o;
})()],
['⑧ dispatchAction(timelineChange) 与事件名', tlOpt()],
['⑨ 切帧之后的残留', tlOpt()]
];
function std(svg, c) {
return [timelineRow(c), controlRow(svg), tickLabelRow(svg),
' 落盘 path=' + countTag(svg, 'path') + ' circle=' + countTag(svg, 'circle') + ' text=' + countTag(svg, 'text') +
' 字节=' + svg.length + ' NaN=' + ((svg.match(/NaN/g) || []).length),
nanTagRow(svg),
' 系列=' + JSON.stringify((function () {
try { return (c.getOption().series || []).map(function (s) { return s.type + ':' + (s.data || []).length; }); }
catch (e) { return '读不到'; }
})())];
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b14' + String.fromCharCode(97 + idx), function () {
if (idx === 7) {
var S = { got: {}, eType: '—', eIdx: '—' };
p.runMany([
{
label: '⑧-a 四个名字一起挂,发一次 type:"timelineChange"',
o: tlOpt(), build: std,
act: function (chart, reader, arr) {
var names = ['timelineChange', 'timelineChanged', 'timelinechanged', 'TimelineChanged'];
S.got = {}; S.eType = '—'; S.eIdx = '—';
for (var k = 0; k < names.length; k++) {
(function (nm) {
S.got[nm] = 0;
try {
reader.on(nm, function (e) { S.got[nm]++; S.eType = e && e.type; S.eIdx = e && e.currentIndex; });
} catch (err) { }
})(names[k]);
}
try { reader.dispatchAction({ type: 'timelineChange', currentIndex: 1 }); }
catch (err2) { arr.push(' ⇒ 动作抛错:' + err2.message); }
for (var k2 = 0; k2 < names.length; k2++) { try { reader.off(names[k2]); } catch (err3) { } }
try { chart.dispatchAction({ type: 'timelineChange', currentIndex: 1 }); } catch (err4) { }
},
after: function (svg, c) {
return [' 事件命中=' + JSON.stringify(S.got) + ' 回调里 e.type=' + JSON.stringify(S.eType) +
' e.currentIndex=' + JSON.stringify(S.eIdx),
' ⇒ chart.on 的名字大小写不敏感(挂上去之前先 toLowerCase:echarts.js:245-248),'
+ '但认的是 timelineChanged 这个词;timelineChange 是动作的 type、不是事件名,命中恒 0',
' ⇒ 回调里 e.type 是全小写 "timelinechanged":事件名在 timelineAction.js:48 注册成 timelineChanged,'
+ '派发前过 createEventType 的 toLowerCase(echarts.js:2288-2292)',
timelineRow(c)];
}
},
{
label: '⑧-b 把 type 写成小写 timelinechange',
o: tlOpt(), build: std,
act: function (chart, reader, arr) {
var hits = 0;
try { reader.on('timelineChanged', function () { hits++; }); } catch (e0) { }
try { reader.dispatchAction({ type: 'timelinechange', currentIndex: 2 }); }
catch (e1) { arr.push(' ⇒ dispatchAction 当场抛:' + e1.message); }
try { chart.dispatchAction({ type: 'timelinechange', currentIndex: 2 }); } catch (e2) { }
arr.push(' 小写 type 这一次:事件命中=' + hits + '(dispatchAction 的 type 查表不 lowerCase ⇒ 大小写敏感)');
try { reader.off('timelineChanged'); } catch (e3) { }
},
after: function (svg, c) { return [timelineRow(c)]; }
},
{
label: '⑧-c 帧只有 3 个,用动作推 currentIndex:7(loop:true)',
o: tlOpt(), build: std,
act: function (chart, reader, arr) {
try { reader.dispatchAction({ type: 'timelineChange', currentIndex: 7 }); } catch (e) { arr.push(' ⇒ ' + e.message); }
try { chart.dispatchAction({ type: 'timelineChange', currentIndex: 7 }); } catch (e2) { }
arr.push(' ⇒ 同一个 7:写进 option 不夹(② 那一档留着 7、检查点飞成 NaN),走动作要过 setCurrentIndex ⇒ loop:true 时 7%3=1');
},
after: function (svg, c) { return [timelineRow(c), ' (夹与取模发生在 TimelineModel.js:76-83,getCurrentIndex 只是原样读 option.currentIndex)']; }
},
{
label: '⑧-d 同上,loop:false',
o: tlOpt({ loop: false }), build: std,
act: function (chart, reader, arr) {
try { reader.dispatchAction({ type: 'timelineChange', currentIndex: 7 }); } catch (e) { arr.push(' ⇒ ' + e.message); }
try { chart.dispatchAction({ type: 'timelineChange', currentIndex: 7 }); } catch (e2) { }
arr.push(' ⇒ loop:false ⇒ 夹到 count-1 = 2');
},
after: function (svg, c) { return [timelineRow(c)]; }
}
]);
return;
}
if (idx === 8) {
function ser(inst) {
try { return (inst.getOption().series || []).map(function (s) { return (s.data || []).slice(0, 4); }); }
catch (e) { return '读不到'; }
}
function skel(inst) {
try {
return 'xAxis[0].data 长度=' + ((inst.getOption().xAxis[0] || {}).data || []).length +
' 系列数=' + (inst.getOption().series || []).length;
} catch (e) { return '读不到'; }
}
p.runMany([{
label: V[idx][0], o: V[idx][1], build: std,
act: function (chart, reader, arr) {
arr.push(' 切帧前系列=' + JSON.stringify(ser(reader)) + ' ' + skel(reader));
try { reader.dispatchAction({ type: 'timelineChange', currentIndex: 1 }); } catch (e) { arr.push(' ⇒ ' + e.message); }
try { chart.dispatchAction({ type: 'timelineChange', currentIndex: 1 }); } catch (e0) { }
arr.push(' 切帧后系列=' + JSON.stringify(ser(reader)) + ' ' + skel(reader));
try { reader.dispatchAction({ type: 'timelineChange', currentIndex: 2 }); } catch (e2) { }
try { chart.dispatchAction({ type: 'timelineChange', currentIndex: 2 }); } catch (e3) { }
arr.push(' 再切一帧=' + JSON.stringify(ser(reader)) + ' ' + skel(reader));
},
after: function (svg, c) {
return [timelineRow(c),
' ⇒ 帧是**增量合并**的:三帧各只写 series.data,轴与骨架逐档不变(探针 D9a/D11a:写 name 的帧改掉了名,'
+ '空对象帧什么都不改;要靠 replaceMerge 才切得动系列数)'];
}
}]);
return;
}
if (idx === 4) {
p.runMany([
{
label: '⑤-a axisType:"value" + 纯年串(不抛错,但被千分位毁了)',
o: tlOpt({ axisType: 'value', data: ['2023', '2024', '2025'] }), build: std
},
{
label: '⑤-b axisType:"value" + ISO 串(帧名读成 null ⇒ 整条 NaN)',
o: tlOpt({ axisType: 'value', data: ['2023-01-01', '2023-07-01', '2024-01-01'] }), build: std
}
]);
return;
}
if (idx === 5) {
p.runMany([
{
label: '⑥-a axisType:"time"(默认)+ ISO 串(标签被刻度格式化接管)',
o: tlOpt({ axisType: 'time', data: ['2023-01-01', '2023-07-01', '2024-01-01'] }), build: std
},
{
label: '⑥-b 压根不写 axisType(库补默认值 time)+ 中文帧名',
o: (function () { var oo = tlOpt({ data: ['甲', '乙', '丙'] }); delete oo.timeline.axisType; return oo; })(),
build: std
}
]);
return;
}
p.run(V[idx][0], V[idx][1], std);
});
})(i);
}
p.run(V[0][0], V[0][1], std);
}
/* ===== 面板十五:三年日线看板(第二十二节,收口档,800×360) ===== */
function quarterAgg() {
var map = {}, order = [];
for (var i = 0; i < DAILY.length; i++) {
var y = DAILY[i].date.slice(0, 4), q = Math.floor((+DAILY[i].date.slice(5, 7) – 1) / 3) + 1;
var k = y + 'Q' + q;
if (map[k] == null) { map[k] = 0; order.push(k); }
map[k] += DAILY[i].sales;
}
return { names: order, sums: order.map(function (k) { return Math.round(map[k] * 100) / 100; }) };
}
function dashBoardOpt(which) {
var dates = DAILY.map(function (r) { return r.date; });
var sales = DAILY.map(function (r) { return r.sales; });
var qa = quarterAgg();
var base = {
animation: false,
title: { text: '三年日销看板', left: 12, top: 6 },
grid: { left: 70, right: 30, top: 40, height: 150 },
xAxis: { type: 'category', data: dates },
yAxis: [{ type: 'value', name: '销售额' }],
series: [{ type: 'line', data: sales, showSymbol: false }],
dataZoom: []
};
if (which === 0) {
base.dataZoom = [
{ type: 'slider', xAxisIndex: 0, start: 60, end: 100, height: 24, bottom: 8 },
{ type: 'slider', yAxisIndex: 0, orient: 'vertical', start: 0, end: 100, right: 8, top: 40, height: 150 }
];
} else if (which === 2) {
base.dataZoom = [{ type: 'inside', xAxisIndex: 0, start: 60, end: 100, filterMode: 'none' },
{ type: 'slider', xAxisIndex: 0, start: 60, end: 100, height: 24, bottom: 8 }];
} else if (which === 3) {
base.toolbox = { feature: { dataZoom: { yAxisIndex: 'none' }, restore: {} }, left: 'center', top: 4 };
base.dataZoom = [{ type: 'slider', xAxisIndex: 0, start: 60, end: 100, height: 24, bottom: 8 }];
} else if (which === 4) {
base.dataZoom = [{ type: 'slider', xAxisIndex: 0, start: 60, end: 100, minSpan: 100 / 1096 * 182, height: 24, bottom: 8 }];
} else {
base.dataZoom = [{ type: 'slider', xAxisIndex: 0, start: 60, end: 100, height: 24, bottom: 8 }];
}
if (which === 1 || which === 5) {
// 帧只点亮「当季那一段」的柱子:data 长度必须等于 1096(与 x 轴类目逐格对齐),
// 且要用 [{}, {…}] 给第一个系列占位——帧里的 series 是按下标合并的。
var qOf = DAILY.map(function (r) {
return r.date.slice(0, 4) + 'Q' + (Math.floor((+r.date.slice(5, 7) – 1) / 3) + 1);
});
var frames = qa.names.map(function (k) {
return { series: [{}, { data: DAILY.map(function (r, i) { return qOf[i] === k ? r.sales : null; }) }] };
});
return /** @type {any} */({
animation: false,
baseOption: mix(base, {
series: [{ type: 'line', data: sales, showSymbol: false },
{ type: 'bar', data: DAILY.map(function (r, i) { return qOf[i] === qa.names[0] ? r.sales : null; }),
xAxisIndex: 0, yAxisIndex: 0, barWidth: '80%' }],
grid: { left: 70, right: 30, top: 40, height: 150 },
dataZoom: [{ type: 'slider', xAxisIndex: 0, height: 24, bottom: 8 }]
}),
timeline: {
axisType: 'category', data: qa.names, bottom: 40, height: 40,
label: { interval: 1 }, playInterval: 400
},
options: frames
});
}
return /** @type {any} */(base);
}
function initPanel15() {
var p = makePanel('p15');
var V = ['① 双 slider:上面管日期、下面管数值', '② + timeline 按季度 12 帧(帧点亮当季柱)',
'③ + inside 滚轮,filterMode:"none"', '④ + toolbox 框选与 restore',
'⑤ minSpan 半年 + 手算季度聚合表', '⑥ 逐帧播放时的窗口读数'];
function std(svg, c) {
return [dzListModel(c), dzRow(c, 0), dzCount(c) > 1 ? dzRow(c, 1) : ' dz1 =(只有一根)',
axisRow(c, 'x', 0), axisRow(c, 'y', 0), gridRow(c), countRow(c),
timelineRow(c), shadowRow(svg),
' 落盘 path=' + countTag(svg, 'path') + ' text=' + countTag(svg, 'text') + ' 字节=' + svg.length +
' NaN=' + ((svg.match(/NaN/g) || []).length)];
}
for (var i = 0; i < V.length; i++) {
(function (idx) {
bind('b15' + String.fromCharCode(97 + idx), function () {
if (idx === 4) {
p.run(V[idx], dashBoardOpt(4), std, function (chart, reader, arr) {
var p1 = { type: 'dataZoom', dataZoomIndex: 0, start: 0, end: 3 };
var err = fire(reader, p1);
fire(chart, p1);
arr.push(' ⇒ 想压到 3%(比半年窄):' + (err ? '这一趟动作被拒:' + err : '动作正常送达,是 minSpan 把窗口顶回了半年'));
arr.push(' ⇒ 压完之后窗口读数见下(minSpan 把它顶回半年,看 dz0 的「生效窗」与 span 族里的 minSpan)');
}, function (svg, c) {
return [dzRow(c, 0), axisRow(c, 'x', 0), countRow(c), quarterRows()];
});
return;
}
if (idx === 5) {
var got = 0;
p.run(V[idx], dashBoardOpt(1), std, function (chart, reader, arr) {
try { reader.on('timelineChanged', function () { got++; }); } catch (e) { }
for (var k = 1; k <= 3; k++) {
var p1 = { type: 'timelineChange', currentIndex: k };
fire(reader, p1);
fire(chart, p1);
try {
var d = (reader.getOption().dataZoom || [])[0] || {};
var bdata = (((reader.getOption().series || [])[1] || {}).data) || [];
var filled = [];
for (var q = 0; q < bdata.length; q++) { if (bdata[q] != null) filled.push(q); }
arr.push(' 第 ' + k + ' 帧回读窗口 start/end=' + JSON.stringify([d.start, d.end]) +
' 柱(第二条系列)非空 ' + filled.length + ' 格,下标 ' +
(filled.length ? filled[0] + ' ~ ' + filled[filled.length – 1] : '(无)') +
',日线(第一条系列)条数=' + ((((reader.getOption().series || [])[0] || {}).data) || []).length);
} catch (e3) { arr.push(' 第 ' + k + ' 帧读不到:' + e3.message); }
}
try { reader.off('timelineChanged'); } catch (e4) { }
arr.push(' ⇒ 三帧各触发 1 次 timelineChanged,命中 ' + got + ' 次;上面回读到的 start/end 是这一帧 option 里写的窗口'
+ '(帧是增量合并:这三帧各只写 series[1].data,没写 dataZoom ⇒ 窗口留着上一帧的值,日线那条也一格没动)');
}, function (svg, c) {
return [timelineRow(c), dzRow(c, 0), axisRow(c, 'x', 0)];
});
return;
}
p.run(V[idx], dashBoardOpt(idx), std);
});
})(i);
}
p.run(V[0], dashBoardOpt(0), std);
}
【参数速查】
这一段的默认值不是抄文档,是现场读的。探针把三型 dataZoom 与 timeline 的 constructor.defaultOption(merge 之前的原样)和 getOption() 里 merge 完的 option 并排打印,颜色一律写成解析之后的十六进制或 rgba 串——源码里它们是 tokens.color.accent20 这类令牌,住在 echarts/lib/visual/tokens.js。开表之前先立两件事。一,基座只有四键:DataZoomModel.js:405-410 写的是 {z:4, filterMode:'filter', start:0, end:100},一个版式键都没有;slider 往上加 28 键(SliderZoomModel.js:57-129,最后一键是 defaultLocationEdgeGap:15),inside 加 6 键(InsideZoomModel.js:55-62),select 一型加零键——它只活到基座为止,这就是它画不出滑块的原因。这两族的 defaultOption 也都是平铺的(现场读 defaultOption.option 为 undefined),跟 title、legend 那种「normal 还套一层」的老写法不同,别去 option.option 里找默认值。二,merge 完你一定多看见三样:throttle 是 DataZoomModel.js:246-252 现算的(开启动画且 animationDurationUpdate > 0 ⇒ 100,否则 20,本章所有档都写 animation:false,所以现场读回的一直是 20),startValue/endValue 则可能是 dataZoomProcessor.js:120-135 回填的生效窗而不是你的声明(第六节)——B0 里你只写了 start:25, end:75,getOption() 却会给你 startValue 与 endValue。
表一:dataZoom 基座(三型共用)
| dataZoom | object | object[] | — | — | 顶层组件数组。写进 series 里是死键,落盘一个字节都不动(第一节、【最小可跑】✗1) |
| dataZoom.type | string | 'slider' | — | slider / inside / select。不写时由 installCommon.js:52 的 subTypeDefaulter 折成 slider(第四节) |
| dataZoom.xAxisIndex | number | number[] | ‘all’ | ‘none’ | 自动认领 | — | 三档认领链见第二节;空数组 [] 是一根合法的死滑块(第十七节 ④) |
| dataZoom.yAxisIndex | 同上 | 自动认领 | — | 与 xAxisIndex 共用那条链,orient 决定它先扑哪一族 |
| dataZoom.radiusAxisIndex | 同上 | 自动认领 | — | 极坐标那两维与 singleAxis 也在同一份五维名单里(helper.js:47) |
| dataZoom.filterMode | string | 'filter' | — | filter / weakFilter / empty / none,处置的是行(第十二节) |
| dataZoom.start | number | 0 | 会被刷成生效窗 | 百分比。分母是跨度不是根数,取整只有 Math.round 一条(第三节) |
| dataZoom.end | number | 100 | 会被刷成生效窗 | 写反不抛错,AxisProxy.js:169-170 两句 asc() 把窗口折回来(第四节) |
| dataZoom.startValue | number | string | Date | — | 常被回填 | 值模式。越界不平抛、把整扇窗平推进轴里(第七节);日期串按本地时区(第十九节 ④) |
| dataZoom.endValue | number | string | Date | — | 常被回填 | 同上。轴上另有一个同名键,那是柱子的起笔值,两个人不是一回事(第九节) |
| dataZoom.z | number | 4 | — | 滑块那组图元的层号 |
| dataZoom.throttle | number | null | 100;animation:false 时读到 20 | :246-252 现算 | 写 null 关节流。第一次显式写值之后自动节流就此关闭(:245-247) |
| dataZoom.minSpan | number | — | — | 窗口宽度下限(百分比),会被夹进 [0, 跨度](第十一节) |
| dataZoom.maxSpan | number | — | 至少等于 minSpan | 上限。sliderMove.js:77-79 保证它不小于 minSpan |
| dataZoom.minValueSpan | number | — | 可由 minSpan 派生 | 同一件事的「值」版本,time 轴上就是毫秒。优先级压过百分比那一族(AxisProxy.js:379) |
| dataZoom.rangeMode | string[] | — | — | 两端各一格 ['value' | 'percent', …],决定 span 族拿哪一对键去夹(第二十节 :148、:185) |
| dataZoom.orient | string | 不在任何 defaultOption 里,现场读到 undefined | :144 / :146 | 由 _makeAutoOrientByTargetAxis(:237-244)按目标轴推;没人指定轴时落 'horizontal' |
| dataZoom.id | string | — | — | 给动作与 toolbox 定向用。toolbox 自己造的那两根的 id 是 toolbox-dataZoom_xAxis0 前面挂内部前缀 |
表二:slider 那一型多出来的 28 键
| dataZoom.show | boolean | true | 有 | false 撤画面但窗口照算、数据照裁(第二节) |
| dataZoom.left / dataZoom.bottom | number | string | null | null | 有 | 默认这两键是 null;管版面的是 right/top/width/height 那四键(SliderZoomModel.js:57-65),第十四节定律一读的是 top 与 height |
| dataZoom.right / dataZoom.top | 同上 | 'ph' | 有 | 'ph' 不是「自动」而是占位符:SliderZoomView.js:144-150 每次布局现算一份,:159-165 只把还挂着 'ph' 的那几键换掉,:157-158 明写「不写回 option,因为 resize 时要重算」 |
| dataZoom.width / dataZoom.height | 同上 | 'ph' | 有 | 横滑块时 height 折成 DEFAULT_FILLER_SIZE = 30(:63、:150)、width 折成目标 grid 的宽;「40」这个数在本章任何默认档里都不出现 |
| dataZoom.borderColor | ColorString | '#e0e4f2' | 有 | 轨道描边 |
| dataZoom.borderRadius | number | number[] | 0 | 有 | 轨道圆角 |
| dataZoom.backgroundColor | ZRColor | 'rgba(0,0,0,0)' | 有 | 轨道底(第十五节那四条矩形之一) |
| dataZoom.dataBackground | object | {lineStyle:{color:'#a1aed9',width:0.5}, areaStyle:{color:'#c0c9e6',opacity:0.2}} | 有 | 缩略图窗口外那半截 |
| dataZoom.selectedDataBackground | object | {lineStyle:{color:'#8292cc',width:0.5}, areaStyle:{color:'#c0c9e6',opacity:0.3}} | 有 | 窗口内那半截(第十六节数的是它们的顶点) |
| dataZoom.fillerColor | ZRColor | 'rgba(135,175,274,0.2)' | 有 | 选中区填充。源码里第三通道就写着 274,那一串是原样落盘的 |
| dataZoom.handleIcon | string | 一条 path:// 竖槽 | 有 | 官方标注 @deprecated Use handleStyle instead(shared.d.ts:10804-10806) |
| dataZoom.handleSize | number | string | '100%' | 有 | 字符串是滑块高度的百分比,宽度按图标宽高比算(第十四节) |
| dataZoom.handleStyle | object | {color:'#fff',borderColor:'#c0c9e6'} | 有 | 手柄填充与描边 |
| dataZoom.moveHandleSize | number | string | 7 | 有 | 写 0 时整条拖动条不画(第十五节) |
| dataZoom.moveHandleIcon | string | 三条 path:// 的图标 | 有 | 拖动条身上的图案 |
| dataZoom.moveHandleStyle | object | {color:'#8292cc',opacity:0.5} | 有 | 拖动条样式 |
| dataZoom.showDetail | boolean | true | 无 | 只管拖动那一下的浮标签 |
| dataZoom.showDataShadow | boolean | ‘auto’ | 'auto' | 有 | false 时三组面三组线整个消失(第十六节) |
| dataZoom.realtime | boolean | true | 无 | 假的时候松手才落窗(第六节那两道门之外,这是第三道闸) |
| dataZoom.zoomLock | boolean | false | 无 | 只许平移不许缩放。minSpan:200 被夹成整根轴时看起来和它一样(第十一节) |
| dataZoom.textStyle | object | {color:'#6d6e73'} | 无 | 它被 SliderZoomView.js:472-485 读去做手柄标签的文字,而手柄标签默认 invisible(:474-477);d.ts 里还留着一句 Slider dataZoom don't support textStyle(:10772) |
| dataZoom.brushSelect | boolean | true | 有 | 关掉之后拖动条整条不画(第十五节 ⑥) |
| dataZoom.brushStyle | object | {color:'#a1aed9',opacity:0.3} | 无 | 框选进行中的那层盖子的样式,静图上看不见 |
| dataZoom.labelPrecision | number | ‘auto’ | — | 无 | 只影响浮标签的显示位数,源码明写「不允许用它改数值精度或步进」(shared.d.ts:10824-10828) |
| dataZoom.labelFormatter | string | function | — | 无 | 同上,只在交互里出现 |
| dataZoom.handleLabel | object | 不在 defaultOption 里 | 无 | {show:false};emphasis.handleLabel.show 默认才是 true |
| dataZoom.emphasis | object | {handleLabel:{show:true},handleStyle:{borderColor:'#8292cc'},moveHandleStyle:{opacity:0.8}} | 无 | 全在 hover 里 |
| dataZoom.defaultLocationEdgeGap | number | 15 | 有 | d.ts 标了 @private(:10844-10848)。第十四节定律一「组顶 = 容器高 − 30 − 这个数 − 有没有拖动条」读的就是它(SliderZoomView.js:142、:148) |
表三:inside 那一型多出来的键
| dataZoom.disabled | boolean | false | 无 | inside 本来一个像素都不落,所以这一族的键全都只在交互里活 |
| dataZoom.zoomLock | boolean | false | 无 | InsideZoomModel.js:55-62 的六键之一(滑块那型在 SliderZoomModel.js:108 也写了一遍);只许平移不许缩放 |
| dataZoom.zoomOnMouseWheel | boolean | ‘shift’ | ‘ctrl’ | ‘alt’ | true | 无 | modifier 写法是「按住才缩放」 |
| dataZoom.moveOnMouseMove | 同上 | true | 无 | 拖动平移 |
| dataZoom.moveOnMouseWheel | 同上 | false | 无 | 滚轮默认不平移,与上一键互斥使用 |
| dataZoom.preventDefaultMouseMove | boolean | true | 无 | 不让页面跟着滚。移动端与嵌入式里第一个要动的就是它 |
| dataZoom.cursorGrab | string | 不在 defaultOption 里,实际 'grab' | 无 | 默认值住在 RoamController.js:105-106,模型层 get 读回来是 undefined |
| dataZoom.cursorGrabbing | string | 同上,实际 'grabbing' | 无 | 抓起来那一下的光标 |
现场读 inside merge 完的 option 一共 15 项(探针 tools/__probe/ch20keysi.mjs):基座四键 z/filterMode/start/end(DataZoomModel.js:405-411)+ InsideZoomModel.js:55-62 那六键 + throttle + startValue/endValue + type/xAxisIndex。同一档 slider 是 37 项、select 只有 9 项。没有 show、没有 realtime、没有 textStyle,也没有任何版式键——所以给 inside 写 height:40 之类的键不是「不生效」,是压根没人读。
表四:timeline
| timeline | object | — | — | 与 baseOption、options 成套用(第二十一节)。完全不写 ⇒ 组件数 0 |
| timeline.axisType | string | 'time' | TimelineModel.js:162 | 三值 category / time / value。帧名显示成什么归它管,不归 data 管(第二十一节) |
| timeline.data | array | [] | — | 帧名或 {value, label, itemStyle…} 对象 |
| timeline.currentIndex | number | 0 | 不夹不取模 | 写进 option 不夹、走动作才夹(第二十一节 ⑥⑦) |
| timeline.autoPlay | boolean | false | — | 真值 ⇒ 库自己发 timelineChange 动作,于是会走 :52-53 那两句夹取 |
| timeline.playInterval | number | 2000 | — | 每帧毫秒 |
| timeline.loop | boolean | true | :54-62 | 假值且播到末帧时,库自己发一条 timelinePlayChange 把播放关掉 |
| timeline.rewind | boolean | false | — | 倒着播 |
| timeline.realtime | boolean | true | — | 拖检查点时逐帧切换,还是松手才切 |
| timeline.controlPosition | string | 'left' | preprocessor.js:70-80 | 老键。preprocessor 会把它搬进 controlStyle.position 然后 delete 掉原键(坑 12) |
| timeline.controlStyle | object | {show:true,showPlayBtn:true,showPrevBtn:true,showNextBtn:true,itemSize:24,itemGap:12,position:'left',prevBtnSize:18,nextBtnSize:18,color:'#6578ba'} | 有 | 三枚按钮的开关与摆位。show:false 时 fill="#6578ba" 从 4 处掉到 1 处(剩下那处是检查点) |
| timeline.label | object | {show:true,position:'auto',interval:'auto',rotate:0,color:'#6d6e73'} | 有 | position 可以是数字,负数往左/上(SliderTimelineModel.js:77-88 的注释) |
| timeline.checkpointStyle | object | {symbol:'circle',symbolSize:15,color:'#6578ba',animation:true,animationDuration:300,animationEasing:'quinticInOut'} | 有 | 那个会动的检查点 |
| timeline.lineStyle | object | {show:true,width:2,color:'#e0e4f2'} | 有 | 轴脊 |
| timeline.itemStyle | object | {color:'#c0c9e6',borderWidth:0} | 有 | 刻度点 |
| timeline.symbol / timeline.symbolSize | string / number | 'circle' / 12 | 有 | 刻度点的形状 |
| timeline.progress | object | {lineStyle:{color:'#a1aed9'},itemStyle:{color:'#8292cc'} | 有 | 「播过的这一头」的颜色 |
| timeline.orient | string | 'horizontal' | SliderTimelineModel.js:65 | 注意 dataZoom.orient 没有默认值,这一族的有 |
| timeline.left / timeline.right | 同上 | '20%' / '20%' | — | 默认让开两侧,padding 是 15,height 是 40,bottom 是 0 |
| timeline.backgroundColor | ZRColor | 'rgba(0,0,0,0)' | 有 | 板底色,borderColor 默认 '#b7b9be'、borderWidth 默认 0 |
| timeline.type | string | — | preprocessor.js:60-68 | EC2 遗留。'number' ⇒ axisType:'value'、'time' ⇒ axisType:'time',然后原键被 delete(坑 11) |
| timeline.replaceMerge | string[] | — | timelineAction.js:65-67 | 切帧时框架件怎么替换,第二十一节那本账 |
表五:toolbox.feature.dataZoom 与它造出来的东西
| toolbox.feature.dataZoom.show | boolean | true | 挂上就造组件,跟你点不点没关系(第十八节) |
| toolbox.feature.dataZoom.filterMode | string | 'filter' | 转给那两根内部 select(feature/DataZoom.js:249) |
| toolbox.feature.dataZoom.xAxisIndex | 同基座 | 不写 ⇒ 'all' | makeAxisFinder(:187-205)只看你写没写。写 0 不减组件,要减得写 'none' 或 false |
| toolbox.feature.dataZoom.yAxisIndex | 同上 | 不写 ⇒ 'all' | 'none' 那根不生成(面板十五 ④ 就只剩 xAxis0) |
| toolbox.feature.dataZoom.icon | object | {zoom:'M0,13.5h26.9 …', back:'M22,1.4L9.9,13.5 …'} | 两枚图标各一条 path |
| toolbox.feature.dataZoom.title | object | 从 locale 取 ['toolbox','dataZoom','title'] | :164,所以中文包换的是词不是键 |
| toolbox.feature.dataZoom.brushStyle | object | {borderWidth:0,color:'rgba(234,237,245,0.5)'} | 框选那层盖子的样式,tokens.color.backgroundTint |
表六:动作、事件,以及那三份账各用哪一句读
| 一次把两端改成百分比 | 动作 dataZoom | dataZoomIndex 或 dataZoomId,配 start/end | dataZoomAction.js:44-58 把它交给 setRawRange,option 与 settledOption 一起翻(第六节) |
| 只动一端 | setOption merge | 写在 dataZoom[i] 里 | 另一端连模式带值都留着 ⇒ 半新一半旧(第五节) |
| 框选落窗 | 动作 dataZoom | batch:[{dataZoomId, startValue, endValue}] | toolbox 自己发的就是这个形状(feature/DataZoom.js:142-152) |
| 进 / 出框选态 | 动作 takeGlobalCursor | key:'dataZoomSelect'、dataZoomSelectActive | 不建组件、不改窗口,只把 brush 开关拨一下(第十八节) |
| 一切回到声明值 | 动作 restore | — | 窗口回到 option 声明值、zoomMM 清空(第十八节 ④) |
| 只退一格 | 没有对外动作 | — | handlers.back 走的是它自己那本 history(:183-185),restore 不认 |
| 切帧 | 动作 timelineChange | currentIndex | 走动作会夹(timelineAction.js:52-53),写进 option 不夹(第二十一节 ⑥⑦) |
| 开关播放 | 动作 timelinePlayChange | playState(大写 S) | :72-81;播到末帧且 loop:false 时库自己发一次 playState:false |
| 听缩放 | 事件 datazoom | e.batch | 事件名在派发前过 toLowerCase(echarts.js:2288-2292)。chart.on 的名字也先转小写(:245-248),所以挂 'dataZoom' 收得到,但回调里 e.type 是全小写 |
| 听切帧 | 事件 timelineChanged | e.currentIndex | 四个名字一起挂上去实测命中 {"timelineChange":0,"timelineChanged":1,"timelinechanged":1,"TimelineChanged":1};而 dispatchAction 的 type 查表不转小写,timelinechange 命中 0(第二十一节 ⑧) |
| 读「我写的」 | chart.getModel().getComponent('dataZoom', i).settledOption | — | 五键的名单就是 retrieveRawOption(DataZoomModel.js:419-425):start/end/startValue/endValue/throttle |
| 读「这一端上次用什么键写的」 | 同一个模型上的 getRangePropMode() | — | 两格数组,setRawRange 才更新它(第五节) |
| 读「生效的」 | findRepresentativeAxisProxy().calculateDataWindow(settledOption) | — | 返回 {value, percent, percentInverted, valuePrecision};option 上那四键是它第二轮刷出来的副本(第六节) |
表七:写下去「静图落盘」到底有没有反应
这张表是探针 ch20dead 的 33 档(落盘同 18、落盘异 15):每档渲染两次(基线 / 加上这个键),把 zrN-cls-K、zrN-cK 两类计数器归一化之后逐字节比。「无」不等于死键,它只意味着这条键的效果在 animation:false 的静图上看不见——SSR 串里没有鼠标状态。
| dataZoom.show / orient / filterMode | 有 | 画面与行的处置,第二、十二节 |
| 版式六键与 borderColor / borderRadius / backgroundColor | 有 | 轨道长方形,第十四节 |
| dataZoom.fillerColor / handleIcon / handleSize / handleStyle / moveHandleSize / moveHandleIcon / moveHandleStyle | 有 | 滑块身上那四条矩形,第十五节 |
| dataZoom.dataBackground / selectedDataBackground / showDataShadow | 有 | 缩略图三组面三组线,第十六节 |
| dataZoom.brushSelect | 有 | 关掉时拖动条整条不画,第十五节 ⑥ |
| dataZoom.defaultLocationEdgeGap | 有 | 组顶闭式定律,第十四节 |
| dataZoom.brushStyle / labelPrecision / labelFormatter / handleLabel / emphasis | 无 | 全在 hover 与拖动里 |
| dataZoom.showDetail / realtime / zoomLock / throttle | 无 | 交互闸门与节流 |
| dataZoom.textStyle | 无 | 读得到、也真被读,但对象默认 invisible(表二那一行) |
| dataZoom.rangeMode | 无 | 它改的是夹窗口时用哪一对键,B0 上没东西可夹(第二十节) |
| dataZoom.minSpan | 无 | 25% 的下限顶不住本来就有 50% 的窗口(【最小可跑】⑦ 用 4% 才撞出来) |
| dataZoom.z | 无 | 这张图上没有第二个同层组件来争绘制顺序 |
| 这一型的全部键(表三那八行) | 无 | inside 本来一个像素都不落(第二节) |
【常见坑】
坑 1:把 dataZoom 写进 series 里,图一个字不改,库一声不吭
最小可跑那一页的 ✗1 就是它:把 dataZoom:[{type:'slider',…}] 塞进 series[0],getOption().dataZoom 读回来是一根都没有,组件树 0 根,画面上 12 行、x 轴区间 [0, 11]——和「你以为有滑块」的图长得一模一样,不发警告、不抛错。原因是 series 那份键名单(Series.js 的 defaultOption 链)里根本没有 dataZoom 这个键,它不是被忽略,是压根没人读。修法:dataZoom 永远是顶层组件。这一课类型门禁抓得住(本仓库的 node tools/verify-types.mjs 会因为多余键报错),肉眼抓不住,所以把它写进 CI 比写进代码评审靠谱。
坑 2:把 getOption() 当成「我写的那份」
三笔账长得很像:option 上那四键会被 dataZoomProcessor.js:120-135 刷成生效窗,getOption() 读的就是它;settledOption 留的是你真正写进去的那份;getRangePropMode() 留的是这一端上次是用哪种键写的。start:-5, end:150 那档最典型:settledOption 原样 [-5,150,null,null],而 option 与回读已经是 [0,100]/[0,11](第六节、【最小可跑】)。修法:调试时把三行一起打(min.html 的 read() 就是这三行),别只打 getOption()。生产代码里不要把回读的 start/end 当成用户意图往回写——第二十节 ⑧ 那条「用 minValueSpan 时 getOption() 的 start/end 已经是被顶开之后的值」说的就是这类事故。
坑 3:拖一下滑块,y 轴的刻度也换了
不是渲染毛病,是 filter 的定义:y 轴的区间是从「窗口里还剩哪几行」算出来的。第十三节在 1096 天上扫了 8 档,逐档实测「轴上界 ≥ 窗口内 max」一次例外都没有——[0, 44] 那档窗口内 max 是 66005.33,轴区间 [0, 70000];[274, 493] 那档 max 126729.31,轴区间 [0, 150000]。窗口越窄越像抽牌:minSpan:0.5 只剩 6 行时轴立刻塌到 [0, 70000]。修法三条,按侵入性从小到大:yAxis.min/max 写死(第八节,注意 fixMM 一真窗口就换了分母)、filterMode:'none'(y 轴恒为 [0, 250000],代价是同一扇窗落盘从 26028 涨到 36242 字节,因为 1096 个点全得写进 SVG)、或者干脆只挂 xAxisIndex。
坑 4:同一根轴上两根 dataZoom,后写的那根白写
第十七节的定律是「先来者称王」:dataZoomProcessor.js:64-71 先问「这根轴已经有主人了吗」,答是,则后来者的 proxy 全不生效。第十八节 ⑧ 给了最疼的一次实测(探针 tools/__probe/ch20ex23.mjs 的 E2-2):先只挂 toolbox——它建图时就造好两根内部 select 并当了主人——之后 setOption 补一根 {type:'slider', start:20, end:80}:你的 settledOption 老老实实是 [20,80,null,null],生效窗却是 [0,100]/[0,11],滑块满窗摆着、一格都没缩,而 getOption() 里它排第 2 槽、前两槽是 null 洞。治法有四条,全都实测过:① 把那根 slider 写进第一个 setOption(面板十五 ④ 就赢在这儿;E2-3 读到清单变成 [0] slider host=true、[1] select host=false、[2] select host=true,生效窗 [20,80]);② 补写时把 toolbox 连轴一起请走:replaceMerge 里同时写 toolbox 和 dataZoom,而且这一份 option 里不再出现 toolbox(探针 ch20ex23b.mjs C-A:只剩一根 slider、host=true、getOption().toolbox 读回 []);③ 让它别认领你那根轴,toolbox.feature.dataZoom.xAxisIndex:'none'(C-E);④ notMerge 整张重画(C-D 有效,代价是别的状态一起丢)。两条看着像治法其实不是:只写 replaceMerge:['dataZoom'] 无效(E2-4:host 仍 false、null 洞照在、x 轴还是 [0,11]);给 feature.dataZoom 写 show:false 也无效(C-C)。原因是内部件不是「建图时造好就删不掉」,而是每次 setOption 都由 concatInternalOptions 重新拼进新 option(Global.js:257 → internalComponentCreator.js:51-65),replaceMerge 认 id 不认来源,而它保持组件原有次序、中间被删的内部件只留洞(Global.js 头部 Caution 第 (3) 条)——补写那根因此永远排在内部两根之后,而顺序正是主人的判据。还有个坑中坑:把 feature.dataZoom 写成 null 会让 toolbox 渲染时抛 TypeError: Cannot read properties of null (reading 'iconStatus')(C-G1/C-G2,带不带别的 feature 都抛)——ToolboxView.js:90 把你的 null 原样包成 new Model(featureOpt, …),:233 却要去它身上读 'iconStatus'。
坑 5:getOption().dataZoom 里混进 null,.map(d => d.start) 崩在手里
Global.js:382-391 倒着扫组件数组,把带内部 id 的槽置 null,只在「一路都在末尾」时才缩长度。只挂过 toolbox 的图上那两根都在尾部 ⇒ 读到 [];后来又补了一根 slider ⇒ 读到长度 3、逐槽 ["0:null(洞)","1:null(洞)","2:slider"](第十八节)。修法:凡挂过 feature.dataZoom 的图,回读组件数组先 filter(Boolean)。同理,getOption().series[0].data 的长度是 12 不代表画面上是 12 行,那是数据层的声明(第一节)。
坑 6:只 merge 一端,结果「半新一半旧」
DataZoomModel.js:255-273 最后一行注释原文 // else remain its original setting.——没人碰的那一端,模式与值都原样留着。面板三 ⑥:先写 {startValue:2, endValue:8},再 merge 一个 {start:50},读回来 rangePropMode 是 ["percent","value"],生效窗 值=[6, 8]——左端是新百分比(50% × 11 = 5.5 → 6),右端还是老的值模式 8。修法:要么两端一起写,要么走 dispatchAction(setRawRange 同时翻两端)。练习 1 让你自己撞一次。
坑 7:minSpan 的分母当成数据条数
跨度是 max − min,12 根类目是 11、1096 天是 1095。第二十二节 ⑤ 把这两个分母撞在一起:想设「半年」,按条数写 100 / 1096 * 182 = 16.605839416058394,而库把值折回百分比用的是跨度 ⇒ 同一个窗口的两个百分比 16.605839 与 16.621005 差在这儿,派生出的 minValueSpan 成了 181.8339416058394——比 182 少 0.166 天。修法:想按天/按根数设下限,就写 minValueSpan(写 182 就是 182),别拿 minSpan 去除条数。
坑 8:start 与 endValue 混写时,span 族按值那一侧生效
这不是我的推测,是源码自己承认的歧义。AxisProxy.js:178-181 挂着 PENDING: For historical reason, the option design is partially incompatible: … (Currently using minValueSpan/maxValueSpan.)。修法:一整端全用百分比、另一整端全用值,或者干脆只用一种;要复现就照第二十节那两档写。
坑 9:'2024-01-01' 这种日期串差 8 小时,而 useUTC 救不了你
util/number.js:441-446:没有时区后缀就按本地解释;:454-459 带 Z 或偏移才走 Date.UTC(注释解释不能用 new Date,因为夏令时会让偏移自己跳);:464 数字原样当毫秒。第十九节 ④⑤ 同一份数据只差写法:'2023-06-01' 那档生效窗 1685548800000、画面上 366 行,毫秒那档 1685577600000、367 行,中间正好差 28800000 ms = 8 小时(本机 UTC+8)。而 useUTC:true 只改刻度切在哪一分钟(scale/Time.js:92 的 _useUTC 只被 getTicks 用),窗口四键一个字节都不动(⑥ 两档对照)。修法:跨时区的看板里 startValue 统一写带 Z 的串或统一写毫秒,别混。
坑 10:轴上只有一根数据,缩略图糊出一串 NaN
第二十节 ⑨:只喂一行数据时 time 轴 6 个 NaN、category 轴 0 个。式子在 SliderZoomView.js:285-286——类目那一支写了 Math.max(1, data.count() – 1),时间那一支 size[0] / (x最大 − x最小) 没有兜底,于是 300 ÷ 0 = Infinity、再乘 0 得 NaN,落盘成 <polygon points="300 0 0 0 NaN 15">。注意坏的范围:折线本体、x 轴刻度、滑块手柄、轴区间全都正常,只有缩略图坏。修法两条:showDataShadow:false,或给 x 轴写 min/max 让它有跨度。
坑 11:timeline 不写 axisType,帧名糊成一串 NaN
TimelineModel.js:162 的默认值是 'time',不是 'category'。探针现场三档(tools/__probe/ch20ctl.mjs):什么都不写 ⇒ axisType="time"、23 个 NaN;写 EC2 遗留的 type:'number' ⇒ 被 preprocessor 换成 axisType:"value" 并 delete type、14 个 NaN;只有老实写 axisType:'category' 才 0 个。第二十一节那两本账拆得开:14 = 三枚刻度点各 3 个(matrix(6,0,0,6,NaN,NaN) 两个 + ecmeta_data_index="NaN" 一个)9 个 + 检查点 2 个 + 三枚标签 3 个;23 = 同一批 11 个属性 + 三枚标签各印 NaN:NaN:NaN NaN(一枚 4 个)。修法:帧名是字符串就写 axisType:'category',别指望 data 里的名字自解释。
坑 12:controlPosition 写下去会被搬走,读回来不是你的值
preprocessor.js:70-80 干三件事:把 opt.controlPosition 搬进 controlStyle.position(前提是 controlStyle 里没写过 position);如果搬过去的值是 'none' 且 controlStyle.show 没写,就改成 show:false 并删掉 controlStyle.position;最后 delete opt.controlPosition。所以探针 B 档写 controlPosition:'none' 之后,getOption().timeline.controlPosition 读回来是默认的 "left"(那是 TimelineModel.js:171 的 defaultOption,不是你写的),真正的开关落在 controlStyle.show:false 上,fill="#6578ba" 从 4 处掉到 1 处。更坑的是两处都写:{controlPosition:'none', controlStyle:{show:true}} 因为 has(controlStyle,'show') 为真,第二支不进,于是 controlStyle.position 停在 'none' 这个非法值上而按钮照画(探针 E 档)。修法:只用 controlStyle.position 与 controlStyle.show,把 controlPosition 当遗留键。
坑 13:inside 与 slider 都不写 Index 时,它找主人的规矩是三档,不是「找同坐标系」
_fillAutoTargetAxisByOrient(DataZoomModel.js:175-236)第一档找与滑块平行的轴,拿到第一根之后把同一 grid 里其它平行轴一起挂上(:206-214);而 gridIndex 没写时 getReferringComponents('grid', SINGLE_REFERRING) 永远返回第一块 grid(util/model.js:601),于是「两块 grid 上的两根 x 轴」在库眼里本来就同 grid ⇒ 一起被缩。默认方向是 horizontal(:146),它永远先扑 x 轴,而且不看 type——value 轴照样被抓去缩放(第二节实测 值=[15, 45])。修法:两根以上轴就自己写 Index;想只让一根动,另一根的 gridIndex 写实。顺带记一笔:xAxisIndex: [] 不是笔误而是合法写法(:160-162 明写空数组也算 specified),它造的是一根「谁都不管」的死滑块(第十七节 ④)。
坑 14:拿字节数当定律读,或者拿落盘的像素反推公式
本章两次撞上同一件事。第一,renderToSVGString() 每次重新分配 zrN-cls-K 与 zrN-cK,同一档在两个进程里能差 100 字节(B0 这次 8149、上次 8256)——所以本章所有「逐字节相同」都先把这两类记号归一化,字节数绝不跨档比。第二,落盘的 x 坐标只有一位小数(zrender/lib/svg/graphic.js:116 取 scope.compress && getPathPrecision(el),svg/helper.js:107-111 在缩放为 1 时返回 1 位),于是 0.824 写作 0.8;要核对公式请回到模型层那串原数,别把一笔取整当成一条定律(第十九节末尾)。
【工程文件】
本章在项目里新建三个文件(都在 file= 块里印全了,读者可原样复制):
ch20/
├── min.html ← 最小可跑(147 行):10 个档 + 清空日志,三份账并排打印,不 fetch
├── index.html ← 十五面板实验台骨架(272 行):15 个 h2、18 个容器、123 个按钮、15 个黄框
└── app.js ← 读数器与十五个面板(2516 行):图元几何 + 三份账 + 手算对账
跑法:
node tools/extract.mjs –write # 把本章三个代码块重建成 code/ch20/
node tools/serve.mjs # 默认 127.0.0.1:18000
打开 http://127.0.0.1:18000/code/ch20/index.html。index.html 必须走服务器:app.js 顶上 Promise.all 要 fetch data/sales-daily.json(1096 行日销,2023-01-01 ~ 2025-12-31)与 data/demo-stock.json(261 天 K 线),面板二、六、七、十、十三、十五这六格量的都是真数据上的行为——第十三节那本「窗口下标 [657, 723]、窗口内 max 与 y 轴区间」、第十九节那 8 小时、第十六节那 275 个顶点、第二十二节那张季度表,全靠这两份文件。min.html 一个 fetch 都没有,生成 code/ 之后双击就能跑。
app.js 里六处值得单独指出来的写法:
- 每档另开一个同尺寸的 ssr:true 影子实例,黄框只读它。pointer-events="visible" 与 ecmeta_* 这两类属性只在 ssr 模式下才写(zrender/lib/svg/graphic.js:35-36、zrender/lib/svg/Painter.js:68),不建影子实例就读不到滑块那套属性;而 d 与 transform 两边逐字相同,所以「屏幕上的图」与「黄框里的数」不会各说一套话。档与档之间影子实例 dispose 重建,一个都不留。
- 三份账各读一处:option 上那四键、settledOption 与 getRangePropMode()、findRepresentativeAxisProxy().calculateDataWindow()。【最小可跑】那十档的差别全在这三行里,只读第一行的人会以为 ④⑤⑦ 三档「什么都没发生」。
- bare() 归一化在前,比字符串在后。所有「逐字节相同」的断言只替换 zr\\d+-(cls|ani)-\\d+ 与 zr\\d+-c\\d+ 这两类记号,其余一个字符不动——表七那 33 档的判定全靠它(坑 14)。
- 碰模型层的那一行全包 try。渲染门禁的沙箱把 getModel() 做成假桩件,所以沙箱里印「(桩件里没有真模型)」、浏览器里才有真数。这张清单上的东西只能去浏览器验收:滑块几何、缩略图顶点列、settledOption 与 rangePropMode。
- 抽稀与摆点分开数:shadowShape(svg) 数的是 polyline/polygon 的顶点,shadowXs(svg) 取的是落盘的 x 列(一位小数),第十六节那本「549 个顶点」靠前者、第十九节那本「不等距的 x 列」靠后者。
- 帧的 series 一律写成 [{}, {data: […]}]:_mergeOption 是按下标合并的,第二条系列才是柱子(第二十二节 ②)。这条在 app.js 里是唯一一处「看着多余但不能省」的代码。
【练习】
练习 1:先手算,再按一个按钮——把「值模式 + 只 merge 一端」这两件事撞在一起
打开 code/ch20/min.html,照 ⑤ 那档的写法自己加一档 b9:dataZoom:[{type:'slider', xAxisIndex:0, startValue:4, endValue:9}]。按下去之前先把四行读数手算出来:option 上的 start/end、settledOption 五键、生效窗、画面上还剩几行。然后照第五节那道门再 merge 一次,只写 {start:50}。
标准答案(探针 tools/__probe/ch20ex1.mjs,本机 node 原文):第一趟四键 start/end=[36.36363636363637, 81.81818181818183]、startValue/endValue=[4, 9],settled=[null,null,4,9],rangePropMode=["value","value"],生效窗 值=[4,9],画面上 6 行 = 9 − 4 + 1,x 轴区间 [4,9]、刻度 6 个。那两串小数就是第三节那条定律的正面证据:4 ÷ 11 = 36.36363636363637、9 ÷ 11 = 81.81818181818183,分母是跨度 11 不是根数 12。第二趟只 merge 左端之后:四键 [50, 81.81818181818183, 6, 9]、settled=[50,null,4,9]、模式翻成 ["percent","value"]、生效窗 值=[6,9]、画面上 4 行——左端 round(50% × 11) = round(5.5) = 6(第三节那条半格往上进),右端没人碰、还是值模式的 9。注意 percentInverted 那一栏是 54.545454…:生效窗的左端是「折成整数下标 6 之后」的 6/11,不是你写的 50。
练习 2:亲手造一次「后来者白写」,再把它治好——顺手量一次 replaceMerge 治不了它
在 code/ch20/app.js 的面板十二 ⑧ 那条顺序上走一遍:先 setOption({toolbox:{feature:{dataZoom:{}}}, …}) 建图,再 setOption({dataZoom:[{type:'slider', xAxisIndex:0, start:20, end:80}]})。黄框要读到三件事:getOption().dataZoom 长度 3、前两槽是 null 洞、你那根的 host=false 而生效窗 [0,100]/[0,11]。然后回答:为什么滑块画得出来、窗口却纹丝不动?(答案在 dataZoomProcessor.js:64-71 与 AxisProxy.js:271:后来者读的是主人的 settledOption。)
治好它请试三条,每条的黄框都要读到生效窗真的变成 [20,80]、host=true。第一条最省事:把那根 slider 写进第一个 setOption(探针 E2-3 的清单是 [0] slider host=true、[1] select host=false、[2] select host=true——两根轴各有各的主人)。第二条把它连 toolbox 一起请走,第三条让 toolbox 别认领 x 轴:
// 第二条:这一份里没有 toolbox => 内部件生成器整个不走(Global.js:257)
chart.setOption({ dataZoom: [{ type: 'slider', xAxisIndex: 0, start: 20, end: 80 }] }, { replaceMerge: ['toolbox', 'dataZoom'] });
// 第三条:只让 toolbox 认领 y 轴,x 轴留给你
chart.setOption({ toolbox: { feature: { dataZoom: { xAxisIndex: 'none' } } },
dataZoom: [{ type: 'slider', xAxisIndex: 0, start: 20, end: 80 }] });
再亲手撞一次那条假治法:把第二条改成只写 replaceMerge:['dataZoom'],黄框会原样给你探针 E2-4 那三个读数——host=false、两个 null 洞、x 轴 [0,11]。读法:内部件不是「建图时造好就再也删不掉」,而是每次 setOption 都重新拼进来(Global.js:257 → internalComponentCreator.js:51-65),而 replaceMerge 认 id 不认来源;Global.js 头部 Caution 的第 (3) 条还写着「保持组件原有次序,宁可留洞」,所以补写的那根永远排在内部两根之后——顺序正是主人的判据(坑 4)。
加分题:在上面每条路上各跑一次 (chart.getOption().dataZoom || []).filter(Boolean).map(d => d.start),再故意去掉 filter(Boolean) 跑一次,把抛错那行的原文抄下来——坑 5 就是你的报错信息。
练习 3:把 8 小时那一格自己造出来,再用一行代码压回去
照第十九节 ④⑤ 写两档,数据用 data/sales-daily.json 但 x 一律写 +new Date(r.date + 'T00:00:00Z'),xAxis.type:'time':一档 startValue:'2023-06-01', endValue:'2024-06-01',一档 startValue:new Date('2023-06-01T00:00:00Z').getTime()。黄框要读到 1685548800000 与 1685577600000 相差 28800000,画面上 366 与 367。第三步:把第一档的两个串改成 '2023-06-01T00:00:00Z' / '2024-06-01T00:00:00Z',读数应该逐字落到第二档那串毫秒上、画面上变回 367。这一步才是练习的全部目的:证明 366/367 那 1 行的差不是舍入、是时区,而且它一行代码就能治。
再加一档 useUTC:true 对照,确认窗口四键一个字节都不变(只有刻度换到 UTC 时刻)——这条最容易记反:useUTC 管的是刻度怎么切,不是窗口怎么算。最后一件事,把这套读数的思路写成 assertWindow(chart, expect),签名照 read() 抄:输入「你写的 dataZoom」与「期望的生效窗」,内部把 calculateDataWindow 那一行与手算的 round(p% × 跨度) 比,不等就抛错。缩放是用户操作驱动的,生产包里没人替你核对窗口。
【小结】
这一章讲了一个窗口、三份账、两套状态机。窗口是 dataZoom 唯一真正改写的东西,它挂在轴上而不是系列上:proxy 按轴建、一根轴只有一个,存在 helper.js:176-196 那张按 axisModel.uid 索引的表里(dataZoomProcessor.js:64-71),所以「滑块拖过去」动的是那根轴的 AxisProxy 里那扇窗,series.data 那个数组从头到尾没人碰——可 getData().count() 确实掉了,因为处在 process 阶段的 dataZoomProcessor 按 filterMode 把窗口外的行处置了一遍(第一、十二节)。三份账要分开读:option 上那四键是库的成品(dataZoomProcessor.js:120-135 刷进去的生效窗),settledOption 是你真正写进去的那份(retrieveRawOption 那五键),getRangePropMode() 是每一端上次用什么键写的、而且没人碰就不擦。本章所有「明明写了却没生效」的坑,全发生在这三份账被当成一份读的地方。
算术只有三条,请背下来。round(百分比 × 跨度):分母是 max − min,12 根类目是 11、1096 天是 1095,取整只有 Math.round 一条,「上界向上、下界向下」那句老话在类目轴与时间轴上都不成立(第三、七、二十二节)。asc() 折窗:两端写反不报错,AxisProxy.js:169-170 把两个数组都排序(第四节)。主人只有一个:同一根轴上先到者称王,后来者的 settledOption 照留、生效窗照读主人的(第十七节,toolbox 那两根内部 select 让这条定律变得很疼,第十八节)。几何另有两条闭式定律(第十四节)和缩略图的四条(第十六节),它们读的键是 orient、defaultLocationEdgeGap、right/top/width/height 那四个 'ph'、brushSelect(它决定拖动条那 7 像素算不算进组顶,SliderZoomView.js:136-137)——filterMode 之类管数据的键一个都影响不到它们。
另一半收获是读盘的方法:inside 与 show:false 撤掉的是画面不是效力,所以「看不见」和「没有效」要分开验;「逐字节相同」必须先归一化 zrN-cls-K;落盘坐标只有一位小数,别拿它反推公式;get 读得到不能证明键存在,得看官方名单加「改了它落盘变不变」(表七那 33 档就是干这件事的);而 NaN 也不是唯一的坏消息——零跨度时只有缩略图坏,timeline 帧名糊掉时 23 个 NaN 里有 12 个在标签文本里。
timeline 那一半和 dataZoom 公用一套「组件—轴—窗口」的骨架,但它管的是帧不是行:帧是 {baseOption, options} 的按下标合并,currentIndex 写进 option 不夹、走动作才夹,帧名显示成什么归 axisType 管(默认 'time',这是本章最贵的一个默认值)。记住「帧是合并、窗口是回填、主人只有一个」,缩放与播放这两套状态机就不会互相打架(第二十一、二十二节)。下一章离开交互,回到画面:color 的调色板从哪来、visualMap 怎么把值映射成视觉,第 21 章见。
本篇完。第 20 章到此结束。
网硕互联帮助中心

评论前必须登录!
注册