为什么自己管理视口
PipeMonitor 的历史页要在一块手机宽度的屏幕上展示从 30 秒到 24 小时不等的时间区间。fl_chart 的 LineChart 默认会把整段数据塞进当前可视区域,结果就是:30 秒分度下数据点挤成一团,24 小时分度下 X 轴标签密到互相覆盖。
解决思路是把”完整时间轴”和”当前可视窗口”分成两层:完整时间轴由分度值预设决定对齐边界和刻度间隔,可视窗口由用户拖动产生并在完整轴上做 clamp。这样无论查询区间多宽,屏幕上始终只显示 6–8 个大格,标签密度恒定。
X 轴分度值预设
历史页顶部是一个 SegmentedButton,对应 6 档预设分度值。每一档除了”一个大格代表多少秒”,还带”切到该档时一屏显示几个大格”,后者决定了默认视口宽度:
/// X 轴分度值预设:一个大格代表多少秒,以及切到该档时一屏显示几个大格。
const _intervalPresets = <({int seconds, int visibleIntervals, String label})>[
(seconds: 30, visibleIntervals: 6, label: '30s'),
(seconds: 60, visibleIntervals: 6, label: '1min'),
(seconds: 300, visibleIntervals: 6, label: '5min'),
(seconds: 600, visibleIntervals: 6, label: '10min'),
(seconds: 3600, visibleIntervals: 8, label: '1h'),
(seconds: _dayIntervalSeconds, visibleIntervals: 8, label: '24h'),
];
短分度(30s–10min)一屏 6 格,长分度(1h/24h)一屏 8 格——长分度下时间标签更宽(带日期),减少一格能避免标签互相压住。
预设不仅决定刻度间隔,还决定时间标签格式:30 秒分度显示到秒,跨天则带日期:
/// 短区间只显示时分;跨天后再带日期,避免手机窄屏下横轴标签互相压住。
String _xAxisFormat({
required DateTime from,
required DateTime to,
required int intervalSeconds,
}) {
if (intervalSeconds < 60) {
return 'HH:mm:ss';
}
final span = to.difference(from);
if (span <= const Duration(hours: 24)) {
return 'HH:mm';
}
return 'MM-dd\nHH:mm';
}
完整 X 轴的 minX/maxX 不是直接用查询时间 [from, to],而是按分度值对齐到刻度边界,避免首尾刻度只露半截:
_XAxisSpec _buildXAxisSpec({
required DateTime from,
required DateTime to,
required int intervalSeconds,
}) {
final interval = Duration(seconds: intervalSeconds);
final intervalMs = interval.inMilliseconds.toDouble();
final alignedFrom = _floorToIntervalBoundary(from, interval);
final alignedTo = _ceilToIntervalBoundary(to, interval);
final safeTo = alignedTo.isAfter(alignedFrom)
? alignedTo
: alignedFrom.add(interval);
return _XAxisSpec(
minX: alignedFrom.millisecondsSinceEpoch.toDouble(),
maxX: safeTo.millisecondsSinceEpoch.toDouble(),
interval: intervalMs,
);
}
Y 轴”1/2/5 × 10ⁿ”刻度算法
Y 轴的难点是不同字段量级差异巨大:温度跨度可能只有几度,累计量可能上百万。如果直接用 (maxY - minY) / 6 当刻度间隔,会出现 0.37、1234.56 这种不利于阅读的刻度。
selectChartAxisInterval 的策略是:先按字段类型给一组预设刻度候选(温度 [0.1, 0.2, 0.5, 1, 2, 5, 10, 20],累计量 [10, 20, 50, 100, 200, 500, 1000] 等),从候选里找第一个不小于 span / targetTickCount 的值;候选都太小时再退到”1/2/5 × 10ⁿ”动态生成:
/// 按目标间隔选择图表刻度;超过预设上限时自动扩展为 1/2/5 × 10ⁿ。
double? selectChartAxisInterval({
required double min,
required double max,
required List<double> preferredIntervals,
int targetTickCount = 6,
}) {
assert(preferredIntervals.isNotEmpty);
assert(targetTickCount > 0);
final span = (max - min).abs();
if (!span.isFinite || span <= 0) return null;
final target = span / targetTickCount;
for (final interval in preferredIntervals) {
if (interval >= target) return interval;
}
return _niceIntervalAtLeast(target);
}
/// 返回不小于 [target] 的 1/2/5 十进制刻度,限制超大范围下的刻度数量。
double _niceIntervalAtLeast(double target) {
final exponent = (math.log(target) / math.ln10).floor();
final magnitude = math.pow(10, exponent).toDouble();
if (!magnitude.isFinite || magnitude <= 0) return target;
final normalized = target / magnitude;
final multiplier = normalized <= 1
? 1.0
: normalized <= 2
? 2.0
: normalized <= 5
? 5.0
: 10.0;
final interval = multiplier * magnitude;
return interval.isFinite ? interval : target;
}
测试用例覆盖了三种典型场景:预设范围内直接命中、超过预设上限时动态放大到 2 × 10⁵、零跨度时返回 null 交给图表库自动处理:
test('累计量跨度超过预设上限时动态放大刻度', () {
final interval = selectChartAxisInterval(
min: 0,
max: 1200000,
preferredIntervals: totalIntervals,
);
expect(interval, 200000);
expect(1200000 / interval!, lessThanOrEqualTo(6));
});
1200000 / 6 = 200000,正好命中 2 × 10⁵,刻度数量不超过 6 个。
可拖动视口
视口状态保存在 _ChartState 里:_viewportMinX/_viewportMaxX 是当前可视窗口的 X 轴边界(毫秒时间戳),_lastFullAxisMaxX 记录上一次完整轴的右端,用来判断”是否贴在最新位置”。
拖动逻辑在 GestureDetector.onHorizontalDragUpdate 里。关键点是把屏幕像素位移换算成数据坐标位移:先算出绘图区实际宽度(总宽减去 Y 轴预留宽),再用”可视窗口跨度 / 绘图宽度”作为像素到数据的缩放比,最后乘一个灵敏度系数:
onHorizontalDragUpdate: (details) {
final plotWidth = math.max(
1.0,
constraints.maxWidth - yReserved - 24.0,
);
final visibleSpan = visibleMaxX - visibleMinX;
final shift =
-details.delta.dx *
(visibleSpan / plotWidth) *
_chartDragSensitivity;
if (shift == 0) return;
final fullSpan = xAxis.maxX - xAxis.minX;
if (fullSpan <= visibleSpan) return;
var nextMin = visibleMinX + shift;
var nextMax = visibleMaxX + shift;
if (nextMin < xAxis.minX) {
nextMin = xAxis.minX;
nextMax = nextMin + visibleSpan;
}
if (nextMax > xAxis.maxX) {
nextMax = xAxis.maxX;
nextMin = nextMax - visibleSpan;
}
// ...
setState(() {
_viewportMinX = nextMin;
_viewportMaxX = nextMax;
});
},
fullSpan <= visibleSpan 时直接 return——查询区间本身就比视口窄,没必要拖动。clamp 阶段不是简单截断,而是把窗口整体推到边界,保证可视跨度恒定。
pinned-to-latest 行为
查询窗口自动刷新时,如果用户当前正贴在右端(即 _wasPinnedToLatest() 返回 true),视口会跟随完整轴右端一起移动,模拟”实时追最新数据”;一旦用户手动往左拖了,就不再自动跟随,避免抢用户操作。
bool _wasPinnedToLatest() {
final oldAxisMaxX = _lastFullAxisMaxX;
final oldViewportMaxX = _viewportMaxX;
if (oldAxisMaxX == null || oldViewportMaxX == null) return true;
return (oldAxisMaxX - oldViewportMaxX).abs() <= 1.0;
}
_ensureViewport 还处理一个边界 case:从 24h 缩回短分度时,视口必须重置到右端,否则会停在旧的宽视口位置上,看上去像”卡住”。
多通道共用时间轴
组合视图(如”温度组”)会把多个字段渲染成上下排列的小图。如果每张小图都自己画一套 X 轴标签,不仅浪费纵向空间,还会因为每张图的 Y 范围不同导致标签位置错开。
解决方案是把时间轴从 _LineChartSection 里抽出来,单独放在最底部。每张小图 reserveBottomTitles: false,最后由 _MultiChannelBottomTitles 统一渲染:
// 多通道的时间轴统一放在最底部,避免每张小图都预留空白。
showBottomTitles: false,
reserveBottomTitles: false,
_MultiChannelBottomTitles 用 Stack + Positioned 手动摆放标签,因为 fl_chart 0.68 的内置 SideTitles 在多图场景下对齐困难。手动摆的好处是可以精确控制标签的水平位置:
for (var value = firstTick; value <= visibleMaxX; value += xAxis.interval) {
if (_isVisibleAxisEdgeLabel(value, visibleMinX, visibleMaxX)) {
continue;
}
final d = DateTime.fromMillisecondsSinceEpoch(value.toInt());
final centerX =
plotLeft + ((value - visibleMinX) / visibleSpan) * plotWidth;
labels.add(
Positioned(
left: (centerX - labelWidth / 2)
.clamp(0.0, constraints.maxWidth - labelWidth)
.toDouble(),
// ...
),
);
}
多通道还支持”统一 Y 轴”模式(如温度组),所有通道共享全局 chartMinY/chartMaxY,方便横向比较各通道数值差异。统一 Y 轴时刻度间隔也必须按共享后的总范围重新计算:
final unifiedYInterval = vm.view.useUnifiedYInterval
? _yAxisInterval(
field: nonEmpty.first.field,
minY: globalChartMinY,
maxY: globalChartMaxY,
)
: null;
数据断点检测
历史数据里常常有采样间隔突变的场景:设备掉线几分钟再恢复,或者上传周期被远程命令临时改过。如果直接连线,曲线会出现一条横跨整段空白的斜线,误导用户以为数据是连续的。
断点检测策略是按数据点间隔的中位数动态估算阈值:先算所有相邻点间隔,取中位数作为”正常间隔”,再乘以倍数(_historyGapBreakMultiplier = 3)作为断线阈值,并设一个最低下限(90 秒):
Duration _historyGapBreakThreshold(List<HistoryPoint> points) {
if (points.length < 2) return _historyMinGapBreakThreshold;
final gaps = <int>[];
for (var i = 0; i < points.length - 1; i++) {
final gapMs = points[i + 1].timestamp
.difference(points[i].timestamp)
.inMilliseconds;
if (gapMs > 0) gaps.add(gapMs);
}
if (gaps.isEmpty) return _historyMinGapBreakThreshold;
gaps.sort();
final mid = gaps.length ~/ 2;
final normalGapMs = gaps.length.isOdd
? gaps[mid]
: (gaps[mid - 1] + gaps[mid]) ~/ 2;
final dynamicThresholdMs = normalGapMs * _historyGapBreakMultiplier;
return Duration(
milliseconds: math.max(
_historyMinGapBreakThreshold.inMilliseconds,
dynamicThresholdMs,
),
);
}
中位数比平均值更抗异常点干扰——一两个超长间隔不会把阈值拉高到漏断。检测到断点位置插入 FlSpot.nullSpot,fl_chart 会在 nullSpot 处断开折线:
if (next.difference(current) > gapBreakThreshold) {
// 断点阈值按服务器历史点间隔动态估算,避免正常长间隔采样被误断开。
spots.add(FlSpot.nullSpot);
}
边缘标签隐藏
fl_chart 默认会在 minX/maxX 位置也画一个刻度标签,但这个标签往往和图表边框、Y 轴标签重叠。fl_chart 0.68 没有 minIncluded/maxIncluded 参数,所以手动判断:
/// `fl_chart 0.68` 没有 `minIncluded/maxIncluded`,这里手动隐藏边界标签,避免和内部刻度重叠。
bool _isAxisEdgeLabel(double value, TitleMeta meta) {
const epsilon = 0.000001;
return (value - meta.min).abs() < epsilon ||
(value - meta.max).abs() < epsilon;
}
getTitlesWidget 里命中边界值就返回 SizedBox.shrink()。多通道场景下自定义的 _MultiChannelBottomTitles 用了不同的 epsilon(1.0 毫秒),因为视口边界是浮点数运算结果,不能用极小 epsilon:
bool _isVisibleAxisEdgeLabel(double value, double min, double max) {
const epsilon = 1.0;
return (value - min).abs() < epsilon || (value - max).abs() < epsilon;
}
单点 Badge
查询区间内只有 1 个数据点时,fl_chart 画出来就是孤立一个点,用户容易误以为”曲线没画出来”。_FieldSeries.from 检测单点场景:
final hasSinglePoint = rawSpots.length == 1;
单点时圆点半径放大到 4(正常 2.5),并在右上角显示一个 Badge 提示用户:
if (s.hasSinglePoint)
const Positioned(top: 8, right: 8, child: _SinglePointBadge()),
class _SinglePointBadge extends StatelessWidget {
const _SinglePointBadge();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
color: Color(0xCCFFFFFF),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('当前时间段仅 1 个点', style: TextStyle(fontSize: 12)),
),
);
}
}
小结
- X 轴分度值预设同时定义了刻度间隔、默认视口宽度和时间标签格式,6 档覆盖 30s 到 24h
- Y 轴刻度先查字段预设候选,超过候选上限再退到”1/2/5 × 10ⁿ”动态生成,保证刻度可读
- 视口状态独立于完整时间轴,拖动时按像素到数据的缩放比换算,clamp 保证可视跨度恒定
- pinned-to-latest 通过比较
_lastFullAxisMaxX与_viewportMaxX判断是否贴右端,自动追最新数据 - 多通道共用底部时间轴,节省纵向空间,统一 Y 轴便于横向比较
- 数据断点按中位数 × 3 动态阈值检测,插
FlSpot.nullSpot断开折线 - 边缘标签手动隐藏避免与边框/Y 轴重叠,单点场景显示 Badge 提示