The renderer kept a braille canvas, char-field scene, star-glyph/orbital helpers, and seed/links params from earlier visual iterations that the final timeline bar chart never uses. Remove them (~190 lines), simplify the empty-state placeholder, and refresh the module + RPC docstrings to describe what actually ships.
656 lines
24 KiB
Python
656 lines
24 KiB
Python
"""Terminal renderer for the learning timeline (learned skills + memories).
|
|
|
|
The desktop app (``apps/desktop/src/app/starmap``) paints a GPU radial
|
|
constellation; a terminal can't, so this is a *rendition* of the same data as a
|
|
timeline bar chart — date rows, proportional skill/memory bars colored by the
|
|
day's dominant category, and a cumulative trajectory sparkline — plus per-slice
|
|
bucket metadata the TUI walks as a tree. The age gradient and complementary
|
|
memory ink are ported from the desktop source, not guessed.
|
|
|
|
Grids are emitted as style runs — ``[text, style, alpha, hex?]`` — so each
|
|
consumer maps the semantic style + brightness onto its own palette; the
|
|
optional 4th element overrides the base color (category heatmap). Pure,
|
|
stdlib-only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Iterable, Optional
|
|
|
|
# time-axis.ts LEAD_IN: the oldest node sits just off recency 0.
|
|
LEAD_IN = 0.06
|
|
|
|
# constants.ts AGE_GRADIENT — old quiet, recent bright.
|
|
AGE_OLD_INK = 0.42
|
|
AGE_MID_INK = 0.74
|
|
AGE_NEW_INK = 0.95
|
|
AGE_MID = 0.52
|
|
|
|
# Style keys consumers map to base colors (brightness = the run alpha).
|
|
STYLE_BG = "bg"
|
|
STYLE_SKILL = "skill"
|
|
STYLE_MEMORY = "memory"
|
|
STYLE_LABEL = "label"
|
|
STYLE_DIM = "dim"
|
|
|
|
# Legend glyphs mirror NODE_SHAPE (skill = circle, memory = diamond).
|
|
SKILL_GLYPH = "●"
|
|
MEMORY_GLYPH = "◆"
|
|
_LABEL_KEYS = tuple("123456789abc")
|
|
|
|
Run = list # [text, style, alpha, hex?]
|
|
Row = list # list[Run]
|
|
Grid = list # list[Row]
|
|
|
|
|
|
def _to_ts(value: Any) -> Optional[float]:
|
|
try:
|
|
return None if value is None else float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _clamp(v: float, lo: float, hi: float) -> float:
|
|
return lo if v < lo else hi if v > hi else v
|
|
|
|
|
|
def _smoothstep(p: float) -> float:
|
|
p = _clamp(p, 0.0, 1.0)
|
|
return p * p * (3 - 2 * p)
|
|
|
|
|
|
def recency_ink(rec: float) -> float:
|
|
"""Port of geometry.ts ``recencyInk`` — smoothstep age → ink alpha."""
|
|
t = _clamp(rec, 0.0, 1.0)
|
|
if t <= AGE_MID:
|
|
return AGE_OLD_INK + (AGE_MID_INK - AGE_OLD_INK) * _smoothstep(t / AGE_MID)
|
|
return AGE_MID_INK + (AGE_NEW_INK - AGE_MID_INK) * _smoothstep((t - AGE_MID) / (1 - AGE_MID))
|
|
|
|
|
|
def format_date(ts: Optional[float]) -> str:
|
|
if not ts:
|
|
return "unknown"
|
|
try:
|
|
return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%-d %b %Y")
|
|
except (ValueError, OSError, OverflowError):
|
|
return "unknown"
|
|
|
|
|
|
def compute_recency(nodes: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""Port of time-axis.ts ``computeRecency`` (id → recency ratio, timed flag)."""
|
|
known = [t for t in (_to_ts(n.get("timestamp")) for n in nodes) if t is not None]
|
|
min_ts = min(known) if known else None
|
|
max_ts = max(known) if known else None
|
|
timed = min_ts is not None and max_ts is not None and max_ts > min_ts
|
|
|
|
ordered = sorted(
|
|
nodes,
|
|
key=lambda n: (
|
|
_to_ts(n.get("timestamp")) if _to_ts(n.get("timestamp")) is not None else math.inf,
|
|
str(n.get("id", "")),
|
|
),
|
|
)
|
|
last = max(len(ordered) - 1, 1)
|
|
ord_ratio = {str(n.get("id", "")): (i / last if len(ordered) > 1 else 0.0) for i, n in enumerate(ordered)}
|
|
|
|
rec: dict[str, float] = {}
|
|
for n in nodes:
|
|
nid = str(n.get("id", ""))
|
|
ts = _to_ts(n.get("timestamp"))
|
|
if timed and ts is not None and min_ts is not None and max_ts is not None:
|
|
ratio = (ts - min_ts) / (max_ts - min_ts)
|
|
else:
|
|
ratio = ord_ratio.get(nid, 0.0)
|
|
rec[nid] = LEAD_IN + (1 - LEAD_IN) * _clamp(ratio, 0.0, 1.0)
|
|
|
|
return {"rec": rec, "timed": timed, "minTs": min_ts, "maxTs": max_ts}
|
|
|
|
|
|
def _date_at(rec: dict[str, Any], reveal: float) -> Optional[float]:
|
|
if not rec.get("timed"):
|
|
return None
|
|
lo, hi = rec.get("minTs"), rec.get("maxTs")
|
|
if lo is None or hi is None:
|
|
return None
|
|
return round(lo + _clamp(reveal, 0, 1) * (hi - lo))
|
|
|
|
|
|
# ── Color: ported from color.ts so memory ink + age fade match the desktop ──
|
|
|
|
|
|
def hex_to_rgb(s: str) -> tuple[int, int, int]:
|
|
s = s.strip().lstrip("#")
|
|
if len(s) == 3:
|
|
s = "".join(c * 2 for c in s)
|
|
try:
|
|
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
|
except (ValueError, IndexError):
|
|
return 255, 215, 0
|
|
|
|
|
|
def rgb_to_hex(c: tuple) -> str:
|
|
return "#{:02X}{:02X}{:02X}".format(*(int(_clamp(v, 0, 255)) for v in c))
|
|
|
|
|
|
def mix_rgb(a: tuple, b: tuple, t: float) -> tuple[int, int, int]:
|
|
p = _clamp(t, 0.0, 1.0)
|
|
return tuple(round(a[i] + (b[i] - a[i]) * p) for i in range(3)) # type: ignore[return-value]
|
|
|
|
|
|
def _rgb_to_hsl(c: tuple) -> tuple[float, float, float]:
|
|
r, g, b = (x / 255 for x in c)
|
|
mx, mn = max(r, g, b), min(r, g, b)
|
|
light = (mx + mn) / 2
|
|
d = mx - mn
|
|
if not d:
|
|
return 0.0, 0.0, light
|
|
s = d / (2 - mx - mn) if light > 0.5 else d / (mx + mn)
|
|
if mx == r:
|
|
h = (g - b) / d + (6 if g < b else 0)
|
|
elif mx == g:
|
|
h = (b - r) / d + 2
|
|
else:
|
|
h = (r - g) / d + 4
|
|
return h * 60, s, light
|
|
|
|
|
|
def _hsl_to_rgb(h: float, s: float, light: float) -> tuple[int, int, int]:
|
|
hue = ((h % 360) + 360) % 360
|
|
c = (1 - abs(2 * light - 1)) * s
|
|
x = c * (1 - abs(((hue / 60) % 2) - 1))
|
|
m = light - c / 2
|
|
if hue < 60:
|
|
r, g, b = c, x, 0.0
|
|
elif hue < 120:
|
|
r, g, b = x, c, 0.0
|
|
elif hue < 180:
|
|
r, g, b = 0.0, c, x
|
|
elif hue < 240:
|
|
r, g, b = 0.0, x, c
|
|
elif hue < 300:
|
|
r, g, b = x, 0.0, c
|
|
else:
|
|
r, g, b = c, 0.0, x
|
|
return round((r + m) * 255), round((g + m) * 255), round((b + m) * 255)
|
|
|
|
|
|
def _complementary_ink(c: tuple) -> tuple[int, int, int]:
|
|
h, s, light = _rgb_to_hsl(c)
|
|
return _hsl_to_rgb(h + 165, max(s, 0.5), _clamp(light, 0.5, 0.7))
|
|
|
|
|
|
def derive_palette(primary_hex: str, *, dark: bool = True) -> dict[str, str]:
|
|
"""Port of color.ts ``computePalette`` (the bits a terminal needs)."""
|
|
primary = hex_to_rgb(primary_hex)
|
|
base = (255, 255, 255) if dark else (0, 0, 0)
|
|
bg = (8, 8, 12) if dark else (250, 250, 250)
|
|
return {
|
|
"primary": primary_hex,
|
|
"skill": rgb_to_hex(mix_rgb(primary, base, 0.12 if dark else 0.18)),
|
|
"memory": rgb_to_hex(mix_rgb(_complementary_ink(primary), bg, 0.45)),
|
|
"label": rgb_to_hex(mix_rgb(base, bg, 0.35)),
|
|
"dim": rgb_to_hex(mix_rgb(base, bg, 0.7)),
|
|
"bg": rgb_to_hex(bg),
|
|
}
|
|
|
|
|
|
def _node_score(node: dict[str, Any], rec: float) -> float:
|
|
"""Pick which visible objects deserve map markers + label rows."""
|
|
if node.get("kind") == "memory":
|
|
return 3.5 + rec
|
|
use = float(node.get("useCount", 0) or 0)
|
|
return rec * 2 + math.sqrt(max(0.0, use)) + (2.0 if node.get("pinned") else 0.0)
|
|
|
|
|
|
def _node_label(node: dict[str, Any]) -> str:
|
|
text = str(node.get("label") or node.get("id") or "unknown").strip()
|
|
return text if len(text) <= 26 else text[:23].rstrip() + "…"
|
|
|
|
|
|
def _node_meta(node: dict[str, Any]) -> str:
|
|
if node.get("kind") == "memory":
|
|
source = "profile memory" if node.get("memorySource") == "profile" else "memory"
|
|
return f"{source} · {format_date(_to_ts(node.get('timestamp')))}"
|
|
bits = [str(node.get("category") or "skill"), format_date(_to_ts(node.get("timestamp")))]
|
|
count = int(node.get("useCount", 0) or 0)
|
|
if count:
|
|
bits.append(f"x{count}")
|
|
if node.get("pinned"):
|
|
bits.append("pinned")
|
|
return " · ".join(bits)
|
|
|
|
|
|
# ── Timeline chart frame ─────────────────────────────────────────────────────
|
|
|
|
|
|
class _ChartBucket:
|
|
__slots__ = ("label", "ts", "skills", "memories", "nodes", "rec")
|
|
|
|
def __init__(self, label: str, ts: float):
|
|
self.label = label
|
|
self.ts = ts
|
|
self.skills = 0
|
|
self.memories = 0
|
|
self.nodes: list[dict[str, Any]] = []
|
|
self.rec = 1.0
|
|
|
|
@property
|
|
def total(self) -> int:
|
|
return self.skills + self.memories
|
|
|
|
|
|
def _period_key(ts: float, granularity: str) -> tuple[int, ...]:
|
|
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
if granularity == "day":
|
|
return (dt.year, dt.month, dt.day)
|
|
if granularity == "month":
|
|
return (dt.year, dt.month)
|
|
return (dt.year,)
|
|
|
|
|
|
def _period_label(ts: float, granularity: str) -> str:
|
|
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
if granularity == "day":
|
|
return dt.strftime("%-d %b")
|
|
if granularity == "month":
|
|
return dt.strftime("%b %Y")
|
|
return dt.strftime("%Y")
|
|
|
|
|
|
def _build_chart_buckets(nodes: list[dict[str, Any]], rec: dict[str, Any], max_rows: int) -> list[_ChartBucket]:
|
|
"""Timeline rows: finest date granularity that fits, oldest → newest."""
|
|
if not nodes:
|
|
return []
|
|
if not rec["timed"]:
|
|
ordered = sorted(nodes, key=lambda n: rec["rec"].get(str(n.get("id", "")), 0.0))
|
|
n_bins = min(max_rows, max(1, len(ordered)))
|
|
buckets = [_ChartBucket(f"#{i + 1}", float(i)) for i in range(n_bins)]
|
|
for node in ordered:
|
|
idx = int(_clamp(math.floor(rec["rec"].get(str(node.get("id", "")), 0.0) * n_bins), 0, n_bins - 1))
|
|
b = buckets[idx]
|
|
b.nodes.append(node)
|
|
if node.get("kind") == "memory":
|
|
b.memories += 1
|
|
else:
|
|
b.skills += 1
|
|
return buckets
|
|
|
|
chosen: Optional[list[_ChartBucket]] = None
|
|
for granularity in ("day", "month", "year"):
|
|
groups: dict[tuple[int, ...], _ChartBucket] = {}
|
|
for node in nodes:
|
|
ts = _to_ts(node.get("timestamp"))
|
|
if ts is None:
|
|
continue
|
|
key = _period_key(ts, granularity)
|
|
bucket = groups.get(key)
|
|
if bucket is None:
|
|
bucket = _ChartBucket(_period_label(ts, granularity), ts)
|
|
groups[key] = bucket
|
|
bucket.nodes.append(node)
|
|
if node.get("kind") == "memory":
|
|
bucket.memories += 1
|
|
else:
|
|
bucket.skills += 1
|
|
# For short spans, keep the useful day-by-day graph even when the caller
|
|
# asked for fewer rows; terminal scrollback is better than collapsing a
|
|
# month of activity into one unreadable bar.
|
|
if len(groups) <= max_rows or (granularity == "day" and len(groups) <= 32):
|
|
chosen = [groups[key] for key in sorted(groups)]
|
|
break
|
|
|
|
if chosen is None:
|
|
# If even yearly buckets overflow, fall back to even time bins.
|
|
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
|
|
n_bins = max(1, max_rows)
|
|
chosen = []
|
|
for i in range(n_bins):
|
|
ts = min_ts + (i / max(1, n_bins - 1)) * (max_ts - min_ts) if min_ts and max_ts else float(i)
|
|
chosen.append(_ChartBucket(format_date(ts), ts))
|
|
for node in nodes:
|
|
r = rec["rec"].get(str(node.get("id", "")), 0.0)
|
|
idx = int(_clamp(math.floor(r * n_bins), 0, n_bins - 1))
|
|
b = chosen[idx]
|
|
b.nodes.append(node)
|
|
if node.get("kind") == "memory":
|
|
b.memories += 1
|
|
else:
|
|
b.skills += 1
|
|
|
|
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
|
|
span = (max_ts - min_ts) if min_ts is not None and max_ts is not None and max_ts > min_ts else 0
|
|
for bucket in chosen:
|
|
bucket.rec = LEAD_IN + (1 - LEAD_IN) * ((bucket.ts - min_ts) / span) if span else 1.0
|
|
return chosen
|
|
|
|
|
|
def _bucket_label_node(bucket: _ChartBucket) -> Optional[dict[str, Any]]:
|
|
if not bucket.nodes:
|
|
return None
|
|
return max(bucket.nodes, key=lambda node: _node_score(node, _to_ts(node.get("timestamp")) or bucket.ts))
|
|
|
|
|
|
def _bucket_nodes(bucket: _ChartBucket, memory_lookup: Optional[dict[str, dict[str, Any]]] = None) -> list[dict[str, Any]]:
|
|
out: list[dict[str, Any]] = []
|
|
# Chronological within the slice so the TUI tree reads oldest → newest.
|
|
ordered = sorted(bucket.nodes, key=lambda n: _to_ts(n.get("timestamp")) or bucket.ts)
|
|
for node in ordered:
|
|
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
|
|
raw_label = str(node.get("label") or node.get("id") or "unknown").strip()
|
|
memory = (memory_lookup or {}).get(str(node.get("id", "")))
|
|
out.append(
|
|
{
|
|
"id": str(node.get("id", "")),
|
|
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
|
|
"label": _node_label(node),
|
|
"fullLabel": raw_label,
|
|
"meta": _node_meta(node),
|
|
"body": str(memory.get("body", "")) if memory else "",
|
|
"style": style,
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def _bucket_rows(buckets: list[_ChartBucket], payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
cmap = category_color_map(payload)
|
|
memory_lookup = {
|
|
f"memory:{card.get('source')}:{idx}": card
|
|
for idx, card in enumerate(payload.get("memory", []) or [])
|
|
if isinstance(card, dict)
|
|
}
|
|
rows: list[dict[str, Any]] = []
|
|
for idx, bucket in enumerate(buckets):
|
|
cat = _bucket_category(bucket)
|
|
rows.append(
|
|
{
|
|
"index": idx,
|
|
"label": bucket.label,
|
|
"date": format_date(bucket.ts),
|
|
"skills": bucket.skills,
|
|
"memories": bucket.memories,
|
|
"total": bucket.total,
|
|
"category": cat,
|
|
"color": cmap.get(cat) if cat else None,
|
|
"nodes": _bucket_nodes(bucket, memory_lookup),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def _category_counts(payload: dict[str, Any]) -> list[tuple[str, int]]:
|
|
clusters = [
|
|
(str(c.get("category")), int(c.get("count", 0)))
|
|
for c in payload.get("clusters", []) or []
|
|
if c.get("category") and c.get("category") != "memory"
|
|
]
|
|
if clusters:
|
|
return clusters
|
|
counts: dict[str, int] = {}
|
|
for node in payload.get("nodes", []):
|
|
if node.get("kind") == "memory":
|
|
continue
|
|
cat = str(node.get("category") or "skill")
|
|
counts[cat] = counts.get(cat, 0) + 1
|
|
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
|
|
|
|
def category_color_map(payload: dict[str, Any]) -> dict[str, str]:
|
|
"""Deterministic, evenly-spread hue per skill category (theme-independent)."""
|
|
clusters = _category_counts(payload)
|
|
n = max(1, len(clusters))
|
|
# Golden-angle hue spacing so adjacent categories never collide in color.
|
|
return {cat: rgb_to_hex(_hsl_to_rgb((i * 137.508) % 360, 0.55, 0.62)) for i, (cat, _c) in enumerate(clusters)}
|
|
|
|
|
|
def category_legend(payload: dict[str, Any], limit: int = 4) -> list[dict[str, Any]]:
|
|
cmap = category_color_map(payload)
|
|
cats = _category_counts(payload)
|
|
shown = cats[:limit]
|
|
hidden = max(0, len(cats) - len(shown))
|
|
return [
|
|
{"glyph": "●", "color": cmap.get(cat, ""), "label": f"{cat} ({count})"}
|
|
for cat, count in shown
|
|
] + ([{"glyph": "·", "color": "", "label": f"+{hidden}"}] if hidden else [])
|
|
|
|
|
|
def _bucket_category(bucket: _ChartBucket) -> Optional[str]:
|
|
counts: dict[str, int] = {}
|
|
for node in bucket.nodes:
|
|
if node.get("kind") == "memory":
|
|
continue
|
|
cat = str(node.get("category") or "skill")
|
|
counts[cat] = counts.get(cat, 0) + 1
|
|
return max(counts, key=lambda k: counts[k]) if counts else None
|
|
|
|
|
|
def _trajectory_row(buckets: list[_ChartBucket], width: int, reveal: float) -> Row:
|
|
"""Cumulative learning curve as a compact star-path sparkline."""
|
|
if not buckets:
|
|
return []
|
|
total = sum(b.total for b in buckets) or 1
|
|
visible = int(_clamp(math.ceil(reveal * len(buckets)), 0, len(buckets)))
|
|
acc = 0
|
|
points: list[int] = []
|
|
for b in buckets[:visible]:
|
|
acc += b.total
|
|
points.append(round((acc / total) * (width - 1)))
|
|
cells = [" "] * width
|
|
last = 0
|
|
for p in points:
|
|
for x in range(min(last, p), max(last, p) + 1):
|
|
if 0 <= x < width and cells[x] == " ":
|
|
cells[x] = "·"
|
|
if 0 <= p < width:
|
|
cells[p] = "✦"
|
|
last = p
|
|
return [["trajectory ", STYLE_LABEL, 0.55], ["".join(cells), STYLE_SKILL, 0.48]]
|
|
|
|
|
|
def render_graph(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, reveal: float = 1.0) -> dict[str, Any]:
|
|
"""Render one timeline frame at ``reveal`` (0→1).
|
|
|
|
Date rows with proportional skill/memory bars colored by the day's dominant
|
|
category, numbered markers tied to label rows, and a cumulative trajectory
|
|
sparkline underneath.
|
|
"""
|
|
reveal = _clamp(reveal, 0.0, 1.0)
|
|
cols = max(44, cols)
|
|
rows = max(14, rows)
|
|
nodes = list(payload.get("nodes", []))
|
|
if not nodes:
|
|
placeholder = [["no learning yet — keep using Hermes and it maps out here", STYLE_DIM, 0.7]]
|
|
return {"grid": [placeholder], "date": "", "reveal": reveal, "visible": 0}
|
|
|
|
rec = compute_recency(nodes)
|
|
cmap = category_color_map(payload)
|
|
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3))
|
|
n_buckets = len(buckets)
|
|
visible_bucket_count = int(_clamp(math.ceil(reveal * n_buckets), 0, n_buckets))
|
|
max_total = max((b.total for b in buckets), default=1) or 1
|
|
label_w = min(9, max(len(b.label) for b in buckets))
|
|
bar_w = max(14, cols - label_w - 16)
|
|
|
|
grid: Grid = []
|
|
labels: list[dict[str, Any]] = []
|
|
visible = 0
|
|
for i, bucket in enumerate(buckets):
|
|
if i >= visible_bucket_count:
|
|
grid.append([])
|
|
continue
|
|
visible += bucket.total
|
|
ink = recency_ink(bucket.rec)
|
|
bar_len = max(1, round((bucket.total / max_total) * bar_w)) if bucket.total else 0
|
|
skill_len = round((bucket.skills / bucket.total) * bar_len) if bucket.total else 0
|
|
if bucket.skills and skill_len == 0:
|
|
skill_len = 1
|
|
memory_len = bar_len - skill_len
|
|
if bucket.memories and memory_len == 0 and bar_len > 1:
|
|
memory_len = 1
|
|
skill_len = bar_len - 1
|
|
|
|
node = _bucket_label_node(bucket)
|
|
marker = ""
|
|
if node and len(labels) < 6:
|
|
marker = _LABEL_KEYS[len(labels)]
|
|
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
|
|
labels.append(
|
|
{
|
|
"key": marker,
|
|
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
|
|
"label": _node_label(node),
|
|
"meta": _node_meta(node),
|
|
"style": style,
|
|
"alpha": round(ink, 3),
|
|
}
|
|
)
|
|
|
|
cat = _bucket_category(bucket)
|
|
cat_hex = cmap.get(cat) if cat else None
|
|
|
|
row: Row = [[f"{bucket.label:>{label_w}} ", STYLE_LABEL, ink], ["│ ", STYLE_DIM, 0.55]]
|
|
if marker:
|
|
row.append([marker, STYLE_LABEL, 0.95])
|
|
elif bucket.total:
|
|
head_hex = cat_hex if bucket.skills else None
|
|
row.append(["✦" if bucket.skills else "◆", STYLE_SKILL if bucket.skills else STYLE_MEMORY, ink, head_hex])
|
|
if skill_len:
|
|
# Bar colored by the day's dominant category — a learning heatmap.
|
|
row.append(["━" * skill_len, STYLE_SKILL, ink, cat_hex])
|
|
if memory_len:
|
|
if memory_len == 1:
|
|
mem_trail = "◆"
|
|
else:
|
|
mem_trail = "◆" + ("━" * (memory_len - 2)) + "◆"
|
|
row.append([mem_trail, STYLE_MEMORY, max(0.65, ink)])
|
|
if bar_len < bar_w:
|
|
# Empty space keeps counts aligned; starmap texture lives in the
|
|
# trajectory row below, where it reads as signal rather than noise.
|
|
row.append([" " * (bar_w - bar_len), STYLE_BG, 1.0])
|
|
row.append([" ", STYLE_BG, 1.0])
|
|
row.append([str(bucket.skills), STYLE_SKILL, max(0.72, ink)])
|
|
if bucket.memories:
|
|
row.append(["+", STYLE_DIM, 0.6])
|
|
row.append([str(bucket.memories), STYLE_MEMORY, max(0.72, ink)])
|
|
if i == visible_bucket_count - 1:
|
|
row.append([" ◀ now", STYLE_LABEL, 0.9])
|
|
elif bucket.total == max_total and max_total > 1:
|
|
row.append([" ☄ peak", STYLE_LABEL, 0.75])
|
|
grid.append(row)
|
|
|
|
# Cumulative learning trajectory underneath the rows.
|
|
grid.append([[(" " * (label_w + 2)), STYLE_BG, 1.0], *_trajectory_row(buckets, max(12, cols - label_w - 13), reveal)])
|
|
|
|
return {
|
|
"grid": grid,
|
|
"date": format_date(_date_at(rec, reveal)),
|
|
"reveal": reveal,
|
|
"visible": visible,
|
|
"labels": labels,
|
|
}
|
|
|
|
|
|
# ── Trimmings ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def build_legend(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
nodes = payload.get("nodes", [])
|
|
skills = sum(1 for n in nodes if n.get("kind") != "memory")
|
|
memories = sum(1 for n in nodes if n.get("kind") == "memory")
|
|
return [
|
|
{"glyph": SKILL_GLYPH, "style": STYLE_SKILL, "label": f"skills ({skills})"},
|
|
{"glyph": MEMORY_GLYPH, "style": STYLE_MEMORY, "label": f"memories ({memories})"},
|
|
]
|
|
|
|
|
|
def axis_labels(payload: dict[str, Any]) -> dict[str, str]:
|
|
rec = compute_recency(list(payload.get("nodes", [])))
|
|
if not rec["timed"]:
|
|
return {"start": "oldest", "end": "now"}
|
|
return {"start": format_date(rec.get("minTs")), "end": format_date(rec.get("maxTs"))}
|
|
|
|
|
|
def _peak_day(payload: dict[str, Any]) -> Optional[str]:
|
|
counts: dict[tuple[int, ...], int] = {}
|
|
reps: dict[tuple[int, ...], float] = {}
|
|
for node in payload.get("nodes", []):
|
|
ts = _to_ts(node.get("timestamp"))
|
|
if ts is None:
|
|
continue
|
|
key = _period_key(ts, "day")
|
|
counts[key] = counts.get(key, 0) + 1
|
|
reps[key] = ts
|
|
if not counts:
|
|
return None
|
|
best = max(counts, key=lambda k: counts[k])
|
|
return f"busiest day {_period_label(reps[best], 'day')} · {counts[best]} learned"
|
|
|
|
|
|
def build_summary(payload: dict[str, Any]) -> list[str]:
|
|
stats = payload.get("stats", {}) or {}
|
|
lines: list[str] = []
|
|
learned = stats.get("learned_skills", stats.get("nodes", 0))
|
|
mem = stats.get("memory_nodes", 0)
|
|
edges = stats.get("related_edges", 0)
|
|
lines.append(f"{learned} learned skills · {mem} memories · {edges} skill links")
|
|
extra = []
|
|
if stats.get("memory_skill_edges"):
|
|
extra.append(f"{stats['memory_skill_edges']} memory↔skill links")
|
|
peak = _peak_day(payload)
|
|
if peak:
|
|
extra.append(peak)
|
|
if extra:
|
|
lines.append(" · ".join(extra))
|
|
return lines
|
|
|
|
|
|
def _merge_runs(cells: Iterable[Run]) -> Row:
|
|
out: Row = []
|
|
for run in cells:
|
|
text, style, alpha = run[0], run[1], (run[2] if len(run) > 2 else 1.0)
|
|
hex_override = run[3] if len(run) > 3 else None
|
|
prev_hex = out[-1][3] if out and len(out[-1]) > 3 else None
|
|
if out and out[-1][1] == style and abs(out[-1][2] - alpha) < 1e-6 and prev_hex == hex_override:
|
|
out[-1][0] += text
|
|
else:
|
|
merged: Run = [text, style, alpha]
|
|
if hex_override:
|
|
merged.append(hex_override)
|
|
out.append(merged)
|
|
return out
|
|
|
|
|
|
def render_frames(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, frames: int = 48) -> dict[str, Any]:
|
|
"""Pre-render a full play-through (reveal 0→1) plus static legend/summary."""
|
|
frames = max(2, min(frames, 240))
|
|
nodes = list(payload.get("nodes", []))
|
|
rec = compute_recency(nodes)
|
|
# Mirror render_graph's bucketing so the interactive row list lines up with
|
|
# what the user sees.
|
|
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3)) if nodes else []
|
|
out_frames = []
|
|
for i in range(frames):
|
|
reveal = i / (frames - 1)
|
|
frame = render_graph(payload, cols=cols, rows=rows, reveal=reveal)
|
|
out_frames.append(
|
|
{
|
|
"reveal": frame["reveal"],
|
|
"date": frame["date"],
|
|
"visible": frame["visible"],
|
|
"grid": frame["grid"],
|
|
"labels": frame.get("labels", []),
|
|
}
|
|
)
|
|
return {
|
|
"frames": out_frames,
|
|
"legend": build_legend(payload),
|
|
"categories": category_legend(payload),
|
|
"buckets": _bucket_rows(buckets, payload),
|
|
"summary": build_summary(payload),
|
|
"axis": axis_labels(payload),
|
|
"count": len(payload.get("nodes", [])),
|
|
"cols": cols,
|
|
"rows": rows,
|
|
}
|