Arslan1997 commited on
Commit
1e09b5f
·
1 Parent(s): c8b7149

launching

Browse files
.env.example CHANGED
@@ -52,6 +52,8 @@ R2_PUBLIC_BASE_URL=
52
  # Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
53
  LLM_MODEL=openai/glm-5.3
54
  LLM_FAST_MODEL=openai/glm-5.3-flash
 
 
55
  LLM_API_BASE=https://api.z.ai/api/paas/v4
56
  LLM_API_KEY=
57
  LLM_TEMPERATURE=1.0
 
52
  # Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
53
  LLM_MODEL=openai/glm-5.3
54
  LLM_FAST_MODEL=openai/glm-5.3-flash
55
+ # Chat triage classifier (greetings, off topic, follow ups). Empty = LLM_FAST_MODEL.
56
+ LLM_TRIAGE_MODEL=
57
  LLM_API_BASE=https://api.z.ai/api/paas/v4
58
  LLM_API_KEY=
59
  LLM_TEMPERATURE=1.0
backend/app/config.py CHANGED
@@ -63,6 +63,8 @@ class Settings(BaseSettings):
63
  # LLM (LiteLLM model strings; Z.ai GLM by default)
64
  llm_model: str = "openai/glm-5.3"
65
  llm_fast_model: str = "openai/glm-5.3-flash"
 
 
66
  llm_api_base: str = "https://api.z.ai/api/paas/v4"
67
  llm_api_key: str = ""
68
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
 
63
  # LLM (LiteLLM model strings; Z.ai GLM by default)
64
  llm_model: str = "openai/glm-5.3"
65
  llm_fast_model: str = "openai/glm-5.3-flash"
66
+ # Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
67
+ llm_triage_model: str = ""
68
  llm_api_base: str = "https://api.z.ai/api/paas/v4"
69
  llm_api_key: str = ""
70
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
backend/app/llm/provider.py CHANGED
@@ -52,6 +52,14 @@ def fast_lm() -> dspy.LM:
52
  return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
53
 
54
 
 
 
 
 
 
 
 
 
55
  def configure_default() -> None:
56
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
57
 
 
52
  return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
53
 
54
 
55
+ @lru_cache
56
+ def triage_lm() -> dspy.LM:
57
+ """Cheap model that decides whether a chat message needs research at all."""
58
+ if not settings.llm_triage_model:
59
+ return fast_lm()
60
+ return _build(settings.llm_triage_model, effort="low", temperature=0.2, max_tokens=2000)
61
+
62
+
63
  def configure_default() -> None:
64
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
65
 
backend/app/llm/signatures.py CHANGED
@@ -120,7 +120,9 @@ class ResearchArchive(dspy.Signature):
120
  ### heading only when the answer has distinct parts. Put [n] markers right after the sentence
121
  or list item they support. Never use em dashes."""
122
 
123
- question: str = dspy.InputField()
 
 
124
  archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
125
  answer: str = dspy.OutputField(desc="Answer with [n] citation markers")
126
  citations: list[FileCitation] = dspy.OutputField()
@@ -253,3 +255,25 @@ class EvergreenScore(dspy.Signature):
253
  score: float = dspy.OutputField(desc="0 (dated) to 1 (timeless)")
254
  reason: str = dspy.OutputField()
255
  angle: str = dspy.OutputField(desc="One line hook for resharing now")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  ### heading only when the answer has distinct parts. Put [n] markers right after the sentence
121
  or list item they support. Never use em dashes."""
122
 
123
+ question: str = dspy.InputField(desc="A standalone question (follow ups already resolved)")
124
+ conversation: str = dspy.InputField(desc="Recent turns of this chat, oldest first. Use it for context "
125
+ "and to avoid repeating yourself; cite only what you read now")
126
  archive_guide: str = dspy.InputField(desc="How this notebook's files are laid out")
127
  answer: str = dspy.OutputField(desc="Answer with [n] citation markers")
128
  citations: list[FileCitation] = dspy.OutputField()
 
255
  score: float = dspy.OutputField(desc="0 (dated) to 1 (timeless)")
256
  reason: str = dspy.OutputField()
257
  angle: str = dspy.OutputField(desc="One line hook for resharing now")
258
+
259
+
260
+ class TriageMessage(dspy.Signature):
261
+ """Triage a chat message sent to a research assistant that answers ONLY from one writer's archive of
262
+ posts. Decide what it needs before any expensive research runs.
263
+ - archive: needs facts, ideas, quotes or opinions from the writer's posts (including follow ups
264
+ like "say more", "what about 2023?", "compare that with pricing").
265
+ - chitchat: greetings, thanks, reactions, small talk.
266
+ - about_app: how to use Notestack itself (sources, notebooks, audio, launch kits).
267
+ - off_topic: general knowledge, coding, math, news, or anything the posts cannot answer.
268
+ For archive, rewrite the message into a standalone question using the conversation, and pick depth:
269
+ quick for a single fact or lookup, deep for synthesis across many posts. For the other kinds, write
270
+ a short, warm reply (1 to 3 sentences) that answers or gently steers back to what the archive can
271
+ do, suggesting one concrete question. Never use em dashes."""
272
+
273
+ conversation: str = dspy.InputField(desc="Recent turns, oldest first")
274
+ message: str = dspy.InputField()
275
+ notebook: str = dspy.InputField(desc="Notebook title and some of its post titles")
276
+ kind: Literal["archive", "chitchat", "about_app", "off_topic"] = dspy.OutputField()
277
+ standalone_question: str = dspy.OutputField(desc="For archive: the question with follow ups resolved")
278
+ depth: Literal["quick", "deep"] = dspy.OutputField()
279
+ reply: str = dspy.OutputField(desc="For non archive kinds: the reply to show. Empty for archive")
backend/app/pipeline/research.py CHANGED
@@ -1,5 +1,11 @@
1
  """Agentic research over the file system corpus: the model explores files with list/search/read,
2
- then every citation is checked against the real file before it reaches the writer."""
 
 
 
 
 
 
3
 
4
  import re
5
  from collections.abc import Callable
@@ -10,8 +16,8 @@ import dspy
10
  from app.config import settings
11
  from app.corpus import Corpus, ScopedTools
12
  from app.llm.postprocess import strip_em_dashes
13
- from app.llm.provider import main_lm, track_usage
14
- from app.llm.signatures import ResearchArchive
15
 
16
  _MARKER = re.compile(r"\[(\d+)\]")
17
 
@@ -33,6 +39,87 @@ class ResearchResult:
33
  prompt_tokens: int = 0
34
  completion_tokens: int = 0
35
  steps: list[tuple[str, str]] = field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def archive_guide(tools: ScopedTools) -> str:
@@ -73,9 +160,24 @@ def research(
73
  allowed: dict[str, str],
74
  question: str,
75
  on_step: Callable[[str, str], None] | None = None,
 
 
76
  ) -> ResearchResult:
 
77
  if not allowed:
78
- return ResearchResult("This notebook has no posts yet. Add some sources first.", True, [])
 
 
 
 
 
 
 
 
 
 
 
 
79
  corpus.sync()
80
  steps: list[tuple[str, str]] = []
81
 
