上篇写了我为什么把图表层从 WiFi Lens 里抽出来。这篇看下一个问题:ChartLens 在一个真实应用里怎么工作,以及是哪些工程决策塑造了它的功能。
文中的例子来自 ChartLens DemoApp,一个独立的 macOS 应用,它绕开 WiFi Lens 的业务代码来跑图表功能。DemoApp 把例子控制得足够小,好把设计讲清楚。
画第一张图表
从折线图开始。
下面的代码是伪代码。它展示结构,不是生产代码。
这个小例子画出一张完整的折线图。它给读者 ChartLens 的主要形状:数据、样式和渲染各自分开,于是应用层可以专注在它自己拥有的数据上。

DemoApp 截图,BasicChartsDemo 折线图
let points = ...
let series = ChartSeries(
points: points,
style: .line(color: .blue)
)
Chart(
series: [series],
axis: ...
)
三个概念撑起这张图表。ChartPoint 表示数据。ChartSeries 把共用同一种渲染样式的点归成一组。ChartSeriesStyle 控制颜色、线宽、插值模式这类视觉选择。
这里的参数列表不重要。重要的是这个拆分:数据、样式、渲染入口各自只担一件事。
数据从 ChartPoint 流进 ChartSeries,再流进 Chart。Chart 负责坐标映射、坐标轴标签、网格线和曲线渲染。应用准备好数据,把图表的事留给图表层。
这个简单的例子已经展示了核心设计。图表负责渲染,应用拥有数据,两边尽量保持分离。交互、浮层和特殊图表类型都建在这个拆分之上。
加上交互
静态图表把数据摆出来。用户仍然需要从图表里得到答案:这里的准确值是多少,附近的点长什么样,能不能放大到更小的区间。
ChartLens 把交互集中在三个回调上:
ChartInteraction(
onHover: { point, screenPt, location in
// 鼠标移动,返回最近的数据点。
},
onTap: { point in
// 点击,返回最近的数据点。
},
onZoom: { xMin, xMax in
// 拖拽缩放,返回新的 X 区间。
},
zoomGestureEnabled: true
)
所有回调都跑在 @MainActor 上。业务状态由应用持有。ChartLens 不在图表内部保存悬停状态或选中状态。它检测输入,报告结果。
命中测试用的是一条简单规则:扫描所有系列里的所有点,选 X 轴方向上离光标最近的那个。不用 Y 轴阈值,也不做半径判断。这对时间序列数据效果很好。光标左右移动时,准星锁定到最近的时间点,不会跳。
准星本身是一个浮层:
Chart(series: series, axis: axis, interaction: interaction) { geo, _ in
CrosshairOverlay(
geometry: geo,
hoverPoint: hoverPoint,
config: CrosshairConfig(valueLabelFormatter: { String(format: "%.1f dBm", $0) })
)
}
CrosshairOverlay 接收 ChartGeometry 和当前的悬停点。它在 Canvas 上画竖线、点标记,以及带渐变背景的数值标签。应用把 onHover 拿到的点传进来,浮层负责绘制。

光标跟随的效果
让多张图表同步:细节图 + 总览图
有些页面既需要完整范围,也需要放大看局部。一次漫游测试可能跑好几分钟,而用户只想看其中几秒的 AP 切换事件。
DetailOverviewChart 把这个模式打包成一个组件。上方的图显示当前的细节窗口。下方的总览带显示完整数据范围和一个可拖拽的区间选择器。
DetailOverviewChart(
series: series,
domain: 0...200,
defaultWindowSpan: 40,
domainLabel: { String(format: "%.0fs", $0) }
)
domain 定义完整数据范围。defaultWindowSpan 定义初始可见窗口。用户可以拖动两端的把手改变窗口大小,或者拖动窗口本体平移。

DetailOverviewDemo 的拖拽与缩放 GIF

