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

launching

Browse files
backend/app/routers/topics.py CHANGED
@@ -1,5 +1,10 @@
 
 
 
1
  import uuid
2
- from collections import Counter
 
 
3
  from itertools import combinations
4
 
5
  from fastapi import APIRouter, Depends, HTTPException
@@ -12,33 +17,174 @@ from app.services.jobs import create_job, serialize_job
12
 
13
  router = APIRouter(prefix="/api/topics", tags=["topics"])
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  @router.get("")
17
  def topic_map(ctx: Ctx = Depends(get_ctx), min_posts: int = 1, limit: int = 80):
18
- """Nodes are topics sized by post count; edges join topics that share posts."""
19
  topics = ctx.db.scalars(
20
  select(Topic).where(Topic.workspace_id == ctx.workspace.id, Topic.post_count >= min_posts)
21
- .order_by(Topic.post_count.desc()).limit(min(limit, 300))
22
  ).all()
23
- ids = {t.id for t in topics}
24
- links = ctx.db.execute(
25
- select(DocumentTopic.document_id, DocumentTopic.topic_id).where(DocumentTopic.topic_id.in_(ids))
26
- ).all() if ids else []
27
- by_doc: dict[uuid.UUID, list[uuid.UUID]] = {}
28
- for doc_id, topic_id in links:
29
- by_doc.setdefault(doc_id, []).append(topic_id)
 
 
 
30
  edges: Counter[tuple[str, str]] = Counter()
31
  for tids in by_doc.values():
32
- for a, b in combinations(sorted(str(t) for t in tids), 2):
33
  edges[(a, b)] += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  untagged = ctx.db.scalar(
35
  select(Document.id).where(Document.workspace_id == ctx.workspace.id, Document.path.is_not(None),
36
  Document.id.not_in(select(DocumentTopic.document_id))).limit(1)
37
  )
38
  return {
39
- "nodes": [{"id": str(t.id), "name": t.name, "summary": t.summary, "post_count": t.post_count}
40
- for t in topics],
41
  "edges": [{"source": a, "target": b, "weight": w} for (a, b), w in edges.most_common(400)],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  "has_untagged_posts": untagged is not None,
43
  }
44
 
@@ -54,18 +200,42 @@ def topic_detail(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
54
  .where(DocumentTopic.topic_id == topic.id)
55
  .order_by(DocumentTopic.weight.desc(), Document.published_at.desc())
56
  ).all()