@@ -85,14 +187,16 @@ def research(
85
  on_step(kind, detail)
86
 
87
  tools = ScopedTools(corpus, allowed, on_step=log)
 
88
  agent = dspy.ReAct(
89
  ResearchArchive,
90
  tools=[tools.list_files, tools.search, tools.read],
91
- max_iters=settings.research_max_steps,
92
  )
93
  lm = main_lm()
94
  with track_usage(lm) as usage, dspy.context(lm=lm):
95
- pred = agent(question=question, archive_guide=archive_guide(tools))
 
96
 
97
  text, cites = verify_citations(tools, pred.answer, pred.citations or [])
98
  unsupported = bool(pred.unsupported) or not cites
@@ -103,7 +207,9 @@ def research(
103
  text=strip_em_dashes(text),
104
  unsupported=unsupported,
105
  citations=cites,
106
- prompt_tokens=usage.prompt_tokens,
107
- completion_tokens=usage.completion_tokens,
108
  steps=steps,
 
 
109
  )
 
1
  """Agentic research over the file system corpus: the model explores files with list/search/read,
2
+ then every citation is checked against the real file before it reaches the writer.
3
+
4
+ Cost control: every message is triaged first. Greetings and thanks are answered from a phrase list
5
+ (no model call). Everything else gets one cheap call on the fast model that reads the recent
6
+ conversation, answers off topic and app questions directly, and turns follow ups into standalone
7
+ questions. Only real archive questions run the research agent, with a small step budget for simple
8
+ lookups and the full budget for synthesis."""
9
 
10
  import re
11
  from collections.abc import Callable
 
16
  from app.config import settings
17
  from app.corpus import Corpus, ScopedTools
18
  from app.llm.postprocess import strip_em_dashes
19
+ from app.llm.provider import main_lm, track_usage, triage_lm
20
+ from app.llm.signatures import ResearchArchive, TriageMessage
21
 
22
  _MARKER = re.compile(r"\[(\d+)\]")
23
 
 
39
  prompt_tokens: int = 0
40
  completion_tokens: int = 0
41
  steps: list[tuple[str, str]] = field(default_factory=list)
42
+ kind: str = "archive" # archive | chitchat | about_app | off_topic
43
+ question: str = "" # the standalone question that was researched
44
+
45
+
46
+ @dataclass
47
+ class Turn:
48
+ role: str # user | assistant
49
+ text: str
50
+
51
+
52
+ HISTORY_TURNS = 8
53
+ QUICK_STEPS = 4
54
+
55
+ _CHITCHAT = re.compile(
56
+ r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening)|thanks|thank you|thx|ty|cheers|ok(ay)?|"
57
+ r"cool|great|nice|awesome|perfect|got it|sounds good|bye|goodbye|see you|lol|haha)[\s!.,:)]*$",
58
+ re.IGNORECASE,
59
+ )
60
+ _GREETING = re.compile(r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening))", re.IGNORECASE)
61
+ _BYE = re.compile(r"^\s*(bye|goodbye|see you)", re.IGNORECASE)
62
+
63
+
64
+ def conversation_text(history: list[Turn], limit: int = HISTORY_TURNS) -> str:
65
+ """Last few turns, citation markers stripped, each trimmed, so the prompt stays small."""
66
+ if not history:
67
+ return "(new conversation)"
68
+ lines = []
69
+ for t in history[-limit:]:
70
+ text = _MARKER.sub("", t.text).strip()
71
+ if len(text) > 700:
72
+ text = text[:700].rsplit(" ", 1)[0] + " ..."
73
+ lines.append(f"{'Writer' if t.role == 'user' else 'Assistant'}: {text}")
74
+ return "\n".join(lines)
75
+
76
+
77
+ def instant_reply(message: str, history: list[Turn]) -> str | None:
78
+ """Canned replies for pure small talk: no model call at all."""
79
+ if not _CHITCHAT.match(message):
80
+ return None
81
+ if _BYE.match(message):
82
+ return "Clear skies. Your archive will be here when you come back."
83
+ if _GREETING.match(message):
84
+ return ("Hi! Ask me anything about the posts in this notebook, for example what you have written "
85
+ "about pricing, or how your view on something changed over time.")
86
+ if history:
87
+ return "Glad that helped. Want to dig further into any of those posts?"
88
+ return "Ask me anything about the posts in this notebook and I will answer with citations."
89
+
90
+
91
+ @dataclass
92
+ class Triage:
93
+ kind: str
94
+ question: str
95
+ depth: str
96
+ reply: str
97
+ prompt_tokens: int = 0
98
+ completion_tokens: int = 0
99
+
100
+
101
+ def triage(message: str, history: list[Turn], notebook: str) -> Triage:
102
+ """One cheap call: is this worth researching, and what exactly should be researched?"""
103
+ lm = triage_lm()
104
+ try:
105
+ with track_usage(lm) as usage, dspy.context(lm=lm):
106
+ pred = dspy.Predict(TriageMessage)(conversation=conversation_text(history), message=message,
107
+ notebook=notebook)
108
+ except Exception:
109
+ # If triage fails, fall back to researching the raw message rather than failing the chat.
110
+ return Triage("archive", message, "deep", "")
111
+ kind = pred.kind if pred.kind in {"archive", "chitchat", "about_app", "off_topic"} else "archive"
112
+ question = (pred.standalone_question or "").strip() or message
113
+ reply = strip_em_dashes((pred.reply or "").strip())
114
+ if kind != "archive" and not reply:
115
+ kind = "archive" # nothing to say without research: research it
116
+ return Triage(kind, question, pred.depth if pred.depth in {"quick", "deep"} else "deep", reply,
117
+ usage.prompt_tokens, usage.completion_tokens)
118
+
119
+
120
+ def notebook_brief(title: str, allowed: dict[str, str]) -> str:
121
+ titles = list(allowed.values())[:25]
122
+ return f"Notebook: {title}. {len(allowed)} posts, including: " + "; ".join(titles)
123
 
124
 
125
  def archive_guide(tools: ScopedTools) -> str:
 
160
  allowed: dict[str, str],
161
  question: str,
162
  on_step: Callable[[str, str], None] | None = None,
163
+ history: list[Turn] | None = None,
164
+ notebook_title: str = "",
165
  ) -> ResearchResult:
166
+ history = history or []
167
  if not allowed:
168
+ return ResearchResult("This notebook has no posts yet. Add some sources first.", True, [], kind="chitchat")
169
+
170
+ canned = instant_reply(question, history)
171
+ if canned:
172
+ return ResearchResult(canned, False, [], kind="chitchat", question=question)
173
+
174
+ if on_step:
175
+ on_step("think", "Reading the conversation")
176
+ route = triage(question, history, notebook_brief(notebook_title, allowed))
177
+ if route.kind != "archive":
178
+ return ResearchResult(route.reply, False, [], route.prompt_tokens, route.completion_tokens,
179
+ kind=route.kind, question=question)
180
+
181
  corpus.sync()
182
  steps: list[tuple[str, str]] = []
183
 
 
187
  on_step(kind, detail)
188
 
189
  tools = ScopedTools(corpus, allowed, on_step=log)
