@nex-ai/nex 0.1.20 → 0.1.21

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.
@@ -0,0 +1,846 @@
1
+ /**
2
+ * Self-contained HTML graph visualization using D3.js with SVG rendering.
3
+ * Exact clone of Zep's graph approach, adapted for Nex entity/insight structure.
4
+ * Generates a single HTML string that can be opened in any browser.
5
+ */
6
+ const NODE_COLORS = {
7
+ person: "#EC4899", // pink-500
8
+ company: "#3B82F6", // blue-500
9
+ deal: "#F59E0B", // amber-500
10
+ lead: "#EF4444", // red-500
11
+ contact: "#06B6D4", // cyan-500
12
+ opportunity: "#F97316", // orange-500
13
+ task: "#10B981", // emerald-500
14
+ note: "#84CC16", // lime-500
15
+ event: "#8B5CF6", // violet-500
16
+ topic: "#14B8A6", // teal-500
17
+ product: "#A855F7", // purple-500
18
+ project: "#6366F1", // indigo-500
19
+ ghost: "#8B5CF6", // violet-500
20
+ entity_insight: "#EC4899", // pink-500
21
+ knowledge_insight: "#06B6D4", // cyan-500
22
+ };
23
+ const DEFAULT_COLOR = "#EC4899";
24
+ function escapeHtml(str) {
25
+ return str
26
+ .replace(/&/g, "&")
27
+ .replace(/</g, "&lt;")
28
+ .replace(/>/g, "&gt;")
29
+ .replace(/"/g, "&quot;")
30
+ .replace(/'/g, "&#39;");
31
+ }
32
+ function escapeJsonForHtml(data) {
33
+ return JSON.stringify(data)
34
+ .replace(/</g, "\\u003c")
35
+ .replace(/>/g, "\\u003e")
36
+ .replace(/&/g, "\\u0026");
37
+ }
38
+ export function generateGraphHtml(data) {
39
+ const nodeColors = { ...NODE_COLORS };
40
+ const typeSet = new Set();
41
+ for (const n of data.nodes)
42
+ typeSet.add(n.type);
43
+ for (const n of (data.insight_nodes ?? []))
44
+ typeSet.add(n.source);
45
+ const legendTypes = Array.from(typeSet).sort();
46
+ // Count total insights
47
+ let totalInsights = 0;
48
+ for (const key of Object.keys(data.insights ?? {})) {
49
+ totalInsights += (data.insights[key] ?? []).length;
50
+ }
51
+ totalInsights += (data.insight_nodes ?? []).length;
52
+ const totalConnections = data.total_edges + data.total_context_edges;
53
+ return `<!DOCTYPE html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width,initial-scale=1">
58
+ <title>Nex — Workspace Graph</title>
59
+ <style>
60
+ *{margin:0;padding:0;box-sizing:border-box}
61
+ body{background:#0f172a;color:#e2e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow:hidden}
62
+ svg{width:100%;height:100vh;cursor:grab;border-radius:0;position:fixed;top:0;left:0;z-index:1}
63
+ svg:active{cursor:grabbing}
64
+ #search-box{position:fixed;top:16px;left:16px;z-index:10;background:rgba(30,41,59,0.9);border:1px solid #334155;border-radius:10px;padding:8px 14px;color:#e2e8f0;font-size:14px;width:240px;outline:none;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
65
+ #search-box::placeholder{color:#64748b}
66
+ #search-box:focus{border-color:#EC4899;box-shadow:0 0 0 2px rgba(236,72,153,.2)}
67
+ #stats{position:fixed;top:16px;right:16px;z-index:10;background:rgba(30,41,59,0.9);border:1px solid #334155;border-radius:10px;padding:8px 14px;font-size:13px;color:#94a3b8;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
68
+ #legend{position:fixed;bottom:16px;left:16px;z-index:10;background:rgba(30,41,59,0.9);border:1px solid #334155;border-radius:10px;padding:10px 14px;font-size:13px;max-height:50vh;overflow-y:auto;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
69
+ .legend-item{display:flex;align-items:center;gap:8px;margin:4px 0;color:#94a3b8}
70
+ .legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
71
+ .legend-line{width:20px;height:2px;flex-shrink:0;border-radius:1px}
72
+ .legend-sep{border-top:1px solid #334155;margin:6px 0}
73
+ #detail-panel{position:fixed;top:0;right:-380px;width:380px;height:100vh;background:rgba(30,41,59,0.95);border-left:1px solid #334155;z-index:20;padding:20px;overflow-y:auto;transition:right .25s cubic-bezier(.4,0,.2,1);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
74
+ #detail-panel.open{right:0}
75
+ #detail-panel h3{margin-bottom:12px;font-size:16px;color:#f1f5f9;font-weight:600}
76
+ #detail-panel .field{margin-bottom:10px;font-size:13px}
77
+ #detail-panel .field .label{color:#64748b;font-size:11px;text-transform:uppercase;letter-spacing:.5px;font-weight:500}
78
+ #detail-panel .field .value{color:#cbd5e1;margin-top:2px;word-wrap:break-word;overflow-wrap:break-word;white-space:pre-wrap}
79
+ .insight-card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:10px 12px;margin-top:6px}
80
+ .insight-card .insight-type{display:inline-block;background:#334155;border-radius:4px;padding:2px 8px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#94a3b8;margin-right:6px;font-weight:500}
81
+ .insight-card .insight-confidence{font-size:11px;color:#64748b;float:right}
82
+ .insight-card .insight-content{margin-top:6px;font-size:12px;color:#94a3b8;line-height:1.5;word-wrap:break-word;overflow-wrap:break-word;white-space:pre-wrap}
83
+ #detail-close{position:absolute;top:12px;right:12px;background:none;border:none;color:#64748b;cursor:pointer;font-size:18px;transition:color .15s}
84
+ #detail-close:hover{color:#e2e8f0}
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <input id="search-box" type="text" placeholder="Search entities..." autocomplete="off">
89
+ <div id="stats">${escapeHtml(String(data.total_nodes))} entities &middot; ${escapeHtml(String(totalConnections))} connections &middot; ${escapeHtml(String(totalInsights))} insights</div>
90
+ <div id="legend">
91
+ ${legendTypes
92
+ .map((t) => `<div class="legend-item"><span class="legend-dot" style="background:${escapeHtml(nodeColors[t] ?? DEFAULT_COLOR)}"></span>${escapeHtml(t)}</div>`)
93
+ .join("\n")}
94
+ <div class="legend-sep"></div>
95
+ <div class="legend-item"><span class="legend-line" style="background:#475569"></span>Formal</div>
96
+ <div class="legend-item"><span class="legend-line" style="background:#8B5CF6"></span>Context</div>
97
+ <div class="legend-item"><span class="legend-line" style="background:#F59E0B"></span>Triplet</div>
98
+ <div class="legend-item"><span class="legend-line" style="background:#EC4899"></span>Insight</div>
99
+ </div>
100
+ <div id="detail-panel">
101
+ <button id="detail-close">&times;</button>
102
+ <h3 id="detail-name"></h3>
103
+ <div id="detail-body"></div>
104
+ </div>
105
+
106
+ <!-- D3.js v7 — same as Zep's force-graph approach -->
107
+ <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
108
+
109
+ <script id="graph-data" type="application/json">${escapeJsonForHtml(data)}</script>
110
+
111
+ <script>
112
+ (function(){
113
+ var data = JSON.parse(document.getElementById("graph-data").textContent);
114
+ var width = window.innerWidth;
115
+ var height = window.innerHeight;
116
+
117
+ var nodeColors = {
118
+ person:"#EC4899", company:"#3B82F6", deal:"#F59E0B",
119
+ lead:"#EF4444", contact:"#06B6D4", opportunity:"#F97316",
120
+ task:"#10B981", note:"#84CC16", event:"#8B5CF6",
121
+ topic:"#14B8A6", product:"#A855F7", project:"#6366F1",
122
+ ghost:"#8B5CF6", entity_insight:"#EC4899", knowledge_insight:"#06B6D4"
123
+ };
124
+ var defaultColor = "#EC4899";
125
+ var edgeTypeColors = {formal:"#475569", context:"#8B5CF6", triplet:"#F59E0B", insight:"#EC4899"};
126
+
127
+ // ── Build entity ID set (entities + ghosts, NO insights yet) ──
128
+ var entityIdSet = {};
129
+ data.nodes.forEach(function(n){ entityIdSet[n.id] = true; });
130
+
131
+ // ── Index insight nodes by target entity for on-demand expansion ──
132
+ var insightsByEntity = {};
133
+ (data.context_edges || []).forEach(function(e){
134
+ if (e.edge_type === "insight") {
135
+ var targetId = entityIdSet[e.target] ? e.target : (entityIdSet[e.source] ? e.source : null);
136
+ var insightId = e.target === targetId ? e.source : e.target;
137
+ if (targetId) {
138
+ if (!insightsByEntity[targetId]) insightsByEntity[targetId] = [];
139
+ var insData = null;
140
+ (data.insight_nodes || []).forEach(function(ins){ if (ins.id === insightId) insData = ins; });
141
+ if (insData) insightsByEntity[targetId].push({node: insData, edge: e});
142
+ }
143
+ }
144
+ });
145
+
146
+ // ── Count insights per entity ──
147
+ var insightCount = {};
148
+ // Use insightsByEntity as authoritative count (same data as insightsMap, avoid double-counting)
149
+ for (var eid in insightsByEntity) insightCount[eid] = insightsByEntity[eid].length;
150
+ var insightsMap = data.insights || {};
151
+ // Only count from insightsMap for entities NOT already counted via insightsByEntity
152
+ for (var eid2 in insightsMap) {
153
+ if (!insightCount[eid2]) insightCount[eid2] = (insightsMap[eid2] || []).length;
154
+ }
155
+
156
+ // ── Build links from formal edges + non-insight context edges ──
157
+ // Group links by source-target pair for curve offset (like Zep)
158
+ var linkGroupMap = {};
159
+ function addLink(source, target, label, edgeType) {
160
+ if (!entityIdSet[source] || !entityIdSet[target]) return;
161
+ var key = source < target ? source + "-" + target : target + "-" + source;
162
+ if (!linkGroupMap[key]) linkGroupMap[key] = [];
163
+ linkGroupMap[key].push({source: source, target: target, label: label, edgeType: edgeType, color: edgeTypeColors[edgeType] || "#475569"});
164
+ }
165
+ (data.edges || []).forEach(function(e){ addLink(e.source, e.target, e.label, "formal"); });
166
+ (data.context_edges || []).forEach(function(e){
167
+ if (e.edge_type === "insight") return;
168
+ addLink(e.source, e.target, e.label, e.edge_type);
169
+ });
170
+
171
+ // Assign curve strengths per group (Zep's pattern)
172
+ var baseLinks = [];
173
+ for (var gk in linkGroupMap) {
174
+ var group = linkGroupMap[gk];
175
+ var count = group.length;
176
+ var baseStrength = 0.2;
177
+ group.forEach(function(lnk, idx){
178
+ lnk.curveStrength = count > 1 ? (-baseStrength + idx * (baseStrength * 2) / (count - 1)) : 0;
179
+ baseLinks.push(lnk);
180
+ });
181
+ }
182
+
183
+ // ── Build degree map ──
184
+ var degree = {};
185
+ data.nodes.forEach(function(n){ degree[n.id] = 0; });
186
+ baseLinks.forEach(function(l){
187
+ degree[l.source] = (degree[l.source] || 0) + 1;
188
+ degree[l.target] = (degree[l.target] || 0) + 1;
189
+ });
190
+
191
+ // ── Identify isolated & ghost nodes ──
192
+ var linkedNodeIds = {};
193
+ baseLinks.forEach(function(l){ linkedNodeIds[l.source] = true; linkedNodeIds[l.target] = true; });
194
+ var isolatedNodeIds = {};
195
+ data.nodes.forEach(function(n){ if (!linkedNodeIds[n.id]) isolatedNodeIds[n.id] = true; });
196
+
197
+ // ── Build D3 node objects ──
198
+ var entityNodes = data.nodes.map(function(n){
199
+ return {
200
+ id: n.id, name: n.name, type: n.type, nodeKind: "entity",
201
+ primary_attribute: n.primary_attribute || "", created_at: n.created_at || "",
202
+ color: nodeColors[n.type] || defaultColor,
203
+ insightCount: insightCount[n.id] || 0
204
+ };
205
+ });
206
+
207
+ // ── Track expanded insights ──
208
+ var expandedEntities = {};
209
+
210
+ // ── Current working nodes/links arrays ──
211
+ var currentNodes = entityNodes.slice();
212
+ var currentLinks = baseLinks.map(function(l){ return {source: l.source, target: l.target, label: l.label, edgeType: l.edgeType, color: l.color, curveStrength: l.curveStrength}; });
213
+
214
+ function rebuildGraphArrays() {
215
+ currentNodes = entityNodes.slice();
216
+ currentLinks = baseLinks.map(function(l){ return {source: l.source, target: l.target, label: l.label, edgeType: l.edgeType, color: l.color, curveStrength: l.curveStrength}; });
217
+ for (var entId in expandedEntities) {
218
+ var items = insightsByEntity[entId] || [];
219
+ var entNode = null;
220
+ entityNodes.forEach(function(n){ if (n.id === entId) entNode = n; });
221
+ items.forEach(function(item, i){
222
+ var ins = item.node;
223
+ var fullContent = ins.content || "";
224
+ var label = fullContent.length > 50 ? fullContent.substring(0, 48) + ".." : fullContent;
225
+ var angle = (2 * Math.PI * i) / items.length;
226
+ var dist = 60;
227
+ currentNodes.push({
228
+ id: ins.id, name: label, fullContent: fullContent,
229
+ type: ins.source, nodeKind: "insight",
230
+ primary_attribute: ins.type, created_at: "",
231
+ color: nodeColors[ins.source] || "#EC4899",
232
+ insightCount: 0, confidence: ins.confidence,
233
+ x: entNode ? entNode.x + Math.cos(angle) * dist : width/2,
234
+ y: entNode ? entNode.y + Math.sin(angle) * dist : height/2
235
+ });
236
+ currentLinks.push({
237
+ source: ins.id, target: entId, label: ins.type,
238
+ edgeType: "insight", color: "#EC4899", curveStrength: 0
239
+ });
240
+ });
241
+ }
242
+ }
243
+
244
+ // ── Focus/search/hover state ──
245
+ var focusedNodeId = null;
246
+ var focusedSet = {};
247
+ var searchActive = false;
248
+ var searchMatches = {};
249
+
250
+ // ══════════════════════════════════════════════════════════
251
+ // SVG SETUP — exact Zep pattern: <svg> → <g> for zoom
252
+ // ══════════════════════════════════════════════════════════
253
+ var svg = d3.select("body").append("svg")
254
+ .attr("width", width)
255
+ .attr("height", height)
256
+ .attr("viewBox", "0 0 " + width + " " + height)
257
+ .style("background-color", "#0f172a");
258
+
259
+ // Drop-shadow filter (Zep: drop-shadow(0 2px 4px rgba(0,0,0,0.2)))
260
+ var defs = svg.append("defs");
261
+ var filter = defs.append("filter").attr("id", "drop-shadow").attr("x", "-50%").attr("y", "-50%").attr("width", "200%").attr("height", "200%");
262
+ filter.append("feDropShadow").attr("dx", 0).attr("dy", 2).attr("stdDeviation", 2).attr("flood-color", "rgba(0,0,0,0.3)");
263
+
264
+ var g = svg.append("g");
265
+
266
+ // ── Zoom behavior — Zep: scaleExtent [0.1, 4] ──
267
+ var currentZoomScale = 0.8;
268
+ var LABEL_ZOOM_THRESHOLD = 0.6; // node labels appear above this
269
+ var LINK_LABEL_ZOOM_THRESHOLD = 0.9; // link labels appear above this
270
+
271
+ var zoomBehavior = d3.zoom()
272
+ .scaleExtent([0.1, 4])
273
+ .on("zoom", function(event){
274
+ g.attr("transform", event.transform);
275
+ var newScale = event.transform.k;
276
+ // Toggle label visibility on scale change
277
+ if ((newScale >= LABEL_ZOOM_THRESHOLD) !== (currentZoomScale >= LABEL_ZOOM_THRESHOLD)) {
278
+ nodeLayer.selectAll("g.node-group > text:not(.insight-badge)")
279
+ .attr("display", newScale >= LABEL_ZOOM_THRESHOLD ? null : "none");
280
+ }
281
+ if ((newScale >= LINK_LABEL_ZOOM_THRESHOLD) !== (currentZoomScale >= LINK_LABEL_ZOOM_THRESHOLD)) {
282
+ linkLayer.selectAll(".link-label")
283
+ .attr("display", newScale >= LINK_LABEL_ZOOM_THRESHOLD ? null : "none");
284
+ }
285
+ currentZoomScale = newScale;
286
+ });
287
+ svg.call(zoomBehavior).call(zoomBehavior.transform, d3.zoomIdentity.scale(0.8).translate(width * 0.1, height * 0.1));
288
+
289
+ // Click on SVG background → unfocus
290
+ svg.on("click", function(event){
291
+ if (event.target.tagName === "svg" || event.target === svg.node()) {
292
+ unfocus();
293
+ }
294
+ });
295
+
296
+ // ── Layer groups (links below nodes) ──
297
+ var linkLayer = g.append("g").attr("class", "links");
298
+ var nodeLayer = g.append("g").attr("class", "nodes");
299
+
300
+ // ══════════════════════════════════════════════════════════
301
+ // D3 FORCE SIMULATION — exact Zep parameters
302
+ // ══════════════════════════════════════════════════════════
303
+ var simulation = d3.forceSimulation(currentNodes)
304
+ .force("link", d3.forceLink(currentLinks).id(function(d){ return d.id; }).distance(200).strength(0.2))
305
+ .force("charge", d3.forceManyBody()
306
+ .strength(function(d){ return isolatedNodeIds[d.id] ? -500 : -3000; })
307
+ .distanceMin(20).distanceMax(500).theta(0.8))
308
+ .force("center", d3.forceCenter(width / 2, height / 2).strength(0.05))
309
+ .force("collide", d3.forceCollide().radius(50).strength(0.3).iterations(5))
310
+ .force("isolatedGravity", d3.forceRadial(100, width / 2, height / 2)
311
+ .strength(function(d){ return isolatedNodeIds[d.id] ? 0.15 : (d.type === "ghost" ? 0.03 : 0.01); }))
312
+ .velocityDecay(0.4)
313
+ .alphaDecay(0.05)
314
+ .alphaMin(0.001);
315
+
316
+ // ══════════════════════════════════════════════════════════
317
+ // RENDER FUNCTIONS
318
+ // ══════════════════════════════════════════════════════════
319
+ var linkSelection, nodeSelection;
320
+
321
+ function render() {
322
+ // ── LINKS ──
323
+ linkLayer.selectAll("g.link-group").remove();
324
+ linkSelection = linkLayer.selectAll("g.link-group")
325
+ .data(currentLinks, function(d){ return d.source.id + "-" + d.target.id + "-" + d.label; })
326
+ .join("g")
327
+ .attr("class", "link-group");
328
+
329
+ // Path for each link
330
+ linkSelection.append("path")
331
+ .attr("stroke", function(d){ return d.color; })
332
+ .attr("stroke-opacity", 0.6)
333
+ .attr("stroke-width", 1)
334
+ .attr("fill", "none")
335
+ .attr("cursor", "pointer");
336
+
337
+ // Label group (rect + text) — Zep pattern
338
+ var labelG = linkSelection.append("g")
339
+ .attr("class", "link-label")
340
+ .attr("cursor", "pointer")
341
+ .attr("display", currentZoomScale >= LINK_LABEL_ZOOM_THRESHOLD ? null : "none");
342
+
343
+ labelG.append("rect")
344
+ .attr("fill", "#1e293b")
345
+ .attr("rx", 4).attr("ry", 4)
346
+ .attr("opacity", 0.9);
347
+
348
+ labelG.append("text")
349
+ .attr("fill", "#94a3b8")
350
+ .attr("font-size", "8px")
351
+ .attr("text-anchor", "middle")
352
+ .attr("dominant-baseline", "middle")
353
+ .attr("pointer-events", "none")
354
+ .text(function(d){ return d.label || ""; });
355
+
356
+ // ── NODES ──
357
+ nodeLayer.selectAll("g.node-group").remove();
358
+ nodeSelection = nodeLayer.selectAll("g.node-group")
359
+ .data(currentNodes, function(d){ return d.id; })
360
+ .join("g")
361
+ .attr("class", "node-group")
362
+ .attr("cursor", "pointer")
363
+ .call(dragBehavior);
364
+
365
+ // Circle — Zep: r=10, drop-shadow, stroke-width 1
366
+ nodeSelection.append("circle")
367
+ .attr("r", function(d){
368
+ if (d.nodeKind === "insight") return 6;
369
+ if (d.type === "ghost") return 7;
370
+ return 10;
371
+ })
372
+ .attr("fill", function(d){ return d.color; })
373
+ .attr("stroke", "#e2e8f0")
374
+ .attr("stroke-width", function(d){ return d.type === "ghost" ? 1 : 1; })
375
+ .attr("filter", "url(#drop-shadow)")
376
+ .attr("stroke-dasharray", function(d){ return d.type === "ghost" ? "2,2" : null; });
377
+
378
+ // Insight count badge (white text inside node)
379
+ nodeSelection.filter(function(d){ return d.nodeKind !== "insight" && d.insightCount > 0; })
380
+ .append("text")
381
+ .attr("class", "insight-badge")
382
+ .attr("text-anchor", "middle")
383
+ .attr("dominant-baseline", "central")
384
+ .attr("font-size", "8px")
385
+ .attr("font-weight", "700")
386
+ .attr("fill", "#fff")
387
+ .attr("pointer-events", "none")
388
+ .text(function(d){ return d.insightCount; });
389
+
390
+ // Label — Zep: x=15, font-size 12px, font-weight 500
391
+ // Hidden until zoom >= LABEL_ZOOM_THRESHOLD
392
+ nodeSelection.append("text")
393
+ .attr("x", 15)
394
+ .attr("y", "0.3em")
395
+ .attr("text-anchor", "start")
396
+ .attr("fill", "#e2e8f0")
397
+ .attr("font-weight", "500")
398
+ .attr("font-size", "12px")
399
+ .attr("pointer-events", "none")
400
+ .attr("display", currentZoomScale >= LABEL_ZOOM_THRESHOLD ? null : "none")
401
+ .text(function(d){
402
+ var name = d.name || d.id;
403
+ return name.length > 28 ? name.substring(0, 26) + ".." : name;
404
+ });
405
+
406
+ // Click handler
407
+ nodeSelection.on("click", function(event, d){
408
+ event.stopPropagation();
409
+ handleNodeClick(d);
410
+ });
411
+
412
+ // Hover handlers
413
+ nodeSelection.on("mouseenter", function(event, d){
414
+ d3.select(this).select("circle")
415
+ .attr("stroke", "#60a5fa")
416
+ .attr("stroke-width", 3);
417
+ }).on("mouseleave", function(event, d){
418
+ d3.select(this).select("circle")
419
+ .attr("stroke", "#e2e8f0")
420
+ .attr("stroke-width", 1);
421
+ applyVisualState();
422
+ });
423
+ }
424
+
425
+ // ── Drag behavior — exact Zep pattern ──
426
+ var dragBehavior = d3.drag()
427
+ .on("start", function(event){
428
+ if (!event.active) simulation.velocityDecay(0.7).alphaDecay(0.1).alphaTarget(0.1).restart();
429
+ event.subject.fx = event.subject.x;
430
+ event.subject.fy = event.subject.y;
431
+ d3.select(this).select("circle").attr("stroke", "#60a5fa").attr("stroke-width", 3);
432
+ })
433
+ .on("drag", function(event){
434
+ event.subject.fx = event.x;
435
+ event.subject.fy = event.y;
436
+ })
437
+ .on("end", function(event){
438
+ if (!event.active) simulation.velocityDecay(0.4).alphaDecay(0.05).alphaTarget(0);
439
+ event.subject.fx = event.x;
440
+ event.subject.fy = event.y;
441
+ d3.select(this).select("circle").attr("stroke", "#e2e8f0").attr("stroke-width", 1);
442
+ });
443
+
444
+ // ══════════════════════════════════════════════════════════
445
+ // TICK — update positions (Zep: quadratic Bézier paths)
446
+ // ══════════════════════════════════════════════════════════
447
+ simulation.on("tick", function(){
448
+ if (!linkSelection || !nodeSelection) return;
449
+
450
+ linkSelection.each(function(d){
451
+ var el = d3.select(this);
452
+ var path = el.select("path");
453
+ var labelGroup = el.select(".link-label");
454
+
455
+ var sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y;
456
+ if (sx == null || tx == null) return;
457
+
458
+ // Self-referencing loop
459
+ if (d.source.id === d.target.id) {
460
+ var rx = 40, ry = 90, offset = ry + 20;
461
+ var cx = sx, cy = sy - offset;
462
+ path.attr("d", "M" + sx + "," + sy + " C" + (cx-rx) + "," + cy + " " + (cx+rx) + "," + cy + " " + sx + "," + sy);
463
+ labelGroup.attr("transform", "translate(" + cx + "," + (cy-10) + ")");
464
+ } else {
465
+ // Quadratic Bézier — Zep's curve calculation
466
+ var dx = tx - sx, dy = ty - sy;
467
+ var dr = Math.sqrt(dx*dx + dy*dy) || 1;
468
+ var midX = (sx + tx) / 2, midY = (sy + ty) / 2;
469
+ var normalX = -dy / dr, normalY = dx / dr;
470
+ var curveMag = dr * (d.curveStrength || 0);
471
+ var controlX = midX + normalX * curveMag;
472
+ var controlY = midY + normalY * curveMag;
473
+ path.attr("d", "M" + sx + "," + sy + " Q" + controlX + "," + controlY + " " + tx + "," + ty);
474
+
475
+ // Position label at midpoint of Bézier — Zep's getPointAtLength pattern
476
+ var pathNode = path.node();
477
+ if (pathNode && pathNode.getTotalLength) {
478
+ var pathLen = pathNode.getTotalLength();
479
+ var midPt = pathNode.getPointAtLength(pathLen / 2);
480
+ if (midPt) {
481
+ var angle = Math.atan2(ty - sy, tx - sx) * 180 / Math.PI;
482
+ var rot = (angle > 90 || angle < -90) ? angle - 180 : angle;
483
+ labelGroup.attr("transform", "translate(" + midPt.x + "," + midPt.y + ") rotate(" + rot + ")");
484
+ }
485
+ }
486
+ }
487
+
488
+ // Size the label background rect
489
+ var text = labelGroup.select("text");
490
+ var rect = labelGroup.select("rect");
491
+ var textNode = text.node();
492
+ if (textNode) {
493
+ var bbox = textNode.getBBox();
494
+ if (bbox.width > 0) {
495
+ rect.attr("x", -bbox.width/2 - 6).attr("y", -bbox.height/2 - 4)
496
+ .attr("width", bbox.width + 12).attr("height", bbox.height + 8);
497
+ }
498
+ }
499
+ });
500
+
501
+ // Update node positions — Zep: translate(x,y)
502
+ nodeSelection.attr("transform", function(d){ return "translate(" + d.x + "," + d.y + ")"; });
503
+ });
504
+
505
+ // ── Initial render ──
506
+ render();
507
+
508
+ // ── Zoom-to-fit after simulation settles ──
509
+ var hasZoomed = false;
510
+ simulation.on("end", function(){
511
+ if (hasZoomed) return;
512
+ hasZoomed = true;
513
+ zoomToFit();
514
+ });
515
+ // Fallback zoom-to-fit
516
+ setTimeout(function(){ if (!hasZoomed) { hasZoomed = true; zoomToFit(); } }, 2000);
517
+
518
+ function zoomToFit(duration) {
519
+ duration = duration || 750;
520
+ var bounds = g.node().getBBox();
521
+ if (!bounds || bounds.width === 0) return;
522
+ var fullW = width, fullH = height;
523
+ var midX = bounds.x + bounds.width / 2;
524
+ var midY = bounds.y + bounds.height / 2;
525
+ var scale = 0.65 * Math.min(fullW / bounds.width, fullH / bounds.height);
526
+ if (!isFinite(scale) || !isFinite(midX)) return;
527
+ var transform = d3.zoomIdentity
528
+ .translate(fullW/2 - midX*scale, fullH/2 - midY*scale)
529
+ .scale(scale);
530
+ svg.transition().duration(duration).ease(d3.easeCubicInOut).call(zoomBehavior.transform, transform);
531
+ }
532
+
533
+ // ══════════════════════════════════════════════════════════
534
+ // VISUAL STATE — focus/search dimming
535
+ // ══════════════════════════════════════════════════════════
536
+ function applyVisualState() {
537
+ if (!nodeSelection || !linkSelection) return;
538
+
539
+ nodeSelection.select("circle")
540
+ .attr("fill", function(d){
541
+ if (focusedNodeId && !focusedSet[d.id]) return "#1e293b";
542
+ if (searchActive && !searchMatches[d.id]) return "#1e293b";
543
+ return d.color;
544
+ })
545
+ .attr("stroke", function(d){
546
+ if (focusedNodeId && focusedSet[d.id]) return "#60a5fa";
547
+ return "#e2e8f0";
548
+ })
549
+ .attr("stroke-width", function(d){
550
+ if (focusedNodeId && d.id === focusedNodeId) return 3;
551
+ if (focusedNodeId && focusedSet[d.id]) return 2;
552
+ return 1;
553
+ })
554
+ .attr("opacity", function(d){
555
+ if (focusedNodeId && !focusedSet[d.id]) return 0.15;
556
+ if (searchActive && !searchMatches[d.id]) return 0.15;
557
+ return 1;
558
+ });
559
+
560
+ nodeSelection.selectAll("text")
561
+ .attr("opacity", function(d){
562
+ if (focusedNodeId && !focusedSet[d.id]) return 0.1;
563
+ if (searchActive && !searchMatches[d.id]) return 0.1;
564
+ return 1;
565
+ });
566
+
567
+ linkSelection.select("path")
568
+ .attr("stroke", function(d){
569
+ var sid = typeof d.source === "object" ? d.source.id : d.source;
570
+ var tid = typeof d.target === "object" ? d.target.id : d.target;
571
+ if (focusedNodeId) {
572
+ if (focusedSet[sid] && focusedSet[tid]) return d.color;
573
+ return "#0f172a";
574
+ }
575
+ return d.color;
576
+ })
577
+ .attr("stroke-opacity", function(d){
578
+ var sid = typeof d.source === "object" ? d.source.id : d.source;
579
+ var tid = typeof d.target === "object" ? d.target.id : d.target;
580
+ if (focusedNodeId && !(focusedSet[sid] && focusedSet[tid])) return 0.05;
581
+ if (searchActive && !(searchMatches[sid] && searchMatches[tid])) return 0.05;
582
+ return 0.6;
583
+ })
584
+ .attr("stroke-width", function(d){
585
+ var sid = typeof d.source === "object" ? d.source.id : d.source;
586
+ var tid = typeof d.target === "object" ? d.target.id : d.target;
587
+ if (focusedNodeId && focusedSet[sid] && focusedSet[tid]) return 2;
588
+ return 1;
589
+ });
590
+
591
+ linkSelection.select(".link-label")
592
+ .attr("opacity", function(d){
593
+ var sid = typeof d.source === "object" ? d.source.id : d.source;
594
+ var tid = typeof d.target === "object" ? d.target.id : d.target;
595
+ if (focusedNodeId && !(focusedSet[sid] && focusedSet[tid])) return 0;
596
+ if (searchActive && !(searchMatches[sid] && searchMatches[tid])) return 0;
597
+ return 1;
598
+ });
599
+ }
600
+
601
+ // ══════════════════════════════════════════════════════════
602
+ // NODE CLICK — focus, expand insights, zoom to neighborhood
603
+ // ══════════════════════════════════════════════════════════
604
+ var panel = document.getElementById("detail-panel");
605
+ var detailName = document.getElementById("detail-name");
606
+ var detailBody = document.getElementById("detail-body");
607
+ document.getElementById("detail-close").addEventListener("click", function(){ unfocus(); });
608
+
609
+ function unfocus() {
610
+ panel.classList.remove("open");
611
+ expandedEntities = {};
612
+ focusedNodeId = null;
613
+ focusedSet = {};
614
+ searchActive = false;
615
+ searchMatches = {};
616
+ rebuildAndRestart();
617
+ applyVisualState();
618
+ }
619
+
620
+ function handleNodeClick(d) {
621
+ // Toggle focus
622
+ if (focusedNodeId === d.id) {
623
+ unfocus();
624
+ return;
625
+ }
626
+
627
+ focusedNodeId = d.id;
628
+ focusedSet = {};
629
+ focusedSet[d.id] = true;
630
+
631
+ // Add 1st-degree neighbors
632
+ currentLinks.forEach(function(l){
633
+ var sid = typeof l.source === "object" ? l.source.id : l.source;
634
+ var tid = typeof l.target === "object" ? l.target.id : l.target;
635
+ if (sid === d.id) focusedSet[tid] = true;
636
+ if (tid === d.id) focusedSet[sid] = true;
637
+ });
638
+
639
+ if (d.nodeKind === "insight") {
640
+ showInsightDetail(d);
641
+ } else {
642
+ // Toggle insight expansion
643
+ expandedEntities = {};
644
+ if (insightsByEntity[d.id] && insightsByEntity[d.id].length > 0) {
645
+ expandedEntities[d.id] = true;
646
+ }
647
+ showEntityDetail(d);
648
+ rebuildAndRestart();
649
+ // After rebuild, include new insight nodes in focusedSet
650
+ currentNodes.forEach(function(n){
651
+ if (n.nodeKind === "insight") focusedSet[n.id] = true;
652
+ });
653
+ }
654
+
655
+ applyVisualState();
656
+ zoomToNeighborhood(d);
657
+ }
658
+
659
+ function rebuildAndRestart() {
660
+ rebuildGraphArrays();
661
+ simulation.nodes(currentNodes);
662
+ simulation.force("link", d3.forceLink(currentLinks).id(function(d){ return d.id; }).distance(200).strength(0.2));
663
+ simulation.alpha(0.3).restart();
664
+ render();
665
+ }
666
+
667
+ // ── Zoom to neighborhood — exact Zep pattern ──
668
+ function zoomToNeighborhood(d) {
669
+ var connectedNodes = [d];
670
+ currentNodes.forEach(function(n){
671
+ if (n.id !== d.id && focusedSet[n.id] && n.x != null) connectedNodes.push(n);
672
+ });
673
+ if (connectedNodes.length < 1) return;
674
+
675
+ var padding = 80;
676
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
677
+ connectedNodes.forEach(function(n){
678
+ if (n.x < minX) minX = n.x;
679
+ if (n.y < minY) minY = n.y;
680
+ if (n.x > maxX) maxX = n.x;
681
+ if (n.y > maxY) maxY = n.y;
682
+ });
683
+ minX -= padding; minY -= padding; maxX += padding; maxY += padding;
684
+ var bw = maxX - minX, bh = maxY - minY;
685
+ var scale = 0.9 * Math.min(width / bw, height / bh);
686
+ var midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;
687
+ if (!isFinite(scale) || !isFinite(midX)) return;
688
+ var transform = d3.zoomIdentity
689
+ .translate(width/2 - midX*scale, height/2 - midY*scale)
690
+ .scale(scale);
691
+ svg.transition().duration(750).ease(d3.easeCubicInOut).call(zoomBehavior.transform, transform);
692
+ }
693
+
694
+ // ══════════════════════════════════════════════════════════
695
+ // DETAIL PANEL
696
+ // ══════════════════════════════════════════════════════════
697
+ function addField(parent, labelText, valueText) {
698
+ var field = document.createElement("div"); field.className = "field";
699
+ var lbl = document.createElement("div"); lbl.className = "label"; lbl.textContent = labelText;
700
+ var val = document.createElement("div"); val.className = "value"; val.textContent = valueText;
701
+ field.appendChild(lbl); field.appendChild(val); parent.appendChild(field);
702
+ }
703
+
704
+ function showInsightDetail(d) {
705
+ detailName.textContent = d.primary_attribute || "Insight";
706
+ while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild);
707
+ addField(detailBody, "Kind", "Insight (" + d.type + ")");
708
+ addField(detailBody, "Type", d.primary_attribute || "");
709
+ if (d.confidence != null) addField(detailBody, "Confidence", Math.round(d.confidence * 100) + "%");
710
+ addField(detailBody, "Content", d.fullContent || d.name);
711
+ panel.classList.add("open");
712
+ }
713
+
714
+ function showEntityDetail(d) {
715
+ detailName.textContent = d.name || d.id;
716
+ while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild);
717
+ addField(detailBody, "Type", d.type || "");
718
+ if (d.primary_attribute) addField(detailBody, "Primary", d.primary_attribute);
719
+ if (d.created_at) addField(detailBody, "Created", d.created_at);
720
+
721
+ // Connections
722
+ var connCount = 0;
723
+ var connectedList = [];
724
+ var nodeMap = {};
725
+ entityNodes.forEach(function(n){ nodeMap[n.id] = n; });
726
+ baseLinks.forEach(function(l){
727
+ if (l.source === d.id || (typeof l.source === "object" && l.source.id === d.id)) { connCount++; connectedList.push(typeof l.target === "object" ? l.target.id : l.target); }
728
+ if (l.target === d.id || (typeof l.target === "object" && l.target.id === d.id)) { connCount++; connectedList.push(typeof l.source === "object" ? l.source.id : l.source); }
729
+ });
730
+ addField(detailBody, "Connections", String(connCount));
731
+
732
+ if (connectedList.length > 0) {
733
+ var field = document.createElement("div"); field.className = "field";
734
+ var lbl = document.createElement("div"); lbl.className = "label"; lbl.textContent = "Connected to";
735
+ field.appendChild(lbl);
736
+ var val = document.createElement("div"); val.className = "value";
737
+ connectedList.slice(0, 20).forEach(function(cid){
738
+ var line = document.createElement("div");
739
+ var cn = nodeMap[cid];
740
+ line.textContent = cn ? cn.name : cid;
741
+ val.appendChild(line);
742
+ });
743
+ if (connectedList.length > 20) {
744
+ var more = document.createElement("em");
745
+ more.textContent = "...and " + (connectedList.length - 20) + " more";
746
+ val.appendChild(more);
747
+ }
748
+ field.appendChild(val); detailBody.appendChild(field);
749
+ }
750
+
751
+ // Insights in panel
752
+ var panelInsights = insightsMap[d.id] || [];
753
+ if (panelInsights.length > 0) {
754
+ var insightField = document.createElement("div"); insightField.className = "field";
755
+ var insightLbl = document.createElement("div"); insightLbl.className = "label";
756
+ insightLbl.textContent = "Insights (" + panelInsights.length + ")";
757
+ insightField.appendChild(insightLbl);
758
+ panelInsights.forEach(function(ins){
759
+ var card = document.createElement("div"); card.className = "insight-card";
760
+ var badge = document.createElement("span"); badge.className = "insight-type"; badge.textContent = ins.type;
761
+ card.appendChild(badge);
762
+ var conf = document.createElement("span"); conf.className = "insight-confidence";
763
+ conf.textContent = Math.round(ins.confidence * 100) + "%";
764
+ card.appendChild(conf);
765
+ var content = document.createElement("div"); content.className = "insight-content"; content.textContent = ins.content;
766
+ card.appendChild(content);
767
+ insightField.appendChild(card);
768
+ });
769
+ detailBody.appendChild(insightField);
770
+ }
771
+
772
+ panel.classList.add("open");
773
+ }
774
+
775
+ // ══════════════════════════════════════════════════════════
776
+ // SEARCH — highlight + zoom
777
+ // ══════════════════════════════════════════════════════════
778
+ var searchBox = document.getElementById("search-box");
779
+ var searchResultsEl = document.createElement("div");
780
+ searchResultsEl.id = "search-results";
781
+ searchResultsEl.style.cssText = "position:fixed;top:52px;left:16px;z-index:10;font-size:12px;color:#64748b;padding:0 4px;display:none";
782
+ document.body.appendChild(searchResultsEl);
783
+
784
+ var searchTimer = null;
785
+ searchBox.addEventListener("input", function(){
786
+ if (searchTimer) clearTimeout(searchTimer);
787
+ searchTimer = setTimeout(function(){
788
+ var q = searchBox.value.trim().toLowerCase();
789
+ searchMatches = {};
790
+ searchActive = false;
791
+ focusedNodeId = null;
792
+ focusedSet = {};
793
+ searchResultsEl.style.display = "none";
794
+ if (q) {
795
+ searchActive = true;
796
+ var directMatches = [];
797
+ currentNodes.forEach(function(n){
798
+ var name = (n.name || n.id || "").toLowerCase();
799
+ var type = (n.type || "").toLowerCase();
800
+ var primary = (n.primary_attribute || "").toLowerCase();
801
+ if (name.indexOf(q) >= 0 || type.indexOf(q) >= 0 || primary.indexOf(q) >= 0) {
802
+ searchMatches[n.id] = true;
803
+ directMatches.push(n);
804
+ }
805
+ });
806
+ // 1st-degree neighbors
807
+ currentLinks.forEach(function(l){
808
+ var sid = typeof l.source === "object" ? l.source.id : l.source;
809
+ var tid = typeof l.target === "object" ? l.target.id : l.target;
810
+ if (searchMatches[sid]) searchMatches[tid] = true;
811
+ if (searchMatches[tid]) searchMatches[sid] = true;
812
+ });
813
+ searchResultsEl.textContent = directMatches.length + " result" + (directMatches.length !== 1 ? "s" : "");
814
+ searchResultsEl.style.display = "block";
815
+ // Zoom to matches
816
+ if (directMatches.length === 1 && directMatches[0].x != null) {
817
+ var t = d3.zoomIdentity.translate(width/2 - directMatches[0].x*2, height/2 - directMatches[0].y*2).scale(2);
818
+ svg.transition().duration(400).call(zoomBehavior.transform, t);
819
+ } else if (directMatches.length > 1) {
820
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
821
+ directMatches.forEach(function(n){
822
+ if (n.x != null) { if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x; if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y; }
823
+ });
824
+ var cx = (minX+maxX)/2, cy = (minY+maxY)/2;
825
+ var bw = maxX-minX+200, bh = maxY-minY+200;
826
+ var z = Math.min(width/bw, height/bh, 4);
827
+ var t2 = d3.zoomIdentity.translate(width/2-cx*z, height/2-cy*z).scale(Math.max(z, 0.3));
828
+ svg.transition().duration(400).call(zoomBehavior.transform, t2);
829
+ }
830
+ }
831
+ applyVisualState();
832
+ }, 150);
833
+ });
834
+
835
+ // ── Handle window resize ──
836
+ window.addEventListener("resize", function(){
837
+ width = window.innerWidth;
838
+ height = window.innerHeight;
839
+ svg.attr("width", width).attr("height", height).attr("viewBox", "0 0 " + width + " " + height);
840
+ });
841
+ })();
842
+ </script>
843
+ </body>
844
+ </html>`;
845
+ }
846
+ //# sourceMappingURL=graph-html.js.map