Re-implement `.default_view()` on `Viz`

Since we don't really need it defined on the "chart widget" move it to
a viz method and rework it to hell:

- always discard the invalid view l > r case.
- use the graphic's UPPX to determine UI-to-scene coordinate scaling for
  the L1-label collision detection, if there is no L1 just offset by
  a few (index step scaled) datums; this allows us to drop the 2x
  x-range calls as was hacked previous.
- handle no-data-in-view cases explicitly and error if we get any
  ostensibly impossible cases.
- expect caller to trigger a graphics cycle if needed.

Further support this includes a rework a slew of other important
details:

- add `Viz.index_step`, an idempotent computed, index (presumably uniform)
  step value which is needed for variable sample rate graphics displayed
  on an epoch (second) time index.
- rework `Viz.datums_range()` to pass view x-endpoints as first and last
  elements in return `tuple`; tighten up snap-to-data edge case logic
  using `max()`/`min()` calls and better internal var naming.
- adjust all calls to `slice_from_time()` to not expect an "abs" slice.
- drop all `.yrange` resetting since we can just have the `Renderer` do
  it when necessary.
epoch_indexing_and_dataviz_layer
Tyler Goodlet 2022-12-07 16:31:32 -05:00
parent 5ab4e5493e
commit 50209752c3
1 changed files with 231 additions and 61 deletions

View File

