memgit 0.1.1__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.
- memgit/__init__.py +3 -0
- memgit/cli.py +1267 -0
- memgit/graph.py +486 -0
- memgit/http_server.py +231 -0
- memgit/importer.py +121 -0
- memgit/mcp_server.py +418 -0
- memgit/models.py +80 -0
- memgit/repo.py +714 -0
- memgit/scorer.py +123 -0
- memgit/store.py +176 -0
- memgit/tokens.py +48 -0
- memgit/toon.py +356 -0
- memgit-0.1.1.dist-info/METADATA +457 -0
- memgit-0.1.1.dist-info/RECORD +18 -0
- memgit-0.1.1.dist-info/WHEEL +5 -0
- memgit-0.1.1.dist-info/entry_points.txt +2 -0
- memgit-0.1.1.dist-info/licenses/LICENSE +21 -0
- memgit-0.1.1.dist-info/top_level.txt +1 -0
memgit/graph.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Graph builder and HTML visualizer for memgit memory stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .models import Mnemonic, Checkpoint
|
|
11
|
+
from .repo import Repository
|
|
12
|
+
|
|
13
|
+
_TYPE_COLOR = {
|
|
14
|
+
"fb": "#f97316", # orange — feedback
|
|
15
|
+
"us": "#3b82f6", # blue — user
|
|
16
|
+
"pj": "#10b981", # teal — project
|
|
17
|
+
"rf": "#8b5cf6", # violet — reference
|
|
18
|
+
"cn": "#eab308", # yellow — convention
|
|
19
|
+
"lx": "#ec4899", # pink — lesson
|
|
20
|
+
}
|
|
21
|
+
_TYPE_LABEL = {
|
|
22
|
+
"fb": "feedback",
|
|
23
|
+
"us": "user",
|
|
24
|
+
"pj": "project",
|
|
25
|
+
"rf": "reference",
|
|
26
|
+
"cn": "convention",
|
|
27
|
+
"lx": "lesson",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_WIKILINK_RE = re.compile(r'\[\[([a-z0-9_-]+)\]\]', re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_edges(mnemonics: list["Mnemonic"]) -> list[dict]:
|
|
34
|
+
slug_set = {m.slug for m in mnemonics}
|
|
35
|
+
edges = []
|
|
36
|
+
seen = set()
|
|
37
|
+
for m in mnemonics:
|
|
38
|
+
# Explicit links
|
|
39
|
+
for ref in m.related:
|
|
40
|
+
if ref in slug_set:
|
|
41
|
+
key = (m.slug, ref, "related")
|
|
42
|
+
if key not in seen:
|
|
43
|
+
edges.append({"source": m.slug, "target": ref, "type": "related"})
|
|
44
|
+
seen.add(key)
|
|
45
|
+
for ref in m.supersedes:
|
|
46
|
+
if ref in slug_set:
|
|
47
|
+
key = (m.slug, ref, "supersedes")
|
|
48
|
+
if key not in seen:
|
|
49
|
+
edges.append({"source": m.slug, "target": ref, "type": "supersedes"})
|
|
50
|
+
seen.add(key)
|
|
51
|
+
# Implicit [[wikilink]] refs in text fields
|
|
52
|
+
text = " ".join(filter(None, [m.rule, m.why, m.when, m.desc]))
|
|
53
|
+
for ref in _WIKILINK_RE.findall(text):
|
|
54
|
+
if ref in slug_set and ref != m.slug:
|
|
55
|
+
key = (m.slug, ref, "ref")
|
|
56
|
+
if key not in seen:
|
|
57
|
+
edges.append({"source": m.slug, "target": ref, "type": "ref"})
|
|
58
|
+
seen.add(key)
|
|
59
|
+
return edges
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_graph_data(repo: "Repository") -> dict:
|
|
63
|
+
mnemonics = repo.list()
|
|
64
|
+
checkpoints = repo.log(limit=20)
|
|
65
|
+
|
|
66
|
+
nodes = []
|
|
67
|
+
for m in mnemonics:
|
|
68
|
+
rule_preview = m.rule[:120] + "…" if len(m.rule) > 120 else m.rule
|
|
69
|
+
nodes.append({
|
|
70
|
+
"id": m.slug,
|
|
71
|
+
"type": m.type_code,
|
|
72
|
+
"priority": m.priority,
|
|
73
|
+
"rule": rule_preview,
|
|
74
|
+
"tags": m.tags,
|
|
75
|
+
"color": _TYPE_COLOR.get(m.type_code, "#94a3b8"),
|
|
76
|
+
"type_label": _TYPE_LABEL.get(m.type_code, m.type_code),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
edges = _extract_edges(mnemonics)
|
|
80
|
+
|
|
81
|
+
ck_list = []
|
|
82
|
+
for ck in checkpoints:
|
|
83
|
+
d = ck.diff_summary
|
|
84
|
+
ck_list.append({
|
|
85
|
+
"sha": ck.sha[:8] if ck.sha else "?",
|
|
86
|
+
"message": ck.message,
|
|
87
|
+
"date": ck.timestamp.strftime("%Y-%m-%d %H:%M"),
|
|
88
|
+
"trigger": ck.trigger,
|
|
89
|
+
"added": len(d.added) if d else 0,
|
|
90
|
+
"modified": len(d.modified) if d else 0,
|
|
91
|
+
"removed": len(d.removed) if d else 0,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
type_counts: dict[str, int] = {}
|
|
95
|
+
for m in mnemonics:
|
|
96
|
+
type_counts[m.type_code] = type_counts.get(m.type_code, 0) + 1
|
|
97
|
+
|
|
98
|
+
thread = repo.current_thread()
|
|
99
|
+
head = repo.head_sha()
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"nodes": nodes,
|
|
103
|
+
"edges": edges,
|
|
104
|
+
"checkpoints": ck_list,
|
|
105
|
+
"type_counts": type_counts,
|
|
106
|
+
"type_colors": _TYPE_COLOR,
|
|
107
|
+
"type_labels": _TYPE_LABEL,
|
|
108
|
+
"meta": {
|
|
109
|
+
"thread": thread,
|
|
110
|
+
"head": head[:8] if head else "?",
|
|
111
|
+
"total": len(mnemonics),
|
|
112
|
+
"edge_count": len(edges),
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_HTML_TEMPLATE = r"""<!DOCTYPE html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="UTF-8">
|
|
121
|
+
<title>memgit graph — {thread} / {head}</title>
|
|
122
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
123
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
124
|
+
<style>
|
|
125
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
126
|
+
body { font-family: 'SF Mono', 'Fira Code', monospace; background: #0f1117; color: #e2e8f0; height: 100vh; display: flex; flex-direction: column; }
|
|
127
|
+
|
|
128
|
+
#header { padding: 10px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
|
129
|
+
#header h1 { font-size: 14px; font-weight: 600; color: #38bdf8; letter-spacing: 0.05em; }
|
|
130
|
+
#header .meta { font-size: 11px; color: #64748b; }
|
|
131
|
+
#header .badge { background: #1e293b; border: 1px solid #334155; border-radius: 4px; padding: 2px 8px; font-size: 11px; color: #94a3b8; }
|
|
132
|
+
|
|
133
|
+
#main { display: flex; flex: 1; overflow: hidden; }
|
|
134
|
+
|
|
135
|
+
#sidebar { width: 240px; flex-shrink: 0; border-right: 1px solid #1e293b; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 14px; }
|
|
136
|
+
#sidebar h2 { font-size: 10px; font-weight: 600; letter-spacing: 0.1em; color: #475569; text-transform: uppercase; margin-bottom: 6px; }
|
|
137
|
+
|
|
138
|
+
.filter-btn { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 5px; border: 1px solid #1e293b; background: #1e293b; cursor: pointer; font-size: 11px; font-family: inherit; color: #94a3b8; width: 100%; text-align: left; transition: all 0.15s; }
|
|
139
|
+
.filter-btn .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
140
|
+
.filter-btn .count { margin-left: auto; color: #475569; }
|
|
141
|
+
.filter-btn.active { border-color: currentColor; color: #e2e8f0; background: #0f172a; }
|
|
142
|
+
.filter-btn:hover { border-color: #334155; }
|
|
143
|
+
|
|
144
|
+
#stats-list { list-style: none; }
|
|
145
|
+
#stats-list li { display: flex; justify-content: space-between; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1e293b; color: #94a3b8; }
|
|
146
|
+
#stats-list li span:last-child { color: #e2e8f0; font-weight: 600; }
|
|
147
|
+
|
|
148
|
+
.ck-entry { background: #1e293b; border-radius: 6px; padding: 7px 9px; margin-bottom: 6px; border-left: 3px solid #334155; }
|
|
149
|
+
.ck-sha { font-size: 11px; color: #fbbf24; font-weight: 600; }
|
|
150
|
+
.ck-msg { font-size: 11px; color: #94a3b8; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
|
+
.ck-date { font-size: 10px; color: #475569; margin-top: 2px; }
|
|
152
|
+
.ck-delta { font-size: 10px; margin-top: 3px; display: flex; gap: 6px; }
|
|
153
|
+
.ck-add { color: #34d399; }
|
|
154
|
+
.ck-mod { color: #fbbf24; }
|
|
155
|
+
.ck-del { color: #f87171; }
|
|
156
|
+
|
|
157
|
+
#canvas-wrap { flex: 1; position: relative; overflow: hidden; }
|
|
158
|
+
#canvas-wrap svg { width: 100%; height: 100%; }
|
|
159
|
+
|
|
160
|
+
#tooltip { position: absolute; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px 13px; font-size: 11px; max-width: 280px; pointer-events: none; display: none; z-index: 100; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
|
|
161
|
+
#tooltip .t-slug { font-weight: 700; color: #38bdf8; font-size: 12px; margin-bottom: 4px; }
|
|
162
|
+
#tooltip .t-type { font-size: 10px; color: #64748b; margin-bottom: 6px; }
|
|
163
|
+
#tooltip .t-rule { color: #cbd5e1; line-height: 1.5; }
|
|
164
|
+
#tooltip .t-tags { margin-top: 6px; color: #64748b; font-size: 10px; }
|
|
165
|
+
|
|
166
|
+
#search-wrap { padding: 10px; border-bottom: 1px solid #1e293b; flex-shrink: 0; }
|
|
167
|
+
#search { width: 100%; background: #1e293b; border: 1px solid #334155; border-radius: 5px; padding: 6px 10px; color: #e2e8f0; font-family: inherit; font-size: 12px; outline: none; }
|
|
168
|
+
#search:focus { border-color: #38bdf8; }
|
|
169
|
+
#search::placeholder { color: #475569; }
|
|
170
|
+
|
|
171
|
+
.node circle { cursor: pointer; stroke-width: 1.5px; }
|
|
172
|
+
.node text { font-size: 9px; fill: #94a3b8; pointer-events: none; font-family: 'SF Mono', monospace; }
|
|
173
|
+
.node.highlighted circle { stroke: #fff; stroke-width: 2.5px; }
|
|
174
|
+
.node.dimmed circle { opacity: 0.15; }
|
|
175
|
+
.node.dimmed text { opacity: 0.1; }
|
|
176
|
+
|
|
177
|
+
.link { stroke-opacity: 0.4; }
|
|
178
|
+
.link.related { stroke: #22d3ee; stroke-dasharray: 0; }
|
|
179
|
+
.link.supersedes { stroke: #f87171; stroke-dasharray: 4 2; }
|
|
180
|
+
.link.ref { stroke: #64748b; stroke-dasharray: 0; }
|
|
181
|
+
.link.dimmed { stroke-opacity: 0.05; }
|
|
182
|
+
|
|
183
|
+
#legend { position: absolute; bottom: 12px; right: 12px; background: rgba(15,17,23,0.9); border: 1px solid #1e293b; border-radius: 8px; padding: 10px 12px; font-size: 10px; color: #64748b; }
|
|
184
|
+
#legend div { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
|
185
|
+
#legend .ldot { width: 8px; height: 8px; border-radius: 50%; }
|
|
186
|
+
#legend .lline { width: 20px; height: 2px; }
|
|
187
|
+
#legend .lref { border-top: 2px solid #64748b; }
|
|
188
|
+
#legend .lrelated { border-top: 2px solid #22d3ee; }
|
|
189
|
+
#legend .lsupersedes { border-top: 2px dashed #f87171; }
|
|
190
|
+
</style>
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
|
|
194
|
+
<div id="header">
|
|
195
|
+
<h1>memgit graph</h1>
|
|
196
|
+
<span class="badge">thread: {thread}</span>
|
|
197
|
+
<span class="badge">HEAD: {head}</span>
|
|
198
|
+
<span class="meta" id="vis-count"></span>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div id="main">
|
|
202
|
+
<div id="sidebar">
|
|
203
|
+
<div>
|
|
204
|
+
<h2>Filter by type</h2>
|
|
205
|
+
<div id="filter-btns"></div>
|
|
206
|
+
</div>
|
|
207
|
+
<div>
|
|
208
|
+
<h2>Stats</h2>
|
|
209
|
+
<ul id="stats-list"></ul>
|
|
210
|
+
</div>
|
|
211
|
+
<div>
|
|
212
|
+
<h2>Checkpoints</h2>
|
|
213
|
+
<div id="ck-list"></div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
|
218
|
+
<div id="search-wrap">
|
|
219
|
+
<input id="search" type="text" placeholder="Search memories…">
|
|
220
|
+
</div>
|
|
221
|
+
<div id="canvas-wrap">
|
|
222
|
+
<svg id="graph"></svg>
|
|
223
|
+
<div id="tooltip"></div>
|
|
224
|
+
<div id="legend">
|
|
225
|
+
<div><span style="color:#94a3b8;font-size:10px;font-weight:600">Node size = priority</span></div>
|
|
226
|
+
<div><span class="lline lref"></span> wikilink ref</div>
|
|
227
|
+
<div><span class="lline lrelated"></span> related</div>
|
|
228
|
+
<div><span class="lline lsupersedes"></span> supersedes</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<script>
|
|
235
|
+
const DATA = __GRAPH_DATA__;
|
|
236
|
+
|
|
237
|
+
const TYPE_LABELS = DATA.type_labels;
|
|
238
|
+
const TYPE_COLORS = DATA.type_colors;
|
|
239
|
+
const nodes = DATA.nodes;
|
|
240
|
+
const edges = DATA.edges;
|
|
241
|
+
const checkpoints = DATA.checkpoints;
|
|
242
|
+
const typeCounts = DATA.type_counts;
|
|
243
|
+
const meta = DATA.meta;
|
|
244
|
+
|
|
245
|
+
// ── Sidebar: filters ─────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
document.getElementById('vis-count').textContent =
|
|
248
|
+
`${meta.total} memories · ${meta.edge_count} links`;
|
|
249
|
+
|
|
250
|
+
const filterBtns = document.getElementById('filter-btns');
|
|
251
|
+
let activeTypes = new Set(Object.keys(typeCounts));
|
|
252
|
+
|
|
253
|
+
Object.entries(typeCounts).sort((a,b) => b[1]-a[1]).forEach(([tc, cnt]) => {
|
|
254
|
+
const btn = document.createElement('button');
|
|
255
|
+
btn.className = 'filter-btn active';
|
|
256
|
+
btn.dataset.type = tc;
|
|
257
|
+
const col = TYPE_COLORS[tc] || '#94a3b8';
|
|
258
|
+
btn.innerHTML = `<span class="dot" style="background:${col}"></span>${TYPE_LABELS[tc]||tc}<span class="count">${cnt}</span>`;
|
|
259
|
+
btn.style.color = col;
|
|
260
|
+
btn.addEventListener('click', () => {
|
|
261
|
+
if (activeTypes.has(tc)) {
|
|
262
|
+
if (activeTypes.size === 1) return;
|
|
263
|
+
activeTypes.delete(tc);
|
|
264
|
+
btn.classList.remove('active');
|
|
265
|
+
} else {
|
|
266
|
+
activeTypes.add(tc);
|
|
267
|
+
btn.classList.add('active');
|
|
268
|
+
}
|
|
269
|
+
updateVisibility();
|
|
270
|
+
});
|
|
271
|
+
filterBtns.appendChild(btn);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Sidebar: stats ────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const statsList = document.getElementById('stats-list');
|
|
277
|
+
[
|
|
278
|
+
['Memories', meta.total],
|
|
279
|
+
['Links', meta.edge_count],
|
|
280
|
+
['Types', Object.keys(typeCounts).length],
|
|
281
|
+
['Checkpoints', checkpoints.length],
|
|
282
|
+
].forEach(([k, v]) => {
|
|
283
|
+
statsList.innerHTML += `<li><span>${k}</span><span>${v}</span></li>`;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── Sidebar: checkpoints ──────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
const ckList = document.getElementById('ck-list');
|
|
289
|
+
checkpoints.forEach(ck => {
|
|
290
|
+
const div = document.createElement('div');
|
|
291
|
+
div.className = 'ck-entry';
|
|
292
|
+
const delta = [
|
|
293
|
+
ck.added ? `<span class="ck-add">+${ck.added}</span>` : '',
|
|
294
|
+
ck.modified? `<span class="ck-mod">~${ck.modified}</span>` : '',
|
|
295
|
+
ck.removed ? `<span class="ck-del">-${ck.removed}</span>` : '',
|
|
296
|
+
].filter(Boolean).join('');
|
|
297
|
+
div.innerHTML = `
|
|
298
|
+
<div class="ck-sha">${ck.sha}</div>
|
|
299
|
+
<div class="ck-msg">${ck.message}</div>
|
|
300
|
+
<div class="ck-date">${ck.date} · ${ck.trigger}</div>
|
|
301
|
+
${delta ? `<div class="ck-delta">${delta}</div>` : ''}
|
|
302
|
+
`;
|
|
303
|
+
ckList.appendChild(div);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── D3 Graph ──────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
const svg = d3.select('#graph');
|
|
309
|
+
const wrap = document.getElementById('canvas-wrap');
|
|
310
|
+
|
|
311
|
+
let W = wrap.clientWidth, H = wrap.clientHeight;
|
|
312
|
+
|
|
313
|
+
const g = svg.append('g');
|
|
314
|
+
|
|
315
|
+
svg.call(d3.zoom()
|
|
316
|
+
.scaleExtent([0.1, 4])
|
|
317
|
+
.on('zoom', e => g.attr('transform', e.transform))
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const priorityRadius = { 1: 5, 2: 8, 3: 13 };
|
|
321
|
+
|
|
322
|
+
const sim = d3.forceSimulation(nodes)
|
|
323
|
+
.force('link', d3.forceLink(edges).id(d => d.id).distance(80).strength(0.4))
|
|
324
|
+
.force('charge', d3.forceManyBody().strength(-120))
|
|
325
|
+
.force('center', d3.forceCenter(W / 2, H / 2))
|
|
326
|
+
.force('collision', d3.forceCollide().radius(d => (priorityRadius[d.priority] || 8) + 4));
|
|
327
|
+
|
|
328
|
+
const link = g.append('g').attr('class', 'links')
|
|
329
|
+
.selectAll('line')
|
|
330
|
+
.data(edges)
|
|
331
|
+
.enter().append('line')
|
|
332
|
+
.attr('class', d => `link ${d.type}`);
|
|
333
|
+
|
|
334
|
+
const node = g.append('g').attr('class', 'nodes')
|
|
335
|
+
.selectAll('g')
|
|
336
|
+
.data(nodes)
|
|
337
|
+
.enter().append('g')
|
|
338
|
+
.attr('class', 'node')
|
|
339
|
+
.call(d3.drag()
|
|
340
|
+
.on('start', dragstart)
|
|
341
|
+
.on('drag', dragged)
|
|
342
|
+
.on('end', dragend)
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
node.append('circle')
|
|
346
|
+
.attr('r', d => priorityRadius[d.priority] || 8)
|
|
347
|
+
.attr('fill', d => d.color)
|
|
348
|
+
.attr('stroke', d => d.color)
|
|
349
|
+
.attr('fill-opacity', 0.85);
|
|
350
|
+
|
|
351
|
+
node.append('text')
|
|
352
|
+
.attr('dy', d => (priorityRadius[d.priority] || 8) + 10)
|
|
353
|
+
.attr('text-anchor', 'middle')
|
|
354
|
+
.text(d => d.id.length > 18 ? d.id.slice(0, 17) + '…' : d.id);
|
|
355
|
+
|
|
356
|
+
sim.on('tick', () => {
|
|
357
|
+
link
|
|
358
|
+
.attr('x1', d => d.source.x)
|
|
359
|
+
.attr('y1', d => d.source.y)
|
|
360
|
+
.attr('x2', d => d.target.x)
|
|
361
|
+
.attr('y2', d => d.target.y);
|
|
362
|
+
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Resize
|
|
366
|
+
window.addEventListener('resize', () => {
|
|
367
|
+
W = wrap.clientWidth; H = wrap.clientHeight;
|
|
368
|
+
sim.force('center', d3.forceCenter(W / 2, H / 2)).alpha(0.1).restart();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Tooltip
|
|
372
|
+
const tooltip = document.getElementById('tooltip');
|
|
373
|
+
|
|
374
|
+
node.on('mouseover', (event, d) => {
|
|
375
|
+
const tags = d.tags.filter(t => !['fb','pj','us','rf','cn','lx'].includes(t));
|
|
376
|
+
tooltip.innerHTML = `
|
|
377
|
+
<div class="t-slug">${d.id}</div>
|
|
378
|
+
<div class="t-type">[${d.type}] ${d.type_label} · priority ${d.priority}</div>
|
|
379
|
+
<div class="t-rule">${d.rule}</div>
|
|
380
|
+
${tags.length ? `<div class="t-tags">tags: ${tags.join(', ')}</div>` : ''}
|
|
381
|
+
`;
|
|
382
|
+
tooltip.style.display = 'block';
|
|
383
|
+
moveTooltip(event);
|
|
384
|
+
})
|
|
385
|
+
.on('mousemove', moveTooltip)
|
|
386
|
+
.on('mouseout', () => { tooltip.style.display = 'none'; });
|
|
387
|
+
|
|
388
|
+
function moveTooltip(event) {
|
|
389
|
+
const rect = wrap.getBoundingClientRect();
|
|
390
|
+
let x = event.clientX - rect.left + 14;
|
|
391
|
+
let y = event.clientY - rect.top + 14;
|
|
392
|
+
if (x + 290 > W) x -= 300;
|
|
393
|
+
if (y + 150 > H) y -= 160;
|
|
394
|
+
tooltip.style.left = x + 'px';
|
|
395
|
+
tooltip.style.top = y + 'px';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Click to highlight neighbours
|
|
399
|
+
const edgeSet = new Map();
|
|
400
|
+
edges.forEach(e => {
|
|
401
|
+
const src = typeof e.source === 'object' ? e.source.id : e.source;
|
|
402
|
+
const tgt = typeof e.target === 'object' ? e.target.id : e.target;
|
|
403
|
+
if (!edgeSet.has(src)) edgeSet.set(src, new Set());
|
|
404
|
+
if (!edgeSet.has(tgt)) edgeSet.set(tgt, new Set());
|
|
405
|
+
edgeSet.get(src).add(tgt);
|
|
406
|
+
edgeSet.get(tgt).add(src);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
let selected = null;
|
|
410
|
+
|
|
411
|
+
node.on('click', (event, d) => {
|
|
412
|
+
event.stopPropagation();
|
|
413
|
+
if (selected === d.id) {
|
|
414
|
+
selected = null;
|
|
415
|
+
node.classed('highlighted', false).classed('dimmed', false);
|
|
416
|
+
link.classed('dimmed', false);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
selected = d.id;
|
|
420
|
+
const neighbours = edgeSet.get(d.id) || new Set();
|
|
421
|
+
node.classed('highlighted', n => n.id === d.id || neighbours.has(n.id));
|
|
422
|
+
node.classed('dimmed', n => n.id !== d.id && !neighbours.has(n.id));
|
|
423
|
+
link.classed('dimmed', l => {
|
|
424
|
+
const src = typeof l.source === 'object' ? l.source.id : l.source;
|
|
425
|
+
const tgt = typeof l.target === 'object' ? l.target.id : l.target;
|
|
426
|
+
return src !== d.id && tgt !== d.id;
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
svg.on('click', () => {
|
|
431
|
+
selected = null;
|
|
432
|
+
node.classed('highlighted', false).classed('dimmed', false);
|
|
433
|
+
link.classed('dimmed', false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Search
|
|
437
|
+
const searchInput = document.getElementById('search');
|
|
438
|
+
searchInput.addEventListener('input', () => {
|
|
439
|
+
const q = searchInput.value.trim().toLowerCase();
|
|
440
|
+
if (!q) {
|
|
441
|
+
node.classed('highlighted', false).classed('dimmed', false);
|
|
442
|
+
link.classed('dimmed', false);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const matches = new Set(
|
|
446
|
+
nodes.filter(n => n.id.includes(q) || n.rule.toLowerCase().includes(q) || n.tags.some(t => t.includes(q)))
|
|
447
|
+
.map(n => n.id)
|
|
448
|
+
);
|
|
449
|
+
node.classed('highlighted', n => matches.has(n.id));
|
|
450
|
+
node.classed('dimmed', n => !matches.has(n.id));
|
|
451
|
+
link.classed('dimmed', l => {
|
|
452
|
+
const src = typeof l.source === 'object' ? l.source.id : l.source;
|
|
453
|
+
const tgt = typeof l.target === 'object' ? l.target.id : l.target;
|
|
454
|
+
return !matches.has(src) && !matches.has(tgt);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Type filter → show/hide nodes
|
|
459
|
+
function updateVisibility() {
|
|
460
|
+
node.style('display', d => activeTypes.has(d.type) ? null : 'none');
|
|
461
|
+
link.style('display', l => {
|
|
462
|
+
const src = typeof l.source === 'object' ? l.source : nodes.find(n=>n.id===l.source);
|
|
463
|
+
const tgt = typeof l.target === 'object' ? l.target : nodes.find(n=>n.id===l.target);
|
|
464
|
+
return (src && activeTypes.has(src.type) && tgt && activeTypes.has(tgt.type)) ? null : 'none';
|
|
465
|
+
});
|
|
466
|
+
const visible = nodes.filter(n => activeTypes.has(n.type)).length;
|
|
467
|
+
document.getElementById('vis-count').textContent =
|
|
468
|
+
`${visible}/${meta.total} memories · ${meta.edge_count} links`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Drag helpers
|
|
472
|
+
function dragstart(event, d) { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
|
|
473
|
+
function dragged(event, d) { d.fx = event.x; d.fy = event.y; }
|
|
474
|
+
function dragend(event, d) { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }
|
|
475
|
+
</script>
|
|
476
|
+
</body>
|
|
477
|
+
</html>
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def render_html(data: dict) -> str:
|
|
482
|
+
html = _HTML_TEMPLATE
|
|
483
|
+
html = html.replace("{thread}", data["meta"]["thread"])
|
|
484
|
+
html = html.replace("{head}", data["meta"]["head"])
|
|
485
|
+
html = html.replace("__GRAPH_DATA__", json.dumps(data, ensure_ascii=False))
|
|
486
|
+
return html
|