190
+ budget = min(QUICK_STEPS, settings.research_max_steps) if route.depth == "quick" else settings.research_max_steps
191
  agent = dspy.ReAct(
192
  ResearchArchive,
193
  tools=[tools.list_files, tools.search, tools.read],
194
+ max_iters=budget,
195
  )
196
  lm = main_lm()
197
  with track_usage(lm) as usage, dspy.context(lm=lm):
198
+ pred = agent(question=route.question, conversation=conversation_text(history),
199
+ archive_guide=archive_guide(tools))
200
 
201
  text, cites = verify_citations(tools, pred.answer, pred.citations or [])
202
  unsupported = bool(pred.unsupported) or not cites
 
207
  text=strip_em_dashes(text),
208
  unsupported=unsupported,
209
  citations=cites,
210
+ prompt_tokens=usage.prompt_tokens + route.prompt_tokens,
211
+ completion_tokens=usage.completion_tokens + route.completion_tokens,
212
  steps=steps,
213
+ kind="archive",
214
+ question=route.question,
215
  )
backend/app/routers/notebooks.py CHANGED
@@ -13,7 +13,7 @@ from app.config import settings
13
  from app.corpus import Corpus
14
  from app.llm.provider import provider_name
15
  from app.models import Artifact, Chat, Citation, Document, DocumentTopic, Message, Notebook, NotebookDocument, Topic
16
- from app.pipeline.research import research
17
  from app.routers.sources import serialize_document
18
  from app.services.artifacts import latest_jobs, serialize_artifact
19
  from app.services.jobs import record_usage
@@ -219,7 +219,7 @@ def _sse(event: str, data: dict) -> str:
219
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
220
 
221
 
222
- _STEP_LABEL = {"list": "Scanning the archive", "search": "Searching for", "read": "Reading"}
223
 
224
 
225
  @router.post("/{notebook_id}/chat")
@@ -232,6 +232,11 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
232
  db.add(chat_row)
233
  db.flush()
234
  chat_row.updated_at = datetime.now(UTC) # keeps the history list newest first
 
 
 
 
 
235
  db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
236
  db.commit()
237
 
@@ -252,7 +257,8 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
252
  def on_step(kind: str, detail: str) -> None:
253
  loop.call_soon_threadsafe(steps.put_nowait, (kind, detail))
254
 
255
- task = asyncio.create_task(asyncio.to_thread(research, corpus, allowed, body.question, on_step))
 
256
  while not task.done() or not steps.empty():
257
  try:
258
  kind, detail = await asyncio.wait_for(steps.get(), timeout=0.25)
 
13
  from app.corpus import Corpus
14
  from app.llm.provider import provider_name
15
  from app.models import Artifact, Chat, Citation, Document, DocumentTopic, Message, Notebook, NotebookDocument, Topic
16
+ from app.pipeline.research import Turn, research
17
  from app.routers.sources import serialize_document
18
  from app.services.artifacts import latest_jobs, serialize_artifact
19
  from app.services.jobs import record_usage
 
219
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
220
 
221
 
222
+ _STEP_LABEL = {"list": "Scanning the archive", "search": "Searching for", "read": "Reading", "think": ""}
223
 
224
 
225
  @router.post("/{notebook_id}/chat")
 
232
  db.add(chat_row)
233
  db.flush()
234
  chat_row.updated_at = datetime.now(UTC) # keeps the history list newest first
235
+ # Memory: the chat so far (before this message), oldest first, capped to the last few turns.
236
+ prior = db.scalars(
237
+ select(Message).where(Message.chat_id == chat_row.id).order_by(Message.created_at.desc()).limit(8)
238
+ ).all()
239
+ history = [Turn(m.role, m.content) for m in reversed(prior)]
240
  db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
241
  db.commit()
242
 
 
257
  def on_step(kind: str, detail: str) -> None:
258
  loop.call_soon_threadsafe(steps.put_nowait, (kind, detail))
259
 
260
+ task = asyncio.create_task(asyncio.to_thread(
261
+ research, corpus, allowed, body.question, on_step, history, nb.title))
262
  while not task.done() or not steps.empty():
263
  try:
264
  kind, detail = await asyncio.wait_for(steps.get(), timeout=0.25)
frontend/src/pages/TopicMap.tsx CHANGED
@@ -1,22 +1,138 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
2
- import { useNavigate } from "react-router-dom";
 
 
 
 
 
 
 
 
 
3
  import { notebooksApi, topicsApi } from "../api/endpoints";
4
- import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types";
5
  import { Reader } from "../components/Reader";
6
- import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui";
7
  import { useJob } from "../hooks/useJob";
8
  import { playClick } from "../lib/sound";
9
 
10
- const W = 900;
11
- const H = 620;
 
 
12
 
13
- type P = { id: string; x: number; y: number; vx: number; vy: number; r: number };
14
  type View = { x: number; y: number; w: number; h: number };
 
15
 
16
  const HOME: View = { x: 0, y: 0, w: W, h: H };
17
  const MIN_W = W / 8;
18
  const MAX_W = W * 1.6;
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  /** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */
21
  function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
22
  const [view, setView] = useState<View>(HOME);
@@ -33,7 +149,6 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
33
  const scaleView = (v: View, factor: number, fx: number, fy: number): View => {
34
  const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor));
35
  const h = (w / W) * H;
36
- // Keep the focus point fixed on screen while scaling.
37
  return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h };
38
  };
39
 
@@ -56,7 +171,7 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
56
  });
57
 
58
  const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
59
- if ((e.target as Element).closest(".map-node")) return;
60
  drag.current = { px: e.clientX, py: e.clientY, view, moved: false };
61
  svgRef.current?.setPointerCapture(e.pointerId);
62
  };
@@ -79,12 +194,10 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
79
  zoomAt(0.6, p.x, p.y);
80
  };
81
  const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h }));
82
- const reset = () => {
83
- setView(HOME);
84
- };
85
- const focusOn = (x: number, y: number) =>
86
- setView((v) => {
87
- const w = Math.min(v.w, W / 2);
88
  const h = (w / W) * H;
89
  return { x: x - w / 2, y: y - h / 2, w, h };
90
  });
@@ -99,65 +212,66 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
99
  };
100
  }
101
 
