uPlot 尺度范围问题收尾评估:scan/auto/range 分离模型与 7 个历史 Issue 的关闭矩阵

原创2026-09-23 13:47:25906 阅读
文章标签:前端图表库数据可视化

uPlot 尺度范围问题收尾评估:scan/auto/range 分离模型与 7 个历史 Issue 的关闭矩阵

导读

本文基于 uPlot 仓库内的技术评估文档 scale-range-closure-assessment.md,系统梳理围绕 Y 轴尺度范围(scale range)的 7 个历史 GitHub Issue(823、808、915、1133、648、650、655)的当前状态与底层实现。核心是 uPlot 新引入的 scale.scan(数据扫描)、scale.auto(自动重算调度)与 scale.range(范围推导)三者分离的尺度模型,以及静态范围数组在缩放、双击复位、数据替换等场景下的调度策略。读完本文,你将掌握完整域扫描、静态范围复位、动态后端范围、只读候选范围查询等实战配置,并理解这些行为在 src/uPlot.js 中的实现依据与对应测试覆盖。

背景:Policy B 尺度范围模型

在进入各 Issue 之前,先明确这一轮改动的总纲。scale.auto、scale.scan 与 scale.range 现在是三个职责完全分离的控制项:

控制项 职责
scale.auto 控制数据或 X 轴变化之后的隐式重算调度
scale.scan 控制重算时对数据做最小/最大值扫描的方式
scale.range 从扫描结果推导出实际显示边界

在源码中,扫描器的选择发生在 src/uPlot.js:scale.scan 为 true 时使用内置缓存扫描器 scanCached,为 false 时使用 scanNone(返回 [null, null]),省略时默认走 scanAuto——即由 scale.auto 的返回值决定是否扫描。而完全具体的 range: [min, max] 数组会把缺省 scan 默认为 false,但保留 auto: true 的默认值。这一条规则是 Issue 915 修复的关键,后文会展开。

围绕该模型,仓库提供了公共 API:uPlot.scan(self, scaleKey, i0?, i1?, cache?)(静态方法)、u.setRange(scaleKey, min, max) 与 cursor.drag.setRange,类型声明见 dist/uPlot.d.ts。

关闭矩阵总览

以下矩阵记录了 7 个 Issue 的当前状态与技术评估结论(评估基准为改动前 bundle 提交 7074d27 与当前源码/生成产物):

Issue 当前状态 技术评估
823 已关闭(completed) 全域扫描会纳入“初始隐藏、后变为可见”的序列
808 已关闭(completed) 剪裁在无扫描时即可工作;scale.scan 独立启用静态范围的极值扫描
915 已关闭(completed) 双击在 XY 缩放后恢复静态 Y;两种数据模式均有回归覆盖
1133 已关闭(completed) 自动后端范围可行;scan: false 去掉不必要的 Y 极值扫描
648 已关闭(completed) 原始拖拽配置配合文档化的自动范围策略正常工作
650 打开(Open) 独立候选范围可用,但 helper/range 组合并不等价于完整复位
655 已关闭(completed) 初始具体 Y 边界在 auto: false 下经 X 缩放与数据替换后依然保留

各 Issue 的缩略演示与回归覆盖记录在 scale-range-issue-review-summary.md 中;演示文件位于 demos/issues/。

Issue 823:全域 Y 范围(full-domain scan)

结论

Issue 823 已关闭。核心诉求是:X 轴放大后 Y 范围仍然基于整个 X 域计算,并且当一条初始隐藏的序列通过图例变为可见时,Y 范围要纳入该序列的全域极值。

完整配置如下:

y: {
	auto: (u, viaAutoScaleX) => viaAutoScaleX,

	scan: (u, scaleKey) => {
		return uPlot.scan(u, scaleKey, null, null, true);
	},

	range: (u, min, max) => {
		return uPlot.rangeNum(min, max, 0.1, true);
	},
}

关键点是 scan 回调传入 null 索引而不是当前 X 窗口。在 src/uPlot.js 中,i0/i1 为 null 时分别取 0 与 data.length - 1,即扫描每个参与数据数组的全长。显式的 cache: true 表示将扫描结果直接写入参与序列/facet 的极值缓存,因为内置数据极值正是该回调的最终每序列值。

行为序列

  1. 初始设置扫描整个 X 域;
  2. 显式 X 缩放不改变 Y;
  3. 序列开关触发 Y 重算;
  4. 自定义 scan 忽略当前 X 窗口;
  5. 新可见序列贡献其全域极值。

