docsgraph 0.1.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cairn/__init__.py +5 -0
- cairn/bench/__init__.py +37 -0
- cairn/bench/baseline.py +236 -0
- cairn/bench/dataset.py +109 -0
- cairn/bench/judge.py +126 -0
- cairn/bench/metrics.py +32 -0
- cairn/bench/report.py +143 -0
- cairn/bench/runner.py +219 -0
- cairn/cli/__init__.py +5 -0
- cairn/cli/app.py +776 -0
- cairn/cli/config.py +105 -0
- cairn/core/__init__.py +41 -0
- cairn/core/errors.py +68 -0
- cairn/core/types.py +147 -0
- cairn/embed/__init__.py +17 -0
- cairn/embed/base.py +31 -0
- cairn/embed/doubao.py +167 -0
- cairn/embed/fake.py +36 -0
- cairn/embed/openai_compatible.py +155 -0
- cairn/engine/__init__.py +18 -0
- cairn/engine/indexer.py +298 -0
- cairn/engine/manifest.py +83 -0
- cairn/entity/__init__.py +21 -0
- cairn/entity/base.py +52 -0
- cairn/entity/fake.py +34 -0
- cairn/entity/heuristic.py +148 -0
- cairn/index/__init__.py +39 -0
- cairn/index/entities.py +244 -0
- cairn/index/summaries.py +269 -0
- cairn/index/tree.py +274 -0
- cairn/index/vectors.py +287 -0
- cairn/index/xrefs.py +195 -0
- cairn/ingest/__init__.py +36 -0
- cairn/ingest/base.py +46 -0
- cairn/ingest/markdown.py +244 -0
- cairn/ingest/markitdown.py +145 -0
- cairn/ingest/pdf.py +357 -0
- cairn/inspection.py +971 -0
- cairn/mcp/__init__.py +12 -0
- cairn/mcp/schemas.py +547 -0
- cairn/mcp/server.py +363 -0
- cairn/providers.py +50 -0
- cairn/py.typed +0 -0
- cairn/repo.py +1486 -0
- cairn/repo_search.py +1505 -0
- cairn/summarize/__init__.py +18 -0
- cairn/summarize/base.py +56 -0
- cairn/summarize/cache.py +66 -0
- cairn/summarize/fake.py +43 -0
- cairn/summarize/openai_compatible.py +148 -0
- cairn/summarize/prompts.py +73 -0
- cairn/tools/__init__.py +31 -0
- cairn/tools/base.py +126 -0
- cairn/tools/find_mentions.py +93 -0
- cairn/tools/get_related.py +140 -0
- cairn/tools/get_section.py +130 -0
- cairn/tools/outline.py +75 -0
- cairn/tools/read_range.py +94 -0
- cairn/tools/search_keyword.py +94 -0
- cairn/tools/search_semantic.py +181 -0
- cairn/xref/__init__.py +24 -0
- cairn/xref/base.py +50 -0
- cairn/xref/fake.py +40 -0
- cairn/xref/heuristic.py +217 -0
- docsgraph-0.1.0a2.dist-info/METADATA +688 -0
- docsgraph-0.1.0a2.dist-info/RECORD +69 -0
- docsgraph-0.1.0a2.dist-info/WHEEL +4 -0
- docsgraph-0.1.0a2.dist-info/entry_points.txt +3 -0
- docsgraph-0.1.0a2.dist-info/licenses/LICENSE +201 -0
cairn/inspection.py
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
# ruff: noqa: E501
|
|
2
|
+
"""Static inspector generation for built Cairn indexes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from cairn.tools.base import DocumentIndex
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_inspector(index: DocumentIndex, *, out: Path) -> Path:
|
|
15
|
+
"""Write a standalone HTML inspector for a loaded document index."""
|
|
16
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
data = _build_payload(index)
|
|
18
|
+
out.write_text(_render_html(data), encoding="utf-8")
|
|
19
|
+
return out
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _build_payload(index: DocumentIndex) -> dict[str, Any]:
|
|
23
|
+
nodes: list[dict[str, Any]] = []
|
|
24
|
+
edges: list[dict[str, Any]] = []
|
|
25
|
+
|
|
26
|
+
for section in index.tree:
|
|
27
|
+
summary = index.summaries.get(section.id)
|
|
28
|
+
nodes.append(
|
|
29
|
+
{
|
|
30
|
+
"id": section.id,
|
|
31
|
+
"label": section.title,
|
|
32
|
+
"kind": "section",
|
|
33
|
+
"level": section.level,
|
|
34
|
+
"path": " / ".join(section.path),
|
|
35
|
+
"gist": summary.gist if summary is not None else "",
|
|
36
|
+
"synopsis": summary.synopsis if summary is not None else "",
|
|
37
|
+
"head": section.raw_text.strip()[:320],
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
if section.parent is not None:
|
|
41
|
+
edges.append({"source": section.parent, "target": section.id, "kind": "tree"})
|
|
42
|
+
|
|
43
|
+
section_ids = {node["id"] for node in nodes}
|
|
44
|
+
if index.xrefs is not None:
|
|
45
|
+
for ref in index.xrefs:
|
|
46
|
+
if ref.src in section_ids and ref.dst in section_ids:
|
|
47
|
+
edges.append(
|
|
48
|
+
{
|
|
49
|
+
"source": ref.src,
|
|
50
|
+
"target": ref.dst,
|
|
51
|
+
"kind": f"xref:{ref.kind}",
|
|
52
|
+
"confidence": ref.confidence,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
entity_count = 0
|
|
57
|
+
if index.entities is not None:
|
|
58
|
+
entities = sorted(
|
|
59
|
+
index.entities,
|
|
60
|
+
key=lambda ent: (-len(ent.mentions), ent.canonical),
|
|
61
|
+
)
|
|
62
|
+
entity_count = len(entities)
|
|
63
|
+
for entity in entities[:60]:
|
|
64
|
+
entity_id = f"entity:{entity.kind}:{entity.canonical}"
|
|
65
|
+
nodes.append(
|
|
66
|
+
{
|
|
67
|
+
"id": entity_id,
|
|
68
|
+
"label": entity.canonical,
|
|
69
|
+
"kind": "entity",
|
|
70
|
+
"entityKind": entity.kind,
|
|
71
|
+
"mentions": len(entity.mentions),
|
|
72
|
+
"surfaceForms": list(entity.surface_forms),
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
seen_sections: set[str] = set()
|
|
76
|
+
for mention in entity.mentions:
|
|
77
|
+
if mention.section_id in section_ids and mention.section_id not in seen_sections:
|
|
78
|
+
edges.append(
|
|
79
|
+
{
|
|
80
|
+
"source": entity_id,
|
|
81
|
+
"target": mention.section_id,
|
|
82
|
+
"kind": "mention",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
seen_sections.add(mention.section_id)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"docId": index.doc_id,
|
|
89
|
+
"nodes": nodes,
|
|
90
|
+
"edges": edges,
|
|
91
|
+
"stats": {
|
|
92
|
+
"nodes": len(nodes),
|
|
93
|
+
"edges": len(edges),
|
|
94
|
+
"sections": len(index.tree),
|
|
95
|
+
"entities": entity_count,
|
|
96
|
+
"entitiesShown": sum(1 for node in nodes if node["kind"] == "entity"),
|
|
97
|
+
"entitiesHidden": max(0, entity_count - 60),
|
|
98
|
+
"maxDepth": max((node.get("level", 0) for node in nodes), default=0),
|
|
99
|
+
"treeEdges": sum(1 for edge in edges if edge["kind"] == "tree"),
|
|
100
|
+
"xrefEdges": sum(1 for edge in edges if str(edge["kind"]).startswith("xref:")),
|
|
101
|
+
"mentionEdges": sum(1 for edge in edges if edge["kind"] == "mention"),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render_html(data: dict[str, Any]) -> str:
|
|
107
|
+
data_json = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
|
|
108
|
+
title = html.escape(str(data["docId"]), quote=True)
|
|
109
|
+
template = """<!doctype html>
|
|
110
|
+
<html lang="en">
|
|
111
|
+
<head>
|
|
112
|
+
<meta charset="utf-8">
|
|
113
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
114
|
+
<title>Cairn Inspector - __DOC_TITLE__</title>
|
|
115
|
+
<style>
|
|
116
|
+
:root {
|
|
117
|
+
--bg: #f5f6f1;
|
|
118
|
+
--surface: #ffffff;
|
|
119
|
+
--surface-2: #fafbf7;
|
|
120
|
+
--ink: #161d18;
|
|
121
|
+
--muted: #657268;
|
|
122
|
+
--quiet: #8c978f;
|
|
123
|
+
--line: #d8ded5;
|
|
124
|
+
--line-strong: #bcc7bf;
|
|
125
|
+
--section: #1f6f78;
|
|
126
|
+
--entity: #a25f20;
|
|
127
|
+
--tree: #5ca69a;
|
|
128
|
+
--mention: #c8923a;
|
|
129
|
+
--xref: #6f5aa5;
|
|
130
|
+
--focus: #0c5b49;
|
|
131
|
+
--danger: #9b3e30;
|
|
132
|
+
--shadow: 0 18px 50px rgba(31, 42, 35, .11);
|
|
133
|
+
}
|
|
134
|
+
* {
|
|
135
|
+
box-sizing: border-box;
|
|
136
|
+
}
|
|
137
|
+
body {
|
|
138
|
+
margin: 0;
|
|
139
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
140
|
+
color: var(--ink);
|
|
141
|
+
background: var(--bg);
|
|
142
|
+
letter-spacing: 0;
|
|
143
|
+
}
|
|
144
|
+
.app {
|
|
145
|
+
display: grid;
|
|
146
|
+
grid-template-columns: 304px minmax(520px, 1fr) 380px;
|
|
147
|
+
min-height: 100vh;
|
|
148
|
+
}
|
|
149
|
+
.sidebar,
|
|
150
|
+
.details {
|
|
151
|
+
background: var(--surface);
|
|
152
|
+
overflow: auto;
|
|
153
|
+
}
|
|
154
|
+
.sidebar {
|
|
155
|
+
border-right: 1px solid var(--line);
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
}
|
|
159
|
+
.details {
|
|
160
|
+
border-left: 1px solid var(--line);
|
|
161
|
+
}
|
|
162
|
+
.sidebar-head,
|
|
163
|
+
.details-head,
|
|
164
|
+
.panel-block {
|
|
165
|
+
padding: 18px;
|
|
166
|
+
}
|
|
167
|
+
.sidebar-head,
|
|
168
|
+
.details-head {
|
|
169
|
+
border-bottom: 1px solid var(--line);
|
|
170
|
+
}
|
|
171
|
+
h1,
|
|
172
|
+
h2,
|
|
173
|
+
h3,
|
|
174
|
+
p {
|
|
175
|
+
margin: 0;
|
|
176
|
+
}
|
|
177
|
+
h1 {
|
|
178
|
+
font-size: 19px;
|
|
179
|
+
line-height: 1.2;
|
|
180
|
+
}
|
|
181
|
+
h2 {
|
|
182
|
+
font-size: 18px;
|
|
183
|
+
line-height: 1.25;
|
|
184
|
+
overflow-wrap: anywhere;
|
|
185
|
+
}
|
|
186
|
+
h3 {
|
|
187
|
+
font-size: 13px;
|
|
188
|
+
text-transform: uppercase;
|
|
189
|
+
color: var(--muted);
|
|
190
|
+
margin-bottom: 10px;
|
|
191
|
+
}
|
|
192
|
+
.subtle {
|
|
193
|
+
color: var(--muted);
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
line-height: 1.45;
|
|
196
|
+
margin-top: 6px;
|
|
197
|
+
overflow-wrap: anywhere;
|
|
198
|
+
}
|
|
199
|
+
.stats {
|
|
200
|
+
display: grid;
|
|
201
|
+
grid-template-columns: 1fr 1fr;
|
|
202
|
+
border-bottom: 1px solid var(--line);
|
|
203
|
+
}
|
|
204
|
+
.stat {
|
|
205
|
+
min-height: 72px;
|
|
206
|
+
padding: 14px 18px;
|
|
207
|
+
border-right: 1px solid var(--line);
|
|
208
|
+
border-bottom: 1px solid var(--line);
|
|
209
|
+
background: var(--surface-2);
|
|
210
|
+
}
|
|
211
|
+
.stat:nth-child(2n) {
|
|
212
|
+
border-right: 0;
|
|
213
|
+
}
|
|
214
|
+
.stat strong {
|
|
215
|
+
display: block;
|
|
216
|
+
font-size: 22px;
|
|
217
|
+
line-height: 1;
|
|
218
|
+
}
|
|
219
|
+
.stat span {
|
|
220
|
+
color: var(--muted);
|
|
221
|
+
font-size: 12px;
|
|
222
|
+
}
|
|
223
|
+
button,
|
|
224
|
+
input {
|
|
225
|
+
border: 1px solid var(--line);
|
|
226
|
+
border-radius: 6px;
|
|
227
|
+
font: inherit;
|
|
228
|
+
background: var(--surface);
|
|
229
|
+
color: var(--ink);
|
|
230
|
+
}
|
|
231
|
+
button {
|
|
232
|
+
min-height: 34px;
|
|
233
|
+
padding: 7px 10px;
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
}
|
|
236
|
+
button:hover,
|
|
237
|
+
.row:hover {
|
|
238
|
+
border-color: var(--line-strong);
|
|
239
|
+
background: #f7faf5;
|
|
240
|
+
}
|
|
241
|
+
.segmented {
|
|
242
|
+
display: grid;
|
|
243
|
+
grid-template-columns: 1fr 1fr;
|
|
244
|
+
gap: 6px;
|
|
245
|
+
}
|
|
246
|
+
.segmented button {
|
|
247
|
+
text-align: center;
|
|
248
|
+
}
|
|
249
|
+
.segmented button.active {
|
|
250
|
+
border-color: var(--focus);
|
|
251
|
+
background: #e8f2ee;
|
|
252
|
+
color: #103f35;
|
|
253
|
+
}
|
|
254
|
+
.legend {
|
|
255
|
+
display: grid;
|
|
256
|
+
gap: 8px;
|
|
257
|
+
font-size: 13px;
|
|
258
|
+
color: var(--muted);
|
|
259
|
+
}
|
|
260
|
+
.legend-row {
|
|
261
|
+
display: flex;
|
|
262
|
+
align-items: center;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
}
|
|
265
|
+
.swatch {
|
|
266
|
+
width: 18px;
|
|
267
|
+
height: 5px;
|
|
268
|
+
border-radius: 999px;
|
|
269
|
+
background: var(--line);
|
|
270
|
+
flex: 0 0 auto;
|
|
271
|
+
}
|
|
272
|
+
.swatch.section {
|
|
273
|
+
background: var(--section);
|
|
274
|
+
}
|
|
275
|
+
.swatch.entity {
|
|
276
|
+
background: var(--entity);
|
|
277
|
+
}
|
|
278
|
+
.swatch.tree {
|
|
279
|
+
background: var(--tree);
|
|
280
|
+
}
|
|
281
|
+
.swatch.mention {
|
|
282
|
+
background: var(--mention);
|
|
283
|
+
}
|
|
284
|
+
.swatch.xref {
|
|
285
|
+
background: var(--xref);
|
|
286
|
+
}
|
|
287
|
+
.search {
|
|
288
|
+
width: 100%;
|
|
289
|
+
height: 38px;
|
|
290
|
+
padding: 0 11px;
|
|
291
|
+
}
|
|
292
|
+
.row-list {
|
|
293
|
+
display: grid;
|
|
294
|
+
gap: 7px;
|
|
295
|
+
max-height: 42vh;
|
|
296
|
+
overflow: auto;
|
|
297
|
+
}
|
|
298
|
+
.row {
|
|
299
|
+
border: 1px solid var(--line);
|
|
300
|
+
border-radius: 6px;
|
|
301
|
+
padding: 9px 10px;
|
|
302
|
+
background: var(--surface);
|
|
303
|
+
cursor: pointer;
|
|
304
|
+
}
|
|
305
|
+
.row.active {
|
|
306
|
+
border-color: var(--focus);
|
|
307
|
+
background: #edf5f0;
|
|
308
|
+
}
|
|
309
|
+
.row-title {
|
|
310
|
+
font-size: 13px;
|
|
311
|
+
font-weight: 650;
|
|
312
|
+
line-height: 1.25;
|
|
313
|
+
overflow-wrap: anywhere;
|
|
314
|
+
}
|
|
315
|
+
.row-meta {
|
|
316
|
+
color: var(--muted);
|
|
317
|
+
font-size: 12px;
|
|
318
|
+
margin-top: 4px;
|
|
319
|
+
}
|
|
320
|
+
main {
|
|
321
|
+
position: relative;
|
|
322
|
+
min-width: 0;
|
|
323
|
+
overflow: hidden;
|
|
324
|
+
}
|
|
325
|
+
.topbar {
|
|
326
|
+
position: absolute;
|
|
327
|
+
left: 18px;
|
|
328
|
+
right: 18px;
|
|
329
|
+
top: 14px;
|
|
330
|
+
z-index: 2;
|
|
331
|
+
display: flex;
|
|
332
|
+
gap: 8px;
|
|
333
|
+
align-items: center;
|
|
334
|
+
justify-content: flex-end;
|
|
335
|
+
pointer-events: none;
|
|
336
|
+
}
|
|
337
|
+
.tool-pill {
|
|
338
|
+
display: inline-flex;
|
|
339
|
+
align-items: center;
|
|
340
|
+
gap: 8px;
|
|
341
|
+
padding: 7px 9px;
|
|
342
|
+
border: 1px solid var(--line);
|
|
343
|
+
border-radius: 6px;
|
|
344
|
+
background: rgba(255, 255, 255, .88);
|
|
345
|
+
box-shadow: var(--shadow);
|
|
346
|
+
pointer-events: auto;
|
|
347
|
+
}
|
|
348
|
+
.tool-pill button {
|
|
349
|
+
min-height: 28px;
|
|
350
|
+
padding: 4px 8px;
|
|
351
|
+
}
|
|
352
|
+
.tool-pill button.active {
|
|
353
|
+
border-color: var(--focus);
|
|
354
|
+
background: #e8f2ee;
|
|
355
|
+
color: #103f35;
|
|
356
|
+
}
|
|
357
|
+
.status {
|
|
358
|
+
color: var(--muted);
|
|
359
|
+
font-size: 12px;
|
|
360
|
+
}
|
|
361
|
+
svg {
|
|
362
|
+
display: block;
|
|
363
|
+
width: 100%;
|
|
364
|
+
height: 100vh;
|
|
365
|
+
background:
|
|
366
|
+
linear-gradient(90deg, rgba(22, 29, 24, .035) 1px, transparent 1px),
|
|
367
|
+
linear-gradient(0deg, rgba(22, 29, 24, .035) 1px, transparent 1px),
|
|
368
|
+
radial-gradient(circle at 30% 20%, #ffffff 0, #f5f6f1 42%, #edf1ea 100%);
|
|
369
|
+
background-size: 40px 40px, 40px 40px, 100% 100%;
|
|
370
|
+
}
|
|
371
|
+
.edge {
|
|
372
|
+
stroke: var(--line);
|
|
373
|
+
stroke-width: 1.4;
|
|
374
|
+
opacity: .78;
|
|
375
|
+
}
|
|
376
|
+
.edge.tree {
|
|
377
|
+
stroke: var(--tree);
|
|
378
|
+
stroke-width: 2;
|
|
379
|
+
}
|
|
380
|
+
.edge.mention {
|
|
381
|
+
stroke: var(--mention);
|
|
382
|
+
stroke-dasharray: 5 5;
|
|
383
|
+
}
|
|
384
|
+
.edge.xref {
|
|
385
|
+
stroke: var(--xref);
|
|
386
|
+
stroke-width: 2;
|
|
387
|
+
}
|
|
388
|
+
.edge.active {
|
|
389
|
+
opacity: 1;
|
|
390
|
+
stroke-width: 3;
|
|
391
|
+
}
|
|
392
|
+
.node {
|
|
393
|
+
cursor: grab;
|
|
394
|
+
}
|
|
395
|
+
.node:active {
|
|
396
|
+
cursor: grabbing;
|
|
397
|
+
}
|
|
398
|
+
.node circle {
|
|
399
|
+
stroke: #fff;
|
|
400
|
+
stroke-width: 2;
|
|
401
|
+
filter: drop-shadow(0 5px 12px rgba(24, 32, 27, .2));
|
|
402
|
+
}
|
|
403
|
+
.node.section circle {
|
|
404
|
+
fill: var(--section);
|
|
405
|
+
}
|
|
406
|
+
.node.entity circle {
|
|
407
|
+
fill: var(--entity);
|
|
408
|
+
}
|
|
409
|
+
.node.selected circle {
|
|
410
|
+
stroke: #102d27;
|
|
411
|
+
stroke-width: 3;
|
|
412
|
+
}
|
|
413
|
+
.node text {
|
|
414
|
+
font-size: 12px;
|
|
415
|
+
fill: var(--ink);
|
|
416
|
+
opacity: 0;
|
|
417
|
+
paint-order: stroke;
|
|
418
|
+
stroke: rgba(255, 255, 255, .9);
|
|
419
|
+
stroke-width: 4px;
|
|
420
|
+
stroke-linejoin: round;
|
|
421
|
+
pointer-events: none;
|
|
422
|
+
}
|
|
423
|
+
.node.selected text,
|
|
424
|
+
.node.neighbor text,
|
|
425
|
+
.node.show-label text {
|
|
426
|
+
opacity: 1;
|
|
427
|
+
}
|
|
428
|
+
.dim {
|
|
429
|
+
opacity: .15;
|
|
430
|
+
}
|
|
431
|
+
.meta {
|
|
432
|
+
display: flex;
|
|
433
|
+
flex-wrap: wrap;
|
|
434
|
+
gap: 6px;
|
|
435
|
+
margin-top: 12px;
|
|
436
|
+
}
|
|
437
|
+
.pill {
|
|
438
|
+
border: 1px solid var(--line);
|
|
439
|
+
border-radius: 999px;
|
|
440
|
+
padding: 4px 8px;
|
|
441
|
+
font-size: 12px;
|
|
442
|
+
color: var(--muted);
|
|
443
|
+
background: var(--surface-2);
|
|
444
|
+
}
|
|
445
|
+
.details-section {
|
|
446
|
+
padding: 18px;
|
|
447
|
+
border-bottom: 1px solid var(--line);
|
|
448
|
+
}
|
|
449
|
+
.details-section p {
|
|
450
|
+
font-size: 14px;
|
|
451
|
+
line-height: 1.55;
|
|
452
|
+
overflow-wrap: anywhere;
|
|
453
|
+
}
|
|
454
|
+
.rel-list {
|
|
455
|
+
display: grid;
|
|
456
|
+
gap: 7px;
|
|
457
|
+
}
|
|
458
|
+
.rel-item {
|
|
459
|
+
border: 1px solid var(--line);
|
|
460
|
+
border-radius: 6px;
|
|
461
|
+
padding: 9px 10px;
|
|
462
|
+
font-size: 13px;
|
|
463
|
+
background: var(--surface-2);
|
|
464
|
+
}
|
|
465
|
+
.rel-item strong {
|
|
466
|
+
display: block;
|
|
467
|
+
margin-bottom: 3px;
|
|
468
|
+
}
|
|
469
|
+
.empty {
|
|
470
|
+
color: var(--muted);
|
|
471
|
+
font-size: 13px;
|
|
472
|
+
}
|
|
473
|
+
@media (max-width: 1180px) {
|
|
474
|
+
.app {
|
|
475
|
+
grid-template-columns: 280px minmax(420px, 1fr);
|
|
476
|
+
}
|
|
477
|
+
.details {
|
|
478
|
+
grid-column: 1 / -1;
|
|
479
|
+
border-left: 0;
|
|
480
|
+
border-top: 1px solid var(--line);
|
|
481
|
+
max-height: 44vh;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
@media (max-width: 760px) {
|
|
485
|
+
.app {
|
|
486
|
+
display: block;
|
|
487
|
+
}
|
|
488
|
+
.sidebar,
|
|
489
|
+
.details {
|
|
490
|
+
border: 0;
|
|
491
|
+
}
|
|
492
|
+
svg {
|
|
493
|
+
height: 68vh;
|
|
494
|
+
}
|
|
495
|
+
.topbar {
|
|
496
|
+
left: 10px;
|
|
497
|
+
right: 10px;
|
|
498
|
+
top: 10px;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
</style>
|
|
502
|
+
</head>
|
|
503
|
+
<body>
|
|
504
|
+
<div class="app">
|
|
505
|
+
<aside class="sidebar">
|
|
506
|
+
<div class="sidebar-head">
|
|
507
|
+
<h1>Cairn Inspector</h1>
|
|
508
|
+
<p class="subtle" id="doc"></p>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="stats" id="stats"></div>
|
|
511
|
+
<div class="panel-block">
|
|
512
|
+
<h3>View</h3>
|
|
513
|
+
<div class="segmented">
|
|
514
|
+
<button data-mode="combined" class="active">Combined relations</button>
|
|
515
|
+
<button data-mode="tree">Outline tree</button>
|
|
516
|
+
<button data-mode="entities">Entity mentions</button>
|
|
517
|
+
<button data-mode="xrefs">Cross references</button>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
<div class="panel-block">
|
|
521
|
+
<h3>Search</h3>
|
|
522
|
+
<input class="search" id="search" placeholder="Filter graph">
|
|
523
|
+
</div>
|
|
524
|
+
<div class="panel-block">
|
|
525
|
+
<h3>Visible Nodes</h3>
|
|
526
|
+
<div class="row-list" id="node-list"></div>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="panel-block">
|
|
529
|
+
<h3>Legend</h3>
|
|
530
|
+
<div class="legend">
|
|
531
|
+
<div class="legend-row"><span class="swatch section"></span> Section</div>
|
|
532
|
+
<div class="legend-row"><span class="swatch entity"></span> Entity</div>
|
|
533
|
+
<div class="legend-row"><span class="swatch tree"></span> Parent-child</div>
|
|
534
|
+
<div class="legend-row"><span class="swatch mention"></span> Mention</div>
|
|
535
|
+
<div class="legend-row"><span class="swatch xref"></span> Cross-reference</div>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
</aside>
|
|
539
|
+
<main>
|
|
540
|
+
<div class="topbar">
|
|
541
|
+
<div class="tool-pill">
|
|
542
|
+
<button id="fit">Fit</button>
|
|
543
|
+
<button id="stabilize">Stabilize</button>
|
|
544
|
+
<button id="labels">Labels</button>
|
|
545
|
+
<span class="status" id="status"></span>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
<svg id="graph" role="img" aria-label="Cairn index relationship graph"></svg>
|
|
549
|
+
</main>
|
|
550
|
+
<section class="details" id="details"></section>
|
|
551
|
+
</div>
|
|
552
|
+
<script>
|
|
553
|
+
const DATA = __DATA__;
|
|
554
|
+
const svg = document.getElementById('graph');
|
|
555
|
+
const details = document.getElementById('details');
|
|
556
|
+
const search = document.getElementById('search');
|
|
557
|
+
const nodeList = document.getElementById('node-list');
|
|
558
|
+
const statusEl = document.getElementById('status');
|
|
559
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
560
|
+
const PERF = {
|
|
561
|
+
largeGraph: DATA.nodes.length > 80 || DATA.edges.length > 360,
|
|
562
|
+
maxVisibleEdges: DATA.edges.length > 900 ? 560 : DATA.edges.length > 360 ? 360 : 900,
|
|
563
|
+
neighborLabelBudget: DATA.nodes.length > 80 || DATA.edges.length > 360 ? 12 : 28,
|
|
564
|
+
allLabelBudget: DATA.nodes.length > 80 || DATA.edges.length > 360 ? 42 : 140,
|
|
565
|
+
allLabelNodeLimit: 120,
|
|
566
|
+
settleLimit: DATA.nodes.length > 80 || DATA.edges.length > 360 ? 48 : 130,
|
|
567
|
+
stabilizeTicks: DATA.nodes.length > 80 || DATA.edges.length > 360 ? 72 : 180,
|
|
568
|
+
};
|
|
569
|
+
let mode = 'combined';
|
|
570
|
+
let selectedId = DATA.nodes[0] ? DATA.nodes[0].id : null;
|
|
571
|
+
let nodes = [];
|
|
572
|
+
let edges = [];
|
|
573
|
+
let untrimmedEdgeCount = 0;
|
|
574
|
+
let clippedEdgeCount = 0;
|
|
575
|
+
let raf = null;
|
|
576
|
+
let settleTicks = 0;
|
|
577
|
+
let dragging = null;
|
|
578
|
+
let showAllLabels = !PERF.largeGraph;
|
|
579
|
+
let edgeEls = [];
|
|
580
|
+
let nodeEls = new Map();
|
|
581
|
+
let layoutCache = new Map();
|
|
582
|
+
function edgeVisible(e) {
|
|
583
|
+
if (mode === 'tree') return e.kind === 'tree';
|
|
584
|
+
if (mode === 'entities') return e.kind === 'mention';
|
|
585
|
+
if (mode === 'xrefs') return String(e.kind).startsWith('xref:');
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
function nodeVisible(n) {
|
|
589
|
+
if (mode === 'tree') return n.kind === 'section';
|
|
590
|
+
if (mode === 'entities') return n.kind === 'entity' || DATA.edges.some(e => e.kind === 'mention' && (e.source === n.id || e.target === n.id));
|
|
591
|
+
if (mode === 'xrefs') return n.kind === 'section';
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
function nodeSearchText(n) {
|
|
595
|
+
return (n.label + ' ' + (n.path || '') + ' ' + (n.gist || '') + ' ' + (n.synopsis || '') + ' ' + (n.entityKind || '')).toLowerCase();
|
|
596
|
+
}
|
|
597
|
+
function filteredData() {
|
|
598
|
+
const q = search.value.trim().toLowerCase();
|
|
599
|
+
let outNodes = DATA.nodes.filter(nodeVisible);
|
|
600
|
+
let matched = new Set();
|
|
601
|
+
if (q) {
|
|
602
|
+
matched = new Set(outNodes.filter(n => nodeSearchText(n).includes(q)).map(n => n.id));
|
|
603
|
+
for (const e of DATA.edges) {
|
|
604
|
+
if (matched.has(e.source)) matched.add(e.target);
|
|
605
|
+
if (matched.has(e.target)) matched.add(e.source);
|
|
606
|
+
}
|
|
607
|
+
outNodes = outNodes.filter(n => matched.has(n.id));
|
|
608
|
+
}
|
|
609
|
+
const ids = new Set(outNodes.map(n => n.id));
|
|
610
|
+
const outEdges = DATA.edges.filter(e => edgeVisible(e) && ids.has(e.source) && ids.has(e.target));
|
|
611
|
+
const visibleEdges = prioritizedEdges(outEdges, matched);
|
|
612
|
+
untrimmedEdgeCount = outEdges.length;
|
|
613
|
+
clippedEdgeCount = Math.max(0, outEdges.length - visibleEdges.length);
|
|
614
|
+
return {
|
|
615
|
+
nodes: outNodes,
|
|
616
|
+
edges: visibleEdges,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function prioritizedEdges(items, matched) {
|
|
620
|
+
if (items.length <= PERF.maxVisibleEdges) return items;
|
|
621
|
+
const selected = selectedId;
|
|
622
|
+
return [...items].sort((a, b) => edgePriority(b, selected, matched) - edgePriority(a, selected, matched)).slice(0, PERF.maxVisibleEdges);
|
|
623
|
+
}
|
|
624
|
+
function edgePriority(e, selected, matched) {
|
|
625
|
+
let score = e.kind === 'tree' ? 30 : String(e.kind).startsWith('xref:') ? 22 : 14;
|
|
626
|
+
if (selected && (e.source === selected || e.target === selected)) score += 100;
|
|
627
|
+
if (matched.has(e.source) || matched.has(e.target)) score += 60;
|
|
628
|
+
if (e.confidence) score += Number(e.confidence) * 10;
|
|
629
|
+
return score;
|
|
630
|
+
}
|
|
631
|
+
function initLayout() {
|
|
632
|
+
const rect = svg.getBoundingClientRect();
|
|
633
|
+
const w = rect.width || 1000;
|
|
634
|
+
const h = rect.height || 800;
|
|
635
|
+
const f = filteredData();
|
|
636
|
+
nodes = f.nodes.map((n, i) => {
|
|
637
|
+
const row = Math.floor(i / 4);
|
|
638
|
+
const column = i % 4;
|
|
639
|
+
const depth = Math.min(n.level || 1, 6);
|
|
640
|
+
const cached = layoutCache.get(n.id);
|
|
641
|
+
return {
|
|
642
|
+
...n,
|
|
643
|
+
x: cached ? cached.x : n.kind === 'entity' ? w - 160 - (column * 18) : 110 + depth * 90 + (column * 18),
|
|
644
|
+
y: cached ? cached.y : 86 + ((row * 58) % Math.max(160, h - 128)),
|
|
645
|
+
vx: 0,
|
|
646
|
+
vy: 0,
|
|
647
|
+
fixed: false,
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
edges = f.edges;
|
|
651
|
+
settleTicks = 0;
|
|
652
|
+
renderNodeList();
|
|
653
|
+
updateStatus();
|
|
654
|
+
}
|
|
655
|
+
function buildGraphDom() {
|
|
656
|
+
svg.innerHTML = '';
|
|
657
|
+
edgeEls = [];
|
|
658
|
+
nodeEls = new Map();
|
|
659
|
+
const defs = document.createElementNS(SVG_NS, 'defs');
|
|
660
|
+
const marker = document.createElementNS(SVG_NS, 'marker');
|
|
661
|
+
marker.setAttribute('id', 'arrow');
|
|
662
|
+
marker.setAttribute('viewBox', '0 0 10 10');
|
|
663
|
+
marker.setAttribute('refX', '9');
|
|
664
|
+
marker.setAttribute('refY', '5');
|
|
665
|
+
marker.setAttribute('markerWidth', '6');
|
|
666
|
+
marker.setAttribute('markerHeight', '6');
|
|
667
|
+
marker.setAttribute('orient', 'auto-start-reverse');
|
|
668
|
+
const arrow = document.createElementNS(SVG_NS, 'path');
|
|
669
|
+
arrow.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
|
670
|
+
arrow.setAttribute('fill', '#6f5aa5');
|
|
671
|
+
marker.appendChild(arrow);
|
|
672
|
+
defs.appendChild(marker);
|
|
673
|
+
svg.appendChild(defs);
|
|
674
|
+
|
|
675
|
+
const edgeLayer = document.createElementNS(SVG_NS, 'g');
|
|
676
|
+
edgeLayer.setAttribute('class', 'edge-layer');
|
|
677
|
+
const nodeLayer = document.createElementNS(SVG_NS, 'g');
|
|
678
|
+
nodeLayer.setAttribute('class', 'node-layer');
|
|
679
|
+
svg.appendChild(edgeLayer);
|
|
680
|
+
svg.appendChild(nodeLayer);
|
|
681
|
+
|
|
682
|
+
const edgeFragment = document.createDocumentFragment();
|
|
683
|
+
for (const e of edges) {
|
|
684
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
685
|
+
const edgeClass = e.kind === 'tree' ? 'tree' : e.kind === 'mention' ? 'mention' : 'xref';
|
|
686
|
+
line.setAttribute('class', 'edge ' + edgeClass);
|
|
687
|
+
if (edgeClass === 'xref') line.setAttribute('marker-end', 'url(#arrow)');
|
|
688
|
+
edgeFragment.appendChild(line);
|
|
689
|
+
edgeEls.push({ edge: e, el: line, edgeClass });
|
|
690
|
+
}
|
|
691
|
+
edgeLayer.appendChild(edgeFragment);
|
|
692
|
+
|
|
693
|
+
const nodeFragment = document.createDocumentFragment();
|
|
694
|
+
for (const n of nodes) {
|
|
695
|
+
const g = document.createElementNS(SVG_NS, 'g');
|
|
696
|
+
g.setAttribute('class', 'node ' + n.kind);
|
|
697
|
+
g.addEventListener('pointerdown', event => startDrag(event, n.id));
|
|
698
|
+
g.addEventListener('click', () => selectNode(n.id));
|
|
699
|
+
const c = document.createElementNS(SVG_NS, 'circle');
|
|
700
|
+
c.setAttribute('r', n.kind === 'entity' ? Math.min(18, 7 + Math.sqrt(n.mentions || 1) * 3) : Math.max(8, 16 - (n.level || 1)));
|
|
701
|
+
const t = document.createElementNS(SVG_NS, 'text');
|
|
702
|
+
t.setAttribute('x', 14); t.setAttribute('y', 4);
|
|
703
|
+
t.textContent = n.label.length > 34 ? n.label.slice(0, 32) + '...' : n.label;
|
|
704
|
+
g.appendChild(c); g.appendChild(t);
|
|
705
|
+
nodeFragment.appendChild(g);
|
|
706
|
+
nodeEls.set(n.id, g);
|
|
707
|
+
}
|
|
708
|
+
nodeLayer.appendChild(nodeFragment);
|
|
709
|
+
}
|
|
710
|
+
function tick() {
|
|
711
|
+
const rect = svg.getBoundingClientRect();
|
|
712
|
+
const w = rect.width || 1000;
|
|
713
|
+
const h = rect.height || 800;
|
|
714
|
+
const byId = new Map(nodes.map(n => [n.id, n]));
|
|
715
|
+
for (let step = 0; step < 1; step++) {
|
|
716
|
+
for (const n of nodes) {
|
|
717
|
+
if (n.fixed) continue;
|
|
718
|
+
n.vx += (w / 2 - n.x) * 0.0008;
|
|
719
|
+
n.vy += (h / 2 - n.y) * 0.0008;
|
|
720
|
+
if (n.kind === 'section') n.vx += ((120 + Math.min(n.level || 1, 5) * 115) - n.x) * 0.0017;
|
|
721
|
+
if (n.kind === 'entity') n.vx += ((w - 190) - n.x) * 0.0014;
|
|
722
|
+
}
|
|
723
|
+
const pairStep = nodes.length > 180 ? Math.ceil(nodes.length / 160) : 1;
|
|
724
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
725
|
+
for (let j = i + 1; j < nodes.length; j += pairStep) {
|
|
726
|
+
const a = nodes[i], b = nodes[j];
|
|
727
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
728
|
+
const d2 = dx * dx + dy * dy || 1;
|
|
729
|
+
const d = Math.sqrt(d2);
|
|
730
|
+
const force = Math.min(4200 / d2, 1.8);
|
|
731
|
+
dx /= d; dy /= d;
|
|
732
|
+
if (!a.fixed) {
|
|
733
|
+
a.vx -= dx * force; a.vy -= dy * force;
|
|
734
|
+
}
|
|
735
|
+
if (!b.fixed) {
|
|
736
|
+
b.vx += dx * force; b.vy += dy * force;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
for (const e of edges) {
|
|
741
|
+
const a = byId.get(e.source), b = byId.get(e.target);
|
|
742
|
+
if (!a || !b) continue;
|
|
743
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
744
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
745
|
+
const desired = e.kind === 'tree' ? 145 : 220;
|
|
746
|
+
const force = (d - desired) * 0.012;
|
|
747
|
+
dx /= d; dy /= d;
|
|
748
|
+
if (!a.fixed) {
|
|
749
|
+
a.vx += dx * force; a.vy += dy * force;
|
|
750
|
+
}
|
|
751
|
+
if (!b.fixed) {
|
|
752
|
+
b.vx -= dx * force; b.vy -= dy * force;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const n of nodes) {
|
|
756
|
+
if (n.fixed) continue;
|
|
757
|
+
n.vx *= 0.82; n.vy *= 0.82;
|
|
758
|
+
n.x = Math.max(28, Math.min(w - 28, n.x + n.vx));
|
|
759
|
+
n.y = Math.max(62, Math.min(h - 28, n.y + n.vy));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
settleTicks += 1;
|
|
763
|
+
}
|
|
764
|
+
function linkedNodeIds() {
|
|
765
|
+
const linked = new Set();
|
|
766
|
+
if (selectedId) {
|
|
767
|
+
linked.add(selectedId);
|
|
768
|
+
for (const e of edges) {
|
|
769
|
+
if (e.source === selectedId) linked.add(e.target);
|
|
770
|
+
if (e.target === selectedId) linked.add(e.source);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return linked;
|
|
774
|
+
}
|
|
775
|
+
function relationCounts() {
|
|
776
|
+
const counts = new Map();
|
|
777
|
+
for (const e of edges) {
|
|
778
|
+
counts.set(e.source, (counts.get(e.source) || 0) + 1);
|
|
779
|
+
counts.set(e.target, (counts.get(e.target) || 0) + 1);
|
|
780
|
+
}
|
|
781
|
+
return counts;
|
|
782
|
+
}
|
|
783
|
+
function labelNodeIds(linked) {
|
|
784
|
+
const labels = new Set();
|
|
785
|
+
const counts = relationCounts();
|
|
786
|
+
const byImportance = (a, b) => {
|
|
787
|
+
const aScore = (a.kind === 'entity' ? a.mentions || 0 : counts.get(a.id) || 0);
|
|
788
|
+
const bScore = (b.kind === 'entity' ? b.mentions || 0 : counts.get(b.id) || 0);
|
|
789
|
+
return bScore - aScore;
|
|
790
|
+
};
|
|
791
|
+
if (showAllLabels) {
|
|
792
|
+
const candidates = PERF.largeGraph
|
|
793
|
+
? [...nodes].sort(byImportance).slice(0, PERF.allLabelBudget)
|
|
794
|
+
: nodes.length <= PERF.allLabelNodeLimit
|
|
795
|
+
? nodes
|
|
796
|
+
: [...nodes].sort(byImportance).slice(0, PERF.allLabelBudget);
|
|
797
|
+
for (const n of candidates) labels.add(n.id);
|
|
798
|
+
}
|
|
799
|
+
if (selectedId) labels.add(selectedId);
|
|
800
|
+
const neighbors = nodes
|
|
801
|
+
.filter(n => n.id !== selectedId && linked.has(n.id))
|
|
802
|
+
.sort(byImportance)
|
|
803
|
+
.slice(0, PERF.neighborLabelBudget);
|
|
804
|
+
for (const n of neighbors) labels.add(n.id);
|
|
805
|
+
return labels;
|
|
806
|
+
}
|
|
807
|
+
function updateGraphDom() {
|
|
808
|
+
const linked = linkedNodeIds();
|
|
809
|
+
const labels = labelNodeIds(linked);
|
|
810
|
+
const byId = new Map(nodes.map(n => [n.id, n]));
|
|
811
|
+
for (const item of edgeEls) {
|
|
812
|
+
const e = item.edge;
|
|
813
|
+
const a = byId.get(e.source), b = byId.get(e.target);
|
|
814
|
+
if (!a || !b) continue;
|
|
815
|
+
item.el.setAttribute('x1', a.x); item.el.setAttribute('y1', a.y);
|
|
816
|
+
item.el.setAttribute('x2', b.x); item.el.setAttribute('y2', b.y);
|
|
817
|
+
const active = selectedId && (e.source === selectedId || e.target === selectedId);
|
|
818
|
+
item.el.setAttribute('class', 'edge ' + item.edgeClass + (active ? ' active' : '') + (selectedId && !linked.has(e.source) && !linked.has(e.target) ? ' dim' : ''));
|
|
819
|
+
}
|
|
820
|
+
for (const n of nodes) {
|
|
821
|
+
const g = nodeEls.get(n.id);
|
|
822
|
+
if (!g) continue;
|
|
823
|
+
const labelClass = labels.has(n.id) ? ' show-label' : '';
|
|
824
|
+
const neighborClass = selectedId !== n.id && linked.has(n.id) ? ' neighbor' : '';
|
|
825
|
+
g.setAttribute('class', 'node ' + n.kind + labelClass + neighborClass + (selectedId === n.id ? ' selected' : '') + (selectedId && !linked.has(n.id) ? ' dim' : ''));
|
|
826
|
+
g.setAttribute('transform', `translate(${n.x},${n.y})`);
|
|
827
|
+
layoutCache.set(n.id, { x: n.x, y: n.y });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function renderFrame() {
|
|
831
|
+
if (settleTicks < PERF.settleLimit || dragging) tick();
|
|
832
|
+
updateGraphDom();
|
|
833
|
+
if (settleTicks < PERF.settleLimit || dragging) {
|
|
834
|
+
raf = requestAnimationFrame(renderFrame);
|
|
835
|
+
} else {
|
|
836
|
+
raf = null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function startAnimation() {
|
|
840
|
+
if (raf) cancelAnimationFrame(raf);
|
|
841
|
+
raf = requestAnimationFrame(renderFrame);
|
|
842
|
+
}
|
|
843
|
+
function renderStatic() {
|
|
844
|
+
if (raf) cancelAnimationFrame(raf);
|
|
845
|
+
raf = null;
|
|
846
|
+
updateGraphDom();
|
|
847
|
+
}
|
|
848
|
+
function selectNode(id) {
|
|
849
|
+
selectedId = id;
|
|
850
|
+
showDetails();
|
|
851
|
+
renderNodeList();
|
|
852
|
+
renderStatic();
|
|
853
|
+
}
|
|
854
|
+
function startDrag(event, id) {
|
|
855
|
+
const node = nodes.find(n => n.id === id);
|
|
856
|
+
if (!node) return;
|
|
857
|
+
dragging = { id, dx: node.x - event.clientX, dy: node.y - event.clientY };
|
|
858
|
+
node.fixed = true;
|
|
859
|
+
svg.setPointerCapture(event.pointerId);
|
|
860
|
+
startAnimation();
|
|
861
|
+
}
|
|
862
|
+
svg.addEventListener('pointermove', event => {
|
|
863
|
+
if (!dragging) return;
|
|
864
|
+
const node = nodes.find(n => n.id === dragging.id);
|
|
865
|
+
if (!node) return;
|
|
866
|
+
const rect = svg.getBoundingClientRect();
|
|
867
|
+
node.x = Math.max(28, Math.min(rect.width - 28, event.clientX + dragging.dx));
|
|
868
|
+
node.y = Math.max(62, Math.min(rect.height - 28, event.clientY + dragging.dy));
|
|
869
|
+
node.vx = 0;
|
|
870
|
+
node.vy = 0;
|
|
871
|
+
renderStatic();
|
|
872
|
+
});
|
|
873
|
+
svg.addEventListener('pointerup', () => {
|
|
874
|
+
dragging = null;
|
|
875
|
+
startAnimation();
|
|
876
|
+
});
|
|
877
|
+
svg.addEventListener('pointerleave', () => {
|
|
878
|
+
dragging = null;
|
|
879
|
+
startAnimation();
|
|
880
|
+
});
|
|
881
|
+
function showDetails() {
|
|
882
|
+
const node = DATA.nodes.find(n => n.id === selectedId) || DATA.nodes[0];
|
|
883
|
+
if (!node) return;
|
|
884
|
+
const rels = DATA.edges.filter(e => e.source === node.id || e.target === node.id).map(e => {
|
|
885
|
+
const otherId = e.source === node.id ? e.target : e.source;
|
|
886
|
+
const other = DATA.nodes.find(n => n.id === otherId);
|
|
887
|
+
return `<div class="rel-item"><strong>${escapeHtml(e.kind)}</strong><span>${escapeHtml(other ? other.label : otherId)}</span></div>`;
|
|
888
|
+
}).join('') || '<p class="empty">No direct relations.</p>';
|
|
889
|
+
if (node.kind === 'section') {
|
|
890
|
+
details.innerHTML = `<div class="details-head"><h2>${escapeHtml(node.label)}</h2><div class="meta"><span class="pill">section</span><span class="pill">level ${node.level}</span></div></div><div class="details-section"><h3>Path</h3><p>${escapeHtml(node.path || '')}</p></div><div class="details-section"><h3>Gist</h3><p>${escapeHtml(node.gist || '')}</p></div><div class="details-section"><h3>Synopsis</h3><p>${escapeHtml(node.synopsis || '')}</p></div><div class="details-section"><h3>Head</h3><p>${escapeHtml(node.head || '')}</p></div><div class="details-section"><h3>Relations</h3><div class="rel-list">${rels}</div></div>`;
|
|
891
|
+
} else {
|
|
892
|
+
details.innerHTML = `<div class="details-head"><h2>${escapeHtml(node.label)}</h2><div class="meta"><span class="pill">entity</span><span class="pill">${escapeHtml(node.entityKind || '')}</span><span class="pill">${node.mentions || 0} mentions</span></div></div><div class="details-section"><h3>Surface Forms</h3><p>${escapeHtml((node.surfaceForms || []).join(', '))}</p></div><div class="details-section"><h3>Relations</h3><div class="rel-list">${rels}</div></div>`;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function escapeHtml(value) {
|
|
896
|
+
return String(value).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
|
897
|
+
}
|
|
898
|
+
function drawStats() {
|
|
899
|
+
document.getElementById('doc').textContent = DATA.docId;
|
|
900
|
+
document.getElementById('stats').innerHTML = [
|
|
901
|
+
['Sections', DATA.stats.sections],
|
|
902
|
+
['Entities', DATA.stats.entitiesShown + '/' + DATA.stats.entities],
|
|
903
|
+
['Relations', DATA.stats.edges],
|
|
904
|
+
['Depth', DATA.stats.maxDepth],
|
|
905
|
+
].map(([label, value]) => `<div class="stat"><strong>${value}</strong><span>${label}</span></div>`).join('');
|
|
906
|
+
}
|
|
907
|
+
function renderNodeList() {
|
|
908
|
+
const q = search.value.trim().toLowerCase();
|
|
909
|
+
const visible = new Set(nodes.map(n => n.id));
|
|
910
|
+
const relCount = new Map();
|
|
911
|
+
for (const e of DATA.edges) {
|
|
912
|
+
relCount.set(e.source, (relCount.get(e.source) || 0) + 1);
|
|
913
|
+
relCount.set(e.target, (relCount.get(e.target) || 0) + 1);
|
|
914
|
+
}
|
|
915
|
+
const rows = DATA.nodes
|
|
916
|
+
.filter(n => visible.has(n.id))
|
|
917
|
+
.sort((a, b) => (b.kind === 'entity' ? b.mentions || 0 : relCount.get(b.id) || 0) - (a.kind === 'entity' ? a.mentions || 0 : relCount.get(a.id) || 0))
|
|
918
|
+
.slice(0, 80)
|
|
919
|
+
.map(n => {
|
|
920
|
+
const meta = n.kind === 'entity' ? `${escapeHtml(n.entityKind || 'entity')} - ${n.mentions || 0} mentions` : `level ${n.level || 0} - ${relCount.get(n.id) || 0} relations`;
|
|
921
|
+
return `<div class="row ${selectedId === n.id ? 'active' : ''}" data-id="${escapeHtml(n.id)}"><div class="row-title">${escapeHtml(n.label)}</div><div class="row-meta">${meta}</div></div>`;
|
|
922
|
+
});
|
|
923
|
+
nodeList.innerHTML = rows.join('') || `<p class="empty">${q ? 'No matches.' : 'No visible nodes.'}</p>`;
|
|
924
|
+
nodeList.querySelectorAll('.row').forEach(row => row.addEventListener('click', () => selectNode(row.dataset.id)));
|
|
925
|
+
}
|
|
926
|
+
function updateStatus() {
|
|
927
|
+
const edgeText = clippedEdgeCount > 0 ? `${edges.length}/${untrimmedEdgeCount} edges` : `${edges.length} edges`;
|
|
928
|
+
statusEl.textContent = `${nodes.length} nodes - ${edgeText}`;
|
|
929
|
+
}
|
|
930
|
+
function reset() {
|
|
931
|
+
if (raf) cancelAnimationFrame(raf);
|
|
932
|
+
initLayout();
|
|
933
|
+
buildGraphDom();
|
|
934
|
+
const q = search.value.trim().toLowerCase();
|
|
935
|
+
const directMatch = q ? nodes.find(n => nodeSearchText(n).includes(q)) : null;
|
|
936
|
+
if (directMatch) {
|
|
937
|
+
selectedId = directMatch.id;
|
|
938
|
+
} else if (!nodes.some(n => n.id === selectedId)) {
|
|
939
|
+
selectedId = nodes[0] ? nodes[0].id : null;
|
|
940
|
+
}
|
|
941
|
+
showDetails();
|
|
942
|
+
renderNodeList();
|
|
943
|
+
startAnimation();
|
|
944
|
+
}
|
|
945
|
+
document.querySelectorAll('button[data-mode]').forEach(btn => btn.addEventListener('click', () => {
|
|
946
|
+
document.querySelectorAll('button[data-mode]').forEach(b => b.classList.remove('active'));
|
|
947
|
+
btn.classList.add('active');
|
|
948
|
+
mode = btn.dataset.mode;
|
|
949
|
+
reset();
|
|
950
|
+
}));
|
|
951
|
+
search.addEventListener('input', reset);
|
|
952
|
+
document.getElementById('fit').addEventListener('click', reset);
|
|
953
|
+
document.getElementById('stabilize').addEventListener('click', () => {
|
|
954
|
+
for (let i = 0; i < PERF.stabilizeTicks; i++) tick();
|
|
955
|
+
settleTicks = PERF.settleLimit;
|
|
956
|
+
renderStatic();
|
|
957
|
+
});
|
|
958
|
+
document.getElementById('labels').addEventListener('click', event => {
|
|
959
|
+
showAllLabels = !showAllLabels;
|
|
960
|
+
event.currentTarget.classList.toggle('active', showAllLabels);
|
|
961
|
+
renderStatic();
|
|
962
|
+
});
|
|
963
|
+
window.addEventListener('resize', reset);
|
|
964
|
+
document.getElementById('labels').classList.toggle('active', showAllLabels);
|
|
965
|
+
drawStats();
|
|
966
|
+
reset();
|
|
967
|
+
</script>
|
|
968
|
+
</body>
|
|
969
|
+
</html>
|
|
970
|
+
"""
|
|
971
|
+
return template.replace("__DOC_TITLE__", title).replace("__DATA__", data_json)
|