#!/usr/bin/env python3
"""Vault graph image — render the brain's knowledge graph to a PNG (or SVG),
Obsidian-style, with zero dependencies.

A headless port of the desktop app's 2D graph view (alexandria-desktop:
``renderer/lib/graphForces.ts``, ``renderer/lib/graphColors.ts``,
``renderer/components/GraphPanel.tsx``). Same data — the scan behind
``graph_get`` (:func:`alexandria_vault.vault_core.graph.graph_snapshot`); same
physics — a faithful d3-force port (Barnes-Hut many-body charge, degree-
normalized link springs, center + weak x/y positioning, velocity Verlet with
d3's alpha schedule); same palette — near-black canvas, cool-gray notes, warm
sand hubs. The picture matches what the desktop app (and Obsidian) show.

One deliberate divergence from the live canvas: after the physics settle, a
deterministic separation pass (Gephi "Noverlap"-style, with d3-forceCollide's
mass split so hubs hold their ground) eases notes apart until no two disks
overlap at the rendered fit — in every output format, on warm and fully-cached
layouts alike. ``--no-collide`` restores the raw physics placement.

Stdlib-only (`python3`), deterministic (seeded layout: the same vault renders
the same image). If numpy happens to be importable the charge pass vectorizes
automatically (~10x); nothing requires it. Full-vault layouts warm-start from a
positions cache at ``.state/cache/graph-image-layout.json`` (gitignored), so a
re-render after a normal day's writing settles in seconds, and an unchanged
vault re-renders with no simulation at all.

Usage:
    python3 -m alexandria_vault.engine.graph_image --vault .          # → Agent/Graphs/<date> Vault Graph.png
    ... --focus "Some Note" --hops 2       # the local graph around one note
    ... --labels 24                        # small-caps labels on the top-N hubs
    ... --theme desktop|obsidian|light
    ... --no-color-by-folder               # classic monochrome (folder hues are the default)
    ... --group "People=#8fb573"           # explicit color groups (repeatable, longest prefix wins)
    ... --include-unresolved               # phantom nodes for not-yet-created targets
    ... --width 8000 --height 6000         # 8K print (default is 4K-class 4000x3000)
    ... --format html                      # self-contained INTERACTIVE page — hover a note for
                                           #   its label + lit neighborhood, click to pin, pan/zoom
    ... --format svg|both|all              # vector / png+svg / all three (an --out suffix implies)
    ... --json                             # machine-readable meta (paths, counts, hubs, timings)
    ... --fast                             # coarser theta + faster alpha decay (big vaults, no numpy)
    ... --no-collide                       # allow raw-physics node overlaps (separation is the default)
    ... --relayout | --no-cache            # ignore / skip the layout cache
"""
import argparse
import hashlib
import json
import math
import os
import random
import struct
import subprocess
import sys
import time
import zlib
import datetime as dt
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, resolve_vault

# `--vault` must land in VAULT_DIR before any locator runs (engine convention).
consume_vault_arg()


# ── Physics + palette, ported verbatim ───────────────────────────────────────
# The desktop's slider→engine mappings (renderer/lib/graphForces.ts) evaluated
# at DEFAULT_GRAPH_SETTINGS (renderer/components/GraphControls.tsx): these are
# the numbers the app actually runs with, not a re-tuning.
NODE_SIZE_MULT = 1.3          # nodeSizeMult(0.5)
LINK_THICKNESS_PX = 0.68      # linkThicknessPx(0.12)
XY_STRENGTH = 0.048           # centerStrength(0.12) — forceX/forceY toward 0
CHARGE_STRENGTH = -50.0       # repelStrength(0) — the app's interactive default
CHARGE_DISTANCE_MAX = 2500.0  # forceManyBody().distanceMax(2500)
LINK_DISTANCE = 265.0         # linkDistancePx(0.5)
LINK_FORCE_MULT = 1.0         # the Link-force slider default
HUB_DEGREE = 14               # a note this connected draws in the hub color
ACTIVE_NODE_SCALE = 1.3       # how much a focused note swells
VELOCITY_DECAY = 0.55         # d3 velocityDecay(0.45) → v *= 1 - 0.45
ALPHA_DECAY = 0.03            # GraphPanel's simulation settings
ALPHA_MIN = 0.001             # d3 default
THETA2 = 0.81                 # d3 Barnes-Hut default (theta 0.9); --fast widens it
DIST_MIN2 = 1.0               # d3 forceManyBody distanceMin² default
INIT_SPREAD = 800.0           # buildGraph(): (random()-0.5) * 800

# Still-image tuning — where a static print wants different defaults than an
# interactive canvas (you can't zoom a PNG). Each is a CLI flag whose help names
# the interactive value; the physics above stay the verbatim port.
STILL_CHARGE = -130.0    # stronger repulsion opens the core up: on a dense vault
#                          the app's -50 fuses the middle into fog at overview
STILL_NODE_BOOST = 1.15  # a touch more node presence against the edge haze
STILL_EDGE_DAMP = 0.72   # dims the theme's link alpha for stills: tens of
#                          thousands of overlapping edges accumulate into fog at
#                          the interactive alpha (an explicit --edge-alpha wins)
STILL_FOLDER_COLORS = True  # the default style: a hue per top-level folder /
#                          Areas/<Area> (2+ folders only — a flat vault stays
#                          monochrome). --no-color-by-folder restores the gray
#                          notes + sand hubs of the interactive canvas.
STILL_HUB_SHARE = 0.08   # hub color for at most the top ~8% by degree — on a
#                          dense vault a fixed degree-14 cutoff would turn half
#                          the picture sand; sparse vaults keep the app's rule
COLLIDE_GAP_REF = 1.2    # no-overlap pass: clear space between disk edges, in px
#                          at the min(W,H)=1500 reference frame (≈2.4px at 4K).
#                          Also absorbs the ~2% fit-frame difference of the HTML
#                          export's 0.9-viewport mapping, so the guarantee holds
#                          there at default zoom too
COLLIDE_SETTLE = 0.3     # overshoot past exact touching, in layout units, so a
#                          projected pair lands with real slack instead of on
#                          the exact enforcement boundary
COLLIDE_QUANT = 2        # decimals positions are quantized to before each
#                          verification round — the same precision cache_save
#                          keeps, so the zero-overlap certificate is issued on
#                          exactly the values a warm run reloads and a healed
#                          layout re-checks bit-identically (no re-easing churn)
COLLIDE_SWEEPS = 80      # relaxation sweeps per round (typically <10 are used)
COLLIDE_ROUNDS = 8       # separate → re-fit → re-check cycles: pushing nodes
#                          apart can grow the bbox, which shrinks the fit scale
#                          and thus grows every radius in layout units — loop
#                          until one full sweep passes clean at the final fit

# renderer/lib/graphColors.ts — the shared 2D/3D palette — plus an Obsidian-
# flavored theme (the stock graph view's grays) and a light counterpart.
THEMES = {
    "desktop": dict(bg=(12, 12, 12), node=(183, 189, 201), hub=(240, 216, 168),
                    link=(110, 116, 135), link_alpha=0.5, label=(215, 217, 225),
                    focus=(116, 183, 255), neighbor=(207, 224, 255),
                    dim=(120, 125, 142), dim_alpha=0.32,
                    edge_active=(130, 187, 255), edge_active_alpha=0.85),
    "obsidian": dict(bg=(30, 30, 30), node=(172, 174, 178), hub=(172, 174, 178),
                     link=(108, 110, 114), link_alpha=0.45, label=(198, 200, 205),
                     focus=(127, 109, 242), neighbor=(178, 168, 250),
                     dim=(110, 112, 116), dim_alpha=0.35,
                     edge_active=(148, 132, 246), edge_active_alpha=0.85),
    "light": dict(bg=(246, 247, 249), node=(96, 102, 116), hub=(206, 156, 62),
                  link=(148, 152, 164), link_alpha=0.5, label=(70, 74, 84),
                  focus=(36, 120, 220), neighbor=(120, 170, 235),
                  dim=(168, 172, 182), dim_alpha=0.5,
                  edge_active=(36, 120, 220), edge_active_alpha=0.8),
}

CACHE_REL = ".state/cache/graph-image-layout.json"


def today_local() -> dt.date:
    """Match the digest's notion of 'today' (DIGEST_TODAY) so a sweep-wired run
    stamps the right calendar day."""
    iso = os.environ.get("DIGEST_TODAY")
    if iso:
        try:
            return dt.date.fromisoformat(iso)
        except ValueError:
            pass
    return dt.date.today()


def base_size(degree: int) -> float:
    """graphForces.ts baseSize(): 1 + min(sqrt(degree) * 0.4, 5)."""
    return 1.0 + min(math.sqrt(degree) * 0.4, 5.0)


def hub_cutoff(snap: dict) -> int:
    """The degree at which a note draws in the hub color: the app's fixed
    HUB_DEGREE, raised on dense vaults so at most ~STILL_HUB_SHARE of notes
    qualify (a fixed 14 turns half of a heavily-linked vault sand)."""
    degs = sorted((snap["degree"][n] for n in snap["nodes"]
                   if not n.startswith("?:")), reverse=True)
    if not degs:
        return HUB_DEGREE
    q = degs[min(len(degs) - 1, max(0, int(len(degs) * STILL_HUB_SHARE)))]
    return max(HUB_DEGREE, q + 1) if q >= HUB_DEGREE else HUB_DEGREE