102
- /** Small deterministic force layout: repulsion between all nodes, springs along edges, pull to center. */
103
- function layout(data: TopicMapData): Map<string, P> {
104
- const maxCount = Math.max(1, ...data.nodes.map((n) => n.post_count));
105
- const nodes: P[] = data.nodes.map((n, i) => {
106
- const a = (i / Math.max(1, data.nodes.length)) * Math.PI * 2 * 2.39996;
107
- const rad = 60 + (i % 7) * 30;
108
- return { id: n.id, x: W / 2 + Math.cos(a) * rad, y: H / 2 + Math.sin(a) * rad, vx: 0, vy: 0, r: 6 + 22 * Math.sqrt(n.post_count / maxCount) };
109
- });
110
- const byId = new Map(nodes.map((n) => [n.id, n]));
111
- const maxW = Math.max(1, ...data.edges.map((e) => e.weight));
112
- for (let step = 0; step < 320; step++) {
113
- const cool = 1 - step / 320;
114
- for (let i = 0; i < nodes.length; i++) {
115
- for (let j = i + 1; j < nodes.length; j++) {
116
- const a = nodes[i];
117
- const b = nodes[j];
118
- let dx = a.x - b.x;
119
- let dy = a.y - b.y;
120
- let d2 = dx * dx + dy * dy;
121
- if (d2 < 0.01) {
122
- dx = 0.1;
123
- dy = 0.1;
124
- d2 = 0.02;
125
- }
126
- const minD = a.r + b.r + 26;
127
- const f = (2400 / d2) * (d2 < minD * minD ? 3 : 1);
128
- const d = Math.sqrt(d2);
129
- a.vx += (dx / d) * f;
130
- a.vy += (dy / d) * f;
131
- b.vx -= (dx / d) * f;
132
- b.vy -= (dy / d) * f;
133
- }
134
- }
135
- for (const e of data.edges) {
136
- const a = byId.get(e.source);
137
- const b = byId.get(e.target);
138
- if (!a || !b) continue;
139
- const dx = b.x - a.x;
140
- const dy = b.y - a.y;
141
- const d = Math.sqrt(dx * dx + dy * dy) || 1;
142
- const target = 110 - 40 * (e.weight / maxW);
143
- const f = (d - target) * 0.02 * (0.5 + e.weight / maxW);
144
- a.vx += (dx / d) * f;
145
- a.vy += (dy / d) * f;
146
- b.vx -= (dx / d) * f;
147
- b.vy -= (dy / d) * f;
148
- }
149
- for (const n of nodes) {
150
- n.vx += (W / 2 - n.x) * 0.004;
151
- n.vy += (H / 2 - n.y) * 0.004;
152
- n.x += Math.max(-12, Math.min(12, n.vx)) * cool;
153
- n.y += Math.max(-12, Math.min(12, n.vy)) * cool;
154
- n.x = Math.max(n.r + 10, Math.min(W - n.r - 10, n.x));
155
- n.y = Math.max(n.r + 10, Math.min(H - n.r - 10, n.y));
156
- n.vx *= 0.6;
157
- n.vy *= 0.6;
158
- }
159
- }
160
- return byId;
 
161
  }
162
 
