mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
191 lines
6.2 KiB
Python
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)
|