# ── The graph ────────────────────────────────────────────────────────────────

def load_snapshot(vault: Path) -> dict:
    from alexandria_vault.vault_core.graph import graph_snapshot
    return graph_snapshot(vault)


def add_phantoms(snap: dict) -> None:
    """Fold unresolved targets in as phantom nodes (Obsidian's faded
    not-yet-created notes). Phantom ids carry a ``?:`` prefix no real
    vault-relative path can have."""
    from alexandria_vault.vault_core.wikilink import resolve_key
    labels, refs = {}, {}
    for src, targets in snap["unresolved"].items():
        for t in targets:
            key = "?:" + resolve_key(t)
            labels.setdefault(key, t)
            refs.setdefault(key, set()).add(src)
    for key in sorted(labels):
        snap["nodes"].append(key)
        snap["degree"][key] = 0
    known = set(snap["degree"])
    for key in sorted(labels):
        for src in sorted(refs[key]):
            if src in known:
                snap["edges"].append((src, key))
                snap["degree"][src] += 1
                snap["degree"][key] += 1
    snap["phantom_labels"] = labels


def stem_of(rel: str) -> str:
    if rel.startswith("?:"):
        return rel[2:]
    return os.path.splitext(os.path.basename(rel))[0]


def resolve_focus(name: str, snap: dict):
    """A --focus argument → one node id, matching the wikilink resolver's
    semantics (path, then basename/alias-free stem, case-insensitive)."""
    from alexandria_vault.vault_core.wikilink import resolve_key
    nodes = snap["nodes"]
    low = name.lower().lstrip("/")
    exact = [n for n in nodes if n.lower() in (low, low + ".md")]
    if len(exact) == 1:
        return exact[0], []
    key = resolve_key(name)
    cands = [n for n in nodes if not n.startswith("?:") and resolve_key(stem_of(n)) == key]
    if len(cands) == 1:
        return cands[0], []
    return None, (exact or cands)


def focus_subgraph(snap: dict, focus: str, hops: int) -> dict:
    adj = {}
    for s, t in snap["edges"]:
        adj.setdefault(s, set()).add(t)
        adj.setdefault(t, set()).add(s)
    keep, ring, dist = {focus}, {focus}, {focus: 0}
    for h in range(1, hops + 1):
        nxt = set()
        for n in ring:
            for m in adj.get(n, ()):
                if m not in keep:
                    nxt.add(m)
                    dist[m] = h
        keep |= nxt
        ring = nxt
    nodes = [n for n in snap["nodes"] if n in keep]
    edges = [(s, t) for s, t in snap["edges"] if s in keep and t in keep]
    out = dict(snap)
    out.update(nodes=nodes, edges=edges, focus=focus, hop_dist=dist)
    return out


def components(nodes, edges) -> int:
    idx = {n: i for i, n in enumerate(nodes)}
    parent = list(range(len(nodes)))

    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]
            a = parent[a]
        return a

    for s, t in edges:
        ra, rb = find(idx[s]), find(idx[t])
        if ra != rb:
            parent[ra] = rb
    return len({find(i) for i in range(len(nodes))})


# ── Layout: the d3-force port ────────────────────────────────────────────────

def _bh_charge(x, y, vx, vy, alpha, strength, theta2, rng):
    """One forceManyBody pass — Barnes-Hut over a bucketed quadtree.

    Matches d3's model: force on a node from a far region is
    ``value * alpha / l`` along the offset (l = squared distance), regions are
    "far" when width²/theta² < l, interactions stop at distanceMax and soften
    inside distanceMin. Leaves bucket up to 8 points (opened leaves go exact),
    which keeps the tree shallow — this is the hot loop of the whole module.
    """
    n = len(x)
    minx, maxx = min(x), max(x)
    miny, maxy = min(y), max(y)
    size = max(maxx - minx, maxy - miny, 1e-9)
    dmax2 = CHARGE_DISTANCE_MAX * CHARGE_DISTANCE_MAX

    # Bulk top-down build: parallel lists beat objects by a wide margin here.
    QTHR, QCX, QCY, QVAL, QKIDS, QPTS = [], [], [], [], [], []

    def alloc(s):
        QTHR.append((s * s) / theta2)
        QCX.append(0.0)
        QCY.append(0.0)
        QVAL.append(0.0)
        QKIDS.append(None)
        QPTS.append(None)
        return len(QTHR) - 1

    root = alloc(size)
    work = [(root, list(range(n)), minx, miny, size)]
    while work:
        qi, pts, x0, y0, s = work.pop()
        if len(pts) <= 8 or s < 1e-3:
            QPTS[qi] = pts
            continue
        h = s * 0.5
        mx, my = x0 + h, y0 + h
        q = ([], [], [], [])
        for i in pts:
            q[(1 if x[i] >= mx else 0) | (2 if y[i] >= my else 0)].append(i)
        kids = []
        for ci, (cx0, cy0) in enumerate(((x0, y0), (mx, y0), (x0, my), (mx, my))):
            if q[ci]:
                child = alloc(h)
                kids.append(child)
                work.append((child, q[ci], cx0, cy0, h))
        QKIDS[qi] = kids

    # Aggregates, children-first (children always alloc after their parent).
    for qi in range(len(QTHR) - 1, -1, -1):
        pts = QPTS[qi]
        if pts is not None:
            cnt = len(pts)
            QCX[qi] = sum(x[i] for i in pts) / cnt
            QCY[qi] = sum(y[i] for i in pts) / cnt
            QVAL[qi] = strength * cnt
        else:
            w = cx = cy = 0.0
            for c in QKIDS[qi]:
                cw = abs(QVAL[c])
                w += cw
                cx += cw * QCX[c]
                cy += cw * QCY[c]
            QCX[qi], QCY[qi] = cx / w, cy / w
            QVAL[qi] = sum(QVAL[c] for c in QKIDS[qi])

    sqrt = math.sqrt
    sa = strength * alpha
    for i in range(n):
        xi, yi = x[i], y[i]
        fvx = fvy = 0.0
        stack = [root]
        pop = stack.pop
        push = stack.extend
        while stack:
            qi = pop()
            dx = QCX[qi] - xi
            dy = QCY[qi] - yi
            l = dx * dx + dy * dy
            if QTHR[qi] < l:
                if l < dmax2:
                    if l < DIST_MIN2:
                        l = sqrt(l) or 1e-6
                    f = QVAL[qi] * alpha / l
                    fvx += dx * f
                    fvy += dy * f
            elif QPTS[qi] is not None:
                for j in QPTS[qi]:
                    if j == i:
                        continue
                    dx = x[j] - xi
                    dy = y[j] - yi
                    l = dx * dx + dy * dy
                    if l == 0.0:
                        dx = (rng.random() - 0.5) * 1e-6
                        dy = (rng.random() - 0.5) * 1e-6
                        l = dx * dx + dy * dy
                    if l < dmax2:
                        if l < DIST_MIN2:
                            l = sqrt(l)
                        f = sa / l
                        fvx += dx * f
                        fvy += dy * f
            else:
                push(QKIDS[qi])
        vx[i] += fvx
        vy[i] += fvy


def _numpy_charge(np, x, y, vx, vy, alpha, strength):
    """Exact pairwise charge, chunked — used when numpy is importable. Visually
    identical to Barnes-Hut (it IS the force BH approximates) and ~10x faster."""
    X = np.asarray(x)
    Y = np.asarray(y)
    n = len(x)
    dmax2 = CHARGE_DISTANCE_MAX * CHARGE_DISTANCE_MAX
    accx = np.zeros(n)
    accy = np.zeros(n)
    block = max(256, int(4.0e6 / max(n, 1)))
    for s in range(0, n, block):
        e = min(n, s + block)
        dx = X[None, :] - X[s:e, None]
        dy = Y[None, :] - Y[s:e, None]
        l = dx * dx + dy * dy
        l[l == 0.0] = np.inf          # self (and exact-coincident) pairs drop out
        near = l < DIST_MIN2
        if near.any():
            l[near] = np.sqrt(l[near])
        f = (strength * alpha) / l
        f[l >= dmax2] = 0.0
        accx[s:e] = (dx * f).sum(axis=1)
        accy[s:e] = (dy * f).sum(axis=1)
    for i in range(n):
        vx[i] += accx[i]
        vy[i] += accy[i]