163
  export default function TopicMap() {
@@ -165,6 +279,7 @@ export default function TopicMap() {
165
  const [job, setJob] = useState<Job | null>(null);
166
  const [selected, setSelected] = useState<TopicDetail | null>(null);
167
  const [hover, setHover] = useState<string | null>(null);
 
168
  const [reading, setReading] = useState<string | null>(null);
169
  const [error, setError] = useState<string | null>(null);
170
  const navigate = useNavigate();
@@ -179,7 +294,8 @@ export default function TopicMap() {
179
  if (j.status === "done") load();
180
  });
181
 
182
- const positions = useMemo(() => (data ? layout(data) : new Map<string, P>()), [data]);
 
183
  const neighbors = useMemo(() => {
184
  const m = new Map<string, Set<string>>();
185
  data?.edges.forEach((e) => {
@@ -190,9 +306,38 @@ export default function TopicMap() {
190
  });
191
  return m;
192
  }, [data]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
  const focus = hover ?? selected?.id ?? null;
195
  const running = live && (live.status === "queued" || live.status === "running");
 
 
 
196
 
197
  const rebuild = async (full: boolean) => {
198
  setError(null);
@@ -211,16 +356,38 @@ export default function TopicMap() {
211
  }
212
  setSelected(await topicsApi.get(id));
213
  };
214
- const scale = view.w / W; // keeps labels and strokes a constant size on screen
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
  return (
217
  <div className="page-wrap">
218
  <PageHeader eyebrow="Topic map" title="The constellations in your archive">
219
- <button className="btn" disabled={Boolean(running)} onClick={() => rebuild(false)}>
 
 
 
 
 
 
 
 
220
  {data?.has_untagged_posts ? "Map new posts" : "Refresh"}
221
  </button>
222
- <button className="btn" disabled={Boolean(running)} onClick={() => rebuild(true)}>
223
- Rebuild from scratch
224
  </button>
225
  </PageHeader>
226
  {live && (running || live.status === "failed") && <JobProgress job={live} />}
@@ -251,7 +418,46 @@ export default function TopicMap() {
251
  1:1
252
  </button>
253
  </div>
254
- <p className="map-hint mono muted">Scroll to zoom · drag to pan · double click to dive in</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  <svg
256
  ref={svgRef}
257
  viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
@@ -259,6 +465,7 @@ export default function TopicMap() {
259
  aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
260
  tabIndex={0}
261
  className="map-svg"
 
262
  {...handlers}
263
  onKeyDown={(e) => {
264
  const step = 0.12;
@@ -274,17 +481,45 @@ export default function TopicMap() {
274
  }}
275
  >
276
  <defs>
277
- <radialGradient id="star" cx="50%" cy="50%" r="50%">
278
- <stop offset="0%" stopColor="#ffffff" />
279
- <stop offset="55%" stopColor="#217cff" stopOpacity="0.9" />
280
  <stop offset="100%" stopColor="#217cff" stopOpacity="0" />
281
  </radialGradient>
 
 
 
 
 
 
 
 
 
282
  </defs>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  {data.edges.map((e) => {
284
  const a = positions.get(e.source);
285
  const b = positions.get(e.target);
286
  if (!a || !b) return null;
287
  const lit = focus && (e.source === focus || e.target === focus);
 
288
  return (
289
  <line
290
  key={`${e.source}-${e.target}`}
@@ -292,64 +527,226 @@ export default function TopicMap() {
292
  y1={a.y}
293
  x2={b.x}
294
  y2={b.y}
 
295
  stroke={lit ? "#217cff" : "#ffffff"}
296
- strokeOpacity={lit ? 0.9 : focus ? 0.05 : 0.14}
297
- strokeWidth={(lit ? 1.6 : 1) * scale}
 
298
  />
299
  );
300
  })}
 
301
  {data.nodes.map((n) => {
302
  const p = positions.get(n.id)!;
303
  const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id);
304
  const active = selected?.id === n.id;
 
 
 
 
 
305
  return (
306
  <g
307
  key={n.id}
308
  className="map-node"
309
- opacity={dim ? 0.25 : 1}
310
  onMouseEnter={() => setHover(n.id)}
311
  onMouseLeave={() => setHover(null)}
312
  onClick={() => open(n.id)}
313
  onDoubleClick={() => open(n.id, true)}
314
  tabIndex={0}
315
  role="button"
316
- aria-label={`${n.name}, ${n.post_count} posts`}
317
  onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open(n.id)}
318
  >
319
- <circle cx={p.x} cy={p.y} r={p.r * 1.9} fill="url(#star)" opacity={active ? 0.9 : 0.45} />
320
- <circle cx={p.x} cy={p.y} r={Math.max(3, p.r * 0.45)} fill="#ffffff" />
321
- <text x={p.x} y={p.y + p.r + 16 * scale} textAnchor="middle" className="map-label" style={{ fontSize: 12 * scale, strokeWidth: 4 * scale }}>
322
- {n.name}
323
- </text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  </g>
325
  );
326
  })}
327
  </svg>
328
  </div>
 
329
  <aside className="card map-side">
330
  {!selected && (
331
  <>
332
- <p className="eyebrow">{data.nodes.length} topics</p>
333
- <p className="muted">Click a star to see its posts. Brighter lines mean topics that appear together more often.</p>
334
- <ol className="topic-rank">
335
- {data.nodes.slice(0, 12).map((n) => (
336
- <li key={n.id}>
337
- <button className="link-btn" onClick={() => open(n.id, true)}>
338
- {n.name}
339
- </button>
340
- <span className="mono muted"> {n.post_count}</span>
341
- </li>
342
- ))}
343
- </ol>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  </>
345
  )}
 
346
  {selected && (
347
  <>
348
- <button className="link-btn mono muted" onClick={() => setSelected(null)}>
349
- All topics
350
  </button>
351
- <h2>{selected.name}</h2>
 
 
 
352
  {selected.summary && <p className="muted">{selected.summary}</p>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  <button
354
  className="btn btn-small btn-primary"
355
  onClick={async () => {
 
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type MouseEvent as ReactMouseEvent,
8
+ type PointerEvent as ReactPointerEvent,
9
+ type RefObject,
10
+ } from "react";
11
+ import { Link, useNavigate } from "react-router-dom";
12
  import { notebooksApi, topicsApi } from "../api/endpoints";
13
+ import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types";
14
  import { Reader } from "../components/Reader";
15
+ import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui";
16
  import { useJob } from "../hooks/useJob";
17
  import { playClick } from "../lib/sound";
18
 
19
+ // Virtual canvas. The layout runs in open space and is then fitted into this box with a margin.
20
+ const W = 1200;
21
+ const H = 780;
22
+ const PAD = 90;
23
 
24
+ type P = { id: string; x: number; y: number; vx: number; vy: number; r: number; galaxy: number };
25
  type View = { x: number; y: number; w: number; h: number };
26
+ type ColorMode = "recency" | "momentum";
27
 
28
  const HOME: View = { x: 0, y: 0, w: W, h: H };
29
  const MIN_W = W / 8;
30
  const MAX_W = W * 1.6;
31
 
32
+ // Deterministic pseudo random, so the map looks the same on every visit.
33
+ function seeded(n: number) {
34
+ const x = Math.sin(n * 12.9898) * 43758.5453;
35
+ return x - Math.floor(x);
36
+ }
37
+
38
+ /** Force layout with generous spacing: strong repulsion (room for labels), long constellation lines,
39
+ * a pull toward each galaxy's centre so related topics cluster, then fitted to the canvas. */
40
+ function layout(data: TopicMapData): Map<string, P> {
41
+ const maxCount = Math.max(1, ...data.nodes.map((n) => n.post_count));
42
+ const nodes: P[] = data.nodes.map((n, i) => {
43
+ const a = i * 2.39996;
44
+ const rad = 80 + Math.sqrt(i) * 70;
45
+ return {
46
+ id: n.id,
47
+ x: Math.cos(a) * rad,
48
+ y: Math.sin(a) * rad,
49
+ vx: 0,
50
+ vy: 0,
51
+ r: 5 + 20 * Math.sqrt(n.post_count / maxCount),
52
+ galaxy: n.galaxy,
53
+ };
54
+ });
55
+ const byId = new Map(nodes.map((n) => [n.id, n]));
56
+ const maxW = Math.max(1, ...data.edges.map((e) => e.weight));
57
+ const STEPS = 420;
58
+ for (let step = 0; step < STEPS; step++) {
59
+ const cool = 1 - step / STEPS;
60
+ for (let i = 0; i < nodes.length; i++) {
61
+ for (let j = i + 1; j < nodes.length; j++) {
62
+ const a = nodes[i];
63
+ const b = nodes[j];
64
+ let dx = a.x - b.x;
65
+ let dy = a.y - b.y;
66
+ let d2 = dx * dx + dy * dy;
67
+ if (d2 < 0.01) {
68
+ dx = 0.1;
69
+ dy = 0.1;
70
+ d2 = 0.02;
71
+ }
72
+ const minD = a.r + b.r + 90; // room for two labels side by side
73
+ const k = a.galaxy === b.galaxy ? 7000 : 11000; // other galaxies push harder, leaving voids between them
74
+ const f = (k / d2) * (d2 < minD * minD ? 3 : 1);
75
+ const d = Math.sqrt(d2);
76
+ a.vx += (dx / d) * f;
77
+ a.vy += (dy / d) * f;
78
+ b.vx -= (dx / d) * f;
79
+ b.vy -= (dy / d) * f;
80
+ }
81
+ }
82
+ for (const e of data.edges) {
83
+ const a = byId.get(e.source);
84
+ const b = byId.get(e.target);
85
+ if (!a || !b) continue;
86
+ const dx = b.x - a.x;
87
+ const dy = b.y - a.y;
88
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
89
+ const target = 190 - 70 * (e.weight / maxW);
90
+ const f = (d - target) * 0.012 * (0.4 + e.weight / maxW);
91
+ a.vx += (dx / d) * f;
92
+ a.vy += (dy / d) * f;
93
+ b.vx -= (dx / d) * f;
94
+ b.vy -= (dy / d) * f;
95
+ }
96
+ // Galaxy cohesion.
97
+ const centers = new Map<number, { x: number; y: number; n: number }>();
98
+ for (const n of nodes) {
99
+ const c = centers.get(n.galaxy) ?? { x: 0, y: 0, n: 0 };
100
+ c.x += n.x;
101
+ c.y += n.y;
102
+ c.n += 1;
103
+ centers.set(n.galaxy, c);
104
+ }
105
+ for (const n of nodes) {
106
+ const c = centers.get(n.galaxy)!;
107
+ if (c.n > 1) {
108
+ n.vx += (c.x / c.n - n.x) * 0.006;
109
+ n.vy += (c.y / c.n - n.y) * 0.006;
110
+ }
111
+ n.vx += -n.x * 0.0012; // gentle gravity toward the origin
112
+ n.vy += -n.y * 0.0012;
113
+ n.x += Math.max(-16, Math.min(16, n.vx)) * cool;
114
+ n.y += Math.max(-16, Math.min(16, n.vy)) * cool;
115
+ n.vx *= 0.6;
116
+ n.vy *= 0.6;
117
+ }
118
+ }
119
+ // Fit into the canvas with a margin, keeping proportions; never enlarge a small map past 1.4x.
120
+ const xs = nodes.map((n) => n.x);
121
+ const ys = nodes.map((n) => n.y);
122
+ const minX = Math.min(...xs);
123
+ const maxX = Math.max(...xs);
124
+ const minY = Math.min(...ys);
125
+ const maxY = Math.max(...ys);
126
+ const s = Math.min((W - PAD * 2) / Math.max(1, maxX - minX), (H - PAD * 2) / Math.max(1, maxY - minY), 1.4);
127
+ const ox = (W - (maxX - minX) * s) / 2 - minX * s;
128
+ const oy = (H - (maxY - minY) * s) / 2 - minY * s;
129
+ for (const n of nodes) {
130
+ n.x = n.x * s + ox;
131
+ n.y = n.y * s + oy;
132
+ }
133
+ return byId;
134
+ }
135
+
136
  /** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */
137
  function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
138
  const [view, setView] = useState<View>(HOME);
 
149
  const scaleView = (v: View, factor: number, fx: number, fy: number): View => {
150
  const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor));
151
  const h = (w / W) * H;
 
152
  return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h };
153
  };
154
 
 
171
  });
172
 
173
  const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
174
+ if ((e.target as Element).closest(".map-node, .nebula")) return;
175
  drag.current = { px: e.clientX, py: e.clientY, view, moved: false };
176
  svgRef.current?.setPointerCapture(e.pointerId);
177
  };
 
194
  zoomAt(0.6, p.x, p.y);
195
  };
196
  const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h }));