展示细节图、RangeSelector 与总览图数据流的架构图
RangeSelector 负责拖拽行为。左把手改起点,右把手改终点,拖动窗口本体平移区间。它还支持 followMax:当 domain 增长时,让可见窗口钉在最新的数据上,相当于图表的 tail -f。
WiFi Lens 里的一个细节改掉了实现。followMax 需要用计算属性从 domain 推导出窗口。用 @State 加一条 onChange 链会产生一帧延迟,因为 SwiftUI 在 body 计算之后才跑 onChange(of:)。在实时图表里,这一帧看得出来。
用浮层扩展图表
ChartLens 遵循一条规则:图表算几何、画基础视觉;应用通过浮层注入产品 UI。
浮层构建器的形状是这样:
@ViewBuilder overlay: (ChartGeometry, [any ChartSeriesProtocol]) -> Overlay
图表把 ChartGeometry 传进浮层。这个结构搭起了数据空间和屏幕空间之间的桥:
let screenPoint = geo.dataToPoint(x: 42.0, y: -55.0)
let dataPoint = geo.pointToData(screenPoint: someCGPoint)
应用拿到坐标映射之后,就能在图表之上画产品自己的 UI。
阈值线:在 Canvas 上指定的 Y 值处画一条虚线,不用改图表渲染器。
Canvas { context, _ in
let y = geo.chartRect.maxY - (threshold - geo.yMin) * geo.scaleY
var line = Path()
line.move(to: CGPoint(x: geo.chartRect.minX, y: y))
line.addLine(to: CGPoint(x: geo.chartRect.maxX, y: y))
context.stroke(line, with: .color(.red.opacity(0.6)),
style: StrokeStyle(lineWidth: 1, dash: [5, 3]))
}
浮层提示:用悬停点和 dataToPoint 把一张信息卡放到数据点旁边。
数据标签:把常驻标签放在点的上方,并用 annotationRect 让它们离坐标轴标签远一点。

OverlayDemo 截图,展示 Tooltip Overlay、数据标签和阈值线
annotationRect 值得单独说一句。ChartLens 把图表区域拆成 frameRect、plotRect、annotationRect 和 axisLabelRects。如果所有 UI 元素都用 plotRect,标签会盖住曲线,浮层提示会被裁掉。annotationRect 给常驻标签和引出说明一块属于自己的合法区域。
这些功能背后的工程
前面几节讲的是怎么用 ChartLens。
花时间最多的工作在这些 API 后面。很多功能来自 WiFi Lens 的需求。当现有做法在产品内部失败时,我把解法搬进 ChartLens,而不是在每个页面里再堆一份代码。
插值模式的选择
ChartLens 目前提供五种曲线模式:linear、Catmull-Rom、clamped cubic、step 和 Gaussian。
这些模式存在,是因为不同的数据行为不同。我加 clamped cubic 是因为一个真实的数据问题冒出来了,不是因为我想要多一种视觉选项。
clamped cubic 这个例子值得展开。
Catmull-Rom 通过相邻点估算切线方向来给出平滑曲线。数据跳变时,切线会把曲线推到这些点的 Y 范围之外。对 RSSI 来说,这意味着图表显示出一个从未发生过的信号强度。
clamped cubic 用 Fritsch-Carlson 单调三次 Hermite 插值来避免这一点。它不在屏幕空间里夹紧控制点,那样会产生折角。它调整切线的模长:当相邻斜率指向不同方向时,算法缩放切线,让曲线留在数据范围内。曲线既不越界,又保持 C1 连续,所以每个数据点上的切线依然平滑。
DemoApp 里有一个 SplineOvershootDemo,带两组数据:一组下降,100 → 10 → 10 → 10 → 50;一组上升,10 → 10 → 10 → 100 → 10。Catmull-Rom 在两种情况下都越界。clamped cubic 留在数据范围内。

SplineOvershootDemo 截图,对比 Drop 与 Rise
高斯曲线
Wi-Fi 频谱图不是普通的折线图。
每个 AP 代表一段覆盖范围,而不是一个采样点。用普通的直线连接看起来不对,也表现不出邻近信道是怎么重叠的。ChartLens 把高斯当成一种特殊的曲线生成策略,而不是普通的插值模式。
每个 AP 只需要两个数据点(左右信道边界),外加一个 RSSI 值作为幅度。ChartLens 把它变成一条 80 步的平滑钟形曲线。
sigma 控制曲线宽度,来自信道带宽,取 halfWidth / 4。曲线尾部向底噪淡出。多个 AP 可以用各自的颜色和透明度独立叠加。

