prototype: graph encoding variants for hourly history decision (wayfinder #68)

This commit is contained in:
xavierk
2026-09-11 11:08:59 +05:30
parent f406285a0f
commit c28991678b
12 changed files with 2071 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# PROTOTYPE — graph encoding & hourly drill-down (throwaway)
Answers wayfinder ticket **Choose daily graph encoding and hourly drill-down**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
Which graph encoding best communicates hourly writes and a day's usage
without misleading: daily bars vs one candle per day vs rolling hourly strip,
with range -> selected day -> hourly detail -> back navigation.
## Run
./run # or: python3 tui_prototype.py
## Variants (switch with [ and ])
- **A — Daily bars**: one bar per local display day (bytes written), drill
into 24 hourly bars with Enter / click.
- **B — Daily candles**: one candle per day derived from hourly values
(open = first evidenced hour, close = last, high/low = max/min hourly).
Same drill-down.
- **C — Rolling hourly strip**: continuous last-72-hours columns with day
separators; click an hour for its readout (no day level).
## Data states encoded (synthetic)
measured zero (0 B) · gap / no evidence · unallocated usage (measured,
attribution unknown) · partial day ("so far") · deliberate-disable pause band ·
day before monitoring began.
Gaps never render as zero. Unallocated is a separate visual segment, never
spread into hours.
## Framework facts verified
Textual 8.2.8 (installed): no BarChart widget (roadmap-only), Sparkline is
non-interactive. Bars/candles here are a custom Static renderable; mouse
clicks map via widget-local x. No new dependencies.
## Screenshots
screenshots/ holds SVG exports at 80x24 and 140x40 per variant.
Regenerate with: python3 capture.py
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
# PROTOTYPE helper — regenerate screenshots/*.svg via headless Textual run.
import asyncio
from tui_prototype import GraphPrototype
async def shot(app, size, presses, path):
async with app.run_test(size=size) as pilot:
for p in presses:
await pilot.press(p)
svg = app.export_screenshot()
with open(path, "w") as fh:
fh.write(svg)
print("wrote", path)
async def main():
for w, h in ((80, 24), (140, 40)):
for v in "ABC":
presses = ["]"] * "ABC".index(v)
await shot(GraphPrototype(), (w, h), presses,
"screenshots/v%s_%dx%d_range.svg" % (v, w, h))
# drill-down detail (variant A) and a mid-range selection (variant B)
await shot(GraphPrototype(), (80, 24), ["left", "enter"],
"screenshots/vA_80x24_hourly-detail.svg")
await shot(GraphPrototype(), (140, 40), ["]", "left", "left"],
"screenshots/vB_140x40_selected-gap-day.svg")
if __name__ == "__main__":
asyncio.run(main())
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# PROTOTYPE launcher
exec python3 "$(dirname "$0")/tui_prototype.py" "$@"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 78 KiB

+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env python3
# PROTOTYPE — throwaway. Wayfinder ticket "Choose daily graph encoding and
# hourly drill-down" on map "Fenris TUI polish and hourly history".
# Synthetic data only. Not production code; no tests by design.
#
# Variants (switch with [ / ]):
# A daily bars (day -> Enter -> hourly bars -> Esc back)
# B daily candles (OHLC derived from hourly values, same drill-down)
# C rolling 72-hour strip with day separators (no day level)
#
# Encoding contract under test (accepted hourly-history decision):
# measured zero = 0 B, distinct from gap (no evidence), distinct from
# unallocated usage (measured total, hour attribution unknown).
from __future__ import annotations
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import Static
# ---------------------------------------------------------------------------
# Synthetic evidence
# ---------------------------------------------------------------------------
@dataclass
class Hour:
gb: float = 0.0 # measured bytes written; 0.0 == measured zero
coverage: float = 1.0 # share of hour classified known
@dataclass
class Day:
hours: list = field(default_factory=list) # list[Hour | None]; None = gap
unallocated: float = 0.0 # GB measured, attribution unknown
elapsed_h: float = 24.0 # elapsed span (partial < 24)
note: str = ""
pause: str = "" # deliberate-disable band
@property
def evidenced(self):
return [h for h in self.hours if h is not None]
@property
def allocated_gb(self):
return sum(h.gb for h in self.evidenced)
@property
def gap_hours(self):
return sum(1 for h in self.hours if h is None)
def _profile(mult=1.0, zero=False):
shape = [0.3, 0.2, 0.1, 0.1, 0.2, 0.5, 1.4, 2.6, 3.1, 2.2,
1.8, 2.0, 2.4, 3.0, 3.4, 2.8, 2.1, 1.9, 2.3, 1.6,
1.1, 0.8, 0.5, 0.4]
return [Hour(0.0 if zero else round(v * mult, 2), 1.0) for v in shape]
def synth_days(now):
"""14 local display days ending today (partial). Every ticket state present."""
days = []
days.append(Day([None] * 24, note="no evidence — before monitoring began"))
days.append(Day(_profile(1.0)))
g = _profile(0.9)
for i in range(10, 16):
g[i] = None
days.append(Day(g, note="gap 10:00–16:00 (no evidence, not zero)"))
days.append(Day(_profile(0.0), note="measured zero day"))
days.append(Day(_profile(1.2), unallocated=8.5,
note="8.5 GB unallocated (interval attribution unknown)"))
days.append(Day(_profile(4.0), note="heavy write day"))
days.append(Day(_profile(0.7),
pause="paused 09:00–11:00 (deliberate disable — excluded)"))
days.append(Day(_profile(1.1)))
days.append(Day(_profile(0.9)))
m = _profile(1.0)
m[3] = Hour(0.0, 1.0)
m[17] = None
days.append(Day(m, note="zero hour 03:00, gap hour 17:00"))
days.append(Day(_profile(1.3), unallocated=1.9))
days.append(Day(_profile(1.0)))
days.append(Day(_profile(0.8)))
days.append(Day(_profile(1.5)[:14], elapsed_h=14,
note="today — 14 h elapsed, so far"))
assert len(days) == 14
return days
# ---------------------------------------------------------------------------
# Rendering (Rich markup into Static)
# ---------------------------------------------------------------------------
AMBER = "#e0a458" # default-preset graph colour (approved input)
UNALLOC = "#8a7f70" # measured, attribution unknown
GAP = "#5f6b73"
ZERO = "#9bb0bf"
SELECT = "#f2d5a0"
FILL = "█"
EIGHTHS = " ▁▂▃▄▅▆▇█"
def _fmt_gb(v):
if v >= 100:
return "%d GB" % v
return "%s GB" % ("%.1f" % v if v < 10 else "%.0f" % v)
def render_days_bars(days, height, sel):
"""Variant A: one bar per day. Unallocated stacked on top, visually separate."""
n = len(days)
mx = max((d.allocated_gb + d.unallocated) for d in days) or 1.0
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, d in enumerate(days):
if not d.evidenced and not d.unallocated:
for r in range(height):
grid[r][i], style[r][i] = "·", GAP
continue
rows = []
ua_cells = d.unallocated / mx * height
for _ in range(int(ua_cells + 1e-9)):
rows.append(("▒", UNALLOC))
if ua_cells - int(ua_cells) > 0.12:
rows.append((EIGHTHS[int((ua_cells % 1) * 8)], UNALLOC))
cells = (d.allocated_gb + d.unallocated) / mx * height
for _ in range(int(cells + 1e-9)):
rows.append((FILL, AMBER))
if cells - int(cells) > 0.12:
rows.append((EIGHTHS[int((cells % 1) * 8)], AMBER))
if not rows:
rows = [("·", ZERO)]
rr = height - 1
for ch, st in rows:
if rr < 0:
break
grid[rr][i], style[rr][i] = ch, st
rr -= 1
if d.elapsed_h < 24:
rr = height - len(rows)
if 0 <= rr < height:
grid[rr][i], style[rr][i] = "┄", SELECT
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
parts.append(" ")
lines.append("".join(parts).rstrip())
marks = [" "] * (2 * n)
marks[2 * sel] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_days_candles(days, height, sel):
"""Variant B: one candle per day. open = first evidenced hour, close = last,
high/low = max/min hourly GB. Body solid when close >= open, hollow otherwise;
partial day renders dashed. 3-wide slot + 1 space."""
n = len(days)
stats = []
for d in days:
ev = [h.gb for h in d.evidenced]
stats.append(None if not ev else
dict(open=ev[0], close=ev[-1], hi=max(ev), lo=min(ev)))
mx = max((s["hi"] for s in stats if s), default=1.0) or 1.0
def row(v):
return height - 1 - min(int(v / mx * (height - 1)), height - 1)
cols = [] # per day: list[height] of 3-char cell strings + styles
for i, s in enumerate(stats):
d = days[i]
if s is None:
cols.append([("╎╎╎", GAP)] * height)
continue
top, bot = row(s["hi"]), row(s["lo"])
body_hi, body_lo = row(max(s["open"], s["close"])), row(min(s["open"], s["close"]))
solid = s["close"] >= s["open"]
partial = d.elapsed_h < 24
body_fill = "▒" if partial else (FILL if solid else "░")
body_st = AMBER if (solid or partial) else GAP
col = []
for r in range(height):
if body_lo <= r <= body_hi:
col.append((body_fill * 3, body_st))
elif top <= r <= bot:
col.append((" │ ", AMBER if solid else GAP))
else:
col.append((" ", None))
cols.append(col)
lines = [(" " * (4 * sel)) + "▼"]
for r in range(height):
parts = []
for i in range(n):
cells, st = cols[i][r]
parts.append(cells if st is None else "[%s]%s[/]" % (st, cells))
parts.append(" ")
lines.append("".join(parts).rstrip())
return lines
def render_hours_bars(day, height, sel):
"""Day detail: one bar per hour. Gaps never zero."""
n = len(day.hours)
mx = max([h.gb for h in day.evidenced] + [day.unallocated, 1e-9])
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, h in enumerate(day.hours):
if h is None:
grid[height - 1][i], style[height - 1][i] = "░", GAP
continue
if h.gb == 0:
grid[height - 1][i], style[height - 1][i] = "·", ZERO
continue
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
grid[rr][i], style[rr][i] = FILL, AMBER
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
grid[rr][i], style[rr][i] = EIGHTHS[int((cells % 1) * 8)], AMBER
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = [" "] * n
marks[sel % n] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_strip(days, height, sel):
"""Variant C: rolling hourly strip, last 72 h, day separators."""
flat = [] # (day_idx, hour_idx, Hour|None|'future')
for di, d in enumerate(days[-4:]):
real = len(days) - 4 + di
for hi in range(24):
h = d.hours[hi] if hi < len(d.hours) else "future"
flat.append((real, hi, h))
flat = flat[-72:]
mx = max([f[2].gb for f in flat if f[2] not in (None, "future")] + [1e-9])
cols = []
for _, _, h in flat:
col = [(" ", None)] * height
if h == "future":
col[height - 1] = ("·", None)
elif h is None:
col[height - 1] = ("░", GAP)
elif h.gb == 0:
col[height - 1] = ("·", ZERO)
else:
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
col[rr] = (FILL, AMBER)
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
col[rr] = (EIGHTHS[int((cells % 1) * 8)], AMBER)
cols.append(col)
lines = []
for r in range(height):
parts = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
parts.append("[%s]│[/]" % SELECT)
prev = f[0]
ch, st = cols[i][r]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
marks.append(" ")
prev = f[0]
marks.append("▼" if i == sel else " ")
lines.insert(0, "".join(marks).rstrip())
return lines, flat
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
VARIANT_NAMES = {"A": "Daily bars", "B": "Daily candles", "C": "Rolling 72 h strip"}
GH = 10 # graph rows
class GraphPrototype(App):
TITLE = "PROTOTYPE — usage-history graph encoding"
CSS = """
#col { height: 100%; }
#hdr { height: 2; color: $text-muted; }
#graph { height: auto; padding: 0 1; }
#readout { height: auto; padding: 0 1; }
#legend { height: 3; padding: 0 1; color: $text-muted; }
#footer { dock: bottom; height: 2; background: $panel; padding: 0 1; }
"""
BINDINGS = [
Binding("[", "prev_variant", "variant ←"),
Binding("]", "next_variant", "variant →"),
Binding("left", "left", "←"),
Binding("right", "right", "→"),
Binding("enter", "drill", "open"),
Binding("escape,backspace", "back", "back"),
Binding("q", "quit", "quit"),
]
def __init__(self):
super().__init__()
self.now = datetime.now().astimezone()
self.days = synth_days(self.now)
self.variant = "A"
self.view = "days" # days | hours (A/B); strip (C)
self.sel_day = len(self.days) - 1
self.sel_hour = 13
self.sel_strip = 71
def day_label(self, i):
d = (self.now - timedelta(days=len(self.days) - 1 - i)).date()
return d, d.strftime("%a %d %b")
def tz_label(self):
off = self.now.strftime("%z")
return "Local · UTC%s · %s" % (off[:3] + ":" + off[3:], time.tzname[0])
def day_readout(self, d, i):
_, lab = self.day_label(i)
if not d.evidenced and not d.unallocated:
return "%s — no evidence (not zero): %s" % (lab, d.note or "")
bits = ["%s — %s written" % (lab, _fmt_gb(d.allocated_gb))]
span = 24 if d.elapsed_h == 24 else len(d.hours)
bits.append("%d/%d h evidenced" % (span - d.gap_hours, span))
if d.unallocated:
bits.append("+ %s unallocated" % _fmt_gb(d.unallocated))
if d.elapsed_h < 24:
bits.append("partial · %d h elapsed · so far" % int(d.elapsed_h))
cov = sum(h.coverage for h in d.evidenced) / max(len(d.hours), 1)
bits.append("coverage %.0f%%" % (100 * cov))
txt = " · ".join(bits)
if d.note:
txt += " — %s" % d.note
if d.pause:
txt += " — %s" % d.pause
return txt
def compose(self) -> ComposeResult:
with Vertical(id="col"):
yield Static("", id="hdr")
yield Static("", id="graph")
yield Static("", id="readout")
yield Static("", id="legend")
yield Static("", id="footer")
def on_mount(self) -> None:
self.refresh_all()
def refresh_all(self):
self.query_one("#hdr", Static).update(
"🐺 Fenris · usage history · %s · writes per hour" % self.tz_label())
g = self.query_one("#graph", Static)
ro = self.query_one("#readout", Static)
if self.view == "hours":
d = self.days[self.sel_day]
lines = render_hours_bars(d, GH, self.sel_hour)
lines.append("".join(("%02d" % i) if i % 3 == 0 else " " for i in range(len(d.hours))))
lines.append(" midnight → 23:00 local")
g.update("\n".join(lines))
h = d.hours[self.sel_hour]
if h is None:
dtxt = "hour %02d:00 — no evidence (not zero)" % self.sel_hour
elif h.gb == 0:
dtxt = "hour %02d:00 — 0 B measured" % self.sel_hour
else:
dtxt = "hour %02d:00 — %s written · coverage %.0f%%" % (
self.sel_hour, _fmt_gb(h.gb), 100 * h.coverage)
_, lab = self.day_label(self.sel_day)
extra = ""
if d.unallocated:
extra = " · %s unallocated (shown separately, never spread into hours)" % _fmt_gb(d.unallocated)
ro.update("%s ▸ %s%s" % (lab, dtxt, extra))
elif self.variant in ("A", "B"):
lines = (render_days_bars(self.days, GH, self.sel_day) if self.variant == "A"
else render_days_candles(self.days, GH, self.sel_day))
_, last = self.day_label(len(self.days) - 1)
lines.append(" " + " ".join(
self.day_label(i)[1].split()[1] for i in range(len(self.days))))
g.update("\n".join(lines))
ro.update(self.day_readout(self.days[self.sel_day], self.sel_day))
else:
lines, flat = render_strip(self.days, GH, self.sel_strip)
g.update("\n".join(lines))
txt = ""
if 0 <= self.sel_strip < len(flat):
di, hi, h = flat[self.sel_strip]
_, lab = self.day_label(di)
if h == "future":
txt = "future — not counted"
elif h is None:
txt = "no evidence (not zero)"
elif h.gb == 0:
txt = "0 B measured"
else:
txt = "%s written · coverage %.0f%%" % (_fmt_gb(h.gb), 100 * h.coverage)
ro.update("%s %02d:00 — %s" % (lab, hi, txt))
self.query_one("#legend", Static).update(
"legend: [%(a)s]█ allocated[/] [%(u)s]▒ unallocated (attribution unknown)[/] "
"[%(g)s]░ gap · no evidence — never zero[/] [%(z)s]· measured 0 B[/] "
"[%(s)s]┄ partial day (so far)[/]" % dict(a=AMBER, u=UNALLOC, g=GAP, z=ZERO, s=SELECT))
v = "%s: %s" % (self.variant, VARIANT_NAMES[self.variant])
mode = "hourly strip" if self.variant == "C" else (
"day detail" if self.view == "hours" else "range view")
self.query_one("#footer", Static).update(
"PROTOTYPE ▸ %s ▸ %s [ / ] variant · ←/→ select · Enter open · Esc back · click" % (v, mode))
def action_prev_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) - 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_next_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) + 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_left(self):
if self.view == "hours":
self.sel_hour = max(0, self.sel_hour - 1)
elif self.variant == "C":
self.sel_strip = max(0, self.sel_strip - 1)
else:
self.sel_day = max(0, self.sel_day - 1)
self.refresh_all()
def action_right(self):
if self.view == "hours":
self.sel_hour = min(23, self.sel_hour + 1)
elif self.variant == "C":
self.sel_strip = min(71, self.sel_strip + 1)
else:
self.sel_day = min(len(self.days) - 1, self.sel_day + 1)
self.refresh_all()
def action_drill(self):
if self.variant in ("A", "B") and self.view == "days":
self.view = "hours"
self.sel_hour = 13
self.refresh_all()
def action_back(self):
if self.view == "hours":
self.view = "days"
self.refresh_all()
def on_click(self, event: events.Click) -> None:
w = event.widget
if w is None or w.id != "graph":
return
x = event.x
if self.view == "hours":
self.sel_hour = max(0, min(23, x))
elif self.variant == "C":
self.sel_strip = max(0, min(71, x))
else:
self.sel_day = max(0, min(len(self.days) - 1, x // 2))
self.refresh_all()
if __name__ == "__main__":
GraphPrototype().run()