From de5b1737b4d66397bbcec40867d3666be978d6f9 Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 27 Jan 2026 20:51:21 -0500 Subject: [PATCH] Add humanized duration labels to gap annotations Introduce `humanize_duration()` helper in `.tsp._annotate` to convert seconds to short human-readable format (d/h/m/s). Extend annot-ctl API with `add_text()` method for placing `pg.TextItem` labels on charts. Also, - add duration labels on RHS of gap arrows in `markup_gaps()` - handle text item removal in `rm_annot()` match block - expose `TextItem` cmd in `serve_rc_annots()` IPC handler - use `hcolor()` for named-to-hex color conversion - set anchor positioning for up vs down gaps (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- piker/tsp/_annotate.py | 66 ++++++++++++++++++++++++++++++ piker/ui/_remote_ctl.py | 90 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/piker/tsp/_annotate.py b/piker/tsp/_annotate.py index f333d4a4..fa9f04c3 100644 --- a/piker/tsp/_annotate.py +++ b/piker/tsp/_annotate.py @@ -35,6 +35,51 @@ if TYPE_CHECKING: from piker.ui._remote_ctl import AnnotCtl +def humanize_duration( + seconds: float, +) -> str: + ''' + Convert duration in seconds to short human-readable form. + + Uses smallest appropriate time unit: + - d: days + - h: hours + - m: minutes + - s: seconds + + Examples: + - 86400 -> "1d" + - 28800 -> "8h" + - 180 -> "3m" + - 45 -> "45s" + + ''' + abs_secs: float = abs(seconds) + + if abs_secs >= 86400: + days: float = abs_secs / 86400 + if days >= 10: + return f'{int(days)}d' + return f'{days:.1f}d' + + elif abs_secs >= 3600: + hours: float = abs_secs / 3600 + if hours >= 10: + return f'{int(hours)}h' + return f'{hours:.1f}h' + + elif abs_secs >= 60: + mins: float = abs_secs / 60 + if mins >= 10: + return f'{int(mins)}m' + return f'{mins:.1f}m' + + else: + if abs_secs >= 10: + return f'{int(abs_secs)}s' + return f'{abs_secs:.1f}s' + + async def markup_gaps( fqme: str, timeframe: float, @@ -124,6 +169,10 @@ async def markup_gaps( opn: float = row['open'][0] cls: float = prev_r['close'][0] + # get gap duration for humanized label + gap_dur_s: float = row['s_diff'][0] + gap_label: str = humanize_duration(gap_dur_s) + # BGM=0.16 is the normal diff from overlap between bars, SO # just go slightly "in" from that "between them". from_idx: int = BGM - .06 # = .10 @@ -181,6 +230,23 @@ async def markup_gaps( **arrow_kwargs ) + # add duration label to RHS of arrow + if sgn == -1: # up-gap + anchor = (0, 0) # XXX, i dun get dese dims.. XD + else: # down-gap + anchor = (0, 1) # XXX y, x? + + text_aid: int = await actl.add_text( + fqme=fqme, + timeframe=timeframe, + text=gap_label, + x=iend + 1, + y=cls, + color=color, + anchor=anchor, + ) + aids[text_aid] = {'text': gap_label} + # tell chart to redraw all its # graphics view layers Bo await actl.redraw( diff --git a/piker/ui/_remote_ctl.py b/piker/ui/_remote_ctl.py index c1153e2b..ccea90e1 100644 --- a/piker/ui/_remote_ctl.py +++ b/piker/ui/_remote_ctl.py @@ -48,6 +48,7 @@ from piker.service import find_service from piker.brokers import SymbolNotFound from piker.ui.qt import ( QGraphicsItem, + QColor, ) from ._display import DisplayState from ._interaction import ChartView @@ -57,6 +58,7 @@ from ._editors import ( ) from ._chart import ChartPlotWidget from ._dataviz import Viz +from ._style import hcolor log = get_logger(__name__) @@ -93,7 +95,7 @@ _annots: AnnotsTable = {} _editors: EditorsTable = {} def rm_annot( - annot: ArrowEditor|SelectRect + annot: ArrowEditor|SelectRect|pg.TextItem ) -> bool: global _editors match annot: @@ -114,6 +116,12 @@ def rm_annot( annot.delete() return True + case pg.TextItem(): + scene = annot.scene() + if scene: + scene.removeItem(annot) + return True + return False @@ -230,8 +238,41 @@ async def serve_rc_annots( aids.add(aid) await annot_req_stream.send(aid) - # TODO, use `pg.TextItem` to put a humaized - # time label beside the arrows + case { + 'cmd': 'TextItem', + 'fqme': fqme, + 'timeframe': timeframe, + 'kwargs': { + 'text': str(text), + 'x': int()|float() as x, + 'y': int()|float() as y, + 'color': color, + 'anchor': list(anchor), + }, + }: + ds: DisplayState = _dss[fqme] + chart: ChartPlotWidget = { + 60: ds.hist_chart, + 1: ds.chart, + }[timeframe] + + # convert named color to hex + color_hex: str = hcolor(color) + + # create text item + text_item: pg.TextItem = pg.TextItem( + text=text, + color=color_hex, + anchor=anchor, + ) + text_item.setPos(x, y) + chart.plotItem.addItem(text_item) + + aid: str = str(uuid4()) + annots[aid] = text_item + aids: set[int] = ctxs[ipc_key][1] + aids.add(aid) + await annot_req_stream.send(aid) case { 'cmd': 'remove', @@ -497,6 +538,49 @@ class AnnotCtl(Struct): ) return aid + async def add_text( + self, + fqme: str, + timeframe: float, + text: str, + x: float, + y: float, + color: str|tuple = 'dad_blue', + anchor: tuple[float, float] = (0, 1), + + from_acm: bool = False, + + ) -> int: + ''' + Add a `pg.TextItem` annotation to the target view. + + anchor: (x, y) where (0,0) is upper-left, (1,1) is lower-right + + ''' + ipc: MsgStream = self._get_ipc(fqme) + await ipc.send({ + 'fqme': fqme, + 'cmd': 'TextItem', + 'timeframe': timeframe, + 'kwargs': { + 'text': text, + 'x': float(x), + 'y': float(y), + 'color': color, + 'anchor': tuple(anchor), + }, + }) + 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 async def open_annot_ctl(