197
+ const reset = () => setView(HOME);
198
+ const focusOn = (x: number, y: number, width = W / 2) =>
199
+ setView(() => {
200
+ const w = Math.max(MIN_W, Math.min(W, width));
 
 
201
  const h = (w / W) * H;
202
  return { x: x - w / 2, y: y - h / 2, w, h };
203
  });
 
212
  };
213
  }
214
 
215
+ // Small pieces
216
+
217
+ function Sparkline({ values, height = 36, highlightFrom }: { values: number[]; height?: number; highlightFrom?: number }) {
218
+ const max = Math.max(1, ...values);
219
+ const bw = 100 / values.length;
220
+ return (
221
+ <svg className="sparkline" viewBox={`0 0 100 ${height}`} preserveAspectRatio="none" aria-hidden="true">
222
+ {values.map((v, i) => {
223
+ const h = v ? Math.max(2, (v / max) * (height - 2)) : 1;
224
+ return (
225
+ <rect
226
+ key={i}
227
+ x={i * bw + bw * 0.15}
228
+ y={height - h}
229
+ width={bw * 0.7}
230
+ height={h}
231
+ rx={0.8}
232
+ className={highlightFrom !== undefined && i >= highlightFrom ? "recent" : ""}
233
+ />
234
+ );
235
+ })}
236
+ </svg>
237
+ );
238
+ }
239
+
240
+ const STATUS_LABEL: Record<TopicNode["status"], string> = {
241
+ rising: "Rising",
242
+ steady: "Steady orbit",
243
+ dormant: "Dormant",
244
+ };
245
+
246
+ function momentumText(m: number) {
247
+ if (!Number.isFinite(m) || m === 0) return "no recent posts";
248
+ return m >= 1 ? `${m.toFixed(1)}x its usual share lately` : `${Math.round(m * 100)}% of its usual share lately`;
249
+ }
250
+
251
+ /** Deep field: faint background stars plus survey rings, drawn once. */
252
+ function DeepField() {
253
+ const stars = useMemo(
254
+ () =>
255
+ Array.from({ length: 260 }, (_, i) => ({
256
+ x: seeded(i + 1) * W,
257
+ y: seeded(i + 1000) * H,
258
+ r: 0.3 + seeded(i + 2000) * 1.1,
259
+ o: 0.15 + seeded(i + 3000) * 0.5,
260
+ })),
261
+ [],
262
+ );
263
+ return (
264
+ <g className="deep-field" aria-hidden="true">
265
+ {[140, 260, 390, 520].map((r) => (
266
+ <circle key={r} cx={W / 2} cy={H / 2} r={r} className="survey-ring" />
267
+ ))}
268
+ <line x1={W / 2} y1={0} x2={W / 2} y2={H} className="survey-axis" />
269
+ <line x1={0} y1={H / 2} x2={W} y2={H / 2} className="survey-axis" />
270
+ {stars.map((s, i) => (
271
+ <circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#ffffff" opacity={s.o} />
272
+ ))}
273
+ </g>
274
+ );
275
  }
276
 
277
  export default function TopicMap() {
 
279
  const [job, setJob] = useState<Job | null>(null);
280
  const [selected, setSelected] = useState<TopicDetail | null>(null);
281
  const [hover, setHover] = useState<string | null>(null);
282
+ const [mode, setMode] = useState<ColorMode>("recency");
283
  const [reading, setReading] = useState<string | null>(null);
284
  const [error, setError] = useState<string | null>(null);
285
  const navigate = useNavigate();
 
294
  if (j.status === "done") load();
295
  });
296
 
297
+ const positions = useMemo(() => (data?.nodes.length ? layout(data) : new Map<string, P>()), [data]);
298
+ const byId = useMemo(() => new Map((data?.nodes ?? []).map((n) => [n.id, n])), [data]);
299
  const neighbors = useMemo(() => {
300
  const m = new Map<string, Set<string>>();
301
  data?.edges.forEach((e) => {
 
306
  });
307
  return m;
308
  }, [data]);
309
+ // Nebula for each galaxy with more than one star: centred on its members, sized to cover them.
310
+ const nebulae = useMemo(() => {
311
+ if (!data) return [];
312
+ return data.galaxies
313
+ .filter((g) => g.size > 1)
314
+ .map((g) => {
315
+ const pts = g.topic_ids.map((id) => positions.get(id)).filter(Boolean) as P[];
316
+ const cx = pts.reduce((s, p) => s + p.x, 0) / Math.max(pts.length, 1);
317
+ const cy = pts.reduce((s, p) => s + p.y, 0) / Math.max(pts.length, 1);
318
+ const rx = Math.max(90, ...pts.map((p) => Math.abs(p.x - cx) + p.r)) + 50;
319
+ const ry = Math.max(80, ...pts.map((p) => Math.abs(p.y - cy) + p.r)) + 45;
320
+ return { ...g, cx, cy, rx, ry };
321
+ });
322
+ }, [data, positions]);
323
+
324
+ const brightest = useMemo(
325
+ () => new Set((data?.nodes ?? []).slice().sort((a, b) => b.post_count - a.post_count).slice(0, 3).map((n) => n.id)),
326
+ [data],
327
+ );
328
+ const maxEdge = Math.max(1, ...(data?.edges ?? []).map((e) => e.weight));
329
+ const recentBucket = useMemo(() => {
330
+ const a = data?.archive;
331
+ if (!a?.start || !a.end || !a.recent_from) return undefined;
332
+ const span = +new Date(a.end) - +new Date(a.start) || 1;
333
+ return Math.floor(((+new Date(a.recent_from) - +new Date(a.start)) / span) * 12);
334
+ }, [data]);
335
 
336
  const focus = hover ?? selected?.id ?? null;
337
  const running = live && (live.status === "queued" || live.status === "running");
338
+ const scale = view.w / W; // keeps labels and strokes a constant size on screen
339
+ const zoomedIn = scale < 0.75;
340
+ const hovered = hover ? byId.get(hover) : null;
341
 
342
  const rebuild = async (full: boolean) => {
343
  setError(null);
 
356
  }
357
  setSelected(await topicsApi.get(id));
358
  };
359
+
360
+ const flyToGalaxy = (gid: number) => {
361
+ const n = nebulae.find((g) => g.id === gid);
362
+ if (n) focusOn(n.cx, n.cy, Math.max(n.rx * 2.6, (n.ry * 2.6 * W) / H));
363
+ };
364
+
365
+ const starColor = (n: TopicNode) => {
366
+ if (mode === "momentum") return n.status === "rising" ? "#ffffff" : n.status === "dormant" ? "rgba(255,255,255,0.45)" : "#217cff";
367
+ // recency: fresh topics burn white, old ones cool toward dim blue
368
+ return n.recency > 0.75 ? "#ffffff" : n.recency > 0.4 ? "#9cc4ff" : "#217cff";
369
+ };
370
+ const starGlow = (n: TopicNode) =>
371
+ mode === "momentum" ? (n.status === "rising" ? 1 : n.status === "dormant" ? 0.25 : 0.55) : 0.25 + 0.75 * n.recency;
372
+
373
+ const galaxyCount = data ? data.galaxies.filter((g) => g.size > 1).length : 0;
374
 
