diet-guard/diet_guard/_gatelock_body_graph.py
Krzysztof kuhy Rudnicki 43e4b4070d Replace PC gate text with glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:35:34 +01:00

191 lines
6.2 KiB
Python

"""The Body tab's weight graph, drawn on a plain ``tk.Canvas``.
No plotting dependency: the gate runs on system ``/usr/bin/python`` and a
line, some dots, four gridlines and a shaded healthy band are all a weight
trend needs. The geometry (:func:`graph_points`, :func:`value_ticks`) is pure
so it is tested without Tk; :func:`draw_weight_graph` only issues canvas
calls.
.. note::
Imports ``tkinter`` for types only; it never builds widgets, so it is not
in ``_GATE_TK_MODULES``.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from typing import TYPE_CHECKING
from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED
from diet_guard._gatelock_glyphs import EMPTY, INFINITE, OK
from diet_guard._gatelock_typography import CAPTION
from diet_guard._gatelock_ui import BG, FG
if TYPE_CHECKING:
import tkinter as tk
__all__ = [
"GRAPH_RANGES",
"GraphBox",
"draw_weight_graph",
"graph_points",
"value_ticks",
]
# (label, days back; None = everything).
GRAPH_RANGES: tuple[tuple[str, int | None], ...] = (
("30", 30),
("90", 90),
("365", 365),
(INFINITE, None),
)
_HEALTHY_FILL = "#1f3a2a"
_GRID = "#33363d"
_DOT_R = 3
_TICKS = 4
# Size of the empty-range mark, in points.
_EMPTY_SIZE = 48
# Keep a flat line off the frame: pad the value range by this much (kg).
_MIN_SPAN_KG = 2.0
# Include a healthy-band edge on the axis only when it is this close (kg).
_BAND_REACH_KG = 5.0
# Headroom above and below the data, as a fraction of its range.
_PAD_FRACTION = 0.1
@dataclass(frozen=True)
class GraphBox:
"""Canvas size and the inner plot margins (px)."""
width: int
height: int
left: int = 56
right: int = 16
top: int = 16
bottom: int = 28
def x(self, frac: float) -> float:
"""Canvas x for a 0..1 position along the time axis."""
return self.left + frac * (self.width - self.left - self.right)
def y(self, frac: float) -> float:
"""Canvas y for a 0..1 position up the value axis."""
return self.height - self.bottom - frac * (self.height - self.top - self.bottom)
def in_range(
weights: dict[str, float], today: date, days: int | None
) -> dict[str, float]:
"""The weigh-ins within the last ``days`` days (all when None)."""
if days is None:
return dict(weights)
first = (today - timedelta(days=days)).isoformat()
return {day: kg for day, kg in weights.items() if day >= first}
def value_span(
values: list[float], band: tuple[float, float] | None
) -> tuple[float, float]:
"""Low/high of the value axis: the data, padded, plus a *nearby* band edge.
A band edge more than :data:`_BAND_REACH_KG` from the data is left off the
axis: pulling a 60 kg floor under an 84-to-82 kg trend flattens the trend
into one line, and the trend is what the graph is for.
"""
low, high = min(values), max(values)
if band is not None:
for edge in band:
if low - _BAND_REACH_KG <= edge <= high + _BAND_REACH_KG:
low, high = min(low, edge), max(high, edge)
pad = max((high - low) * _PAD_FRACTION, _MIN_SPAN_KG / 2)
return low - pad, high + pad
def graph_points(
weights: dict[str, float], box: GraphBox, span: tuple[float, float]
) -> list[tuple[float, float]]:
"""Canvas points for each weigh-in, time on x (by date), kg on y."""
days = sorted(weights)
first = date.fromisoformat(days[0])
total = max((date.fromisoformat(days[-1]) - first).days, 1)
low, high = span
return [
(
box.x(
(date.fromisoformat(day) - first).days / total if len(days) > 1 else 0.5
),
box.y((weights[day] - low) / (high - low)),
)
for day in days
]
def value_ticks(span: tuple[float, float]) -> list[float]:
"""Evenly spaced gridline values across the axis, ends included."""
low, high = span
return [low + (high - low) * i / _TICKS for i in range(_TICKS + 1)]
def _draw_band(
canvas: tk.Canvas,
box: GraphBox,
span: tuple[float, float],
healthy: tuple[float, float],
) -> None:
"""Shade the healthy-BMI weights, clipped to the axis (skipped if off it)."""
low, high = span
if healthy[0] >= high or healthy[1] <= low:
return
top = box.y((min(healthy[1], high) - low) / (high - low))
bottom = box.y((max(healthy[0], low) - low) / (high - low))
canvas.create_rectangle(
box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline=""
)
font = (_COLORS.typography.font_family, CAPTION)
# The green band is the healthy-BMI weight range; a check marks it.
canvas.create_text(
box.x(1) - 4, top + 2, text=OK, anchor="ne", fill=_MUTED, font=font
)
def draw_weight_graph(
canvas: tk.Canvas,
weights: dict[str, float],
box: GraphBox,
healthy: tuple[float, float] | None,
) -> None:
"""Redraw the whole graph; an empty range shows a large empty-set mark."""
canvas.delete("all")
font = (_COLORS.typography.font_family, CAPTION)
if not weights:
canvas.create_text(
box.width / 2,
box.height / 2,
text=EMPTY,
fill=_MUTED,
font=(_COLORS.typography.font_family, _EMPTY_SIZE),
)
return
span = value_span(list(weights.values()), healthy)
low, high = span
if healthy is not None:
_draw_band(canvas, box, span, healthy)
for value in value_ticks(span):
y = box.y((value - low) / (high - low))
canvas.create_line(box.x(0), y, box.x(1), y, fill=_GRID)
canvas.create_text(
box.left - 6, y, text=f"{value:.1f}", anchor="e", fill=_MUTED, font=font
)
points = graph_points(weights, box, span)
if len(points) > 1:
canvas.create_line(*[c for p in points for c in p], fill=_ACCENT, width=2)
for x, y in points:
canvas.create_oval(
x - _DOT_R, y - _DOT_R, x + _DOT_R, y + _DOT_R, fill=_ACCENT, outline=BG
)
days = sorted(weights)
base = box.height - box.bottom + 14
canvas.create_text(box.x(0), base, text=days[0], anchor="w", fill=FG, font=font)
canvas.create_text(box.x(1), base, text=days[-1], anchor="e", fill=FG, font=font)