Skip to content

图表层

ui/charts/ 的 9 个组件与共享的 Chart.js 桥接层。色值口径见 design-tokens.md

1. 架构:一层 canvas 宿主 + 一层主题桥

        chartjs.ts                 ChartCanvas.svelte           TrendChart / Donut / …
  ┌──────────────────────┐        ┌──────────────────┐        ┌────────────────────┐
  │ ensureChart()        │        │ 拥有 <canvas>     │        │ $derived 出 config │
  │ chartTheme()         │◀───────│ 每个 canvas 一个  │◀───────│ 组件只管数据与配置 │
  │ resolveColor()       │        │ Chart 实例        │        │                    │
  │ 4 个自绘插件          │        │ 就地 update()     │        └────────────────────┘
  └──────────────────────┘        └──────────────────┘

数据、格式化、主题的变化统一表现为「一个新的 config」,由 ChartCanvaschart.update('none') 就地应用,而不是销毁重建。这既保住了性能,也让主题切换不闪。

颜色为什么在运行时解析

resolveColor('var(--c-data-1)') 会去 :root 上读实际值。Chart.js 不认识 CSS 变量 (它往 canvas 里画,不走 CSS 层叠),所以必须解析。

chartTheme() 里第一行是 void theme.mode;——一次刻意的反应性订阅。宿主的明暗态 一变,所有 $derived 的 config 重算,颜色随之重解析。theme.mode 因此只是重绘信号, 颜色本身仍然只有 CSS 一个来源。

主题令牌

chartTheme() 返回的是文字色的 alpha 派生,因此不需要为亮暗各写一套:

alpha用途
grid0.10常规网格线
gridDash0.15虚线网格(日期界)
axisLine0.20轴线 / 外框
baseline0.30参考线(比轴线略强,又不与系列竞争)
crosshair0.35悬停十字线
tick0.65轴刻度文字

另外三个直接取表面令牌:tooltipBg / tooltipText / tooltipBorder

字体

ensureChart() 里把 Chart.defaults.font.family 设成 --sans、size 设成 11。 不设的话每张图都会渲染成 Chart.js 默认的 12px Helvetica,与周围的 Inter 明显打架。

只注册用到的部件

ensureChart() 显式注册 LineController / BarController / DoughnutController 及配套元素——为了 tree-shaking。加新图表类型要在这里补注册。

外置 HTML tooltip

Chart.js 默认把 tooltip 画在 canvas 内部,在小图上会被裁掉(150px 的仪表、 火花线、110px 的时间线)。因此库里用的是一个单例 HTML 元素(.chart-tip,样式在 app.css),定位在 caret 旁并做视口夹取,可以自由溢出。

2. 四个自绘插件

Chart.js 没有内建这些,都在 chartjs.ts 里,通过 options.plugins.<id> 传参。

插件做什么参数来自
plotFrame在绘图区外围画一圈细实线框(Radar 的 chart-outer-line){ color }
crosshair悬停时在活动点位置画竖直十字线{ color }
zeroLine在固定 y 值处画横向虚线参考线;超出当前 y 范围时跳过{ color, value }
missingRegions用斜纹填充标出「无上报」的区间{ regions, color }

TrendChart 四个全带:plotFrameaxisLine 色、missingRegionsgridDashcrosshaircrosshair 色,zeroLine 只在传了 baseline 时启用。

missingRegions 是把 null0 这条数据口径变成视觉的地方:空桶是缺报, 画成斜纹缺口;真的没有变化才是 0。规则本身见 api/flaps.md

3. 组件

ChartCanvas

所有 Chart.js 图表的宿主。业务代码一般不直接用它,除非要画库里没有的图。

prop类型说明
configChartConfiguration<any>由父组件 $derived
height? / width?number
fixed?boolean固定尺寸画布(火花线、迷你条)vs 随容器响应(线图、柱图、环图)
fill?boolean撑满 flex 父容器的高度;此时 height 变成最小值
label?string无障碍标签
plugins?Plugin[]图表局部插件,必须在组件生命周期内保持不变

config 的类型参数是 any,好让 ChartConfiguration<'doughnut'> 这类具体化配置 不必绕 unknown 转型。

内部有一个容易被忽视的守卫:创建实例前先 Chart.getChart(canvas)?.destroy(), 清掉上一次挂载在这个 canvas 上残留的实例。否则开发时的 HMR 重挂会让 new Chart 抛 "Canvas is already in use",图表从此静默不再绘制。

TrendChart

Radar 式时序趋势:多系列平滑折线、可选渐变面积、虚线对比系列。

prop类型说明
seriesSeries[]见下
timestamps(string | number | null)[]ISO 串或 epoch 整数都认,内部走 parseTs
height?number默认 200
fill?boolean撑满 flex 父高;height 变成最小值
zeroBased?booleany 轴从 0 起
baseline?number在该 y 值画虚线参考线并保证它落在 y 范围内
format?(v) => stringtooltip 数值格式化,默认 fmtNum
tickFormat?(v) => stringy 轴刻度单独的格式化(例如紧凑记数)
leftAxisWidth?number强制左轴宽度,让本图的绘图区与下方另一张图对齐
ts
interface Series {
  label: string;
  color: string;
  values: (number | null)[];   // null = 空桶 → 画成缺口
  fill?: boolean;              // 主系列的渐变面积
  dash?: boolean;              // 对比系列(上一周期)
  step?: boolean;              // 阶梯线
}

step 不是审美选择:计数类序列(路由数、对端数)在两次采样之间并不连续变化, 阶梯线才是语义正确的画法。

