tangled vouch map with historical data
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add incoming and outgoing and optimize a bit

+161 -64
+161 -64
web/index.html
··· 319 319 <button class="filter-btn active" data-kind="vouch/denounce"> 320 320 <span class="filter-dot" style="background:var(--denounce)"></span>denounce 321 321 </button> 322 + <button class="filter-btn active" data-kind="vouch/mixed"> 323 + <span class="filter-dot" style="background:#df8e1d"></span>mixed 324 + </button> 322 325 <button class="filter-btn" data-kind="follow"> 323 326 <span class="filter-dot" style="background:var(--follow)"></span>follow 324 327 </button> ··· 327 330 <span id="stat-nodes">0</span> nodes &middot; 328 331 <span id="stat-vouches">0</span> vouches &middot; 329 332 <span id="stat-denounces">0</span> denounces &middot; 333 + <span id="stat-mixed">0</span> mixed &middot; 330 334 <span id="stat-follows">0</span> follows 331 335 </div> 332 336 </div> ··· 348 352 const edgeFilters = { 349 353 'vouch/vouch': true, 350 354 'vouch/denounce': true, 355 + 'vouch/mixed': true, 351 356 'follow': false, 352 357 }; 353 358 ··· 364 369 return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); 365 370 } 366 371 372 + function isDarkMode() { 373 + return window.matchMedia('(prefers-color-scheme: dark)').matches; 374 + } 375 + 367 376 function shortenDID(did) { 368 377 if (did.length > 24) return did.slice(0, 12) + '…' + did.slice(-8); 369 378 return did; ··· 405 414 initGraph(); 406 415 } 407 416 417 + let nodeDegrees = {}; 418 + let nodeInVouch = {}; 419 + let nodeInDenounce = {}; 420 + let nodeOutVouch = {}; 421 + let nodeOutDenounce = {}; 422 + let nodeColors = {}; 423 + 408 424 function buildGraph() { 409 425 if (!graphData) return { nodes: [], links: [] }; 410 426 411 427 const nodeSet = new Set(); 412 428 const links = []; 413 429 430 + // deduplicate mutual edges between same pair 431 + // for vouch/denounce: if A→B vouch and B→A denounce exist, merge into one line 432 + // for same-kind mutual: A→B vouch and B→A vouch -> one line with mutual=true 433 + const pairIndex = new Map(); // "min|max" -> { vouch: link, denounce: link } 414 434 for (const edge of graphData.edges) { 415 435 if (!edgeFilters[edge.kind]) continue; 416 - nodeSet.add(edge.source); 417 - nodeSet.add(edge.target); 418 - links.push({ 419 - source: edge.source, 420 - target: edge.target, 421 - kind: edge.kind, 422 - reason: edge.reason, 423 - time: edge.time, 424 - }); 436 + const min = edge.source < edge.target ? edge.source : edge.target; 437 + const max = edge.source < edge.target ? edge.target : edge.source; 438 + const pairKey = min + '|' + max; 439 + if (!pairIndex.has(pairKey)) pairIndex.set(pairKey, {}); 440 + const entry = pairIndex.get(pairKey); 441 + const kind = edge.kind === 'vouch/vouch' ? 'vouch' : edge.kind === 'vouch/denounce' ? 'denounce' : 'follow'; 442 + if (kind === 'vouch' || kind === 'denounce') { 443 + if (!entry[kind]) { 444 + entry[kind] = { 445 + source: edge.source, 446 + target: edge.target, 447 + kind: edge.kind, 448 + reason: edge.reason, 449 + time: edge.time, 450 + }; 451 + } else { 452 + // same kind, both directions -> mutual 453 + entry[kind].mutual = true; 454 + } 455 + } else { 456 + // follows: just add directly 457 + if (!entry.follows) entry.follows = []; 458 + entry.follows.push({ 459 + source: edge.source, 460 + target: edge.target, 461 + kind: edge.kind, 462 + reason: edge.reason, 463 + time: edge.time, 464 + mutual: false, 465 + }); 466 + } 467 + } 468 + 469 + for (const entry of pairIndex.values()) { 470 + if (entry.vouch && entry.denounce) { 471 + // mixed: one line showing both 472 + nodeSet.add(entry.vouch.source); 473 + nodeSet.add(entry.vouch.target); 474 + links.push({ 475 + source: entry.vouch.source, 476 + target: entry.vouch.target, 477 + kind: 'vouch/mixed', 478 + reason: entry.vouch.reason, 479 + time: entry.vouch.time, 480 + mutual: true, 481 + }); 482 + } else if (entry.vouch) { 483 + nodeSet.add(entry.vouch.source); 484 + nodeSet.add(entry.vouch.target); 485 + links.push(entry.vouch); 486 + } else if (entry.denounce) { 487 + nodeSet.add(entry.denounce.source); 488 + nodeSet.add(entry.denounce.target); 489 + links.push(entry.denounce); 490 + } 491 + if (entry.follows) { 492 + for (const fl of entry.follows) { 493 + nodeSet.add(fl.source); 494 + nodeSet.add(fl.target); 495 + links.push(fl); 496 + } 497 + } 425 498 } 426 499 427 500 const nodes = [...nodeSet].map(id => { 428 501 const p = profileMap[id] || {}; 429 502 return { id, label: p.handle || id, handle: p.handle || '', avatar: p.avatar || '' }; 430 503 }); 504 + 505 + // pre-compute degrees and vouch/denounce counts 506 + nodeDegrees = {}; 507 + nodeInVouch = {}; 508 + nodeInDenounce = {}; 509 + nodeOutVouch = {}; 510 + nodeOutDenounce = {}; 511 + for (const n of nodes) { nodeDegrees[n.id] = 0; nodeInVouch[n.id] = 0; nodeInDenounce[n.id] = 0; nodeOutVouch[n.id] = 0; nodeOutDenounce[n.id] = 0; } 512 + for (const link of links) { 513 + const src = link.source.id || link.source; 514 + const tgt = link.target.id || link.target; 515 + nodeDegrees[src] = (nodeDegrees[src] || 0) + 1; 516 + nodeDegrees[tgt] = (nodeDegrees[tgt] || 0) + 1; 517 + if (link.kind === 'vouch/vouch') { nodeInVouch[tgt] = (nodeInVouch[tgt] || 0) + 1; nodeOutVouch[src] = (nodeOutVouch[src] || 0) + 1; } 518 + else if (link.kind === 'vouch/denounce') { nodeInDenounce[tgt] = (nodeInDenounce[tgt] || 0) + 1; nodeOutDenounce[src] = (nodeOutDenounce[src] || 0) + 1; } 519 + else if (link.kind === 'vouch/mixed') { nodeInVouch[tgt] = (nodeInVouch[tgt] || 0) + 1; nodeInDenounce[tgt] = (nodeInDenounce[tgt] || 0) + 1; nodeOutVouch[src] = (nodeOutVouch[src] || 0) + 1; nodeOutDenounce[src] = (nodeOutDenounce[src] || 0) + 1; } 520 + } 521 + 522 + // pre-compute node colors 523 + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 524 + nodeColors = {}; 525 + for (const n of nodes) { 526 + const vouches = nodeInVouch[n.id] || 0; 527 + const denounces = nodeInDenounce[n.id] || 0; 528 + const total = vouches + denounces; 529 + if (total === 0) { nodeColors[n.id] = isDark ? '#6e738d' : '#9ca0b0'; continue; } 530 + const ratio = denounces / total; 531 + if (ratio > 0.5) nodeColors[n.id] = isDark ? '#ed8796' : '#d20f39'; 532 + else if (ratio > 0.1) nodeColors[n.id] = isDark ? '#eed49f' : '#df8e1d'; 533 + else nodeColors[n.id] = isDark ? '#a6da95' : '#40a02b'; 534 + } 535 + 431 536 return { nodes, links }; 432 537 } 433 538 ··· 435 540 switch (link.kind) { 436 541 case 'vouch/vouch': return getCSS('--vouch-edge') || 'rgba(64,160,43,0.6)'; 437 542 case 'vouch/denounce': return getCSS('--denounce-edge') || 'rgba(210,15,57,0.6)'; 543 + case 'vouch/mixed': return isDarkMode() ? 'rgba(238,212,159,0.6)' : 'rgba(223,142,29,0.6)'; 438 544 case 'follow': return getCSS('--follow-edge') || 'rgba(136,57,239,0.55)'; 439 545 default: return 'rgba(107,114,128,0.3)'; 440 546 } 441 547 } 442 548 443 549 function edgeWidth(link) { 444 - switch (link.kind) { 445 - case 'vouch/vouch': return 2; 446 - case 'vouch/denounce': return 1.5; 447 - case 'follow': return 1; 448 - default: return 1; 449 - } 550 + const base = (() => { 551 + switch (link.kind) { 552 + case 'vouch/vouch': return 2; 553 + case 'vouch/denounce': return 1.5; 554 + case 'vouch/mixed': return 2; 555 + case 'follow': return 1; 556 + default: return 1; 557 + } 558 + })(); 559 + return link.mutual ? base + 1 : base; 450 560 } 451 561 452 562 function arrowColor(link) { 453 563 switch (link.kind) { 454 564 case 'vouch/vouch': return getCSS('--vouch') || '#40a02b'; 455 565 case 'vouch/denounce': return getCSS('--denounce') || '#d20f39'; 566 + case 'vouch/mixed': return isDarkMode() ? '#eed49f' : '#df8e1d'; 456 567 case 'follow': return getCSS('--follow') || '#8839ef'; 457 568 default: return '#6b7280'; 458 569 } 459 570 } 460 571 461 - function nodeColor(node, links) { 462 - const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 463 - // count incoming vouch vs denounce edges for this node (as target) 464 - let vouches = 0, denounces = 0; 465 - for (const link of links) { 466 - const tgt = link.target.id || link.target; 467 - if (tgt === node.id) { 468 - if (link.kind === 'vouch/vouch') vouches++; 469 - else if (link.kind === 'vouch/denounce') denounces++; 470 - } 471 - } 472 - const total = vouches + denounces; 473 - if (total === 0) return isDark ? '#6e738d' : '#9ca0b0'; // gray = no vouch/denounce 474 - const ratio = denounces / total; 475 - if (ratio > 0.5) return isDark ? '#ed8796' : '#d20f39'; // red = majority denounced 476 - if (ratio > 0.1) return isDark ? '#eed49f' : '#df8e1d'; // yellow = >10% denounced 477 - return isDark ? '#a6da95' : '#40a02b'; // green = mostly vouched 572 + function nodeColor(node) { 573 + return nodeColors[node.id] || (window.matchMedia('(prefers-color-scheme: dark)').matches ? '#6e738d' : '#9ca0b0'); 478 574 } 479 575 480 576 const avatarCache = new Map(); ··· 502 598 .backgroundColor(isDark ? '#111827' : '#f1f5f9') 503 599 .nodeLabel('') 504 600 .nodeCanvasObject((node, ctx, globalScale) => { 505 - const size = Math.max(6, Math.min(20, 4 * Math.log2(getNodeDegree(node.id, data.links) + 2))); 601 + const size = Math.max(6, Math.min(20, 4 * Math.log2(getNodeDegree(node.id) + 2))); 506 602 const img = node.avatar ? getAvatarImg(node.avatar) : null; 507 603 508 604 if (img && img.complete && img.naturalWidth > 0) { ··· 515 611 ctx.restore(); 516 612 517 613 // colored ring matching node degree 518 - const ringColor = nodeColor(node, data.links); 614 + const ringColor = nodeColor(node); 519 615 ctx.beginPath(); 520 616 ctx.arc(node.x, node.y, size + 1.5, 0, 2 * Math.PI); 521 617 ctx.strokeStyle = ringColor; 522 618 ctx.lineWidth = 2; 523 619 ctx.stroke(); 524 620 } else { 525 - const color = nodeColor(node, data.links); 621 + const color = nodeColor(node); 526 622 ctx.beginPath(); 527 623 ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); 528 624 ctx.fillStyle = isDark ? '#1f2937' : '#f1f5f9'; ··· 541 637 } 542 638 }) 543 639 .nodeCanvasObjectMode(() => 'replace') 544 - .nodeVal(node => Math.max(1, Math.log2(getNodeDegree(node.id, data.links) + 1))) 640 + .nodeVal(node => Math.max(1, Math.log2(getNodeDegree(node.id) + 1))) 545 641 .linkColor(edgeColor) 546 642 .linkWidth(edgeWidth) 547 643 .linkDirectionalArrowLength(link => link.kind.startsWith('vouch/') ? 4 : 3) ··· 575 671 const srcP = profileMap[srcId] || {}; 576 672 const tgtP = profileMap[tgtId] || {}; 577 673 const kindClass = link.kind === 'vouch/denounce' ? 'kd' 578 - : link.kind === 'vouch/vouch' ? 'kv' : 'kf'; 674 + : link.kind === 'vouch/vouch' ? 'kv' 675 + : link.kind === 'vouch/mixed' ? 'ks' : 'kf'; 579 676 const label = link.kind === 'vouch/denounce' ? 'denounce' 580 - : link.kind === 'vouch/vouch' ? 'vouch' : 'follow'; 677 + : link.kind === 'vouch/vouch' ? 'vouch' 678 + : link.kind === 'vouch/mixed' ? 'mixed' : 'follow'; 679 + const mutualTag = link.mutual ? ' (mutual)' : ''; 581 680 tooltip.style.display = 'block'; 582 681 const srcAvatar = srcP.avatar 583 682 ? `<img class="tooltip-avatar" src="/api/proxy/avatar?url=${encodeURIComponent(srcP.avatar)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="tooltip-avatar-fallback" style="display:none">${(srcP.handle || srcId).charAt(0).toUpperCase()}</div>` ··· 589 688 <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap"> 590 689 ${srcAvatar} 591 690 <span class="did">${srcP.handle || shortenDID(srcId)}</span> 592 - <span class="${kindClass} action-word">${label}s</span> 691 + <span class="${kindClass} action-word">${label}s${mutualTag}</span> 593 692 ${tgtAvatar} 594 693 <span class="did">${tgtP.handle || shortenDID(tgtId)}</span> 595 694 </div> ··· 602 701 .onNodeClick(node => { 603 702 if (node) window.open(`https://tangled.org/${node.id}`, '_blank'); 604 703 }) 605 - .cooldownTime(4000); 704 + .cooldownTime(1500); 606 705 607 - // tune forces: moderate repulsion, longer links to spread center, distance cap prevents outliers 608 - fg.d3Force('charge').strength(-100).distanceMax(400); 609 - fg.d3Force('link').distance(80).strength(0.5); 706 + // tune forces for better spacing 707 + fg.d3Force('charge').strength(node => { 708 + const deg = nodeDegrees[node.id] || 1; 709 + return -30 * Math.min(deg, 20); 710 + }); 711 + fg.d3Force('link').distance(link => link.kind === 'follow' ? 30 : 60); 610 712 611 713 container.addEventListener('mousemove', e => { 612 714 if (tooltip.style.display === 'block') { ··· 637 739 : `<div class="tooltip-avatar-fallback">${(handle || node.id).charAt(0).toUpperCase()}</div>`; 638 740 639 741 // count incoming vouches vs denounces 640 - let vouches = 0, denounces = 0; 641 - for (const link of (fg ? fg.graphData().links : [])) { 642 - const tgt = link.target.id || link.target; 643 - if (tgt === node.id) { 644 - if (link.kind === 'vouch/vouch') vouches++; 645 - else if (link.kind === 'vouch/denounce') denounces++; 646 - } 647 - } 648 - const total = vouches + denounces; 742 + const inV = nodeInVouch[node.id] || 0; 743 + const inD = nodeInDenounce[node.id] || 0; 744 + const outV = nodeOutVouch[node.id] || 0; 745 + const outD = nodeOutDenounce[node.id] || 0; 746 + const total = inV + inD; 649 747 let trustLine = ''; 650 - if (total > 0) { 651 - const pct = Math.round((vouches / total) * 100); 748 + if (total > 0 || outV + outD > 0) { 749 + const pct = total > 0 ? Math.round((inV / total) * 100) : 100; 652 750 const cls = pct > 90 ? 'kv' : pct > 50 ? 'ks' : 'kd'; 653 - trustLine = `<div style="margin-top:4px;font-size:0.6875rem"><span class="${cls}">${pct}%</span> trusted <span style="color:var(--text-muted)">(${vouches}v / ${denounces}d)</span></div>`; 751 + const inPart = total > 0 ? `<span class="kv">${inV}v</span> <span class="kd">${inD}d</span> in` : ''; 752 + const outPart = outV + outD > 0 ? `<span class="kv">${outV}v</span> <span class="kd">${outD}d</span> out` : ''; 753 + const sep = inPart && outPart ? ' · ' : ''; 754 + trustLine = `<div style="margin-top:4px;font-size:0.6875rem"><span class="${cls}">${pct}%</span> trusted · ${inPart}${sep}${outPart}</div>`; 654 755 } 655 756 656 757 tooltip.innerHTML = ` ··· 672 773 // the next paint will pick up the avatar naturally 673 774 } 674 775 675 - function getNodeDegree(nodeId, links) { 676 - let degree = 0; 677 - for (const link of links) { 678 - const src = link.source.id || link.source; 679 - const tgt = link.target.id || link.target; 680 - if (src === nodeId || tgt === nodeId) degree++; 681 - } 682 - return degree; 776 + function getNodeDegree(nodeId) { 777 + return nodeDegrees[nodeId] || 0; 683 778 } 684 779 685 780 function refreshGraph() { ··· 691 786 692 787 function updateStats(data) { 693 788 document.getElementById('stat-nodes').textContent = data.nodes.length; 694 - let vouches = 0, denounces = 0, follows = 0; 789 + let vouches = 0, denounces = 0, mixed = 0, follows = 0; 695 790 for (const l of data.links) { 696 791 switch (l.kind) { 697 792 case 'vouch/vouch': vouches++; break; 698 793 case 'vouch/denounce': denounces++; break; 794 + case 'vouch/mixed': mixed++; break; 699 795 case 'follow': follows++; break; 700 796 } 701 797 } 702 798 document.getElementById('stat-vouches').textContent = vouches; 703 799 document.getElementById('stat-denounces').textContent = denounces; 800 + document.getElementById('stat-mixed').textContent = mixed; 704 801 document.getElementById('stat-follows').textContent = follows; 705 802 headerStats.textContent = `${data.nodes.length} nodes / ${data.links.length} edges`; 706 803 }