def run_layout(nodes, edges, degree, opts, warm=None, log=lambda s: None):
    """d3-force, headless: the exact force stack GraphPanel.tsx builds —
    charge, link, center, x, y — integrated on d3's alpha schedule. Returns
    ``(positions, ticks, engine)``."""
    n = len(nodes)
    rng = random.Random(opts.seed)
    idx = {nid: i for i, nid in enumerate(nodes)}
    # Initial positions hash from each note's PATH (not a sequence RNG): one new
    # note then nudges the settled picture locally instead of re-rolling every
    # other note's start — the composition stays recognizable as the vault
    # grows, with or without the warm cache. (The desktop seeds Math.random(),
    # which re-rolls per load; a still generator wants a stabler identity.)
    x = [0.0] * n
    y = [0.0] * n
    for nid, i in idx.items():
        h = hashlib.md5(("%d:%s" % (opts.seed, nid)).encode("utf-8")).digest()
        x[i] = (int.from_bytes(h[0:8], "big") / 2.0 ** 64 - 0.5) * INIT_SPREAD
        y[i] = (int.from_bytes(h[8:16], "big") / 2.0 ** 64 - 0.5) * INIT_SPREAD
    vx = [0.0] * n
    vy = [0.0] * n

    alpha0, decay, theta2 = 1.0, ALPHA_DECAY, THETA2
    if opts.fast:
        decay, theta2 = 0.06, 1.69
    if warm:
        placed = 0
        for nid, i in idx.items():
            p = warm.get(nid)
            if p:
                x[i], y[i] = p[0], p[1]
                placed += 1
        if placed == n and not opts.relayout:
            return {nid: (x[i], y[i]) for nid, i in idx.items()}, 0, "cached"
        # New notes start where their placed neighbors sit (plus a jitter), so
        # the settled picture shifts locally instead of re-rolling globally.
        if placed:
            adj = {}
            for s, t in edges:
                adj.setdefault(s, []).append(t)
                adj.setdefault(t, []).append(s)
            for nid, i in idx.items():
                if nid in warm:
                    continue
                anchors = [warm[m] for m in adj.get(nid, ()) if m in warm]
                if anchors:
                    x[i] = sum(a[0] for a in anchors) / len(anchors) + (rng.random() - 0.5) * 60
                    y[i] = sum(a[1] for a in anchors) / len(anchors) + (rng.random() - 0.5) * 60
            alpha0, decay = 0.12, 0.05

    # forceLink internals: per-link strength mult/max(1, min(deg)) (the
    # Obsidian/d3 hub normalization, graphForces.ts makeLinkStrength) and d3's
    # positional bias so light nodes move more than hubs.
    m = len(edges)
    ls = [0] * m
    lt = [0] * m
    lstr = [0.0] * m
    lbias = [0.0] * m
    for k, (s, t) in enumerate(edges):
        si, ti = idx[s], idx[t]
        ds, dtg = degree[s], degree[t]
        ls[k], lt[k] = si, ti
        lstr[k] = LINK_FORCE_MULT / max(1, min(ds, dtg))
        lbias[k] = ds / (ds + dtg) if (ds + dtg) else 0.5

    engine = "python-bh"
    np = None
    if opts.engine in ("auto", "numpy"):
        try:
            import numpy as np  # noqa: F401
            engine = "numpy"
        except ImportError:
            np = None
            if opts.engine == "numpy":
                sys.exit("--engine numpy: numpy is not importable here")

    est = int(math.log(ALPHA_MIN / alpha0) / math.log(1.0 - decay)) + 1
    if opts.ticks:
        est = min(est, opts.ticks)
    alpha, tick, t0 = alpha0, 0, time.time()
    sqrt = math.sqrt
    while True:
        alpha += (0.0 - alpha) * decay
        if np is not None:
            _numpy_charge(np, x, y, vx, vy, alpha, opts.charge)
        else:
            _bh_charge(x, y, vx, vy, alpha, opts.charge, theta2, rng)
        for k in range(m):
            si, ti = ls[k], lt[k]
            dx = x[ti] + vx[ti] - x[si] - vx[si]
            dy = y[ti] + vy[ti] - y[si] - vy[si]
            l = sqrt(dx * dx + dy * dy)
            if l == 0.0:
                dx = (rng.random() - 0.5) * 1e-6
                dy = (rng.random() - 0.5) * 1e-6
                l = sqrt(dx * dx + dy * dy)
            f = (l - LINK_DISTANCE) / l * alpha * lstr[k]
            dx *= f
            dy *= f
            b = lbias[k]
            vx[ti] -= dx * b
            vy[ti] -= dy * b
            vx[si] += dx * (1.0 - b)
            vy[si] += dy * (1.0 - b)
        # forceCenter (strength 1): shift positions so the mean sits at 0,0 —
        # then forceX/forceY + integration, folded into one pass per node.
        sx = sum(x) / n
        sy = sum(y) / n
        ka = XY_STRENGTH * alpha
        for i in range(n):
            px = x[i] - sx
            py = y[i] - sy
            v = (vx[i] - px * ka) * VELOCITY_DECAY
            w = (vy[i] - py * ka) * VELOCITY_DECAY
            x[i] = px + v
            y[i] = py + w
            vx[i] = v
            vy[i] = w
        tick += 1
        if tick % 25 == 0:
            log("arranging %d nodes… tick %d/%d  α=%.3f  (%.1fs)"
                % (n, tick, est, alpha, time.time() - t0))
        if alpha < ALPHA_MIN or (opts.ticks and tick >= opts.ticks):
            break
    return {nid: (x[i], y[i]) for nid, i in idx.items()}, tick, engine


def resolve_overlaps(snap, pos, opts):
    """The no-overlap guarantee: ease any two touching notes apart, in place.

    Runs after the physics (and after a cache load — a cached layout never
    ticks, so this can't live inside the simulation). Radii are the render's
    own pixel radii converted to layout units through the same fit mapping
    ``render()`` applies, plus COLLIDE_GAP_REF of clear space; that ratio is
    output-size-invariant (radius and fit both scale with min(W,H)), so one
    pass covers the PNG, the SVG, and the HTML page at default zoom. Overlaps
    resolve by full positional projection with d3-forceCollide's r² mass split
    (the larger disk moves less — hubs hold, leaves ease around them), swept
    over a uniform grid until a whole sweep finds nothing; separation can grow
    the bbox and thus shrink the fit, so the sweep re-runs with re-fitted radii
    until one passes clean end to end. Deterministic (stable order, seeded
    tie-break jitter). Returns ``(nodes_moved, sweeps)``.
    """
    nodes = snap["nodes"]
    n = len(nodes)
    if n <= 1:
        return 0, 0
    focus = snap.get("focus")
    mn = float(min(opts.width, opts.height))
    scale_ref = mn / 1500.0
    deg = snap["degree"]
    half_gap = 0.5 * COLLIDE_GAP_REF * scale_ref
    rpx = [scale_ref * NODE_SIZE_MULT * STILL_NODE_BOOST * base_size(deg[nid])
           * opts.node_scale * (ACTIVE_NODE_SCALE if nid == focus else 1.0)
           + half_gap for nid in nodes]
    pad = (0.035 * mn + max(rpx)
           + (26.0 * scale_ref if (opts.labels or focus) else 0.0))
    x = [pos[nid][0] for nid in nodes]
    y = [pos[nid][1] for nid in nodes]
    rng = random.Random(opts.seed ^ 0x5DEECE66)
    sqrt = math.sqrt
    moved, sweeps = set(), 0
    for _round in range(COLLIDE_ROUNDS):
        # Quantize to the cache's precision first: a round that then finds
        # nothing certifies the exact values cache_save writes and a later
        # run reloads, so the check replays bit-identically and stays quiet.
        for i in range(n):
            x[i] = round(x[i], COLLIDE_QUANT)
            y[i] = round(y[i], COLLIDE_QUANT)
        spanx = max(max(x) - min(x), 1e-6)
        spany = max(max(y) - min(y), 1e-6)
        s = min((opts.width - 2.0 * pad) / spanx, (opts.height - 2.0 * pad) / spany)
        if s <= 0:  # canvas smaller than its own padding — nothing to enforce
            break
        R = [r / s for r in rpx]
        cell = 2.0 * max(R)  # any colliding pair sits within adjacent cells
        hits_round = 0
        for _sweep in range(COLLIDE_SWEEPS):
            grid = {}
            for i in range(n):
                grid.setdefault((int(x[i] / cell), int(y[i] / cell)), []).append(i)
            hits = 0
            for i in range(n):
                xi, yi, ri = x[i], y[i], R[i]
                gx, gy = int(xi / cell), int(yi / cell)
                for cx in (gx - 1, gx, gx + 1):
                    for cy in (gy - 1, gy, gy + 1):
                        for j in grid.get((cx, cy), ()):
                            if j <= i:
                                continue
                            dx = x[j] - xi
                            dy = y[j] - yi
                            mind = ri + R[j]
                            d2 = dx * dx + dy * dy
                            if d2 >= mind * mind:
                                continue
                            d = sqrt(d2)
                            if d < 1e-9:  # coincident: split on a seeded bearing
                                a = rng.random() * 6.2832
                                dx, dy, d = math.cos(a), math.sin(a), 1.0
                                push = mind + COLLIDE_SETTLE
                            else:
                                push = mind - d + COLLIDE_SETTLE
                            rj = R[j]
                            wj = ri * ri / (ri * ri + rj * rj)
                            f = push / d
                            x[j] += dx * f * wj
                            y[j] += dy * f * wj
                            x[i] -= dx * f * (1.0 - wj)
                            y[i] -= dy * f * (1.0 - wj)
                            xi, yi = x[i], y[i]
                            moved.add(i)
                            moved.add(j)
                            hits += 1
            sweeps += 1
            hits_round += hits
            if hits == 0:
                # A clean sweep over a freshly built grid: nothing moved during
                # it, so the grid stayed accurate — a true zero-overlap pass.
                break
        if hits_round == 0:
            break
    if moved:
        for i, nid in enumerate(nodes):
            pos[nid] = (x[i], y[i])
    return len(moved), sweeps


# ── Layout cache ─────────────────────────────────────────────────────────────

def _cache_key(opts) -> str:
    basis = [1, opts.seed, opts.fast, opts.include_unresolved,
             LINK_DISTANCE, opts.charge, XY_STRENGTH]
    return hashlib.sha1(json.dumps(basis).encode()).hexdigest()[:16]


