diff --git a/piker/tsp/_annotate.py b/piker/tsp/_annotate.py index 797c38cf..70344b66 100644 --- a/piker/tsp/_annotate.py +++ b/piker/tsp/_annotate.py @@ -50,7 +50,6 @@ async def markup_gaps( ''' aids: dict[int] = {} for i in range(gaps.height): - row: pl.DataFrame = gaps[i] # the gap's RIGHT-most bar's OPEN value @@ -113,7 +112,6 @@ async def markup_gaps( await tractor.pause() istart: int = prev_r['index'][0] - # TODO: implement px-col width measure # and ensure at least as many px-cols # shown per rect as configured by user. @@ -125,25 +123,25 @@ async def markup_gaps( rect_gap: float = BGM*3/8 opn: float = row['open'][0] + cls: float = prev_r['close'][0] ro: tuple[float, float] = ( - # dt_end_t, iend + rect_gap + 1, opn, ) - cls: float = prev_r['close'][0] lc: tuple[float, float] = ( - # dt_start_t, istart - rect_gap, # + 1 , cls, ) - color: str = 'dad_blue' diff: float = cls - opn sgn: float = copysign(1, diff) - color: str = { - -1: 'buy_green', - 1: 'sell_red', - }[sgn] + + color: str = 'dad_blue' + # TODO? mks more sense to have up/down coloring? + # color: str = { + # -1: 'lilypad_green', # up-gap + # 1: 'wine', # down-gap + # }[sgn] rect_kwargs: dict[str, Any] = dict( fqme=fqme, @@ -153,9 +151,27 @@ async def markup_gaps( color=color, ) + # add up/down rects aid: int = await actl.add_rect(**rect_kwargs) assert aid aids[aid] = rect_kwargs + direction: str = ( + 'down' if sgn == 1 + else 'up' + ) + arrow_kwargs: dict[str, Any] = dict( + fqme=fqme, + timeframe=timeframe, + x=iend, + y=cls, + color=color, + alpha=160, + pointing=direction, + ) + + aid: int = await actl.add_arrow( + **arrow_kwargs + ) # tell chart to redraw all its # graphics view layers Bo diff --git a/piker/ui/_remote_ctl.py b/piker/ui/_remote_ctl.py index 05e145e7..42f8a9b7 100644 --- a/piker/ui/_remote_ctl.py +++ b/piker/ui/_remote_ctl.py @@ -27,10 +27,12 @@ from contextlib import ( from functools import partial from pprint import pformat from typing import ( - # Any, AsyncContextManager, + Literal, ) +from uuid import uuid4 +import pyqtgraph as pg import tractor import trio from tractor import trionics @@ -49,11 +51,13 @@ from piker.ui.qt import ( ) from ._display import DisplayState from ._interaction import ChartView -from ._editors import SelectRect +from ._editors import ( + SelectRect, + ArrowEditor, +) from ._chart import ChartPlotWidget from ._dataviz import Viz - log = get_logger(__name__) # NOTE: this is UPDATED by the `._display.graphics_update_loop()` @@ -83,8 +87,34 @@ _ctxs: IpcCtxTable = {} # the "annotations server" which actually renders to a Qt canvas). # type AnnotsTable = dict[int, QGraphicsItem] AnnotsTable = dict[int, QGraphicsItem] +EditorsTable = dict[int, ArrowEditor] _annots: AnnotsTable = {} +_editors: EditorsTable = {} + +def rm_annot( + annot: ArrowEditor|SelectRect +) -> bool: + global _editors + match annot: + case pg.ArrowItem(): + editor = _editors[annot._uid] + editor.remove(annot) + # ^TODO? only remove each arrow or all? + # if editor._arrows: + # editor.remove_all() + # else: + # log.warning( + # f'Annot already removed!\n' + # f'{annot!r}\n' + # ) + return True + + case SelectRect(): + annot.delete() + return True + + return False async def serve_rc_annots( @@ -95,6 +125,12 @@ async def serve_rc_annots( annots: AnnotsTable, ) -> None: + ''' + A small viz(ualization) server for remote ctl of chart + annotations. + + ''' + global _editors async for msg in annot_req_stream: match msg: case { @@ -104,7 +140,6 @@ async def serve_rc_annots( 'meth': str(meth), 'kwargs': dict(kwargs), }: - ds: DisplayState = _dss[fqme] chart: ChartPlotWidget = { 60: ds.hist_chart, @@ -136,15 +171,67 @@ async def serve_rc_annots( aids.add(aid) await annot_req_stream.send(aid) + case { + 'cmd': 'ArrowEditor', + 'fqme': fqme, + 'timeframe': timeframe, + 'meth': 'add'|'remove' as meth, + 'kwargs': { + 'x': float(x), + 'y': float(y), + 'pointing': pointing, + 'color': color, + 'aid': str()|None as aid, + 'alpha': int(alpha), + }, + # ?TODO? split based on method fn-sigs? + # 'pointing', + }: + ds: DisplayState = _dss[fqme] + chart: ChartPlotWidget = { + 60: ds.hist_chart, + 1: ds.chart, + }[timeframe] + cv: ChartView = chart.cv + godw = chart.linked.godwidget + + arrows = ArrowEditor(godw=godw) + # `.add/.remove()` API + if meth != 'add': + # await tractor.pause() + raise ValueError( + f'Invalid arrow-edit request ?\n' + f'{msg!r}\n' + ) + + aid: str = str(uuid4()) + arrow: pg.ArrowItem = arrows.add( + plot=chart.plotItem, + uid=aid, + x=x, + y=y, + pointing=pointing, + color=color, + alpha=alpha, + ) + annots[aid] = arrow + _editors[aid] = arrows + aids: set[int] = ctxs[ipc_key][1] + aids.add(aid) + await annot_req_stream.send(aid) + + # TODO, use `pg.TextItem` to put a humaized + # time label beside the arrows + case { 'cmd': 'remove', - 'aid': int(aid), + 'aid': int(aid)|str(aid), }: # NOTE: this is normally entered on # a client's annotation de-alloc normally # prior to detach or modify. annot: QGraphicsItem = annots[aid] - annot.delete() + assert rm_annot(annot) # respond to client indicating annot # was indeed deleted. @@ -188,6 +275,12 @@ async def remote_annotate( ) -> None: global _dss, _ctxs + if not _dss: + raise RuntimeError( + 'Race condition on chart-init state ??\n' + 'Anoter actor is trying to annoate this chart ' + 'before it has fully spawned.\n' + ) assert _dss _ctxs[ctx.cid] = (ctx, set()) @@ -212,7 +305,7 @@ async def remote_annotate( assert _ctx is ctx for aid in aids: annot: QGraphicsItem = _annots[aid] - annot.delete() + assert rm_annot(annot) class AnnotCtl(Struct): @@ -334,20 +427,55 @@ class AnnotCtl(Struct): 'timeframe': timeframe, }) - # TODO: do we even need this? - # async def modify( - # self, - # aid: int, # annotation id - # meth: str, # far end graphics object method to invoke - # params: dict[str, Any], # far end `meth(**kwargs)` - # ) -> bool: - # ''' - # Modify an existing (remote) annotation's graphics - # paramters, thus changing it's appearance / state in real - # time. + async def add_arrow( + self, + fqme: str, + timeframe: float, + x: float, + y: float, + pointing: Literal[ + 'up', + 'down', + ], + # TODO: a `Literal['view', 'scene']` for this? + # domain: str = 'view', # or 'scene' + color: str = 'dad_blue', + alpha: int = 116, - # ''' - # raise NotImplementedError + from_acm: bool = False, + + ) -> int: + ''' + Add a `SelectRect` annotation to the target view, return + the instances `id(obj)` from the remote UI actor. + + ''' + ipc: MsgStream = self._get_ipc(fqme) + await ipc.send({ + 'fqme': fqme, + 'cmd': 'ArrowEditor', + 'timeframe': timeframe, + # 'meth': str(meth), + 'meth': 'add', + 'kwargs': { + 'x': float(x), + 'y': float(y), + 'color': color, + 'pointing': pointing, # up|down + 'alpha': alpha, + 'aid': None, + }, + }) + aid: int = await ipc.receive() + self._ipcs[aid] = ipc + if not from_acm: + self._annot_stack.push_async_callback( + partial( + self.remove, + aid, + ) + ) + return aid @acm @@ -374,7 +502,9 @@ async def open_annot_ctl( # TODO: print the current discoverable actor UID set # here as well? if not maybe_portals: - raise RuntimeError('No chart UI actors found in service domain?') + raise RuntimeError( + 'No chart actors found in service domain?' + ) for portal in maybe_portals: ctx_mngrs.append(