personal memory agent
1<div class="workspace-content" style="display:flex;flex-direction:column;height:calc(100vh - var(--facet-bar-height) - var(--app-bar-height) - 40px);overflow:hidden;">
2 <!-- Filter controls -->
3 <div class="graph-controls" id="graph-controls" style="display:none;">
4 <div class="graph-controls-row">
5 <div class="graph-type-filters">
6 <button class="graph-type-btn active" data-type="person" style="--btn-color:#2563eb">Person</button>
7 <button class="graph-type-btn active" data-type="company" style="--btn-color:#16a34a">Company</button>
8 <button class="graph-type-btn active" data-type="project" style="--btn-color:#b45309">Project</button>
9 <button class="graph-type-btn active" data-type="tool" style="--btn-color:#6b7280">Tool</button>
10 </div>
11 <div class="graph-time-filters">
12 <button class="graph-time-btn" data-days="7">7d</button>
13 <button class="graph-time-btn" data-days="30">30d</button>
14 <button class="graph-time-btn active" data-days="90">90d</button>
15 <button class="graph-time-btn" data-days="">All</button>
16 </div>
17 <div class="graph-strength-filter">
18 <label for="min-strength" title="Filter out weaker connections — higher means only stronger relationships">Min strength</label>
19 <input type="range" id="min-strength" min="0" max="500" value="0" step="5">
20 <span id="min-strength-val">0</span>
21 </div>
22 <div class="graph-stats" id="graph-stats" aria-live="polite"></div>
23 <button id="graph-view-toggle" class="graph-view-toggle" title="Switch between graph and list view">View as list</button>
24 </div>
25 </div>
26
27 <!-- Graph container -->
28 <div id="graph-container" style="flex:1;position:relative;min-height:0;">
29 <div class="graph-loading" id="graph-loading"><div class="graph-spinner"></div>Loading knowledge graph...</div>
30 <div class="graph-empty" id="graph-empty" style="display:none;">
31 <div class="graph-empty-icon">🕸️</div>
32 <h2>Your knowledge graph builds itself from daily use</h2>
33 <p>As solstone captures your meetings, conversations, and work, entities and relationships appear here automatically.</p>
34 <a href="/app/home" class="graph-empty-action">Get started with solstone →</a>
35 </div>
36 <div class="graph-error" id="graph-error" style="display:none;">
37 <div class="graph-error-icon">⚠️</div>
38 <h2>Couldn't load the knowledge graph</h2>
39 <p>This usually means the server didn't respond. <a href="#" id="graph-retry">Try again</a></p>
40 </div>
41 <div id="graph-canvas" tabindex="0" role="img" aria-label="Knowledge graph" style="width:100%;height:100%;display:none;"></div>
42 <div id="graph-stabilize"><div id="graph-stabilize-bar"></div></div>
43 <div id="graph-reload-overlay"><div class="graph-spinner"></div></div>
44 <div id="graph-sr-summary" class="sr-only" aria-live="polite"></div>
45 <div id="graph-list" style="display:none;overflow:auto;height:100%;">
46 <table class="graph-table">
47 <thead>
48 <tr>
49 <th role="button" tabindex="0" aria-sort="none" data-col="name">Name</th>
50 <th role="button" tabindex="0" aria-sort="none" data-col="type">Type</th>
51 <th role="button" tabindex="0" aria-sort="descending" data-col="score">Score</th>
52 <th role="button" tabindex="0" aria-sort="none" data-col="connections">Connections</th>
53 </tr>
54 </thead>
55 <tbody id="graph-list-body"></tbody>
56 </table>
57 </div>
58 </div>
59
60 <!-- Entity detail panel (slide-in from right) -->
61 <div class="graph-detail-panel" id="graph-detail-panel" tabindex="-1" aria-label="Entity details">
62 <div class="graph-detail-header">
63 <h2 id="detail-name" tabindex="-1"></h2>
64 <button class="graph-detail-close" id="detail-close">×</button>
65 </div>
66 <div class="graph-detail-body" id="detail-body"></div>
67 </div>
68</div>
69
70<style>
71/* Controls bar */
72.graph-controls {
73 padding: 0.5rem 0.75rem;
74 border-bottom: 1px solid #e5e7eb;
75 background: #fafafa;
76 flex-shrink: 0;
77}
78.graph-controls-row {
79 display: flex;
80 align-items: center;
81 gap: 1rem;
82 flex-wrap: wrap;
83}
84.graph-type-filters, .graph-time-filters {
85 display: flex;
86 gap: 0.25rem;
87}
88.graph-type-btn, .graph-time-btn {
89 padding: 0.25rem 0.6rem;
90 border: 1px solid #d1d5db;
91 border-radius: 4px;
92 background: white;
93 font-size: 0.8rem;
94 cursor: pointer;
95 transition: all 0.15s;
96 color: #374151;
97}
98.graph-type-btn.active {
99 background: var(--btn-color, #2563eb);
100 color: white;
101 border-color: var(--btn-color, #2563eb);
102}
103.graph-time-btn.active {
104 background: #374151;
105 color: white;
106 border-color: #374151;
107}
108.graph-type-btn:hover, .graph-time-btn:hover {
109 opacity: 0.85;
110}
111.graph-strength-filter {
112 display: flex;
113 align-items: center;
114 gap: 0.4rem;
115 font-size: 0.8rem;
116 color: #6b7280;
117}
118.graph-strength-filter input[type=range] {
119 width: 80px;
120 height: 4px;
121}
122.graph-stats {
123 margin-left: auto;
124 font-size: 0.8rem;
125 color: #6b7280;
126}
127
128/* Loading / empty states */
129.graph-loading {
130 text-align: center;
131 padding: 4em;
132 color: #666;
133}
134.graph-empty {
135 text-align: center;
136 padding: 4em 2em;
137 max-width: 450px;
138 margin: 2em auto;
139}
140.graph-error {
141 text-align: center;
142 padding: 4em 2em;
143 max-width: 450px;
144 margin: 2em auto;
145}
146.graph-empty-icon {
147 font-size: 4em;
148 margin-bottom: 0.25em;
149}
150.graph-error-icon {
151 font-size: 4em;
152 margin-bottom: 0.25em;
153}
154.graph-empty h2 {
155 margin: 0 0 0.5em 0;
156 font-size: 1.3em;
157 font-weight: 600;
158 color: #333;
159}
160.graph-error h2 {
161 margin: 0 0 0.5em 0;
162 font-size: 1.3em;
163 font-weight: 600;
164 color: #333;
165}
166.graph-empty p {
167 margin: 0;
168 color: #666;
169 line-height: 1.5;
170}
171.graph-empty-action {
172 display: inline-block;
173 margin-top: 1em;
174 color: #2563eb;
175 text-decoration: none;
176 font-size: 0.85rem;
177}
178.graph-empty-action:hover { text-decoration: underline; }
179.graph-empty-action:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
180#graph-canvas:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
181.graph-error p {
182 margin: 0;
183 color: #666;
184 line-height: 1.5;
185}
186
187/* Detail panel */
188.graph-detail-panel {
189 position: absolute;
190 top: 0;
191 right: 0;
192 width: 340px;
193 max-width: 90vw;
194 height: 100%;
195 background: white;
196 box-shadow: -2px 0 12px rgba(0,0,0,0.12);
197 z-index: 20;
198 transform: translateX(100%);
199 transition: transform 0.2s ease;
200 display: flex;
201 flex-direction: column;
202 overflow: hidden;
203}
204.graph-detail-panel.open {
205 transform: translateX(0);
206}
207.graph-detail-header {
208 display: flex;
209 align-items: center;
210 justify-content: space-between;
211 padding: 0.75rem 1rem;
212 border-bottom: 1px solid #e5e7eb;
213 flex-shrink: 0;
214}
215.graph-detail-header h2 {
216 margin: 0;
217 font-size: 1.1rem;
218 font-weight: 600;
219 color: #111827;
220 overflow: hidden;
221 text-overflow: ellipsis;
222 white-space: nowrap;
223}
224.graph-detail-close {
225 background: none;
226 border: none;
227 font-size: 1.5rem;
228 color: #6b7280;
229 cursor: pointer;
230 padding: 0 0.25rem;
231 line-height: 1;
232}
233.graph-detail-close:hover {
234 color: #111827;
235}
236.graph-detail-close:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
237.graph-detail-body {
238 flex: 1;
239 overflow-y: auto;
240 padding: 0.75rem 1rem;
241 font-size: 0.9rem;
242 color: #374151;
243}
244.detail-section {
245 margin-bottom: 1rem;
246}
247.detail-section-title {
248 font-size: 0.75rem;
249 font-weight: 600;
250 color: #6b7280;
251 margin: 0 0 0.4rem 0;
252}
253.detail-type-badge {
254 display: inline-block;
255 padding: 0.15rem 0.5rem;
256 border-radius: 4px;
257 font-size: 0.75rem;
258 font-weight: 500;
259 color: white;
260 margin-bottom: 0.5rem;
261}
262.detail-principal-badge {
263 display: inline-block;
264 padding: 0.15rem 0.5rem;
265 border-radius: 4px;
266 font-size: 0.75rem;
267 font-weight: 500;
268 background: #f59e0b;
269 color: white;
270 margin-left: 0.4rem;
271}
272.detail-description {
273 color: #4b5563;
274 line-height: 1.5;
275 margin-bottom: 0.75rem;
276}
277.detail-score-grid {
278 display: grid;
279 grid-template-columns: 1fr 1fr;
280 gap: 0.3rem;
281}
282.detail-score-item {
283 display: flex;
284 justify-content: space-between;
285 font-size: 0.82rem;
286}
287.detail-score-label {
288 color: #6b7280;
289}
290.detail-score-value {
291 font-weight: 600;
292 color: #111827;
293}
294.detail-connected-list {
295 list-style: none;
296 padding: 0;
297 margin: 0;
298}
299.detail-connected-item {
300 padding: 0.2rem 0;
301 border-bottom: 1px solid #f3f4f6;
302 display: flex;
303 justify-content: space-between;
304 font-size: 0.82rem;
305}
306.detail-connected-item:last-child { border-bottom: none; }
307.detail-connected-name {
308 background: none;
309 border: none;
310 padding: 0;
311 font: inherit;
312 cursor: pointer;
313 color: #2563eb;
314}
315.detail-connected-name:hover { text-decoration: underline; }
316.detail-connected-name:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
317.detail-connected-rel {
318 color: #9ca3af;
319 font-size: 0.75rem;
320}
321.detail-activity-item {
322 padding: 0.2rem 0;
323 border-bottom: 1px solid #f3f4f6;
324 font-size: 0.82rem;
325}
326.detail-activity-item:last-child { border-bottom: none; }
327.detail-activity-day {
328 color: #6b7280;
329 font-weight: 600;
330 margin-right: 0.4rem;
331}
332.detail-entity-link {
333 display: inline-block;
334 margin-top: 0.5rem;
335 color: #2563eb;
336 text-decoration: none;
337 font-size: 0.85rem;
338}
339.detail-entity-link:hover { text-decoration: underline; }
340.sr-only {
341 position: absolute;
342 width: 1px;
343 height: 1px;
344 padding: 0;
345 margin: -1px;
346 overflow: hidden;
347 clip: rect(0, 0, 0, 0);
348 white-space: nowrap;
349 border: 0;
350}
351.graph-view-toggle {
352 background: var(--bg-secondary, #f5f5f5);
353 border: 1px solid var(--border-color, #e5e5e5);
354 border-radius: 4px;
355 padding: 4px 10px;
356 font-size: 0.8rem;
357 cursor: pointer;
358 color: var(--text-primary, #333);
359}
360.graph-view-toggle:hover {
361 background: var(--bg-tertiary, #e5e5e5);
362}
363.graph-table {
364 width: 100%;
365 border-collapse: collapse;
366 font-size: 0.85rem;
367}
368.graph-table th,
369.graph-table td {
370 text-align: left;
371 padding: 6px 10px;
372 border-bottom: 1px solid var(--border-color, #e5e5e5);
373}
374.graph-table th {
375 font-weight: 600;
376 cursor: pointer;
377 user-select: none;
378 white-space: nowrap;
379 color: var(--text-secondary, #666);
380 font-size: 0.75rem;
381 text-transform: uppercase;
382 letter-spacing: 0.05em;
383}
384.graph-table th:hover {
385 color: var(--text-primary, #333);
386}
387.graph-table th[aria-sort="ascending"]::after { content: " ▲"; }
388.graph-table th[aria-sort="descending"]::after { content: " ▼"; }
389.graph-table tbody tr:hover {
390 background: var(--bg-secondary, #f5f5f5);
391}
392
393/* Spinner */
394.graph-spinner { width:24px; height:24px; border:3px solid #e5e7eb; border-top-color:#2563eb; border-radius:50%; animation:graph-spin 0.8s linear infinite; margin:0 auto 0.5em; }
395.graph-spinner-sm { width:18px; height:18px; border-width:2px; }
396@keyframes graph-spin { to { transform:rotate(360deg) } }
397
398/* Stabilization progress bar */
399#graph-stabilize { position:absolute; bottom:0; left:0; right:0; height:3px; background:#e5e7eb; z-index:10; display:none; }
400#graph-stabilize-bar { height:100%; width:0; background:#2563eb; transition:width 0.1s; }
401
402/* Reload overlay */
403.graph-reloading #graph-canvas { opacity:0.4; transition:opacity 0.15s; }
404#graph-reload-overlay { display:none; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:10; }
405.graph-reloading #graph-reload-overlay { display:block; }
406
407/* ── Responsive: Tablet (≤768px) ── */
408@media (max-width: 768px) {
409 .graph-controls-row { gap: 0.75rem; }
410 .graph-detail-panel { width: 280px; }
411}
412
413/* ── Responsive: Mobile (≤375px) ── */
414@media (max-width: 375px) {
415 .workspace-content { overflow-x: hidden; }
416
417 .graph-controls-row { gap: 0.5rem; }
418
419 .graph-type-filters,
420 .graph-time-filters,
421 .graph-strength-filter,
422 .graph-stats,
423 .graph-view-toggle { width: 100%; }
424
425 .graph-stats { margin-left: 0; }
426
427 .graph-type-btn,
428 .graph-time-btn,
429 .graph-view-toggle { min-height: 44px; padding: 0.5rem 0.75rem; }
430
431 .graph-strength-filter input[type=range] { width: 100%; height: 8px; }
432
433 .graph-detail-panel { width: 100%; max-width: 100vw; }
434
435 .graph-detail-close {
436 min-width: 44px;
437 min-height: 44px;
438 display: flex;
439 align-items: center;
440 justify-content: center;
441 font-size: 1.8rem;
442 }
443
444 #graph-canvas { min-height: 300px; }
445
446 .graph-empty-action,
447 #graph-retry,
448 .detail-entity-link { min-height: 44px; display: inline-flex; align-items: center; }
449
450 .detail-connected-item { min-height: 44px; display: flex; align-items: center; }
451}
452</style>
453
454<script src="{{ vendor_lib('vis-network') }}"></script>
455
456<script>
457(function() {
458 // --- State ---
459 let network = null;
460 let graphData = null;
461 let activeTypes = new Set(['person', 'company', 'project', 'tool']);
462 let timeDays = 90;
463 let minStrength = 0;
464 let detailOpen = false;
465 let listMode = false;
466 let sortState = { column: 'score', ascending: false };
467
468 // --- Color maps ---
469 const TYPE_COLORS = {
470 person: '#2563eb',
471 company: '#16a34a',
472 project: '#b45309',
473 tool: '#6b7280',
474 unknown: '#a1a1aa',
475 };
476
477 const TYPE_SEEDS = {
478 person: { x: -300, y: -200 },
479 company: { x: 300, y: -200 },
480 project: { x: -300, y: 200 },
481 tool: { x: 300, y: 200 },
482 unknown: { x: 0, y: 0 },
483 };
484
485 const EDGE_REL_COLORS = {
486 'works-on': '#8b5cf6',
487 'works-at': '#06b6d4',
488 'discusses-with': '#ec4899',
489 'collaborates-with': '#10b981',
490 'manages': '#f97316',
491 'reports-to': '#f97316',
492 'member-of': '#6366f1',
493 'uses': '#64748b',
494 };
495
496 // --- Helpers ---
497 function sinceFromDays(days) {
498 if (!days) return 'all';
499 const d = new Date();
500 d.setDate(d.getDate() - days);
501 return d.toISOString().slice(0,10).replace(/-/g,'');
502 }
503
504 function escapeHtml(text) {
505 const div = document.createElement('div');
506 div.textContent = text || '';
507 return div.innerHTML;
508 }
509
510 function formatDay(d) {
511 if (!d || d.length < 8) return d || '';
512 return d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8);
513 }
514
515 // --- Data fetch ---
516 async function fetchGraph() {
517 const params = new URLSearchParams();
518 const facet = window.selectedFacet;
519 if (facet) params.set('facet', facet);
520 params.set('since', sinceFromDays(timeDays));
521 const types = Array.from(activeTypes).join(',');
522 if (types) params.set('types', types);
523 if (minStrength > 0) params.set('min_strength', minStrength);
524 params.set('limit', '100');
525
526 const resp = await fetch('/app/graph/api/graph?' + params.toString());
527 if (!resp.ok) throw new Error('Failed to fetch graph');
528 return resp.json();
529 }
530
531 async function fetchEntity(name) {
532 const params = new URLSearchParams();
533 const facet = window.selectedFacet;
534 if (facet) params.set('facet', facet);
535 const resp = await fetch('/app/graph/api/entity/' + encodeURIComponent(name) + '?' + params.toString());
536 if (!resp.ok) return null;
537 return resp.json();
538 }
539
540 // --- Graph rendering ---
541 function buildVisData(data) {
542 const nodes = data.nodes.map(n => {
543 const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
544 return {
545 id: n.id,
546 label: n.name,
547 value: n.is_principal ? n.score * 1.5 : n.score,
548 color: {
549 background: color,
550 border: n.is_principal ? '#f59e0b' : color,
551 highlight: { background: color, border: n.is_principal ? '#f59e0b' : '#111827' },
552 hover: { background: color, border: n.is_principal ? '#f59e0b' : '#374151' },
553 },
554 borderWidth: n.is_principal ? 3 : 1.5,
555 font: { color: '#374151' },
556 title: n.name + ' (' + n.type + ') — score: ' + n.score.toFixed(1),
557 _data: n,
558 ...(window.selectedFacet ? {} : (() => {
559 const seed = TYPE_SEEDS[n.type] || TYPE_SEEDS.unknown;
560 return {
561 x: seed.x + (Math.random() - 0.5) * 150,
562 y: seed.y + (Math.random() - 0.5) * 150,
563 };
564 })()),
565 };
566 });
567
568 const edges = data.edges.map((e, i) => {
569 if (e.edge_type === 'explicit') {
570 const relColor = EDGE_REL_COLORS[e.relationship_type] || '#9ca3af';
571 return {
572 id: 'e' + i,
573 from: e.from,
574 to: e.to,
575 value: e.frequency,
576 color: { color: relColor, opacity: Math.min(0.9, 0.5 + e.frequency * 0.05), highlight: relColor, hover: relColor },
577 arrows: { to: { enabled: true, scaleFactor: 0.5 } },
578 smooth: { type: 'curvedCW', roundness: 0.15 },
579 title: (e.relationship_type || 'related') + ' (' + e.frequency + ')',
580 };
581 } else {
582 return {
583 id: 'e' + i,
584 from: e.from,
585 to: e.to,
586 value: e.frequency,
587 color: { color: '#d1d5db', opacity: Math.min(0.8, 0.4 + e.frequency * 0.04), highlight: '#9ca3af', hover: '#9ca3af' },
588 dashes: [4, 4],
589 smooth: { type: 'curvedCW', roundness: 0.1 },
590 title: 'co-occurrence (' + e.frequency + ')',
591 };
592 }
593 });
594
595 return {
596 nodes: new vis.DataSet(nodes),
597 edges: new vis.DataSet(edges),
598 };
599 }
600
601 function renderGraph(data) {
602 graphData = data;
603 const container = document.getElementById('graph-canvas');
604 const loading = document.getElementById('graph-loading');
605 const empty = document.getElementById('graph-empty');
606 const error = document.getElementById('graph-error');
607 const controls = document.getElementById('graph-controls');
608
609 loading.style.display = 'none';
610 error.style.display = 'none';
611
612 if (!data.nodes || data.nodes.length === 0) {
613 container.style.display = 'none';
614 document.getElementById('graph-list').style.display = 'none';
615 empty.style.display = 'block';
616 controls.style.display = 'none';
617 container.removeAttribute('aria-label');
618 document.getElementById('graph-sr-summary').textContent = '';
619 return;
620 }
621
622 empty.style.display = 'none';
623 container.style.display = 'block';
624 controls.style.display = 'block';
625 if (listMode) {
626 container.style.display = 'none';
627 document.getElementById('graph-list').style.display = 'block';
628 } else {
629 document.getElementById('graph-list').style.display = 'none';
630 }
631
632 const visData = buildVisData(data);
633
634 const options = {
635 physics: {
636 solver: 'forceAtlas2Based',
637 forceAtlas2Based: {
638 gravitationalConstant: -200,
639 centralGravity: 0.005,
640 springLength: 230,
641 springConstant: 0.015,
642 damping: 0.4,
643 avoidOverlap: 0.8,
644 },
645 stabilization: { iterations: 1000 },
646 },
647 nodes: {
648 shape: 'dot',
649 scaling: { min: 12, max: 45, label: { enabled: true, min: 14, max: 24 } },
650 borderWidth: 1.5,
651 shadow: { enabled: true, size: 4, x: 1, y: 1, color: 'rgba(0,0,0,0.1)' },
652 },
653 edges: {
654 scaling: { min: 1, max: 5 },
655 smooth: { enabled: true, type: 'curvedCW', roundness: 0.15 },
656 },
657 interaction: {
658 hover: true,
659 tooltipDelay: 100,
660 hideEdgesOnDrag: true,
661 hideEdgesOnZoom: true,
662 },
663 layout: {
664 improvedLayout: true,
665 },
666 };
667
668 if (network) {
669 network.setData(visData);
670 network.fit({ animation: { duration: 300 } });
671 } else {
672 requestAnimationFrame(function() {
673 // Guard against stale rAF callback if network was already created
674 if (network) {
675 network.setData(visData);
676 network.fit({ animation: { duration: 300 } });
677 return;
678 }
679
680 network = new vis.Network(container, visData, options);
681 // Show stabilization progress
682 var stabilizeEl = document.getElementById('graph-stabilize');
683 stabilizeEl.style.display = 'block';
684 network.on('stabilizationProgress', function(params) {
685 var pct = (params.iterations / params.total * 100);
686 document.getElementById('graph-stabilize-bar').style.width = pct + '%';
687 });
688
689 // Fit graph to container after stabilization
690 network.on('stabilizationIterationsDone', function() {
691 document.getElementById('graph-stabilize').style.display = 'none';
692 document.getElementById('graph-stabilize-bar').style.width = '0';
693 network.fit({ animation: { duration: 300 } });
694
695 // Post-stabilization recovery guard
696 if (container.offsetWidth === 0 || container.offsetHeight === 0) {
697 console.error('Graph canvas has zero dimensions after stabilization');
698 document.getElementById('graph-error').style.display = 'block';
699 container.style.display = 'none';
700 return;
701 }
702
703 // Check if all nodes clustered at origin (render failure)
704 var positions = network.getPositions();
705 var nodeIds = Object.keys(positions);
706 if (nodeIds.length > 0) {
707 var allAtOrigin = nodeIds.every(function(id) {
708 return Math.abs(positions[id].x) < 1 && Math.abs(positions[id].y) < 1;
709 });
710 if (allAtOrigin) {
711 console.warn('All nodes at origin after stabilization, attempting recovery fit');
712 network.fit({ animation: { duration: 300 } });
713 }
714 }
715 });
716
717 // Click node → inspect
718 network.on('click', function(params) {
719 if (params.nodes.length > 0) {
720 const nodeId = params.nodes[0];
721 const nodeData = visData.nodes.get(nodeId);
722 if (nodeData && nodeData._data) {
723 showDetail(nodeData._data);
724 }
725 } else {
726 // Click canvas → dismiss
727 closeDetail();
728 }
729 });
730 });
731 }
732
733 // Update stats
734 updateStats(data);
735 updateSrSummary(data);
736 if (listMode) renderList();
737 container.setAttribute('aria-label', 'Knowledge graph: ' + data.nodes.length + ' entities, ' + data.edges.length + ' connections');
738 }
739
740 function updateStats(data) {
741 const el = document.getElementById('graph-stats');
742 el.textContent = data.nodes.length + ' entities, ' + data.edges.length + ' connections';
743 }
744
745 function updateSrSummary(data) {
746 const el = document.getElementById('graph-sr-summary');
747 if (!data.nodes || data.nodes.length === 0) {
748 el.textContent = '';
749 return;
750 }
751 const top = data.nodes.slice().sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 10);
752 const topList = top.map(n => n.name + ' (' + n.type + ')').join(', ');
753 el.textContent = 'Knowledge graph showing ' + data.nodes.length + ' entities and ' + data.edges.length + ' connections. Top entities: ' + topList + '.';
754 }
755
756 function renderList() {
757 if (!graphData || !graphData.nodes) return;
758 const tbody = document.getElementById('graph-list-body');
759
760 // Build connection count map
761 const connCount = new Map();
762 (graphData.edges || []).forEach(e => {
763 connCount.set(e.from, (connCount.get(e.from) || 0) + 1);
764 connCount.set(e.to, (connCount.get(e.to) || 0) + 1);
765 });
766
767 // Build rows with connection count
768 const rows = graphData.nodes.map(n => ({
769 name: n.name,
770 type: n.type,
771 score: n.score || 0,
772 connections: connCount.get(n.id) || 0,
773 }));
774
775 // Sort
776 const col = sortState.column;
777 const dir = sortState.ascending ? 1 : -1;
778 rows.sort((a, b) => {
779 const av = a[col], bv = b[col];
780 if (typeof av === 'string') return dir * av.localeCompare(bv);
781 return dir * ((av || 0) - (bv || 0));
782 });
783
784 // Render
785 tbody.innerHTML = rows.map(r =>
786 '<tr><td>' + escapeHtml(r.name) + '</td><td>' + escapeHtml(r.type) + '</td><td>' + r.score + '</td><td>' + r.connections + '</td></tr>'
787 ).join('');
788 }
789
790 // --- Detail panel ---
791 function showDetail(nodeData) {
792 const panel = document.getElementById('graph-detail-panel');
793 const nameEl = document.getElementById('detail-name');
794 const bodyEl = document.getElementById('detail-body');
795
796 nameEl.textContent = nodeData.name;
797 bodyEl.innerHTML = '<div style="text-align:center;padding:2em;color:#999;"><div class="graph-spinner graph-spinner-sm" style="margin:0 auto 0.5em;"></div>Loading...</div>';
798 panel.classList.add('open');
799 detailOpen = true;
800 nameEl.focus();
801
802 fetchEntity(nodeData.id).then(intel => {
803 if (!intel || intel.error) {
804 bodyEl.innerHTML = '<div style="padding:1em;color:#666;">' +
805 '<strong>Couldn\'t load entity details</strong><br>' +
806 '<span style="color:#999;font-size:0.85rem;">The server may not have responded.</span><br>' +
807 '<a href="#" class="detail-retry-link" style="color:#2563eb;text-decoration:none;font-size:0.85rem;">Try again</a>' +
808 '</div>';
809 bodyEl.querySelector('.detail-retry-link').addEventListener('click', function(e) {
810 e.preventDefault();
811 showDetail(nodeData);
812 });
813 return;
814 }
815 renderDetail(intel, nodeData);
816 });
817 }
818
819 function renderDetail(intel, nodeData) {
820 const bodyEl = document.getElementById('detail-body');
821 const identity = intel.identity || {};
822 const strength = intel.strength || {};
823 const typeColor = TYPE_COLORS[identity.type?.toLowerCase()] || TYPE_COLORS.unknown;
824
825 let html = '';
826
827 // Type badge
828 html += '<div>';
829 html += '<span class="detail-type-badge" style="background:' + typeColor + '">' + escapeHtml(identity.type || nodeData.type) + '</span>';
830 if (identity.is_principal) {
831 html += '<span class="detail-principal-badge">You</span>';
832 }
833 html += '</div>';
834
835 // Description
836 if (identity.description) {
837 html += '<div class="detail-description">' + escapeHtml(identity.description) + '</div>';
838 }
839
840 // Strength score
841 html += '<div class="detail-section">';
842 html += '<div class="detail-section-title">strength score</div>';
843 html += '<div style="font-size:1.3em;font-weight:700;color:#111827;margin-bottom:0.3rem;">' + (strength.score || 0).toFixed(1) + '</div>';
844 html += '<div class="detail-score-grid">';
845 html += scoreItem('Co-occurrence', strength.co_occurrence);
846 html += scoreItem('Appearances', strength.appearance);
847 html += scoreItem('Recency', strength.recency?.toFixed(2));
848 html += scoreItem('Facet breadth', strength.facet_breadth);
849 html += scoreItem('Observation depth', strength.observation_depth);
850 html += '</div></div>';
851
852 // Connected entities (from network field)
853 const networkEntities = intel.network || {};
854 const connectedNames = Object.keys(networkEntities).sort((a, b) => networkEntities[b] - networkEntities[a]).slice(0, 15);
855 if (connectedNames.length > 0) {
856 html += '<div class="detail-section">';
857 html += '<div class="detail-section-title">connected entities</div>';
858 html += '<ul class="detail-connected-list">';
859 for (const name of connectedNames) {
860 html += '<li class="detail-connected-item">';
861 html += '<button class="detail-connected-name" data-entity="' + escapeHtml(name) + '">' + escapeHtml(name) + '</button>';
862 html += '<span class="detail-connected-rel">' + networkEntities[name] + ' shared</span>';
863 html += '</li>';
864 }
865 html += '</ul></div>';
866 }
867
868 // Recent activity
869 const activity = (intel.activity || []).slice(0, 10);
870 if (activity.length > 0) {
871 html += '<div class="detail-section">';
872 html += '<div class="detail-section-title">recent activity</div>';
873 for (const a of activity) {
874 html += '<div class="detail-activity-item">';
875 html += '<span class="detail-activity-day">' + formatDay(a.day) + '</span>';
876 const label = a.event_title || a.signal_type || '';
877 html += escapeHtml(label);
878 if (a.target_name) html += ' → ' + escapeHtml(a.target_name);
879 html += '</div>';
880 }
881 html += '</div>';
882 }
883
884 // Link to entities app
885 html += '<a class="detail-entity-link" href="/app/entities#' + encodeURIComponent(identity.entity_id || nodeData.id) + '">View full intelligence →</a>';
886
887 bodyEl.innerHTML = html;
888
889 // Click connected entity names
890 bodyEl.querySelectorAll('.detail-connected-name').forEach(el => {
891 el.addEventListener('click', () => {
892 const eName = el.dataset.entity;
893 // Try to find this entity in the current graph
894 if (graphData) {
895 const matchNode = graphData.nodes.find(n => n.name === eName || n.id === eName);
896 if (matchNode) {
897 showDetail(matchNode);
898 if (network) network.selectNodes([matchNode.id]);
899 return;
900 }
901 }
902 // Fallback: just fetch directly
903 showDetail({ id: eName, name: eName, type: 'unknown' });
904 });
905 });
906 }
907
908 function scoreItem(label, value) {
909 return '<div class="detail-score-item"><span class="detail-score-label">' + label + '</span><span class="detail-score-value">' + (value ?? 0) + '</span></div>';
910 }
911
912 function closeDetail() {
913 document.getElementById('graph-detail-panel').classList.remove('open');
914 detailOpen = false;
915 if (network) network.unselectAll();
916 document.getElementById('graph-canvas').focus();
917 }
918
919 // --- Filter handlers ---
920 document.querySelectorAll('.graph-type-btn').forEach(btn => {
921 btn.addEventListener('click', () => {
922 const t = btn.dataset.type;
923 if (btn.classList.contains('active')) {
924 btn.classList.remove('active');
925 activeTypes.delete(t);
926 } else {
927 btn.classList.add('active');
928 activeTypes.add(t);
929 }
930 reload();
931 });
932 });
933
934 document.querySelectorAll('.graph-time-btn').forEach(btn => {
935 btn.addEventListener('click', () => {
936 document.querySelectorAll('.graph-time-btn').forEach(b => b.classList.remove('active'));
937 btn.classList.add('active');
938 timeDays = btn.dataset.days ? parseInt(btn.dataset.days) : 0;
939 reload();
940 });
941 });
942
943 const strengthSlider = document.getElementById('min-strength');
944 const strengthVal = document.getElementById('min-strength-val');
945 let strengthTimeout = null;
946 strengthSlider.addEventListener('input', () => {
947 strengthVal.textContent = strengthSlider.value;
948 });
949 strengthSlider.addEventListener('change', () => {
950 minStrength = parseInt(strengthSlider.value);
951 reload();
952 });
953
954 // --- View toggle ---
955 document.getElementById('graph-view-toggle').addEventListener('click', function() {
956 listMode = !listMode;
957 const canvas = document.getElementById('graph-canvas');
958 const list = document.getElementById('graph-list');
959 this.textContent = listMode ? 'View as graph' : 'View as list';
960 if (listMode) {
961 canvas.style.display = 'none';
962 list.style.display = 'block';
963 renderList();
964 } else {
965 list.style.display = 'none';
966 canvas.style.display = 'block';
967 if (network) network.fit();
968 }
969 });
970
971 // --- Sort handlers ---
972 document.querySelectorAll('.graph-table th').forEach(th => {
973 function doSort() {
974 const col = th.dataset.col;
975 if (sortState.column === col) {
976 sortState.ascending = !sortState.ascending;
977 } else {
978 sortState.column = col;
979 sortState.ascending = col === 'name';
980 }
981 // Update aria-sort on all headers
982 document.querySelectorAll('.graph-table th').forEach(h => {
983 h.setAttribute('aria-sort', h.dataset.col === sortState.column
984 ? (sortState.ascending ? 'ascending' : 'descending')
985 : 'none');
986 });
987 renderList();
988 }
989 th.addEventListener('click', doSort);
990 th.addEventListener('keydown', function(e) {
991 if (e.key === 'Enter' || e.key === ' ') {
992 e.preventDefault();
993 doSort();
994 }
995 });
996 });
997
998 document.getElementById('detail-close').addEventListener('click', closeDetail);
999 document.addEventListener('keydown', function(e) {
1000 if (e.key === 'Escape' && detailOpen) {
1001 closeDetail();
1002 }
1003 });
1004 document.getElementById('graph-retry').addEventListener('click', function(e) {
1005 e.preventDefault();
1006 reload();
1007 });
1008
1009 // --- Facet awareness ---
1010 window.addEventListener('facet.switch', () => {
1011 reload();
1012 });
1013
1014 // --- Load / reload ---
1015 let loadCount = 0;
1016 async function reload() {
1017 const thisLoad = ++loadCount;
1018 // Show reload overlay if graph already rendered
1019 var gc = document.getElementById('graph-container');
1020 if (network) gc.classList.add('graph-reloading');
1021 try {
1022 const data = await fetchGraph();
1023 if (thisLoad !== loadCount) { gc.classList.remove('graph-reloading'); return; } // stale
1024 renderGraph(data);
1025 gc.classList.remove('graph-reloading');
1026 } catch (err) {
1027 gc.classList.remove('graph-reloading');
1028 console.error('Graph load failed:', err);
1029 if (thisLoad === loadCount) {
1030 document.getElementById('graph-loading').style.display = 'none';
1031 document.getElementById('graph-empty').style.display = 'none';
1032 document.getElementById('graph-error').style.display = 'block';
1033 document.getElementById('graph-canvas').style.display = 'none';
1034 document.getElementById('graph-list').style.display = 'none';
1035 document.getElementById('graph-controls').style.display = 'none';
1036 document.getElementById('graph-sr-summary').textContent = '';
1037 }
1038 }
1039 }
1040
1041 // Initial load
1042 reload();
1043})();
1044</script>