def cache_load(vault: Path, opts):
    if opts.no_cache or opts.focus:
        return None
    try:
        d = json.loads((vault / CACHE_REL).read_text(encoding="utf-8"))
        if d.get("key") == _cache_key(opts):
            return {k: (v[0], v[1]) for k, v in d["positions"].items()}
    except Exception:
        pass
    return None


def cache_save(vault: Path, opts, pos) -> None:
    if opts.no_cache or opts.focus:
        return
    try:
        p = vault / CACHE_REL
        p.parent.mkdir(parents=True, exist_ok=True)
        d = {"key": _cache_key(opts),
             # COLLIDE_QUANT decimals: the no-overlap pass certifies positions
             # at exactly this precision, so saving must not re-round them.
             "positions": {k: [round(v[0], COLLIDE_QUANT), round(v[1], COLLIDE_QUANT)]
                           for k, v in pos.items()}}
        p.write_text(json.dumps(d), encoding="utf-8")
    except OSError:
        pass


# ── Raster canvas: AA primitives + a PNG encoder, stdlib only ────────────────

class Canvas:
    def __init__(self, w, h, bg):
        self.w, self.h = w, h
        self.buf = bytearray(bytes(bg) * (w * h))

    def line(self, x0, y0, x1, y1, color, alpha):
        """Xiaolin-Wu antialiased 1px line, alpha-composited."""
        buf, w, h = self.buf, self.w, self.h
        cr, cg, cb = color
        steep = abs(y1 - y0) > abs(x1 - x0)
        if steep:
            x0, y0, x1, y1 = y0, x0, y1, x1
        if x0 > x1:
            x0, x1, y0, y1 = x1, x0, y1, y0
        dx = x1 - x0
        grad = (y1 - y0) / dx if dx else 0.0
        xs = int(x0 + 0.5)
        xe = int(x1 + 0.5)
        inter = y0 + grad * (xs - x0)
        if steep:
            for px in range(xs, xe + 1):
                iy = int(inter) if inter >= 0 else int(inter) - 1
                fr = inter - iy
                for py, a in ((iy, (1.0 - fr) * alpha), (iy + 1, fr * alpha)):
                    if 0 <= py < w and 0 <= px < h and a > 0.004:
                        i = (px * w + py) * 3
                        buf[i] += int((cr - buf[i]) * a)
                        buf[i + 1] += int((cg - buf[i + 1]) * a)
                        buf[i + 2] += int((cb - buf[i + 2]) * a)
                inter += grad
        else:
            for px in range(xs, xe + 1):
                iy = int(inter) if inter >= 0 else int(inter) - 1
                fr = inter - iy
                for py, a in ((iy, (1.0 - fr) * alpha), (iy + 1, fr * alpha)):
                    if 0 <= px < w and 0 <= py < h and a > 0.004:
                        i = (py * w + px) * 3
                        buf[i] += int((cr - buf[i]) * a)
                        buf[i + 1] += int((cg - buf[i + 1]) * a)
                        buf[i + 2] += int((cb - buf[i + 2]) * a)
                inter += grad

    def edge(self, x0, y0, x1, y1, color, alpha, thickness):
        """A link: thin edges are one alpha-scaled Wu line, thicker ones a fan
        of parallel lines offset along the perpendicular."""
        if thickness <= 1.2:
            self.line(x0, y0, x1, y1, color, alpha * max(0.05, min(1.0, thickness)))
            return
        k = int(math.ceil(thickness))
        dx, dy = x1 - x0, y1 - y0
        l = math.hypot(dx, dy) or 1.0
        px, py = -dy / l, dx / l
        a = alpha * min(1.0, thickness / k)  # total ink stays ∝ thickness·alpha
        for j in range(k):
            off = (j - (k - 1) / 2.0)
            self.line(x0 + px * off, y0 + py * off, x1 + px * off, y1 + py * off,
                      color, a)

    def circle(self, cx, cy, r, color, alpha):
        buf, w, h = self.buf, self.w, self.h
        cr, cg, cb = color
        x0 = max(0, int(cx - r - 1))
        x1 = min(w - 1, int(cx + r + 1))
        y0 = max(0, int(cy - r - 1))
        y1 = min(h - 1, int(cy + r + 1))
        sqrt = math.sqrt
        for py in range(y0, y1 + 1):
            dy = py + 0.5 - cy
            dy2 = dy * dy
            row = py * w
            for px in range(x0, x1 + 1):
                dx = px + 0.5 - cx
                cov = r - sqrt(dx * dx + dy2) + 0.5
                if cov <= 0.0:
                    continue
                a = alpha if cov >= 1.0 else alpha * cov
                i = (row + px) * 3
                buf[i] += int((cr - buf[i]) * a)
                buf[i + 1] += int((cg - buf[i + 1]) * a)
                buf[i + 2] += int((cb - buf[i + 2]) * a)

    def glyph_text(self, x, y, text, scale, color, alpha):
        """Small-caps 5x7 bitmap text (labels). Returns the advance width."""
        cx = x
        for ch in text:
            rows = FONT.get(ch.upper())
            if rows is None:
                continue
            for ry, bits in enumerate(rows):
                for rx, b in enumerate(bits):
                    if b == "1":
                        self._rect(cx + rx * scale, y + ry * scale, scale, color, alpha)
            cx += 6 * scale
        return cx - x

    def _rect(self, x, y, s, color, alpha):
        buf, w, h = self.buf, self.w, self.h
        cr, cg, cb = color
        for py in range(y, y + s):
            if py < 0 or py >= h:
                continue
            for px in range(x, x + s):
                if px < 0 or px >= w:
                    continue
                i = (py * w + px) * 3
                buf[i] += int((cr - buf[i]) * alpha)
                buf[i + 1] += int((cg - buf[i + 1]) * alpha)
                buf[i + 2] += int((cb - buf[i + 2]) * alpha)

    def downsample(self, factor):
        if factor <= 1:
            return self
        w2, h2 = self.w // factor, self.h // factor
        out = Canvas(w2, h2, (0, 0, 0))
        src, dst = self.buf, out.buf
        stride = self.w * 3
        f2 = factor * factor
        half = f2 // 2
        oi = 0
        for yy in range(h2):
            rows = [(yy * factor + k) * stride for k in range(factor)]
            for xx in range(w2):
                base = xx * factor * 3
                for c in range(3):
                    s = 0
                    for r0 in rows:
                        i = r0 + base + c
                        for k in range(factor):
                            s += src[i + k * 3]
                    dst[oi] = (s + half) // f2
                    oi += 1
        return out

    def write_png(self, path):
        def chunk(tag, data):
            return (struct.pack(">I", len(data)) + tag + data
                    + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
        stride = self.w * 3
        raw = bytearray()
        for yy in range(self.h):
            raw.append(0)  # filter: None
            raw += self.buf[yy * stride:(yy + 1) * stride]
        ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 2, 0, 0, 0)
        with open(path, "wb") as f:
            f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
                    + chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b""))


# 5x7 small-caps font for hub labels — lowercase renders as small caps.
FONT = {
    "A": ("01110", "10001", "10001", "11111", "10001", "10001", "10001"),
    "B": ("11110", "10001", "10001", "11110", "10001", "10001", "11110"),
    "C": ("01110", "10001", "10000", "10000", "10000", "10001", "01110"),
    "D": ("11100", "10010", "10001", "10001", "10001", "10010", "11100"),
    "E": ("11111", "10000", "10000", "11110", "10000", "10000", "11111"),
    "F": ("11111", "10000", "10000", "11110", "10000", "10000", "10000"),
    "G": ("01110", "10001", "10000", "10111", "10001", "10001", "01111"),
    "H": ("10001", "10001", "10001", "11111", "10001", "10001", "10001"),
    "I": ("01110", "00100", "00100", "00100", "00100", "00100", "01110"),
    "J": ("00111", "00010", "00010", "00010", "00010", "10010", "01100"),
    "K": ("10001", "10010", "10100", "11000", "10100", "10010", "10001"),
    "L": ("10000", "10000", "10000", "10000", "10000", "10000", "11111"),
    "M": ("10001", "11011", "10101", "10101", "10001", "10001", "10001"),
    "N": ("10001", "11001", "10101", "10011", "10001", "10001", "10001"),
    "O": ("01110", "10001", "10001", "10001", "10001", "10001", "01110"),
    "P": ("11110", "10001", "10001", "11110", "10000", "10000", "10000"),
    "Q": ("01110", "10001", "10001", "10001", "10101", "10010", "01101"),
    "R": ("11110", "10001", "10001", "11110", "10100", "10010", "10001"),
    "S": ("01111", "10000", "10000", "01110", "00001", "00001", "11110"),
    "T": ("11111", "00100", "00100", "00100", "00100", "00100", "00100"),
    "U": ("10001", "10001", "10001", "10001", "10001", "10001", "01110"),
    "V": ("10001", "10001", "10001", "10001", "10001", "01010", "00100"),
    "W": ("10001", "10001", "10001", "10101", "10101", "10101", "01010"),
    "X": ("10001", "10001", "01010", "00100", "01010", "10001", "10001"),
    "Y": ("10001", "10001", "01010", "00100", "00100", "00100", "00100"),
    "Z": ("11111", "00001", "00010", "00100", "01000", "10000", "11111"),
    "0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
    "1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
    "2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
    "3": ("11110", "00001", "00001", "01110", "00001", "00001", "11110"),
    "4": ("00010", "00110", "01010", "10010", "11111", "00010", "00010"),
    "5": ("11111", "10000", "11110", "00001", "00001", "10001", "01110"),
    "6": ("00110", "01000", "10000", "11110", "10001", "10001", "01110"),
    "7": ("11111", "00001", "00010", "00100", "01000", "01000", "01000"),
    "8": ("01110", "10001", "10001", "01110", "10001", "10001", "01110"),
    "9": ("01110", "10001", "10001", "01111", "00001", "00010", "01100"),
    " ": ("00000", "00000", "00000", "00000", "00000", "00000", "00000"),
    "-": ("00000", "00000", "00000", "01110", "00000", "00000", "00000"),
    "+": ("00000", "00100", "00100", "11111", "00100", "00100", "00000"),
    ".": ("00000", "00000", "00000", "00000", "00000", "01100", "01100"),
    ",": ("00000", "00000", "00000", "00000", "00000", "00100", "01000"),
    "'": ("00100", "00100", "00000", "00000", "00000", "00000", "00000"),
    ":": ("00000", "00000", "00100", "00000", "00100", "00000", "00000"),
    "(": ("00010", "00100", "01000", "01000", "01000", "00100", "00010"),
    ")": ("01000", "00100", "00010", "00010", "00010", "00100", "01000"),
    "&": ("01100", "10010", "10010", "01100", "10101", "10010", "01101"),
    "/": ("00001", "00010", "00010", "00100", "01000", "01000", "10000"),
    "#": ("01010", "01010", "11111", "01010", "11111", "01010", "01010"),
    "?": ("01110", "10001", "00001", "00010", "00100", "00000", "00100"),
}


