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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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 @@
+
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()