57
- return {"id": str(topic.id), "name": topic.name, "summary": topic.summary, "post_count": topic.post_count,
58
- "posts": [{**serialize_document(d), "weight": w} for d, w in rows]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
 
61
  @router.post("/rebuild")
62
  def rebuild(ctx: Ctx = Depends(get_ctx), full: bool = False):
63
- params = {}
64
  if full:
65
  # Clear per post tags so every post is mapped again.
66
  for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
67
  if d.metadata_json and "topics" in d.metadata_json:
68
  d.metadata_json = {k: v for k, v in d.metadata_json.items() if k != "topics"}
69
  ctx.db.commit()
70
- job = create_job(ctx.db, ctx.workspace.id, "topics", params, max_attempts=2)
71
  return serialize_job(job)
 
1
+ """Topic map: topics as stars, co-occurrence as constellation lines, plus the insight a writer needs:
2
+ which topics are rising, which went dormant, which travel together, and how each one evolved."""
3
+
4
  import uuid
5
+ from collections import Counter, defaultdict
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime, timedelta
8
  from itertools import combinations
9
 
10
  from fastapi import APIRouter, Depends, HTTPException
 
17
 
18
  router = APIRouter(prefix="/api/topics", tags=["topics"])
19
 
20
+ BUCKETS = 12 # timeline resolution across the archive's lifetime
21
+
22
+
23
+ def _aware(dt: datetime | None) -> datetime | None:
24
+ return dt if dt is None or dt.tzinfo else dt.replace(tzinfo=UTC)
25
+
26
+
27
+ @dataclass
28
+ class TopicStats:
29
+ topic: Topic
30
+ dates: list[datetime] = field(default_factory=list)
31
+ docs: set[uuid.UUID] = field(default_factory=set)
32
+
33
+
34
+ @dataclass
35
+ class Archive:
36
+ start: datetime
37
+ end: datetime
38
+ recent_from: datetime
39
+ total_docs: int
40
+ recent_docs: int
41
+
42
+ def bucket(self, dt: datetime) -> int:
43
+ span = (self.end - self.start).total_seconds() or 1
44
+ return min(BUCKETS - 1, max(0, int((dt - self.start).total_seconds() / span * BUCKETS)))
45
+
46
+
47
+ def _load(ctx: Ctx, topic_ids: set[uuid.UUID] | None = None):
48
+ """Topic -> dated posts, and the archive's time frame."""
49
+ q = (
50
+ select(DocumentTopic.topic_id, DocumentTopic.document_id, Document.published_at, Document.created_at)
51
+ .join(Document, Document.id == DocumentTopic.document_id)
52
+ .where(Document.workspace_id == ctx.workspace.id)
53
+ )
54
+ if topic_ids is not None:
55
+ q = q.where(DocumentTopic.topic_id.in_(topic_ids))
56
+ rows = ctx.db.execute(q).all()
57
+ all_dates = [
58
+ _aware(p or c)
59
+ for p, c in ctx.db.execute(
60
+ select(Document.published_at, Document.created_at).where(
61
+ Document.workspace_id == ctx.workspace.id, Document.path.is_not(None))
62
+ ).all()
63
+ ]
64
+ now = datetime.now(UTC)
65
+ start = min(all_dates, default=now)
66
+ end = max(all_dates, default=now)
67
+ span = end - start
68
+ # "Recent" is the last quarter of the writer's own publishing history (at least 60 days), so an archive
69
+ # that stopped in 2022 still shows what was rising at the end of it.
70
+ window = max(span / 4, timedelta(days=60))
71
+ recent_from = end - window
72
+ archive = Archive(start, end, recent_from, len(all_dates), sum(1 for d in all_dates if d >= recent_from))
73
+ return rows, archive
74
+
75
+
76
+ def _status(count: int, recent: int, last: datetime | None, archive: Archive) -> tuple[str, float]:
77
+ """(status, momentum). Momentum compares the topic's share of recent posts to its share of all posts."""
78
+ overall = count / max(archive.total_docs, 1)
79
+ recent_share = recent / max(archive.recent_docs, 1)
80
+ momentum = round(recent_share / overall, 2) if overall else 0.0
81
+ if recent == 0 and last and last < archive.recent_from:
82
+ return "dormant", momentum
83
+ if momentum >= 1.4 and recent >= 2:
84
+ return "rising", momentum
85
+ return "steady", momentum
86
+
87
+
88
+ def _galaxies(node_ids: list[str], edges: Counter) -> dict[str, int]:
89
+ """Weighted label propagation: topics that share posts settle into the same galaxy."""
90
+ label = {n: i for i, n in enumerate(node_ids)}
91
+ nbrs: dict[str, list[tuple[str, int]]] = defaultdict(list)
92
+ for (a, b), w in edges.items():
93
+ nbrs[a].append((b, w))
94
+ nbrs[b].append((a, w))
95
+ for _ in range(12):
96
+ changed = False
97
+ for n in node_ids: # deterministic order: biggest topics first
98
+ if not nbrs[n]:
99
+ continue
100
+ score: Counter = Counter()
101
+ for m, w in nbrs[n]:
102
+ score[label[m]] += w
103
+ best = max(score.items(), key=lambda kv: (kv[1], -kv[0]))[0]
104
+ if score[best] > score.get(label[n], 0) and best != label[n]:
105
+ label[n] = best
106
+ changed = True
107
+ if not changed:
108
+ break
109
+ return label
110
+
111
 
112
  @router.get("")
113
  def topic_map(ctx: Ctx = Depends(get_ctx), min_posts: int = 1, limit: int = 80):
 
114
  topics = ctx.db.scalars(
115
  select(Topic).where(Topic.workspace_id == ctx.workspace.id, Topic.post_count >= min_posts)
116
+ .order_by(Topic.post_count.desc(), Topic.name).limit(min(limit, 300))
117
  ).all()
118
+ by_id = {t.id: TopicStats(t) for t in topics}
119
+ rows, archive = _load(ctx, set(by_id)) if by_id else ([], None)
120
+
121
+ by_doc: dict[uuid.UUID, list[str]] = defaultdict(list)
122
+ for topic_id, doc_id, published, created in rows:
123
+ st = by_id[topic_id]
124
+ if doc_id not in st.docs:
125
+ st.docs.add(doc_id)
126
+ st.dates.append(_aware(published or created))
127
+ by_doc[doc_id].append(str(topic_id))
128
  edges: Counter[tuple[str, str]] = Counter()
129
  for tids in by_doc.values():
130
+ for a, b in combinations(sorted(set(tids)), 2):
131
  edges[(a, b)] += 1
132
+
133
+ node_ids = [str(t.id) for t in topics]
134
+ galaxy_of = _galaxies(node_ids, edges)
135
+ nodes = []
136
+ for t in topics:
137
+ st = by_id[t.id]
138
+ dates = sorted(st.dates)
139
+ count = len(dates) or t.post_count
140
+ recent = sum(1 for d in dates if archive and d >= archive.recent_from)
141
+ first, last = (dates[0], dates[-1]) if dates else (None, None)
142
+ status, momentum = _status(count, recent, last, archive) if archive else ("steady", 1.0)
143
+ timeline = [0] * BUCKETS
144
+ for d in dates:
145
+ timeline[archive.bucket(d)] += 1
146
+ span = (archive.end - archive.start).total_seconds() if archive else 0
147
+ recency = round((last - archive.start).total_seconds() / span, 3) if last and span else 1.0
148
+ nodes.append({
149
+ "id": str(t.id), "name": t.name, "summary": t.summary, "post_count": count,
150
+ "first_at": first.isoformat() if first else None, "last_at": last.isoformat() if last else None,
151
+ "recent_posts": recent, "momentum": momentum, "status": status, "recency": recency,
152
+ "timeline": timeline, "galaxy": galaxy_of[str(t.id)],
153
+ })
154
+
155
+ # Galaxies: named after their brightest (most written about) star.
156
+ members: dict[int, list[dict]] = defaultdict(list)
157
+ for n in nodes:
158
+ members[n["galaxy"]].append(n)
159
+ galaxies = []
160
+ for gid, ns in sorted(members.items(), key=lambda kv: -sum(n["post_count"] for n in kv[1])):
161
+ lead = max(ns, key=lambda n: n["post_count"])
162
+ galaxies.append({"id": gid, "name": lead["name"], "topic_ids": [n["id"] for n in ns],
163
+ "post_count": sum(n["post_count"] for n in ns), "size": len(ns)})
164
+ names = {n["id"]: n["name"] for n in nodes}
165
+ rising = sorted((n for n in nodes if n["status"] == "rising"), key=lambda n: -n["momentum"])[:5]
166
+ dormant = sorted((n for n in nodes if n["status"] == "dormant"), key=lambda n: -n["post_count"])[:5]
167
  untagged = ctx.db.scalar(
168
  select(Document.id).where(Document.workspace_id == ctx.workspace.id, Document.path.is_not(None),
169
  Document.id.not_in(select(DocumentTopic.document_id))).limit(1)
170
  )
171
  return {
172
+ "nodes": nodes,
 
173
  "edges": [{"source": a, "target": b, "weight": w} for (a, b), w in edges.most_common(400)],
174
+ "galaxies": galaxies,
175
+ "insights": {
176
+ "rising": [{"id": n["id"], "name": n["name"], "momentum": n["momentum"]} for n in rising],
177
+ "dormant": [{"id": n["id"], "name": n["name"], "last_at": n["last_at"], "post_count": n["post_count"]}
178
+ for n in dormant],
179
+ "pairs": [{"a": names[a], "b": names[b], "a_id": a, "b_id": b, "posts": w}
180
+ for (a, b), w in edges.most_common(5)],
181
+ },
182
+ "archive": {
183
+ "start": archive.start.isoformat() if archive else None,
184
+ "end": archive.end.isoformat() if archive else None,
185
+ "recent_from": archive.recent_from.isoformat() if archive else None,
186
+ "posts": archive.total_docs if archive else 0,
187
+ },
188
  "has_untagged_posts": untagged is not None,
189
  }
190
 
 
200
  .where(DocumentTopic.topic_id == topic.id)
201
  .order_by(DocumentTopic.weight.desc(), Document.published_at.desc())
202
  ).all()