# ── Color plumbing ───────────────────────────────────────────────────────────

def hsl_rgb(h, s, l):
    c = (1 - abs(2 * l - 1)) * s
    xx = c * (1 - abs((h / 60.0) % 2 - 1))
    m = l - c / 2
    r, g, b = ((c, xx, 0), (xx, c, 0), (0, c, xx),
               (0, xx, c), (xx, 0, c), (c, 0, xx))[int(h // 60) % 6]
    return (int((r + m) * 255), int((g + m) * 255), int((b + m) * 255))


def parse_hex(s):
    s = s.lstrip("#")
    if len(s) == 3:
        s = "".join(ch * 2 for ch in s)
    return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))


def folder_of(rel: str) -> str:
    parts = rel.split("/")
    if len(parts) <= 1:
        return "(root)"
    if parts[0] == "Areas" and len(parts) >= 3:
        return "Areas/" + parts[1]
    return parts[0]


def build_groups(nodes, opts, theme_name):
    """Path-prefix → color, Obsidian color-groups style. --group entries win
    over --color-by-folder's generated hues; longest matching prefix wins."""
    groups = {}
    if opts.color_by_folder:
        folders = sorted({folder_of(n) for n in nodes if not n.startswith("?:")})
        if len(folders) >= 2:  # a flat vault stays monochrome
            light = theme_name == "light"
            for i, f in enumerate(folders):
                groups[f] = hsl_rgb((i * 137.508) % 360.0, 0.42, 0.45 if light else 0.62)
    for spec in opts.group or ():
        if "=" not in spec:
            sys.exit("--group expects PREFIX=#RRGGBB, got %r" % spec)
        prefix, _, color = spec.partition("=")
        groups[prefix.strip().strip("/")] = parse_hex(color.strip())
    return groups


def node_style(nid, snap, groups, t, hub_deg):
    """(color, alpha) for one node — focus state, groups, phantoms, hubs; the
    one styling truth shared by the PNG, SVG, and HTML renderers."""
    focus = snap.get("focus")
    hop = snap.get("hop_dist", {})
    if nid.startswith("?:"):
        return t["dim"], 0.45
    if focus:
        if nid == focus:
            return t["focus"], 1.0
        if hop.get(nid) == 1:
            return t["neighbor"], 1.0
        if hop.get(nid, 99) >= 2:
            gc = group_color(nid, groups)
            return (gc or t["dim"]), t["dim_alpha"] + 0.25
    gc = group_color(nid, groups)
    if gc is not None:
        return gc, 1.0
    if snap["degree"][nid] >= hub_deg:
        return t["hub"], 1.0
    return t["node"], 1.0


def group_color(rel, groups):
    if not groups or rel.startswith("?:"):
        return None
    best, best_len = None, -1
    for prefix, color in groups.items():
        if prefix == "(root)":
            if "/" not in rel and best_len < 0:
                best, best_len = color, 0
        elif (rel == prefix or rel.startswith(prefix + "/")) and len(prefix) > best_len:
            best, best_len = color, len(prefix)
    return best


# ── Rendering ────────────────────────────────────────────────────────────────

def render(snap, pos, opts, theme):
    t = THEMES[theme]
    ss = opts.ss
    W, H = opts.width * ss, opts.height * ss
    scale_ref = ss * min(opts.width, opts.height) / 1500.0
    groups = build_groups(snap["nodes"], opts, theme)
    focus = snap.get("focus")
    hop = snap.get("hop_dist", {})

    nodes = snap["nodes"]
    hub_deg = hub_cutoff(snap)
    radii = {}
    for nid in nodes:
        r = (scale_ref * NODE_SIZE_MULT * STILL_NODE_BOOST
             * base_size(snap["degree"][nid]) * opts.node_scale)
        if nid == focus:
            r *= ACTIVE_NODE_SCALE
        radii[nid] = r
    rmax = max(radii.values()) if radii else 2.0

    xs = [pos[n][0] for n in nodes]
    ys = [pos[n][1] for n in nodes]
    minx, maxx = min(xs), max(xs)
    miny, maxy = min(ys), max(ys)
    pad = 0.035 * min(W, H) + rmax + (26 * scale_ref if opts.labels or focus else 0)
    spanx = max(maxx - minx, 1e-6)
    spany = max(maxy - miny, 1e-6)
    scale = min((W - 2 * pad) / spanx, (H - 2 * pad) / spany)
    offx = (W - scale * (minx + maxx)) / 2.0
    offy = (H - scale * (miny + maxy)) / 2.0

    def P(nid):
        px, py = pos[nid]
        return px * scale + offx, py * scale + offy

    link_alpha = (opts.edge_alpha if opts.edge_alpha is not None
                  else t["link_alpha"] * STILL_EDGE_DAMP)
    thickness = LINK_THICKNESS_PX * scale_ref * opts.edge_scale

    def node_paint(nid):
        return node_style(nid, snap, groups, t, hub_deg)

    cv = Canvas(W, H, t["bg"])
    active_edges = []
    for s, tg in snap["edges"]:
        if focus and (s == focus or tg == focus):
            active_edges.append((s, tg))
            continue
        x0, y0 = P(s)
        x1, y1 = P(tg)
        a = link_alpha * (0.7 if tg.startswith("?:") or s.startswith("?:") else 1.0)
        cv.edge(x0, y0, x1, y1, t["link"], a, thickness)
    for s, tg in active_edges:
        x0, y0 = P(s)
        x1, y1 = P(tg)
        cv.edge(x0, y0, x1, y1, t["edge_active"], t["edge_active_alpha"],
                max(thickness * 1.5, 1.0))

    for nid in sorted(nodes, key=lambda n: radii[n]):
        color, alpha = node_paint(nid)
        x0, y0 = P(nid)
        cv.circle(x0, y0, radii[nid], color, alpha)

    labeled = []
    if opts.labels or focus:
        want = []
        if focus:
            want.append(focus)
        real = [n for n in nodes if not n.startswith("?:") and n != focus]
        real.sort(key=lambda n: (-snap["degree"][n], n))
        want += real[:opts.labels]
        fs = max(1, int(round(scale_ref)))  # glyph pixel scale
        occupied = []
        for nid in want:
            text = stem_of(nid)
            if len(text) > 28:
                text = text[:27] + "."
            tw = len(text) * 6 * fs
            x0, y0 = P(nid)
            lx = int(x0 - tw / 2)
            ly = int(y0 + radii[nid] + 3 * fs)
            box = (lx - 2, ly - 2, lx + tw + 2, ly + 7 * fs + 2)
            if any(not (box[2] < o[0] or box[0] > o[2] or box[3] < o[1] or box[1] > o[3])
                   for o in occupied):
                continue
            occupied.append(box)
            for dx0, dy0 in ((-fs, 0), (fs, 0), (0, -fs), (0, fs)):
                cv.glyph_text(lx + dx0, ly + dy0, text, fs, t["bg"], 0.75)
            cv.glyph_text(lx, ly, text, fs,
                          t["label"] if nid != focus else t["focus"], 0.95)
            labeled.append(nid)

    if ss > 1:
        cv = cv.downsample(ss)
    return cv, labeled