@ -25,6 +25,7 @@ incremental update.
from __future__ import annotations from __future__ import annotations
from typing import ( from typing import (
Optional, Optional,
TYPE_CHECKING,
) )
import msgspec import msgspec
@ -49,7 +50,6 @@ from ..data._pathops import (
) )
from ._ohlc import ( from ._ohlc import (
BarItems, BarItems,
# bar_from_ohlc_row,
) )
from ._curve import ( from ._curve import (
Curve, Curve,
@ -63,6 +63,11 @@ from .._profile import (
) )
if TYPE_CHECKING:
from ._interaction import ChartView
from ._chart import ChartPlotWidget
log = get_logger(__name__) log = get_logger(__name__)
@ -231,11 +236,13 @@ class Viz(msgspec.Struct): # , frozen=True):
is_ohlc: bool = False is_ohlc: bool = False
render: bool = True # toggle for display loop render: bool = True # toggle for display loop
# _index_field: str = 'index'
_index_field: str = 'time' _index_field: str = 'time'
# downsampling state # downsampling state
_last_uppx: float = 0 _last_uppx: float = 0
_in_ds: bool = False _in_ds: bool = False
_index_step: float | None = None
# map from uppx -> (downsampled data, incremental graphics) # map from uppx -> (downsampled data, incremental graphics)
_src_r: Optional[Renderer] = None _src_r: Optional[Renderer] = None
@ -244,12 +251,6 @@ class Viz(msgspec.Struct): # , frozen=True):
tuple[Renderer, pg.GraphicsItem], tuple[Renderer, pg.GraphicsItem],
] = (None, None) ] = (None, None)
# TODO: hackery to be able to set a shm later
# but whilst also allowing this type to hashable,
# likely will require serializable token that is used to attach
# to the underlying shm ref after startup?
# _shm: Optional[ShmArray] = None # currently, may be filled in "later"
# cache of y-range values per x-range input. # cache of y-range values per x-range input.
_mxmns: dict[tuple[int, int], tuple[float, float]] = {} _mxmns: dict[tuple[int, int], tuple[float, float]] = {}
@ -261,6 +262,17 @@ class Viz(msgspec.Struct): # , frozen=True):
def index_field(self) -> str: def index_field(self) -> str:
return self._index_field return self._index_field
def index_step(
self,
reset: bool = False,
) -> float:
if self._index_step is None:
index = self.shm.array[self.index_field]
self._index_step = index[-1] - index[-2]
return self._index_step
def maxmin( def maxmin(
self, self,
lbar: int, lbar: int,
@ -275,23 +287,35 @@ class Viz(msgspec.Struct): # , frozen=True):
''' '''
# TODO: hash the slice instead maybe? # TODO: hash the slice instead maybe?
# https://stackoverflow.com/a/29980872 # https://stackoverflow.com/a/29980872
rkey = (lbar, rbar) rkey = (round(lbar), round(rbar))
cached_result = self._mxmns.get(rkey) cached_result = self._mxmns.get(rkey)
do_print = 'btc' in self.name
if cached_result: if cached_result:
# if do_print:
# print(
# f'{self.name} CACHED maxmin\n'
# f'{rkey} -> {cached_result}'
# )
return cached_result return cached_result
shm = self.shm shm = self.shm
if shm is None: if shm is None:
breakpoint()
return None return None
arr = shm.array arr = shm.array
# times = arr['time']
# step = round(times[-1] - times[-2])
# if (
# do_print
# and step == 60
# ):
# breakpoint()
# get relative slice indexes into array # get relative slice indexes into array
if self.index_field == 'time': if self.index_field == 'time':
( read_slc = slice_from_time(
abs_slc,
read_slc,
) = slice_from_time(
arr, arr,
start_t=lbar, start_t=lbar,
stop_t=rbar, stop_t=rbar,
@ -306,11 +330,17 @@ class Viz(msgspec.Struct): # , frozen=True):
] ]
if not slice_view.size: if not slice_view.size:
log.warning(f'{self.name} no maxmin in view?')
# breakpoint()
return None return None
elif self.yrange: elif self.yrange:
mxmn = self.yrange mxmn = self.yrange
# print(f'{self.name} M4 maxmin: {mxmn}') if do_print:
print(
f'{self.name} M4 maxmin:\n'
f'{rkey} -> {mxmn}'
)
else: else:
if self.is_ohlc: if self.is_ohlc:
@ -323,7 +353,11 @@ class Viz(msgspec.Struct): # , frozen=True):
yhigh = np.max(view) yhigh = np.max(view)
mxmn = ylow, yhigh mxmn = ylow, yhigh
# print(f'{self.name} MANUAL maxmin: {mxmn}') if do_print:
print(
f'{self.name} MANUAL ohlc={self.is_ohlc} maxmin:\n'
f'{rkey} -> {mxmn}'
)
# cache result for input range # cache result for input range
assert mxmn assert mxmn
@ -333,8 +367,7 @@ class Viz(msgspec.Struct): # , frozen=True):
def view_range(self) -> tuple[int, int]: def view_range(self) -> tuple[int, int]:
''' '''
Return the indexes in view for the associated Return the start and stop x-indexes for the managed ``ViewBox``.
plot displaying this viz's data.
''' '''
vr = self.plot.viewRect() vr = self.plot.viewRect()
@ -345,15 +378,18 @@ class Viz(msgspec.Struct): # , frozen=True):
def bars_range(self) -> tuple[int, int, int, int]: def bars_range(self) -> tuple[int, int, int, int]:
''' '''
Return a range tuple for the bars present in view. Return a range tuple for the left-view, left-datum, right-datum
and right-view x-indices.
''' '''
start, l, datum_start, datum_stop, r, stop = self.datums_range() l, start, datum_start, datum_stop, stop, r = self.datums_range()
return l, datum_start, datum_stop, r return l, datum_start, datum_stop, r
def datums_range( def datums_range(
self, self,
view_range: None | tuple[float, float] = None,
index_field: str | None = None, index_field: str | None = None,
array: None | np.ndarray = None,
) -> tuple[ ) -> tuple[
int, int, int, int, int, int int, int, int, int, int, int
@ -362,39 +398,54 @@ class Viz(msgspec.Struct): # , frozen=True):
Return a range tuple for the datums present in view. Return a range tuple for the datums present in view.
''' '''
l, r = self.view_range() l, r = view_range or self.view_range()
index_field: str = index_field or self.index_field index_field: str = index_field or self.index_field
if index_field == 'index': if index_field == 'index':
l, r = round(l), round(r) l, r = round(l), round(r)
array = self.shm.array if array is None:
array = self.shm.array
index = array[index_field] index = array[index_field]
start = index[0] first = round(index[0])
stop = index[-1] last = round(index[-1])
# first and last datums in view determined by
# l / r view range.
leftmost = round(l)
rightmost = round(r)
# invalid view state
if ( if (
l < 0 r < l
or r < l or l < 0
or l < start or r < 0
or (l > last and r > last)
): ):
datum_start = start leftmost = first
datum_stop = stop rightmost = last
else: else:
datum_start = max(l, start) rightmost = max(
datum_stop = r min(last, rightmost),
if l < stop: first,
datum_stop = min(r, stop) )
assert datum_start < datum_stop leftmost = min(
max(first, leftmost),
last,
rightmost - 1,
)
assert leftmost < rightmost
return ( return (
start,
l, # left x-in-view l, # left x-in-view
datum_start, first, # first datum
datum_stop, leftmost,
rightmost,
last, # last_datum
r, # right-x-in-view r, # right-x-in-view
stop,
) )
def read( def read(
@ -415,6 +466,7 @@ class Viz(msgspec.Struct): # , frozen=True):
''' '''
index_field: str = index_field or self.index_field index_field: str = index_field or self.index_field
vr = l, r = self.view_range()
# readable data # readable data
array = self.shm.array array = self.shm.array
@ -423,13 +475,17 @@ class Viz(msgspec.Struct): # , frozen=True):
profiler('self.shm.array READ') profiler('self.shm.array READ')
( (
ifirst,
l, l,
ifirst,
lbar, lbar,
rbar, rbar,
r,
ilast, ilast,
) = self.datums_range(index_field=index_field) r,
) = self.datums_range(
view_range=vr,
index_field=index_field,
array=array,
)
# if rbar < lbar: # if rbar < lbar:
# breakpoint() # breakpoint()
@ -440,26 +496,22 @@ class Viz(msgspec.Struct): # , frozen=True):
# TODO: support time slicing # TODO: support time slicing
if index_field == 'time': if index_field == 'time':
( read_slc = slice_from_time(
abs_slc,
read_slc,
) = slice_from_time(
array, array,
start_t=lbar, start_t=lbar,
stop_t=rbar, stop_t=rbar,
) )
in_view = array[read_slc]
# diff = rbar - lbar # TODO: maybe we should return this from the slicer call
# if ( # above?
# 'btc' in self.name in_view = array[read_slc]
# and 'hist' not in self.shm.token if in_view.size:
# ): abs_indx = in_view['index']
# print( abs_slc = slice(
# f'{self.name}: len(iv) = {len(in_view)}\n' int(abs_indx[0]),
# f'start/stop: {lbar},{rbar}\n', int(abs_indx[-1]),
# f'diff: {diff}\n', )
# )
if profiler: if profiler:
profiler( profiler(
'`slice_from_time(' '`slice_from_time('
@ -635,8 +687,6 @@ class Viz(msgspec.Struct): # , frozen=True):
should_redraw = True should_redraw = True
showing_src_data = True showing_src_data = True
# reset yrange to be computed from source data
self.yrange = None
# MAIN RENDER LOGIC: # MAIN RENDER LOGIC:
# - determine in view data and redraw on range change # - determine in view data and redraw on range change
@ -662,10 +712,6 @@ class Viz(msgspec.Struct): # , frozen=True):
**rkwargs, **rkwargs,
) )
if showing_src_data:
# print(f"{self.name} SHOWING SOURCE")
# reset yrange to be computed from source data
self.yrange = None
if not out: if not out:
log.warning(f'{self.name} failed to render!?') log.warning(f'{self.name} failed to render!?')
@ -678,7 +724,6 @@ class Viz(msgspec.Struct): # , frozen=True):
# XXX: SUPER UGGGHHH... without this we get stale cache # XXX: SUPER UGGGHHH... without this we get stale cache
# graphics that don't update until you downsampler again.. # graphics that don't update until you downsampler again..
# reset = False
# if reset: # if reset:
# with graphics.reset_cache(): # with graphics.reset_cache():
# # assign output paths to graphicis obj # # assign output paths to graphicis obj
@ -798,6 +843,129 @@ class Viz(msgspec.Struct): # , frozen=True):
) )
).length() ).length()
def default_view(
self,
bars_from_y: int = int(616 * 3/8),
y_offset: int = 0,
do_ds: bool = True,
) -> None:
'''
Set the plot's viewbox to a "default" startup setting where
we try to show the underlying data range sanely.
'''
shm: ShmArray = self.shm
array: np.ndarray = shm.array
view: ChartView = self.plot.vb
(
vl,
first_datum,
datum_start,
datum_stop,
last_datum,
vr,
) = self.datums_range(array=array)
# invalid case: view is not ordered correctly
# return and expect caller to sort it out.
if (
vl > vr
):
log.warning(
'Skipping `.default_view()` viewbox not initialized..\n'
f'l -> r: {vl} -> {vr}\n'
f'datum_start -> datum_stop: {datum_start} -> {datum_stop}\n'
)
return
chartw: ChartPlotWidget = self.plot.getViewWidget()
index_field = self.index_field
step = self.index_step()
if index_field == 'time':
# transform l -> r view range values into
# data index domain to determine how view
# should be reset to better match data.
read_slc = slice_from_time(
array,
start_t=vl,
stop_t=vr,
step=step,
)
index_iv = array[index_field][read_slc]
uppx: float = self.graphics.x_uppx() or 1
# l->r distance in scene units, no larger then data span
data_diff = last_datum - first_datum
rl_diff = min(vr - vl, data_diff)
# orient by offset from the y-axis including
# space to compensate for the L1 labels.
if not y_offset:
# we get the L1 spread label "length" in view coords and
# make sure it doesn't colide with the right-most datum in
# view.
_, l1_len = chartw.pre_l1_xs()
offset = l1_len/(uppx*step)
# if no L1 label is present just offset by a few datums
# from the y-axis.
if chartw._max_l1_line_len == 0:
offset += 3*step
else:
offset = (y_offset * step) + uppx*step
# align right side of view to the rightmost datum + the selected
# offset from above.
r_reset = last_datum + offset
# no data is in view so check for the only 2 sane cases:
# - entire view is LEFT of data
# - entire view is RIGHT of data
if index_iv.size == 0:
log.warning(f'No data in view for {vl} -> {vr}')
# 2 cases either the view is to the left or right of the
# data set.
if (
vl <= first_datum
and vr <= first_datum
):
l_reset = first_datum
elif (
vl >= last_datum
and vr >= last_datum
):
l_reset = r_reset - rl_diff
else:
raise RuntimeError(f'Unknown view state {vl} -> {vr}')
else:
# maintain the l->r view distance
l_reset = r_reset - rl_diff
# remove any custom user yrange setttings
if chartw._static_yrange == 'axis':
chartw._static_yrange = None
view.setXRange(
min=l_reset,
max=r_reset,
padding=0,
)
if do_ds:
view.maybe_downsample_graphics()
view._set_yrange()
# caller should do this!
# self.linked.graphics_cycle()
class Renderer(msgspec.Struct): class Renderer(msgspec.Struct):
@ -959,6 +1127,8 @@ class Renderer(msgspec.Struct):
fast_path = self.fast_path fast_path = self.fast_path
reset = False reset = False
self.viz.yrange = None
# redraw the entire source data if we have either of: # redraw the entire source data if we have either of:
# - no prior path graphic rendered or, # - no prior path graphic rendered or,
# - we always intend to re-render the data only in view # - we always intend to re-render the data only in view