对比系列用同色虚线,不是另一种颜色——这样「本期 vs 上期」读起来是同一条指标的 两个时间窗,而不是两个指标。

leftAxisWidth 解决的是上下堆叠两张图时绘图区左边缘对不齐的问题(两张图的 y 轴 刻度位数不同)。

Sparkline

内联的小趋势线 / 面积图,固定尺寸。

ts
values: (number | null)[];   // null = 空桶,画成缺口
width?: number;   // 默认 120
height?: number;  // 默认 34
color?: string;   // 默认 var(--c-data-1)
fill?: boolean;         // 渐变填充,默认 true
strokeWidth?: number;   // 默认 1.5
smooth?: boolean;       // 默认 true
interactive?: boolean;  // 悬停读数
fluid?: boolean;        // 拉伸到容器宽度(此时 width 被忽略)
format?: (v: number) => string;

内部对 values 做了一次浅拷贝。Chart.js 会用 Object.defineProperty 给数据数组 装桩,这在 $state 代理数组上会抛异常。传 runes 状态数组进来是安全的,就是因为 这个拷贝。

BarChart

按时间桶的堆叠竖条。

ts
groups: { label: string; parts: { key: string; value: number; color: string }[] }[];
height?: number;        // 默认 130
maxLabels?: number;     // 默认 6,x 轴标签抽稀
leftAxisWidth?: number; // 与上方图表对齐用的固定左槽

分段的顺序与颜色按首次出现确定并在各组间保持稳定——否则某一桶缺了某个 key 时, 其上下的颜色会错位。

Donut

环图 / 半环,Chart.js doughnut 支撑。悬停高亮分段并在中心显示其数值。

ts
segments: { label: string; value: number; color: string }[];
size?: number;        // 默认 150
thickness?: number;   // 默认 20
centerValue?: string | number;
centerLabel?: string;
half?: boolean;       // 半环(仪表)形态
format?: (v: number) => string;   // tooltip / 悬停中心的格式化

中心文字是 HTML 覆盖层——Chart.js 没有原生的中心标签。

half 的实现里有一处必须知道的几何修正:Chart.js 把 180° 弧垂直居中在方形画布里 (getRatioAndOffset),半圆的顶边落在 size/4、赤道落在 3·size/4而不是0…size/2。所以要把画布上提 size/4,裁切窗口才正好露出完整半圆。按 0…size/2 裁只会看到拱顶那一条细弧。

HalfDonutGauge

半环仪表 + 弧上下各一组 .pair 统计。仪表盘的流量构成与 FleetRouting 的 RPKI / 协议族仪表共用这一套解剖。

ts
segments: { label: string; value: number; color: string }[];
size?: number;       // 默认 190
thickness?: number;  // 默认 28
top: { color: string; label: string; value: string };     // value 已格式化,如 '63%'
bottom: { color: string; label: string; value: string };
format?: (v: number) => string;   // tooltip 格式化

RateGauge

对数刻度的半弧速率表盘(对齐 FlapAlerted)。

ts
value: number;   // 900s 90 分位截尾均值(rate_per_s)
size?: number;   // 默认 220

它是「一个值配一把刻度」的主角数字,不是百分比环(那是 HalfDonutGauge 的活)。

刻度是固定的四段等长弧:0→1→10→100→1000(次/秒)。1 以下线性、各数量级之间取对数。 理由是量程:平稳期机群在 1/s 以下,全网事件期能到几百(生产实测 214/s)——线性扫描 会让指针永远贴在某一端。

超出量程的值夹取到满弧,但主角数字仍然说真话。

填充色是中性数据色:fleet 级没有定义阈值,表盘陈述量级,不做判断。

ShareBar

比例条,纯 CSS / SVG,不走 Chart.js。上方一行图例点 + 标签 + 大号百分比,下方一条 分段条。

ts
segments: { label: string; value: number; color: string }[];
height?: number;      // 默认 30
showHeader?: boolean; // 图例 + 百分比行,默认 true
format?: (v: number) => string;   // tooltip 的绝对值格式化,默认 fmtNum

参数(2px 间隙、2px 圆角、< 0.1% 的处理)是从 Radar 的 StackedBar 源码里读出来的, 详见 design-tokens.md

小于 0.1% 的段渲染成 < 0.1% 并保留一条细缝宽度,而不是消失。

ChartLegend

Radar 式的紧凑 HTML 图例。用 HTML 而不是 Chart.js 内建,这样字体与间距跟设计系统 走。

ts
items: {
  label: string;
  color: string;
  dash?: boolean;    // 对比系列 → 虚线 swatch
  line?: boolean;    // 线系列 → 实线 swatch(Radar 的 22×6 标记)
  value?: string | number;   // 标签后的加粗数值
}[];
column?: boolean;    // 竖排(环图侧边图例)

三种 swatch 形态(点 / 实线 / 虚线)不是装饰:它们区分「分类项 / 线系列 / 对比系列」, 让图例本身就说明了那条线在图上长什么样。

4. 加新图表时

  1. ensureChart() 里注册需要的 controller / element;
  2. 组件里 $derived 出 config,颜色一律经 resolveColor()
  3. 主题相关的值取自 chartTheme()不要自己读 CSS 变量拼 alpha;
  4. 交给 ChartCanvas 渲染,不要自己 new Chart
  5. 数量序列用 --c-data-*,状态序列用 --c-ok/warn/bad,两套不混;
  6. 空桶画缺口或斜纹,不要画成 0