def render_svg(snap, pos, opts, theme, path):
    t = THEMES[theme]
    W, H = opts.width, opts.height
    saved_ss = opts.ss
    opts.ss = 1
    try:
        scale_ref = min(W, H) / 1500.0
        groups = build_groups(snap["nodes"], opts, theme)
        focus = snap.get("focus")
        hop = snap.get("hop_dist", {})
        nodes = snap["nodes"]
        hub_deg = hub_cutoff(snap)
        radii = {n: scale_ref * NODE_SIZE_MULT * STILL_NODE_BOOST
                 * base_size(snap["degree"][n])
                 * opts.node_scale * (ACTIVE_NODE_SCALE if n == focus else 1.0)
                 for n in nodes}
        xs = [pos[n][0] for n in nodes]
        ys = [pos[n][1] for n in nodes]
        minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys)
        pad = 0.035 * min(W, H) + (max(radii.values()) if radii else 2)
        scale = min((W - 2 * pad) / max(maxx - minx, 1e-6),
                    (H - 2 * pad) / max(maxy - miny, 1e-6))
        offx = (W - scale * (minx + maxx)) / 2.0
        offy = (H - scale * (miny + maxy)) / 2.0
        link_alpha = (opts.edge_alpha if opts.edge_alpha is not None
                      else t["link_alpha"] * STILL_EDGE_DAMP)
        thickness = max(LINK_THICKNESS_PX * scale_ref * opts.edge_scale, 0.25)

        def rgb(c):
            return "rgb(%d,%d,%d)" % c

        out = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d">' % (W, H),
               '<rect width="%d" height="%d" fill="%s"/>' % (W, H, rgb(t["bg"])),
               '<g stroke="%s" stroke-opacity="%.2f" stroke-width="%.2f">'
               % (rgb(t["link"]), link_alpha, thickness)]
        for s, tg in snap["edges"]:
            if focus and (s == focus or tg == focus):
                continue
            out.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>'
                       % (pos[s][0] * scale + offx, pos[s][1] * scale + offy,
                          pos[tg][0] * scale + offx, pos[tg][1] * scale + offy))
        out.append("</g>")
        if focus:
            out.append('<g stroke="%s" stroke-opacity="%.2f" stroke-width="%.2f">'
                       % (rgb(t["edge_active"]), t["edge_active_alpha"], thickness * 1.5))
            for s, tg in snap["edges"]:
                if s == focus or tg == focus:
                    out.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>'
                               % (pos[s][0] * scale + offx, pos[s][1] * scale + offy,
                                  pos[tg][0] * scale + offx, pos[tg][1] * scale + offy))
            out.append("</g>")
        for nid in sorted(nodes, key=lambda n: radii[n]):
            color, alpha = node_style(nid, snap, groups, t, hub_deg)
            title = stem_of(nid).replace("&", "&amp;").replace("<", "&lt;")
            out.append('<circle cx="%.1f" cy="%.1f" r="%.2f" fill="%s" fill-opacity="%.2f">'
                       '<title>%s</title></circle>'
                       % (pos[nid][0] * scale + offx, pos[nid][1] * scale + offy,
                          radii[nid], rgb(color), alpha, title))
        out.append("</svg>")
        Path(path).write_text("\n".join(out), encoding="utf-8")
    finally:
        opts.ss = saved_ss


# ── Interactive HTML export ──────────────────────────────────────────────────
# One self-contained page: the settled layout + a canvas renderer with the
# desktop app's hover semantics (hover = label + PEAK location + lit
# neighborhood, everything else dimmed; click pins; drag pans; wheel zooms;
# double-click resets). No external assets, so it renders live anywhere a
# chat client shows HTML.

HTML_TEMPLATE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>__TITLE__</title>
<style>
  html,body{margin:0;height:100%;background:__BG__;overflow:hidden}
  #cv{display:block;width:100%;height:100%}
  .hud{position:fixed;font:12px/1.45 -apple-system,'Segoe UI',Roboto,sans-serif;color:#aeb4c2;user-select:none;pointer-events:none;z-index:2}
  #tip{display:none;max-width:420px;background:rgba(17,18,23,.94);border:1px solid rgba(255,255,255,.09);border-radius:8px;padding:9px 12px}
  #tip b{display:block;font-size:14px;color:#eaf2ff;font-weight:600;margin-bottom:2px}
  #tip span{color:#8d93a5;font-size:11px;display:block}
  #tip .pk{display:flex;align-items:center;gap:6px;font-size:12px;color:#c6cbd8;margin-bottom:1px}
  #tip .pk i{width:8px;height:8px;border-radius:50%;flex:none}
  #tip .pk em{font-style:normal}
  #menu{position:fixed;left:12px;top:10px;z-index:3;width:26px;height:26px;border-radius:6px;
    background:rgba(17,18,23,.92);border:1px solid rgba(255,255,255,.1);color:#aeb4c2;
    font-size:13px;line-height:1;cursor:pointer;display:none;align-items:center;justify-content:center;padding:0}
  #menu:hover{color:#eaf2ff;border-color:rgba(255,255,255,.22)}
  #legend{left:12px;top:44px;display:none;background:rgba(17,18,23,.85);border:1px solid rgba(255,255,255,.07);
    border-radius:7px;padding:7px 10px}
  #legend div{display:flex;align-items:center;gap:7px;margin:2px 0}
  #legend i{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
  #legend em{font-style:normal;color:#6e7382;margin-left:2px}
  #stats{left:14px;bottom:10px;color:#7b8090}
</style></head><body>
<canvas id="cv"></canvas>
<button id="menu" title="Show legend">&#9776;</button>
<div id="legend" class="hud"></div><div id="tip" class="hud"></div>
<div id="stats" class="hud">__STATS__</div>
<script>
const D=__DATA__;
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
const tip=document.getElementById('tip'),leg=document.getElementById('legend');
const n=D.X.length,adj=[];for(let i=0;i<n;i++)adj.push([]);
for(let i=0;i<D.E.length;i+=2){const a=D.E[i],b=D.E[i+1];adj[a].push(b);adj[b].push(a);}
for(let i=0;i<D.EA.length;i+=2){const a=D.EA[i],b=D.EA[i+1];adj[a].push(b);adj[b].push(a);}
const byDeg=Array.from({length:n},(_,i)=>i).sort((a,b)=>adj[b].length-adj[a].length);
const order=Array.from({length:n},(_,i)=>i).sort((a,b)=>D.R[a]-D.R[b]);
let minx=1e18,maxx=-1e18,miny=1e18,maxy=-1e18;
for(let i=0;i<n;i++){minx=Math.min(minx,D.X[i]);maxx=Math.max(maxx,D.X[i]);miny=Math.min(miny,D.Y[i]);maxy=Math.max(maxy,D.Y[i]);}
const base=document.createElement('canvas'),bctx=base.getContext('2d');
let W=0,H=0,dpr=1,k=1,ox=0,oy=0,k0=1,rs=1,hov=-1,pin=-1;
function col(c,a){return 'rgba('+c[0]+','+c[1]+','+c[2]+','+(a===undefined?1:a)+')';}
function fit(){W=innerWidth;H=innerHeight;dpr=devicePixelRatio||1;
 for(const c of [cv,base]){c.width=Math.max(1,W*dpr);c.height=Math.max(1,H*dpr);}
 cv.style.width=W+'px';cv.style.height=H+'px';
 k0=Math.min(W*.9/Math.max(maxx-minx,1),H*.9/Math.max(maxy-miny,1));rs=Math.min(W,H)/1500;}
function reset(){k=k0;ox=W/2-k*(minx+maxx)/2;oy=H/2-k*(miny+maxy)/2;}
const sx=i=>D.X[i]*k+ox,sy=i=>D.Y[i]*k+oy,rad=i=>D.R[i]*rs*Math.sqrt(k/k0);
function lw(){return Math.max(.4,.68*rs*Math.min(Math.sqrt(k/k0),1.6));}
function la(){return D.T.la*Math.min(1,1/Math.pow(Math.max(1,(k/k0)/3),.45));}
function drawBase(){bctx.setTransform(dpr,0,0,dpr,0,0);bctx.fillStyle=col(D.T.bg);bctx.fillRect(0,0,W,H);
 bctx.lineWidth=lw();bctx.strokeStyle=col(D.T.link,la());bctx.beginPath();
 for(let i=0;i<D.E.length;i+=2){bctx.moveTo(sx(D.E[i]),sy(D.E[i]));bctx.lineTo(sx(D.E[i+1]),sy(D.E[i+1]));}
 bctx.stroke();
 if(D.EA.length){bctx.strokeStyle=col(D.T.ea,D.T.eaa);bctx.lineWidth=Math.max(1,lw()*1.5);bctx.beginPath();
  for(let i=0;i<D.EA.length;i+=2){bctx.moveTo(sx(D.EA[i]),sy(D.EA[i]));bctx.lineTo(sx(D.EA[i+1]),sy(D.EA[i+1]));}bctx.stroke();}
 for(const i of order){const c=D.PAL[D.C[i]];bctx.fillStyle=col(c,c[3]);
  bctx.beginPath();bctx.arc(sx(i),sy(i),rad(i),0,6.2832);bctx.fill();}
 if(k/k0>2.2){bctx.font='11px -apple-system,sans-serif';bctx.textAlign='center';
  const seen=[];let put=0;
  for(const i of byDeg){if(put>=50)break;const x=sx(i),y=sy(i);
   if(x<-20||y<-20||x>W+20||y>H+20||rad(i)<4)continue;
   const t=stem(i),w=bctx.measureText(t).width,bx=[x-w/2-3,y+rad(i)+2,x+w/2+3,y+rad(i)+16];
   if(seen.some(o=>!(bx[2]<o[0]||bx[0]>o[2]||bx[3]<o[1]||bx[1]>o[3])))continue;
   seen.push(bx);put++;
   bctx.lineWidth=3;bctx.strokeStyle=col(D.T.bg,.85);bctx.strokeText(t,x,y+rad(i)+12);
   bctx.fillStyle=col(D.T.lab,.95);bctx.fillText(t,x,y+rad(i)+12);}}
}
function stem(i){let p=D.P[i];if(p.startsWith('?:'))p=p.slice(2);const b=p.split('/').pop();return b.replace(/\\.md$/,'');}
function dir(i){const p=D.P[i];const j=p.lastIndexOf('/');return j<0?'':p.slice(0,j);}
function peak(i){const p=D.P[i];if(p.startsWith('?:'))return null;const s=p.split('/');
 if(s.length<=1)return '(root)';return s[0]==='Areas'&&s.length>=3?s[0]+'/'+s[1]:s[0];}
