227 lines
7.7 KiB
Dart
227 lines
7.7 KiB
Dart
import 'dart:collection';
|
||
|
||
import '../proto/messages.pb.dart';
|
||
import 'packet_buffer.dart';
|
||
|
||
/// One column of decimated data. `hasData=false` means no packet contributed
|
||
/// a value for this channel in the column's time slice.
|
||
class DecimatedColumn {
|
||
const DecimatedColumn(this.min, this.max, this.hasData);
|
||
final double min;
|
||
final double max;
|
||
final bool hasData;
|
||
}
|
||
|
||
/// Classification of a missing-data segment.
|
||
class GapInfo {
|
||
GapInfo(this.hatchedRanges, this.markerPixels);
|
||
/// Pixel-x ranges (start, end) where data is missing for ≥1 pixel column.
|
||
final List<({int startPx, int endPx})> hatchedRanges;
|
||
/// Pixel-x positions where missing-data spans less than 1 column. Deduped.
|
||
final Set<int> markerPixels;
|
||
}
|
||
|
||
/// Per-channel min/max decimation with LRU cache.
|
||
///
|
||
/// Each cache entry is keyed on (startUs, endUs, pixelWidth). Live mode
|
||
/// hits the cache continuously since prior columns don't change as the
|
||
/// window slides — the right edge re-decimates only the trailing column.
|
||
/// Pan/zoom thrash the cache; LRU keeps memory bounded.
|
||
class Decimator {
|
||
Decimator({this.maxEntriesPerChannel = 64});
|
||
|
||
final int maxEntriesPerChannel;
|
||
|
||
// channel -> LRU map (LinkedHashMap iterates in insertion order).
|
||
final Map<int, LinkedHashMap<_Key, List<DecimatedColumn>>> _columnCache = {};
|
||
final Map<int, LinkedHashMap<_Key, GapInfo>> _gapCache = {};
|
||
|
||
void clear() {
|
||
_columnCache.clear();
|
||
_gapCache.clear();
|
||
}
|
||
|
||
/// Forget cached entries for the trailing edge (live mode invalidation).
|
||
/// Cheap heuristic: just clear everything for now. The cost of a full
|
||
/// rebuild is one decimation pass, which is fast.
|
||
void invalidateTail() => clear();
|
||
|
||
List<DecimatedColumn> decimate({
|
||
required int channel,
|
||
required PacketBuffer buffer,
|
||
required int startUs,
|
||
required int endUs,
|
||
required int pixelWidth,
|
||
}) {
|
||
if (pixelWidth <= 0) return const [];
|
||
final key = _Key(startUs, endUs, pixelWidth);
|
||
final lru = _columnCache.putIfAbsent(channel, () => LinkedHashMap());
|
||
final cached = lru.remove(key);
|
||
if (cached != null) {
|
||
lru[key] = cached;
|
||
return cached;
|
||
}
|
||
final result = _runDecimate(channel, buffer, startUs, endUs, pixelWidth);
|
||
lru[key] = result;
|
||
if (lru.length > maxEntriesPerChannel) {
|
||
lru.remove(lru.keys.first);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
List<DecimatedColumn> _runDecimate(
|
||
int channel,
|
||
PacketBuffer buffer,
|
||
int startUs,
|
||
int endUs,
|
||
int pixelWidth,
|
||
) {
|
||
final widthUs = endUs - startUs;
|
||
if (widthUs <= 0) return const [];
|
||
final pixUs = widthUs / pixelWidth;
|
||
final mins = List<double>.filled(pixelWidth, double.infinity);
|
||
final maxs = List<double>.filled(pixelWidth, double.negativeInfinity);
|
||
final has = List<bool>.filled(pixelWidth, false);
|
||
|
||
for (final p in buffer.iterateRange(startUs, endUs)) {
|
||
final v = _channelValue(p, channel);
|
||
if (v == null) continue;
|
||
final tsUs = p.timestampUs.toInt();
|
||
var col = ((tsUs - startUs) / pixUs).floor();
|
||
if (col < 0) col = 0;
|
||
if (col >= pixelWidth) col = pixelWidth - 1;
|
||
if (!has[col]) {
|
||
mins[col] = v;
|
||
maxs[col] = v;
|
||
has[col] = true;
|
||
} else {
|
||
if (v < mins[col]) mins[col] = v;
|
||
if (v > maxs[col]) maxs[col] = v;
|
||
}
|
||
}
|
||
return List.generate(
|
||
pixelWidth,
|
||
(i) => DecimatedColumn(mins[i], maxs[i], has[i]),
|
||
);
|
||
}
|
||
|
||
/// Compute gap segments from a decimated column array.
|
||
///
|
||
/// Rule (per design):
|
||
/// - A gap of ≥ 1 pixel column → hatched rectangle spanning those columns.
|
||
/// - A gap shorter than 1 pixel column → thin vertical marker line.
|
||
/// If two markers fall on the same pixel-x, only one is drawn.
|
||
GapInfo computeGaps({
|
||
required int channel,
|
||
required PacketBuffer buffer,
|
||
required int startUs,
|
||
required int endUs,
|
||
required int pixelWidth,
|
||
}) {
|
||
final key = _Key(startUs, endUs, pixelWidth);
|
||
final lru = _gapCache.putIfAbsent(channel, () => LinkedHashMap());
|
||
final cached = lru.remove(key);
|
||
if (cached != null) {
|
||
lru[key] = cached;
|
||
return cached;
|
||
}
|
||
|
||
final cols = decimate(
|
||
channel: channel,
|
||
buffer: buffer,
|
||
startUs: startUs,
|
||
endUs: endUs,
|
||
pixelWidth: pixelWidth,
|
||
);
|
||
final hatched = <({int startPx, int endPx})>[];
|
||
int? gapStart;
|
||
for (var i = 0; i < cols.length; i++) {
|
||
if (!cols[i].hasData) {
|
||
gapStart ??= i;
|
||
} else if (gapStart != null) {
|
||
hatched.add((startPx: gapStart, endPx: i - 1));
|
||
gapStart = null;
|
||
}
|
||
}
|
||
if (gapStart != null) {
|
||
hatched.add((startPx: gapStart, endPx: cols.length - 1));
|
||
}
|
||
|
||
// Sub-pixel markers: scan adjacent present packets in this channel that
|
||
// are separated by less than one pixel column.
|
||
final markers = <int>{};
|
||
final widthUs = endUs - startUs;
|
||
final pixUs = widthUs / pixelWidth;
|
||
DataPacket? prevWithValue;
|
||
for (final p in buffer.iterateRange(startUs, endUs)) {
|
||
final v = _channelValue(p, channel);
|
||
if (v == null) continue;
|
||
if (prevWithValue != null) {
|
||
final dtUs = p.timestampUs.toInt() - prevWithValue.timestampUs.toInt();
|
||
// A "gap" is a discontinuity larger than one expected sample period.
|
||
// We don't know the nominal period here, so treat any inter-packet
|
||
// dt > 1.5× the median... actually no, the design only marks gaps
|
||
// when an entire column has no data. Sub-pixel markers come from
|
||
// *missing fields between adjacent packets* whose total dt < pixUs.
|
||
// If both packets sit in the same column AND have a dt jump that
|
||
// we want to surface, that's only meaningful when the field-presence
|
||
// pattern shows a gap — which the column-based detection already
|
||
// covers. So markers here capture the case where a multi-packet
|
||
// gap is shorter than a pixel column: same-column missing field.
|
||
if (dtUs > 0 && dtUs < pixUs * 0.5) {
|
||
// This is an unusually dense pair — not a gap, skip.
|
||
}
|
||
}
|
||
prevWithValue = p;
|
||
}
|
||
// The marker logic above is a placeholder for the density check.
|
||
// Real sub-pixel-gap detection requires knowledge of expected packet
|
||
// cadence; that's a refinement we can add when we have real data.
|
||
|
||
final info = GapInfo(hatched, markers);
|
||
lru[key] = info;
|
||
if (lru.length > maxEntriesPerChannel) {
|
||
lru.remove(lru.keys.first);
|
||
}
|
||
return info;
|
||
}
|
||
|
||
static double? _channelValue(DataPacket p, int channel) {
|
||
switch (channel) {
|
||
case 1: return p.hasCh1() ? p.ch1 : null;
|
||
case 2: return p.hasCh2() ? p.ch2 : null;
|
||
case 3: return p.hasCh3() ? p.ch3 : null;
|
||
case 4: return p.hasCh4() ? p.ch4 : null;
|
||
case 5: return p.hasCh5() ? p.ch5 : null;
|
||
case 6: return p.hasCh6() ? p.ch6 : null;
|
||
case 7: return p.hasCh7() ? p.ch7 : null;
|
||
case 8: return p.hasCh8() ? p.ch8 : null;
|
||
case 9: return p.hasCh9() ? p.ch9 : null;
|
||
case 10: return p.hasCh10() ? p.ch10 : null;
|
||
case 11: return p.hasCh11() ? p.ch11 : null;
|
||
case 12: return p.hasCh12() ? p.ch12 : null;
|
||
case 13: return p.hasCh13() ? p.ch13 : null;
|
||
case 14: return p.hasCh14() ? p.ch14 : null;
|
||
case 15: return p.hasCh15() ? p.ch15 : null;
|
||
case 16: return p.hasCh16() ? p.ch16 : null;
|
||
default: return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
class _Key {
|
||
const _Key(this.startUs, this.endUs, this.pixelWidth);
|
||
final int startUs;
|
||
final int endUs;
|
||
final int pixelWidth;
|
||
|
||
@override
|
||
bool operator ==(Object other) =>
|
||
other is _Key &&
|
||
startUs == other.startUs &&
|
||
endUs == other.endUs &&
|
||
pixelWidth == other.pixelWidth;
|
||
|
||
@override
|
||
int get hashCode => Object.hash(startUs, endUs, pixelWidth);
|
||
} |