203
+ doc_ids = [d.id for d, _ in rows]
204
+ _, archive = _load(ctx, {topic.id})
205
+ dates = sorted(_aware(d.published_at or d.created_at) for d, _ in rows)
206
+ recent = sum(1 for d in dates if d >= archive.recent_from)
207
+ status, momentum = _status(len(dates), recent, dates[-1] if dates else None, archive)
208
+ timeline = [0] * BUCKETS
209
+ for d in dates:
210
+ timeline[archive.bucket(d)] += 1
211
+ # Topics that share posts with this one, strongest first.
212
+ related = Counter()
213
+ if doc_ids:
214
+ for other_id, in ctx.db.execute(
215
+ select(DocumentTopic.topic_id).where(DocumentTopic.document_id.in_(doc_ids),
216
+ DocumentTopic.topic_id != topic.id)
217
+ ).all():
218
+ related[other_id] += 1
219
+ related_topics = {t.id: t for t in ctx.db.scalars(select(Topic).where(Topic.id.in_(list(related))))} \
220
+ if related else {}
221
+ return {
222
+ "id": str(topic.id), "name": topic.name, "summary": topic.summary, "post_count": len(dates),
223
+ "first_at": dates[0].isoformat() if dates else None, "last_at": dates[-1].isoformat() if dates else None,
224
+ "recent_posts": recent, "momentum": momentum, "status": status, "timeline": timeline,
225
+ "timeline_start": archive.start.isoformat(), "timeline_end": archive.end.isoformat(),
226
+ "related": [{"id": str(tid), "name": related_topics[tid].name, "shared_posts": n}
227
+ for tid, n in related.most_common(6) if tid in related_topics],
228
+ "posts": [{**serialize_document(d), "weight": w} for d, w in rows],
229
+ }
230
 