function draw(){ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,cv.width,cv.height);ctx.drawImage(base,0,0);
 const a=pin>=0?pin:hov;if(a<0){tip.style.display='none';return;}
 ctx.setTransform(dpr,0,0,dpr,0,0);
 ctx.fillStyle=col(D.T.bg,.62);ctx.fillRect(0,0,W,H);
 ctx.strokeStyle=col(D.T.ea,D.T.eaa);ctx.lineWidth=Math.max(.8,lw()*1.5);ctx.beginPath();
 for(const m of adj[a]){ctx.moveTo(sx(a),sy(a));ctx.lineTo(sx(m),sy(m));}
 ctx.stroke();
 for(const m of adj[a]){ctx.fillStyle=col(D.T.nb);ctx.beginPath();ctx.arc(sx(m),sy(m),rad(m),0,6.2832);ctx.fill();}
 ctx.fillStyle=col(D.T.ac);ctx.beginPath();ctx.arc(sx(a),sy(a),rad(a)*1.3,0,6.2832);ctx.fill();
 tip.innerHTML='<b></b><span class="pk"><i></i><em></em></span><span></span><span></span>';
 tip.children[0].textContent=stem(a);
 const pf=peak(a),d=dir(a),g=D.CG[a],pk=tip.children[1];
 pk.firstChild.style.display=g>=0?'':'none';
 if(g>=0)pk.firstChild.style.background=col(D.PAL[g]);
 pk.lastChild.textContent=pf===null?'not yet created':pf.replace('/',' · ');
 tip.children[2].textContent=d;
 tip.children[2].style.display=d&&pf&&d!==pf?'':'none';
 tip.children[3].textContent=adj[a].length+' link'+(adj[a].length===1?'':'s')+(pin>=0?' · pinned (click background to release)':'');
 tip.style.display='block';
 const tx=Math.min(W-tip.offsetWidth-10,Math.max(8,sx(a)+12)),ty=Math.max(8,sy(a)-tip.offsetHeight-10);
 tip.style.left=tx+'px';tip.style.top=(ty<8?sy(a)+14:ty)+'px';
}
function pick(mx,my){const maxr=9.5*rs*Math.sqrt(k/k0)+6;let best=-1,bd=1e18;
 const wx=(mx-ox)/k,wy=(my-oy)/k,wr=maxr/k,CS=D.CS;
 const x0=Math.floor((wx-wr)/CS),x1=Math.floor((wx+wr)/CS),y0=Math.floor((wy-wr)/CS),y1=Math.floor((wy+wr)/CS);
 for(let gx=x0;gx<=x1;gx++)for(let gy=y0;gy<=y1;gy++){const cell=GRID.get(gx+':'+gy);if(!cell)continue;
  for(const i of cell){const dx=sx(i)-mx,dy=sy(i)-my,d=dx*dx+dy*dy,r=rad(i)+5;
   if(d<=r*r&&d<bd){bd=d;best=i;}}}
 return best;}
const GRID=new Map();
(function(){const CS=D.CS;for(let i=0;i<n;i++){const s=Math.floor(D.X[i]/CS)+':'+Math.floor(D.Y[i]/CS);
 (GRID.get(s)||GRID.set(s,[]).get(s)).push(i);}})();
const menu=document.getElementById('menu');
if(D.G.length){for(const g of D.G){const row=document.createElement('div');
 const dot=document.createElement('i');dot.style.background=col(D.PAL[g[1]]);
 row.appendChild(dot);row.appendChild(document.createTextNode(g[0]));
 const em=document.createElement('em');em.textContent=g[2];row.appendChild(em);leg.appendChild(row);}
 menu.style.display='flex';
 menu.addEventListener('click',()=>{const on=leg.style.display==='block';
  leg.style.display=on?'none':'block';menu.title=(on?'Show':'Hide')+' legend';});}
let dragging=false,moved=false,lx=0,ly=0,rafHov=false,mx=0,my=0;
cv.addEventListener('mousedown',e=>{dragging=true;moved=false;lx=e.clientX;ly=e.clientY;});
addEventListener('mouseup',e=>{if(!dragging)return;dragging=false;
 if(!moved){const hit=pick(e.clientX,e.clientY);pin=(hit>=0&&hit!==pin)?hit:-1;draw();}});
cv.addEventListener('mousemove',e=>{mx=e.clientX;my=e.clientY;
 if(dragging){const dx=mx-lx,dy=my-ly;if(Math.abs(dx)+Math.abs(dy)>2)moved=true;
  ox+=dx;oy+=dy;lx=mx;ly=my;drawBase();draw();return;}
 if(rafHov)return;rafHov=true;
 requestAnimationFrame(()=>{rafHov=false;const h=pick(mx,my);
  cv.style.cursor=h>=0?'pointer':'default';
  if(h!==hov){hov=h;draw();}});});
cv.addEventListener('mouseleave',()=>{hov=-1;draw();});
cv.addEventListener('wheel',e=>{e.preventDefault();
 const f=Math.exp(-e.deltaY*0.0016),nk=Math.min(k0*40,Math.max(k0*.15,k*f)),g=nk/k;
 ox=e.clientX-(e.clientX-ox)*g;oy=e.clientY-(e.clientY-oy)*g;k=nk;drawBase();draw();},{passive:false});
