uPlot 尺度范围问题收尾评估:scan/auto/range 分离模型与 7 个历史 Issue 的关闭矩阵
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 的极值缓存,因为内置数据极值正是该回调的最终每序列值。
行为序列
- 初始设置扫描整个 X 域;
- 显式 X 缩放不改变 Y;
- 序列开关触发 Y 重算;
- 自定义 scan 忽略当前 X 窗口;
- 新可见序列贡献其全域极值。
演示(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 次重复与混合点击,检查状态输出与图表坐标变化。
边界语义
- 历史对比:自动后端范围在改动前的
7074d27bundle 中同样可用;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/ 中交互验证。