@nex-ai/nex 0.1.19 → 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.
- package/README.md +6 -1
- package/dist/commands/graph.d.ts +4 -0
- package/dist/commands/graph.js +82 -0
- package/dist/commands/graph.js.map +1 -0
- package/dist/commands/integrate.d.ts +1 -1
- package/dist/commands/integrate.js +1 -1
- package/dist/commands/integrate.js.map +1 -1
- package/dist/commands/setup.d.ts +14 -1
- package/dist/commands/setup.js +133 -15
- package/dist/commands/setup.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/graph-html.d.ts +58 -0
- package/dist/lib/graph-html.js +846 -0
- package/dist/lib/graph-html.js.map +1 -0
- package/dist/lib/prompt.d.ts +9 -0
- package/dist/lib/prompt.js +98 -0
- package/dist/lib/prompt.js.map +1 -1
- package/package.json +10 -1
|
@@ -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, "<")
|
|
28
|
+
.replace(/>/g, ">")
|
|
29
|
+
.replace(/"/g, """)
|
|
30
|
+
.replace(/'/g, "'");
|
|
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 · ${escapeHtml(String(totalConnections))} connections · ${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">×</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
|