375
  return (
376
  <div className="page-wrap">
377
  <PageHeader eyebrow="Topic map" title="The constellations in your archive">
378
+ <Tabs<ColorMode>
379
+ tabs={[
380
+ { id: "recency", label: "Recency" },
381
+ { id: "momentum", label: "Momentum" },
382
+ ]}
383
+ value={mode}
384
+ onChange={setMode}
385
+ />
386
+ <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}>
387
  {data?.has_untagged_posts ? "Map new posts" : "Refresh"}
388
  </button>
389
+ <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(true)}>
390
+ Rebuild
391
  </button>
392
  </PageHeader>
393
  {live && (running || live.status === "failed") && <JobProgress job={live} />}
 
418
  1:1
419
  </button>
420
  </div>
421
+
422
+ <div className="map-hud mono" aria-live="polite">
423
+ {hovered ? (
424
+ <>
425
+ <span className="hud-title">{hovered.name}</span>
426
+ <span>
427
+ {hovered.post_count} posts · last {formatDate(hovered.last_at)} · {STATUS_LABEL[hovered.status]}
428
+ </span>
429
+ <Sparkline values={hovered.timeline} height={18} highlightFrom={recentBucket} />
430
+ </>
431
+ ) : (
432
+ <span className="muted">
433
+ {data.nodes.length} stars · {galaxyCount} galaxies · scroll to zoom, drag to pan
434
+ </span>
435
+ )}
436
+ </div>
437
+
438
+ <div className="map-legend mono" aria-label="Legend">
439
+ <span>
440
+ <i className="lg-size" /> size = posts
441
+ </span>
442
+ {mode === "recency" ? (
443
+ <span>
444
+ <i className="lg-bright" /> brighter = written about recently
445
+ </span>
446
+ ) : (
447
+ <>
448
+ <span>
449
+ <i className="lg-tail" /> rising
450
+ </span>
451
+ <span>
452
+ <i className="lg-dormant" /> dormant
453
+ </span>
454
+ </>
455
+ )}
456
+ <span>
457
+ <i className="lg-line" /> lines = shared posts
458
+ </span>
459
+ </div>
460
+
461
  <svg
462
  ref={svgRef}