231
 
232
  @router.post("/rebuild")
233
  def rebuild(ctx: Ctx = Depends(get_ctx), full: bool = False):
 
234
  if full:
235
  # Clear per post tags so every post is mapped again.
236
  for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
237
  if d.metadata_json and "topics" in d.metadata_json:
238
  d.metadata_json = {k: v for k, v in d.metadata_json.items() if k != "topics"}
239
  ctx.db.commit()
240
+ job = create_job(ctx.db, ctx.workspace.id, "topics", {}, max_attempts=2)
241
  return serialize_job(job)
backend/tests/test_buildout.py CHANGED
@@ -263,6 +263,14 @@ def test_topic_map_and_notebook_from_topic(client, auth, run_jobs, feed, llm):
263
  assert len(graph["edges"]) == 2
264
  detail = client.get(f"/api/topics/{names['Writing Business']['id']}", headers=auth).json()
265
  assert len(detail["posts"]) == 2
 
 
 
 
 
 
 
 
266
  nb = client.post(f"/api/notebooks/from-topic/{names['Pricing']['id']}", headers=auth).json()
267
  assert nb["added"] == 1
268
 
 
263
  assert len(graph["edges"]) == 2
264
  detail = client.get(f"/api/topics/{names['Writing Business']['id']}", headers=auth).json()