演示(demos/issues/issue-823-full-domain-scan.html)用恒等 ranger 获得精确边界,初始为 [10, 50],切换序列 B 显示后达到 [10, 500]。

实现细节

uPlot.scan() 返回一个聚合的 <a href="https://link.gitcode.com/i/03b63393e504867aef73c3d15ed26454" target="_blank">min, max] 元组。cache: true 时复用非空参与 Y 序列与 mode-2 facet 缓存,并直接把缓存未命中项写回(见 [src/uPlot.js);mode-1 对齐 X 始终读取自身数据。类型声明中该方法签名见 dist/uPlot.d.ts。

Issue 808:静态范围与线条剪裁

结论

Issue 808 已关闭。原始“边界描边被剪裁”的场景在不扫描的情况下即可验证通过。独立的极值扫描是另一项能力,保留在 API 测试中:

y: {
	range: [-12, 12],
	scan: true,
}

此时尺度固定在 [-12, 12],初始范围计算仍会填充序列极值。canvas 剪裁矩形在不扫描的情况下就按半个描边宽度向外扩展。历史对比表明:在 scale.scan 变更之前,函数包装器对于剪裁本来就非必需。回归测试验证了无 scan: true 时的剪裁扩展,但不校验渲染像素。

语义边界

  • scan: true 控制尺度计算期间的扫描,不会独立调度静态尺度;
  • 完全具体的范围数组保留默认 auto: true,因此默认 setData() 会刷新极值缓存;
  • 显式 auto: false 则阻止这种隐式刷新。

演示见 demos/issues/issue-808-static-range-scan.html。

Issue 915:静态 Y 范围的复位

结论

Issue 915 已关闭,修复同时存在于源码与生成 JavaScript 中(本仓库状态不证明 npm 发布可用性)。下面的配置现在能在手动缩放与双击后恢复静态 Y:

y: {
	range: [1, 10],
}

完全具体的范围数组默认 scan: false 并保留 auto: true。原始报告与 HTML 演示使用 mode 2 与 XY 拖拽;演示现在只保留拖拽与双击,没有复位 workaround 按钮。test/issue-demos.mjs 要求两个尺度都成功恢复。

实现的调度策略

静态范围数组不再强制 auto: false,默认行为是:

  • 初始设置直接应用静态范围,不做 Y 扫描;
  • 手动 Y 缩放仍然可用;
  • 同一批次中具体的 X/Y 边界保持显式;
  • 后续 X-only 缩放、默认 setData()、默认 redraw() 与双击都会恢复静态 Y。

显式的 auto: true、auto: false 与回调依然生效;显式 scan: true、scan: false 与自定义回调同样生效。

若希望在显式 X 变更、默认 redraw 与公共 null/null X 复位期间保留手动 Y,可使用以下可选回调:

y: {
	range: [1, 10],
	auto: (u, viaAutoScaleX) => viaAutoScaleX,
}

双击与自动 setData() 无需 Y 扫描即可恢复静态 Y。该回调不是默认调度策略。注意:显式 auto: false 时,隐式重算不会恢复静态 Y,但显式的 null Y 边界仍会请求重算。

回归覆盖

test/scale-static-range.mjs 在两种数据模式下通过 34 个测试:

  • 默认静态 Y 在 X 缩放、setData()、redraw()、双击后复位;
  • 具体 X/Y 批次同时保留两个显式范围;
  • 显式 auto: true、auto: false 与复位敏感回调;
  • 默认静态范围与回调配置均无 Y 扫描;
  • 显式 X/Y 扫描覆盖,含自定义回调;
  • 部分 Y 范围数组的数据扫描。

现有 asinh 测试保留静态默认阈值 1。Issue 924 关于“公共 null/null X 复位”与“双击”的区别,对复位敏感回调仍然成立。

Issue 1133:动态后端范围

结论

Issue 1133 已关闭。Y 尺度保留默认 auto: true 调度策略,只关闭 Y 极值扫描:

y: {
	// 使用此回调阻止显式 X 范围之后的 Y 自动范围化:
	// auto: (u, viaAutoScaleX) => viaAutoScaleX,
	scan: false,
	range: () => backendRange,
}

更新后端范围后再更新数据:

backendRange = [0, 20];
u.setData(replacementData);

setData() 会复位 X 并调度 Y;Y 尺度调用 range() 时不做 Y 极值扫描,直接应用新的后端边界。

三种更新模式

演示 demos/issues/issue-1133-dynamic-backend-range.html 提供三个可重复点击的按钮:

动作 数据 Y 边界 操作
仅数据 两种形状交替 后端边界不变 setData(nextData)
后端范围 + 数据 两种形状交替 [0, 10] 与 [0, 20] 交替 赋值 backendRange,再 setData(nextData)
仅后端范围 不变 [0, 10] 与 [0, 20] 交替 赋值 backendRange,再 redraw()

X 值保持相同,两种数据形状都落在两个 Y 范围内。HTML 测试覆盖 18 次重复与混合点击,检查状态输出与图表坐标变化。

边界语义

  • 历史对比:自动后端范围在改动前的 7074d27 bundle 中同样可用;scale.scan 的收益是避免 Y 极值扫描;
  • setData(data, false) 不复位尺度,也不调用 range();
  • 具体的 setScale() 与 setRange() 边界绕过 range() 直接应用精确值;通过 setScale() 传显式 null 边界则请求一次独立重算;
  • 可选 auto 回调同时抑制 redraw() 期间的 Y 重算,而不仅是显式 X 缩放。

Issue 648:自动复位与显式缩放的对比

结论

Issue 648 已关闭。以下策略正常工作:

y: {
	auto: (u, viaAutoScaleX) => viaAutoScaleX,
}

当前行为是:

  • 初始自动 X 设置重算 Y;
  • setData(data, true) 重算 Y;
  • 显式 X 缩放保留 Y;
  • 显式 Y 缩放应用精确 Y 边界。

历史对比表明,改动前的 7074d27 bundle 也通过该复现——这是一个既有修复,而不是新的 scale.scan 修复。修订后的演示使用原始 drag: {x: true, y: true, dist: 8, uni: 15} 配置与默认带 padding 的 ranger(见 demos/issues/issue-648-auto-reset-vs-zoom.html)。新的 HTML 回归覆盖手动 Y 缩放后的 X-only 拖拽、重复 redraw、双击复位与替换数据。

Issue 650:无变更的候选范围查询

结论

Issue 650 仍处于打开状态。在现有 helper 解决该问题前,其接受范围必须排除不支持的场景。原始请求是:在“仿佛缩放已被复位”的前提下计算边界,包括隐藏序列变为可见之后。

对于使用兼容、纯 ranger 的普通独立尺度,插件可以在不改变图表的情况下计算候选边界:

const [dataMin, dataMax] = uPlot.scan(u, 'y');
const scaleRange = u.scales.y.range(u, dataMin, dataMax, 'y');

简单 API 回归验证的值如下:

Current applied range: [15, 25]
Scanned data range:    [10, 30]
Candidate scale range: [9, 31]
Range after scan:      [15, 25]

只读语义

helper 返回 uPlot.Range.MinMax。省略 cache 或传 false 时,uPlot.scan() 是只读的,不改变:

  • 尺度边界;
  • 序列极值;
  • 路径(paths);
  • 布局(layout);
  • 钩子(hooks)。

单例 X 展开(如 [5, 5] → [0, 10])现在发生在默认 X ranger 内部;直接调用初始化后的 ranger 可复现该默认展开。自定义 ranger 接收原始相等极值并自行控制单例行为。

仍然不支持的场景

helper/ranger 组合仍不能复现全部自动范围计算:

  • 直接调用 uPlot.scan() 绕过自定义 scale.scan 策略;
  • 依赖尺度(derived scale)使用计算出的父尺度边界,而非直接赋予依赖尺度的数据;
  • range 回调看到的是实时图表,而非模拟复位的布局;
  • helper 默认只读,但任意用户 range 回调不一定是纯函数。

此外,u.setRange(scaleKey, min, max) 应用具体编程边界并绕过 scale.range();cursor.drag.setRange 只能细化或取消内置拖拽边界,不能提供等价于复位的候选范围查询。

修订后的演示(demos/issues/issue-650-candidate-range.html)X 初始为缩放状态,图例可切换窗口外的隐藏离群序列。查询保持边界与缓存不变;候选值在 Outlier 隐藏时为 [8, 32],显示时为 [0, 330],并与独立的全域参考图对比。该演示明确区分“切换触发的重算”与“只读查询”。由于原始配置不够详细,不能假设这些限制可被接受——这是功能范围缺口,而非仅仅缺少便捷方法。

Issue 655:初始具体边界

结论

Issue 655 已关闭。以下配置保持稳定:

y: {
	auto: false,
	min: -15,
	max: 15,
}

初始具体边界:

  • 无需 scale.range() 即可应用;
  • 在显式 X 缩放后存活;
  • 在数据替换后存活;
  • 不阻止后续显式 Y 变更。

历史对比:改动前的 7074d27 bundle 也通过该复现,原始“尺度消失”缺陷在此之前已修复。注意:初始 min/max 不是永久约束,也不是存储的复位目标。配置的 range 在请求重算时提供边界,具体显式 Y 边界仍绕过它。演示 demos/issues/issue-655-fixed-auto-false.html 只使用内置 X 拖拽与复位。

必要工作与可选工作

必要工作

  • Issue 915 的静态范围复位修复与回归在源码与生成 JavaScript 中已完成;Issue 650 仍打开,需要范围决策或进一步实现,静态范围修复并不能解决其剩余范围;
  • 额外 Issue 823 平移/数据更新覆盖与真实浏览器描边验证仍属后续工作;
  • 静态范围修复构建于提交 e75c8b4;当前工作树的 bundle 还包含后续源码变更,但那些 bundle 文件未暂存;本仓库状态不证明 npm 发布可用性。

可选工作

以下改进不解决 Issue 650 的剩余范围缺口:

  • 添加具名的尺度重算方法;
  • 在需要该契约时,为编程显式边界添加通用拦截器。

cursor.drag.setRange 已提供内置拖拽缩放的 clamp、snap 与取消行为。完整的候选范围计算 API 对 Issue 650 并非自动可选,其必要性取决于最终接受的范围。

验证结果与测试体系

最新验证数据

  • 完整 Node 套件(npm test):1,011 通过;
  • 聚焦五文件 scale-range 命令:74 通过;
  • 静态范围套件:34 通过(两种模式);
  • 光标拖拽与范围策略套件:50 通过。

聚焦五文件命令包含静态范围、范围策略、issue、HTML 演示与 asinh 测试。TypeScript 检查未重复执行,因为该 checkout 未安装 tsc。当前声明(dist/uPlot.d.ts)包含 Scale.scan、静态 uPlot.scan、u.setRange() 与 cursor.drag.setRange。

测试文件对照

测试文件 覆盖内容
test/scale-scan.mjs scan 矩阵:auto/scan 组合、回调调用次数、缓存扫描、纯扫描、空数据等
test/scale-range-policy.mjs 范围策略:具体/部分/null 边界、交叉部分范围、X 自动缩放、双击复位、空数据、序数 X、facet Y
test/scale-static-range.mjs 静态范围 34 测试(两种模式)
test/scale-x-range.mjs 单例 X 范围、自定义 ranger 输入、部分边界、自定义扫描、空数据
test/scale-scan-cost.mjs 扫描成本、排序端点、全域对齐 X 计算
test/redraw-scan.mjs redraw 缓存复用、回调刷新、延迟失效、挂起请求
test/cursor-drag.mjs 拖拽边界细化、取消、同步与回调范围
test/issue-demos.mjs 直接执行 HTML 演示脚本的鼠标/图例/按钮交互(DOM/canvas mock),检查尺度值、状态输出、缓存行为与 canvas 几何,不校验渲染像素

真实浏览器的像素级验证仍处于待办状态。issue 915 测试要求成功复位,其结果不解决独立的 issue 650 范围缺口。

关键实现位置速查

  • 扫描核心:scanScaleInternal / scanScale / scanCached / scanCachedX / scanNone / scanAuto,见 src/uPlot.js;
  • 扫描器选择与默认值推导:rangeIsArr && rn[0] != null && rn[1] != null ? false : null,见 src/uPlot.js;
  • 公共静态扫描 API 导出:uPlot.scan = scanScale,见 src/uPlot.js;
  • 类型声明:Scale.scan、Scale.Scan 与静态 scan,见 dist/uPlot.d.ts 与 dist/uPlot.d.ts;
  • 演示文件:demos/issues/ 下的 7 个 HTML 文件对应 7 个 Issue。

总结

从这次收尾评估可以提炼出三条可直接用于生产配置的结论:其一,全域扫描通过 scan 回调传 null 索引实现,配合 cache: true 可在序列显隐切换时保持正确的全域极值;其二,静态范围数组默认 scan: false + auto: true,使双击、setData()、redraw() 与 X-only 缩放都能自动恢复静态 Y,而 auto 回调提供了“保留手动 Y”的精细控制;其三,只读候选范围查询依赖纯 uPlot.scan() 与兼容 ranger 的组合,可用于插件计算“复位后”边界,但对自定义扫描策略、依赖尺度与动态布局并不等价于完整复位。以上行为均有 src/uPlot.js 实现与 test/ 回归测试支撑,可直接在演示 demos/issues/ 中交互验证。

登录后查看全文
uPlot