81 lines
3.2 KiB
Python
81 lines
3.2 KiB
Python
"""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)))
|