personal memory agent
at main 1044 lines 33 kB view raw
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">&times;</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>