
我没打算做一个图表库
ChartLens 一开始只是 WiFi Lens 里的代码。我需要图表来画 Wi-Fi 频谱视图、RSSI 趋势、漫游时间线,还有几个交互页面。每个图表在界面上长得都不一样,但同样的问题一遍遍出现在代码里。
我得把数据值换算成屏幕位置。我得让坐标轴标签不至于挤到绘图区。我得画曲线、填充区域、处理悬停、支持拖拽缩放、把总览图和细节图连起来,还要把标签或浮层放到业务数据的上面。
第一个版本待在它该待的地方:WiFi Lens 里面。应用还小的时候这没问题。后来,每加一个图表都要付一笔熟悉的成本。我写的已经不是 Wi-Fi 逻辑了,我是在反复重建图表基础设施。
于是我把这块代码抽了出来。
WiFi Lens 里的图表问题
WiFi Lens 是一个原生的 macOS Wi-Fi 与蓝牙分析工具。它不只列出附近的网络,它想给用户看到无线环境长什么样。
一个图表显示 2.4 GHz、5 GHz 或 6 GHz 上的信道占用。另一个图表跟踪某个接入点的 RSSI。漫游测试需要一条时间线,把 AP 切换的位置标出来。频谱视图需要把信道宽度和信号强度画在一起。
这些页面处理的是 Wi-Fi 数据,但图表代码处理的是更底层的问题。
RSSI 是负值,通常在 -90 dBm 到 -30 dBm 之间。频谱图要把信道中心和宽度映射到像素上。漫游时间线需要在很长的一段区间上缩放。信道图可能需要 SSID 标签、悬停浮层,或者叠在曲线之上的选中态。
起初我把这些细节都放在各个页面旁边。这帮我快速发了版。然后同样的修补开始在好几个地方重复出现。在一个图表里修好了标签,对另一个图表没有帮助。一种悬停行为在一个视图里能用,到另一个视图里又要重写。图表代码开始散开。
为什么这个应用光靠 Swift Charts 不够
Swift Charts 在很多 SwiftUI 页面里都很好用。做快速图表、静态图表和常见可视化,我到现在也还是喜欢用它。
WiFi Lens 需要的控制比那些页面通常需要的更多。
我需要跨图表共享的坐标映射。我需要用 Canvas 渲染曲线、填充和标记点。我需要 Catmull-Rom、clamped cubic、step、Gaussian 这些插值模式。我需要按 X 位置找最近点的悬停逻辑。我需要拖拽缩放和区间选择。
我还需要让父视图持有业务状态。
表格里选中一个接入点时,图表应该跟着反映这个选中。用户悬停在某个点上时,另一个面板可能要更新。有些页面让图表自己处理点击,另一些页面让父视图决定一次点击意味着什么。
我可以在每个页面里围绕 Swift Charts 继续堆自定义层。那会让产品视图变得更大、更难推理。所以我让 ChartLens 来承担这些重复的图表工作。
ChartLens 不包装 Swift Charts。它为 WiFi Lens 中那些需要更底层控制的部分,提供了一套 SwiftUI 图表渲染与交互层。
范围
我把范围压得很小。
ChartLens 只盯住 WiFi Lens 已经有的那些问题:
- 把数据空间映射到屏幕空间;
- 管理
plotRect、annotationRect和坐标轴标签区域; - 把图表渲染和业务浮层分开;
- 在允许新增图表类型的同时保持 API 小;
- 在产品页面里支持悬停、点击、缩放和区间选择。
我没有从「覆盖所有图表类型」开始。我从 WiFi Lens 里已经存在的行为开始。这个约束帮了忙:它让这个库绑定在我真正用过的代码上,而不是我想象出来的场景上。
这个库以后可以长。眼下它需要对创建它的那个应用保持有用。
把 Wi-Fi 词汇从图表层里拿掉
抽取过程中最关键的一步很简单:把 Wi-Fi 词汇从图表层里去掉。
WiFi Lens 说的是 AP、SSID、RSSI、信道、频段。ChartLens 不该认识这些词。它只需要一个连续的 Double 定义域。
X 值可能表示时间、频率、信道号,或者别的东西。Y 值可能表示 RSSI、速率、占用率,或者别的产品值。ChartLens 只需要知道怎么把这些值放到屏幕上。
基本模型就变成:
ChartPoint
├─ x: 在连续定义域上的位置,比如时间、频率或信道号
└─ y: 该位置上的值,比如 RSSI、速率或占用率
折线图只需要 x 和 y。K 线图可以用一个更丰富的点,带 open、high、low、close。图表层需要每个点能给出它的 X 位置、它的 Y 区间,以及一个用于交互的代表性 Y 值。
这个改动让代码在 WiFi Lens 之外也能用。我抽取的不再是一个 Wi-Fi 图表,而是一种最早出现在 Wi-Fi 应用里的图表行为。
浮层注入
浮层注入成了最重要的边界之一。
图表负责算几何、画基础视觉。产品 UI 留在外面。浮层提示、标签、阈值线、热力图、选中态,都属于 WiFi Lens 或者用这个图表的任何一个应用。
流程是这样的:
创建一个图表
├─ 传入数据系列
├─ 传入坐标轴与样式配置
└─ 在 overlay 里拿到几何信息
└─ 把产品数据点换算成屏幕位置
└─ 画浮层提示、标签、选中态或其他产品 UI
几何信息承担了关键工作。ChartLens 暴露数据空间与屏幕空间之间的映射。产品层决定在上面画什么。
在 WiFi Lens 里,我可以在频谱图上放 SSID 标签,在趋势图上显示悬停浮层,或者在漫游时间线上标出 AP 切换事件,而不用改图表渲染器。
ChartLens 画图表。WiFi Lens 解释图表是什么意思。
几何区域
真实的图表需要不止一个矩形。
一个图表视图有完整的 frame。里面,绘图区承载曲线。坐标轴标签需要自己的空间。产品标注也需要一块安全区域。
如果什么都用 plotRect,标签会盖住曲线,浮层提示会被裁掉,坐标轴标签会和图表内容抢地方。
ChartLens 把这些区域分开:
完整图表 frame:frameRect
├─ 绘图区:plotRect
├─ 坐标轴标签区域:axisLabelRects
└─ 产品标注区域:annotationRect
这是一个很小的抽象,但它消掉了一个常见的布局问题。每个图表元素可以去问自己该待在哪,而不用猜。
annotationRect 在 WiFi Lens 里有意义,因为有些图表标签很密。常驻标签和引出说明需要一块不直接依赖绘图区的区域。
做交互,但不持有产品状态
交互需要同样的分离。
有些图表组件自己在内部处理点击、悬停和选择。这在演示里能用。在产品里,选中通常会影响别的视图。
WiFi Lens 把图表接到外部状态上。表格里选中的 AP 可能会高亮一条曲线。悬停点可能会更新一个详情面板。缩放区间可能会改变屏幕另一部分显示的内容。
ChartLens 提供命中测试和回调。它不保存业务状态。
ChartInteraction
├─ hover:返回光标下的数据点
├─ tap:把最近的数据点传给外部
├─ zoom:拖拽缩放之后传出新的 X 轴区间
└─ zoomGestureEnabled:启用或禁用缩放手势
图表可以处理点击并把结果送出去。父视图决定怎么使用选中的点、悬停状态和缩放区间。
这条边界让 ChartLens 更靠近基础设施,而不是产品逻辑。
先抽出来,再靠使用改进
ChartLens 还很早期。
它支持折线图、面积图、散点图、高斯曲线、K 线图,以及 CrosshairOverlay、RangeSelector 和 DetailOverviewChart。以后它可能会加上柱状图、更多样式控制、更好的无障碍支持,以及更广的平台覆盖。
最初的目标更小:把 WiFi Lens 里已经能跑的图表行为,搬进一个独立的、可测试的、可复用的模块。
一个抽象应该继续服务于创建它的产品。只要 WiFi Lens 还在用 ChartLens,这个库就会持续面对真实的约束。如果它不再对 WiFi Lens 有用,说明这个抽象已经跑偏了。
写在最后
ChartLens 来自一个很具体的维护问题。
WiFi Lens 需要频谱视图、RSSI 趋势、漫游时间线和交互浮层。我把 Wi-Fi 特有的名字去掉,留下了可复用的部分:渲染、坐标映射、插值、交互和浮层注入。
结果既清理了 WiFi Lens,也给了我一个能在别处复用的图表层。
如果你在 SwiftUI 里做复杂图表,ChartLens 也许能帮你想清楚:图表代码该在哪里结束,产品代码该从哪里开始。
链接
ChartLens: https://github.com/ShiinaLabs/chart-lens
WiFi Lens: https://github.com/ShiinaLabs/wifi-lens