From 96552c31e3e9a6f69ce015febac77456675b494c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 00:54:14 -0500 Subject: [PATCH 1/8] feat(learning): profile-scoped memory + learned-skill graph API Assemble a per-profile graph of memories and learned skills over time (agent/learning_graph.py) and serve it at GET /api/learning/graph (hermes_cli/web_server.py), with tests. The radial time axis the desktop renders is derived from this payload; the REST path stays under /learning for backend compatibility. --- agent/learning_graph.py | 312 +++++++++++++++++++++++++++++ hermes_cli/web_server.py | 17 ++ tests/agent/test_learning_graph.py | 85 ++++++++ 3 files changed, 414 insertions(+) create mode 100644 agent/learning_graph.py create mode 100644 tests/agent/test_learning_graph.py diff --git a/agent/learning_graph.py b/agent/learning_graph.py new file mode 100644 index 00000000000..34c4f0af0c2 --- /dev/null +++ b/agent/learning_graph.py @@ -0,0 +1,312 @@ +"""Assemble the "learning made visible" graph for desktop. + +This graph is intentionally scoped to what a user actually learns over time: +- non-base, learned/profile skills (agent-created or used), +- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes. + +Skill links come from declared ``related_skills``. Memory-to-skill links are +derived from lexical overlap so the graph can answer "which learned skills are +connected to the things I remember?". + +Run as a module to print edge-density stats against real data: + + python -m agent.learning_graph +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + + +@dataclass +class SkillNode: + name: str + category: str + source: str = "profile" + timestamp: Optional[int] = None + use_count: int = 0 + state: str = "active" + created_by: Optional[str] = None + pinned: bool = False + related: list[str] = field(default_factory=list) + + +def _frontmatter(text: str) -> dict[str, Any]: + try: + from agent.skill_utils import parse_frontmatter + + fm, _ = parse_frontmatter(text) + return fm or {} + except Exception: + return {} + + +def _related(fm: dict[str, Any]) -> list[str]: + raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills") + if isinstance(raw, list): + return [str(r).strip() for r in raw if str(r).strip()] + if isinstance(raw, str): + return [r.strip() for r in raw.strip("[]").split(",") if r.strip()] + return [] + + +def _category(fm: dict[str, Any], skill_md: Path) -> str: + cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category") + if cat: + return str(cat) + # …/skills///SKILL.md + parts = skill_md.parts + return parts[-3] if len(parts) >= 3 else "general" + + +def _iter_skill_files(roots: list[tuple[str, Path]]): + for source, root in roots: + if root.exists(): + for path in root.rglob("SKILL.md"): + yield source, path + + +def _load_usage() -> dict[str, dict[str, Any]]: + try: + from tools.skill_usage import load_usage + + return load_usage() + except Exception: + path = Path(os.path.expanduser("~/.hermes/skills/.usage.json")) + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _to_int_ts(value: Any) -> Optional[int]: + try: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + s = str(value).strip() + return int(float(s)) if s else None + except Exception: + return None + + +def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]: + usage = _load_usage() + nodes: dict[str, SkillNode] = {} + + for source, skill_md in _iter_skill_files(skill_roots): + if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts): + continue + try: + fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000]) + except OSError: + continue + name = str(fm.get("name") or skill_md.parent.name).strip() + if not name or name in nodes: + continue + rec = usage.get(name, {}) + last_activity = _to_int_ts(rec.get("last_activity_at")) + file_ts = _to_int_ts(skill_md.stat().st_mtime) + nodes[name] = SkillNode( + name=name, + category=_category(fm, skill_md), + source=source, + timestamp=last_activity or file_ts, + use_count=int(rec.get("use_count", 0) or 0), + state=str(rec.get("state", "active") or "active"), + created_by=rec.get("created_by"), + pinned=bool(rec.get("pinned", False)), + related=_related(fm), + ) + return nodes + + +def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]: + """Undirected related_skills edges where BOTH endpoints exist (deduped).""" + seen: set[tuple[str, str]] = set() + edges: list[tuple[str, str]] = [] + for node in nodes.values(): + for target in node.related: + if target in nodes and target != node.name: + key = tuple(sorted((node.name, target))) + if key not in seen: + seen.add(key) + edges.append(key) + return edges + + +def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]: + linked: set[str] = set() + for a, b in edges: + linked.add(a) + linked.add(b) + cats: dict[str, int] = {} + for n in nodes.values(): + cats[n.category] = cats.get(n.category, 0) + 1 + n = len(nodes) or 1 + return { + "nodes": len(nodes), + "related_edges": len(edges), + "edges_per_node": round(len(edges) / n, 3), + "linked_nodes": len(linked), + "isolated_pct": round(100 * (n - len(linked)) / n, 1), + "categories": len(cats), + "agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"), + "used": sum(1 for x in nodes.values() if x.use_count > 0), + "top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8], + } + + +def _memory_cards() -> list[dict[str, Any]]: + """Freeform memory as readable cards. + + ``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each + chunk becomes one card. Every chunk is surfaced — the graph shows everything. + """ + try: + from hermes_constants import get_hermes_home + + base = get_hermes_home() / "memories" + except Exception: + base = Path(os.path.expanduser("~/.hermes/memories")) + + cards: list[dict[str, Any]] = [] + for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")): + path = base / fname + try: + text = path.read_text(encoding="utf-8").strip() + file_ts = _to_int_ts(path.stat().st_mtime) + except OSError: + continue + for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")): + if not chunk: + continue + first = chunk.splitlines()[0].strip().lstrip("# ").strip() + cards.append( + { + "source": source, + "timestamp": file_ts + chunk_idx if file_ts is not None else None, + "title": (first[:80] + "…") if len(first) > 80 else first, + "body": chunk[:1200], + } + ) + return cards + + +def _tokenize(text: str) -> set[str]: + return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3} + + +def _memory_skill_edges(memory_cards: list[dict[str, str]], skills: list[SkillNode]) -> list[tuple[str, str]]: + edges: list[tuple[str, str]] = [] + skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills] + for idx, card in enumerate(memory_cards): + mem_id = f"memory:{card['source']}:{idx}" + text = f"{card.get('title', '')}\n{card.get('body', '')}".lower() + text_tokens = _tokenize(text) + scored: list[tuple[int, str]] = [] + for skill, tokens, skill_name_lower in skill_meta: + score = 0 + if skill_name_lower in text: + score += 6 + score += len(tokens & text_tokens) + if score > 0: + scored.append((score, skill.name)) + scored.sort(key=lambda x: (-x[0], x[1])) + for _, skill_name in scored[:4]: + edges.append((mem_id, skill_name)) + return edges + + +def _skill_roots() -> list[tuple[str, Path]]: + repo = Path(__file__).resolve().parent.parent + try: + from hermes_constants import get_hermes_home + + home_skills = get_hermes_home() / "skills" + except Exception: + home_skills = Path(os.path.expanduser("~/.hermes/skills")) + return [("base", repo / "skills"), ("profile", home_skills)] + + +def build_learning_graph() -> dict[str, Any]: + """Full payload for the desktop learning panel. + + Focus on what is profile-learned and actionable: + - skills that are NOT base-installed and show real learning signal + (agent-created or used), + - memory chunks as first-class graph nodes connected to those learned skills. + """ + all_skills = build_skill_nodes(_skill_roots()) + learned_skills = { + name: node + for name, node in all_skills.items() + if node.source != "base" and (node.created_by == "agent" or node.use_count > 0) + } + skill_edges = build_edges(learned_skills) + memory_cards = _memory_cards() + memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values())) + + edges = skill_edges + memory_edges + clusters: dict[str, int] = {} + for node in learned_skills.values(): + clusters[node.category] = clusters.get(node.category, 0) + 1 + if memory_cards: + clusters["memory"] = len(memory_cards) + + graph_nodes = [ + { + "id": n.name, + "label": n.name, + "kind": "skill", + "timestamp": n.timestamp, + "category": n.category, + "useCount": n.use_count, + "state": n.state, + "createdBy": n.created_by, + "pinned": n.pinned, + } + for n in learned_skills.values() + ] + for i, card in enumerate(memory_cards): + graph_nodes.append( + { + "id": f"memory:{card['source']}:{i}", + "label": card["title"], + "kind": "memory", + "memorySource": card["source"], + "timestamp": card.get("timestamp"), + "category": "memory", + "useCount": 0, + "state": "active", + "createdBy": "memory", + "pinned": False, + } + ) + + return { + "nodes": graph_nodes, + "edges": [{"source": a, "target": b} for a, b in edges], + "clusters": [ + {"category": c, "count": n} + for c, n in sorted(clusters.items(), key=lambda kv: -kv[1]) + ], + "memory": memory_cards, + "stats": { + **density_stats(learned_skills, skill_edges), + "memory_nodes": len(memory_cards), + "memory_skill_edges": len(memory_edges), + "learned_skills": len(learned_skills), + }, + } + + +if __name__ == "__main__": + nodes = build_skill_nodes(_skill_roots()) + print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2)) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 09dacecb9f9..2dc5fc9b60d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2452,6 +2452,23 @@ async def run_curator(): return {"ok": True, "pid": proc.pid, "name": "curator-run"} +@app.get("/api/learning/graph") +async def get_learning_graph(profile: Optional[str] = None): + """Learning graph payload for the desktop panel. + + Profile-scoped view of learned, non-base skills plus memory chunks, with + graph links derived from skill relations and memory-skill overlap. + """ + try: + from agent.learning_graph import build_learning_graph + + with _profile_scope(profile): + return build_learning_graph() + except Exception: + _log.exception("GET /api/learning/graph failed") + raise HTTPException(status_code=500, detail="Failed to build learning graph") + + def _safe_call(mod, fn_name: str, default): try: fn = getattr(mod, fn_name, None) diff --git a/tests/agent/test_learning_graph.py b/tests/agent/test_learning_graph.py new file mode 100644 index 00000000000..298f19920e0 --- /dev/null +++ b/tests/agent/test_learning_graph.py @@ -0,0 +1,85 @@ +"""Behavior contracts for the learning-graph assembler. + +Asserts invariants (edges resolve to real nodes, clusters cover every node, +memory cards are represented consistently), never a snapshot of the live skill +catalog — that catalog grows every release and a count assertion would be a +change-detector. +""" + +from __future__ import annotations + +from agent import learning_graph +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + +def _node(name: str, category: str, related=None): + n = learning_graph.SkillNode(name=name, category=category) + n.related = list(related or []) + return n + + +def test_edges_only_connect_existing_nodes(): + nodes = { + "a": _node("a", "x", related=["b", "ghost"]), + "b": _node("b", "x", related=["a"]), + "c": _node("c", "y"), + } + edges = learning_graph.build_edges(nodes) + + # The a→b link is kept once (deduped, undirected); a→ghost is dropped. + assert edges == [("a", "b")] + + +def test_density_stats_count_isolated_nodes(): + nodes = { + "a": _node("a", "x", related=["b"]), + "b": _node("b", "x", related=["a"]), + "c": _node("c", "y"), + } + stats = learning_graph.density_stats(nodes, learning_graph.build_edges(nodes)) + + assert stats["nodes"] == 3 + assert stats["linked_nodes"] == 2 + assert stats["isolated_pct"] == round(100 / 3, 1) + + +def test_memory_is_cards_split_on_separator(tmp_path): + home = tmp_path / ".hermes" + (home / "memories").mkdir(parents=True) + (home / "memories" / "MEMORY.md").write_text( + "Project uses pytest with xdist\n§\nUser prefers concise responses", + encoding="utf-8", + ) + token = set_hermes_home_override(home) + try: + graph = learning_graph.build_learning_graph() + finally: + reset_hermes_home_override(token) + + titles = [c["title"] for c in graph["memory"]] + assert "Project uses pytest with xdist" in titles + assert "User prefers concise responses" in titles + # Memory cards remain typed cards and also appear as memory-kind nodes. + assert all(c["source"] in {"memory", "profile"} for c in graph["memory"]) + assert all("timestamp" in c for c in graph["memory"]) + assert any(n["kind"] == "memory" for n in graph["nodes"]) + + +def test_full_payload_shape_and_edge_integrity(tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + token = set_hermes_home_override(home) + try: + graph = learning_graph.build_learning_graph() + finally: + reset_hermes_home_override(token) + + ids = {n["id"] for n in graph["nodes"]} + assert all(e["source"] in ids and e["target"] in ids for e in graph["edges"]) + # Every node's category appears in the cluster list. + cluster_cats = {c["category"] for c in graph["clusters"]} + assert all(n["category"] in cluster_cats for n in graph["nodes"]) + skill_nodes = [n for n in graph["nodes"] if n["kind"] == "skill"] + assert graph["stats"]["nodes"] == len(skill_nodes) + assert graph["stats"]["memory_nodes"] == len(graph["memory"]) + assert all("timestamp" in n for n in graph["nodes"]) From dec44994a5be54d835060f593079f0c732567e80 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 00:54:21 -0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(desktop):=20Memory=20Graph=20=E2=80=94?= =?UTF-8?q?=20playable=20radial=20timeline=20of=20memories=20+=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-down Memory Graph panel: memories and skills on a radial time axis (core = oldest, outer rings = newer) with a playable / scrubbable timeline that builds the map up over time. - Reveal lives off the React tree (a ref drives the canvas, a nanostore atom drives the timeline + legend), so a play-through or scrub never re-renders the panel; paint is coalesced to one rAF and playback is abortable, so even frantic scrubbing stays responsive. - Adaptive dated rings: one equal-width ring per POPULATED calendar bucket, a "nice-tick" count scaled to the span. Constant (orthographic) core/band scale — more data grows the disk outward (more rings), never thinner. - A bucket's nodes fill the band inside their ring and ignite staggered by real timestamp across it (no end-dump), with an EVE-style warp-in; the camera steps out band-by-band as rings are reached. - ASCII "computing" core, theme-aware palette with a distinct memory hue, shared trackpad-gesture primitives. - Shareable WoW-style "loadout" codes on a generic, reusable codec (@/lib/loadout: bitstream + DEFLATE + version/checksum frame + base64url). - Opens from the statusbar and command palette; i18n across all locales. Deps: d3-force, fflate (drops unused react-force-graph-2d). --- apps/desktop/package.json | 3 + apps/desktop/scripts/.gitignore | 1 + apps/desktop/scripts/gen-share-codes.ts | 171 ++++ .../desktop/src/app/command-palette/index.tsx | 13 +- apps/desktop/src/app/desktop-controller.tsx | 12 +- apps/desktop/src/app/routes.ts | 15 +- .../app/shell/hooks/use-overlay-routing.ts | 13 +- .../app/shell/hooks/use-statusbar-items.tsx | 8 +- apps/desktop/src/app/starmap/color.ts | 126 +++ apps/desktop/src/app/starmap/constants.ts | 62 ++ apps/desktop/src/app/starmap/geometry.ts | 117 +++ apps/desktop/src/app/starmap/index.tsx | 53 ++ apps/desktop/src/app/starmap/render.ts | 721 +++++++++++++++ .../src/app/starmap/share-code.test.ts | 142 +++ apps/desktop/src/app/starmap/share-code.ts | 186 ++++ .../src/app/starmap/share-controls.tsx | 138 +++ apps/desktop/src/app/starmap/simulation.ts | 267 ++++++ apps/desktop/src/app/starmap/star-map.tsx | 875 ++++++++++++++++++ apps/desktop/src/app/starmap/text.ts | 89 ++ apps/desktop/src/app/starmap/time-axis.ts | 105 +++ apps/desktop/src/app/starmap/timeline.tsx | 281 ++++++ apps/desktop/src/app/starmap/types.ts | 97 ++ .../desktop/src/components/ui/use-zoom-pan.ts | 10 + apps/desktop/src/hermes.ts | 11 + apps/desktop/src/i18n/en.ts | 29 + apps/desktop/src/i18n/ja.ts | 20 +- apps/desktop/src/i18n/types.ts | 29 + apps/desktop/src/i18n/zh-hant.ts | 20 +- apps/desktop/src/i18n/zh.ts | 29 + apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/lib/loadout.ts | 277 ++++++ apps/desktop/src/lib/trackpad-gestures.ts | 50 + apps/desktop/src/store/starmap.ts | 46 + apps/desktop/src/types/hermes.ts | 41 + apps/desktop/vite.config.ts | 27 +- package-lock.json | 556 +---------- 36 files changed, 4078 insertions(+), 564 deletions(-) create mode 100644 apps/desktop/scripts/.gitignore create mode 100644 apps/desktop/scripts/gen-share-codes.ts create mode 100644 apps/desktop/src/app/starmap/color.ts create mode 100644 apps/desktop/src/app/starmap/constants.ts create mode 100644 apps/desktop/src/app/starmap/geometry.ts create mode 100644 apps/desktop/src/app/starmap/index.tsx create mode 100644 apps/desktop/src/app/starmap/render.ts create mode 100644 apps/desktop/src/app/starmap/share-code.test.ts create mode 100644 apps/desktop/src/app/starmap/share-code.ts create mode 100644 apps/desktop/src/app/starmap/share-controls.tsx create mode 100644 apps/desktop/src/app/starmap/simulation.ts create mode 100644 apps/desktop/src/app/starmap/star-map.tsx create mode 100644 apps/desktop/src/app/starmap/text.ts create mode 100644 apps/desktop/src/app/starmap/time-axis.ts create mode 100644 apps/desktop/src/app/starmap/timeline.tsx create mode 100644 apps/desktop/src/app/starmap/types.ts create mode 100644 apps/desktop/src/lib/loadout.ts create mode 100644 apps/desktop/src/lib/trackpad-gestures.ts create mode 100644 apps/desktop/src/store/starmap.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4bf4eaade96..6fd35c0fbc4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,8 +81,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "dnd-core": "^14.0.1", "dompurify": "^3.4.11", + "fflate": "^0.8.3", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", @@ -118,6 +120,7 @@ "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", + "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", "@types/node": "^24.13.2", "@types/react": "^19.2.14", diff --git a/apps/desktop/scripts/.gitignore b/apps/desktop/scripts/.gitignore new file mode 100644 index 00000000000..646f02ffc79 --- /dev/null +++ b/apps/desktop/scripts/.gitignore @@ -0,0 +1 @@ +share-codes.txt diff --git a/apps/desktop/scripts/gen-share-codes.ts b/apps/desktop/scripts/gen-share-codes.ts new file mode 100644 index 00000000000..8de54582461 --- /dev/null +++ b/apps/desktop/scripts/gen-share-codes.ts @@ -0,0 +1,171 @@ +// Throwaway generator: deterministic fake star-map graphs → real share codes +// (runs the actual encoder, so every string round-trips). Run with `npx tsx`. +import { writeFileSync } from 'node:fs' + +import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes' + +import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code' + +const DAY = 86_400 +const END = Math.floor(Date.UTC(2026, 5, 29) / 1000) + +// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs. +const rng = (seed: number) => () => { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296 +} + +const pick = (arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]! + +const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const +const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const +const CREATED = [null, 'agent', 'agent', 'user'] as const + +const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({ + category: pick(CATEGORIES, r()), + createdBy: pick(CREATED, r()), + id, + kind: 'skill', + label, + pinned: r() > 0.85, + state: pick(STATES, r()), + timestamp: ts, + useCount: Math.floor(r() ** 3 * 120) +}) + +const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({ + category: 'memory', + createdBy: 'memory', + id: `memory:${source}:${i}`, + kind: 'memory', + label, + memorySource: source, + pinned: false, + state: 'active', + timestamp: ts, + useCount: 0 +}) + +const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title }) + +// ── 1. Tiny + quirky ────────────────────────────────────────────────────────── +function tiny(): StarmapGraph { + const r = rng(7) + const nodes: StarmapNode[] = [ + skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r), + skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r), + skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r), + memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY), + memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY) + ] + const edges: StarmapEdge[] = [ + { source: 'memory:memory:1', target: 'git-blame-zen' }, + { source: 'rubber-duck', target: 'git-blame-zen' } + ] + const memory = [ + card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY), + card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY) + ] + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 2. Mid-size, mixed signal ──────────────────────────────────────────────── +function mid(): StarmapGraph { + const r = rng(42) + const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking'] + const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r)) + const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode'] + + memTitles.forEach((title, i) => { + const ts = END - Math.floor(r() * 120) * DAY + nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts)) + }) + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 9; i += 1) { + edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` }) + } + + const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY)) + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 3. Dense web, partly undated (ordinal fallback) ────────────────────────── +function web(): StarmapGraph { + const r = rng(1337) + const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) => + // Half the skills carry no timestamp → exercises the ordinal recency path. + skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r) + ) + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 44; i += 1) { + edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` }) + } + + return { clusters: [], edges, memory: [], nodes, stats: {} } +} + +// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ─────────────── +function beast(): StarmapGraph { + const r = rng(2024) + const start = END - 730 * DAY + const span = END - start + const nodes: StarmapNode[] = [] + const memory: StarmapMemoryCard[] = [] + + // Bursts → an interesting waveform instead of a flat smear. + const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span) + + for (let i = 0; i < 240; i += 1) { + const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end + nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r)) + } + + for (let i = 0; i < 150; i += 1) { + const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12) + const source = r() > 0.5 ? 'memory' : 'profile' + nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts)) + memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts)) + } + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 380; i += 1) { + const a = Math.floor(r() * 240) + const b = Math.floor(r() * 240) + + if (a !== b) { + edges.push({ source: `b${a}`, target: `b${b}` }) + } + } + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +const graphs: [string, StarmapGraph][] = [ + ['tiny + quirky', tiny()], + ['mid · mixed signal', mid()], + ['dense web · half undated', web()], + ['the beast · ~2 years', beast()] +] + +const lines: string[] = [] + +for (const [name, g] of graphs) { + const code = encodeShareCode(g) + const back = decodeShareCode(code) // round-trip assert — throws if invalid + // v2 is viz-only: nodes + edge topology survive; memory prose is dropped. + const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length + console.log(`${ok ? 'ok ' : 'BAD'} ${name} — ${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`) + lines.push(`# ${name} — ${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '') +} + +writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n')) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index bfda963204c..ec1c79566dc 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -36,6 +36,7 @@ import { RefreshCw, Settings, Settings2, + Starmap, Sun, Terminal, Users, @@ -68,7 +69,8 @@ import { PROFILES_ROUTE, sessionRoute, SETTINGS_ROUTE, - SKILLS_ROUTE + SKILLS_ROUTE, + STARMAP_ROUTE } from '../routes' import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' @@ -383,7 +385,14 @@ export function CommandPalette() { run: go(CRON_ROUTE) }, { action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) }, - { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) } + { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }, + { + icon: Starmap, + id: 'nav-starmap', + keywords: ['star map', 'memory', 'memories', 'skills', 'graph', 'learning', 'constellation'], + label: t.starmap.title, + run: go(STARMAP_ROUTE) + } ] }, ...branchGroup, diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index fece8887401..61290a0fd62 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -158,6 +158,7 @@ const AgentsView = lazy(async () => ({ default: (await import('./agents')).Agent const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) @@ -262,6 +263,7 @@ export function DesktopController() { openCommandCenterSection, profilesOpen, settingsOpen, + starmapOpen, toggleCommandCenter } = useOverlayRouting() @@ -1117,9 +1119,7 @@ export function DesktopController() { // layer) so pane resize handles still paint above it. Terminals own their state // (incl. a snapshotted cwd) independent of the session, so switching sessions // never rebuilds or closes them; toggling the pane never rebuilds the shells. - const mainOverlays = ( - - ) + const mainOverlays = const overlays = ( <> @@ -1201,6 +1201,12 @@ export function DesktopController() { )} + + {starmapOpen && ( + + + + )} ) diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts index 2b655fccc8d..66ab264e474 100644 --- a/apps/desktop/src/app/routes.ts +++ b/apps/desktop/src/app/routes.ts @@ -8,6 +8,7 @@ export const ARTIFACTS_ROUTE = '/artifacts' export const CRON_ROUTE = '/cron' export const PROFILES_ROUTE = '/profiles' export const AGENTS_ROUTE = '/agents' +export const STARMAP_ROUTE = '/starmap' export type AppView = | 'agents' @@ -19,6 +20,7 @@ export type AppView = | 'profiles' | 'settings' | 'skills' + | 'starmap' export type AppRouteId = | 'agents' @@ -30,6 +32,7 @@ export type AppRouteId = | 'profiles' | 'settings' | 'skills' + | 'starmap' export interface AppRoute { id: AppRouteId @@ -46,7 +49,8 @@ export const APP_ROUTES = [ { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, { id: 'cron', path: CRON_ROUTE, view: 'cron' }, { id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' }, - { id: 'agents', path: AGENTS_ROUTE, view: 'agents' } + { id: 'agents', path: AGENTS_ROUTE, view: 'agents' }, + { id: 'starmap', path: STARMAP_ROUTE, view: 'starmap' } ] as const satisfies readonly AppRoute[] const APP_VIEW_BY_PATH = new Map(APP_ROUTES.map(route => [route.path, route.view])) @@ -55,7 +59,14 @@ const RESERVED_PATHS: ReadonlySet = new Set(APP_ROUTES.map(route => rout // Views that render as a full-screen modal card (OverlayView) over the shell. // While one is open the app's titlebar control clusters must hide so they don't // bleed over the overlay (they sit at a higher z-index than the overlay card). -export const OVERLAY_VIEWS: ReadonlySet = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings']) +export const OVERLAY_VIEWS: ReadonlySet = new Set([ + 'agents', + 'command-center', + 'cron', + 'profiles', + 'settings', + 'starmap' +]) export function isOverlayView(view: AppView): boolean { return OVERLAY_VIEWS.has(view) diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts index d4b0d2130f5..01873b08dd6 100644 --- a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { type CommandCenterSection } from '@/app/command-center' -import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes' +import { + AGENTS_ROUTE, + appViewForPath, + COMMAND_CENTER_ROUTE, + isOverlayView, + NEW_CHAT_ROUTE, + STARMAP_ROUTE +} from '@/app/routes' const SECTIONS = ['sessions', 'system', 'usage'] as const @@ -14,6 +21,7 @@ export function useOverlayRouting() { const settingsOpen = currentView === 'settings' const commandCenterOpen = currentView === 'command-center' const agentsOpen = currentView === 'agents' + const starmapOpen = currentView === 'starmap' const cronOpen = currentView === 'cron' const profilesOpen = currentView === 'profiles' const chatOpen = currentView === 'chat' @@ -53,6 +61,7 @@ export function useOverlayRouting() { }, [closeOverlayToPreviousRoute, commandCenterOpen, navigate]) const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate]) + const openStarmap = useCallback(() => navigate(STARMAP_ROUTE), [navigate]) return { agentsOpen, @@ -64,8 +73,10 @@ export function useOverlayRouting() { currentView, openAgents, openCommandCenterSection, + openStarmap, profilesOpen, settingsOpen, + starmapOpen, toggleCommandCenter } } diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index c58483ad095..753c3893bbd 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -3,8 +3,8 @@ import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' -import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' +import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' @@ -369,11 +369,7 @@ export function useStatusbarItems({ menuAlign: 'end', menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0', menuContent: ( - + ), title: copy.openContextUsage, variant: 'menu' diff --git a/apps/desktop/src/app/starmap/color.ts b/apps/desktop/src/app/starmap/color.ts new file mode 100644 index 00000000000..0d5e4448071 --- /dev/null +++ b/apps/desktop/src/app/starmap/color.ts @@ -0,0 +1,126 @@ +import { BLACK, MODE_DEFAULTS } from './constants' +import { clamp } from './geometry' +import type { Palette, Rgb } from './types' + +// Theme tokens come through `color-mix()`/oklch, so getComputedStyle returns a +// non-rgb() string. Rasterize through a 1x1 canvas to get real sRGB bytes — +// naive string parsing of oklab()/color(srgb …) silently yields black. +let _probe: CanvasRenderingContext2D | null = null + +export function resolveRgb(color: string): Rgb { + if (!_probe) { + const c = document.createElement('canvas') + c.width = 1 + c.height = 1 + _probe = c.getContext('2d', { willReadFrequently: true }) + } + + if (!_probe) { + return { b: 184, g: 163, r: 148 } + } + + _probe.clearRect(0, 0, 1, 1) + _probe.fillStyle = '#888888' + _probe.fillStyle = color + _probe.fillRect(0, 0, 1, 1) + const d = _probe.getImageData(0, 0, 1, 1).data + + return { b: d[2], g: d[1], r: d[0] } +} + +export function rgba(c: Rgb, a: number): string { + return `rgba(${c.r},${c.g},${c.b},${a})` +} + +export function mixRgb(a: Rgb, b: Rgb, t: number): Rgb { + const p = clamp(t, 0, 1) + + return { + b: Math.round(a.b + (b.b - a.b) * p), + g: Math.round(a.g + (b.g - a.g) * p), + r: Math.round(a.r + (b.r - a.r) * p) + } +} + +export function darken(c: Rgb, amount: number): Rgb { + return mixRgb(c, BLACK, amount) +} + +export function luminance(r: number, g: number, b: number): number { + return (0.2126 * r + 0.7152 * g + 0.114 * b) / 255 +} + +function rgbToHsl(c: Rgb): [number, number, number] { + const r = c.r / 255 + const g = c.g / 255 + const b = c.b / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + const d = max - min + let h = 0 + let s = 0 + + if (d) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + } + + return [h, s, l] +} + +function hslToRgb(h: number, s: number, l: number): Rgb { + const hue = ((h % 360) + 360) % 360 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)) + const m = l - c / 2 + + const [r, g, b] = + hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x] + + return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) } +} + +// Complementary ink: rotate the source hue (the theme primary) and keep it vivid +// so memories read as a distinct color from skills, in any theme. +function complementaryInk(c: Rgb): Rgb { + const [h, s, l] = rgbToHsl(c) + + return hslToRgb(h + 165, Math.max(s, 0.5), clamp(l, 0.5, 0.7)) +} + +// Memory ink: the complementary hue muted toward the overlay background so it +// reads as a distinct-but-quiet color (fake alpha), not a loud full-sat pop. +export function memoryInkFor(primary: Rgb, bg: Rgb): Rgb { + return mixRgb(complementaryInk(primary), bg, 0.45) +} + +// Resolve the theme-derived palette once per theme change — the resolveRgb probe +// does a getImageData readback, so this stays out of the per-frame path. Node +// groups borrow restrained tint from the theme; structure stays foreground ink. +export function computePalette(canvas: HTMLCanvasElement): Palette { + const style = getComputedStyle(canvas) + const fg = resolveRgb(style.color) + const darkTheme = luminance(fg.r, fg.g, fg.b) > 0.55 + const base: Rgb = darkTheme ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 } + const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color) + + const bg = resolveRgb( + style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || (darkTheme ? '#000' : '#fff') + ) + + return { + // Band tint derives from the theme primary so rings read consistently in + // both modes (foreground ink would go white on dark / black on light). + bandInk: mixRgb(primary, base, darkTheme ? 0.3 : 0), + base, + bg, + c: MODE_DEFAULTS[darkTheme ? 'dark' : 'light'], + chipBg: darkTheme ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.85)', + darkTheme, + inkInv: darkTheme ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)', + memoryInk: memoryInkFor(primary, bg), + primary, + skillInk: mixRgb(primary, base, darkTheme ? 0.12 : 0.18) + } +} diff --git a/apps/desktop/src/app/starmap/constants.ts b/apps/desktop/src/app/starmap/constants.ts new file mode 100644 index 00000000000..748391a483e --- /dev/null +++ b/apps/desktop/src/app/starmap/constants.ts @@ -0,0 +1,62 @@ +import type { StarmapNode } from '@/types/hermes' + +import type { GraphParams, Rgb, RingParams, Shape } from './types' + +// ── Disk geometry ──────────────────────────────────────────────────────────── +export const RING_INNER = 58 +export const RING_OUTER = 340 +export const ZOOM_MIN = 0.3 +export const ZOOM_MAX = 5 +export const FIT_PADDING = 80 +export const TILT = 1 // vertical squash → "looking down at a tilted disk" +export const RING_STEPS = 4 + +export const WHITE: Rgb = { b: 255, g: 255, r: 255 } +export const BLACK: Rgb = { b: 0, g: 0, r: 0 } + +// Fixed recency (age) gradient — old content quiet, recent content bright. +export const AGE_GRADIENT = { mid: 0.52, midInk: 0.74, newInk: 0.95, oldInk: 0.42, reach: 1 } + +// Node glyph per kind — pure path geometry (the seam a future sprite/instanced +// renderer would bake from). +export const NODE_SHAPE: Record = { memory: 'diamond', skill: 'circle' } + +// Darken the orb body so a bright primary doesn't swallow the sheen (the +// highlight is computed from the original ink, so it still reads). +export const ORB_DARKEN = 0.3 + +// Sheen forced this high when the orb ink is near-white (a white body needs a +// pure-white core to read as a sphere at all). +export const WHITEISH_SHEEN = 0.95 + +// Flat wash alpha for a lit (hovered/selected) date's band. The focused ring +// outline derives from this (×2). +export const LIT_BAND_ALPHA = 0.04 + +export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = { + dark: { + lineAlpha: 0.12, + lineDash: 1.5, + lineDashed: true, + lineWidth: 0.5, + ringAlpha: 0.1, + ringDash: 4, + ringDashed: false, + ringWidth: 1.5 + }, + light: { + lineAlpha: 0.18, + lineDash: 1.5, + lineDashed: true, + lineWidth: 0.5, + ringAlpha: 0.06, + ringDash: 4, + ringDashed: false, + ringWidth: 2 + } +} + +export const RING_PARAMS: Record<'dark' | 'light', RingParams> = { + dark: { bandAlpha: 0.01, lightSize: 0.64, ringAlpha: 0.03, sheen: 0.12 }, + light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.04, sheen: 0.1 } +} diff --git a/apps/desktop/src/app/starmap/geometry.ts b/apps/desktop/src/app/starmap/geometry.ts new file mode 100644 index 00000000000..e4e22eb0c42 --- /dev/null +++ b/apps/desktop/src/app/starmap/geometry.ts @@ -0,0 +1,117 @@ +import type { StarmapNode } from '@/types/hermes' + +import { AGE_GRADIENT, FIT_PADDING, RING_INNER, RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' +import type { Shape, Viewport } from './types' + +export function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)) +} + +// FNV-1a — stable per-id seed for layout angle / starfield. +export function hash(input: string): number { + let h = 2166136261 + + for (let i = 0; i < input.length; i += 1) { + h ^= input.charCodeAt(i) + h = Math.imul(h, 16777619) + } + + return h >>> 0 +} + +export function nodeRadius(n: StarmapNode): number { + if (n.kind === 'memory') { + return 4.4 + } + + const base = n.state === 'archived' || n.state === 'stale' ? 2.4 : 3 + + return base + Math.sqrt(Math.max(0, n.useCount)) * 0.55 + (n.pinned ? 0.8 : 0) +} + +// Smoothstep recency → ink alpha along the age gradient. +export function recencyInk(rec: number): number { + const reach = Math.max(0.01, AGE_GRADIENT.reach) + const mid = clamp(AGE_GRADIENT.mid, 0.01, 0.99) + const t = clamp(rec / reach, 0, 1) + + if (t <= mid) { + const p = t / mid + + return AGE_GRADIENT.oldInk + (AGE_GRADIENT.midInk - AGE_GRADIENT.oldInk) * (p * p * (3 - 2 * p)) + } + + const p = (t - mid) / (1 - mid) + + return AGE_GRADIENT.midInk + (AGE_GRADIENT.newInk - AGE_GRADIENT.midInk) * (p * p * (3 - 2 * p)) +} + +// Trace a centred geometric shape of radius r into the current path. +export function shapePath(ctx: CanvasRenderingContext2D, shape: Shape, x: number, y: number, r: number): void { + ctx.beginPath() + + if (shape === 'square') { + ctx.rect(x - r, y - r, r * 2, r * 2) + + return + } + + if (shape === 'circle') { + ctx.arc(x, y, r, 0, Math.PI * 2) + + return + } + + const pts = shape === 'diamond' ? 4 : shape === 'triangle' ? 3 : 6 + // Diamond/triangle point up; hexagon is flat-topped. + const rot = shape === 'hexagon' ? Math.PI / 6 : -Math.PI / 2 + + for (let i = 0; i < pts; i += 1) { + const a = rot + (i / pts) * Math.PI * 2 + const px = x + Math.cos(a) * r + const py = y + Math.sin(a) * r + + if (i === 0) { + ctx.moveTo(px, py) + } else { + ctx.lineTo(px, py) + } + } + + ctx.closePath() +} + +// Center the tilted disk in the viewport at a fit zoom. `outer` is the radius to +// fit (defaults to the full disk); the scrubber passes the revealed extent so the +// camera tightens at the core and zooms out as the rings grow. +export function fitViewport(w: number, h: number, outer: number = RING_OUTER): Viewport { + if (w <= 0 || h <= 0) { + return { k: 1, x: w / 2, y: h / 2 } + } + + const spanX = (outer + 30) * 2 + const spanY = spanX * TILT + const k = clamp(Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / spanY, 2.2), ZOOM_MIN, ZOOM_MAX) + + // Bias the center down a touch — the timeline along the top adds visual weight + // up there, so true-center reads as sitting high. + return { k, x: w / 2, y: h / 2 + h * 0.05 } +} + +// Target radius for a node at recency `rec` (oldest at the core), scaled to a +// disk of the given outer radius. +export function radiusForRecency(rec: number, outer: number = RING_OUTER): number { + return RING_INNER + rec * (outer - RING_INNER) +} + +// Squared distance from point (px,py) to segment a→b — for cheap link hit-tests. +export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax + const dy = by - ay + const len = dx * dx + dy * dy + const t = len ? clamp(((px - ax) * dx + (py - ay) * dy) / len, 0, 1) : 0 + const cx = ax + dx * t + const cy = ay + dy * t + + return (px - cx) ** 2 + (py - cy) ** 2 +} diff --git a/apps/desktop/src/app/starmap/index.tsx b/apps/desktop/src/app/starmap/index.tsx new file mode 100644 index 00000000000..7603006d8f8 --- /dev/null +++ b/apps/desktop/src/app/starmap/index.tsx @@ -0,0 +1,53 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { useI18n } from '@/i18n' +import { $starmapError, $starmapGraph, $starmapLoading, loadStarmapGraph } from '@/store/starmap' +import type { StarmapGraph } from '@/types/hermes' + +import { Panel, PanelEmpty } from '../overlays/panel' + +import { StarMap } from './star-map' + +// Star map overlay: a top-down map of what Hermes has learned for a profile, +// over a radial time axis. Data is fetched on demand into the $starmap* atoms; +// the map itself lives in ./star-map. The chrome is owned by the map itself +// (timeline scrubber + legend float over the canvas), so there's no panel +// header here. +export function StarmapView({ onClose }: { onClose: () => void }) { + const { t } = useI18n() + const graph = useStore($starmapGraph) + const loading = useStore($starmapLoading) + const error = useStore($starmapError) + + // A pasted share code populates the map with someone else's (or an exported) + // graph, overriding the live profile scan. Cleared by "back to my map" and + // whenever a fresh profile graph loads in. + const [imported, setImported] = useState(null) + + useEffect(() => { + void loadStarmapGraph() + }, []) + + // Drop a stale import when the underlying profile graph changes out from under it. + useEffect(() => { + setImported(null) + }, [graph]) + + const shown = imported ?? graph + + return ( + + {error ? ( + + ) : !shown && loading ? ( + + ) : shown && shown.nodes.length === 0 && !imported ? ( + + ) : shown ? ( + setImported(null)} /> + ) : null} + + ) +} diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts new file mode 100644 index 00000000000..392cf4db48c --- /dev/null +++ b/apps/desktop/src/app/starmap/render.ts @@ -0,0 +1,721 @@ +import { darken, luminance, mixRgb, rgba } from './color' +import { + LIT_BAND_ALPHA, + NODE_SHAPE, + ORB_DARKEN, + RING_INNER, + RING_PARAMS, + TILT, + WHITE, + WHITEISH_SHEEN +} from './constants' +import { clamp, nodeRadius, recencyInk, shapePath } from './geometry' +import { countLabel, ellipsize, metaBadges, nodeFooter, wrapText } from './text' +import type { + FadeBuckets, + MemoryCard, + Palette, + Rect, + Rgb, + Ring, + RingLabelRect, + SimLink, + SimNode, + Viewport +} from './types' + +export interface Scene { + adjacency: Map> + byId: Map + ctx: CanvasRenderingContext2D + dpr: number + fades: FadeBuckets + focusId: null | string + hoverId: null | string + hoverLink: null | string + hoverRing: null | number + links: SimLink[] + memById: Map + nodes: SimNode[] + palette: Palette + // Time scrubber: only paint nodes/links whose recency has been reached. 1 = + // everything (the default, idle state); lower values "build up" the map. + reveal: number + rings: Ring[] + selectedRing: null | number + size: { h: number; w: number } + // Scrub jumps: snap every ease to its target this frame (no birth/fade replay). + snapMotion?: boolean + vp: Viewport +} + +export interface DrawResult { + animating: boolean + ringLabelRects: RingLabelRect[] +} + +// Smoothstep — eases the birth animations (position grow-out) in and out. +const ease = (t: number): number => { + const u = t < 0 ? 0 : t > 1 ? 1 : t + + return u * u * (3 - 2 * u) +} + +// EVE-style warp arrival for node births: the star streaks outward fast, then +// decelerates hard (exponential ease-out) and drops onto its ring — like a ship +// dropping out of warp. WARP_FROM is how deep toward the core it launches from. +const WARP_FROM = 0.32 + +const warpIn = (t: number): number => { + const u = t < 0 ? 0 : t > 1 ? 1 : t + + return u >= 1 ? 1 : 1 - 2 ** (-9 * u) +} + +// Layered birth speeds for the scrubber's parallax: rings expand slowly and +// grandly in the background, stars pop in quicker up front — both well below the +// default hover/focus speeds so the build-up reads as a cinematic settle. +const RING_BIRTH = { down: 0.055, up: 0.032 } +const NODE_BIRTH = { down: 0.11, up: 0.075 } + +// Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus +// a few digits/symbols for the "digital rain / decoding" look. +const SCRAMBLE_CHARS = + 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' + +// Fill the current path as a lit sphere: an offset radial gradient from a hot +// core → darkened body → translucent rim, so a flat circle reads with volume. +// `strength` is how white the core is; `bodyDarken` darkens the body (0 for +// active/hover nodes so they pop full bright). Near-white inks skip the darken +// and force a near-full sheen so the white core still reads. +function sphereFill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + ink: Rgb, + strength: number, + bodyDarken: number +): void { + const mx = Math.max(ink.r, ink.g, ink.b) + const mn = Math.min(ink.r, ink.g, ink.b) + const sat = mx ? (mx - mn) / mx : 0 + const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat) + const eff = strength + (WHITEISH_SHEEN - strength) * whiteness + const hi = mixRgb(ink, WHITE, 0.7 * eff) + const body = darken(ink, bodyDarken * (1 - whiteness)) + const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.05, x, y, r * 1.15) + g.addColorStop(0, rgba(hi, 1)) + g.addColorStop(0.5, rgba(body, 1)) + g.addColorStop(1, rgba(body, 0.85)) + ctx.fillStyle = g + ctx.fill() +} + +const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + +// Paint a full frame of the star map. Pure given its inputs (draws to the +// canvas + advances the fade buckets); returns whether it's still animating and +// the ring-label hit rects for pointer picking. +export function drawScene(scene: Scene): DrawResult { + const { + adjacency, + byId, + ctx, + dpr, + fades, + focusId, + hoverId, + hoverLink, + hoverRing, + links, + memById, + nodes, + palette, + reveal, + rings, + selectedRing, + size, + snapMotion = false, + vp + } = scene + + // Small epsilon so a node exactly at the playhead counts as revealed. + const seen = (rec: number) => rec <= reveal + 1e-3 + // Recency for styling is RELATIVE to the newest revealed node — the current + // "present" — not the bare playhead. So a lone frontier node still reads as + // fresh (bright/full size) even with empty space between it and the scrubber. + // At reveal = 1 the frontier is the newest node, collapsing back to raw recency. + let frontier = 0 + + for (const fn of nodes) { + if (fn.rec <= reveal + 1e-3 && fn.rec > frontier) { + frontier = fn.rec + } + } + + const erec = (rec: number) => (frontier > 0 ? clamp(rec / frontier, 0, 1) : 1) + const { h, w } = size + const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, primary, skillInk } = palette + const { bandAlpha, lightSize, ringAlpha, sheen } = RING_PARAMS[darkTheme ? 'dark' : 'light'] + + let animating = false + const ringLabelRects: RingLabelRect[] = [] + + // Eased opacity per element: snaps up when newly highlighted, eases otherwise. + // `rates` overrides the default in/out lerp speed (the slow births pass their + // own gentler pair so the build-up reads as a graceful settle, not a flash). + const fadeAlpha = ( + bucket: Map, + key: string, + target: number, + snapUp = false, + rates?: { down: number; up: number } + ) => { + const targetAlpha = clamp(target, 0, 1) + const prev = bucket.get(key) + + // Scrub: jump straight to the target so a fast drag doesn't replay easing. + if (snapMotion) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + if (prev == null || (snapUp && targetAlpha > prev)) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + const up = rates?.up ?? 0.22 + const down = rates?.down ?? 0.32 + const rate = targetAlpha > prev ? up : down + const next = prev + (targetAlpha - prev) * rate + + if (Math.abs(next - targetAlpha) < 0.01) { + bucket.set(key, targetAlpha) + + return targetAlpha + } + + animating = true + bucket.set(key, next) + + return next + } + + const shade = (a: number) => `rgba(${base.r},${base.g},${base.b},${a})` + const projX = (wx: number) => wx * vp.k + vp.x + const projY = (wy: number) => wy * vp.k * TILT + vp.y + + // Two composable layers: node highlight (selected ?? hovered) in full ink, and + // a selection-only ring/date filter that only shifts alpha. + const focusSet = focusId ? (adjacency.get(focusId) ?? new Set()) : null + const ringIdx = selectedRing + const ring = ringIdx != null ? (rings[ringIdx] ?? null) : null + // A selected ring owns the band it caps: previous ring → this ring. Ring 0 is + // visual-only/unlabeled, so the first selectable date naturally owns shell 0→1. + const ringLo = ring && ringIdx != null ? (rings[ringIdx - 1]?.ratio ?? 0) - 1e-3 : 0 + const ringHi = ring ? ring.ratio + 1e-3 : 1 + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, w, h) + + ctx.globalAlpha = 1 + + // Tilted world transform for the disk structure. + ctx.setTransform(vp.k * dpr, 0, 0, vp.k * TILT * dpr, vp.x * dpr, vp.y * dpr) + + // The "lit" date = hovered (preview) or selected (locked) — drives the band + // flatten only; the ring outline reacts to selection. + const litRingIdx = hoverRing ?? ringIdx + + // A ring is "laid" one band AHEAD of the playhead — it appears the moment the + // scrubber enters the band beneath it (its inner neighbor's date), so the date + // gridline that caps a region is always drawn before any node in that region. + // Ring 0 is just the visual core; the first real shell still needs non-zero + // playback progress so replay starts empty instead of showing it pre-laid. + const ringSeen = (i: number) => { + const threshold = rings[i - 1]?.ratio ?? 0 + + return i === 0 || (threshold <= 0 ? reveal > 1e-3 : reveal + 1e-3 >= threshold) + } + + // Per-ring "grow out" progress (advanced once per frame, reused by bands / + // outlines / labels): a revealed ring eases its radius from its inner neighbor + // outward to its resting radius, so it expands into place instead of popping. + const ringAppear = rings.map((rg, i) => + ease(fadeAlpha(fades.appear, `ring:${i}`, ringSeen(i) ? 1 : 0, false, RING_BIRTH)) + ) + + // Direction-based origin (the sign of the reveal): a ring growing IN expands + // outward from its inner neighbour — never from the dead centre — while a ring + // fading OUT collapses all the way to the core. ringSeen is the direction tell: + // true = revealing/at rest, false = receding. + const ringDrawR = rings.map((rg, i) => { + const startR = ringSeen(i) ? (rings[i - 1]?.r ?? rg.r) : RING_INNER + + return startR + (rg.r - startR) * (ringAppear[i] ?? 1) + }) + + // Opacity envelope that stays near-full through most of the grow/shrink and + // only fades in the final stretch — so the radius TRAVEL is visible (the ring + // shrinks back into place) instead of just dimming out where it stands. + const ringVis = ringAppear.map(a => clamp(a / 0.55, 0, 1)) + + // Inter-ring bands: a theme-tinted wash sliver at the outer edge; the lit + // date's band flattens to an even wash. + if (bandAlpha > 0 || litRingIdx != null) { + for (let i = 0; i < rings.length - 1; i += 1) { + const lit = litRingIdx != null && i + 1 === litRingIdx + + if (!lit && bandAlpha <= 0) { + continue + } + + // The band tracks its outer ring's grow-in. + if ((ringAppear[i + 1] ?? 1) <= 0.01) { + continue + } + + const inner = ringDrawR[i] ?? 0 + const outer = ringDrawR[i + 1] ?? 0 + + if (lit) { + ctx.fillStyle = rgba(bandInk, LIT_BAND_ALPHA) + } else { + const grad = ctx.createRadialGradient(0, 0, inner, 0, 0, outer) + grad.addColorStop(0, rgba(bandInk, 0)) + grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, bandAlpha)) + ctx.fillStyle = grad + } + + ctx.beginPath() + ctx.arc(0, 0, outer, 0, Math.PI * 2) + ctx.arc(0, 0, inner, 0, Math.PI * 2, true) + ctx.fill() + } + } + + // Ring outline: brightens only on selection — the selected ring + its inner + // neighbor (the two bounding the lit band). + ctx.lineWidth = c.ringWidth / vp.k + ctx.setLineDash(c.ringDashed ? [c.ringDash / vp.k, c.ringDash / vp.k] : []) + rings.forEach((rg, i) => { + const emphasized = ringIdx != null && (i === ringIdx || i === ringIdx - 1) + // Reveal in/out rides the smooth (slow) ringAppear envelope so a ring fades + // out as gracefully as it grew in; the alpha bucket only carries the snappy + // selection emphasis. + const emphasisAlpha = emphasized ? clamp(LIT_BAND_ALPHA * 2, 0, 1) : ringAlpha + const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) + + if (ringAlphaNow < 0.004) { + return + } + + ctx.strokeStyle = shade(ringAlphaNow) + ctx.beginPath() + ctx.arc(0, 0, ringDrawR[i] ?? rg.r, 0, Math.PI * 2) + ctx.stroke() + }) + ctx.setLineDash([]) + + // Screen space for the core, jump routes, and glyphs (crisp, easy to trim). + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + // Ring 0 is intentionally empty: computeRecency's lead-in keeps the oldest + // real data out in the first shell. Fill that gap with a tilted ASCII + // scramble — a decoding-glyph field laid on the disk plane (rows squashed by + // TILT, circular falloff) so the empty core reads as "computing", not missing. + // It animates continuously, so the draw loop is kept hot (animating = true). + const coreX = projX(0) + const coreY = projY(0) + // Fill to the innermost ring (the core shell), not the RING_INNER constant — + // the ring sits in lead-in space, so derive the radius from it directly. + const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94 + const cell = clamp(coreRx * 0.2, 6, 11) + // Aspect-correct on the tilt: rows are spaced by the full glyph height (square + // cells, no vertical squish), but the field is clipped to the disk's ELLIPSE + // (vertical extent = coreRx * TILT), so it sits on the tilted plane while the + // glyphs themselves stay un-squished. Fewer rows fit vertically — that's it. + const coreRy = coreRx * TILT + const half = Math.max(3, Math.round(coreRx / cell)) + const now = performance.now() + + ctx.save() + ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + for (let r = -half; r <= half; r += 1) { + // Per-row flow: half the rows drift left, half right, each at its own speed. + // The drift is a continuous pixel scroll (not a per-cell swap), and each + // glyph's identity is tied to its slot index — so a character visibly slides + // across instead of the whole row flickering in place. Combined with the + // TILT squash + opposite directions, the field reads as a turning surface. + const rowSeed = (r * 19349663) >>> 0 || 1 + const dir = rowSeed & 1 ? 1 : -1 + const speed = 8 + (rowSeed % 16) // px/sec + const scroll = (now / 1000) * speed * dir + const ny = (r * cell) / coreRy + // Latitude dimming: rows away from the equator fade, selling the sphere read. + const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny)) + const kMin = Math.floor((-coreRx - scroll) / cell) - 1 + const kMax = Math.ceil((coreRx - scroll) / cell) + 1 + + for (let k = kMin; k <= kMax; k += 1) { + const sx = k * cell + scroll // screen-space x relative to the core center + const nx = sx / coreRx + const d2 = nx * nx + ny * ny + + if (d2 > 1) { + continue + } + + const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0 + const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0' + // Mostly flat brightness, fading only near the rim (reduced gradient). + const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1) + const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100) + // Fake depth: a stable per-slot value pops a subset of glyphs forward, so + // some characters read as nearer/brighter and drift across in front. + const depth = ((seed >>> 11) % 100) / 100 + const pop = depth > 0.92 ? 2.6 : depth > 0.78 ? 1.6 : 1 + const a = clamp((darkTheme ? 0.25 : 0.33) * edge * flick * rowDim * pop, 0, 0.85) + + if (a < 0.02) { + continue + } + + ctx.fillStyle = rgba(primary, a) + ctx.fillText(ch, coreX + sx, coreY + r * cell) + } + } + + ctx.restore() + ctx.globalAlpha = 1 + animating = true + + // Jump routes — a focused node's links stop at its selection ring. + const focusNode = focusId ? (byId.get(focusId) ?? null) : null + const focusRingR = focusNode ? (nodeRadius(focusNode) + focusNode.rec) * vp.k + 4 : 0 + + for (const link of links) { + const s = typeof link.source === 'object' ? link.source : byId.get(String(link.source)) + const t = typeof link.target === 'object' ? link.target : byId.get(String(link.target)) + + if (!s || !t) { + continue + } + + // A jump route only exists once both of its endpoints have ignited. + const revealed = seen(s.rec) && seen(t.rec) + + const lit = + revealed && + !!focusId && + (s.id === focusId || t.id === focusId || (!!focusSet && focusSet.has(s.id) && focusSet.has(t.id))) + + let x1 = projX(s.x) + let y1 = projY(s.y) + let x2 = projX(t.x) + let y2 = projY(t.y) + + if (s.id === focusId) { + const d = Math.hypot(x2 - x1, y2 - y1) || 1 + x1 += ((x2 - x1) / d) * focusRingR + y1 += ((y2 - y1) / d) * focusRingR + } + + if (t.id === focusId) { + const d = Math.hypot(x1 - x2, y1 - y2) || 1 + x2 += ((x1 - x2) / d) * focusRingR + y2 += ((y1 - y2) / d) * focusRingR + } + + const key = `${s.id}->${t.id}` + const ambient = recencyInk(erec((s.rec + t.rec) / 2)) * c.lineAlpha + + // Hovering a line fades it in a bit (×2, capped — never full white). + const targetAlpha = !revealed + ? 0 + : lit + ? 1 + : key === hoverLink + ? clamp(ambient * 2, 0, 0.7) + : focusId || ring + ? 0.025 + : ambient + + const linkAlpha = fadeAlpha(fades.links, key, targetAlpha, lit) + + if (linkAlpha < 0.004) { + continue + } + + ctx.strokeStyle = shade(linkAlpha) + ctx.setLineDash(lit || !c.lineDashed ? [] : [c.lineDash, c.lineDash]) + ctx.lineWidth = lit ? 1.5 : c.lineWidth + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } + + ctx.setLineDash([]) + + // Nodes: the node layer paints pure ink (focused node + neighbors); the date + // filter is alpha-only, so the two states compose. + for (const n of nodes) { + // The land comes first: a node waits for the ring that CAPS its region (its + // outer date gridline) to grow in before it ignites — so the ring is always + // drawn before any star inside it, not after. + const landLaid = (ringAppear[n.outerRingIndex] ?? 1) >= 0.5 + const revealed = seen(n.rec) && landLaid + const isFocus = revealed && n.id === focusId + const isNeighbor = revealed && !!focusSet && focusSet.has(n.id) + const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi + const nodeHigh = isFocus || isNeighbor + const er = erec(n.rec) + const ageScale = nodeHigh || inRing ? 1 : 0.34 + Math.min(1, er / 0.4) * 0.66 + const r = nodeRadius(n) * vp.k * ageScale + + const baseAlpha = nodeHigh ? 1 : ring ? (inRing ? (focusId ? 0.55 : 1) : 0.16) : focusId ? 0.16 : recencyInk(er) + const alpha = fadeAlpha(fades.nodes, n.id, revealed ? baseAlpha : 0, nodeHigh || inRing) + + // Birth fade + warp rise are coupled (slow rates) so a star grows in instead + // of flashing. Focus snaps (no drift). + const rawBorn = fadeAlpha(fades.appear, n.id, revealed ? 1 : 0, nodeHigh || inRing, NODE_BIRTH) + const born = ease(rawBorn) + const vis = alpha * born + + if (vis < 0.004) { + continue + } + + // Warp-in: streak outward from WARP_FROM·radius and decelerate hard onto the + // ring (origin = disk core), echoing an EVE ship dropping out of warp. + const posScale = WARP_FROM + (1 - WARP_FROM) * warpIn(rawBorn) + const sx = projX(n.x * posScale) + const sy = projY(n.y * posScale) + + ctx.globalAlpha = vis + const nodeInk = nodeHigh ? base : n.kind === 'memory' ? memoryInk : skillInk + const shape = NODE_SHAPE[n.kind] + shapePath(ctx, shape, sx, sy, r) + + if (shape === 'circle') { + // Highlighted orbs pop full bright; others darken so the sheen reads. + sphereFill(ctx, sx, sy, r, nodeInk, sheen, nodeHigh ? 0 : ORB_DARKEN) + } else { + ctx.fillStyle = rgba(nodeInk, 1) + ctx.fill() + } + + if (isFocus) { + ctx.globalAlpha = 1 + ctx.strokeStyle = rgba(nodeInk, 1) + ctx.lineWidth = 1.4 + shapePath(ctx, shape, sx, sy, r + 4) + ctx.stroke() + } + } + + ctx.globalAlpha = 1 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + // Ring date labels (top of each ellipse) — hoverable to focus the ring. Many + // adaptive rings can crowd the top, so labels thin out: skip any that would + // land within LABEL_GAP of the last one drawn (the gridline still shows). + ctx.font = '10px ui-sans-serif, system-ui, sans-serif' + ctx.textAlign = 'center' + const LABEL_GAP = 15 + let lastLabelY = Number.POSITIVE_INFINITY + rings.forEach((rg, i) => { + if (!rg.label) { + return + } + + const sx = projX(0) + // Track the growing radius so the date rides the ring as it expands out. + const sy = projY(-(ringDrawR[i] ?? rg.r)) + + if (sy < 8 || sy > h - 8 || lastLabelY - sy < LABEL_GAP) { + return + } + + lastLabelY = sy + const tw = ctx.measureText(rg.label).width + const boxW = tw + 6 + const isThis = ringIdx === i || hoverRing === i + const faded = (focusId != null || ringIdx != null) && !isThis + // The date rides the same smooth ringAppear envelope, so it recedes as + // gently as it appears; the bucket carries only the snappy focus/selection dim. + const emphasisAlpha = faded ? 0.33 : 1 + const labelAlpha = fadeAlpha(fades.labels, String(i), emphasisAlpha, isThis) * (ringVis[i] ?? 1) + + if (labelAlpha < 0.01) { + return + } + + ctx.globalAlpha = labelAlpha + ctx.fillStyle = rgba(bg, 1) + ctx.fillRect(sx - boxW / 2, sy - 6, boxW, 13) + ctx.fillStyle = shade(isThis ? 1 : 0.2) + ctx.fillText(rg.label, sx, sy + 3) + ctx.globalAlpha = 1 + // Hidden labels (mid fade-out / not yet reached) drop out of hit-testing. + ringLabelRects.push({ h: 18, i, w: boxW + 6, x: sx - boxW / 2 - 3, y: sy - 10 }) + }) + + // Tooltip on focus — measured first so its rect joins the avoidance set and + // neighbor labels route around it. + const tipNode = focusId ? byId.get(focusId) : null + const tip = tipNode && seen(tipNode.rec) ? tipNode : null + let tipRect: null | Rect = null + + if (tip) { + const PADX = 6 + const PADY = 4 + const BADGE_H = 14 + const ROW_GAP = 3 + const LINE_H = 16 + const ITEM_GAP = 8 + const badgeFont = '9px ui-sans-serif, system-ui, sans-serif' + const monoFont = '9px ui-monospace, SFMono-Regular, Menlo, monospace' + const titleFont = '600 11px ui-sans-serif, system-ui, sans-serif' + const footerFont = '9px ui-sans-serif, system-ui, sans-serif' + const FOOTER_H = 13 + // The date (index 0) stays sans; the rest of the tags are monospace. + const badgeFontFor = (i: number) => (i === 0 ? badgeFont : monoFont) + + const badges = metaBadges(tip) + const use = countLabel(tip) + const titleText = tip.kind === 'memory' ? memById.get(tip.id)?.body.split('\n')[0]?.trim() || tip.label : tip.label + + const badgeW = badges.map((b, i) => { + ctx.font = badgeFontFor(i) + + return ctx.measureText(b).width + }) + + const rowW = badgeW.reduce((a, b) => a + b, 0) + ITEM_GAP * Math.max(0, badges.length - 1) + ctx.font = monoFont + const useW = use ? ctx.measureText(use).width : 0 + const metaW = rowW + (use ? ITEM_GAP + useW : 0) + + ctx.font = titleFont + const maxTitleW = Math.min(380, w - 16) - PADX * 2 + const titleLines = wrapText(ctx, titleText, maxTitleW) + const titleW = Math.max(0, ...titleLines.map(l => ctx.measureText(l).width)) + const titleBgW = titleW + PADX * 2 + const titleBgH = titleLines.length * LINE_H + PADY * 2 + + const footerText = nodeFooter(tip) + ctx.font = footerFont + const footerW = footerText ? ctx.measureText(footerText).width : 0 + + const totalW = Math.max(metaW, footerW, titleBgW) + const totalH = BADGE_H + ROW_GAP + titleBgH + (footerText ? ROW_GAP + FOOTER_H : 0) + const bx = clamp(projX(tip.x) - totalW / 2, 4, Math.max(4, w - totalW - 4)) + const by = clamp(projY(tip.y) - (nodeRadius(tip) * vp.k + 8) - totalH, 4, Math.max(4, h - totalH - 4)) + tipRect = { h: totalH, w: totalW, x: bx, y: by } + + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + const badgeMidY = by + BADGE_H / 2 + + // Metadata row, flush at the left edge. + ctx.fillStyle = shade(0.7) + let cx = bx + badges.forEach((label, i) => { + ctx.font = badgeFontFor(i) + ctx.fillText(label, cx, badgeMidY) + cx += badgeW[i] + ITEM_GAP + }) + + if (use) { + ctx.font = monoFont + ctx.fillStyle = shade(0.5) + ctx.fillText(use, cx, badgeMidY) + } + + // Title: inverted (fg/bg flipped) so the focused tooltip pops. + const ty = by + BADGE_H + ROW_GAP + ctx.fillStyle = shade(1) + ctx.fillRect(bx, ty, titleBgW, titleBgH) + ctx.font = titleFont + ctx.fillStyle = inkInv + titleLines.forEach((line, i) => { + ctx.fillText(line, bx + PADX, ty + PADY + LINE_H * i + LINE_H / 2) + }) + + if (footerText) { + ctx.font = footerFont + ctx.fillStyle = shade(0.45) + ctx.fillText(footerText, bx, ty + titleBgH + ROW_GAP + FOOTER_H / 2) + } + + ctx.textBaseline = 'alphabetic' + } + + // Neighbor constellation labels — greedy placement that clamps to the overlay + // and dodges placed labels (date labels + tooltip) so nothing overlaps/clips. + ctx.font = '11px ui-sans-serif, system-ui, sans-serif' + ctx.textAlign = 'center' + const LBL_M = 6 + const LBL_H = 15 + const placed: Rect[] = ringLabelRects.map(r => ({ h: r.h, w: r.w, x: r.x, y: r.y })) + + if (tipRect) { + placed.push(tipRect) + } + + for (const id of focusSet ?? []) { + if (id === hoverId) { + continue + } + + const n = byId.get(id) + + if (!n || !seen(n.rec)) { + continue + } + + const label = ellipsize(ctx, n.label, Math.min(180, w * 0.32)) + const bw = ctx.measureText(label).width + 8 + const x = clamp(projX(n.x) - bw / 2, LBL_M, Math.max(LBL_M, w - bw - LBL_M)) + const top = projY(n.y) - (nodeRadius(n) * vp.k + 7) - LBL_H + 4 + const clampY = (v: number) => clamp(v, LBL_M, Math.max(LBL_M, h - LBL_H - LBL_M)) + const step = LBL_H + 3 + let y: null | number = null + + // Prefer above the node, then fan outward; skip if nothing stays clear (a + // label on the tooltip reads worse than no label). + for (let k = 0; k <= 7 && y == null; k += 1) { + for (const dy of k === 0 ? [0] : [-k * step, k * step]) { + const cand = { h: LBL_H, w: bw, x, y: clampY(top + dy) } + + if (!placed.some(p => rectsOverlap(cand, p))) { + y = cand.y + + break + } + } + } + + if (y == null) { + continue + } + + placed.push({ h: LBL_H, w: bw, x, y }) + ctx.fillStyle = chipBg + ctx.fillRect(x, y, bw, LBL_H) + ctx.fillStyle = shade(0.85) + ctx.fillText(label, x + bw / 2, y + 11) + } + + return { animating, ringLabelRects } +} diff --git a/apps/desktop/src/app/starmap/share-code.test.ts b/apps/desktop/src/app/starmap/share-code.test.ts new file mode 100644 index 00000000000..a011292d8de --- /dev/null +++ b/apps/desktop/src/app/starmap/share-code.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' + +import type { StarmapGraph } from '@/types/hermes' + +import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' + +function sampleGraph(): StarmapGraph { + return { + clusters: [], + edges: [ + { source: 'skill-a', target: 'skill-b' }, + { source: 'skill-b', target: 'memory:profile:0' } + ], + memory: [ + { body: 'Prefers concise answers.', source: 'profile', timestamp: 1_700_000_000, title: 'Tone' }, + { body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' } + ], + nodes: [ + { category: 'devops', createdBy: 'agent', id: 'skill-a', kind: 'skill', label: 'skill-a', pinned: true, state: 'active', timestamp: 1_699_900_000, useCount: 7 }, + { category: 'devops', createdBy: null, id: 'skill-b', kind: 'skill', label: 'skill-b', pinned: false, state: 'draft', timestamp: 1_699_950_000, useCount: 0 }, + { category: 'memory', createdBy: null, id: 'memory:profile:0', kind: 'memory', label: 'A fact', memorySource: 'profile', pinned: false, state: 'active', timestamp: 1_700_000_000, useCount: 0 } + ], + stats: {} + } +} + +// Decoded edges compared by node POSITION (ids are synthesized), so topology is +// the invariant, not the literal id strings. +const topology = (g: StarmapGraph): [number, number][] => { + const idx = new Map(g.nodes.map((n, i) => [n.id, i])) + + return g.edges.map(e => [idx.get(e.source)!, idx.get(e.target)!]) +} + +describe('share-code', () => { + // The viz contract: everything the star map RENDERS survives — kinds, radius + // inputs, time position, edge topology — while text is dropped (it's a loadout, + // not a backup). + it('preserves the visualization', () => { + const g = sampleGraph() + const decoded = decodeShareCode(encodeShareCode(g)) + const span = 1_700_000_000 - 1_699_900_000 + const tol = Math.ceil(span / 4095) + 1 + + expect(decoded.nodes).toHaveLength(g.nodes.length) + + decoded.nodes.forEach((d, i) => { + const o = g.nodes[i]! + expect(d.kind).toBe(o.kind) + expect(d.label).toBe(o.label) + expect(d.useCount).toBe(o.useCount) + expect(d.state).toBe(o.state) + expect(d.pinned).toBe(o.pinned) + expect(d.category).toBe(o.category) + expect(d.memorySource).toBe(o.memorySource) + expect(d.createdBy).toBe(o.createdBy) + + if (o.timestamp == null) { + expect(d.timestamp).toBeNull() + } else { + expect(Math.abs((d.timestamp ?? 0) - o.timestamp)).toBeLessThanOrEqual(tol) + } + }) + + expect(topology(decoded)).toEqual(topology(g)) + }) + + it('drops memory prose (loadout is viz-only)', () => { + expect(decodeShareCode(encodeShareCode(sampleGraph())).memory).toHaveLength(0) + }) + + it('rebuilds clusters from node categories', () => { + const decoded = decodeShareCode(encodeShareCode(sampleGraph())) + + expect(decoded.clusters.find(c => c.category === 'devops')?.count).toBe(2) + }) + + it('produces a short, opaque, prefixed code', () => { + const code = encodeShareCode(sampleGraph()) + + expect(code.startsWith('HML')).toBe(true) + expect(code.slice(3)).toMatch(/^[A-Za-z0-9_-]+$/) + // Strictly smaller than the naive JSON it replaces — the whole point. + expect(code.length).toBeLessThan(JSON.stringify(sampleGraph()).length) + }) + + it('stays compact on a large graph (no string bloat)', () => { + const nodes = Array.from({ length: 500 }, (_, i) => ({ + category: `cat-${i % 8}`, + createdBy: 'agent' as const, + id: `s${i}`, + kind: 'skill' as const, + label: `A fairly verbose skill label number ${i}`, + pinned: false, + state: 'active', + timestamp: 1_700_000_000 + i * 3600, + useCount: i % 50 + })) + + const graph: StarmapGraph = { clusters: [], edges: [], memory: [], nodes, stats: {} } + const code = encodeShareCode(graph) + + // Deflate keeps even verbose, repetitive labels far below the naive JSON. + expect(code.length).toBeLessThan(JSON.stringify(graph).length / 5) + }) + + it('handles an empty graph', () => { + const decoded = decodeShareCode(encodeShareCode({ clusters: [], edges: [], memory: [], nodes: [], stats: {} })) + + expect(decoded.nodes).toHaveLength(0) + expect(decoded.edges).toHaveLength(0) + }) + + it('drops edges whose endpoints are missing', () => { + const g = sampleGraph() + g.edges.push({ source: 'skill-a', target: 'does-not-exist' }) + + expect(decodeShareCode(encodeShareCode(g)).edges).toHaveLength(2) + }) + + it('rejects garbage with a ShareCodeError', () => { + expect(() => decodeShareCode('not a real code !!!')).toThrow(ShareCodeError) + expect(() => decodeShareCode('')).toThrow(ShareCodeError) + }) + + it('rejects a corrupted (bit-flipped) code', () => { + const code = encodeShareCode(sampleGraph()) + // Flip a mid-payload char (trailing base64 bits can be dropped on decode). + const i = Math.floor(code.length / 2) + const corrupted = code.slice(0, i) + (code[i] === 'A' ? 'B' : 'A') + code.slice(i + 1) + + expect(() => decodeShareCode(corrupted)).toThrow(ShareCodeError) + }) + + it('tolerates whitespace, including internal wraps', () => { + const code = encodeShareCode(sampleGraph()) + const wrapped = ` ${code.slice(0, 20)}\n${code.slice(20)}\t` + + expect(() => decodeShareCode(wrapped)).not.toThrow() + expect(decodeShareCode(wrapped).nodes).toHaveLength(sampleGraph().nodes.length) + }) +}) diff --git a/apps/desktop/src/app/starmap/share-code.ts b/apps/desktop/src/app/starmap/share-code.ts new file mode 100644 index 00000000000..6e1e0d70ac1 --- /dev/null +++ b/apps/desktop/src/app/starmap/share-code.ts @@ -0,0 +1,186 @@ +import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout' +import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes' + +// ── Star-map share code ─────────────────────────────────────────────────────── +// +// The body schema for a star map, riding the generic loadout codec (@/lib/loadout +// owns the bitstream, DEFLATE, version+checksum frame, and base64url). We encode +// what the map RENDERS — each node's kind, its time POSITION (12-bit quantized, +// not an absolute epoch), radius inputs (useCount/state/pinned), and an interned +// label + category — plus edges as fixed-width node indices. Memory prose is +// dropped; labels are trimmed. DEFLATE then makes the repetitive label/category +// text almost free. A 60-skill map is a few hundred chars. + +const VERSION = 3 +const PREFIX = 'HML' // "Hermes Memory Loadout" — namespaces our codes like WoW's leading bytes. +const MAX_LABEL = 64 // trim runaway memory titles so one card can't bloat the code. + +const trim = (s: string): string => (s.length > MAX_LABEL ? s.slice(0, MAX_LABEL) : s) + +const KINDS = ['skill', 'memory'] as const +const STATES = ['active', 'archived', 'disabled', 'draft'] as const +const MEM_SOURCES = ['none', 'memory', 'profile'] as const +const CREATED_BY = ['none', 'agent', 'user'] as const + +const REC_BITS = 12 // time position resolution: 1/4096 of the span — sub-pixel here. +const REC_MAX = (1 << REC_BITS) - 1 + +const finiteTs = (v?: null | number): null | number => + typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : null + +function writeNode(w: BitWriter, n: StarmapNode, dict: Dict, minTs: number, span: number): void { + w.uint(idxOf(KINDS, n.kind), 1) + w.varint(dict.id(trim(n.label || ''))) + w.varint(dict.id(n.category || '')) + w.varint(Math.max(0, n.useCount | 0)) + w.uint(idxOf(STATES, n.state), 2) + w.uint(idxOf(MEM_SOURCES, n.memorySource ?? 'none'), 2) + w.uint(idxOf(CREATED_BY, n.createdBy ?? 'none'), 2) + w.bit(n.pinned) + + // Time as a 12-bit POSITION within [minTs, maxTs] — not an absolute epoch. + const ts = finiteTs(n.timestamp) + + if (ts === null) { + w.bit(0) + } else { + w.bit(1) + w.uint(span > 0 ? Math.round(((ts - minTs) / span) * REC_MAX) : 0, REC_BITS) + } +} + +function readNode(r: BitReader, dict: string[], i: number, minTs: number, span: number): StarmapNode { + const kind = KINDS[r.uint(1)] ?? 'skill' + const label = dict[r.varint()] ?? '' + const category = dict[r.varint()] ?? '' + const useCount = r.varint() + const state = STATES[r.uint(2)] ?? 'active' + const memSrc = MEM_SOURCES[r.uint(2)] ?? 'none' + const createdBy = CREATED_BY[r.uint(2)] ?? 'none' + const pinned = r.bit() === 1 + const timestamp = r.bit() === 1 ? minTs + (span > 0 ? Math.round((r.uint(REC_BITS) / REC_MAX) * span) : 0) : null + + // Ids are synthesized (they're never displayed); memory ids mirror the scan's + // `memory::` shape so the rest of the UI is none the wiser. + const isMemory = kind === 'memory' + const source = memSrc === 'none' ? 'memory' : memSrc + + return { + category, + createdBy: createdBy === 'none' ? null : createdBy, + id: isMemory ? `memory:${source}:${i}` : `s${i}`, + kind, + label, + memorySource: isMemory ? source : undefined, + pinned, + state, + timestamp, + useCount + } +} + +function writeGraph(w: BitWriter, graph: StarmapGraph): void { + const dict = new Dict() + + // Intern labels + categories; deflate later squeezes the inevitable repetition. + for (const n of graph.nodes) { + dict.id(trim(n.label || '')) + dict.id(n.category || '') + } + + const stamps = graph.nodes.map(n => finiteTs(n.timestamp)).filter((v): v is number => v !== null) + const minTs = stamps.length ? Math.min(...stamps) : 0 + const maxTs = stamps.length ? Math.max(...stamps) : 0 + const span = maxTs - minTs + + w.varint(minTs) + w.varint(maxTs) + w.varint(dict.list.length) + + for (const s of dict.list) { + w.str(s) + } + + w.varint(graph.nodes.length) + + for (const n of graph.nodes) { + writeNode(w, n, dict, minTs, span) + } + + // Edges reference nodes by position; drop any whose endpoints aren't both nodes. + const order = new Map(graph.nodes.map((n, i) => [n.id, i])) + const edges = graph.edges.filter(e => order.has(e.source) && order.has(e.target)) + const bits = indexBits(graph.nodes.length) + w.varint(edges.length) + + for (const e of edges) { + w.uint(order.get(e.source)!, bits) + w.uint(order.get(e.target)!, bits) + } +} + +function readGraph(r: BitReader): StarmapGraph { + const minTs = r.varint() + const maxTs = r.varint() + const span = maxTs - minTs + + const dictLen = r.varint() + const dict: string[] = [] + + for (let i = 0; i < dictLen; i += 1) { + dict.push(r.str()) + } + + const nodeCount = r.varint() + const nodes: StarmapNode[] = [] + + for (let i = 0; i < nodeCount; i += 1) { + nodes.push(readNode(r, dict, i, minTs, span)) + } + + const bits = indexBits(nodeCount) + const edgeCount = r.varint() + const edges: StarmapEdge[] = [] + + for (let i = 0; i < edgeCount; i += 1) { + const src = nodes[r.uint(bits)] + const dst = nodes[r.uint(bits)] + + if (src && dst) { + edges.push({ source: src.id, target: dst.id }) + } + } + + const counts = new Map() + + for (const n of nodes) { + counts.set(n.category, (counts.get(n.category) ?? 0) + 1) + } + + const clusters = [...counts.entries()].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count) + + // Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map + // apart from a freshly-scanned one. + return { clusters, edges, memory: [], nodes, stats: { imported: true } } +} + +export class ShareCodeError extends LoadoutError {} + +const codec = createLoadout({ + error: ShareCodeError, + noun: 'map code', + prefix: PREFIX, + read: readGraph, + version: VERSION, + write: writeGraph +}) + +// Serialize a star-map graph to a short, opaque, clipboard-safe loadout string. +export function encodeShareCode(graph: StarmapGraph): string { + return codec.encode(graph) +} + +// Parse a loadout string back into a (viz-complete, text-synthesized) graph. +export function decodeShareCode(code: string): StarmapGraph { + return codec.decode(code) +} diff --git a/apps/desktop/src/app/starmap/share-controls.tsx b/apps/desktop/src/app/starmap/share-controls.tsx new file mode 100644 index 00000000000..a782b405a8e --- /dev/null +++ b/apps/desktop/src/app/starmap/share-controls.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useI18n } from '@/i18n' + +interface ShareControlsProps { + // True when the shown map was loaded from a pasted code (not the live scan). + imported?: boolean + // Decode + apply a pasted code. Returns an error string to show inline, or null. + onImport?: (code: string) => null | string + onResetMap?: () => void + // The current map serialized as a WoW-style share code (the copy target). + shareCode?: string +} + +const SECTION_LABEL = 'text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/55' + +// WoW-talent-loadout style sharing: one icon button opens a popover with the +// current map's code (copy/export) and a paste box (import) — drop a string, +// see the build. Lives bottom-right of the map, mirroring the legend. +export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) { + const { t } = useI18n() + const [open, setOpen] = useState(false) + const [draft, setDraft] = useState('') + const [error, setError] = useState(null) + + const apply = () => { + const code = draft.trim() + + if (!code) { + setError(t.starmap.importEmpty) + + return + } + + const err = onImport?.(code) ?? null + setError(err) + + if (err === null) { + setOpen(false) + setDraft('') + } + } + + return ( + { + setOpen(next) + + if (!next) { + setError(null) + } + }} + open={open} + > + + + + + +
+
+ {t.starmap.share} + {imported && ( + + )} +
+ +
+
+ {shareCode || '—'} +
+ +
+
+ +
+ +
+ {t.starmap.importMap} + +
+ { + setDraft(e.target.value) + setError(null) + }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + apply() + } + }} + placeholder={t.starmap.sharePlaceholder} + value={draft} + /> + +
+ + {error &&

{error}

} +
+ + + ) +} diff --git a/apps/desktop/src/app/starmap/simulation.ts b/apps/desktop/src/app/starmap/simulation.ts new file mode 100644 index 00000000000..682170e23b7 --- /dev/null +++ b/apps/desktop/src/app/starmap/simulation.ts @@ -0,0 +1,267 @@ +import { forceCollide, forceLink, forceManyBody, forceRadial, forceSimulation, type Simulation } from 'd3-force' + +import type { StarmapGraph, StarmapNode } from '@/types/hermes' + +import { RING_STEPS } from './constants' +import { clamp, hash, nodeRadius, radiusForRecency } from './geometry' +import { formatDate } from './text' +import { computeRecency, recForRatio } from './time-axis' +import type { Ring, SimLink, SimNode } from './types' + +export interface BuiltSim { + byId: Map + links: SimLink[] + nodes: SimNode[] + rings: Ring[] + sim: Simulation +} + +const DAY = 86_400 + +// Constant ring SCALE: the core radius and the per-ring band are pinned to the +// canonical 5-ring layout, so the empty core and every band are ALWAYS that +// size on the disk — more data grows the disk OUTWARD (more rings) instead of +// stretching a fixed disk thinner. The camera caps its zoom at the 5-ring +// extent (see fitViewport), so this world size is also a constant screen size. +const RING_CORE = radiusForRecency(recForRatio(0)) +const RING_BAND = (radiusForRecency(recForRatio(1)) - RING_CORE) / RING_STEPS +const ringRadius = (i: number): number => RING_CORE + i * RING_BAND + +// Place a node INSIDE its ring's band (the annulus the ring caps), biased toward +// mid-band so it reads as "within the ring", only occasionally grazing an edge — +// never sitting on the outline like a bead. +const placeRadius = (i: number, id: string): number => { + const outer = ringRadius(i) + const inner = i > 0 ? ringRadius(i - 1) : RING_CORE - RING_BAND * 0.5 + const h = (hash(id) % 1000) / 1000 + + return outer - (0.15 + 0.7 * h) * (outer - inner) +} + +interface Unit { + kind: 'day' | 'month' + step: number +} + +// "Nice" calendar intervals, fine → coarse, WITH intermediate rungs (2-day, +// bi-weekly, 2/3/6-month) so the bucketer can land NEAR the target count instead +// of jumping straight from weekly (≈9) to monthly (≈3) and missing the ~5 sweet spot. +const UNITS: Unit[] = [ + { kind: 'day', step: 1 }, + { kind: 'day', step: 2 }, + { kind: 'day', step: 7 }, + { kind: 'day', step: 14 }, + { kind: 'month', step: 1 }, + { kind: 'month', step: 2 }, + { kind: 'month', step: 3 }, + { kind: 'month', step: 6 }, + { kind: 'month', step: 12 } +] + +// Floor a timestamp to the start of its calendar bucket — the key nodes group by. +function bucketStart(ts: number, { kind, step }: Unit): number { + if (kind === 'day') { + const period = step * DAY + + return Math.floor(ts / period) * period + } + + const d = new Date(ts * 1000) + d.setUTCHours(0, 0, 0, 0) + // Floor to a step-month boundary in ABSOLUTE months so steps align across + // years (3-month → Jan/Apr/Jul/Oct, 12-month → Jan). + const absMonth = Math.floor((d.getUTCFullYear() * 12 + d.getUTCMonth()) / step) * step + d.setUTCFullYear(Math.floor(absMonth / 12), absMonth % 12, 1) + + return Math.floor(d.getTime() / 1000) +} + +const populatedStarts = (stamps: number[], u: Unit): number[] => [...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b) + +// "Nice ticks" for time (à la D3/Heckbert): aim for a target ring count that +// grows ~log2 with the span, then snap to the calendar interval whose POPULATED +// count lands nearest it (ties + overshoot break toward fewer/finer). The floor +// is 5 — fewer than that and the play-through "steps" between rings get big and +// abrupt; ~5+ evenly-paced rings give the smooth Spore-style build-up. +function chooseUnit(stamps: number[], spanDays: number): Unit { + const target = clamp(Math.round(4 + Math.log2(Math.max(1, spanDays / 60))), 5, 12) + let best = UNITS[0]! + let bestScore = Infinity + + for (const u of UNITS) { + const count = populatedStarts(stamps, u).length + + if (!count) { + continue + } + + const score = Math.abs(count - target) + (count > target ? 0.5 : 0) + + if (score < bestScore) { + bestScore = score + best = u + } + } + + return best +} + +function bucketLabel(ts: number, { kind, step }: Unit): string { + if (kind === 'day') { + return formatDate(ts) + } + + try { + const d = new Date(ts * 1000) + + return step >= 12 ? String(d.getUTCFullYear()) : d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' }) + } catch { + return formatDate(ts) + } +} + +interface Layout { + // bucket/cap ring a node belongs to (the ring it ignites behind) + index: (n: StarmapNode) => number + // reveal coordinate (0–1) a node ignites at — staggered within its band + rec: (n: StarmapNode) => number + rings: Ring[] + // world radius a node is drawn at (inside its band) + tr: (n: StarmapNode) => number +} + +// Even, unlabeled-ish fallback when there's no usable time span (undated graph +// or one instant): keep the legacy continuous mapping so nothing regresses. +function evenLayout(recById: Map, minTs: null | number, maxTs: null | number, timed: boolean): Layout { + const rings: Ring[] = Array.from({ length: RING_STEPS + 1 }, (_, i) => ({ + label: timed && minTs !== null && maxTs !== null ? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS))) : null, + r: ringRadius(i), + ratio: recForRatio(i / RING_STEPS) + })) + + const capRing = (rec: number): number => { + for (let i = 0; i < rings.length; i += 1) { + if ((rings[i]?.ratio ?? 1) >= rec - 1e-3) { + return i + } + } + + return rings.length - 1 + } + + return { + index: n => capRing(recById.get(n.id) ?? 0), + rec: n => recById.get(n.id) ?? 0, + rings, + tr: n => radiusForRecency(recById.get(n.id) ?? 0) + } +} + +// One equal-width ring per POPULATED calendar bucket; a bucket's nodes fill the +// band INSIDE their ring (fanned by angle) and ignite staggered across it. +function buildLayout(graph: StarmapGraph, recById: Map, minTs: null | number, maxTs: null | number, timed: boolean): Layout { + const stamps = graph.nodes.map(n => Number(n.timestamp)).filter(Number.isFinite) + + if (!(timed && minTs !== null && maxTs !== null && maxTs > minTs && stamps.length)) { + return evenLayout(recById, minTs, maxTs, timed) + } + + const span = maxTs - minTs + const unit = chooseUnit(stamps, span / DAY) + const starts = populatedStarts(stamps, unit) + + if (starts.length < 2) { + return evenLayout(recById, minTs, maxTs, timed) + } + + const indexOfStart = new Map(starts.map((s, i) => [s, i])) + // Reveal pacing is per-BUCKET (uniform), matching the equal-width bands: each + // ring is one even step. (Radius is already index-based.) Using raw time here + // decouples a ring's ignite moment from its position — a bursty gap makes a + // ring appear bands ahead of the nodes that belong to it. Labels stay real dates. + const last = Math.max(1, starts.length - 1) + const rings: Ring[] = starts.map((s, i) => ({ label: bucketLabel(s, unit), r: ringRadius(i), ratio: recForRatio(i / last) })) + + // A node's bucket is its ring; undated nodes (rare, in an otherwise-timed + // graph) fall to the newest ring so they still appear. + const indexFor = (n: StarmapNode): number => { + const ts = Number(n.timestamp) + + return Number.isFinite(ts) ? (indexOfStart.get(bucketStart(ts, unit)) ?? starts.length - 1) : starts.length - 1 + } + + // Node POSITION fills the band inside its bucket ring (placeRadius); its IGNITE + // time is staggered ACROSS that band, ordered by real timestamp, so a busy + // bucket trickles in over its whole slice instead of every node popping at + // once (the "everything floods in at the end" bug). + const buckets: StarmapNode[][] = starts.map(() => []) + + for (const n of graph.nodes) { + buckets[indexFor(n)]!.push(n) + } + + const tsOf = (n: StarmapNode): number => (Number.isFinite(Number(n.timestamp)) ? Number(n.timestamp) : Infinity) + const recByNode = new Map() + + buckets.forEach((bucket, i) => { + bucket.sort((a, b) => (tsOf(a) === tsOf(b) ? a.id.localeCompare(b.id) : tsOf(a) - tsOf(b))) + + const hi = rings[i]!.ratio + const lo = i > 0 ? rings[i - 1]!.ratio : 0 + const m = bucket.length + + // f ∈ (0,1]: first node lands just inside the band, last node ON the ring. + bucket.forEach((n, k) => recByNode.set(n.id, lo + ((k + 1) / m) * (hi - lo))) + }) + + return { + index: indexFor, + rec: n => recByNode.get(n.id) ?? 0, + rings, + tr: n => placeRadius(indexFor(n), n.id) + } +} + +// Build the radial time simulation: a node's distance from the core encodes its +// timestamp bucket (radial force dominates; charge/collide only spread nodes +// around their date ring). Rings are dated, equal-width gridlines. +export function buildSimulation(graph: StarmapGraph, onTick: () => void): BuiltSim { + const { maxTs, minTs, rec: recById, timed } = computeRecency(graph.nodes) + const { index, rec: recOf, rings, tr: trOf } = buildLayout(graph, recById, minTs, maxTs, timed) + + const nodes: SimNode[] = graph.nodes.map(n => { + const rec = recOf(n) + const tr = trOf(n) + const angle = ((hash(n.id) % 3600) / 3600) * Math.PI * 2 + + return { ...n, outerRingIndex: index(n), rec, tr, vx: 0, vy: 0, x: Math.cos(angle) * tr, y: Math.sin(angle) * tr } + }) + + const byId = new Map(nodes.map(n => [n.id, n])) + + const links: SimLink[] = graph.edges + .filter(e => byId.has(e.source) && byId.has(e.target)) + .map(e => ({ source: e.source, target: e.target })) + + const sim = forceSimulation(nodes) + .alphaDecay(0.05) + .velocityDecay(0.62) + .force('charge', forceManyBody().strength(-12)) + .force( + 'link', + forceLink(links) + .id(n => n.id) + .distance(26) + .strength(0.06) + ) + .force( + 'collide', + forceCollide() + .radius(n => nodeRadius(n) + 2) + .iterations(2) + ) + .force('radial', forceRadial(n => (n as SimNode).tr, 0, 0).strength(0.92)) + .on('tick', onTick) + + return { byId, links, nodes, rings, sim } +} diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx new file mode 100644 index 00000000000..10896094b97 --- /dev/null +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -0,0 +1,875 @@ +import { type Simulation } from 'd3-force' +import { atom, type WritableAtom } from 'nanostores' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures' +import type { StarmapGraph } from '@/types/hermes' + +import { computePalette, memoryInkFor, resolveRgb, rgba } from './color' +import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' +import { clamp, distToSegmentSq, fitViewport, nodeRadius } from './geometry' +import { drawScene } from './render' +import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' +import { ShareControls } from './share-controls' +import { buildSimulation } from './simulation' +import { formatDate } from './text' +import { buildTimeAxis, dateAtReveal, type TimeAxis } from './time-axis' +import { Timeline } from './timeline' +import type { FadeBuckets, MemoryCard, Palette, Ring, RingLabelRect, SimLink, SimNode, Viewport } from './types' + +// How long a full play-through sweep takes (ms), reveal 0 → 1. Longer = the +// build-up breathes; the eased middle no longer rushes past in a blink. +const SWEEP_MS = 15000 + +// How far to relax the ease toward a flat linear march. The bare smoothstep +// spikes to 1.5× linear speed mid-sweep, which reads as a "snap" through the +// middle; blending it back toward linear flattens that peak (≈1.3× at GENTLE +// = 0.45) so playback glides instead of lurching, while still keeping a soft +// ease-in / ease-out at the very start and end. +const GENTLE = 0.45 + +// Cinematic timing: cubic smoothstep (gentle ease-in / ease-out) relaxed toward +// linear by GENTLE, so the middle never rushes. Monotonic on [0,1], so the +// numeric inverse below stays valid. +function cineEase(t: number): number { + const u = t < 0 ? 0 : t > 1 ? 1 : t + const smooth = u * u * (3 - 2 * u) + + return GENTLE * u + (1 - GENTLE) * smooth +} + +// Numeric inverse (monotonic) so a resume maps the current reveal back to clock +// progress without a closed-form solution. +function invCineEase(y: number): number { + let lo = 0 + let hi = 1 + + for (let i = 0; i < 24; i += 1) { + const mid = (lo + hi) / 2 + + if (cineEase(mid) < y) { + lo = mid + } else { + hi = mid + } + } + + return (lo + hi) / 2 +} + +function revealText(axis: TimeAxis, reveal: number): string { + const date = dateAtReveal(axis, reveal) + + return date !== null ? formatDate(date) : `${Math.round(reveal * axis.size)} / ${axis.size}` +} + +function RevealLabel({ axis, revealStore }: { axis: TimeAxis; revealStore: WritableAtom }) { + const labelRef = useRef(null) + + const sync = useCallback( + (reveal: number) => { + const el = labelRef.current + + if (el) { + el.textContent = revealText(axis, reveal) + } + }, + [axis] + ) + + useEffect(() => revealStore.subscribe(sync), [revealStore, sync]) + + useEffect(() => { + sync(revealStore.get()) + }, [revealStore, sync]) + + return ( + + {revealText(axis, revealStore.get())} + + ) +} + +// A tilted, top-down star map of what Hermes has learned. Time is RADIAL: oldest +// at the core, newest on the outer rings. This component owns the refs, effects +// and pointer wiring; layout lives in simulation.ts and painting in render.ts. +export function StarMap({ + graph, + imported = false, + onImport, + onResetMap +}: { + graph: StarmapGraph + imported?: boolean + onImport?: (graph: StarmapGraph) => void + onResetMap?: () => void +}) { + const canvasRef = useRef(null) + const wrapRef = useRef(null) + + const simRef = useRef>(null) + const nodesRef = useRef([]) + const linksRef = useRef([]) + const byIdRef = useRef(new Map()) + const adjacencyRef = useRef(new Map>()) + const memByIdRef = useRef(new Map()) + const ringsRef = useRef([]) + const ringLabelRectsRef = useRef([]) + + const fadeRef = useRef({ + appear: new Map(), + labels: new Map(), + links: new Map(), + nodes: new Map(), + rings: new Map() + }) + + const doubleTapRef = useRef(createDoubleTapDetector()) + const paletteRef = useRef(null) + const themeDirtyRef = useRef(true) + const invalidateRef = useRef<() => void>(() => {}) + const viewportRef = useRef({ k: 1, x: 0, y: 0 }) + const hoverRef = useRef(null) + const hoveredLinkRef = useRef(null) + const hoveredRingRef = useRef(null) + const selectedRingRef = useRef(null) + const selectedIdRef = useRef(null) + const sizeRef = useRef({ h: 0, w: 0 }) + const dprRef = useRef(1) + const dirtyRef = useRef(true) + // Scrub = direct manipulation (snap the fades to the pointer); Play = the + // cinematic birth/fade easing. One frame's worth of state, never re-rendered. + const snapMotionRef = useRef(false) + + const dragRef = useRef<{ + id: null | string + mode: 'none' | 'pan' + moved: boolean + ring: null | number + sx: number + sy: number + vp: Viewport + }>({ id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: { k: 1, x: 0, y: 0 } }) + + const [selectedId, setSelectedId] = useState(null) + const [size, setSize] = useState({ h: 0, w: 0 }) + // Bumped on theme change so the legend's memory swatch recomputes its color. + const [themeVersion, setThemeVersion] = useState(0) + // Memory's swatch color — the same complementary-of-primary the canvas uses, + // so the legend matches the rendered diamonds exactly. + const [memoryColor, setMemoryColor] = useState('var(--theme-secondary)') + + // Time scrubber: reveal 1 = the whole map (idle default); lower values hide + // not-yet-reached nodes so playing/scrubbing "builds it up". revealRef feeds + // the canvas loop and revealStore feeds the timeline + legend label — so a + // play-through / scrub never re-renders StarMap (the perf win). `playing` + // stays React state since it flips rarely and drives the play effect + button. + const revealStore = useMemo(() => atom(1), []) + const [playing, setPlaying] = useState(false) + // Reveal positions where each dated ring spawns (its inner neighbor's ratio — + // ringSeen reveals one band ahead), surfaced as markers on the timeline. + const [ringStops, setRingStops] = useState([]) + const revealRef = useRef(1) + // Spore-style zoom: the camera fits the *leading ring's* radius, a step + // function of reveal. It holds steady while a band fills, then eases out to the + // next shell when a new ring is reached — growth in discrete jumps, not a + // constant creep. This ref is the camera's current (eased) fit radius. + const camRadiusRef = useRef(RING_OUTER) + const timeAxis = useMemo(() => buildTimeAxis(graph, 72), [graph]) + + // The current map as a WoW-style share code, recomputed only when the graph + // changes (encode walks every node/edge/card, so don't redo it per render). + const shareCode = useMemo(() => encodeShareCode(graph), [graph]) + + // Decode a pasted code and hand the resulting graph up to the StarmapView, + // which swaps it in for the live profile scan. Returns an error string for the + // Timeline to surface inline, or null on success. + const importCode = useCallback( + (code: string): null | string => { + try { + const next = decodeShareCode(code) + onImport?.(next) + + return null + } catch (err) { + return err instanceof ShareCodeError ? err.message : 'Could not read that map code.' + } + }, + [onImport] + ) + + // Mark the canvas dirty and wake the (otherwise-idle) render loop. + const invalidate = useCallback(() => invalidateRef.current(), []) + + // Single writer for the scrubber position: feeds the canvas (ref), the + // timeline + legend label (store subscribers), and wakes the paint loop — + // no React re-render, so playback/scrubbing stays off the render path. + const setRevealValue = useCallback( + (value: number) => { + const next = clamp(value, 0, 1) + revealRef.current = next + revealStore.set(next) + invalidate() + }, + [invalidate, revealStore] + ) + + // Drop every in-flight ease so the next frame snaps to its targets. + const resetFades = useCallback(() => { + for (const bucket of Object.values(fadeRef.current)) { + bucket.clear() + } + }, []) + + const memById = useMemo(() => { + const m = new Map() + graph.memory.forEach((card, i) => m.set(`memory:${card.source}:${i}`, card)) + + return m + }, [graph.memory]) + + const adjacency = useMemo(() => { + const m = new Map>() + + for (const n of graph.nodes) { + m.set(n.id, new Set()) + } + + for (const e of graph.edges) { + m.get(e.source)?.add(e.target) + m.get(e.target)?.add(e.source) + } + + return m + }, [graph.edges, graph.nodes]) + + // Track the wrapper size. + useEffect(() => { + const el = wrapRef.current + + if (!el) { + return + } + + const sync = () => setSize({ h: el.clientHeight, w: el.clientWidth }) + const ro = new ResizeObserver(sync) + ro.observe(el) + sync() + + return () => ro.disconnect() + }, []) + + // (Re)build the radial simulation whenever the graph or size changes. + useEffect(() => { + sizeRef.current = size + + if (size.w === 0 || size.h === 0) { + return + } + + const { byId, links, nodes, rings, sim } = buildSimulation(graph, invalidate) + simRef.current = sim + nodesRef.current = nodes + linksRef.current = links + byIdRef.current = byId + ringsRef.current = rings + // Markers fire when a ring spawns: ringSeen(i) flips at rings[i-1].ratio. + setRingStops(rings.map((rg, i) => (rg.label != null ? (rings[i - 1]?.ratio ?? 0) : -1)).filter(v => v >= 0)) + resetFades() + // Fit the actual disk (outermost ring), so a 3-ring map frames like a 12-ring + // one — count changes the disk size, not the framing. + viewportRef.current = fitViewport(size.w, size.h, rings[rings.length - 1]?.r ?? RING_OUTER) + invalidate() + + if (selectedIdRef.current && !byId.has(selectedIdRef.current)) { + selectedIdRef.current = null + setSelectedId(null) + } + + return () => { + sim.stop() + + if (simRef.current === sim) { + simRef.current = null + } + } + }, [graph, invalidate, resetFades, size]) + + useEffect(() => { + adjacencyRef.current = adjacency + memByIdRef.current = memById + invalidate() + }, [adjacency, invalidate, memById]) + + // The empty-core ASCII scramble uses the bundled JetBrains Mono face. Canvas + // text doesn't reflow when a webfont loads, so repaint once it's ready. + useEffect(() => { + document.fonts?.load('1em "JetBrains Mono"').then(invalidate, () => {}) + }, [invalidate]) + + useEffect(() => { + selectedIdRef.current = selectedId + invalidate() + }, [invalidate, selectedId]) + + // A fresh graph resets the scrubber to "fully built" (the idle default). + useEffect(() => { + camRadiusRef.current = RING_OUTER + snapMotionRef.current = false + setRevealValue(1) + setPlaying(false) + }, [graph, setRevealValue]) + + // The stepped fit radius for a reveal: fit ONE ring BEYOND the leading shell + // (the first not-yet-passed ring) so the ring currently igniting its nodes + // sits comfortably inside the frame with a band of headroom, instead of jammed + // against the edge. Steps only when reveal crosses a boundary — the Spore step. + const targetRadius = useCallback((rev: number): number => { + const rings = ringsRef.current + + if (!rings.length) { + return RING_OUTER + } + + const lead = rings.findIndex(rg => rg.ratio > rev + 1e-3) + const i = lead === -1 ? rings.length - 1 : lead + const band = (rings[1]?.r ?? RING_OUTER) - (rings[0]?.r ?? 0) + + // Small headroom (a third of a band) so the igniting ring isn't jammed at the + // frame edge during playback, without zooming the resting view out. + return rings[i]!.r + band * 0.35 + }, []) + + const applyFit = useCallback((radius: number) => { + const { h, w } = sizeRef.current + + if (w > 0 && h > 0) { + viewportRef.current = fitViewport(w, h, radius) + } + }, []) + + // Snap the camera to a reveal's stepped target (scrubbing / reset — no glide). + const fitForReveal = useCallback( + (rev: number) => { + camRadiusRef.current = targetRadius(rev) + applyFit(camRadiusRef.current) + }, + [applyFit, targetRadius] + ) + + // Playback: sweep reveal 0 → 1 over SWEEP_MS, then stop (play once). + useEffect(() => { + if (!playing) { + return + } + + let raf = 0 + let start = 0 + + const step = (now: number) => { + if (!start) { + // Anchor (in clock-space) so a resume continues from the current reveal. + start = now - invCineEase(revealRef.current) * SWEEP_MS + } + + const progress = Math.min(1, (now - start) / SWEEP_MS) + const next = cineEase(progress) + + // Ease the camera toward the leading ring's radius (a step target): it + // holds while a band fills, then pushes out when the next shell is reached. + const target = targetRadius(next) + camRadiusRef.current += (target - camRadiusRef.current) * 0.1 + applyFit(camRadiusRef.current) + setRevealValue(next) + + // End once the reveal is complete AND the camera has settled on the final + // shell, so the last push-out finishes instead of cutting off. + if (progress >= 1 && Math.abs(target - camRadiusRef.current) < 0.5) { + camRadiusRef.current = target + applyFit(target) + setPlaying(false) + + return + } + + raf = requestAnimationFrame(step) + } + + raf = requestAnimationFrame(step) + + return () => cancelAnimationFrame(raf) + }, [applyFit, playing, setRevealValue, targetRadius]) + + const onTogglePlay = useCallback(() => { + if (playing) { + setPlaying(false) + + return + } + + // Leaving scrub: play eases (cinematic) rather than holding the snapped view. + snapMotionRef.current = false + + // Replay from the start when parked at the end. Snap straight to the empty + // state (no fade-out) before playing in. + if (revealRef.current >= 1) { + resetFades() + fitForReveal(0) + setRevealValue(0) + } + + setPlaying(true) + }, [fitForReveal, playing, resetFades, setRevealValue]) + + const onScrub = useCallback( + (value: number) => { + const next = clamp(value, 0, 1) + setPlaying(false) + // Scrub is direct manipulation: snap fades + camera to the pointer so a + // fast drag jumps there instead of replaying the birth-in from a stale spot. + snapMotionRef.current = true + fitForReveal(next) + setRevealValue(next) + }, + [fitForReveal, setRevealValue] + ) + + // Spacebar toggles playback (unless typing, or the play button itself is + // focused — that already handles Space natively, so skip to avoid a double). + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.code !== 'Space' && e.key !== ' ') { + return + } + + const el = document.activeElement + const tag = el?.tagName + + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON' || (el as HTMLElement | null)?.isContentEditable) { + return + } + + e.preventDefault() + onTogglePlay() + } + + window.addEventListener('keydown', onKey) + + return () => window.removeEventListener('keydown', onKey) + }, [onTogglePlay]) + + // Recompute the legend's memory swatch from the live --theme-primary (matches + // the canvas), re-running on theme change and once the canvas is mounted. + useEffect(() => { + const el = canvasRef.current ?? wrapRef.current + + if (!el) { + return + } + + const style = getComputedStyle(el) + const val = style.getPropertyValue('--theme-primary').trim() + + if (val) { + const bgVal = style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000' + setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9)) + } + }, [size, themeVersion]) + + // Repaint + repalette when the theme/mode changes (class + inline vars on ). + useEffect(() => { + const mo = new MutationObserver(() => { + themeDirtyRef.current = true + setThemeVersion(v => v + 1) + invalidate() + }) + + mo.observe(document.documentElement, { + attributeFilter: ['class', 'style', 'data-hermes-mode', 'data-hermes-theme'], + attributes: true + }) + + return () => mo.disconnect() + }, [invalidate]) + + // Event-driven render loop: no frames while idle. Anything that changes the + // view calls invalidate(); a draw that's still animating reschedules itself. + useEffect(() => { + let raf = 0 + // Continuous self-animation (the core scramble) only needs ~30fps; cap it so + // the idle loop isn't a 60fps full-scene redraw. Interaction bypasses the cap. + const ANIM_MS = 1000 / 30 + let lastAnimTs = 0 + let force = true + + // The scramble keeps the loop perpetually "animating", so a fully-built, + // untouched map still repaints 30×/s for as long as the panel is open. That's + // wasted CPU/GPU (WindowServer compositing) when the window isn't even the one + // you're looking at. Freeze the loop while the window is hidden or unfocused; + // a frozen core next to other work is fine, and it resumes instantly on focus. + const isPaused = () => (typeof document !== 'undefined' && document.hidden) || (typeof document.hasFocus === 'function' && !document.hasFocus()) + let paused = isPaused() + + const schedule = () => { + if (!paused && !raf) { + raf = requestAnimationFrame(frame) + } + } + + const draw = (): boolean => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + + if (!canvas || !ctx) { + return false + } + + if (themeDirtyRef.current || !paletteRef.current) { + paletteRef.current = computePalette(canvas) + themeDirtyRef.current = false + } + + const { animating, ringLabelRects } = drawScene({ + adjacency: adjacencyRef.current, + byId: byIdRef.current, + ctx, + dpr: dprRef.current, + fades: fadeRef.current, + focusId: selectedIdRef.current ?? hoverRef.current, + hoverId: hoverRef.current, + hoverLink: hoveredLinkRef.current, + hoverRing: hoveredRingRef.current, + links: linksRef.current, + memById: memByIdRef.current, + nodes: nodesRef.current, + palette: paletteRef.current, + reveal: revealRef.current, + rings: ringsRef.current, + selectedRing: selectedRingRef.current, + size: sizeRef.current, + snapMotion: snapMotionRef.current, + vp: viewportRef.current + }) + + // One-shot: a scrub snaps this frame; hover/focus afterward eases as usual + // (buckets are already at target, so the next eased frames don't move). + snapMotionRef.current = false + ringLabelRectsRef.current = ringLabelRects + + return animating + } + + const frame = (ts: number) => { + raf = 0 + + if (!dirtyRef.current) { + return + } + + // Throttle animation-only frames; an interaction (force) always draws now. + if (!force && ts - lastAnimTs < ANIM_MS) { + schedule() + + return + } + + force = false + lastAnimTs = ts + dirtyRef.current = draw() + + if (dirtyRef.current) { + schedule() + } + } + + invalidateRef.current = () => { + dirtyRef.current = true + force = true + schedule() + } + + // Suspend the loop when the window drops out of view/focus; wake + force a + // fresh frame the moment it returns so the resume is seamless. + const onActivity = () => { + const next = isPaused() + + if (next === paused) { + return + } + + paused = next + + if (paused) { + if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + } else { + dirtyRef.current = true + force = true + schedule() + } + } + + document.addEventListener('visibilitychange', onActivity) + window.addEventListener('blur', onActivity) + window.addEventListener('focus', onActivity) + + schedule() + + return () => { + cancelAnimationFrame(raf) + document.removeEventListener('visibilitychange', onActivity) + window.removeEventListener('blur', onActivity) + window.removeEventListener('focus', onActivity) + + invalidateRef.current = () => {} + } + }, []) + + // Size the backing canvas (DPR-aware). + useEffect(() => { + sizeRef.current = size + dprRef.current = Math.min(2, window.devicePixelRatio || 1) + const canvas = canvasRef.current + + if (canvas && size.w > 0 && size.h > 0) { + canvas.width = Math.round(size.w * dprRef.current) + canvas.height = Math.round(size.h * dprRef.current) + canvas.style.width = `${size.w}px` + canvas.style.height = `${size.h}px` + } + + invalidate() + }, [invalidate, size]) + + // ── Pointer interactions (invert the tilted projection for hit-testing) ───── + const pickNode = (cssX: number, cssY: number): null | SimNode => { + const vp = viewportRef.current + const wx = (cssX - vp.x) / vp.k + const wy = (cssY - vp.y) / (vp.k * TILT) + let best: null | SimNode = null + let bestD = Infinity + + for (const n of nodesRef.current) { + const r = nodeRadius(n) + 6 + const d = (n.x - wx) ** 2 + (n.y - wy) ** 2 + + if (d < r * r && d < bestD) { + bestD = d + best = n + } + } + + return best + } + + // Nearest link within ~5px of the cursor (screen space), or null. + const pickLink = (cssX: number, cssY: number): null | string => { + const vp = viewportRef.current + let best: null | string = null + let bestD = 25 + + for (const link of linksRef.current) { + const s = typeof link.source === 'object' ? link.source : byIdRef.current.get(String(link.source)) + const t = typeof link.target === 'object' ? link.target : byIdRef.current.get(String(link.target)) + + if (!s || !t) { + continue + } + + const d = distToSegmentSq( + cssX, + cssY, + s.x * vp.k + vp.x, + s.y * vp.k * TILT + vp.y, + t.x * vp.k + vp.x, + t.y * vp.k * TILT + vp.y + ) + + if (d < bestD) { + bestD = d + best = `${s.id}->${t.id}` + } + } + + return best + } + + const pickRingLabel = (cssX: number, cssY: number): null | number => { + for (const r of ringLabelRectsRef.current) { + if (cssX >= r.x && cssX <= r.x + r.w && cssY >= r.y && cssY <= r.y + r.h) { + return r.i + } + } + + return null + } + + const localXY = (e: React.MouseEvent): { x: number; y: number } => { + const rect = canvasRef.current?.getBoundingClientRect() + + return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) } + } + + const resetView = () => { + setPlaying(false) + viewportRef.current = fitViewport(sizeRef.current.w, sizeRef.current.h, ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER) + selectedRingRef.current = null + invalidate() + setSelectedId(null) + } + + const onMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0) { + return + } + + const { x, y } = localXY(e) + const ringHit = pickRingLabel(x, y) + hoveredRingRef.current = null + // Nodes aren't draggable (static map) — remember which was pressed so a click + // (press without movement) can select it; any drag just pans. + const nodeId = ringHit == null ? (pickNode(x, y)?.id ?? null) : null + dragRef.current = { id: nodeId, mode: 'pan', moved: false, ring: ringHit, sx: e.clientX, sy: e.clientY, vp: viewportRef.current } + } + + const onMouseMove = (e: React.MouseEvent) => { + const drag = dragRef.current + + if (drag.mode === 'none') { + const { x, y } = localXY(e) + const ringHit = pickRingLabel(x, y) + const id = ringHit == null ? (pickNode(x, y)?.id ?? null) : null + // Links are the last fallback (only when not over a node/date). + const linkKey = ringHit == null && id == null ? pickLink(x, y) : null + + if (id !== hoverRef.current || ringHit !== hoveredRingRef.current || linkKey !== hoveredLinkRef.current) { + hoverRef.current = id + hoveredRingRef.current = ringHit + hoveredLinkRef.current = linkKey + invalidate() + } + + return + } + + const dx = e.clientX - drag.sx + const dy = e.clientY - drag.sy + + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + drag.moved = true + } + + if (drag.mode === 'pan') { + // Taking manual control of the camera ends an auto-fit play-through. + if (drag.moved) { + setPlaying(false) + } + + viewportRef.current = { ...drag.vp, x: drag.vp.x + dx, y: drag.vp.y + dy } + invalidate() + } + } + + const endDrag = () => { + const drag = dragRef.current + + // A click (press without movement) toggles a ring date, a node, or clears. + if (drag.mode === 'pan' && !drag.moved) { + // Double tap (trackpad tap-to-click may never emit a dblclick) resets view. + if (doubleTapRef.current()) { + resetView() + dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current } + + return + } + + // Independent toggles: a date and a node can both be selected. + if (drag.ring != null) { + selectedRingRef.current = selectedRingRef.current === drag.ring ? null : drag.ring + } else if (drag.id) { + setSelectedId(prev => (prev === drag.id ? null : drag.id)) + } else { + selectedRingRef.current = null + setSelectedId(null) + } + + invalidate() + } + + dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current } + } + + const onMouseLeave = () => { + hoverRef.current = null + hoveredRingRef.current = null + hoveredLinkRef.current = null + invalidate() + endDrag() + } + + const onWheel = (e: React.WheelEvent) => { + const rect = canvasRef.current?.getBoundingClientRect() + + if (!rect) { + return + } + + // macOS smart zoom (two-finger double-tap) → reset (see lib/trackpad-gestures). + if (isSmartZoomWheel(e)) { + resetView() + + return + } + + // Manual zoom takes over the camera from any auto-fit play-through. + setPlaying(false) + + const px = e.clientX - rect.left + const py = e.clientY - rect.top + const vp = viewportRef.current + const k = clamp(vp.k * (e.deltaY > 0 ? 0.9 : 1.1), ZOOM_MIN, ZOOM_MAX) + viewportRef.current = { k, x: px - ((px - vp.x) / vp.k) * k, y: py - ((py - vp.y) / vp.k) * k } + invalidate() + } + + return ( +
+ + + {/* Timeline scrubber — centered along the top, clear of the close button. + z-20 lifts it above the titlebar's app-region drag layer (z-10) so the + scrubber receives pointer events instead of dragging the window. */} +
+ +
+ + {/* Share / import (WoW-talent-style code) — bottom-right, mirroring the legend. */} +
+ +
+ + {/* Legend — bottom-left, one entry per line like a conventional key. */} +
+ + skill + + + memory + + core = oldest · outer = newer + +
+
+ ) +} diff --git a/apps/desktop/src/app/starmap/text.ts b/apps/desktop/src/app/starmap/text.ts new file mode 100644 index 00000000000..7b99f0599d5 --- /dev/null +++ b/apps/desktop/src/app/starmap/text.ts @@ -0,0 +1,89 @@ +import type { StarmapNode } from '@/types/hermes' + +export function formatDate(ts?: null | number): string { + if (!ts) { + return 'unknown' + } + + try { + return new Date(ts * 1000).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) + } catch { + return 'unknown' + } +} + +// Tag-style badge items for the hover tooltip — date first. Use-count is NOT a +// badge (rendered separately, right-aligned) so it's excluded here. +export function metaBadges(n: StarmapNode): string[] { + const out: string[] = [formatDate(n.timestamp)] + + if (n.kind === 'memory') { + out.push(n.memorySource === 'profile' ? 'profile memory' : 'memory') + } else { + out.push(n.category) + + if (n.createdBy === 'agent') { + out.push('learned') + } + + if (n.pinned) { + out.push('pinned') + } + } + + return out.filter(Boolean) +} + +// Bare "xN" use-count, last in the badge row. Null when never used. +export function countLabel(n: StarmapNode): null | string { + return n.kind === 'skill' && n.useCount > 0 ? `x${n.useCount}` : null +} + +// Footer-row content for the tooltip. Reserved primitive — returns nothing for +// now (skills have no UUID; their id is just the name). Wire real detail here +// later and the tooltip lays it out automatically. +export function nodeFooter(node: StarmapNode): null | string { + void node + + return null +} + +// Greedy word-wrap for the tooltip title so long memory lines don't blow out. +export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] { + const words = text.split(/\s+/).filter(Boolean) + const lines: string[] = [] + let line = '' + + for (const word of words) { + const next = line ? `${line} ${word}` : word + + if (!line || ctx.measureText(next).width <= maxW) { + line = next + } else { + lines.push(line) + line = word + } + } + + if (line) { + lines.push(line) + } + + return lines +} + +// Trim to fit maxW, appending an ellipsis (keeps floating labels compact so they +// don't span the overlay). +export function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string { + if (ctx.measureText(text).width <= maxW) { + return text + } + + let s = text + + while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) { + s = s.slice(0, -1) + } + + return `${s.trimEnd()}…` +} diff --git a/apps/desktop/src/app/starmap/time-axis.ts b/apps/desktop/src/app/starmap/time-axis.ts new file mode 100644 index 00000000000..88ad99209cf --- /dev/null +++ b/apps/desktop/src/app/starmap/time-axis.ts @@ -0,0 +1,105 @@ +import type { StarmapGraph, StarmapNode } from '@/types/hermes' + +import { clamp } from './geometry' + +// Empty lead-in: push the oldest node off 0 so the timeline opens on a beat of +// emptiness (you watch the first node grow in). Radial position is otherwise a +// truthful linear map of time, so rings line up with the nodes they date. +export const LEAD_IN = 0.06 +export const recForRatio = (ratio: number): number => LEAD_IN + (1 - LEAD_IN) * clamp(ratio, 0, 1) + +export interface Recency { + maxTs: null | number + minTs: null | number + // id → recency ratio (0 oldest … 1 newest). Timed by timestamp when the span + // is real, else ordinal so an undated graph still "builds up" in a stable order. + rec: Map + timed: boolean +} + +// Shared recency model for both the radial layout (simulation.ts) and the +// timeline scrubber, so a node's ring distance and its ignite time agree. +export function computeRecency(nodes: StarmapNode[]): Recency { + const known = nodes + .map(n => (typeof n.timestamp === 'number' && Number.isFinite(n.timestamp) ? Number(n.timestamp) : null)) + .filter((v): v is number => v !== null) + + const minTs = known.length ? Math.min(...known) : null + const maxTs = known.length ? Math.max(...known) : null + const timed = minTs !== null && maxTs !== null && maxTs > minTs + + const ordered = [...nodes].sort((a, b) => { + const at = typeof a.timestamp === 'number' ? a.timestamp : Infinity + const bt = typeof b.timestamp === 'number' ? b.timestamp : Infinity + + return at === bt ? a.id.localeCompare(b.id) : at - bt + }) + + const ordRatio = new Map(ordered.map((n, i) => [n.id, ordered.length > 1 ? i / (ordered.length - 1) : 0])) + const rec = new Map() + + // Radius is a truthful linear map of time (ordinal only as a fallback for the + // undated). Co-timed nodes share a radius and fan out by ANGLE in the sim — so + // a burst reads as a populated ring, and the dated rings stay accurate. + for (const n of nodes) { + const ratio = + timed && typeof n.timestamp === 'number' && minTs !== null && maxTs !== null + ? (Number(n.timestamp) - minTs) / (maxTs - minTs) + : (ordRatio.get(n.id) ?? 0) + + rec.set(n.id, recForRatio(ratio)) + } + + return { maxTs, minTs, rec, timed } +} + +export interface TimeBucket { + memory: number + skill: number + total: number +} + +export interface TimeAxis { + buckets: TimeBucket[] + maxTotal: number + maxTs: null | number + minTs: null | number + // Total node count — the denominator for the "n / total" label when undated. + size: number + timed: boolean +} + +// Bucket nodes across recency [0,1] into a fixed-width histogram — the little +// bars the scrubber rides over. Skill/memory kept separate so the bars can show +// the same two-tone split as the map glyphs. +export function buildTimeAxis(graph: StarmapGraph, bucketCount = 48): TimeAxis { + const { maxTs, minTs, rec, timed } = computeRecency(graph.nodes) + const n = Math.max(1, bucketCount) + const buckets: TimeBucket[] = Array.from({ length: n }, () => ({ memory: 0, skill: 0, total: 0 })) + + for (const node of graph.nodes) { + const r = rec.get(node.id) ?? 0 + const idx = clamp(Math.floor(r * n), 0, n - 1) + const b = buckets[idx]! + b.total += 1 + + if (node.kind === 'memory') { + b.memory += 1 + } else { + b.skill += 1 + } + } + + const maxTotal = buckets.reduce((m, b) => Math.max(m, b.total), 0) + + return { buckets, maxTotal, maxTs, minTs, size: graph.nodes.length, timed } +} + +// Wall-clock date at a reveal ratio (linear in time when the graph is dated). +export function dateAtReveal(axis: TimeAxis, reveal: number): null | number { + if (!axis.timed || axis.minTs === null || axis.maxTs === null) { + return null + } + + return Math.round(axis.minTs + clamp(reveal, 0, 1) * (axis.maxTs - axis.minTs)) +} diff --git a/apps/desktop/src/app/starmap/timeline.tsx b/apps/desktop/src/app/starmap/timeline.tsx new file mode 100644 index 00000000000..aaa7f40b9e7 --- /dev/null +++ b/apps/desktop/src/app/starmap/timeline.tsx @@ -0,0 +1,281 @@ +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' + +import { Codicon } from '@/components/ui/codicon' + +import type { TimeAxis } from './time-axis' + +interface TimelineProps { + axis: TimeAxis + // Colour for memory stars — matches the map's memory glyph. + memoryColor?: string + onScrub: (reveal: number) => void + onTogglePlay: () => void + playing: boolean + revealStore: RevealSignal + // Reveal positions (0–1) where rings spawn — drawn as anchor ticks. + ringStops?: number[] +} + +interface RevealSignal { + get: () => number + subscribe: (listener: (value: number) => void) => () => void +} + +interface Star { + delay: number + duration: number + kind: 'memory' | 'skill' + leftPct: number + opacity: number + size: number + topPct: number +} + +const ACTIVE_MARKER_CLASS = 'opacity-100' +const INACTIVE_MARKER_CLASS = 'opacity-30' +// Busiest bucket gets this many stars; quieter ones scale down proportionally. +const MAX_STARS_PER_BUCKET = 7 + +// Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders. +function rng(seed: number): () => number { + let a = seed >>> 0 + + return () => { + a += 0x6d2b79f5 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Scatter each time bucket's activity into stars: count ∝ events, split between +// skill- and memory-coloured stars, jittered within the bucket's horizontal slot +// and across the track height. A starmap timeline for a starmap. +function buildStars(axis: TimeAxis): Star[] { + const n = Math.max(1, axis.buckets.length) + const stars: Star[] = [] + + axis.buckets.forEach((b, i) => { + if (b.total === 0) { + return + } + + const intensity = axis.maxTotal > 0 ? b.total / axis.maxTotal : 0 + const count = Math.max(1, Math.round(intensity * MAX_STARS_PER_BUCKET)) + const skillCount = Math.round((b.skill / b.total) * count) + const r = rng(i * 9973 + 7) + const slot = 1 / n + + for (let s = 0; s < count; s++) { + const jitter = (r() - 0.5) * slot * 0.9 + const center = (i + 0.5) / n + + stars.push({ + delay: r() * 3, + duration: 2.4 + r() * 2.6, + kind: s < skillCount ? 'skill' : 'memory', + leftPct: Math.max(0, Math.min(1, center + jitter)) * 100, + // Brighter, slightly larger stars are rarer. + opacity: 0.5 + r() * 0.5, + size: 1 + Math.round(r() * r() * 2.2), + topPct: 12 + r() * 76 + }) + } + }) + + return stars +} + +// Playback scrubber as a constellation: dim stars are the unrevealed future; a +// scanner sweep ignites them (bright + twinkling) left→right as the reveal +// advances. The bright layer is clipped by the reveal CSS var, so the rAF sweep +// in StarMap drives it with zero per-frame JS. +export const Timeline = memo(function Timeline({ + axis, + memoryColor = 'var(--theme-secondary)', + onScrub, + onTogglePlay, + playing, + revealStore, + ringStops = [] +}: TimelineProps) { + const trackRef = useRef(null) + const draggingRef = useRef(false) + const markerRefs = useRef([]) + + const stars = useMemo(() => buildStars(axis), [axis]) + + const syncReveal = useCallback( + (value: number) => { + const reveal = Math.max(0, Math.min(1, value)) + const track = trackRef.current + + if (track) { + track.style.setProperty('--starmap-reveal', String(reveal)) + track.setAttribute('aria-valuenow', String(Math.round(reveal * 100))) + } + + ringStops.forEach((stop, i) => { + const el = markerRefs.current[i] + + if (!el) { + return + } + + const active = stop <= reveal + + el.classList.toggle(ACTIVE_MARKER_CLASS, active) + el.classList.toggle(INACTIVE_MARKER_CLASS, !active) + }) + }, + [ringStops] + ) + + useEffect(() => revealStore.subscribe(syncReveal), [revealStore, syncReveal]) + + useEffect(() => { + markerRefs.current.length = ringStops.length + syncReveal(revealStore.get()) + }, [revealStore, ringStops.length, syncReveal]) + + const ratioAt = (clientX: number): number => { + const rect = trackRef.current?.getBoundingClientRect() + + if (!rect || rect.width === 0) { + return revealStore.get() + } + + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + } + + const onPointerDown = (e: React.PointerEvent) => { + draggingRef.current = true + e.currentTarget.setPointerCapture(e.pointerId) + onScrub(ratioAt(e.clientX)) + } + + const onPointerMove = (e: React.PointerEvent) => { + if (draggingRef.current) { + onScrub(ratioAt(e.clientX)) + } + } + + const onPointerUp = (e: React.PointerEvent) => { + draggingRef.current = false + + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) + } + } + + const colorFor = (kind: Star['kind']) => (kind === 'skill' ? 'var(--theme-primary)' : memoryColor) + + return ( +
+ + + + +
+ {/* Dashed midline — a faint horizontal axis the stars ride over. */} +
+ + {/* Dim constellation — the unrevealed future. */} +
+ {stars.map((star, i) => ( +
+ ))} +
+ + {/* Ignited constellation — bright + twinkling, clipped to the reveal. */} +
+ {stars.map((star, i) => { + const color = colorFor(star.kind) + + return ( +
+ ) + })} +
+ + {/* Ring-spawn anchor ticks — small bright stars that light up on pass. */} + {ringStops.map((stop, i) => ( +
{ + if (el) { + markerRefs.current[i] = el + } + }} + style={{ left: `${stop * 100}%` }} + /> + ))} + + {/* Playhead — a thin white sweep line. */} +
+
+
+ ) +}) diff --git a/apps/desktop/src/app/starmap/types.ts b/apps/desktop/src/app/starmap/types.ts new file mode 100644 index 00000000000..ab74e5396a4 --- /dev/null +++ b/apps/desktop/src/app/starmap/types.ts @@ -0,0 +1,97 @@ +import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force' + +import type { StarmapGraph, StarmapNode } from '@/types/hermes' + +export type MemoryCard = StarmapGraph['memory'][number] + +export type Shape = 'circle' | 'diamond' | 'hexagon' | 'square' | 'triangle' + +export interface Viewport { + k: number + x: number + y: number +} + +export interface Rgb { + b: number + g: number + r: number +} + +export interface Rect { + h: number + w: number + x: number + y: number +} + +export interface SimNode extends StarmapNode, SimulationNodeDatum { + outerRingIndex: number // first ring that caps this node's recency band + rec: number // recency 0 (oldest) → 1 (newest) + tr: number // time-anchored target radius + x: number + y: number +} + +export interface SimLink extends SimulationLinkDatum { + source: SimNode | string + target: SimNode | string +} + +// Per-mode line/ring style. +export interface GraphParams { + lineAlpha: number + lineDash: number + lineDashed: boolean + lineWidth: number + ringAlpha: number + ringDash: number + ringDashed: boolean + ringWidth: number +} + +// Per-mode ring/orb params (band wash, light-sliver size, ring outline alpha, orb sheen). +export interface RingParams { + bandAlpha: number + lightSize: number + ringAlpha: number + sheen: number +} + +export interface Palette { + bandInk: Rgb + base: Rgb + bg: Rgb + c: GraphParams + chipBg: string + darkTheme: boolean + inkInv: string + memoryInk: Rgb + primary: Rgb + skillInk: Rgb +} + +export interface Ring { + label: null | string + r: number + ratio: number +} + +export interface RingLabelRect { + h: number + i: number + w: number + x: number + y: number +} + +export interface FadeBuckets { + // Per-element "birth" progress 0→1 used to ease position (nodes rise outward + // into place, rings grow out) as the scrubber reveals them. Separate from the + // alpha buckets so it stays monotonic and isn't perturbed by focus/selection. + appear: Map + labels: Map + links: Map + nodes: Map + rings: Map +} diff --git a/apps/desktop/src/components/ui/use-zoom-pan.ts b/apps/desktop/src/components/ui/use-zoom-pan.ts index 86759297d64..db95c5bc71e 100644 --- a/apps/desktop/src/components/ui/use-zoom-pan.ts +++ b/apps/desktop/src/components/ui/use-zoom-pan.ts @@ -7,6 +7,8 @@ import { useState } from 'react' +import { isSmartZoomWheel } from '@/lib/trackpad-gestures' + interface Transform { scale: number x: number @@ -43,6 +45,14 @@ export function useZoomPan() { const onWheel = useCallback( (event: ReactWheelEvent) => { event.preventDefault() + + // macOS smart zoom (two-finger double-tap) → reset, not zoom-in. + if (isSmartZoomWheel(event)) { + setTransform({ scale: 1, x: 0, y: 0 }) + + return + } + const rect = event.currentTarget.getBoundingClientRect() const cx = event.clientX - rect.left - rect.width / 2 const cy = event.clientY - rect.top - rect.height / 2 diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 8e0656a9e7a..8a304e3a468 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -41,6 +41,7 @@ import type { SessionMessagesResponse, SessionSearchResponse, SkillInfo, + StarmapGraph, StatusResponse, ToolsetConfig, ToolsetInfo @@ -113,6 +114,7 @@ export type { SessionSearchResult, SkillInfo, StaleAuxAssignment, + StarmapGraph, StatusResponse, ToolsetConfig, ToolsetInfo @@ -489,6 +491,15 @@ export function getSkills(): Promise { }) } +export function getStarmapGraph(): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + // Backend REST contract — stays /api/learning even though the UI feature is + // now "star map". Renaming this would break against an un-upgraded backend. + path: '/api/learning/graph' + }) +} + export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> { return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ ...profileScoped(), diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4f0610a3177..00b801065fa 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -181,6 +181,7 @@ export const en: Translations = { muteHaptics: 'Mute haptics', unmuteHaptics: 'Unmute haptics', openSettings: 'Open settings', + openStarmap: 'Open memory graph', openKeybinds: 'Keyboard shortcuts' }, @@ -752,6 +753,32 @@ export const en: Translations = { failedToUpdate: name => `Failed to update ${name}` }, + starmap: { + title: 'Memory Graph', + subtitle: (nodes, clusters) => `${nodes} skills across ${clusters} categories`, + close: 'Close memory graph', + refresh: 'Refresh', + memory: 'Memory', + filterAll: 'All', + filterUsed: 'Used', + filterLearned: 'Learned', + viewGraph: 'Graph', + loadFailed: 'Could not load memory graph', + loading: 'Loading…', + emptyTitle: 'Nothing learned yet', + emptyDesc: 'As Hermes builds skills and memories for your work, they appear here.', + share: 'Share map', + shareTitle: 'Import / export map', + sharePlaceholder: 'Paste a map code…', + copy: 'Copy map code', + copied: 'Copied!', + importMap: 'Import a map', + importBtn: 'Load', + importEmpty: 'Paste a map code to load it.', + importSuccess: nodes => `Loaded a map with ${nodes} ${nodes === 1 ? 'node' : 'nodes'}.`, + importedBadge: 'imported map', + resetToMine: 'Back to my map' + }, agents: { close: 'Close agents', title: 'Spawn tree', @@ -1845,6 +1872,8 @@ export const en: Translations = { running: count => `${count} running`, cron: 'Cron', openCron: 'Open cron jobs', + starmap: 'Memory Graph', + openStarmap: 'Open memory graph', turnRunning: 'Running', currentTurnElapsed: 'Current turn elapsed', contextUsage: 'Context usage', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 36c2a83adac..2e54f9d787e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -181,7 +181,8 @@ export const ja = defineLocale({ showRightSidebar: '右サイドバーを表示', muteHaptics: '触覚フィードバックをオフ', unmuteHaptics: '触覚フィードバックをオン', - openSettings: '設定を開く' + openSettings: '設定を開く', + openStarmap: 'メモリグラフを開く' }, language: { @@ -864,6 +865,21 @@ export const ja = defineLocale({ failedToUpdate: name => `${name} の更新に失敗しました` }, + starmap: { + title: 'メモリグラフ', + subtitle: (nodes, clusters) => `${clusters} カテゴリの ${nodes} スキル`, + close: 'メモリグラフを閉じる', + refresh: '更新', + memory: 'メモリ', + filterAll: 'すべて', + filterUsed: '使用済み', + filterLearned: '学習済み', + viewGraph: 'グラフ', + loadFailed: 'メモリグラフを読み込めませんでした', + loading: '読み込み中…', + emptyTitle: 'まだ学習はありません', + emptyDesc: 'Hermes がスキルやメモリを蓄積すると、ここに表示されます。' + }, agents: { close: 'エージェントを閉じる', title: 'スポーンツリー', @@ -1965,6 +1981,8 @@ export const ja = defineLocale({ running: count => `${count} 実行中`, cron: 'Cron', openCron: 'Cron ジョブを開く', + starmap: 'メモリグラフ', + openStarmap: 'メモリグラフを開く', turnRunning: '実行中', currentTurnElapsed: '現在のターン経過時間', contextUsage: 'コンテキスト使用状況', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index adbe452a7c9..96a85ab7e5b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -223,6 +223,7 @@ export interface Translations { muteHaptics: string unmuteHaptics: string openSettings: string + openStarmap: string openKeybinds: string } @@ -649,6 +650,32 @@ export interface Translations { failedToUpdate: (name: string) => string } + starmap: { + title: string + subtitle: (nodes: number, clusters: number) => string + close: string + refresh: string + memory: string + filterAll: string + filterUsed: string + filterLearned: string + viewGraph: string + loadFailed: string + loading: string + emptyTitle: string + emptyDesc: string + share: string + shareTitle: string + sharePlaceholder: string + copy: string + copied: string + importMap: string + importBtn: string + importEmpty: string + importSuccess: (nodes: number) => string + importedBadge: string + resetToMine: string + } agents: { close: string title: string @@ -1498,6 +1525,8 @@ export interface Translations { running: (count: number) => string cron: string openCron: string + starmap: string + openStarmap: string turnRunning: string currentTurnElapsed: string contextUsage: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 399fd22e93a..6c4e37c6f18 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -175,7 +175,8 @@ export const zhHant = defineLocale({ showRightSidebar: '顯示右側邊欄', muteHaptics: '靜音觸感回饋', unmuteHaptics: '開啟觸感回饋', - openSettings: '開啟設定' + openSettings: '開啟設定', + openStarmap: '開啟記憶圖譜' }, language: { @@ -836,6 +837,21 @@ export const zhHant = defineLocale({ failedToUpdate: name => `更新 ${name} 失敗` }, + starmap: { + title: '記憶圖譜', + subtitle: (nodes, clusters) => `${clusters} 個類別中的 ${nodes} 個技能`, + close: '關閉記憶圖譜', + refresh: '重新整理', + memory: '記憶', + filterAll: '全部', + filterUsed: '已使用', + filterLearned: '已學習', + viewGraph: '圖譜', + loadFailed: '無法載入記憶圖譜', + loading: '載入中…', + emptyTitle: '尚無學習內容', + emptyDesc: '當 Hermes 為你的工作建立技能與記憶時,會顯示在這裡。' + }, agents: { close: '關閉代理', title: '派生樹', @@ -1904,6 +1920,8 @@ export const zhHant = defineLocale({ running: count => `${count} 個執行中`, cron: '排程', openCron: '開啟排程工作', + starmap: '記憶圖譜', + openStarmap: '開啟記憶圖譜', turnRunning: '執行中', currentTurnElapsed: '目前回合已用時間', contextUsage: '上下文使用量', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index b41b7edaacf..1f8bda52d10 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -176,6 +176,7 @@ export const zh: Translations = { muteHaptics: '关闭触感反馈', unmuteHaptics: '开启触感反馈', openSettings: '打开设置', + openStarmap: '打开记忆图谱', openKeybinds: '键盘快捷键' }, @@ -936,6 +937,32 @@ export const zh: Translations = { failedToUpdate: name => `更新 ${name} 失败` }, + starmap: { + title: '记忆图谱', + subtitle: (nodes, clusters) => `${clusters} 个类别中的 ${nodes} 个技能`, + close: '关闭记忆图谱', + refresh: '刷新', + memory: '记忆', + filterAll: '全部', + filterUsed: '已使用', + filterLearned: '已学习', + viewGraph: '图谱', + loadFailed: '无法加载记忆图谱', + loading: '加载中…', + emptyTitle: '尚无学习内容', + emptyDesc: '当 Hermes 为你的工作构建技能和记忆时,会显示在这里。', + share: '分享图谱', + shareTitle: '导入 / 导出图谱', + sharePlaceholder: '粘贴图谱代码…', + copy: '复制图谱代码', + copied: '已复制!', + importMap: '导入图谱', + importBtn: '加载', + importEmpty: '粘贴图谱代码以加载。', + importSuccess: nodes => `已加载包含 ${nodes} 个节点的图谱。`, + importedBadge: '导入的图谱', + resetToMine: '返回我的图谱' + }, agents: { close: '关闭代理', title: '派生树', @@ -2016,6 +2043,8 @@ export const zh: Translations = { running: count => `${count} 个运行中`, cron: '排程', openCron: '打开排程任务', + starmap: '记忆图谱', + openStarmap: '打开记忆图谱', turnRunning: '运行中', currentTurnElapsed: '当前回合已用时间', contextUsage: '上下文用量', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index f7a825fda15..f5b839816cb 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -91,6 +91,7 @@ import { IconSettings2 as Settings2, IconAdjustmentsHorizontal as SlidersHorizontal, IconSquare as Square, + IconChartDots3 as Starmap, IconSteeringWheel as SteeringWheel, IconPlayerStopFilled as StopFilled, IconSun as Sun, @@ -204,6 +205,7 @@ export { Settings2, SlidersHorizontal, Square, + Starmap, SteeringWheel, StopFilled, Sun, diff --git a/apps/desktop/src/lib/loadout.ts b/apps/desktop/src/lib/loadout.ts new file mode 100644 index 00000000000..6991a106bac --- /dev/null +++ b/apps/desktop/src/lib/loadout.ts @@ -0,0 +1,277 @@ +import { deflateSync, inflateSync } from 'fflate' + +// ── Loadout codec ───────────────────────────────────────────────────────────── +// +// A generic, WoW-talent-loadout-style binary share codec: pack *bits and +// indices* (not JSON), DEFLATE the body, frame it with a version + checksum, and +// emit a short, opaque, clipboard-safe base64url string under a namespacing +// prefix. Domain code supplies only the body schema (`write`/`read` over the +// BitWriter/BitReader); everything else — compression, integrity, framing, +// whitespace tolerance, typed errors — lives here so a new shareable thing +// (e.g. an enabled-skills set) is just a new `createLoadout({ … })`. + +// ── Little-endian bit writer (WoW's WriteBits, low bit first) ──────────────── +export class BitWriter { + private bits: number[] = [] + + bit(v: 0 | 1 | boolean): void { + this.bits.push(v ? 1 : 0) + } + + uint(value: number, width: number): void { + let v = value >>> 0 + + for (let i = 0; i < width; i += 1) { + this.bits.push(v & 1) + v >>>= 1 + } + } + + // LEB128-style varint: 7 payload bits per group, high "continue" bit set while + // more groups follow. + varint(value: number): void { + let v = Math.max(0, Math.floor(value)) + + do { + const group = v & 0x7f + v = Math.floor(v / 128) + this.bit(v > 0 ? 1 : 0) + this.uint(group, 7) + } while (v > 0) + } + + str(s: string): void { + const bytes = new TextEncoder().encode(s) + this.varint(bytes.length) + + for (const b of bytes) { + this.uint(b, 8) + } + } + + bytes(): Uint8Array { + const out = new Uint8Array(Math.ceil(this.bits.length / 8)) + + for (let i = 0; i < this.bits.length; i += 1) { + if (this.bits[i]) { + out[i >> 3]! |= 1 << (i & 7) + } + } + + return out + } +} + +export class BitReader { + private pos = 0 + + constructor(private readonly buf: Uint8Array) {} + + bit(): number { + if (this.pos >= this.buf.length * 8) { + throw new RangeError('loadout truncated') + } + + const i = this.pos++ + + return (this.buf[i >> 3]! >> (i & 7)) & 1 + } + + uint(width: number): number { + let v = 0 + + for (let i = 0; i < width; i += 1) { + v |= this.bit() << i + } + + return v >>> 0 + } + + varint(): number { + let v = 0 + let shift = 0 + + for (;;) { + const cont = this.bit() + v += this.uint(7) * 2 ** shift + shift += 7 + + if (!cont) { + return v + } + } + } + + str(): string { + const len = this.varint() + const bytes = new Uint8Array(len) + + for (let i = 0; i < len; i += 1) { + bytes[i] = this.uint(8) + } + + return new TextDecoder().decode(bytes) + } +} + +// Interns repeated strings (labels, categories, …) so each record spends one +// varint id instead of the full string; DEFLATE then squeezes the dictionary. +export class Dict { + private readonly index = new Map() + readonly list: string[] = [] + + id(s: string): number { + const hit = this.index.get(s) + + if (hit !== undefined) { + return hit + } + + const id = this.list.length + this.index.set(s, id) + this.list.push(s) + + return id + } +} + +// Index of `value` in a fixed enum table, clamped to 0 so an unknown value +// decodes to the table's first (default) member instead of throwing. +export const idxOf = (table: T, value: string): number => { + const i = table.indexOf(value as T[number]) + + return i < 0 ? 0 : i +} + +// Bits needed to address `n` items positionally (fixed-width back-references). +export const indexBits = (n: number): number => (n <= 1 ? 1 : Math.ceil(Math.log2(n))) + +// ── base64url over the raw bytes (URL- and clipboard-safe, no padding) ──────── +function toBase64Url(buf: Uint8Array): string { + let bin = '' + + for (const b of buf) { + bin += String.fromCharCode(b) + } + + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function fromBase64Url(s: string): Uint8Array { + const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)) + const out = new Uint8Array(bin.length) + + for (let i = 0; i < bin.length; i += 1) { + out[i] = bin.charCodeAt(i) + } + + return out +} + +// FNV-1a over the body bytes, low 16 bits — a tamper/corruption gate, not crypto. +function checksum16(buf: Uint8Array): number { + let h = 0x811c9dc5 + + for (const b of buf) { + h ^= b + h = Math.imul(h, 0x01000193) + } + + return (h >>> 0) & 0xffff +} + +export class LoadoutError extends Error {} + +export interface Loadout { + decode(code: string): T + encode(value: T): string +} + +export interface LoadoutSpec { + /** Namespacing prefix (like WoW's leading bytes), e.g. 'HML'. */ + prefix: string + /** Bumped whenever the body schema changes incompatibly. */ + version: number + /** Write the domain body; framing/compression/checksum are added around it. */ + write: (w: BitWriter, value: T) => void + /** Read the domain body back. May throw — it's wrapped as a LoadoutError. */ + read: (r: BitReader) => T + /** Noun for user-facing error messages, e.g. 'map code'. Default: 'code'. */ + noun?: string + /** Error subclass to throw, so callers can `instanceof` their own type. */ + error?: new (message: string) => LoadoutError +} + +const HEAD_BYTES = 3 // 8-bit version + 16-bit checksum + +// Build an encode/decode pair for a domain value. The body schema is the only +// thing a caller writes; everything else (deflate, version+checksum frame, +// base64url, whitespace tolerance, typed errors) is shared. +export function createLoadout(spec: LoadoutSpec): Loadout { + const Err = spec.error ?? LoadoutError + const noun = spec.noun ?? 'code' + const Noun = noun.charAt(0).toUpperCase() + noun.slice(1) + + const encode = (value: T): string => { + const body = new BitWriter() + spec.write(body, value) + const payload = deflateSync(body.bytes(), { level: 9 }) + + const head = new BitWriter() + head.uint(spec.version, 8) + head.uint(checksum16(payload), 16) + const headBytes = head.bytes() + + const framed = new Uint8Array(headBytes.length + payload.length) + framed.set(headBytes, 0) + framed.set(payload, headBytes.length) + + return spec.prefix + toBase64Url(framed) + } + + const decode = (code: string): T => { + // Strip ALL whitespace, not just the ends — a pasted code often picks up soft + // wraps / newlines, and base64 decoding chokes on any of it. + const cleaned = code.replace(/\s+/g, '') + const raw = cleaned.startsWith(spec.prefix) ? cleaned.slice(spec.prefix.length) : cleaned + + if (!raw) { + throw new Err(`That doesn't look like a ${noun}.`) + } + + let framed: Uint8Array + + try { + framed = fromBase64Url(raw) + } catch { + throw new Err(`That doesn't look like a ${noun}.`) + } + + if (framed.length <= HEAD_BYTES) { + throw new Err(`${Noun} is too short to be valid.`) + } + + const head = new BitReader(framed.subarray(0, HEAD_BYTES)) + const version = head.uint(8) + const storedSum = head.uint(16) + + if (version !== spec.version) { + throw new Err(`${Noun} is version ${version}; this build reads version ${spec.version}.`) + } + + const payload = framed.subarray(HEAD_BYTES) + + if (checksum16(payload) !== storedSum) { + throw new Err(`${Noun} looks corrupted (checksum mismatch).`) + } + + try { + return spec.read(new BitReader(inflateSync(payload))) + } catch (err) { + throw new Err(err instanceof Error ? `${Noun} is malformed: ${err.message}` : `${Noun} is malformed.`) + } + } + + return { decode, encode } +} diff --git a/apps/desktop/src/lib/trackpad-gestures.ts b/apps/desktop/src/lib/trackpad-gestures.ts new file mode 100644 index 00000000000..c829086b3a1 --- /dev/null +++ b/apps/desktop/src/lib/trackpad-gestures.ts @@ -0,0 +1,50 @@ +// Trackpad / pointer gesture primitives shared across canvas + DOM surfaces. +// +// macOS quirk (Chromium/Electron): both pinch-zoom and "smart zoom" arrive as +// `wheel` events with `ctrlKey` synthetically set — there is no dedicated DOM +// event for either. They're disambiguated by their deltas: +// - pinch-to-zoom: ctrlKey + a non-zero delta +// - smart zoom: ctrlKey + zero deltas (the two-finger double-tap) +// Plain two-finger scroll has ctrlKey === false. Centralising this here keeps +// every zoom/pan surface from re-deriving the same OS trivia (and getting it +// wrong, which makes smart-zoom read as a zoom-in). + +export interface WheelLike { + ctrlKey: boolean + deltaX: number + deltaY: number +} + +/** macOS "smart zoom" (two-finger double-tap): a ctrl-wheel with no delta. */ +export function isSmartZoomWheel(e: WheelLike): boolean { + return e.ctrlKey && e.deltaX === 0 && e.deltaY === 0 +} + +/** Pinch-to-zoom (or ctrl + mouse wheel): a ctrl-wheel carrying a delta. */ +export function isPinchZoomWheel(e: WheelLike): boolean { + return e.ctrlKey && (e.deltaX !== 0 || e.deltaY !== 0) +} + +export const DOUBLE_TAP_MS = 300 + +/** + * Stateful double-tap detector for surfaces where a real `dblclick` may never + * fire (e.g. a trackpad with tap-to-click off). Call it once per discrete tap; + * it returns true when two taps land within `thresholdMs` of each other, then + * resets so a third tap starts a fresh pair. + */ +export function createDoubleTapDetector(thresholdMs: number = DOUBLE_TAP_MS): (now?: number) => boolean { + let last = 0 + + return (now: number = Date.now()): boolean => { + if (now - last < thresholdMs) { + last = 0 + + return true + } + + last = now + + return false + } +} diff --git a/apps/desktop/src/store/starmap.ts b/apps/desktop/src/store/starmap.ts new file mode 100644 index 00000000000..7e5544a8ed4 --- /dev/null +++ b/apps/desktop/src/store/starmap.ts @@ -0,0 +1,46 @@ +import { atom } from 'nanostores' + +import { getStarmapGraph } from '@/hermes' +import type { StarmapGraph } from '@/types/hermes' + +// On-demand cache for the star map. The graph scan touches the skills catalog + +// usage ledger + memory files, so we fetch it only when the panel opens (and on +// an explicit refresh), never on a turn boundary. +export const $starmapGraph = atom(null) +export const $starmapLoading = atom(false) +export const $starmapError = atom(null) + +let inflight: Promise | null = null + +export async function loadStarmapGraph(force = false): Promise { + if (inflight) { + return inflight + } + + if ($starmapGraph.get() && !force) { + return + } + + $starmapLoading.set(true) + $starmapError.set(null) + + inflight = (async () => { + try { + $starmapGraph.set(await getStarmapGraph()) + } catch (err) { + $starmapError.set(err instanceof Error ? err.message : String(err)) + } finally { + $starmapLoading.set(false) + inflight = null + } + })() + + return inflight +} + +/** Drop the cache so the next open refetches against the now-active profile. */ +export function resetStarmapGraph(): void { + inflight = null + $starmapGraph.set(null) + $starmapError.set(null) +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index b3dbb5a6a1a..4892bebff19 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -429,6 +429,47 @@ export interface UsageStats { total: number } +/** One graph node in the star map (learned skill or memory chunk). */ +export interface StarmapNode { + id: string + label: string + kind: 'memory' | 'skill' + memorySource?: 'memory' | 'profile' + timestamp?: null | number + category: string + useCount: number + state: string + createdBy: null | string + pinned: boolean +} + +/** A declared `related_skills` link; both endpoints are guaranteed to be nodes. */ +export interface StarmapEdge { + source: string + target: string +} + +export interface StarmapCluster { + category: string + count: number +} + +/** Freeform memory rendered as a card — never a graph node. */ +export interface StarmapMemoryCard { + source: 'memory' | 'profile' + timestamp?: null | number + title: string + body: string +} + +export interface StarmapGraph { + nodes: StarmapNode[] + edges: StarmapEdge[] + clusters: StarmapCluster[] + memory: StarmapMemoryCard[] + stats: Record +} + export interface ContextUsageCategory { color: string id: string diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 4401868eb8b..7b5e162cd28 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -2,6 +2,28 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' +import fs from 'fs' + +// `hgui` symlinks a worktree's node_modules to the main checkout. Vite realpaths +// those before enforcing server.fs.allow, so codicon/font assets resolve outside +// the worktree root and 404. Whitelist the real node_modules locations. +const real = (p: string): string | null => { + try { + return fs.realpathSync(p) + } catch { + return null + } +} + +const fsAllow = [ + ...new Set( + [ + path.resolve(__dirname, '../..'), + real(path.resolve(__dirname, 'node_modules')), + real(path.resolve(__dirname, '../../node_modules')) + ].filter((p): p is string => p !== null) + ) +] export default defineConfig({ base: './', @@ -47,7 +69,10 @@ export default defineConfig({ server: { host: '127.0.0.1', port: 5174, - strictPort: true + strictPort: true, + fs: { + allow: fsAllow + } }, preview: { host: '127.0.0.1', diff --git a/package-lock.json b/package-lock.json index a8c1ff7a5be..4911e054677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,8 +95,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "dnd-core": "^14.0.1", "dompurify": "^3.4.11", + "fflate": "^0.8.3", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.2", "ignore": "^7.0.5", @@ -132,6 +134,7 @@ "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", + "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", "@types/node": "^24.13.2", "@types/react": "^19.2.14", @@ -1783,72 +1786,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/windows-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1887,448 +1824,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -8435,15 +7930,6 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -10918,6 +10404,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -15552,36 +15044,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", From 7548910ecec412b84cb92e180f0e6017f176f0eb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 01:38:15 -0500 Subject: [PATCH 3/8] perf(desktop): cache memory-graph paint + billboard node sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sprite-atlas the orbs: render each (ink, sheen, darken) appearance once, blit it per node, instead of allocating a radial gradient every frame. - Split paint into a cached static layer + a live core scramble; the heavy scene only re-renders on real change, so an idle map costs a scramble + one drawImage rather than a full redraw. - Pause the render loop while the window is hidden/blurred; resume on focus. - Make the scramble's glyph count data-independent (constant cells to the rim, clamped size) so it's the same field on any graph; size tracks zoom. - Size nodes against the rested fit (fitScale), held stable through playback's spore-zoom — so t≈0 no longer balloons orbs into bubbles. - Wind the timeline constellation along a helix for depth. --- apps/desktop/src/app/starmap/geometry.ts | 7 +- apps/desktop/src/app/starmap/render.ts | 308 +++++++++++++++------- apps/desktop/src/app/starmap/star-map.tsx | 134 ++++++---- apps/desktop/src/app/starmap/timeline.tsx | 30 ++- 4 files changed, 318 insertions(+), 161 deletions(-) diff --git a/apps/desktop/src/app/starmap/geometry.ts b/apps/desktop/src/app/starmap/geometry.ts index e4e22eb0c42..affc94e1add 100644 --- a/apps/desktop/src/app/starmap/geometry.ts +++ b/apps/desktop/src/app/starmap/geometry.ts @@ -1,7 +1,7 @@ import type { StarmapNode } from '@/types/hermes' import { AGE_GRADIENT, FIT_PADDING, RING_INNER, RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' -import type { Shape, Viewport } from './types' +import type { Ring, Shape, Viewport } from './types' export function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)) @@ -104,6 +104,11 @@ export function radiusForRecency(rec: number, outer: number = RING_OUTER): numbe return RING_INNER + rec * (outer - RING_INNER) } +// Screen-space scale at the graph's fully-rested fit. Nodes size against THIS, +// not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how +// big they read (billboarded), while a full-map view keeps its honest density. +export const fitScale = (w: number, h: number, rings: Ring[]): number => fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k + // Squared distance from point (px,py) to segment a→b — for cheap link hit-tests. export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { const dx = bx - ax diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts index 392cf4db48c..8d1be201429 100644 --- a/apps/desktop/src/app/starmap/render.ts +++ b/apps/desktop/src/app/starmap/render.ts @@ -9,7 +9,7 @@ import { WHITE, WHITEISH_SHEEN } from './constants' -import { clamp, nodeRadius, recencyInk, shapePath } from './geometry' +import { clamp, fitScale, nodeRadius, recencyInk, shapePath } from './geometry' import { countLabel, ellipsize, metaBadges, nodeFooter, wrapText } from './text' import type { FadeBuckets, @@ -83,11 +83,66 @@ const NODE_BIRTH = { down: 0.11, up: 0.075 } const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' -// Fill the current path as a lit sphere: an offset radial gradient from a hot -// core → darkened body → translucent rim, so a flat circle reads with volume. -// `strength` is how white the core is; `bodyDarken` darkens the body (0 for -// active/hover nodes so they pop full bright). Near-white inks skip the darken -// and force a near-full sheen so the white core still reads. +// Sphere-sprite atlas: a lit orb is the same picture at every size, so we render +// each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and +// blit it (scaled) per node — instead of allocating a fresh radial gradient for +// every star on every frame. Keyed by appearance, not size; drawImage scales it. +// Reference radius the sprite is rendered at — larger than the usual billboarded +// screen-space orb, so sprites scale down in normal use and stay crisp. +const SPRITE_R = 96 + +const spriteCache = new Map() + +// Build (or fetch) the orb sprite for one appearance: an offset radial gradient +// from a hot core → darkened body → translucent rim, clipped to the disk, so a +// flat circle reads with volume. `strength` is how white the core is; `bodyDarken` +// darkens the body (0 for active/hover nodes so they pop full bright). Near-white +// inks skip the darken and force a near-full sheen so the white core still reads. +function sphereSprite(ink: Rgb, strength: number, bodyDarken: number): HTMLCanvasElement { + const key = `${ink.r},${ink.g},${ink.b}|${strength}|${bodyDarken}` + const cached = spriteCache.get(key) + + if (cached) { + return cached + } + + const R = SPRITE_R + // Margin for the gradient's rim (extends to 1.15·R) so it isn't clipped. + const pad = Math.ceil(R * 0.15) + 1 + const size = (R + pad) * 2 + const c = R + pad + const cv = document.createElement('canvas') + cv.width = size + cv.height = size + const g2 = cv.getContext('2d') + + if (!g2) { + return cv + } + + const mx = Math.max(ink.r, ink.g, ink.b) + const mn = Math.min(ink.r, ink.g, ink.b) + const sat = mx ? (mx - mn) / mx : 0 + const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat) + const eff = strength + (WHITEISH_SHEEN - strength) * whiteness + const hi = mixRgb(ink, WHITE, 0.7 * eff) + const body = darken(ink, bodyDarken * (1 - whiteness)) + const grad = g2.createRadialGradient(c - R * 0.35, c - R * 0.4, R * 0.05, c, c, R * 1.15) + grad.addColorStop(0, rgba(hi, 1)) + grad.addColorStop(0.5, rgba(body, 1)) + grad.addColorStop(1, rgba(body, 0.85)) + g2.fillStyle = grad + g2.beginPath() + g2.arc(c, c, R, 0, Math.PI * 2) + g2.fill() + spriteCache.set(key, cv) + + return cv +} + +// Paint a lit orb of radius `r` centered at (x, y) by blitting its cached sprite. +// Honors the caller's globalAlpha (drawImage multiplies it), matching the old +// gradient fill. No path needed — the sprite already carries the disk + AA rim. function sphereFill( ctx: CanvasRenderingContext2D, x: number, @@ -97,19 +152,10 @@ function sphereFill( strength: number, bodyDarken: number ): void { - const mx = Math.max(ink.r, ink.g, ink.b) - const mn = Math.min(ink.r, ink.g, ink.b) - const sat = mx ? (mx - mn) / mx : 0 - const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat) - const eff = strength + (WHITEISH_SHEEN - strength) * whiteness - const hi = mixRgb(ink, WHITE, 0.7 * eff) - const body = darken(ink, bodyDarken * (1 - whiteness)) - const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.4, r * 0.05, x, y, r * 1.15) - g.addColorStop(0, rgba(hi, 1)) - g.addColorStop(0.5, rgba(body, 1)) - g.addColorStop(1, rgba(body, 0.85)) - ctx.fillStyle = g - ctx.fill() + const sprite = sphereSprite(ink, strength, bodyDarken) + const scale = r / SPRITE_R + const drawSize = sprite.width * scale + ctx.drawImage(sprite, x - drawSize / 2, y - drawSize / 2, drawSize, drawSize) } const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y @@ -156,7 +202,7 @@ export function drawScene(scene: Scene): DrawResult { const erec = (rec: number) => (frontier > 0 ? clamp(rec / frontier, 0, 1) : 1) const { h, w } = size - const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, primary, skillInk } = palette + const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, skillInk } = palette const { bandAlpha, lightSize, ringAlpha, sheen } = RING_PARAMS[darkTheme ? 'dark' : 'light'] let animating = false @@ -208,6 +254,9 @@ export function drawScene(scene: Scene): DrawResult { const shade = (a: number) => `rgba(${base.r},${base.g},${base.b},${a})` const projX = (wx: number) => wx * vp.k + vp.x const projY = (wy: number) => wy * vp.k * TILT + vp.y + // Baseline node scale: the rested fit, held stable while the playback camera + // dives into the core — so t≈0 nodes don't balloon (see fitScale). + const nodeK = fitScale(w, h, rings) // Two composable layers: node highlight (selected ?? hovered) in full ink, and // a selection-only ring/date filter that only shifts alpha. @@ -322,85 +371,16 @@ export function drawScene(scene: Scene): DrawResult { }) ctx.setLineDash([]) - // Screen space for the core, jump routes, and glyphs (crisp, easy to trim). + // Screen space for the jump routes and glyphs (crisp, easy to trim). The empty + // core's animated scramble is NOT painted here — it's the only perpetually + // moving layer, so it's drawn live each frame by drawScramble() on top of the + // (cached) static scene. Everything else in this function is static until an + // input changes, which is why `animating` now reflects only in-flight fades. ctx.setTransform(dpr, 0, 0, dpr, 0, 0) - // Ring 0 is intentionally empty: computeRecency's lead-in keeps the oldest - // real data out in the first shell. Fill that gap with a tilted ASCII - // scramble — a decoding-glyph field laid on the disk plane (rows squashed by - // TILT, circular falloff) so the empty core reads as "computing", not missing. - // It animates continuously, so the draw loop is kept hot (animating = true). - const coreX = projX(0) - const coreY = projY(0) - // Fill to the innermost ring (the core shell), not the RING_INNER constant — - // the ring sits in lead-in space, so derive the radius from it directly. - const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94 - const cell = clamp(coreRx * 0.2, 6, 11) - // Aspect-correct on the tilt: rows are spaced by the full glyph height (square - // cells, no vertical squish), but the field is clipped to the disk's ELLIPSE - // (vertical extent = coreRx * TILT), so it sits on the tilted plane while the - // glyphs themselves stay un-squished. Fewer rows fit vertically — that's it. - const coreRy = coreRx * TILT - const half = Math.max(3, Math.round(coreRx / cell)) - const now = performance.now() - - ctx.save() - ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - for (let r = -half; r <= half; r += 1) { - // Per-row flow: half the rows drift left, half right, each at its own speed. - // The drift is a continuous pixel scroll (not a per-cell swap), and each - // glyph's identity is tied to its slot index — so a character visibly slides - // across instead of the whole row flickering in place. Combined with the - // TILT squash + opposite directions, the field reads as a turning surface. - const rowSeed = (r * 19349663) >>> 0 || 1 - const dir = rowSeed & 1 ? 1 : -1 - const speed = 8 + (rowSeed % 16) // px/sec - const scroll = (now / 1000) * speed * dir - const ny = (r * cell) / coreRy - // Latitude dimming: rows away from the equator fade, selling the sphere read. - const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny)) - const kMin = Math.floor((-coreRx - scroll) / cell) - 1 - const kMax = Math.ceil((coreRx - scroll) / cell) + 1 - - for (let k = kMin; k <= kMax; k += 1) { - const sx = k * cell + scroll // screen-space x relative to the core center - const nx = sx / coreRx - const d2 = nx * nx + ny * ny - - if (d2 > 1) { - continue - } - - const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0 - const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0' - // Mostly flat brightness, fading only near the rim (reduced gradient). - const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1) - const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100) - // Fake depth: a stable per-slot value pops a subset of glyphs forward, so - // some characters read as nearer/brighter and drift across in front. - const depth = ((seed >>> 11) % 100) / 100 - const pop = depth > 0.92 ? 2.6 : depth > 0.78 ? 1.6 : 1 - const a = clamp((darkTheme ? 0.25 : 0.33) * edge * flick * rowDim * pop, 0, 0.85) - - if (a < 0.02) { - continue - } - - ctx.fillStyle = rgba(primary, a) - ctx.fillText(ch, coreX + sx, coreY + r * cell) - } - } - - ctx.restore() - ctx.globalAlpha = 1 - animating = true - // Jump routes — a focused node's links stop at its selection ring. const focusNode = focusId ? (byId.get(focusId) ?? null) : null - const focusRingR = focusNode ? (nodeRadius(focusNode) + focusNode.rec) * vp.k + 4 : 0 + const focusRingR = focusNode ? nodeRadius(focusNode) * nodeK + 4 : 0 for (const link of links) { const s = typeof link.source === 'object' ? link.source : byId.get(String(link.source)) @@ -467,20 +447,30 @@ export function drawScene(scene: Scene): DrawResult { ctx.setLineDash([]) // Nodes: the node layer paints pure ink (focused node + neighbors); the date - // filter is alpha-only, so the two states compose. + // filter is alpha-only, so the two states compose. Track which rings have at + // least one revealed node so a ring's date only shows once it has content. + const revealedRings = new Set() + for (const n of nodes) { // The land comes first: a node waits for the ring that CAPS its region (its // outer date gridline) to grow in before it ignites — so the ring is always // drawn before any star inside it, not after. const landLaid = (ringAppear[n.outerRingIndex] ?? 1) >= 0.5 const revealed = seen(n.rec) && landLaid + + if (revealed) { + revealedRings.add(n.outerRingIndex) + } const isFocus = revealed && n.id === focusId const isNeighbor = revealed && !!focusSet && focusSet.has(n.id) const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi const nodeHigh = isFocus || isNeighbor const er = erec(n.rec) const ageScale = nodeHigh || inRing ? 1 : 0.34 + Math.min(1, er / 0.4) * 0.66 - const r = nodeRadius(n) * vp.k * ageScale + // Stable screen-space radius: use the graph's resting fit zoom, not the + // current playback camera zoom. Full-map views keep their original density, + // while t≈0 spore-zoom no longer inflates nodes into bubbles. + const r = nodeRadius(n) * nodeK * ageScale const baseAlpha = nodeHigh ? 1 : ring ? (inRing ? (focusId ? 0.55 : 1) : 0.16) : focusId ? 0.16 : recencyInk(er) const alpha = fadeAlpha(fades.nodes, n.id, revealed ? baseAlpha : 0, nodeHigh || inRing) @@ -504,12 +494,13 @@ export function drawScene(scene: Scene): DrawResult { ctx.globalAlpha = vis const nodeInk = nodeHigh ? base : n.kind === 'memory' ? memoryInk : skillInk const shape = NODE_SHAPE[n.kind] - shapePath(ctx, shape, sx, sy, r) if (shape === 'circle') { - // Highlighted orbs pop full bright; others darken so the sheen reads. + // Highlighted orbs pop full bright; others darken so the sheen reads. The + // sprite carries the disk, so no path is built for circles. sphereFill(ctx, sx, sy, r, nodeInk, sheen, nodeHigh ? 0 : ORB_DARKEN) } else { + shapePath(ctx, shape, sx, sy, r) ctx.fillStyle = rgba(nodeInk, 1) ctx.fill() } @@ -533,8 +524,10 @@ export function drawScene(scene: Scene): DrawResult { ctx.textAlign = 'center' const LABEL_GAP = 15 let lastLabelY = Number.POSITIVE_INFINITY + // A ring's date only shows once it actually has a revealed node — no floating + // date over a blank disk (t=0) or a lone empty ring. rings.forEach((rg, i) => { - if (!rg.label) { + if (!rg.label || !revealedRings.has(i)) { return } @@ -620,7 +613,7 @@ export function drawScene(scene: Scene): DrawResult { const totalW = Math.max(metaW, footerW, titleBgW) const totalH = BADGE_H + ROW_GAP + titleBgH + (footerText ? ROW_GAP + FOOTER_H : 0) const bx = clamp(projX(tip.x) - totalW / 2, 4, Math.max(4, w - totalW - 4)) - const by = clamp(projY(tip.y) - (nodeRadius(tip) * vp.k + 8) - totalH, 4, Math.max(4, h - totalH - 4)) + const by = clamp(projY(tip.y) - (nodeRadius(tip) * nodeK + 8) - totalH, 4, Math.max(4, h - totalH - 4)) tipRect = { h: totalH, w: totalW, x: bx, y: by } ctx.textAlign = 'left' @@ -687,7 +680,7 @@ export function drawScene(scene: Scene): DrawResult { const label = ellipsize(ctx, n.label, Math.min(180, w * 0.32)) const bw = ctx.measureText(label).width + 8 const x = clamp(projX(n.x) - bw / 2, LBL_M, Math.max(LBL_M, w - bw - LBL_M)) - const top = projY(n.y) - (nodeRadius(n) * vp.k + 7) - LBL_H + 4 + const top = projY(n.y) - (nodeRadius(n) * nodeK + 7) - LBL_H + 4 const clampY = (v: number) => clamp(v, LBL_M, Math.max(LBL_M, h - LBL_H - LBL_M)) const step = LBL_H + 3 let y: null | number = null @@ -719,3 +712,114 @@ export function drawScene(scene: Scene): DrawResult { return { animating, ringLabelRects } } + +// Glyph cells from the core's center to its rim — the target density. In the mid +// range the field is this many cells across (constant "amount of text"), and the +// glyph size tracks the camera. Bump for denser, drop for sparser. +const SCRAMBLE_RADIUS = 6 + +// Glyph size (px) is clamped to this band: the font grows with the camera but +// never balloons on a big/zoomed-in core — past the ceiling the core fills with +// MORE, smaller glyphs instead of fewer huge ones — and stays legible when tiny. +const SCRAMBLE_CELL_MIN = 5 +const SCRAMBLE_CELL_MAX = 13 + +// The empty-core scramble: a tilted, Matrix-style decoding-glyph field laid on +// the disk plane (rows squashed by TILT, clipped to the core ellipse) so the +// empty center reads as "computing", not missing. PURELY decorative — the glyphs +// are a seeded PRNG field, never derived from nodes/memories. Drawn live each +// frame on top of the cached static scene, since it's the only animated layer. +export function drawScramble({ + ctx, + dpr, + palette, + rings, + vp +}: { + ctx: CanvasRenderingContext2D + dpr: number + palette: Palette + rings: Ring[] + vp: Viewport +}): void { + const { darkTheme, primary } = palette + const projX = (wx: number) => wx * vp.k + vp.x + const projY = (wy: number) => wy * vp.k * TILT + vp.y + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const coreX = projX(0) + const coreY = projY(0) + // Fill to the innermost ring (the core shell), not the RING_INNER constant — + // the ring sits in lead-in space, so derive the radius from it directly. + const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94 + + if (coreRx <= 0) { + return + } + + // Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the + // glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more, + // smaller glyphs rather than a few giant ones — and stays legible when tiny. + const cell = clamp(coreRx / SCRAMBLE_RADIUS, SCRAMBLE_CELL_MIN, SCRAMBLE_CELL_MAX) + // Aspect-correct on the tilt: rows are spaced by the full glyph height (square + // cells, no vertical squish), but the field is clipped to the disk's ELLIPSE + // (vertical extent = coreRx * TILT), so it sits on the tilted plane while the + // glyphs themselves stay un-squished. Fewer rows fit vertically — that's it. + const coreRy = coreRx * TILT + const half = Math.max(3, Math.round(coreRx / cell)) + const now = performance.now() + + ctx.save() + ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + for (let r = -half; r <= half; r += 1) { + // Per-row flow: half the rows drift left, half right, each at its own speed. + // The drift is a continuous pixel scroll (not a per-cell swap), and each + // glyph's identity is tied to its slot index — so a character visibly slides + // across instead of the whole row flickering in place. Combined with the + // TILT squash + opposite directions, the field reads as a turning surface. + const rowSeed = (r * 19349663) >>> 0 || 1 + const dir = rowSeed & 1 ? 1 : -1 + const speed = 8 + (rowSeed % 16) // px/sec + const scroll = (now / 1000) * speed * dir + const ny = (r * cell) / coreRy + // Latitude dimming: rows away from the equator fade, selling the sphere read. + const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny)) + const kMin = Math.floor((-coreRx - scroll) / cell) - 1 + const kMax = Math.ceil((coreRx - scroll) / cell) + 1 + + for (let k = kMin; k <= kMax; k += 1) { + const sx = k * cell + scroll // screen-space x relative to the core center + const nx = sx / coreRx + const d2 = nx * nx + ny * ny + + if (d2 > 1) { + continue + } + + const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0 + const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0' + // Mostly flat brightness, fading only near the rim (reduced gradient). + const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1) + const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100) + // Fake depth: a stable per-slot value pops a subset of glyphs forward, so + // some characters read as nearer/brighter and drift across in front. + const depth = ((seed >>> 11) % 100) / 100 + const pop = depth > 0.92 ? 2.6 : depth > 0.78 ? 1.6 : 1 + const a = clamp((darkTheme ? 0.25 : 0.33) * edge * flick * rowDim * pop, 0, 0.85) + + if (a < 0.02) { + continue + } + + ctx.fillStyle = rgba(primary, a) + ctx.fillText(ch, coreX + sx, coreY + r * cell) + } + } + + ctx.restore() + ctx.globalAlpha = 1 +} diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx index 10896094b97..068c95fc426 100644 --- a/apps/desktop/src/app/starmap/star-map.tsx +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -7,8 +7,8 @@ import type { StarmapGraph } from '@/types/hermes' import { computePalette, memoryInkFor, resolveRgb, rgba } from './color' import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants' -import { clamp, distToSegmentSq, fitViewport, nodeRadius } from './geometry' -import { drawScene } from './render' +import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry' +import { drawScene, drawScramble } from './render' import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code' import { ShareControls } from './share-controls' import { buildSimulation } from './simulation' @@ -492,12 +492,12 @@ export function StarMap({ return () => mo.disconnect() }, [invalidate]) - // Event-driven render loop: no frames while idle. Anything that changes the - // view calls invalidate(); a draw that's still animating reschedules itself. + // Render loop. The core scramble animates continuously, so the loop runs while + // the window is focused — but each frame is cheap (live scramble + a blit of the + // cached static layer). The expensive scene only re-renders when invalidate() + // marks it dirty. Capped to ~30fps; interaction (force) bypasses the cap. useEffect(() => { let raf = 0 - // Continuous self-animation (the core scramble) only needs ~30fps; cap it so - // the idle loop isn't a 60fps full-scene redraw. Interaction bypasses the cap. const ANIM_MS = 1000 / 30 let lastAnimTs = 0 let force = true @@ -516,57 +516,96 @@ export function StarMap({ } } - const draw = (): boolean => { + // The static scene (rings, bands, links, nodes, labels) is cached in an + // offscreen layer and only re-rendered when something actually changes — + // dirtyRef flags that. The animated core scramble is the ONLY per-frame work: + // each frame we just clear, draw the live scramble, and blit the cached layer + // on top. So an idle map costs a scramble + one drawImage, not a full redraw. + let staticCanvas: HTMLCanvasElement | null = null + + const paint = () => { const canvas = canvasRef.current const ctx = canvas?.getContext('2d') if (!canvas || !ctx) { - return false + return + } + + if (!staticCanvas) { + staticCanvas = document.createElement('canvas') + } + + // Keep the offscreen layer matched to the backing store; a resize wipes it, + // so force a static rebuild. + if (staticCanvas.width !== canvas.width || staticCanvas.height !== canvas.height) { + staticCanvas.width = canvas.width + staticCanvas.height = canvas.height + dirtyRef.current = true + } + + const offCtx = staticCanvas.getContext('2d') + + if (!offCtx) { + return } if (themeDirtyRef.current || !paletteRef.current) { paletteRef.current = computePalette(canvas) themeDirtyRef.current = false + dirtyRef.current = true } - const { animating, ringLabelRects } = drawScene({ - adjacency: adjacencyRef.current, - byId: byIdRef.current, - ctx, - dpr: dprRef.current, - fades: fadeRef.current, - focusId: selectedIdRef.current ?? hoverRef.current, - hoverId: hoverRef.current, - hoverLink: hoveredLinkRef.current, - hoverRing: hoveredRingRef.current, - links: linksRef.current, - memById: memByIdRef.current, - nodes: nodesRef.current, - palette: paletteRef.current, - reveal: revealRef.current, - rings: ringsRef.current, - selectedRing: selectedRingRef.current, - size: sizeRef.current, - snapMotion: snapMotionRef.current, - vp: viewportRef.current - }) + const palette = paletteRef.current - // One-shot: a scrub snaps this frame; hover/focus afterward eases as usual - // (buckets are already at target, so the next eased frames don't move). - snapMotionRef.current = false - ringLabelRectsRef.current = ringLabelRects + if (!palette) { + return + } - return animating + // Rebuild the cached static layer only when the scene changed; keep + // rebuilding while fades are mid-ease (drawScene returns `animating`). + if (dirtyRef.current) { + const { animating, ringLabelRects } = drawScene({ + adjacency: adjacencyRef.current, + byId: byIdRef.current, + ctx: offCtx, + dpr: dprRef.current, + fades: fadeRef.current, + focusId: selectedIdRef.current ?? hoverRef.current, + hoverId: hoverRef.current, + hoverLink: hoveredLinkRef.current, + hoverRing: hoveredRingRef.current, + links: linksRef.current, + memById: memByIdRef.current, + nodes: nodesRef.current, + palette, + reveal: revealRef.current, + rings: ringsRef.current, + selectedRing: selectedRingRef.current, + size: sizeRef.current, + snapMotion: snapMotionRef.current, + vp: viewportRef.current + }) + + // One-shot: a scrub snaps this frame; hover/focus afterward eases as usual + // (buckets are already at target, so the next eased frames don't move). + snapMotionRef.current = false + ringLabelRectsRef.current = ringLabelRects + dirtyRef.current = animating + } + + // Composite: live scramble underneath, cached static scene on top. + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, canvas.width, canvas.height) + drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current }) + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.drawImage(staticCanvas, 0, 0) } const frame = (ts: number) => { raf = 0 - if (!dirtyRef.current) { - return - } - - // Throttle animation-only frames; an interaction (force) always draws now. + // The scramble animates every frame; throttle to ANIM_MS unless an + // interaction (force) needs an immediate repaint. if (!force && ts - lastAnimTs < ANIM_MS) { schedule() @@ -575,11 +614,8 @@ export function StarMap({ force = false lastAnimTs = ts - dirtyRef.current = draw() - - if (dirtyRef.current) { - schedule() - } + paint() + schedule() } invalidateRef.current = () => { @@ -646,14 +682,16 @@ export function StarMap({ // ── Pointer interactions (invert the tilted projection for hit-testing) ───── const pickNode = (cssX: number, cssY: number): null | SimNode => { const vp = viewportRef.current - const wx = (cssX - vp.x) / vp.k - const wy = (cssY - vp.y) / (vp.k * TILT) + // Hit radius mirrors the billboarded draw: rested fit scale, screen space. + const nodeK = fitScale(sizeRef.current.w, sizeRef.current.h, ringsRef.current) let best: null | SimNode = null let bestD = Infinity for (const n of nodesRef.current) { - const r = nodeRadius(n) + 6 - const d = (n.x - wx) ** 2 + (n.y - wy) ** 2 + const r = nodeRadius(n) * nodeK + 6 + const sx = n.x * vp.k + vp.x + const sy = n.y * vp.k * TILT + vp.y + const d = (sx - cssX) ** 2 + (sy - cssY) ** 2 if (d < r * r && d < bestD) { bestD = d diff --git a/apps/desktop/src/app/starmap/timeline.tsx b/apps/desktop/src/app/starmap/timeline.tsx index aaa7f40b9e7..81da1f62c9c 100644 --- a/apps/desktop/src/app/starmap/timeline.tsx +++ b/apps/desktop/src/app/starmap/timeline.tsx @@ -35,6 +35,10 @@ const ACTIVE_MARKER_CLASS = 'opacity-100' const INACTIVE_MARKER_CLASS = 'opacity-30' // Busiest bucket gets this many stars; quieter ones scale down proportionally. const MAX_STARS_PER_BUCKET = 7 +// Full coils the constellation winds across the timeline's width. +const COIL_TURNS = 6 +// Vertical swing (in % of track height) the coil arcs above/below the midline. +const COIL_AMPLITUDE = 36 // Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders. function rng(seed: number): () => number { @@ -50,9 +54,11 @@ function rng(seed: number): () => number { } } -// Scatter each time bucket's activity into stars: count ∝ events, split between -// skill- and memory-coloured stars, jittered within the bucket's horizontal slot -// and across the track height. A starmap timeline for a starmap. +// Wind each time bucket's activity into stars along a helix: count ∝ events, +// split between skill- and memory-coloured stars, ordered left→right and arced +// above/below the midline by a sine wave so the field reads as a coiling spiral +// rather than random scatter. Front-of-coil stars (cos→1) read brighter and +// larger for a sense of depth. A starmap timeline for a starmap. function buildStars(axis: TimeAxis): Star[] { const n = Math.max(1, axis.buckets.length) const stars: Star[] = [] @@ -69,18 +75,22 @@ function buildStars(axis: TimeAxis): Star[] { const slot = 1 / n for (let s = 0; s < count; s++) { - const jitter = (r() - 0.5) * slot * 0.9 - const center = (i + 0.5) / n + // Ordered position within the bucket's slot keeps the coil smooth. + const frac = (i + (s + 0.5) / count) / n + const angle = frac * COIL_TURNS * Math.PI * 2 + // Depth: front of the coil (cos→1) is brighter/larger than the back. + const depth = (Math.cos(angle) + 1) / 2 + const wobble = (r() - 0.5) * slot * 0.25 + const top = 50 + Math.sin(angle) * COIL_AMPLITUDE + (r() - 0.5) * 5 stars.push({ delay: r() * 3, duration: 2.4 + r() * 2.6, kind: s < skillCount ? 'skill' : 'memory', - leftPct: Math.max(0, Math.min(1, center + jitter)) * 100, - // Brighter, slightly larger stars are rarer. - opacity: 0.5 + r() * 0.5, - size: 1 + Math.round(r() * r() * 2.2), - topPct: 12 + r() * 76 + leftPct: Math.max(0, Math.min(1, frac + wobble)) * 100, + opacity: 0.45 + depth * 0.5, + size: 1 + Math.round(depth * 2.4), + topPct: Math.max(6, Math.min(94, top)) }) } }) From c0b308e1fedd3c681adc0953276b6632f0ee3c03 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 01:48:04 -0500 Subject: [PATCH 4/8] fix(desktop): center memory-graph timeline stars, surface quiet buckets Revert the helix coil to the constellation scatter, biased toward the midline (triangular vertical), and stop a packed core ring from crushing every quieter bucket into one invisible speck: sqrt-scale the per-bucket star count, floor star size to 2px, and lift the dim baseline. --- apps/desktop/src/app/starmap/timeline.tsx | 42 +++++++++++------------ 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/app/starmap/timeline.tsx b/apps/desktop/src/app/starmap/timeline.tsx index 81da1f62c9c..405eb79c94a 100644 --- a/apps/desktop/src/app/starmap/timeline.tsx +++ b/apps/desktop/src/app/starmap/timeline.tsx @@ -35,10 +35,6 @@ const ACTIVE_MARKER_CLASS = 'opacity-100' const INACTIVE_MARKER_CLASS = 'opacity-30' // Busiest bucket gets this many stars; quieter ones scale down proportionally. const MAX_STARS_PER_BUCKET = 7 -// Full coils the constellation winds across the timeline's width. -const COIL_TURNS = 6 -// Vertical swing (in % of track height) the coil arcs above/below the midline. -const COIL_AMPLITUDE = 36 // Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders. function rng(seed: number): () => number { @@ -54,11 +50,11 @@ function rng(seed: number): () => number { } } -// Wind each time bucket's activity into stars along a helix: count ∝ events, -// split between skill- and memory-coloured stars, ordered left→right and arced -// above/below the midline by a sine wave so the field reads as a coiling spiral -// rather than random scatter. Front-of-coil stars (cos→1) read brighter and -// larger for a sense of depth. A starmap timeline for a starmap. +// Scatter each time bucket's activity into stars: count ∝ events, split between +// skill- and memory-coloured stars, jittered within the bucket's horizontal slot +// and across the track height. Vertical placement is biased toward the midline +// (a triangular distribution) so stars cluster near the centre more often than +// the edges. A starmap timeline for a starmap. function buildStars(axis: TimeAxis): Star[] { const n = Math.max(1, axis.buckets.length) const stars: Star[] = [] @@ -69,28 +65,30 @@ function buildStars(axis: TimeAxis): Star[] { } const intensity = axis.maxTotal > 0 ? b.total / axis.maxTotal : 0 - const count = Math.max(1, Math.round(intensity * MAX_STARS_PER_BUCKET)) + // sqrt curve so a single co-timed burst (a packed core ring) doesn't crush + // every quieter bucket down to the 1-star floor and read as blank. + const count = Math.max(1, Math.round(Math.sqrt(intensity) * MAX_STARS_PER_BUCKET)) const skillCount = Math.round((b.skill / b.total) * count) const r = rng(i * 9973 + 7) const slot = 1 / n + const center = (i + 0.5) / n for (let s = 0; s < count; s++) { - // Ordered position within the bucket's slot keeps the coil smooth. - const frac = (i + (s + 0.5) / count) / n - const angle = frac * COIL_TURNS * Math.PI * 2 - // Depth: front of the coil (cos→1) is brighter/larger than the back. - const depth = (Math.cos(angle) + 1) / 2 - const wobble = (r() - 0.5) * slot * 0.25 - const top = 50 + Math.sin(angle) * COIL_AMPLITUDE + (r() - 0.5) * 5 + const jitter = (r() - 0.5) * slot * 0.9 + // Average of two uniforms → triangular peak at 0.5, pulling stars toward + // the midline more often while still reaching the edges occasionally. + const vertical = (r() + r()) / 2 stars.push({ delay: r() * 3, duration: 2.4 + r() * 2.6, kind: s < skillCount ? 'skill' : 'memory', - leftPct: Math.max(0, Math.min(1, frac + wobble)) * 100, - opacity: 0.45 + depth * 0.5, - size: 1 + Math.round(depth * 2.4), - topPct: Math.max(6, Math.min(94, top)) + leftPct: Math.max(0, Math.min(1, center + jitter)) * 100, + // Brighter, slightly larger stars are rarer. + opacity: 0.5 + r() * 0.5, + // Floor at 2px so a lone star in a quiet bucket still reads on black. + size: 2 + Math.round(r() * r() * 2.2), + topPct: 12 + vertical * 76 }) } }) @@ -225,7 +223,7 @@ export const Timeline = memo(function Timeline({ backgroundColor: colorFor(star.kind), height: star.size, left: `${star.leftPct}%`, - opacity: 0.16, + opacity: 0.22, top: `${star.topPct}%`, width: star.size }} From b6e57e215bf08fdf2308881af48d4b97761319d2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 02:06:29 -0500 Subject: [PATCH 5/8] refactor(desktop): share theme-repaint observer; memory-graph depth polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the copy-pasted "re-resolve on theme repaint" MutationObserver into a shared hooks/use-theme-epoch (useThemeEpoch + onThemeRepaint) and consume it from the star map, image-gen placeholder, and useIsDark instead of each hand- rolling its own root observer. Keeps the post-paint read the canvas probes need (useTheme() would read stale CSS — child effects run before applyTheme). Also: light-mode band depth (inner wash), travelling-glow core scramble, and dark-only timeline bloom. --- apps/desktop/src/app/starmap/constants.ts | 2 +- apps/desktop/src/app/starmap/render.ts | 38 ++++++++++++++----- apps/desktop/src/app/starmap/star-map.tsx | 27 +++++-------- apps/desktop/src/app/starmap/timeline.tsx | 9 ++++- .../assistant-ui/embeds/use-is-dark.ts | 24 +++++------- .../chat/image-generation-placeholder.tsx | 9 ++--- apps/desktop/src/hooks/use-theme-epoch.ts | 32 ++++++++++++++++ 7 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/src/hooks/use-theme-epoch.ts diff --git a/apps/desktop/src/app/starmap/constants.ts b/apps/desktop/src/app/starmap/constants.ts index 748391a483e..02f44ab4986 100644 --- a/apps/desktop/src/app/starmap/constants.ts +++ b/apps/desktop/src/app/starmap/constants.ts @@ -58,5 +58,5 @@ export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = { export const RING_PARAMS: Record<'dark' | 'light', RingParams> = { dark: { bandAlpha: 0.01, lightSize: 0.64, ringAlpha: 0.03, sheen: 0.12 }, - light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.04, sheen: 0.1 } + light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.028, sheen: 0.1 } } diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts index 8d1be201429..e529b72a5b4 100644 --- a/apps/desktop/src/app/starmap/render.ts +++ b/apps/desktop/src/app/starmap/render.ts @@ -335,9 +335,22 @@ export function drawScene(scene: Scene): DrawResult { ctx.fillStyle = rgba(bandInk, LIT_BAND_ALPHA) } else { const grad = ctx.createRadialGradient(0, 0, inner, 0, 0, outer) - grad.addColorStop(0, rgba(bandInk, 0)) - grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0)) - grad.addColorStop(1, rgba(bandInk, bandAlpha)) + + if (darkTheme) { + // Dark: a light wash on each band's OUTER rim — reads as light catching + // a raised edge → depth. + grad.addColorStop(0, rgba(bandInk, 0)) + grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, bandAlpha)) + } else { + // Light: flip it — the (darker) wash sits on the INNER edge and fades + // outward, so each shell reads as recessed toward the core (depth), + // not a raised mound. + grad.addColorStop(0, rgba(bandInk, bandAlpha)) + grad.addColorStop(clamp(lightSize, 0.01, 0.99), rgba(bandInk, 0)) + grad.addColorStop(1, rgba(bandInk, 0)) + } + ctx.fillStyle = grad } @@ -358,7 +371,10 @@ export function drawScene(scene: Scene): DrawResult { // out as gracefully as it grew in; the alpha bucket only carries the snappy // selection emphasis. const emphasisAlpha = emphasized ? clamp(LIT_BAND_ALPHA * 2, 0, 1) : ringAlpha - const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) + // The core ring (i 0) fades in from reveal 0 so the scramble orb starts + // un-enclosed (no outline boxing it in) and the shell appears as it plays. + const coreFade = i === 0 ? clamp(reveal / 0.08, 0, 1) : 1 + const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) * coreFade if (ringAlphaNow < 0.004) { return @@ -769,6 +785,7 @@ export function drawScramble({ const coreRy = coreRx * TILT const half = Math.max(3, Math.round(coreRx / cell)) const now = performance.now() + const t = now / 1000 // seconds, for the travelling-glow highlight ctx.save() ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace` @@ -805,11 +822,14 @@ export function drawScramble({ // Mostly flat brightness, fading only near the rim (reduced gradient). const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1) const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100) - // Fake depth: a stable per-slot value pops a subset of glyphs forward, so - // some characters read as nearer/brighter and drift across in front. - const depth = ((seed >>> 11) % 100) / 100 - const pop = depth > 0.92 ? 2.6 : depth > 0.78 ? 1.6 : 1 - const a = clamp((darkTheme ? 0.25 : 0.33) * edge * flick * rowDim * pop, 0, 0.85) + // Travelling glow: two crossing sine waves (drifting in time) light a + // lattice of bright spots that ripple ACROSS the orb — so the highlight + // moves and twinkles instead of being a fixed random set. A per-glyph + // phase keeps neighbours from pulsing in lockstep. + const phase = (seed & 7) * 0.35 + const glow = Math.sin(nx * 4.5 + t * 1.3 + phase) * Math.sin(ny * 4.5 - t * 0.9 + phase) + const pop = 1 + clamp((glow - 0.25) / 0.75, 0, 1) * 2.6 + const a = clamp((darkTheme ? 0.22 : 0.3) * edge * flick * rowDim * pop, 0, 0.9) if (a < 0.02) { continue diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx index 068c95fc426..7a5e597d5bb 100644 --- a/apps/desktop/src/app/starmap/star-map.tsx +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -2,6 +2,7 @@ import { type Simulation } from 'd3-force' import { atom, type WritableAtom } from 'nanostores' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useThemeEpoch } from '@/hooks/use-theme-epoch' import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures' import type { StarmapGraph } from '@/types/hermes' @@ -153,8 +154,9 @@ export function StarMap({ const [selectedId, setSelectedId] = useState(null) const [size, setSize] = useState({ h: 0, w: 0 }) - // Bumped on theme change so the legend's memory swatch recomputes its color. - const [themeVersion, setThemeVersion] = useState(0) + // Increments on every theme repaint (shared hook) so the legend swatch and the + // canvas palette re-resolve against the freshly-painted CSS custom properties. + const themeEpoch = useThemeEpoch() // Memory's swatch color — the same complementary-of-primary the canvas uses, // so the legend matches the rendered diamonds exactly. const [memoryColor, setMemoryColor] = useState('var(--theme-secondary)') @@ -474,23 +476,14 @@ export function StarMap({ const bgVal = style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000' setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9)) } - }, [size, themeVersion]) + }, [size, themeEpoch]) - // Repaint + repalette when the theme/mode changes (class + inline vars on ). + // Repaint + repalette when the theme/mode repaints (the shared observer fires + // after applyTheme rewrites the class + inline vars on ). useEffect(() => { - const mo = new MutationObserver(() => { - themeDirtyRef.current = true - setThemeVersion(v => v + 1) - invalidate() - }) - - mo.observe(document.documentElement, { - attributeFilter: ['class', 'style', 'data-hermes-mode', 'data-hermes-theme'], - attributes: true - }) - - return () => mo.disconnect() - }, [invalidate]) + themeDirtyRef.current = true + invalidate() + }, [invalidate, themeEpoch]) // Render loop. The core scramble animates continuously, so the loop runs while // the window is focused — but each frame is cheap (live scramble + a blit of the diff --git a/apps/desktop/src/app/starmap/timeline.tsx b/apps/desktop/src/app/starmap/timeline.tsx index 405eb79c94a..8cb62e47025 100644 --- a/apps/desktop/src/app/starmap/timeline.tsx +++ b/apps/desktop/src/app/starmap/timeline.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { Codicon } from '@/components/ui/codicon' +import { useTheme } from '@/themes/context' import type { TimeAxis } from './time-axis' @@ -112,6 +113,10 @@ export const Timeline = memo(function Timeline({ const trackRef = useRef(null) const draggingRef = useRef(false) const markerRefs = useRef([]) + // Star glow halos read as depth on a dark track but smear on a light one, so + // the bloom is dark-mode only. + const { resolvedMode } = useTheme() + const glow = resolvedMode === 'dark' const stars = useMemo(() => buildStars(axis), [axis]) @@ -249,7 +254,7 @@ export const Timeline = memo(function Timeline({ '--o': star.opacity, animation: `starmap-twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`, backgroundColor: color, - boxShadow: `0 0 ${star.size + 1}px ${color}`, + boxShadow: glow ? `0 0 ${star.size + 1}px ${color}` : 'none', height: star.size, left: `${star.leftPct}%`, opacity: star.opacity, @@ -266,7 +271,7 @@ export const Timeline = memo(function Timeline({ {ringStops.map((stop, i) => (
{ if (el) { diff --git a/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts b/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts index 178a0c0fd5c..f136439e348 100644 --- a/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts +++ b/apps/desktop/src/components/assistant-ui/embeds/use-is-dark.ts @@ -1,24 +1,18 @@ import { useEffect, useState } from 'react' +import { useThemeEpoch } from '@/hooks/use-theme-epoch' + +const isDarkNow = () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + // Tracks the app's dark/light mode off the `dark` class on (set by // themes/context.tsx). Embeds that theme their own content (tweets) read this. +// Rides the shared theme-repaint observer; setState bails on an unchanged +// boolean, so style-only repaints don't re-render. export function useIsDark(): boolean { - const [dark, setDark] = useState( - () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark') - ) + const epoch = useThemeEpoch() + const [dark, setDark] = useState(isDarkNow) - useEffect(() => { - if (typeof document === 'undefined') { - return - } - - const root = document.documentElement - const observer = new MutationObserver(() => setDark(root.classList.contains('dark'))) - - observer.observe(root, { attributeFilter: ['class'], attributes: true }) - - return () => observer.disconnect() - }, []) + useEffect(() => setDark(isDarkNow()), [epoch]) return dark } diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx index b29434efe22..26806847311 100644 --- a/apps/desktop/src/components/chat/image-generation-placeholder.tsx +++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx @@ -1,6 +1,7 @@ import { type FC, useCallback, useEffect, useRef } from 'react' import { useResizeObserver } from '@/hooks/use-resize-observer' +import { onThemeRepaint } from '@/hooks/use-theme-epoch' type Rgb = { r: number; g: number; b: number } @@ -278,14 +279,10 @@ export const DiffusionCanvas: FC = () => { // Re-resolve when the theme repaints (`applyTheme` toggles `.dark` and // rewrites inline custom props on the root) instead of per animation frame. - const observer = new MutationObserver(sync) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['class', 'style', 'data-hermes-mode'] - }) + const unsubscribe = onThemeRepaint(sync) return () => { - observer.disconnect() + unsubscribe() probe.remove() } }, []) diff --git a/apps/desktop/src/hooks/use-theme-epoch.ts b/apps/desktop/src/hooks/use-theme-epoch.ts new file mode 100644 index 00000000000..710144ef194 --- /dev/null +++ b/apps/desktop/src/hooks/use-theme-epoch.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' + +// Theme repaints (themes/context.tsx) toggle `.dark` + rewrite inline custom +// props/data-hermes-* on . Canvas/probe consumers that rasterize the +// *computed* color-mix()/oklch tokens must re-resolve AFTER the paint — useTheme() +// can't, since a child's effect runs before the provider's applyTheme. A +// MutationObserver fires post-mutation, so the next getComputedStyle is fresh. +// One observer, fanned out to every listener. +const ATTRS = ['class', 'style', 'data-hermes-mode', 'data-hermes-theme'] +const listeners = new Set<() => void>() +let observer: MutationObserver | null = null + +/** Subscribe to theme repaints imperatively (ref/canvas, no re-render). */ +export function onThemeRepaint(fn: () => void): () => void { + if (!observer && typeof document !== 'undefined') { + observer = new MutationObserver(() => listeners.forEach(l => l())) + observer.observe(document.documentElement, { attributeFilter: ATTRS, attributes: true }) + } + + listeners.add(fn) + + return () => void listeners.delete(fn) +} + +/** A counter that ticks on every theme repaint — depend on it to re-resolve colors. */ +export function useThemeEpoch(): number { + const [epoch, setEpoch] = useState(0) + + useEffect(() => onThemeRepaint(() => setEpoch(e => e + 1)), []) + + return epoch +} From bd1d354fc3c509b622853f5b6205fff1007c66dd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 02:10:15 -0500 Subject: [PATCH 6/8] tune(desktop): ignite memory-graph nodes in clusters, not 1-by-1 Within each ring band, split the time-ordered nodes into a few sub-bursts (~5 nodes each) that share an ignite moment, with a touch of per-node jitter. The build-up reads as clustered pops instead of a constant single-file trickle (or an all-at-once flood). --- apps/desktop/src/app/starmap/simulation.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/starmap/simulation.ts b/apps/desktop/src/app/starmap/simulation.ts index 682170e23b7..f18fa67f130 100644 --- a/apps/desktop/src/app/starmap/simulation.ts +++ b/apps/desktop/src/app/starmap/simulation.ts @@ -18,6 +18,10 @@ export interface BuiltSim { const DAY = 86_400 +// Roughly how many nodes share one ignite burst within a ring band — the build +// reads as clustered pops, not a 1-by-1 trickle or an all-at-once flood. +const CLUSTER_SIZE = 5 + // Constant ring SCALE: the core radius and the per-ring band are pinned to the // canonical 5-ring layout, so the empty core and every band are ALWAYS that // size on the disk — more data grows the disk OUTWARD (more rings) instead of @@ -210,8 +214,18 @@ function buildLayout(graph: StarmapGraph, recById: Map, minTs: n const lo = i > 0 ? rings[i - 1]!.ratio : 0 const m = bucket.length - // f ∈ (0,1]: first node lands just inside the band, last node ON the ring. - bucket.forEach((n, k) => recByNode.set(n.id, lo + ((k + 1) / m) * (hi - lo))) + // Ignite in CLUSTERS, not a 1-by-1 trickle: split the band's (time-ordered) + // nodes into a few sub-bursts (~CLUSTER_SIZE each) that share an ignite + // moment, spaced across the band, with a hair of per-node jitter so a burst + // reads as organic rather than perfectly synchronous. + const clusters = Math.max(1, Math.round(m / CLUSTER_SIZE)) + + bucket.forEach((n, k) => { + const c = Math.min(clusters - 1, Math.floor((k / m) * clusters)) + const jitter = ((hash(n.id) % 100) / 100 - 0.5) * (0.5 / clusters) + const f = clamp((c + 1) / clusters + jitter, 0.02, 1) + recByNode.set(n.id, lo + f * (hi - lo)) + }) }) return { From 3e7ed0c53b59174c86cd7ff945c745a35e8c4e51 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 03:22:46 -0500 Subject: [PATCH 7/8] feat(desktop): memory-graph share dialog + core/zoom & light-mode polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rework share/import into one Dialog (matches rename/create): a single code field (copy to share, paste + Load to import) with a hover copy button, a Reset link beside the upload icon when viewing an imported map, and plainer copy. - Core orb: scales with the world zoom (~1.25× the inner shell), backdrop wash behind it; on focus/hover the scene composites above the orb so the active tooltip + lit lines are never covered. - fitViewport floors zoom at the reference (5-ring) extent, so big maps render at a constant scale and pan instead of shrinking every node to fit. - Light mode: flip inter-ring band shading to read as depth (not a mound), fade the core ring in from t=0, drop the timeline star glow. - Timeline: filled play glyph, crisper constellation, date moved into the legend. --- apps/desktop/src/app/starmap/geometry.ts | 18 +- apps/desktop/src/app/starmap/render.ts | 31 ++- .../src/app/starmap/share-controls.tsx | 176 +++++++++--------- apps/desktop/src/app/starmap/star-map.tsx | 53 +++++- apps/desktop/src/app/starmap/timeline.tsx | 35 ++-- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 8 files changed, 186 insertions(+), 130 deletions(-) diff --git a/apps/desktop/src/app/starmap/geometry.ts b/apps/desktop/src/app/starmap/geometry.ts index affc94e1add..c4395e66b0c 100644 --- a/apps/desktop/src/app/starmap/geometry.ts +++ b/apps/desktop/src/app/starmap/geometry.ts @@ -89,9 +89,18 @@ export function fitViewport(w: number, h: number, outer: number = RING_OUTER): V return { k: 1, x: w / 2, y: h / 2 } } - const spanX = (outer + 30) * 2 - const spanY = spanX * TILT - const k = clamp(Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / spanY, 2.2), ZOOM_MIN, ZOOM_MAX) + // Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in). + const kFor = (r: number): number => { + const spanX = (r + 30) * 2 + + return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2) + } + + // Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map + // renders at that constant scale and overflows — you pan it — instead of + // shrinking every node to fit. Smaller extents (few rings, or the playback + // core) still fit tightly / zoom in. + const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX) // Bias the center down a touch — the timeline along the top adds visual weight // up there, so true-center reads as sitting high. @@ -107,7 +116,8 @@ export function radiusForRecency(rec: number, outer: number = RING_OUTER): numbe // Screen-space scale at the graph's fully-rested fit. Nodes size against THIS, // not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how // big they read (billboarded), while a full-map view keeps its honest density. -export const fitScale = (w: number, h: number, rings: Ring[]): number => fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k +export const fitScale = (w: number, h: number, rings: Ring[]): number => + fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k // Squared distance from point (px,py) to segment a→b — for cheap link hit-tests. export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number { diff --git a/apps/desktop/src/app/starmap/render.ts b/apps/desktop/src/app/starmap/render.ts index e529b72a5b4..12e26eab171 100644 --- a/apps/desktop/src/app/starmap/render.ts +++ b/apps/desktop/src/app/starmap/render.ts @@ -80,8 +80,7 @@ const NODE_BIRTH = { down: 0.11, up: 0.075 } // Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus // a few digits/symbols for the "digital rain / decoding" look. -const SCRAMBLE_CHARS = - 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' +const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳' // Sphere-sprite atlas: a lit orb is the same picture at every size, so we render // each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and @@ -477,6 +476,7 @@ export function drawScene(scene: Scene): DrawResult { if (revealed) { revealedRings.add(n.outerRingIndex) } + const isFocus = revealed && n.id === focusId const isNeighbor = revealed && !!focusSet && focusSet.has(n.id) const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi @@ -758,7 +758,7 @@ export function drawScramble({ rings: Ring[] vp: Viewport }): void { - const { darkTheme, primary } = palette + const { bg, darkTheme, primary } = palette const projX = (wx: number) => wx * vp.k + vp.x const projY = (wy: number) => wy * vp.k * TILT + vp.y @@ -766,14 +766,33 @@ export function drawScramble({ const coreX = projX(0) const coreY = projY(0) - // Fill to the innermost ring (the core shell), not the RING_INNER constant — - // the ring sits in lead-in space, so derive the radius from it directly. - const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 0.94 + // Scale with the world (like the rings), but ~1.25× bigger than the bare inner + // shell so the core reads prominently at the rested fit. + const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25 if (coreRx <= 0) { return } + // Backdrop wash: a background-colour radial dimming the core ellipse, so on a + // busy map the nodes/links crowding through the centre recede behind the orb. + // Self-masking — bg over empty bg is invisible, so a sparse map shows no disc. + const washR = coreRx * 1.15 + ctx.save() + ctx.translate(coreX, coreY) + ctx.scale(1, TILT) + const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR) + // Near-opaque across the core (busy graph effectively vanishes behind the orb) + // with a soft falloff only at the rim so there's no hard disc edge. + wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93)) + wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88)) + wash.addColorStop(1, rgba(bg, 0)) + ctx.fillStyle = wash + ctx.beginPath() + ctx.arc(0, 0, washR, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + // Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the // glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more, // smaller glyphs rather than a few giant ones — and stays legible when tiny. diff --git a/apps/desktop/src/app/starmap/share-controls.tsx b/apps/desktop/src/app/starmap/share-controls.tsx index a782b405a8e..45c9bcc0f1b 100644 --- a/apps/desktop/src/app/starmap/share-controls.tsx +++ b/apps/desktop/src/app/starmap/share-controls.tsx @@ -1,11 +1,17 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' -import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@/components/ui/dialog' import { useI18n } from '@/i18n' +import { Upload } from '@/lib/icons' interface ShareControlsProps { // True when the shown map was loaded from a pasted code (not the live scan). @@ -17,20 +23,20 @@ interface ShareControlsProps { shareCode?: string } -const SECTION_LABEL = 'text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/55' - -// WoW-talent-loadout style sharing: one icon button opens a popover with the -// current map's code (copy/export) and a paste box (import) — drop a string, -// see the build. Lives bottom-right of the map, mirroring the legend. +// Share / import a map as a single code. The textarea shows the current map's +// code (copy it to share); edit/replace it and hit Load to view someone else's. +// One field, one button — a standard Dialog matching rename/create. export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) { const { t } = useI18n() const [open, setOpen] = useState(false) - const [draft, setDraft] = useState('') + const [value, setValue] = useState('') const [error, setError] = useState(null) - const apply = () => { - const code = draft.trim() + const own = (shareCode ?? '').trim() + const code = value.trim() + const canLoad = code !== '' && code !== own + const load = () => { if (!code) { setError(t.starmap.importEmpty) @@ -42,97 +48,85 @@ export function ShareControls({ imported = false, onImport, onResetMap, shareCod if (err === null) { setOpen(false) - setDraft('') } } return ( - { - setOpen(next) - - if (!next) { - setError(null) - } - }} - open={open} - > - +
+ {imported && ( - + )} - -
-
- {t.starmap.share} - {imported && ( - + { + setOpen(next) + setError(null) + + if (next) { + setValue(shareCode ?? '') + } + }} + open={open} + > + + + + + + + {t.starmap.shareTitle} + {t.starmap.shareHint} + + + {/* One code field: pre-filled with this map's code (copy to share); edit + or paste another and Load. Copy button floats in on hover, like a + thread code block. */} +
+