463
  viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`}
 
465
  aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
466
  tabIndex={0}
467
  className="map-svg"
468
+ style={{ aspectRatio: `${W} / ${H}` }}
469
  {...handlers}
470
  onKeyDown={(e) => {
471
  const step = 0.12;
 
481
  }}
482
  >
483
  <defs>
484
+ <radialGradient id="star-glow" cx="50%" cy="50%" r="50%">
485
+ <stop offset="0%" stopColor="#ffffff" stopOpacity="0.9" />
486
+ <stop offset="30%" stopColor="#217cff" stopOpacity="0.55" />
487
  <stop offset="100%" stopColor="#217cff" stopOpacity="0" />
488
  </radialGradient>
489
+ <radialGradient id="nebula" cx="50%" cy="50%" r="50%">
490
+ <stop offset="0%" stopColor="#217cff" stopOpacity="0.16" />
491
+ <stop offset="60%" stopColor="#217cff" stopOpacity="0.06" />
492
+ <stop offset="100%" stopColor="#217cff" stopOpacity="0" />
493
+ </radialGradient>
494
+ <linearGradient id="comet" x1="0" y1="0" x2="1" y2="0">
495
+ <stop offset="0%" stopColor="#ffffff" stopOpacity="0.85" />
496
+ <stop offset="100%" stopColor="#217cff" stopOpacity="0" />
497
+ </linearGradient>
498
  </defs>
499
+
500
+ <DeepField />
501
+
502
+ {nebulae.map((g) => (
503
+ <g key={g.id} className="nebula" onClick={() => flyToGalaxy(g.id)}>
504
+ <ellipse cx={g.cx} cy={g.cy} rx={g.rx} ry={g.ry} fill="url(#nebula)" />
505
+ <text
506
+ x={g.cx}
507
+ y={g.cy - g.ry + 18 * scale}
508
+ textAnchor="middle"
509
+ className="galaxy-label"
510
+ style={{ fontSize: 11 * scale, letterSpacing: 3 * scale }}
511
+ >
512
+ {g.name.toUpperCase()} GALAXY
513
+ </text>
514
+ </g>
515
+ ))}
516
+
517
  {data.edges.map((e) => {
518
  const a = positions.get(e.source);
519
  const b = positions.get(e.target);
520
  if (!a || !b) return null;
521
  const lit = focus && (e.source === focus || e.target === focus);
522
+ const strength = e.weight / maxEdge;
523
  return (
524
  <line
525
  key={`${e.source}-${e.target}`}
 
527
  y1={a.y}
528
  x2={b.x}
529
  y2={b.y}
530
+ className="constellation"
531
  stroke={lit ? "#217cff" : "#ffffff"}
532
+ strokeOpacity={lit ? 0.95 : focus ? 0.04 : 0.08 + strength * 0.3}
533
+ strokeWidth={(lit ? 1.4 + strength * 2 : 0.6 + strength * 1.8) * scale}
534
+ strokeDasharray={strength < 0.25 && !lit ? `${3 * scale} ${4 * scale}` : undefined}
535
  />
536
  );
537
  })}
538
+
539
  {data.nodes.map((n) => {
540
  const p = positions.get(n.id)!;
541
  const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id);
542
  const active = selected?.id === n.id;
543
+ const color = starColor(n);
544
+ const glow = starGlow(n);
545
+ const showLabel =
546
+ zoomedIn || brightest.has(n.id) || n.post_count >= 3 || focus === n.id || active || Boolean(focus && neighbors.get(focus)?.has(n.id));
547
+ const tail = mode === "momentum" && n.status === "rising" ? Math.min(90, 30 + n.momentum * 18) : 0;
548
  return (
549
  <g
550
  key={n.id}
551
  className="map-node"
552
+ opacity={dim ? 0.2 : 1}
553
  onMouseEnter={() => setHover(n.id)}
554
  onMouseLeave={() => setHover(null)}
555
  onClick={() => open(n.id)}
556
  onDoubleClick={() => open(n.id, true)}
557
  tabIndex={0}
558
  role="button"
559
+ aria-label={`${n.name}, ${n.post_count} posts, ${STATUS_LABEL[n.status]}`}
560
  onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open(n.id)}
561
  >
562
+ {tail > 0 && (
563
+ <path
564
+ d={`M ${p.x} ${p.y - p.r * 0.35} L ${p.x + tail} ${p.y - tail * 0.45} L ${p.x + p.r * 0.35} ${p.y + p.r * 0.1} Z`}
565
+ fill="url(#comet)"
566
+ opacity={0.8}
567
+ />
568
+ )}
569
+ <circle cx={p.x} cy={p.y} r={p.r * 2.1} fill="url(#star-glow)" opacity={active ? 1 : glow} />
570
+ {brightest.has(n.id) && (
571
+ <g className="spikes" stroke={color} strokeOpacity={0.55} strokeWidth={1 * scale}>
572
+ <line x1={p.x - p.r * 2.4} y1={p.y} x2={p.x + p.r * 2.4} y2={p.y} />
573
+ <line x1={p.x} y1={p.y - p.r * 2.4} x2={p.x} y2={p.y + p.r * 2.4} />
574
+ </g>
575
+ )}
576
+ {mode === "momentum" && n.status === "dormant" && (
577
+ <circle
578
+ cx={p.x}
579
+ cy={p.y}
580
+ r={p.r * 0.9 + 4}
581
+ fill="none"
582
+ stroke="#ffffff"
583
+ strokeOpacity={0.5}
584
+ strokeWidth={1 * scale}
585
+ strokeDasharray={`${2 * scale} ${3 * scale}`}
586
+ />
587
+ )}
588
+ <circle cx={p.x} cy={p.y} r={Math.max(2.5, p.r * 0.42)} fill={color} />
589
+ {active && <circle cx={p.x} cy={p.y} r={p.r + 12} className="orbit-ring" strokeWidth={1.2 * scale} />}
590
+ {showLabel && (
591
+ <text
592
+ x={p.x}
593
+ y={p.y + p.r + 18 * scale}
594
+ textAnchor="middle"
595
+ className="map-label"
596
+ style={{ fontSize: 12.5 * scale, strokeWidth: 4 * scale }}
597
+ >
598
+ {n.name}
599
+ {(zoomedIn || active) && (
600
+ <tspan className="map-count" dx={6 * scale}>
601
+ {n.post_count}
602
+ </tspan>
603
+ )}
604
+ </text>
605
+ )}
606
  </g>
607
  );
608
  })}
609
  </svg>
610
  </div>
611
+
612
  <aside className="card map-side">
613
  {!selected && (
614
  <>
615
+ <p className="eyebrow">Mission briefing</p>
616
+ <p className="muted small">
617
+ {data.archive.posts} posts from {formatDate(data.archive.start)} to {formatDate(data.archive.end)}. Recent means since{" "}
618
+ {formatDate(data.archive.recent_from)}.
619
+ </p>
620
+
621
+ <section className="brief">
622
+ <h3>Galaxies</h3>
623
+ <ul className="brief-list">
624
+ {data.galaxies
625
+ .filter((g) => g.size > 1)
626
+ .slice(0, 6)
627
+ .map((g) => (
628
+ <li key={g.id}>
629
+ <button className="link-btn" onClick={() => flyToGalaxy(g.id)}>
630
+ {g.name}
631
+ </button>
632
+ <span className="mono muted">
633
+ {g.size} topics · {g.post_count} posts
634
+ </span>
635
+ </li>
636
+ ))}
637
+ {galaxyCount === 0 && <li className="muted small">No clusters yet. Map more posts.</li>}
638
+ </ul>
639
+ </section>
640
+
641
+ <section className="brief">
642
+ <h3>Rising</h3>
643
+ <ul className="brief-list">
644
+ {data.insights.rising.map((r) => (
645
+ <li key={r.id}>
646
+ <button className="link-btn" onClick={() => open(r.id, true)}>
647
+ {r.name}
648
+ </button>
649
+ <span className="mono brief-up">{r.momentum.toFixed(1)}x</span>
650
+ </li>
651
+ ))}
652
+ {data.insights.rising.length === 0 && <li className="muted small">Nothing is picking up speed right now.</li>}
653
+ </ul>
654
+ </section>
655
+
656
+ <section className="brief">
657
+ <h3>Dormant</h3>
658
+ <ul className="brief-list">
659
+ {data.insights.dormant.map((d) => (
660
+ <li key={d.id}>
661
+ <button className="link-btn" onClick={() => open(d.id, true)}>
662
+ {d.name}
663
+ </button>
664
+ <span className="mono muted">since {formatDate(d.last_at)}</span>
665
+ </li>
666
+ ))}
667
+ {data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>}
668
+ </ul>
669
+ {data.insights.dormant.length > 0 && (
670
+ <Link to="/app/resurface" className="small-link mono">
671
+ Resurface old posts
672
+ </Link>
673
+ )}
674
+ </section>
675
+
676
+ <section className="brief">
677
+ <h3>Strongest pairings</h3>
678
+ <ul className="brief-list">
679
+ {data.insights.pairs.map((p) => (
680
+ <li key={`${p.a_id}-${p.b_id}`}>
681
+ <span>
682
+ <button className="link-btn" onClick={() => open(p.a_id, true)}>
683
+ {p.a}
684
+ </button>
685
+ <span className="muted"> + </span>
686
+ <button className="link-btn" onClick={() => open(p.b_id, true)}>
687
+ {p.b}
688
+ </button>
689
+ </span>
690
+ <span className="mono muted">{p.posts} posts</span>
691
+ </li>
692
+ ))}
693
+ </ul>
694
+ </section>
695
  </>
696
  )}
697
+
698
  {selected && (
699
  <>
700
+ <button className="link-btn mono muted small" onClick={() => setSelected(null)}>
701
+ Back to briefing
702
  </button>
703
+ <div className="row between">
704
+ <h2>{selected.name}</h2>
705
+ <span className={`pill mono status-${selected.status}`}>{STATUS_LABEL[selected.status]}</span>
706
+ </div>
707
  {selected.summary && <p className="muted">{selected.summary}</p>}
708
+
709
+ <dl className="star-stats">
710
+ <div>
711
+ <dt>Posts</dt>
712
+ <dd>{selected.post_count}</dd>
713
+ </div>
714
+ <div>
715
+ <dt>Recent</dt>
716
+ <dd>{selected.recent_posts}</dd>
717
+ </div>
718
+ <div>
719
+ <dt>First</dt>
720
+ <dd>{formatDate(selected.first_at)}</dd>
721
+ </div>
722
+ <div>
723
+ <dt>Last</dt>
724
+ <dd>{formatDate(selected.last_at)}</dd>
725
+ </div>
726
+ </dl>
727
+ <p className="mono muted small">Momentum: {momentumText(selected.momentum)}</p>
728
+
729
+ <div className="timeline">
730
+ <Sparkline values={selected.timeline} height={44} highlightFrom={recentBucket} />
731
+ <div className="row between mono muted small">
732
+ <span>{formatDate(selected.timeline_start)}</span>
733
+ <span>{formatDate(selected.timeline_end)}</span>
734
+ </div>
735
+ </div>
736
+
737
+ {selected.related.length > 0 && (
738
+ <div className="stack">
739
+ <span className="mono muted small">Travels with</span>
740
+ <div className="chips">
741
+ {selected.related.map((r) => (
742
+ <button key={r.id} className="chip chip-btn" onClick={() => open(r.id, true)} title={`${r.shared_posts} shared posts`}>
743
+ {r.name} <span className="mono">{r.shared_posts}</span>
744
+ </button>
745
+ ))}
746
+ </div>
747
+ </div>
748
+ )}
749
+
750
  <button
751
  className="btn btn-small btn-primary"
752
  onClick={async () => {