cv.addEventListener('dblclick',()=>{fit();reset();drawBase();draw();});
addEventListener('resize',()=>{fit();reset();drawBase();draw();});
fit();reset();drawBase();draw();
</script></body></html>
"""


def render_html(snap, pos, opts, theme, path, vault):
    """The layout + palette, packed into HTML_TEMPLATE as one JSON blob."""
    t = THEMES[theme]
    groups = build_groups(snap["nodes"], opts, theme)
    hub_deg = hub_cutoff(snap)
    focus = snap.get("focus")
    nodes = snap["nodes"]
    idx = {nid: i for i, nid in enumerate(nodes)}

    pal, pal_idx, C = [], {}, []
    for nid in nodes:
        color, alpha = node_style(nid, snap, groups, t, hub_deg)
        key = (color, round(alpha, 3))
        if key not in pal_idx:
            pal_idx[key] = len(pal)
            pal.append([color[0], color[1], color[2], round(alpha, 3)])
        C.append(pal_idx[key])

    # C carries the drawn color (focus renders tint it with the selection
    # palette); CG keeps each note's untinted folder hue for the tooltip's
    # location chip (-1 = no group, e.g. --no-color-by-folder or phantoms).
    CG = []
    for nid in nodes:
        gc = group_color(nid, groups)
        if gc is None:
            CG.append(-1)
            continue
        key = (gc, 1.0)
        if key not in pal_idx:
            pal_idx[key] = len(pal)
            pal.append([gc[0], gc[1], gc[2], 1.0])
        CG.append(pal_idx[key])

    E, EA = [], []
    for s_, t_ in snap["edges"]:
        (EA if focus and (s_ == focus or t_ == focus) else E).extend((idx[s_], idx[t_]))

    R = [round(NODE_SIZE_MULT * STILL_NODE_BOOST * base_size(snap["degree"][nid])
               * opts.node_scale * (ACTIVE_NODE_SCALE if nid == focus else 1.0), 2)
         for nid in nodes]
    link_alpha = (opts.edge_alpha if opts.edge_alpha is not None
                  else t["link_alpha"] * STILL_EDGE_DAMP)

    legend = []
    if groups:
        counts = {}
        for nid in nodes:
            gc = group_color(nid, groups)
            if gc is None:
                continue
            pref = max((pfx for pfx in groups
                        if pfx == "(root)" and "/" not in nid
                        or nid == pfx or nid.startswith(pfx + "/")),
                       key=lambda pfx: 0 if pfx == "(root)" else len(pfx), default=None)
            if pref is not None:
                counts[pref] = counts.get(pref, 0) + 1
        for pref in sorted(counts, key=lambda x: -counts[x])[:14]:
            key = (groups[pref], 1.0)
            if key not in pal_idx:
                pal_idx[key] = len(pal)
                pal.append([groups[pref][0], groups[pref][1], groups[pref][2], 1.0])
            legend.append([pref, pal_idx[key], counts[pref]])

    data = {
        "P": nodes, "X": [round(pos[nid][0], 1) for nid in nodes],
        "Y": [round(pos[nid][1], 1) for nid in nodes],
        "R": R, "C": C, "CG": CG, "PAL": pal, "E": E, "EA": EA, "G": legend, "CS": 30,
        "T": {"bg": list(t["bg"]), "link": list(t["link"]), "la": round(link_alpha, 3),
              "ac": list(t["focus"]), "nb": list(t["neighbor"]),
              "ea": list(t["edge_active"]), "eaa": t["edge_active_alpha"],
              "lab": list(t["label"])},
    }
    title = ("%s — around %s" % (vault.name, stem_of(focus)) if focus
             else "%s — vault graph" % vault.name)
    real = sum(1 for nid in nodes if not nid.startswith("?:"))
    stats = ("{:,} nodes · {:,} links · drag to pan · scroll to zoom · "
             "hover a node · click to pin · double-click to reset"
             .format(real, len(snap["edges"])))
    html = (HTML_TEMPLATE
            .replace("__TITLE__", title.replace("&", "&amp;").replace("<", "&lt;"))
            .replace("__BG__", "rgb(%d,%d,%d)" % t["bg"])
            .replace("__STATS__", stats)
            .replace("__DATA__", json.dumps(data, separators=(",", ":"),
                                            ensure_ascii=False).replace("</", "<\\/")))
    Path(path).write_text(html, encoding="utf-8")


# ── Entry point ──────────────────────────────────────────────────────────────

def main(argv=None):
    p = argparse.ArgumentParser(
        prog="python3 -m alexandria_vault.engine.graph_image",
        description="Render the vault's knowledge graph to an Obsidian-style image.")
    p.add_argument("--out", help="output path (default: Agent/Graphs/<date> Vault Graph.png; "
                                 "a .svg suffix implies --format svg)")
    p.add_argument("--format", choices=("png", "svg", "html", "both", "all"),
                   default=None,
                   help="png (default) · svg (vector) · html (self-contained "
                        "INTERACTIVE page: hover labels + lit neighborhood, pan/"
                        "zoom) · both = png+svg · all = png+svg+html")
    p.add_argument("--width", type=int, default=4000,
                   help="output width in px (default 4000 — 4K-class; 8000 for 8K print)")
    p.add_argument("--height", type=int, default=3000)
    p.add_argument("--ss", type=int, choices=(1, 2, 3), default=1,
                   help="supersampling factor (render at Nx, downsample)")
    p.add_argument("--theme", choices=sorted(THEMES), default="desktop")
    p.add_argument("--focus", help="render the local graph around this note")
    p.add_argument("--hops", type=int, default=2, help="focus neighborhood depth (default 2)")
    p.add_argument("--labels", type=int, default=0, help="label the top-N hubs (default none)")
    p.add_argument("--include-unresolved", action="store_true",
                   help="phantom nodes for linked-but-not-yet-created notes")
    p.add_argument("--color-by-folder", action=argparse.BooleanOptionalAction,
                   default=STILL_FOLDER_COLORS,
                   help="a hue per top-level folder / Areas/<Area> (the default; "
                        "--no-color-by-folder for the classic monochrome look)")
    p.add_argument("--group", action="append", metavar="PREFIX=#HEX",
                   help="explicit color group (repeatable; longest prefix wins)")
    p.add_argument("--node-scale", type=float, default=1.0)
    p.add_argument("--edge-scale", type=float, default=1.0)
    p.add_argument("--edge-alpha", type=float, default=None)
    p.add_argument("--charge", type=float, default=STILL_CHARGE,
                   help="many-body repulsion (default %g; the app's interactive "
                        "canvas runs -50)" % STILL_CHARGE)
    p.add_argument("--seed", type=int, default=42)
    p.add_argument("--ticks", type=int, default=0, help="cap simulation ticks (0 = run to rest)")
    p.add_argument("--fast", action="store_true",
                   help="coarser Barnes-Hut + faster cooling (~4x quicker, minor layout drift)")
    p.add_argument("--collide", action=argparse.BooleanOptionalAction, default=True,
                   help="ease touching notes apart after layout so no two nodes "
                        "overlap (the default; --no-collide keeps the raw "
                        "physics placement)")
    p.add_argument("--engine", choices=("auto", "python", "numpy"), default="auto")
    p.add_argument("--relayout", action="store_true", help="ignore the cached layout")
    p.add_argument("--no-cache", action="store_true", help="neither read nor write the layout cache")
    p.add_argument("--json", action="store_true", help="print machine-readable meta to stdout")
    p.add_argument("--quiet", action="store_true")
    p.add_argument("--open", action="store_true", help="open the image when done (macOS)")
    opts = p.parse_args(argv)

    log = (lambda s: None) if opts.quiet else (lambda s: print(s, file=sys.stderr))
    vault = resolve_vault()
    t_scan = time.time()
    snap = load_snapshot(vault)
    if not snap["nodes"]:
        sys.exit("no Markdown notes found under %s" % vault)
    if opts.include_unresolved:
        add_phantoms(snap)
    t_scan = time.time() - t_scan

    if opts.focus:
        nid, cands = resolve_focus(opts.focus, snap)
        if nid is None:
            if cands:
                sys.exit("--focus %r is ambiguous:\n  " % opts.focus + "\n  ".join(cands[:12]))
            sys.exit("--focus %r matches no note" % opts.focus)
        snap = focus_subgraph(snap, nid, max(1, opts.hops))

    n = len(snap["nodes"])
    if n > 8000 and opts.engine != "numpy" and not opts.fast:
        try:
            import numpy  # noqa: F401
        except ImportError:
            log("%d notes with the pure-python engine — consider --fast" % n)

    t_layout = time.time()
    warm = cache_load(vault, opts)
    pos, ticks, engine = run_layout(snap["nodes"], snap["edges"], snap["degree"],
                                    opts, warm=warm, log=log)
    col_moved = col_sweeps = 0
    if opts.collide:
        col_moved, col_sweeps = resolve_overlaps(snap, pos, opts)
    t_layout = time.time() - t_layout
    # A cache hit can still need easing (positions cached before the no-overlap
    # pass existed, or radii grown by new links); save the settled result so
    # the next run is a true no-op.
    if engine != "cached" or col_moved:
        cache_save(vault, opts, pos)

    # Output paths.
    day = today_local().isoformat()
    if opts.out:
        out = Path(opts.out).expanduser()
        if opts.format is None:
            opts.format = {".svg": "svg", ".html": "html"}.get(out.suffix.lower(), "png")
    else:
        stemname = ("%s Graph — %s" % (day, stem_of(snap["focus"]))
                    if snap.get("focus") else "%s Vault Graph" % day)
        out = vault / "Agent" / "Graphs" / (stemname + ".png")
        if opts.format is None:
            opts.format = "png"
    out.parent.mkdir(parents=True, exist_ok=True)

    t_render = time.time()
    png_path = svg_path = html_path = None
    labeled = []
    if opts.format in ("png", "both", "all"):
        png_path = out if out.suffix.lower() == ".png" else out.with_suffix(".png")
        cv, labeled = render(snap, pos, opts, opts.theme)
        cv.write_png(str(png_path))
    if opts.format in ("svg", "both", "all"):
        svg_path = out if out.suffix.lower() == ".svg" else out.with_suffix(".svg")
        render_svg(snap, pos, opts, opts.theme, str(svg_path))
    if opts.format in ("html", "all"):
        html_path = out if out.suffix.lower() == ".html" else out.with_suffix(".html")
        render_html(snap, pos, opts, opts.theme, str(html_path), vault)
    t_render = time.time() - t_render

    real_nodes = [nd for nd in snap["nodes"] if not nd.startswith("?:")]
    orphans = sum(1 for nd in real_nodes if snap["degree"][nd] == 0)
    ncomp = components(snap["nodes"], snap["edges"])
    hubs = sorted(real_nodes, key=lambda nd: (-snap["degree"][nd], nd))[:10]
    unresolved_targets = len({t for ts in snap["unresolved"].values() for t in ts})

    def relpath(pth):
        if pth is None:
            return None
        try:
            return str(Path(pth).resolve().relative_to(vault))
        except ValueError:
            return str(pth)

    primary = png_path or html_path or svg_path
    meta = {
        "out": relpath(primary), "png": relpath(png_path), "svg": relpath(svg_path),
        "html": relpath(html_path),
        "vault": str(vault), "notes": len(real_nodes), "edges": len(snap["edges"]),
        "unresolved_targets": unresolved_targets, "orphans": orphans,
        "components": ncomp,
        "hubs": [{"note": h, "degree": snap["degree"][h]} for h in hubs],
        "focus": snap.get("focus"), "hops": opts.hops if snap.get("focus") else None,
        "labeled": labeled or None, "theme": opts.theme,
        "size": [opts.width, opts.height],
        "layout": {"engine": engine, "ticks": ticks,
                   "seconds": round(t_layout, 1), "warm": bool(warm),
                   "collide": ({"moved": col_moved, "sweeps": col_sweeps}
                               if opts.collide else None)},
        "scan_seconds": round(t_scan, 1), "render_seconds": round(t_render, 1),
    }
    if opts.json:
        print(json.dumps(meta, ensure_ascii=False))
    else:
        head = ("Local graph: %s (%d hops)" % (stem_of(snap["focus"]), opts.hops)
                if snap.get("focus") else "Vault graph")
        print("%s: %s nodes · %s links · %d components · %d orphans"
              % (head, format(len(real_nodes), ","), format(len(snap["edges"]), ","),
                 ncomp, orphans))
        lay = ("cached layout" if engine == "cached"
               else "%s%d ticks in %.1fs (%s)" % ("warm start, " if warm else "",
                                                  ticks, t_layout, engine))
        if col_moved:
            lay += " · %d notes eased apart" % col_moved
        print("Layout: %s · Scan: %.1fs · Render: %.1fs" % (lay, t_scan, t_render))
        for pth in (png_path, svg_path, html_path):
            if pth:
                print("→ %s (%dx%d, %d KB)"
                      % (relpath(pth), opts.width, opts.height,
                         os.path.getsize(pth) // 1024))
    if opts.open and primary and sys.platform == "darwin":
        subprocess.run(["open", str(primary)], check=False)
    return 0


if __name__ == "__main__":
    sys.exit(main())
