From c28991678b585e5c6687f6dee8ed816771fae48a Mon Sep 17 00:00:00 2001 From: xavierk Date: Fri, 11 Sep 2026 11:08:59 +0530 Subject: [PATCH] prototype: graph encoding variants for hourly history decision (wayfinder #68) --- prototype/graph-encoding/README.md | 45 ++ prototype/graph-encoding/capture.py | 31 ++ prototype/graph-encoding/run | 3 + .../screenshots/vA_140x40_range.svg | 220 ++++++++ .../screenshots/vA_80x24_hourly-detail.svg | 156 ++++++ .../screenshots/vA_80x24_range.svg | 156 ++++++ .../screenshots/vB_140x40_range.svg | 220 ++++++++ .../vB_140x40_selected-gap-day.svg | 220 ++++++++ .../screenshots/vB_80x24_range.svg | 156 ++++++ .../screenshots/vC_140x40_range.svg | 220 ++++++++ .../screenshots/vC_80x24_range.svg | 156 ++++++ prototype/graph-encoding/tui_prototype.py | 488 ++++++++++++++++++ 12 files changed, 2071 insertions(+) create mode 100644 prototype/graph-encoding/README.md create mode 100644 prototype/graph-encoding/capture.py create mode 100755 prototype/graph-encoding/run create mode 100644 prototype/graph-encoding/screenshots/vA_140x40_range.svg create mode 100644 prototype/graph-encoding/screenshots/vA_80x24_hourly-detail.svg create mode 100644 prototype/graph-encoding/screenshots/vA_80x24_range.svg create mode 100644 prototype/graph-encoding/screenshots/vB_140x40_range.svg create mode 100644 prototype/graph-encoding/screenshots/vB_140x40_selected-gap-day.svg create mode 100644 prototype/graph-encoding/screenshots/vB_80x24_range.svg create mode 100644 prototype/graph-encoding/screenshots/vC_140x40_range.svg create mode 100644 prototype/graph-encoding/screenshots/vC_80x24_range.svg create mode 100644 prototype/graph-encoding/tui_prototype.py diff --git a/prototype/graph-encoding/README.md b/prototype/graph-encoding/README.md new file mode 100644 index 0000000..239fdc2 --- /dev/null +++ b/prototype/graph-encoding/README.md @@ -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 diff --git a/prototype/graph-encoding/capture.py b/prototype/graph-encoding/capture.py new file mode 100644 index 0000000..0620f6d --- /dev/null +++ b/prototype/graph-encoding/capture.py @@ -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()) diff --git a/prototype/graph-encoding/run b/prototype/graph-encoding/run new file mode 100755 index 0000000..549483b --- /dev/null +++ b/prototype/graph-encoding/run @@ -0,0 +1,3 @@ +#!/bin/sh +# PROTOTYPE launcher +exec python3 "$(dirname "$0")/tui_prototype.py" "$@" diff --git a/prototype/graph-encoding/screenshots/vA_140x40_range.svg b/prototype/graph-encoding/screenshots/vA_140x40_range.svg new file mode 100644 index 0000000..a81a98e --- /dev/null +++ b/prototype/graph-encoding/screenshots/vA_140x40_range.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                          ▼ +·█ +·█ +·█ +·█ +·█ +·▄█▃ +·███ +·▄██▆▂▂█▄ +·█▂██▆██████┄ +·██·▄█████▁███ + 29 30 31 01 02 03 04 05 06 07 08 09 10 11 +Fri 11 Sep — 30 GB written · 14/14 h evidenced · partial · 14 h elapsed · so far · coverage 100%  — today — 14 h elapsed, so far +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence — never zero· measured 0 B┄ partial day (so far) + + + + + + + + + + + + + + + + + + + + + + +PROTOTYPE ▸ A: Daily bars ▸ range view    [ / ] variant · ←/→ select · Enter open · Esc back · click + + + + diff --git a/prototype/graph-encoding/screenshots/vA_80x24_hourly-detail.svg b/prototype/graph-encoding/screenshots/vA_80x24_hourly-detail.svg new file mode 100644 index 0000000..808485c --- /dev/null +++ b/prototype/graph-encoding/screenshots/vA_80x24_hourly-detail.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +             ▼ +█ +█▆█▁ +▅████ +██▃████▁▆ +███▂▇█████▄█ +████████████▅ +██████████████▁ +███████████████▂ +▃████████████████▃▁ +▇▄▂▂▄███████████████████ +00    03    06    09    12    15    18    21     + midnight → 23:00 local +Thu 10 Sep ▸ hour 13:00 — 2.4 GB written · coverage 100% +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence +— never zero· measured 0 B┄ partial day (so far) + + + + +PROTOTYPE ▸ A: Daily bars ▸ day detail    [ / ] variant · ←/→ select · Enter +open · Esc back · click + + + diff --git a/prototype/graph-encoding/screenshots/vA_80x24_range.svg b/prototype/graph-encoding/screenshots/vA_80x24_range.svg new file mode 100644 index 0000000..0acca0c --- /dev/null +++ b/prototype/graph-encoding/screenshots/vA_80x24_range.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                          ▼ +·█ +·█ +·█ +·█ +·█ +·▄█▃ +·███ +·▄██▆▂▂█▄ +·█▂██▆██████┄ +·██·▄█████▁███ + 29 30 31 01 02 03 04 05 06 07 08 09 10 11 +Fri 11 Sep — 30 GB written · 14/14 h evidenced · partial · 14 h elapsed · so +far · coverage 100%  — today — 14 h elapsed, so far +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence +— never zero· measured 0 B┄ partial day (so far) + + + + +PROTOTYPE ▸ A: Daily bars ▸ range view    [ / ] variant · ←/→ select · Enter +open · Esc back · click + + + diff --git a/prototype/graph-encoding/screenshots/vB_140x40_range.svg b/prototype/graph-encoding/screenshots/vB_140x40_range.svg new file mode 100644 index 0000000..6f90e4a --- /dev/null +++ b/prototype/graph-encoding/screenshots/vB_140x40_range.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                                                    ▼ +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  │  +╎╎╎ │  │  │  │  │  │  │  │  │  +╎╎╎ │  │  │  │  │  │  │  │  │  │  │  │  +╎╎╎████████████ │ █████████████████████ │  + 29 30 31 01 02 03 04 05 06 07 08 09 10 11 +Fri 11 Sep — 30 GB written · 14/14 h evidenced · partial · 14 h elapsed · so far · coverage 100%  — today — 14 h elapsed, so far +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence — never zero· measured 0 B┄ partial day (so far) + + + + + + + + + + + + + + + + + + + + + + +PROTOTYPE ▸ B: Daily candles ▸ range view    [ / ] variant · ←/→ select · Enter open · Esc back · click + + + + diff --git a/prototype/graph-encoding/screenshots/vB_140x40_selected-gap-day.svg b/prototype/graph-encoding/screenshots/vB_140x40_selected-gap-day.svg new file mode 100644 index 0000000..070e35b --- /dev/null +++ b/prototype/graph-encoding/screenshots/vB_140x40_selected-gap-day.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                                            ▼ +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  │  +╎╎╎ │  │  │  │  │  │  │  │  │  +╎╎╎ │  │  │  │  │  │  │  │  │  │  │  │  +╎╎╎████████████ │ █████████████████████ │  + 29 30 31 01 02 03 04 05 06 07 08 09 10 11 +Wed 09 Sep — 37 GB written · 24/24 h evidenced · coverage 100% +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence — never zero· measured 0 B┄ partial day (so far) + + + + + + + + + + + + + + + + + + + + + + +PROTOTYPE ▸ B: Daily candles ▸ range view    [ / ] variant · ←/→ select · Enter open · Esc back · click + + + + diff --git a/prototype/graph-encoding/screenshots/vB_80x24_range.svg b/prototype/graph-encoding/screenshots/vB_80x24_range.svg new file mode 100644 index 0000000..5b4b70c --- /dev/null +++ b/prototype/graph-encoding/screenshots/vB_80x24_range.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                                                    ▼ +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  +╎╎╎ │  │  +╎╎╎ │  │  │  │  │  │  │  │  │  +╎╎╎ │  │  │  │  │  │  │  │  │  │  │  │  +╎╎╎████████████ │ █████████████████████ │  + 29 30 31 01 02 03 04 05 06 07 08 09 10 11 +Fri 11 Sep — 30 GB written · 14/14 h evidenced · partial · 14 h elapsed · so +far · coverage 100%  — today — 14 h elapsed, so far +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence +— never zero· measured 0 B┄ partial day (so far) + + + + +PROTOTYPE ▸ B: Daily candles ▸ range view    [ / ] variant · ←/→ select · +Enter open · Esc back · click + + + diff --git a/prototype/graph-encoding/screenshots/vC_140x40_range.svg b/prototype/graph-encoding/screenshots/vC_140x40_range.svg new file mode 100644 index 0000000..fd4f5fd --- /dev/null +++ b/prototype/graph-encoding/screenshots/vC_140x40_range.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                                                                         ▼ +││█▅ +││▃██ +▂││██▅█ +▅▃█││███▃██ +▄█▁███│▂▁▆│███▆███ +██▅▂████▄▇│▃█▁██▆│▄███████ +███▆████████▃│██▆▃████▄▂▇│████████ +██████████████▂│▃████████████▆│████████ +███████████████▅│██████████████▇▃│▄████████ +▅▃▁▁▃██████████████████▆│▄▂▁▁▂▆████████████████▆▅│▇▅▂▂▅█████████·········· +Fri 11 Sep 23:00 — future — not counted +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence — never zero· measured 0 B┄ partial day (so far) + + + + + + + + + + + + + + + + + + + + + + + +PROTOTYPE ▸ C: Rolling 72 h strip ▸ hourly strip    [ / ] variant · ←/→ select · Enter open · Esc back · click + + + + diff --git a/prototype/graph-encoding/screenshots/vC_80x24_range.svg b/prototype/graph-encoding/screenshots/vC_80x24_range.svg new file mode 100644 index 0000000..0465b23 --- /dev/null +++ b/prototype/graph-encoding/screenshots/vC_80x24_range.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PROTOTYPE — usage-history graph encoding + + + + + + + + + + 🐺 Fenris · usage history · Local · UTC+05:30 · IST · writes per hour + +                                                                         ▼ +││█▅ +││▃██ +▂││██▅█ +▅▃█││███▃██ +▄█▁███│▂▁▆│███▆███ +██▅▂████▄▇│▃█▁██▆│▄███████ +███▆████████▃│██▆▃████▄▂▇│████████ +██████████████▂│▃████████████▆│████████ +███████████████▅│██████████████▇▃│▄████████ +▅▃▁▁▃██████████████████▆│▄▂▁▁▂▆████████████████▆▅│▇▅▂▂▅█████████·········· +Fri 11 Sep 23:00 — future — not counted +legend:  █ allocated▒ unallocated (attribution unknown)░ gap · no evidence +— never zero· measured 0 B┄ partial day (so far) + + + + + + +PROTOTYPE ▸ C: Rolling 72 h strip ▸ hourly strip    [ / ] variant · ←/→ select +· Enter open · Esc back · click + + + diff --git a/prototype/graph-encoding/tui_prototype.py b/prototype/graph-encoding/tui_prototype.py new file mode 100644 index 0000000..b9271ad --- /dev/null +++ b/prototype/graph-encoding/tui_prototype.py @@ -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()