265
  assert len(detail["posts"]) == 2
266
+ # Insight: Habits was only written about in 2024, long before the archive's recent window.
267
+ assert names["Habits"]["status"] == "dormant" and names["Pricing"]["recent_posts"] == 1
268
+ assert len(names["Pricing"]["timeline"]) == 12 and sum(names["Writing Business"]["timeline"]) == 2
269
+ assert graph["galaxies"] and graph["galaxies"][0]["name"] == "Writing Business"
270
+ assert graph["insights"]["dormant"][0]["name"] == "Habits"
271
+ assert graph["insights"]["pairs"][0]["posts"] == 1
272
+ assert {r["name"] for r in detail["related"]} == {"Pricing", "Habits"}
273
+ assert detail["first_at"] < detail["last_at"]
274
  nb = client.post(f"/api/notebooks/from-topic/{names['Pricing']['id']}", headers=auth).json()
275
  assert nb["added"] == 1
276
 
frontend/src/api/types.ts CHANGED
@@ -119,13 +119,54 @@ export type Artifact = {
119
  job: Job | null;
120
  };
121
 
122
- export type TopicNode = { id: string; name: string; summary: string | null; post_count: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  export type TopicMap = {
124
  nodes: TopicNode[];
125
  edges: { source: string; target: string; weight: number }[];
 
 
 
 
 
 
 
126
  has_untagged_posts: boolean;
127
  };
128
- export type TopicDetail = TopicNode & { posts: (Doc & { weight: number })[] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  export type VoiceProfileData = {
131
  tone: string[];
 
119
  job: Job | null;
120
  };
121
 
122
+ export type TopicStatus = "rising" | "steady" | "dormant";
123
+
124
+ export type TopicNode = {
125
+ id: string;
126
+ name: string;
127
+ summary: string | null;
128
+ post_count: number;
129
+ first_at: string | null;
130
+ last_at: string | null;
131
+ recent_posts: number;
132
+ momentum: number;
133
+ status: TopicStatus;
134
+ recency: number; // 0 = only written about at the start of the archive, 1 = written about most recently
135
+ timeline: number[];
136
+ galaxy: number;
137
+ };
138
+
139
+ export type Galaxy = { id: number; name: string; topic_ids: string[]; post_count: number; size: number };
140
+
141
  export type TopicMap = {
142
  nodes: TopicNode[];
143
  edges: { source: string; target: string; weight: number }[];
144
+ galaxies: Galaxy[];
145
+ insights: {
146
+ rising: { id: string; name: string; momentum: number }[];
147
+ dormant: { id: string; name: string; last_at: string | null; post_count: number }[];
148
+ pairs: { a: string; b: string; a_id: string; b_id: string; posts: number }[];
149
+ };
150
+ archive: { start: string | null; end: string | null; recent_from: string | null; posts: number };
151
  has_untagged_posts: boolean;
152
  };
153
+
154
+ export type TopicDetail = {
155
+ id: string;
156
+ name: string;
157
+ summary: string | null;
158
+ post_count: number;
159
+ first_at: string | null;
160
+ last_at: string | null;
161
+ recent_posts: number;
162
+ momentum: number;
163
+ status: TopicStatus;
164
+ timeline: number[];
165
+ timeline_start: string;
166
+ timeline_end: string;
167
+ related: { id: string; name: string; shared_posts: number }[];
168
+ posts: (Doc & { weight: number })[];
169
+ };
170
 
171
  export type VoiceProfileData = {
172
  tone: string[];
frontend/src/styles/showcase.css CHANGED
@@ -584,3 +584,283 @@
584
  font-size: 0.72rem;
585
  margin-top: -4px;
586
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  font-size: 0.72rem;
585
  margin-top: -4px;
586
  }
587
+
588
+ /* ---------- Topic map: space chart ---------- */
589
+ .map-canvas {
590
+ background:
591
+ radial-gradient(60% 50% at 50% 45%, rgba(33, 124, 255, 0.07), transparent 70%),
592
+ #000;
593
+ }
594
+
595
+ .survey-ring {
596
+ fill: none;
597
+ stroke: rgba(255, 255, 255, 0.05);
598
+ stroke-dasharray: 2 6;
599
+ }
600
+
601
+ .survey-axis {
602
+ stroke: rgba(255, 255, 255, 0.04);
603
+ }
604
+
605
+ .nebula {
606
+ cursor: zoom-in;
607
+ }
608
+
609
+ .galaxy-label {
610
+ fill: rgba(156, 196, 255, 0.75);
611
+ font-family: var(--font-mono);
612
+ pointer-events: none;
613
+ }
614
+
615
+ .constellation {
616
+ transition: stroke-opacity 0.25s ease;
617
+ }
618
+
619
+ .map-count {
620
+ fill: var(--blue);
621
+ font-family: var(--font-mono);
622
+ }
623
+
624
+ .orbit-ring {
625
+ fill: none;
626
+ stroke: var(--blue);
627
+ stroke-dasharray: 4 5;
628
+ transform-box: fill-box;
629
+ transform-origin: center;
630
+ animation: orbit-spin 12s linear infinite;
631
+ }
632
+
633
+ @keyframes orbit-spin {
634
+ to {
635
+ transform: rotate(360deg);
636
+ }
637
+ }
638
+
639
+ .spikes {
640
+ pointer-events: none;
641
+ }
642
+
643
+ .map-hud {
644
+ position: absolute;
645
+ left: 14px;
646
+ top: 12px;
647
+ z-index: 1;
648
+ display: grid;
649
+ gap: 4px;
650
+ max-width: min(360px, 60%);
651
+ padding: 10px 12px;
652
+ border: 1px solid var(--blue-20);
653
+ border-radius: 10px;
654
+ background: rgba(0, 0, 0, 0.72);
655
+ backdrop-filter: blur(6px);
656
+ font-size: 0.72rem;
657
+ pointer-events: none;
658
+ }
659
+
660
+ .hud-title {
661
+ font-family: var(--font-display);
662
+ font-size: 0.95rem;
663
+ letter-spacing: 0;
664
+ color: var(--white);
665
+ }
666
+
667
+ .map-hud .sparkline {
668
+ width: 160px;
669
+ height: 18px;
670
+ }
671
+
672
+ .map-legend {
673
+ position: absolute;
674
+ right: 12px;
675
+ bottom: 10px;
676
+ z-index: 1;
677
+ display: flex;
678
+ flex-wrap: wrap;
679
+ justify-content: flex-end;
680
+ gap: 6px 14px;
681
+ max-width: 70%;
682
+ padding: 6px 10px;
683
+ border-radius: 10px;
684
+ background: rgba(0, 0, 0, 0.6);
685
+ font-size: 0.66rem;
686
+ color: var(--text-2);
687
+ pointer-events: none;
688
+ }
689
+
690
+ .map-legend span {
691
+ display: inline-flex;
692
+ align-items: center;
693
+ gap: 6px;
694
+ }
695
+
696
+ .map-legend i {
697
+ display: inline-block;
698
+ }
699
+
700
+ .lg-size {
701
+ width: 10px;
702
+ height: 10px;
703
+ border-radius: 50%;
704
+ background: var(--white);
705
+ box-shadow: 0 0 8px var(--blue), -8px 0 0 -3px var(--white);
706
+ margin-left: 6px;
707
+ }
708
+
709
+ .lg-bright {
710
+ width: 26px;
711
+ height: 6px;
712
+ border-radius: 3px;
713
+ background: linear-gradient(90deg, var(--blue), #ffffff);
714
+ }
715
+
716
+ .lg-tail {
717
+ width: 22px;
718
+ height: 4px;
719
+ border-radius: 3px;
720
+ background: linear-gradient(90deg, #ffffff, transparent);
721
+ }
722
+
723
+ .lg-dormant {
724
+ width: 10px;
725
+ height: 10px;
726
+ border-radius: 50%;
727
+ border: 1px dashed rgba(255, 255, 255, 0.6);
728
+ }
729
+
730
+ .lg-line {
731
+ width: 22px;
732
+ height: 0;
733
+ border-top: 2px solid rgba(255, 255, 255, 0.4);
734
+ }
735
+
736
+ .map-hint {
737
+ display: none;
738
+ }
739
+
740
+ /* Side panel: briefing and star detail */
741
+ .brief {
742
+ display: grid;
743
+ gap: 6px;
744
+ padding-top: 10px;
745
+ border-top: 1px solid var(--line);
746
+ }
747
+
748
+ .brief h3 {
749
+ font-size: 0.8rem;
750
+ font-family: var(--font-mono);
751
+ letter-spacing: 0.14em;
752
+ text-transform: uppercase;
753
+ color: var(--blue);
754
+ }
755
+
756
+ .brief-list {
757
+ list-style: none;
758
+ margin: 0;
759
+ padding: 0;
760
+ display: grid;
761
+ gap: 6px;
762
+ }
763
+
764
+ .brief-list li {
765
+ display: flex;
766
+ justify-content: space-between;
767
+ align-items: baseline;
768
+ gap: 10px;
769
+ font-size: 0.875rem;
770
+ }
771
+
772
+ .brief-list .mono {
773
+ font-size: 0.7rem;
774
+ white-space: nowrap;
775
+ }
776
+
777
+ .brief-up {
778
+ color: var(--white);
779
+ text-shadow: 0 0 8px var(--blue);
780
+ }
781
+
782
+ .star-stats {
783
+ display: grid;
784
+ grid-template-columns: repeat(4, 1fr);
785
+ gap: 8px;
786
+ margin: 0;
787
+ }
788
+
789
+ .star-stats div {
790
+ display: grid;
791
+ gap: 2px;
792
+ padding: 8px;
793
+ border: 1px solid var(--line);
794
+ border-radius: 8px;
795
+ }
796
+
797
+ .star-stats dt {
798
+ font: 0.62rem var(--font-mono);
799
+ letter-spacing: 0.1em;
800
+ text-transform: uppercase;
801
+ color: var(--text-3);
802
+ }
803
+
804
+ .star-stats dd {
805
+ margin: 0;
806
+ font-size: 0.82rem;
807
+ }
808
+
809
+ .timeline {
810
+ display: grid;
811
+ gap: 4px;
812
+ }
813
+
814
+ .sparkline {
815
+ width: 100%;
816
+ height: 44px;
817
+ }
818
+
819
+ .sparkline rect {
820
+ fill: rgba(33, 124, 255, 0.55);
821
+ }
822
+
823
+ .sparkline rect.recent {
824
+ fill: #ffffff;
825
+ }
826
+
827
+ .status-rising {
828
+ border: 1px solid var(--white);
829
+ color: var(--white);
830
+ box-shadow: 0 0 10px var(--blue-60);
831
+ }
832
+
833
+ .status-steady {
834
+ border: 1px solid var(--blue-60);
835
+ color: var(--white);
836
+ }
837
+
838
+ .status-dormant {
839
+ border: 1px dashed var(--line-strong);
840
+ color: var(--text-2);
841
+ }
842
+
843
+ .chip-btn {
844
+ background: transparent;
845
+ color: var(--white);
846
+ font: inherit;
847
+ font-size: 0.75rem;
848
+ cursor: pointer;
849
+ }
850
+
851
+ .chip-btn:hover {
852
+ border-color: var(--blue);
853
+ background: var(--blue-10);
854
+ }
855
+
856
+ .chip-btn .mono {
857
+ font-size: 0.65rem;
858
+ color: var(--blue);
859
+ margin-left: 4px;
860
+ }
861
+
862
+ @media (prefers-reduced-motion: reduce) {
863
+ .orbit-ring {
864
+ animation: none;
865
+ }
866
+ }