"""Pixel-column envelope for drawing; measurement samples are never changed.""" import math from bisect import bisect_left, bisect_right class IndexedTimes(list): """Random-access timestamps, with ordering validated once when loaded.""" def __init__(self, points): super().__init__(points) self.ordered = all(math.isfinite(x) and (i == 0 or self[i-1] <= x) for i, x in enumerate(self)) def viewport_samples(timestamps, values, left, right, neighbours=False, budget=None): """Select only the viewport in logarithmic time for indexed records.""" if isinstance(timestamps, (list, tuple, range)) and getattr(timestamps, "ordered", True): start = bisect_left(timestamps, left) stop = bisect_right(timestamps, right) if neighbours: start = max(0, start - 1) stop = min(len(timestamps), stop + 1) stride = max(1, math.ceil((stop-start) / budget)) if budget else 1 for i in range(start, min(stop, len(values)), stride): yield timestamps[i], values[i] if budget and stop > start and (stop - 1 - start) % stride: yield timestamps[stop-1], values[stop-1] else: # Streaming or unordered data keeps its original gap/order semantics. yield from zip(timestamps, values) def pixel_envelope(timestamps, values, left, right, width): """Yield first/min/max/last samples in time order for each pixel column. NaNs separate paths. Keep one neighbouring sample on either side so clipping and zooming retain lines crossing the viewport boundaries. """ width = max(1, int(width)) scale = width / (right - left) if right > left else 1. bucket = None first = low = high = last = None def emit(): if first is None: return () selected = {point[0]: point for point in (first, low, high, last)} return tuple((selected[index][1], selected[index][2]) for index in sorted(selected)) for index, (stamp, value) in enumerate(viewport_samples(timestamps, values, left, right, neighbours=True)): if not math.isfinite(stamp) or not math.isfinite(value): yield from emit() yield None bucket = None first = low = high = last = None continue column = -1 if stamp < left else width if stamp > right else min(width - 1, int((stamp - left) * scale)) point = (index, stamp, value) if column != bucket: yield from emit() bucket = column first = low = high = last = point elif column == -1: first = low = high = last = point elif column == width: continue else: last = point if value < low[2]: low = point if value > high[2]: high = point yield from emit() def preview_window_count(requested, preview_count, original_count): """Translate a raw-sample window to a display-envelope window.""" if not original_count or original_count <= preview_count: return requested return max(1, min(preview_count, math.ceil(requested * preview_count / original_count)))