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
multiaddrs
Gud Boi 2026-01-27 20:51:21 -05:00
parent 1776242413
commit de5b1737b4
2 changed files with 153 additions and 3 deletions

View File

@ -35,6 +35,51 @@ if TYPE_CHECKING:
from piker.ui._remote_ctl import AnnotCtl 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( async def markup_gaps(
fqme: str, fqme: str,
timeframe: float, timeframe: float,
@ -124,6 +169,10 @@ async def markup_gaps(
opn: float = row['open'][0] opn: float = row['open'][0]
cls: float = prev_r['close'][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 # BGM=0.16 is the normal diff from overlap between bars, SO
# just go slightly "in" from that "between them". # just go slightly "in" from that "between them".
from_idx: int = BGM - .06 # = .10 from_idx: int = BGM - .06 # = .10
@ -181,6 +230,23 @@ async def markup_gaps(
**arrow_kwargs **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 # tell chart to redraw all its
# graphics view layers Bo # graphics view layers Bo
await actl.redraw( await actl.redraw(

View File

@ -48,6 +48,7 @@ from piker.service import find_service
from piker.brokers import SymbolNotFound from piker.brokers import SymbolNotFound
from piker.ui.qt import ( from piker.ui.qt import (
QGraphicsItem, QGraphicsItem,
QColor,
) )
from ._display import DisplayState from ._display import DisplayState
from ._interaction import ChartView from ._interaction import ChartView
@ -57,6 +58,7 @@ from ._editors import (
) )
from ._chart import ChartPlotWidget from ._chart import ChartPlotWidget
from ._dataviz import Viz from ._dataviz import Viz
from ._style import hcolor
log = get_logger(__name__) log = get_logger(__name__)
@ -93,7 +95,7 @@ _annots: AnnotsTable = {}
_editors: EditorsTable = {} _editors: EditorsTable = {}
def rm_annot( def rm_annot(
annot: ArrowEditor|SelectRect annot: ArrowEditor|SelectRect|pg.TextItem
) -> bool: ) -> bool:
global _editors global _editors
match annot: match annot:
@ -114,6 +116,12 @@ def rm_annot(
annot.delete() annot.delete()
return True return True
case pg.TextItem():
scene = annot.scene()
if scene:
scene.removeItem(annot)
return True
return False return False
@ -230,8 +238,41 @@ async def serve_rc_annots(
aids.add(aid) aids.add(aid)
await annot_req_stream.send(aid) await annot_req_stream.send(aid)
# TODO, use `pg.TextItem` to put a humaized case {
# time label beside the arrows '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 { case {
'cmd': 'remove', 'cmd': 'remove',
@ -497,6 +538,49 @@ class AnnotCtl(Struct):
) )
return aid 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 @acm
async def open_annot_ctl( async def open_annot_ctl(