@bufinance/gtm-graph 0.1.0
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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/algorithms/index.cjs +306 -0
- package/dist/algorithms/index.cjs.map +1 -0
- package/dist/algorithms/index.d.cts +205 -0
- package/dist/algorithms/index.d.ts +205 -0
- package/dist/algorithms/index.js +3 -0
- package/dist/algorithms/index.js.map +1 -0
- package/dist/chunk-MW4VOWB5.js +284 -0
- package/dist/chunk-MW4VOWB5.js.map +1 -0
- package/dist/index.cjs +306 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +53 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/view/index.cjs +1142 -0
- package/dist/view/index.cjs.map +1 -0
- package/dist/view/index.d.cts +183 -0
- package/dist/view/index.d.ts +183 -0
- package/dist/view/index.js +1103 -0
- package/dist/view/index.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/view/index.ts
|
|
22
|
+
var view_exports = {};
|
|
23
|
+
__export(view_exports, {
|
|
24
|
+
CLASS_ACCENTS: () => CLASS_ACCENTS,
|
|
25
|
+
EDGE_TYPE_STYLES: () => EDGE_TYPE_STYLES,
|
|
26
|
+
ENTITY_COLORS: () => ENTITY_COLORS,
|
|
27
|
+
FALLBACK_ENTITY_COLOR: () => FALLBACK_ENTITY_COLOR,
|
|
28
|
+
GtmGraphView: () => GtmGraphView,
|
|
29
|
+
TRACE_ACCENTS: () => TRACE_ACCENTS,
|
|
30
|
+
buildEdgeVisuals: () => buildEdgeVisuals,
|
|
31
|
+
buildNodeVisuals: () => buildNodeVisuals,
|
|
32
|
+
buildTrace: () => buildTrace,
|
|
33
|
+
edgeTypeStyle: () => edgeTypeStyle,
|
|
34
|
+
entityColor: () => entityColor,
|
|
35
|
+
nodeRadius: () => nodeRadius,
|
|
36
|
+
resolveClasses: () => resolveClasses,
|
|
37
|
+
traceEdgeIds: () => traceEdgeIds
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(view_exports);
|
|
40
|
+
|
|
41
|
+
// src/view/gtm-graph-view.tsx
|
|
42
|
+
var import_react = require("react");
|
|
43
|
+
|
|
44
|
+
// src/algorithms/graph-paths.ts
|
|
45
|
+
var RECENCY_HALF_LIFE_DAYS = 90;
|
|
46
|
+
var DEFAULT_STRENGTH = 1;
|
|
47
|
+
var DEFAULT_EVIDENCE_COUNT = 1;
|
|
48
|
+
var MIN_EDGE_WEIGHT = 1e-6;
|
|
49
|
+
var MS_PER_DAY = 864e5;
|
|
50
|
+
function recencyFactor(lastSeenAt, now) {
|
|
51
|
+
if (!lastSeenAt) return 1;
|
|
52
|
+
const seenAt = Date.parse(lastSeenAt);
|
|
53
|
+
if (Number.isNaN(seenAt)) return 1;
|
|
54
|
+
const daysStale = Math.max(0, (now - seenAt) / MS_PER_DAY);
|
|
55
|
+
return 0.5 ** (daysStale / RECENCY_HALF_LIFE_DAYS);
|
|
56
|
+
}
|
|
57
|
+
function edgeWeight(edge, now) {
|
|
58
|
+
const strength = edge.strength ?? DEFAULT_STRENGTH;
|
|
59
|
+
const evidence = edge.evidenceCount ?? DEFAULT_EVIDENCE_COUNT;
|
|
60
|
+
const weight = strength * Math.log(1 + Math.max(0, evidence)) * recencyFactor(edge.lastSeenAt, now);
|
|
61
|
+
return Math.max(MIN_EDGE_WEIGHT, weight);
|
|
62
|
+
}
|
|
63
|
+
function buildAdjacency(data, options) {
|
|
64
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
65
|
+
for (const entity of data.entities) adjacency.set(entity.id, []);
|
|
66
|
+
for (const edge of data.relationships) {
|
|
67
|
+
const weight = edgeWeight(edge, options.now);
|
|
68
|
+
const forward = {
|
|
69
|
+
edgeId: edge.id,
|
|
70
|
+
otherId: edge.targetEntityId,
|
|
71
|
+
relationshipType: edge.relationshipType,
|
|
72
|
+
weight
|
|
73
|
+
};
|
|
74
|
+
const backward = {
|
|
75
|
+
edgeId: edge.id,
|
|
76
|
+
otherId: edge.sourceEntityId,
|
|
77
|
+
relationshipType: edge.relationshipType,
|
|
78
|
+
weight
|
|
79
|
+
};
|
|
80
|
+
if (adjacency.has(edge.sourceEntityId) && adjacency.has(edge.targetEntityId)) {
|
|
81
|
+
adjacency.get(edge.sourceEntityId).push(forward);
|
|
82
|
+
adjacency.get(edge.targetEntityId).push(backward);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return adjacency;
|
|
86
|
+
}
|
|
87
|
+
function fewestHops(data, fromId, toId, options = {}) {
|
|
88
|
+
const adjacency = options.adjacency ?? buildAdjacency(data, { now: options.now ?? 0 });
|
|
89
|
+
if (!adjacency.has(fromId) || !adjacency.has(toId)) return null;
|
|
90
|
+
if (fromId === toId) return { nodeIds: [fromId], edgeIds: [], hops: 0, cost: 0 };
|
|
91
|
+
const previous = /* @__PURE__ */ new Map();
|
|
92
|
+
const visited = /* @__PURE__ */ new Set([fromId]);
|
|
93
|
+
let frontier = [fromId];
|
|
94
|
+
let depth = 0;
|
|
95
|
+
while (frontier.length > 0) {
|
|
96
|
+
depth++;
|
|
97
|
+
if (options.maxHops != null && depth > options.maxHops) return null;
|
|
98
|
+
const next = [];
|
|
99
|
+
for (const nodeId of frontier) {
|
|
100
|
+
for (const edge of adjacency.get(nodeId) ?? []) {
|
|
101
|
+
if (visited.has(edge.otherId)) continue;
|
|
102
|
+
visited.add(edge.otherId);
|
|
103
|
+
previous.set(edge.otherId, { nodeId, edge });
|
|
104
|
+
if (edge.otherId === toId) return reconstructPath(previous, fromId, toId);
|
|
105
|
+
next.push(edge.otherId);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
frontier = next;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function reconstructPath(previous, fromId, toId) {
|
|
113
|
+
const nodeIds = [toId];
|
|
114
|
+
const edgeIds = [];
|
|
115
|
+
let cost = 0;
|
|
116
|
+
let cursor = toId;
|
|
117
|
+
while (cursor !== fromId) {
|
|
118
|
+
const step = previous.get(cursor);
|
|
119
|
+
edgeIds.unshift(step.edge.edgeId);
|
|
120
|
+
cost += 1 / step.edge.weight;
|
|
121
|
+
nodeIds.unshift(step.nodeId);
|
|
122
|
+
cursor = step.nodeId;
|
|
123
|
+
}
|
|
124
|
+
return { nodeIds, edgeIds, hops: edgeIds.length, cost };
|
|
125
|
+
}
|
|
126
|
+
function strongestPath(data, fromId, toId, options) {
|
|
127
|
+
const adjacency = options.adjacency ?? buildAdjacency(data, { now: options.now });
|
|
128
|
+
if (!adjacency.has(fromId) || !adjacency.has(toId)) return null;
|
|
129
|
+
if (fromId === toId) return { nodeIds: [fromId], edgeIds: [], hops: 0, cost: 0 };
|
|
130
|
+
const distance = /* @__PURE__ */ new Map([[fromId, 0]]);
|
|
131
|
+
const previous = /* @__PURE__ */ new Map();
|
|
132
|
+
const settled = /* @__PURE__ */ new Set();
|
|
133
|
+
const pending = /* @__PURE__ */ new Set([fromId]);
|
|
134
|
+
while (pending.size > 0) {
|
|
135
|
+
let current = null;
|
|
136
|
+
let best = Number.POSITIVE_INFINITY;
|
|
137
|
+
for (const nodeId of pending) {
|
|
138
|
+
const d = distance.get(nodeId) ?? Number.POSITIVE_INFINITY;
|
|
139
|
+
if (d < best) {
|
|
140
|
+
best = d;
|
|
141
|
+
current = nodeId;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (current == null) break;
|
|
145
|
+
pending.delete(current);
|
|
146
|
+
if (current === toId) break;
|
|
147
|
+
if (settled.has(current)) continue;
|
|
148
|
+
settled.add(current);
|
|
149
|
+
for (const edge of adjacency.get(current) ?? []) {
|
|
150
|
+
if (settled.has(edge.otherId)) continue;
|
|
151
|
+
const candidate = best + 1 / edge.weight;
|
|
152
|
+
if (candidate < (distance.get(edge.otherId) ?? Number.POSITIVE_INFINITY)) {
|
|
153
|
+
distance.set(edge.otherId, candidate);
|
|
154
|
+
previous.set(edge.otherId, { nodeId: current, edge });
|
|
155
|
+
pending.add(edge.otherId);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!distance.has(toId)) return null;
|
|
160
|
+
return reconstructPath(previous, fromId, toId);
|
|
161
|
+
}
|
|
162
|
+
function mulberry32(seed) {
|
|
163
|
+
let a = seed >>> 0;
|
|
164
|
+
return () => {
|
|
165
|
+
a = a + 1831565813 | 0;
|
|
166
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
167
|
+
t ^= t + Math.imul(t ^ t >>> 7, 61 | t);
|
|
168
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
var DEFAULT_BRIDGE_SAMPLE_SIZE = 200;
|
|
172
|
+
var DEFAULT_BRIDGE_SEED = 99;
|
|
173
|
+
function bridgeScores(data, options = {}) {
|
|
174
|
+
const now = options.now ?? 0;
|
|
175
|
+
const adjacency = buildAdjacency(data, { now });
|
|
176
|
+
const nodeIds = data.entities.map((e) => e.id);
|
|
177
|
+
const scores = new Map(nodeIds.map((id) => [id, 0]));
|
|
178
|
+
if (nodeIds.length < 3) return scores;
|
|
179
|
+
let pairs;
|
|
180
|
+
if (options.pairs) {
|
|
181
|
+
pairs = options.pairs;
|
|
182
|
+
} else {
|
|
183
|
+
const sampleSize = options.sampleSize ?? DEFAULT_BRIDGE_SAMPLE_SIZE;
|
|
184
|
+
const random = mulberry32(options.seed ?? DEFAULT_BRIDGE_SEED);
|
|
185
|
+
const seen = /* @__PURE__ */ new Set();
|
|
186
|
+
pairs = [];
|
|
187
|
+
const maxDistinctPairs = nodeIds.length * (nodeIds.length - 1) / 2;
|
|
188
|
+
const target = Math.min(sampleSize, maxDistinctPairs);
|
|
189
|
+
for (let attempt = 0; attempt < target * 20 && pairs.length < target; attempt++) {
|
|
190
|
+
const i = Math.floor(random() * nodeIds.length);
|
|
191
|
+
const j = Math.floor(random() * nodeIds.length);
|
|
192
|
+
if (i === j) continue;
|
|
193
|
+
const key = i < j ? `${i}|${j}` : `${j}|${i}`;
|
|
194
|
+
if (seen.has(key)) continue;
|
|
195
|
+
seen.add(key);
|
|
196
|
+
pairs.push([nodeIds[Math.min(i, j)], nodeIds[Math.max(i, j)]]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const [fromId, toId] of pairs) {
|
|
200
|
+
const path = fewestHops(data, fromId, toId, { adjacency });
|
|
201
|
+
if (!path) continue;
|
|
202
|
+
for (const nodeId of path.nodeIds.slice(1, -1)) {
|
|
203
|
+
scores.set(nodeId, (scores.get(nodeId) ?? 0) + 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let max = 0;
|
|
207
|
+
for (const score of scores.values()) if (score > max) max = score;
|
|
208
|
+
if (max > 0) {
|
|
209
|
+
for (const [nodeId, score] of scores) scores.set(nodeId, score / max);
|
|
210
|
+
}
|
|
211
|
+
return scores;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/algorithms/gtm-decorations.ts
|
|
215
|
+
var POI_EDGE_TYPES = ["champion_of", "decision_maker_for"];
|
|
216
|
+
var DEFAULT_CLOSED_STAGES = ["activo", "nurture/perdido", "won", "lost"];
|
|
217
|
+
var DEFAULT_POI_TITLE_PATTERNS = [
|
|
218
|
+
"founder",
|
|
219
|
+
"co-founder",
|
|
220
|
+
"cofounder",
|
|
221
|
+
"ceo",
|
|
222
|
+
"coo",
|
|
223
|
+
"cfo",
|
|
224
|
+
"owner",
|
|
225
|
+
"partner",
|
|
226
|
+
"managing director",
|
|
227
|
+
"director general",
|
|
228
|
+
"general manager",
|
|
229
|
+
"head of finance",
|
|
230
|
+
"head of operations",
|
|
231
|
+
"finance manager",
|
|
232
|
+
"operations manager"
|
|
233
|
+
];
|
|
234
|
+
function metadataString(metadata, key) {
|
|
235
|
+
const value = metadata?.[key];
|
|
236
|
+
return typeof value === "string" ? value : null;
|
|
237
|
+
}
|
|
238
|
+
function isOpenDeal(deal, closedStages = DEFAULT_CLOSED_STAGES) {
|
|
239
|
+
const stage = metadataString(deal.metadata, "stage");
|
|
240
|
+
if (!stage) return true;
|
|
241
|
+
return !closedStages.includes(stage.trim().toLowerCase());
|
|
242
|
+
}
|
|
243
|
+
function computePoiIds(data, options = {}) {
|
|
244
|
+
const closedStages = options.closedStages ?? DEFAULT_CLOSED_STAGES;
|
|
245
|
+
const titlePatterns = options.titlePatterns ?? DEFAULT_POI_TITLE_PATTERNS;
|
|
246
|
+
const byId = new Map(data.entities.map((e) => [e.id, e]));
|
|
247
|
+
const pois = /* @__PURE__ */ new Set();
|
|
248
|
+
for (const edge of data.relationships) {
|
|
249
|
+
if (!POI_EDGE_TYPES.includes(edge.relationshipType))
|
|
250
|
+
continue;
|
|
251
|
+
const source = byId.get(edge.sourceEntityId);
|
|
252
|
+
const target = byId.get(edge.targetEntityId);
|
|
253
|
+
if (!source || source.entityType !== "person" || !target) continue;
|
|
254
|
+
const isDealTarget = target.entityType === "project";
|
|
255
|
+
if (isDealTarget && !isOpenDeal(target, closedStages)) continue;
|
|
256
|
+
pois.add(source.id);
|
|
257
|
+
}
|
|
258
|
+
for (const entity of data.entities) {
|
|
259
|
+
if (entity.entityType !== "person") continue;
|
|
260
|
+
const title = metadataString(entity.metadata, "title")?.toLowerCase();
|
|
261
|
+
if (!title) continue;
|
|
262
|
+
if (titlePatterns.some((pattern) => title.includes(pattern))) pois.add(entity.id);
|
|
263
|
+
}
|
|
264
|
+
return pois;
|
|
265
|
+
}
|
|
266
|
+
function computeBridgeIds(data, options = {}) {
|
|
267
|
+
const topN = options.topN ?? 5;
|
|
268
|
+
const scores = bridgeScores(data, {
|
|
269
|
+
now: options.now ?? 0,
|
|
270
|
+
seed: options.seed,
|
|
271
|
+
pairs: options.pairs
|
|
272
|
+
});
|
|
273
|
+
const ranked = [...scores.entries()].filter(([, score]) => score > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, topN);
|
|
274
|
+
return new Set(ranked.map(([id]) => id));
|
|
275
|
+
}
|
|
276
|
+
function classifyNode(id, poiIds, bridgeIds) {
|
|
277
|
+
if (poiIds.has(id)) return "poi";
|
|
278
|
+
if (bridgeIds.has(id)) return "bridge";
|
|
279
|
+
return "other";
|
|
280
|
+
}
|
|
281
|
+
function capGraph(data, maxNodes, pinned = /* @__PURE__ */ new Set()) {
|
|
282
|
+
if (data.entities.length <= maxNodes) return data;
|
|
283
|
+
const degree = /* @__PURE__ */ new Map();
|
|
284
|
+
for (const edge of data.relationships) {
|
|
285
|
+
degree.set(edge.sourceEntityId, (degree.get(edge.sourceEntityId) ?? 0) + 1);
|
|
286
|
+
degree.set(edge.targetEntityId, (degree.get(edge.targetEntityId) ?? 0) + 1);
|
|
287
|
+
}
|
|
288
|
+
const ranked = [...data.entities].sort((a, b) => {
|
|
289
|
+
const pinnedDelta = Number(pinned.has(b.id)) - Number(pinned.has(a.id));
|
|
290
|
+
if (pinnedDelta !== 0) return pinnedDelta;
|
|
291
|
+
return (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || a.id.localeCompare(b.id);
|
|
292
|
+
});
|
|
293
|
+
const keep = new Set(ranked.slice(0, maxNodes).map((e) => e.id));
|
|
294
|
+
return {
|
|
295
|
+
entities: data.entities.filter((e) => keep.has(e.id)),
|
|
296
|
+
relationships: data.relationships.filter(
|
|
297
|
+
(r) => keep.has(r.sourceEntityId) && keep.has(r.targetEntityId)
|
|
298
|
+
)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/view/physics.ts
|
|
303
|
+
var REPULSION_STRENGTH = 225;
|
|
304
|
+
var MAX_REPULSION_FORCE = 22;
|
|
305
|
+
var SPRING_REST = 120;
|
|
306
|
+
var SPRING_K = 0.055;
|
|
307
|
+
var COLLISION_PADDING = 18;
|
|
308
|
+
var CENTER_GRAVITY = 0.025;
|
|
309
|
+
var DAMPING = 0.85;
|
|
310
|
+
var MAX_VELOCITY = 15;
|
|
311
|
+
var SIM_STEPS = 500;
|
|
312
|
+
var SEMANTIC_GRAVITY = 0.33;
|
|
313
|
+
var SEARCH_PULL = 0.25;
|
|
314
|
+
var CLEARING_PUSH = 0.01;
|
|
315
|
+
var SEARCH_DAMPING = 0.88;
|
|
316
|
+
function initializePositions(nodes, width, height) {
|
|
317
|
+
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
|
318
|
+
const maxRadius = Math.min(width, height) * 0.35;
|
|
319
|
+
nodes.forEach((n, i) => {
|
|
320
|
+
const angle = i * goldenAngle;
|
|
321
|
+
const r = maxRadius * Math.sqrt((i + 1) / nodes.length);
|
|
322
|
+
n.x = Math.cos(angle) * r;
|
|
323
|
+
n.y = Math.sin(angle) * r;
|
|
324
|
+
n.vx = 0;
|
|
325
|
+
n.vy = 0;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function simulateStep(nodes, edges, _width, _height, activeSearchIds) {
|
|
329
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
330
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
331
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
332
|
+
const a = nodes[i];
|
|
333
|
+
const b = nodes[j];
|
|
334
|
+
let dx = b.x - a.x;
|
|
335
|
+
let dy = b.y - a.y;
|
|
336
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
|
|
337
|
+
dx = (Math.random() - 0.5) * 4;
|
|
338
|
+
dy = (Math.random() - 0.5) * 4;
|
|
339
|
+
}
|
|
340
|
+
const distSq = dx * dx + dy * dy;
|
|
341
|
+
const dist = Math.sqrt(distSq);
|
|
342
|
+
const rawForce = REPULSION_STRENGTH / Math.max(dist, 1);
|
|
343
|
+
const force = Math.min(rawForce, MAX_REPULSION_FORCE);
|
|
344
|
+
const fx = dx / dist * force;
|
|
345
|
+
const fy = dy / dist * force;
|
|
346
|
+
a.vx -= fx;
|
|
347
|
+
a.vy -= fy;
|
|
348
|
+
b.vx += fx;
|
|
349
|
+
b.vy += fy;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const e of edges) {
|
|
353
|
+
const a = nodeMap.get(e.source);
|
|
354
|
+
const b = nodeMap.get(e.target);
|
|
355
|
+
if (!a || !b) continue;
|
|
356
|
+
const dx = b.x - a.x;
|
|
357
|
+
const dy = b.y - a.y;
|
|
358
|
+
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
|
359
|
+
const edgeStrength = e.strength ?? 0.5;
|
|
360
|
+
const restLength = SPRING_REST * (1.4 - edgeStrength * 0.9);
|
|
361
|
+
const k = SPRING_K * (0.5 + edgeStrength);
|
|
362
|
+
const displacement = dist - restLength;
|
|
363
|
+
const force = displacement * k;
|
|
364
|
+
const fx = dx / dist * force;
|
|
365
|
+
const fy = dy / dist * force;
|
|
366
|
+
a.vx += fx;
|
|
367
|
+
a.vy += fy;
|
|
368
|
+
b.vx -= fx;
|
|
369
|
+
b.vy -= fy;
|
|
370
|
+
}
|
|
371
|
+
const searching = activeSearchIds && activeSearchIds.size > 0;
|
|
372
|
+
for (const n of nodes) {
|
|
373
|
+
const dx = -n.x;
|
|
374
|
+
const dy = -n.y;
|
|
375
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
376
|
+
if (searching) {
|
|
377
|
+
if (activeSearchIds.has(n.id)) {
|
|
378
|
+
n.vx += dx / dist * SEARCH_PULL * dist * 0.01;
|
|
379
|
+
n.vy += dy / dist * SEARCH_PULL * dist * 0.01;
|
|
380
|
+
} else {
|
|
381
|
+
n.vx -= dx / dist * CLEARING_PUSH;
|
|
382
|
+
n.vy -= dy / dist * CLEARING_PUSH;
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
n.vx += dx * CENTER_GRAVITY;
|
|
386
|
+
n.vy += dy * CENTER_GRAVITY;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
390
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
391
|
+
const a = nodes[i];
|
|
392
|
+
const b = nodes[j];
|
|
393
|
+
if (a.group !== b.group) continue;
|
|
394
|
+
const dx = b.x - a.x;
|
|
395
|
+
const dy = b.y - a.y;
|
|
396
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
397
|
+
if (dist < 1 || dist > 300) continue;
|
|
398
|
+
const force = SEMANTIC_GRAVITY * Math.min(dist * 0.1, 2);
|
|
399
|
+
const fx = dx / dist * force;
|
|
400
|
+
const fy = dy / dist * force;
|
|
401
|
+
a.vx += fx;
|
|
402
|
+
a.vy += fy;
|
|
403
|
+
b.vx -= fx;
|
|
404
|
+
b.vy -= fy;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const currentDamping = searching ? SEARCH_DAMPING : DAMPING;
|
|
408
|
+
for (const n of nodes) {
|
|
409
|
+
n.vx *= currentDamping;
|
|
410
|
+
n.vy *= currentDamping;
|
|
411
|
+
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
|
412
|
+
if (speed > MAX_VELOCITY) {
|
|
413
|
+
n.vx = n.vx / speed * MAX_VELOCITY;
|
|
414
|
+
n.vy = n.vy / speed * MAX_VELOCITY;
|
|
415
|
+
}
|
|
416
|
+
n.x += n.vx;
|
|
417
|
+
n.y += n.vy;
|
|
418
|
+
}
|
|
419
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
420
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
421
|
+
const a = nodes[i];
|
|
422
|
+
const b = nodes[j];
|
|
423
|
+
const dx = b.x - a.x;
|
|
424
|
+
const dy = b.y - a.y;
|
|
425
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
426
|
+
const minDist = a.radius + b.radius + COLLISION_PADDING;
|
|
427
|
+
if (dist < minDist && dist > 0.01) {
|
|
428
|
+
const overlap = (minDist - dist) / 2;
|
|
429
|
+
const nx = dx / dist;
|
|
430
|
+
const ny = dy / dist;
|
|
431
|
+
a.x -= nx * overlap;
|
|
432
|
+
a.y -= ny * overlap;
|
|
433
|
+
b.x += nx * overlap;
|
|
434
|
+
b.y += ny * overlap;
|
|
435
|
+
const relVelDot = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
|
436
|
+
if (relVelDot < 0) {
|
|
437
|
+
a.vx += nx * relVelDot * 0.5;
|
|
438
|
+
a.vy += ny * relVelDot * 0.5;
|
|
439
|
+
b.vx -= nx * relVelDot * 0.5;
|
|
440
|
+
b.vy -= ny * relVelDot * 0.5;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function runSimulation(nodes, edges, width, height) {
|
|
447
|
+
initializePositions(nodes, width, height);
|
|
448
|
+
for (let i = 0; i < SIM_STEPS; i++) {
|
|
449
|
+
simulateStep(nodes, edges, width, height);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function getMotionSeed(id) {
|
|
453
|
+
let hash = 0;
|
|
454
|
+
for (let i = 0; i < id.length; i++) {
|
|
455
|
+
hash = (hash << 5) - hash + id.charCodeAt(i) | 0;
|
|
456
|
+
}
|
|
457
|
+
const norm = (v) => (v % 1e3 + 1e3) % 1e3 / 1e3;
|
|
458
|
+
return {
|
|
459
|
+
phase: norm(hash) * Math.PI * 2,
|
|
460
|
+
amplitude: 2 + norm(hash >> 8) * 2.5,
|
|
461
|
+
speed: 3e-4 + norm(hash >> 16) * 3e-4
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function getFloatingOffset(id, time) {
|
|
465
|
+
const seed = getMotionSeed(id);
|
|
466
|
+
const phase = seed.phase + time * seed.speed;
|
|
467
|
+
return {
|
|
468
|
+
dx: Math.sin(phase) * seed.amplitude,
|
|
469
|
+
dy: Math.cos(phase * 0.9) * seed.amplitude
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/view/theme.ts
|
|
474
|
+
var ENTITY_COLORS = {
|
|
475
|
+
person: { fill: "#D4BFFF", glow: "#B794FF", stroke: "#A78BFA", label: "People" },
|
|
476
|
+
organization: { fill: "#818CF8", glow: "#6366F1", stroke: "#4F46E5", label: "Organizations" },
|
|
477
|
+
project: { fill: "#86EFAC", glow: "#4ADE80", stroke: "#22C55E", label: "Deals" },
|
|
478
|
+
meeting: { fill: "#FDE68A", glow: "#FBBF24", stroke: "#F59E0B", label: "Meetings" },
|
|
479
|
+
topic: { fill: "#F9A8D4", glow: "#F472B6", stroke: "#EC4899", label: "Topics" },
|
|
480
|
+
contact: { fill: "#99F6E4", glow: "#5EEAD4", stroke: "#2DD4BF", label: "Contacts" }
|
|
481
|
+
};
|
|
482
|
+
var FALLBACK_ENTITY_COLOR = {
|
|
483
|
+
fill: "#9CA3AF",
|
|
484
|
+
glow: "#9CA3AF",
|
|
485
|
+
stroke: "#6B7280",
|
|
486
|
+
label: "Other"
|
|
487
|
+
};
|
|
488
|
+
function entityColor(entityType) {
|
|
489
|
+
return ENTITY_COLORS[entityType] ?? FALLBACK_ENTITY_COLOR;
|
|
490
|
+
}
|
|
491
|
+
var CLASS_ACCENTS = {
|
|
492
|
+
/** POI — BUFI brand purple (purpleDanis). */
|
|
493
|
+
poi: "#6954CF",
|
|
494
|
+
/** Bridge — warm attention amber (agnusDeiDanis family, darkened for contrast). */
|
|
495
|
+
bridge: "#D9A514"
|
|
496
|
+
};
|
|
497
|
+
var TRACE_ACCENTS = {
|
|
498
|
+
strongest: "#6954CF",
|
|
499
|
+
fewest: "#647CE6",
|
|
500
|
+
endpoints: "#FFADEC"
|
|
501
|
+
};
|
|
502
|
+
var EDGE_TYPE_STYLES = {
|
|
503
|
+
works_at: { label: "works at" },
|
|
504
|
+
related_to: { dash: "4 3", label: "related to" },
|
|
505
|
+
champion_of: { label: "champion of" },
|
|
506
|
+
decision_maker_for: { label: "decision maker" },
|
|
507
|
+
owner_of_deal: { label: "deal owner" },
|
|
508
|
+
called: { dash: "2 3", label: "called" },
|
|
509
|
+
messaged: { dash: "2 3", label: "messaged" },
|
|
510
|
+
emailed: { dash: "2 3", label: "emailed" },
|
|
511
|
+
replied_to: { dash: "2 3", label: "replied" },
|
|
512
|
+
met_with: { dash: "6 3", label: "met with" },
|
|
513
|
+
supports: { dash: "4 3", label: "support ticket" },
|
|
514
|
+
enrolled_in: { dash: "1 3", label: "in campaign" },
|
|
515
|
+
intro_path: { dash: "8 4", label: "warm intro" },
|
|
516
|
+
li_connection: { dash: "1 2", label: "LinkedIn connection" }
|
|
517
|
+
};
|
|
518
|
+
function edgeTypeStyle(relationshipType) {
|
|
519
|
+
return EDGE_TYPE_STYLES[relationshipType] ?? {
|
|
520
|
+
dash: "4 3",
|
|
521
|
+
label: relationshipType.replace(/_/g, " ")
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function nodeRadius(entityType) {
|
|
525
|
+
switch (entityType) {
|
|
526
|
+
case "organization":
|
|
527
|
+
return 16;
|
|
528
|
+
case "project":
|
|
529
|
+
return 13;
|
|
530
|
+
case "person":
|
|
531
|
+
case "contact":
|
|
532
|
+
return 12;
|
|
533
|
+
default:
|
|
534
|
+
return 10;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/view/view-model.ts
|
|
539
|
+
var MIN_EDGE_OPACITY = 0.08;
|
|
540
|
+
var MAX_EDGE_OPACITY = 0.8;
|
|
541
|
+
function resolveClasses(data, decorations, options = {}) {
|
|
542
|
+
const poiIds = decorations?.poiIds ? new Set(decorations.poiIds) : computePoiIds(data);
|
|
543
|
+
let bridgeIds;
|
|
544
|
+
if (decorations?.bridgeScores) {
|
|
545
|
+
const topN = options.bridgeTopN ?? 5;
|
|
546
|
+
bridgeIds = new Set(
|
|
547
|
+
Object.entries(decorations.bridgeScores).filter(([, score]) => score > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, topN).map(([id]) => id)
|
|
548
|
+
);
|
|
549
|
+
} else {
|
|
550
|
+
bridgeIds = computeBridgeIds(data, { topN: options.bridgeTopN ?? 5, now: options.now ?? 0 });
|
|
551
|
+
}
|
|
552
|
+
return { poiIds, bridgeIds };
|
|
553
|
+
}
|
|
554
|
+
function buildNodeVisuals(data, poiIds, bridgeIds, searchQuery) {
|
|
555
|
+
const query = searchQuery.trim().toLowerCase();
|
|
556
|
+
return data.entities.map((entity) => {
|
|
557
|
+
const nodeClass = classifyNode(entity.id, poiIds, bridgeIds);
|
|
558
|
+
const colors = entityColor(entity.entityType);
|
|
559
|
+
return {
|
|
560
|
+
id: entity.id,
|
|
561
|
+
entityName: entity.entityName,
|
|
562
|
+
entityType: entity.entityType,
|
|
563
|
+
nodeClass,
|
|
564
|
+
radius: nodeRadius(entity.entityType),
|
|
565
|
+
fill: colors.fill,
|
|
566
|
+
glow: colors.glow,
|
|
567
|
+
stroke: colors.stroke,
|
|
568
|
+
ring: nodeClass === "poi" ? CLASS_ACCENTS.poi : nodeClass === "bridge" ? CLASS_ACCENTS.bridge : null,
|
|
569
|
+
matched: query.length === 0 || entity.entityName.toLowerCase().includes(query)
|
|
570
|
+
};
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function buildEdgeVisuals(data, now) {
|
|
574
|
+
return data.relationships.map((edge) => {
|
|
575
|
+
const strength = Math.min(1, Math.max(0, edge.strength ?? 0.5));
|
|
576
|
+
const recency = recencyFactor(edge.lastSeenAt, now);
|
|
577
|
+
const style = edgeTypeStyle(edge.relationshipType);
|
|
578
|
+
const opacity = Math.min(
|
|
579
|
+
MAX_EDGE_OPACITY,
|
|
580
|
+
Math.max(MIN_EDGE_OPACITY, (0.15 + strength * 0.55) * recency)
|
|
581
|
+
);
|
|
582
|
+
return {
|
|
583
|
+
id: edge.id,
|
|
584
|
+
sourceEntityId: edge.sourceEntityId,
|
|
585
|
+
targetEntityId: edge.targetEntityId,
|
|
586
|
+
relationshipType: edge.relationshipType,
|
|
587
|
+
label: style.label,
|
|
588
|
+
dash: style.dash,
|
|
589
|
+
springStrength: strength,
|
|
590
|
+
opacity,
|
|
591
|
+
evidenceCount: edge.evidenceCount ?? 1
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
function buildTrace(data, fromId, toId, now) {
|
|
596
|
+
const strongest = strongestPath(data, fromId, toId, { now });
|
|
597
|
+
const fewest = fewestHops(data, fromId, toId, { now });
|
|
598
|
+
const diverges = strongest !== null && fewest !== null && strongest.nodeIds.join("|") !== fewest.nodeIds.join("|");
|
|
599
|
+
return { fromId, toId, strongest, fewest, diverges };
|
|
600
|
+
}
|
|
601
|
+
function traceEdgeIds(trace) {
|
|
602
|
+
const strongest = new Set(trace?.strongest?.edgeIds ?? []);
|
|
603
|
+
const fewest = new Set(trace?.fewest?.edgeIds ?? []);
|
|
604
|
+
const nodes = /* @__PURE__ */ new Set([...trace?.strongest?.nodeIds ?? [], ...trace?.fewest?.nodeIds ?? []]);
|
|
605
|
+
return { strongest, fewest, nodes };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/view/gtm-graph-view.tsx
|
|
609
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
610
|
+
var SIM_WIDTH = 1200;
|
|
611
|
+
var SIM_HEIGHT = 800;
|
|
612
|
+
function GtmGraphView({
|
|
613
|
+
data,
|
|
614
|
+
decorations,
|
|
615
|
+
now,
|
|
616
|
+
maxNodes = 400,
|
|
617
|
+
bridgeTopN = 5,
|
|
618
|
+
onEntitySelect,
|
|
619
|
+
height = 560,
|
|
620
|
+
dark = false
|
|
621
|
+
}) {
|
|
622
|
+
const [expanded, setExpanded] = (0, import_react.useState)(false);
|
|
623
|
+
const [searchQuery, setSearchQuery] = (0, import_react.useState)("");
|
|
624
|
+
const [traceMode, setTraceMode] = (0, import_react.useState)(false);
|
|
625
|
+
const [tracePick, setTracePick] = (0, import_react.useState)([]);
|
|
626
|
+
const [hoveredId, setHoveredId] = (0, import_react.useState)(null);
|
|
627
|
+
const [pan, setPan] = (0, import_react.useState)({ x: 0, y: 0 });
|
|
628
|
+
const [zoom, setZoom] = (0, import_react.useState)(0.9);
|
|
629
|
+
const [motionTick, setMotionTick] = (0, import_react.useState)(0);
|
|
630
|
+
const containerRef = (0, import_react.useRef)(null);
|
|
631
|
+
const panningRef = (0, import_react.useRef)(null);
|
|
632
|
+
const motionTimeRef = (0, import_react.useRef)(0);
|
|
633
|
+
const nowRef = (0, import_react.useRef)(now ?? Date.now());
|
|
634
|
+
const effectiveNow = now ?? nowRef.current;
|
|
635
|
+
const { poiIds, bridgeIds } = (0, import_react.useMemo)(
|
|
636
|
+
() => resolveClasses(data, decorations, { bridgeTopN, now: effectiveNow }),
|
|
637
|
+
[data, decorations, bridgeTopN, effectiveNow]
|
|
638
|
+
);
|
|
639
|
+
const pinned = (0, import_react.useMemo)(() => /* @__PURE__ */ new Set([...poiIds, ...bridgeIds]), [poiIds, bridgeIds]);
|
|
640
|
+
const visible = (0, import_react.useMemo)(
|
|
641
|
+
() => expanded ? data : capGraph(data, maxNodes, pinned),
|
|
642
|
+
[data, expanded, maxNodes, pinned]
|
|
643
|
+
);
|
|
644
|
+
const cappedCount = data.entities.length - visible.entities.length;
|
|
645
|
+
const nodeVisuals = (0, import_react.useMemo)(
|
|
646
|
+
() => buildNodeVisuals(visible, poiIds, bridgeIds, searchQuery),
|
|
647
|
+
[visible, poiIds, bridgeIds, searchQuery]
|
|
648
|
+
);
|
|
649
|
+
const edgeVisuals = (0, import_react.useMemo)(
|
|
650
|
+
() => buildEdgeVisuals(visible, effectiveNow),
|
|
651
|
+
[visible, effectiveNow]
|
|
652
|
+
);
|
|
653
|
+
const nodeById = (0, import_react.useMemo)(() => new Map(nodeVisuals.map((n) => [n.id, n])), [nodeVisuals]);
|
|
654
|
+
const positions = (0, import_react.useMemo)(() => {
|
|
655
|
+
const physicsNodes = nodeVisuals.map((n) => ({
|
|
656
|
+
id: n.id,
|
|
657
|
+
x: 0,
|
|
658
|
+
y: 0,
|
|
659
|
+
vx: 0,
|
|
660
|
+
vy: 0,
|
|
661
|
+
radius: n.radius,
|
|
662
|
+
group: n.entityType
|
|
663
|
+
}));
|
|
664
|
+
const physicsEdges = edgeVisuals.map((e) => ({
|
|
665
|
+
source: e.sourceEntityId,
|
|
666
|
+
target: e.targetEntityId,
|
|
667
|
+
strength: e.springStrength
|
|
668
|
+
}));
|
|
669
|
+
runSimulation(physicsNodes, physicsEdges, SIM_WIDTH, SIM_HEIGHT);
|
|
670
|
+
return { map: new Map(physicsNodes.map((n) => [n.id, { x: n.x, y: n.y }])) };
|
|
671
|
+
}, [nodeVisuals, edgeVisuals]);
|
|
672
|
+
(0, import_react.useEffect)(() => {
|
|
673
|
+
let rafId = 0;
|
|
674
|
+
let last = typeof performance !== "undefined" ? performance.now() : 0;
|
|
675
|
+
const animate = (time) => {
|
|
676
|
+
if (time - last >= 48) {
|
|
677
|
+
motionTimeRef.current += time - last;
|
|
678
|
+
last = time;
|
|
679
|
+
setMotionTick((t) => t + 1);
|
|
680
|
+
}
|
|
681
|
+
rafId = requestAnimationFrame(animate);
|
|
682
|
+
};
|
|
683
|
+
if (typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(animate);
|
|
684
|
+
return () => {
|
|
685
|
+
if (rafId && typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId);
|
|
686
|
+
};
|
|
687
|
+
}, []);
|
|
688
|
+
(0, import_react.useEffect)(() => {
|
|
689
|
+
const el = containerRef.current;
|
|
690
|
+
if (!el) return;
|
|
691
|
+
setPan({ x: el.clientWidth / 2, y: height / 2 });
|
|
692
|
+
}, [height]);
|
|
693
|
+
const displayPosition = (0, import_react.useCallback)(
|
|
694
|
+
(id) => {
|
|
695
|
+
const base = positions.map.get(id);
|
|
696
|
+
if (!base) return null;
|
|
697
|
+
const offset = getFloatingOffset(id, motionTimeRef.current);
|
|
698
|
+
return { x: base.x + offset.dx, y: base.y + offset.dy };
|
|
699
|
+
},
|
|
700
|
+
// motionTick forces recompute as the float clock advances
|
|
701
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
702
|
+
[positions, motionTick]
|
|
703
|
+
);
|
|
704
|
+
const trace = (0, import_react.useMemo)(() => {
|
|
705
|
+
if (tracePick.length !== 2) return null;
|
|
706
|
+
return buildTrace(visible, tracePick[0], tracePick[1], effectiveNow);
|
|
707
|
+
}, [tracePick, visible, effectiveNow]);
|
|
708
|
+
const traced = (0, import_react.useMemo)(() => traceEdgeIds(trace), [trace]);
|
|
709
|
+
const handleNodeClick = (0, import_react.useCallback)(
|
|
710
|
+
(entityId) => {
|
|
711
|
+
if (traceMode) {
|
|
712
|
+
setTracePick((prev) => {
|
|
713
|
+
if (prev.includes(entityId)) return prev.filter((id) => id !== entityId);
|
|
714
|
+
if (prev.length >= 2) return [prev[1], entityId];
|
|
715
|
+
return [...prev, entityId];
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
onEntitySelect?.(entityId);
|
|
720
|
+
},
|
|
721
|
+
[traceMode, onEntitySelect]
|
|
722
|
+
);
|
|
723
|
+
const handlePointerDown = (0, import_react.useCallback)(
|
|
724
|
+
(event) => {
|
|
725
|
+
if (event.button !== 0) return;
|
|
726
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
727
|
+
panningRef.current = {
|
|
728
|
+
startX: event.clientX,
|
|
729
|
+
startY: event.clientY,
|
|
730
|
+
originX: pan.x,
|
|
731
|
+
originY: pan.y
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
[pan]
|
|
735
|
+
);
|
|
736
|
+
const handlePointerMove = (0, import_react.useCallback)((event) => {
|
|
737
|
+
const panning = panningRef.current;
|
|
738
|
+
if (!panning) return;
|
|
739
|
+
setPan({
|
|
740
|
+
x: panning.originX + (event.clientX - panning.startX),
|
|
741
|
+
y: panning.originY + (event.clientY - panning.startY)
|
|
742
|
+
});
|
|
743
|
+
}, []);
|
|
744
|
+
const handlePointerUp = (0, import_react.useCallback)(() => {
|
|
745
|
+
panningRef.current = null;
|
|
746
|
+
}, []);
|
|
747
|
+
const handleWheel = (0, import_react.useCallback)((event) => {
|
|
748
|
+
const factor = Math.exp(-event.deltaY * 22e-4);
|
|
749
|
+
setZoom((z) => Math.min(2.5, Math.max(0.25, z * factor)));
|
|
750
|
+
}, []);
|
|
751
|
+
const bg = dark ? "#0b1020" : "#fbfaff";
|
|
752
|
+
const text = dark ? "#c6bbff" : "#333336";
|
|
753
|
+
const subText = dark ? "#8f86c4" : "#87839c";
|
|
754
|
+
const panelBg = dark ? "rgba(15,20,45,0.9)" : "rgba(255,255,255,0.92)";
|
|
755
|
+
const panelBorder = dark ? "rgba(130,140,255,0.25)" : "#e2d0fd";
|
|
756
|
+
const hovered = hoveredId ? nodeById.get(hoveredId) : null;
|
|
757
|
+
const legendTypes = (0, import_react.useMemo)(() => {
|
|
758
|
+
const present = new Set(nodeVisuals.map((n) => n.entityType));
|
|
759
|
+
return Object.entries(ENTITY_COLORS).filter(([type]) => present.has(type));
|
|
760
|
+
}, [nodeVisuals]);
|
|
761
|
+
const traceChain = (path) => {
|
|
762
|
+
if (!path) return "no path";
|
|
763
|
+
return path.nodeIds.map((id, i) => {
|
|
764
|
+
const name = nodeById.get(id)?.entityName ?? id.slice(0, 6);
|
|
765
|
+
if (i === path.nodeIds.length - 1) return name;
|
|
766
|
+
const edge = edgeVisuals.find((e) => e.id === path.edgeIds[i]);
|
|
767
|
+
return `${name} \u2014${edge ? `${edge.label} \xD7${edge.evidenceCount}` : "?"}\u2192 `;
|
|
768
|
+
}).join("");
|
|
769
|
+
};
|
|
770
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
771
|
+
"div",
|
|
772
|
+
{
|
|
773
|
+
ref: containerRef,
|
|
774
|
+
style: {
|
|
775
|
+
position: "relative",
|
|
776
|
+
width: "100%",
|
|
777
|
+
height,
|
|
778
|
+
overflow: "hidden",
|
|
779
|
+
borderRadius: 12,
|
|
780
|
+
border: `1px solid ${panelBorder}`,
|
|
781
|
+
background: bg,
|
|
782
|
+
fontFamily: "Poppins, ui-sans-serif, system-ui, sans-serif",
|
|
783
|
+
color: text
|
|
784
|
+
},
|
|
785
|
+
"data-testid": "gtm-graph-view",
|
|
786
|
+
children: [
|
|
787
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
788
|
+
"div",
|
|
789
|
+
{
|
|
790
|
+
style: {
|
|
791
|
+
position: "absolute",
|
|
792
|
+
top: 10,
|
|
793
|
+
left: 10,
|
|
794
|
+
zIndex: 20,
|
|
795
|
+
display: "flex",
|
|
796
|
+
gap: 8,
|
|
797
|
+
alignItems: "center"
|
|
798
|
+
},
|
|
799
|
+
children: [
|
|
800
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
801
|
+
"input",
|
|
802
|
+
{
|
|
803
|
+
value: searchQuery,
|
|
804
|
+
onChange: (e) => setSearchQuery(e.target.value),
|
|
805
|
+
placeholder: "Search entities\u2026",
|
|
806
|
+
"aria-label": "Search entities",
|
|
807
|
+
style: {
|
|
808
|
+
padding: "6px 10px",
|
|
809
|
+
fontSize: 12,
|
|
810
|
+
borderRadius: 8,
|
|
811
|
+
border: `1px solid ${panelBorder}`,
|
|
812
|
+
background: panelBg,
|
|
813
|
+
color: text,
|
|
814
|
+
outline: "none",
|
|
815
|
+
width: 180
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
),
|
|
819
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
820
|
+
"button",
|
|
821
|
+
{
|
|
822
|
+
type: "button",
|
|
823
|
+
onClick: () => {
|
|
824
|
+
setTraceMode((m) => !m);
|
|
825
|
+
setTracePick([]);
|
|
826
|
+
},
|
|
827
|
+
style: {
|
|
828
|
+
padding: "6px 10px",
|
|
829
|
+
fontSize: 12,
|
|
830
|
+
borderRadius: 8,
|
|
831
|
+
cursor: "pointer",
|
|
832
|
+
border: `1px solid ${traceMode ? CLASS_ACCENTS.poi : panelBorder}`,
|
|
833
|
+
background: traceMode ? CLASS_ACCENTS.poi : panelBg,
|
|
834
|
+
color: traceMode ? "#fff" : text
|
|
835
|
+
},
|
|
836
|
+
children: traceMode ? `Trace: pick ${2 - Math.min(tracePick.length, 2)} node${tracePick.length === 1 ? "" : "s"}` : "Trace warm path"
|
|
837
|
+
}
|
|
838
|
+
),
|
|
839
|
+
!expanded && cappedCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
840
|
+
"button",
|
|
841
|
+
{
|
|
842
|
+
type: "button",
|
|
843
|
+
onClick: () => setExpanded(true),
|
|
844
|
+
style: {
|
|
845
|
+
padding: "6px 10px",
|
|
846
|
+
fontSize: 12,
|
|
847
|
+
borderRadius: 8,
|
|
848
|
+
cursor: "pointer",
|
|
849
|
+
border: `1px solid ${panelBorder}`,
|
|
850
|
+
background: panelBg,
|
|
851
|
+
color: text
|
|
852
|
+
},
|
|
853
|
+
children: [
|
|
854
|
+
"Expand +",
|
|
855
|
+
cappedCount,
|
|
856
|
+
" nodes"
|
|
857
|
+
]
|
|
858
|
+
}
|
|
859
|
+
)
|
|
860
|
+
]
|
|
861
|
+
}
|
|
862
|
+
),
|
|
863
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
864
|
+
"svg",
|
|
865
|
+
{
|
|
866
|
+
width: "100%",
|
|
867
|
+
height: "100%",
|
|
868
|
+
style: { touchAction: "none", cursor: panningRef.current ? "grabbing" : "grab" },
|
|
869
|
+
onPointerDown: handlePointerDown,
|
|
870
|
+
onPointerMove: handlePointerMove,
|
|
871
|
+
onPointerUp: handlePointerUp,
|
|
872
|
+
onWheel: handleWheel,
|
|
873
|
+
role: "img",
|
|
874
|
+
"aria-label": "GTM graph",
|
|
875
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { transform: `translate(${pan.x} ${pan.y}) scale(${zoom})`, children: [
|
|
876
|
+
edgeVisuals.map((edge) => {
|
|
877
|
+
const source = displayPosition(edge.sourceEntityId);
|
|
878
|
+
const target = displayPosition(edge.targetEntityId);
|
|
879
|
+
if (!source || !target) return null;
|
|
880
|
+
const onStrongest = traced.strongest.has(edge.id);
|
|
881
|
+
const onFewest = traced.fewest.has(edge.id);
|
|
882
|
+
const isTraced = onStrongest || onFewest;
|
|
883
|
+
const dimmedByTrace = trace !== null && !isTraced;
|
|
884
|
+
const srcColor = nodeById.get(edge.sourceEntityId)?.stroke ?? "#818CF8";
|
|
885
|
+
const strokeColor = onStrongest ? TRACE_ACCENTS.strongest : onFewest ? TRACE_ACCENTS.fewest : srcColor;
|
|
886
|
+
const opacity = dimmedByTrace ? 0.04 : isTraced ? 0.95 : edge.opacity;
|
|
887
|
+
const width = isTraced ? 2.4 : 0.6 + edge.springStrength * 1.2;
|
|
888
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
889
|
+
"line",
|
|
890
|
+
{
|
|
891
|
+
x1: source.x,
|
|
892
|
+
y1: source.y,
|
|
893
|
+
x2: target.x,
|
|
894
|
+
y2: target.y,
|
|
895
|
+
stroke: strokeColor,
|
|
896
|
+
strokeOpacity: opacity,
|
|
897
|
+
strokeWidth: width,
|
|
898
|
+
strokeLinecap: "round",
|
|
899
|
+
strokeDasharray: isTraced ? void 0 : edge.dash,
|
|
900
|
+
"data-testid": `gtm-edge-${edge.id}`,
|
|
901
|
+
"data-traced": isTraced ? onStrongest ? "strongest" : "fewest" : void 0
|
|
902
|
+
},
|
|
903
|
+
edge.id
|
|
904
|
+
);
|
|
905
|
+
}),
|
|
906
|
+
nodeVisuals.map((node) => {
|
|
907
|
+
const pos = displayPosition(node.id);
|
|
908
|
+
if (!pos) return null;
|
|
909
|
+
const isHovered = hoveredId === node.id;
|
|
910
|
+
const isPicked = tracePick.includes(node.id);
|
|
911
|
+
const onTracePath = traced.nodes.has(node.id);
|
|
912
|
+
let opacity = node.matched ? 1 : 0.12;
|
|
913
|
+
if (trace !== null) opacity = onTracePath || isPicked ? 1 : 0.15;
|
|
914
|
+
return (
|
|
915
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: canvas nodes; hosts provide accessible detail UI via onEntitySelect
|
|
916
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
917
|
+
"g",
|
|
918
|
+
{
|
|
919
|
+
transform: `translate(${pos.x}, ${pos.y})`,
|
|
920
|
+
opacity,
|
|
921
|
+
style: { cursor: "pointer", transition: "opacity 0.25s" },
|
|
922
|
+
onClick: () => handleNodeClick(node.id),
|
|
923
|
+
onPointerEnter: () => setHoveredId(node.id),
|
|
924
|
+
onPointerLeave: () => setHoveredId(null),
|
|
925
|
+
"data-testid": `gtm-node-${node.id}`,
|
|
926
|
+
"data-node-class": node.nodeClass,
|
|
927
|
+
children: [
|
|
928
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { r: node.radius + 7, fill: node.glow, opacity: isHovered ? 0.35 : 0.12 }),
|
|
929
|
+
node.ring && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
930
|
+
"circle",
|
|
931
|
+
{
|
|
932
|
+
r: node.radius + 3.5,
|
|
933
|
+
fill: "none",
|
|
934
|
+
stroke: node.ring,
|
|
935
|
+
strokeWidth: 2,
|
|
936
|
+
strokeDasharray: node.nodeClass === "bridge" ? "3 2" : void 0
|
|
937
|
+
}
|
|
938
|
+
),
|
|
939
|
+
isPicked && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
940
|
+
"circle",
|
|
941
|
+
{
|
|
942
|
+
r: node.radius + 7.5,
|
|
943
|
+
fill: "none",
|
|
944
|
+
stroke: TRACE_ACCENTS.endpoints,
|
|
945
|
+
strokeWidth: 2.5
|
|
946
|
+
}
|
|
947
|
+
),
|
|
948
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
949
|
+
"circle",
|
|
950
|
+
{
|
|
951
|
+
r: node.radius,
|
|
952
|
+
fill: node.fill,
|
|
953
|
+
stroke: isHovered ? "#fff" : node.stroke,
|
|
954
|
+
strokeWidth: isHovered ? 2 : 0.8
|
|
955
|
+
}
|
|
956
|
+
),
|
|
957
|
+
(isHovered || node.nodeClass !== "other" || zoom > 1.3) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
958
|
+
"text",
|
|
959
|
+
{
|
|
960
|
+
y: node.radius + 14,
|
|
961
|
+
textAnchor: "middle",
|
|
962
|
+
style: {
|
|
963
|
+
fontSize: 10,
|
|
964
|
+
fill: subText,
|
|
965
|
+
pointerEvents: "none",
|
|
966
|
+
userSelect: "none"
|
|
967
|
+
},
|
|
968
|
+
children: node.entityName.length > 24 ? `${node.entityName.slice(0, 23)}\u2026` : node.entityName
|
|
969
|
+
}
|
|
970
|
+
)
|
|
971
|
+
]
|
|
972
|
+
},
|
|
973
|
+
node.id
|
|
974
|
+
)
|
|
975
|
+
);
|
|
976
|
+
})
|
|
977
|
+
] })
|
|
978
|
+
}
|
|
979
|
+
),
|
|
980
|
+
hovered && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
981
|
+
"div",
|
|
982
|
+
{
|
|
983
|
+
style: {
|
|
984
|
+
position: "absolute",
|
|
985
|
+
bottom: 12,
|
|
986
|
+
left: 12,
|
|
987
|
+
zIndex: 20,
|
|
988
|
+
padding: "8px 12px",
|
|
989
|
+
borderRadius: 10,
|
|
990
|
+
border: `1px solid ${panelBorder}`,
|
|
991
|
+
background: panelBg,
|
|
992
|
+
fontSize: 12,
|
|
993
|
+
maxWidth: 280,
|
|
994
|
+
pointerEvents: "none"
|
|
995
|
+
},
|
|
996
|
+
children: [
|
|
997
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { fontWeight: 600 }, children: hovered.entityName }),
|
|
998
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { color: subText }, children: [
|
|
999
|
+
hovered.entityType,
|
|
1000
|
+
hovered.nodeClass !== "other" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { color: hovered.ring ?? text, fontWeight: 600 }, children: [
|
|
1001
|
+
" ",
|
|
1002
|
+
"\xB7 ",
|
|
1003
|
+
hovered.nodeClass === "poi" ? "person of interest" : "bridge"
|
|
1004
|
+
] })
|
|
1005
|
+
] })
|
|
1006
|
+
]
|
|
1007
|
+
}
|
|
1008
|
+
),
|
|
1009
|
+
trace && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
1010
|
+
"div",
|
|
1011
|
+
{
|
|
1012
|
+
"data-testid": "gtm-trace-panel",
|
|
1013
|
+
style: {
|
|
1014
|
+
position: "absolute",
|
|
1015
|
+
bottom: 12,
|
|
1016
|
+
right: 12,
|
|
1017
|
+
zIndex: 20,
|
|
1018
|
+
padding: "10px 12px",
|
|
1019
|
+
borderRadius: 10,
|
|
1020
|
+
border: `1px solid ${panelBorder}`,
|
|
1021
|
+
background: panelBg,
|
|
1022
|
+
fontSize: 11,
|
|
1023
|
+
maxWidth: 380
|
|
1024
|
+
},
|
|
1025
|
+
children: [
|
|
1026
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { fontWeight: 600, marginBottom: 4 }, children: [
|
|
1027
|
+
"Warm path ",
|
|
1028
|
+
trace.diverges ? "(strongest \u2260 fewest)" : ""
|
|
1029
|
+
] }),
|
|
1030
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { color: TRACE_ACCENTS.strongest, marginBottom: 2 }, children: [
|
|
1031
|
+
"Strongest: ",
|
|
1032
|
+
traceChain(trace.strongest)
|
|
1033
|
+
] }),
|
|
1034
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { color: TRACE_ACCENTS.fewest }, children: [
|
|
1035
|
+
"Fewest hops: ",
|
|
1036
|
+
traceChain(trace.fewest)
|
|
1037
|
+
] })
|
|
1038
|
+
]
|
|
1039
|
+
}
|
|
1040
|
+
),
|
|
1041
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
1042
|
+
"div",
|
|
1043
|
+
{
|
|
1044
|
+
"data-testid": "gtm-legend",
|
|
1045
|
+
style: {
|
|
1046
|
+
position: "absolute",
|
|
1047
|
+
top: 10,
|
|
1048
|
+
right: 10,
|
|
1049
|
+
zIndex: 20,
|
|
1050
|
+
padding: "8px 12px",
|
|
1051
|
+
borderRadius: 10,
|
|
1052
|
+
border: `1px solid ${panelBorder}`,
|
|
1053
|
+
background: panelBg,
|
|
1054
|
+
fontSize: 11,
|
|
1055
|
+
display: "flex",
|
|
1056
|
+
flexDirection: "column",
|
|
1057
|
+
gap: 4
|
|
1058
|
+
},
|
|
1059
|
+
children: [
|
|
1060
|
+
legendTypes.map(([type, color]) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
|
|
1061
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1062
|
+
"span",
|
|
1063
|
+
{
|
|
1064
|
+
style: {
|
|
1065
|
+
width: 9,
|
|
1066
|
+
height: 9,
|
|
1067
|
+
borderRadius: "50%",
|
|
1068
|
+
background: color.fill,
|
|
1069
|
+
border: `1px solid ${color.stroke}`
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
),
|
|
1073
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: subText }, children: color.label })
|
|
1074
|
+
] }, type)),
|
|
1075
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
|
|
1076
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1077
|
+
"span",
|
|
1078
|
+
{
|
|
1079
|
+
style: {
|
|
1080
|
+
width: 9,
|
|
1081
|
+
height: 9,
|
|
1082
|
+
borderRadius: "50%",
|
|
1083
|
+
border: `2px solid ${CLASS_ACCENTS.poi}`
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
),
|
|
1087
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: subText }, children: "Person of interest" })
|
|
1088
|
+
] }),
|
|
1089
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 6 }, children: [
|
|
1090
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1091
|
+
"span",
|
|
1092
|
+
{
|
|
1093
|
+
style: {
|
|
1094
|
+
width: 9,
|
|
1095
|
+
height: 9,
|
|
1096
|
+
borderRadius: "50%",
|
|
1097
|
+
border: `2px dashed ${CLASS_ACCENTS.bridge}`
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
),
|
|
1101
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: subText }, children: "Bridge" })
|
|
1102
|
+
] })
|
|
1103
|
+
]
|
|
1104
|
+
}
|
|
1105
|
+
),
|
|
1106
|
+
visible.entities.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1107
|
+
"div",
|
|
1108
|
+
{
|
|
1109
|
+
style: {
|
|
1110
|
+
position: "absolute",
|
|
1111
|
+
inset: 0,
|
|
1112
|
+
display: "flex",
|
|
1113
|
+
alignItems: "center",
|
|
1114
|
+
justifyContent: "center",
|
|
1115
|
+
color: subText,
|
|
1116
|
+
fontSize: 13
|
|
1117
|
+
},
|
|
1118
|
+
children: "No GTM entities yet \u2014 the graph fills as the sync ingests leads, deals, and activities."
|
|
1119
|
+
}
|
|
1120
|
+
)
|
|
1121
|
+
]
|
|
1122
|
+
}
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1126
|
+
0 && (module.exports = {
|
|
1127
|
+
CLASS_ACCENTS,
|
|
1128
|
+
EDGE_TYPE_STYLES,
|
|
1129
|
+
ENTITY_COLORS,
|
|
1130
|
+
FALLBACK_ENTITY_COLOR,
|
|
1131
|
+
GtmGraphView,
|
|
1132
|
+
TRACE_ACCENTS,
|
|
1133
|
+
buildEdgeVisuals,
|
|
1134
|
+
buildNodeVisuals,
|
|
1135
|
+
buildTrace,
|
|
1136
|
+
edgeTypeStyle,
|
|
1137
|
+
entityColor,
|
|
1138
|
+
nodeRadius,
|
|
1139
|
+
resolveClasses,
|
|
1140
|
+
traceEdgeIds
|
|
1141
|
+
});
|
|
1142
|
+
//# sourceMappingURL=index.cjs.map
|