GaussianDemo 截图,单个 AP 的钟形曲线与多 AP 频谱叠加
坐标映射与区域分离
ChartGeometry 处在坐标计算的中心。它保存 plotRect(真正的绘制区域)和数据范围:xMin、xMax、yMin、yMax。它提供两个转换:dataToPoint 和 pointToData。
这个设计看着普通,但它塑造了整个图表系统。Overlay、Tooltip、Crosshair、Annotation 全都用同一套映射,而不是各自维护一份坐标计算。
ChartLens 把图表区域拆成四块。frameRect 是整个组件。plotRect 承载曲线。axisLabelRects 放坐标轴标签。annotationRect 给产品标注一块合法区域。
这个拆分修掉了常见的产品 bug:浮层提示撞上坐标轴标签、标签盖住曲线、标注没有安全的地方可待。每个 UI 元素都知道自己该待在哪。
协议驱动的扩展性
还有一处设计要紧:ChartLens 怎么长大。
ChartLens 不把每种图表类型都做进内核。它用协议把数据、渲染器和系列分开。新增一种图表类型不需要改 Chart。
ChartPointProtocol → 数据点接口
ChartSeriesRenderer<Point> → 渲染器接口
ChartSeriesProtocol<Point> → 系列接口,把数据和渲染器组合在一起
Chart<Overlay> 用 [any ChartSeriesProtocol] 保存系列。同一张图表可以容纳不同的点类型和渲染器。折线图用 ChartPoint 配 LineRenderer。K 线图用 CandlestickPoint 配 CandlestickRenderer。两者可以待在同一张 Chart 里。
新增一种图表类型分三步:
// 1. 定义新的数据点类型。
struct CustomPoint: ChartPointProtocol { ... }
// 2. 实现对应的渲染器。
struct CustomRenderer: ChartSeriesRenderer { ... }
// 3. 组合成一个新的 ChartSeries。
ChartSeries(...)
步骤很简单。要紧的是 Chart 保持不变。协议驱动的设计让这个库通过扩展来增加图表类型,而不是改内核。
ChartLens 不需要认识每一种图表类型。折线图、面积图、高斯曲线和 K 线图实现的都是同一组想法。应用决定画什么,ChartLens 负责坐标映射和布局。

BasicChartsDemo、GaussianDemo 与 CandlestickDemo 的对比截图
回到 WiFi Lens
这些部分在 WiFi Lens 里长什么样?

WiFi Lens 截图,频谱图 BandChartView
频谱图(BandChartView)用高斯曲线渲染 AP 的信道占用。每个 AP 只需要两个数据点(左右信道边界)加一个 RSSI 值。ChartLens 生成平滑的钟形曲线,多个 AP 用各自的颜色和透明度叠加。应用层通过浮层加上 SSID 标签、浮层提示和信道占用热力图,而不改 ChartLens 的渲染器。

WiFi Lens 截图,趋势图 TrendChartView
趋势图(TrendChartView)用线性插值和面积填充展示 RSSI 随时间的变化。填充的区域让信号变化更容易读。

WiFi Lens 截图,漫游时间线 RoamingTestView
漫游时间线(RoamingTestView)用 DetailOverviewChart 处理长时间运行的漫游事件。总览带显示整场测试,细节图放大到 AP 切换的时刻。用户拖动窗口来查看不同的时间段。
这些页面看起来各不相同,但它们共用坐标映射、命中测试、浮层注入和区域管理。ChartLens 负责图表基础设施,WiFi Lens 专注在 Wi-Fi 行为上。
写在最后
ChartLens 不是从一次抽象练习开始的。
几乎每个功能都来自我在做 WiFi Lens 时撞上的问题:坐标映射、浮层、实时缩放、高斯曲线、边界越界。当同一个问题再次出现时,我把解法搬进可复用的图表基础设施,而不是再打一个局部补丁。
我没有先设计 ChartLens 再去找使用场景。它是从产品工作里长出来的。
如果你在 SwiftUI 里做复杂图表,希望这些例子能帮你判断:图表代码该在哪里结束,产品代码该从哪里开始。
项目链接
- ChartLens GitHub 仓库,含 DemoApp:https://github.com/ShiinaLabs/chart-lens
- WiFi Lens GitHub 仓库:https://github.com/ShiinaLabs/wifi-lens