···
1
+
"""CLI command for displaying and browsing thread-graphs of blog posts."""
3
+
from dataclasses import dataclass, field
4
+
from datetime import datetime
5
+
from enum import Enum
6
+
from pathlib import Path
7
+
from typing import Dict, List, Optional, Set, Tuple
10
+
from rich.console import Console
15
+
from flask import Flask, render_template_string, jsonify
16
+
from textual import events
17
+
from textual.app import App, ComposeResult
18
+
from textual.containers import Container, Horizontal, Vertical
19
+
from textual.reactive import reactive
20
+
from textual.widget import Widget
21
+
from textual.widgets import Footer, Header, Label, Static
23
+
from ...core.git_store import GitStore
24
+
from ...models import AtomEntry
25
+
from ..main import app
26
+
from ..utils import get_tsv_mode, load_config
31
+
class LinkType(Enum):
32
+
"""Types of links between entries."""
34
+
SELF_REFERENCE = "self" # Link to same user's content
35
+
USER_REFERENCE = "user" # Link to another tracked user
36
+
EXTERNAL = "external" # Link to external content
41
+
"""Represents a node in the thread graph."""
46
+
outbound_links: List[Tuple[str, LinkType]] = field(
47
+
default_factory=list
49
+
inbound_backlinks: List[str] = field(default_factory=list) # entry_ids
52
+
def published_date(self) -> datetime:
53
+
"""Get the published or updated date for sorting."""
54
+
return self.entry.published or self.entry.updated
57
+
def title(self) -> str:
58
+
"""Get the entry title."""
59
+
return self.entry.title
62
+
def summary(self) -> str:
63
+
"""Get a short summary of the entry."""
64
+
if self.entry.summary:
66
+
self.entry.summary[:100] + "..."
67
+
if len(self.entry.summary) > 100
68
+
else self.entry.summary
75
+
"""Represents the full thread graph of interconnected posts."""
77
+
nodes: Dict[str, ThreadNode] = field(default_factory=dict) # entry_id -> ThreadNode
78
+
user_entries: Dict[str, List[str]] = field(
79
+
default_factory=dict
80
+
) # username -> [entry_ids]
81
+
url_to_entry: Dict[str, str] = field(default_factory=dict) # url -> entry_id
83
+
def add_node(self, node: ThreadNode) -> None:
84
+
"""Add a node to the graph."""
85
+
self.nodes[node.entry_id] = node
87
+
# Update user entries index
88
+
if node.username not in self.user_entries:
89
+
self.user_entries[node.username] = []
90
+
self.user_entries[node.username].append(node.entry_id)
92
+
# Update URL mapping
94
+
self.url_to_entry[str(node.entry.link)] = node.entry_id
96
+
def get_connected_components(self) -> List[Set[str]]:
97
+
"""Find all connected components in the graph (threads)."""
98
+
visited: Set[str] = set()
99
+
components: List[Set[str]] = []
101
+
for entry_id in self.nodes:
102
+
if entry_id not in visited:
103
+
component: Set[str] = set()
104
+
self._dfs(entry_id, visited, component)
105
+
components.append(component)
109
+
def _dfs(self, entry_id: str, visited: Set[str], component: Set[str]) -> None:
110
+
"""Depth-first search to find connected components."""
111
+
if entry_id in visited:
114
+
visited.add(entry_id)
115
+
component.add(entry_id)
117
+
node = self.nodes.get(entry_id)
121
+
# Follow outbound links
122
+
for url, link_type in node.outbound_links:
123
+
if url in self.url_to_entry:
124
+
target_id = self.url_to_entry[url]
125
+
self._dfs(target_id, visited, component)
128
+
for backlink_id in node.inbound_backlinks:
129
+
self._dfs(backlink_id, visited, component)
131
+
def get_standalone_entries(self) -> List[str]:
132
+
"""Get entries with no connections."""
134
+
for entry_id, node in self.nodes.items():
135
+
if not node.outbound_links and not node.inbound_backlinks:
136
+
standalone.append(entry_id)
139
+
def sort_component_chronologically(self, component: Set[str]) -> List[str]:
140
+
"""Sort a component by published date."""
142
+
self.nodes[entry_id] for entry_id in component if entry_id in self.nodes
144
+
nodes.sort(key=lambda n: n.published_date)
145
+
return [n.entry_id for n in nodes]
148
+
def build_thread_graph(git_store: GitStore) -> ThreadGraph:
149
+
"""Build the thread graph from all entries in the git store."""
150
+
graph = ThreadGraph()
152
+
# Get all users from index
153
+
index = git_store._load_index()
156
+
# Build user domain mapping
157
+
for username, user_metadata in index.users.items():
160
+
# Add domains from feeds
161
+
for feed_url in user_metadata.feeds:
162
+
from urllib.parse import urlparse
164
+
domain = urlparse(str(feed_url)).netloc.lower()
166
+
domains.add(domain)
168
+
# Add domain from homepage
169
+
if user_metadata.homepage:
170
+
domain = urlparse(str(user_metadata.homepage)).netloc.lower()
172
+
domains.add(domain)
174
+
user_domains[username] = domains
176
+
# Process all entries
177
+
for username in index.users:
178
+
entries = git_store.list_entries(username)
180
+
for entry in entries:
182
+
node = ThreadNode(entry_id=entry.id, username=username, entry=entry)
184
+
# Process outbound links
185
+
for link in getattr(entry, "links", []):
186
+
link_type = categorize_link(link, username, user_domains)
187
+
node.outbound_links.append((link, link_type))
190
+
node.inbound_backlinks = getattr(entry, "backlinks", [])
193
+
graph.add_node(node)
198
+
def categorize_link(
199
+
url: str, source_username: str, user_domains: Dict[str, Set[str]]
201
+
"""Categorize a link as self-reference, user reference, or external."""
202
+
from urllib.parse import urlparse
205
+
parsed = urlparse(url)
206
+
domain = parsed.netloc.lower()
208
+
# Check if it's a self-reference
209
+
if domain in user_domains.get(source_username, set()):
210
+
return LinkType.SELF_REFERENCE
212
+
# Check if it's a reference to another tracked user
213
+
for username, domains in user_domains.items():
214
+
if username != source_username and domain in domains:
215
+
return LinkType.USER_REFERENCE
217
+
# Otherwise it's external
218
+
return LinkType.EXTERNAL
221
+
return LinkType.EXTERNAL
224
+
class ThreadTreeWidget(Static):
225
+
"""Widget for displaying a thread as a tree."""
227
+
def __init__(self, component: Set[str], graph: ThreadGraph, **kwargs):
228
+
super().__init__(**kwargs)
229
+
self.component = component
232
+
def compose(self) -> ComposeResult:
233
+
"""Create the tree display."""
234
+
# Sort entries chronologically
235
+
sorted_ids = self.graph.sort_component_chronologically(self.component)
237
+
# Build tree structure as text
238
+
content_lines = ["Thread:"]
239
+
added_nodes: Set[str] = set()
241
+
# Add nodes in chronological order, showing connections
242
+
for entry_id in sorted_ids:
243
+
if entry_id not in added_nodes:
244
+
self._add_node_to_text(content_lines, entry_id, added_nodes, 0)
246
+
# Join all lines into content
247
+
content = "\n".join(content_lines)
249
+
# Create a Static widget with the content
250
+
yield Static(content, id="thread-content")
252
+
def _add_node_to_text(
253
+
self, content_lines: List[str], entry_id: str, added_nodes: Set[str], indent: int = 0
255
+
"""Recursively add nodes to the text display."""
256
+
if entry_id in added_nodes:
257
+
# Show cycle reference
258
+
node = self.graph.nodes.get(entry_id)
260
+
prefix = " " * indent
261
+
content_lines.append(f"{prefix}↻ {node.username}: {node.title}")
264
+
added_nodes.add(entry_id)
265
+
node = self.graph.nodes.get(entry_id)
269
+
# Format node display
270
+
prefix = " " * indent
271
+
date_str = node.published_date.strftime("%Y-%m-%d")
272
+
node_label = f"{prefix}• {node.username}: {node.title} ({date_str})"
273
+
content_lines.append(node_label)
275
+
# Add connections info
276
+
if node.outbound_links:
277
+
links_by_type: Dict[LinkType, List[str]] = {}
278
+
for url, link_type in node.outbound_links:
279
+
if link_type not in links_by_type:
280
+
links_by_type[link_type] = []
281
+
links_by_type[link_type].append(url)
283
+
for link_type, urls in links_by_type.items():
284
+
type_label = f"{prefix} → {link_type.value}: {len(urls)} link(s)"
285
+
content_lines.append(type_label)
287
+
if node.inbound_backlinks:
288
+
backlink_label = f"{prefix} ← backlinks: {len(node.inbound_backlinks)}"
289
+
content_lines.append(backlink_label)
292
+
class ThreadBrowserApp(App):
293
+
"""Terminal UI for browsing threads."""
297
+
background: $surface;
303
+
border: solid $primary;
304
+
overflow-y: scroll;
310
+
border: solid $secondary;
311
+
overflow-y: scroll;
317
+
("q", "quit", "Quit"),
318
+
("j", "next_thread", "Next Thread"),
319
+
("k", "prev_thread", "Previous Thread"),
320
+
("enter", "select_thread", "View Thread"),
323
+
def __init__(self, graph: ThreadGraph):
327
+
self.current_thread_index = 0
328
+
self._build_thread_list()
330
+
def _build_thread_list(self):
331
+
"""Build the list of threads to display."""
332
+
# Get connected components (actual threads)
333
+
components = self.graph.get_connected_components()
335
+
# Sort components by the earliest date in each
336
+
sorted_components = []
337
+
for component in components:
338
+
if len(component) > 1: # Only show actual threads
339
+
sorted_ids = self.graph.sort_component_chronologically(component)
341
+
first_node = self.graph.nodes.get(sorted_ids[0])
343
+
sorted_components.append((first_node.published_date, component))
345
+
sorted_components.sort(key=lambda x: x[0], reverse=True)
346
+
self.threads = [comp for _, comp in sorted_components]
348
+
def compose(self) -> ComposeResult:
349
+
"""Create the UI layout."""
353
+
with Vertical(id="thread-list"):
354
+
yield Label("Threads", classes="title")
355
+
for i, thread in enumerate(self.threads):
356
+
# Get thread summary
357
+
sorted_ids = self.graph.sort_component_chronologically(thread)
359
+
first_node = self.graph.nodes.get(sorted_ids[0])
361
+
label = f"{i + 1}. {first_node.title} ({len(thread)} posts)"
362
+
yield Label(label, classes="thread-item")
364
+
with Vertical(id="entry-detail"):
366
+
yield ThreadTreeWidget(self.threads[0], self.graph)
370
+
def action_next_thread(self) -> None:
371
+
"""Move to next thread."""
372
+
if self.current_thread_index < len(self.threads) - 1:
373
+
self.current_thread_index += 1
374
+
self.update_display()
376
+
def action_prev_thread(self) -> None:
377
+
"""Move to previous thread."""
378
+
if self.current_thread_index > 0:
379
+
self.current_thread_index -= 1
380
+
self.update_display()
382
+
def action_select_thread(self) -> None:
383
+
"""View detailed thread."""
384
+
# In a real implementation, this could show more detail
387
+
def update_display(self) -> None:
388
+
"""Update the thread display."""
389
+
detail_view = self.query_one("#entry-detail")
390
+
detail_view.remove_children()
392
+
if self.threads and self.current_thread_index < len(self.threads):
393
+
widget = ThreadTreeWidget(
394
+
self.threads[self.current_thread_index], self.graph
396
+
detail_view.mount(widget)
401
+
config_file: Optional[Path] = typer.Option(
402
+
Path("thicket.yaml"),
405
+
help="Path to configuration file",
407
+
interactive: bool = typer.Option(
409
+
"--interactive/--no-interactive",
411
+
help="Launch interactive terminal UI",
413
+
web: bool = typer.Option(
417
+
help="Launch web server with D3 force graph visualization",
419
+
port: int = typer.Option(
423
+
help="Port for web server",
426
+
"""Browse and visualize thread-graphs of interconnected blog posts.
428
+
This command analyzes all blog entries and their links/backlinks to build
429
+
a graph of conversations and references between posts. Threads are displayed
430
+
as connected components in the link graph.
433
+
# Load configuration
434
+
config = load_config(config_file)
436
+
# Initialize Git store
437
+
git_store = GitStore(config.git_store)
439
+
# Build thread graph
440
+
console.print("Building thread graph...")
441
+
graph = build_thread_graph(git_store)
444
+
components = graph.get_connected_components()
445
+
threads = [c for c in components if len(c) > 1]
446
+
standalone = graph.get_standalone_entries()
449
+
f"\n[green]Found {len(threads)} threads and {len(standalone)} standalone posts[/green]"
453
+
# Launch web server with D3 visualization
454
+
_launch_web_server(graph, port)
455
+
elif interactive and threads:
456
+
# Launch terminal UI
457
+
app = ThreadBrowserApp(graph)
460
+
# Display in console
462
+
_display_threads_tsv(graph, threads)
464
+
_display_threads_rich(graph, threads)
466
+
except Exception as e:
467
+
console.print(f"[red]Error building threads: {e}[/red]")
468
+
raise typer.Exit(1)
471
+
def _display_threads_rich(graph: ThreadGraph, threads: List[Set[str]]) -> None:
472
+
"""Display threads using rich formatting."""
473
+
for i, thread in enumerate(threads[:10]): # Show first 10 threads
474
+
sorted_ids = graph.sort_component_chronologically(thread)
476
+
console.print(f"\n[bold]Thread {i + 1}[/bold] ({len(thread)} posts)")
478
+
for j, entry_id in enumerate(sorted_ids):
479
+
node = graph.nodes.get(entry_id)
481
+
date_str = node.published_date.strftime("%Y-%m-%d")
482
+
indent = " " * min(j, 3) # Max 3 levels of indent
483
+
console.print(f"{indent}• [{node.username}] {node.title} ({date_str})")
486
+
if node.outbound_links:
488
+
for _, link_type in node.outbound_links:
489
+
link_summary[link_type] = link_summary.get(link_type, 0) + 1
491
+
link_str = ", ".join(
492
+
[f"{t.value}:{c}" for t, c in link_summary.items()]
494
+
console.print(f"{indent} → Links: {link_str}")
497
+
def _display_threads_tsv(graph: ThreadGraph, threads: List[Set[str]]) -> None:
498
+
"""Display threads in TSV format."""
499
+
print("Thread\tSize\tFirst Post\tLast Post\tUsers")
501
+
for i, thread in enumerate(threads):
502
+
sorted_ids = graph.sort_component_chronologically(thread)
505
+
first_node = graph.nodes.get(sorted_ids[0])
506
+
last_node = graph.nodes.get(sorted_ids[-1])
509
+
for entry_id in thread:
510
+
node = graph.nodes.get(entry_id)
512
+
users.add(node.username)
514
+
if first_node and last_node:
516
+
f"{i + 1}\t{len(thread)}\t{first_node.published_date.strftime('%Y-%m-%d')}\t{last_node.published_date.strftime('%Y-%m-%d')}\t{','.join(users)}"
520
+
def _build_graph_json(graph: ThreadGraph) -> dict:
521
+
"""Convert ThreadGraph to JSON format for D3 visualization."""
525
+
# Color mapping for different users
528
+
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
529
+
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
530
+
"#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5"
533
+
# Assign colors to users
534
+
for i, username in enumerate(set(node.username for node in graph.nodes.values())):
535
+
user_colors[username] = colors[i % len(colors)]
538
+
for entry_id, node in graph.nodes.items():
541
+
"title": node.title,
542
+
"username": node.username,
543
+
"date": node.published_date.strftime("%Y-%m-%d"),
544
+
"summary": node.summary,
545
+
"color": user_colors[node.username],
546
+
"outbound_count": len(node.outbound_links),
547
+
"backlink_count": len(node.inbound_backlinks),
549
+
"self": len([l for l in node.outbound_links if l[1] == LinkType.SELF_REFERENCE]),
550
+
"user": len([l for l in node.outbound_links if l[1] == LinkType.USER_REFERENCE]),
551
+
"external": len([l for l in node.outbound_links if l[1] == LinkType.EXTERNAL])
555
+
# Create links (only for links between tracked entries)
556
+
for entry_id, node in graph.nodes.items():
557
+
for url, link_type in node.outbound_links:
558
+
if url in graph.url_to_entry:
559
+
target_id = graph.url_to_entry[url]
560
+
if target_id in graph.nodes:
562
+
"source": entry_id,
563
+
"target": target_id,
564
+
"type": link_type.value,
572
+
"total_nodes": len(nodes),
573
+
"total_links": len(links),
574
+
"users": list(user_colors.keys()),
575
+
"user_colors": user_colors
580
+
def _launch_web_server(graph: ThreadGraph, port: int) -> None:
581
+
"""Launch Flask web server with D3 force graph visualization."""
582
+
flask_app = Flask(__name__)
584
+
# Store graph data globally for the Flask app
585
+
graph_data = _build_graph_json(graph)
587
+
@flask_app.route('/')
589
+
"""Serve the main visualization page."""
590
+
return render_template_string(HTML_TEMPLATE, port=port)
592
+
@flask_app.route('/api/graph')
594
+
"""API endpoint to serve graph data as JSON."""
595
+
return jsonify(graph_data)
597
+
# Disable Flask logging in development mode
599
+
log = logging.getLogger('werkzeug')
600
+
log.setLevel(logging.ERROR)
602
+
def open_browser():
603
+
"""Open browser after a short delay."""
605
+
webbrowser.open(f'http://localhost:{port}')
607
+
# Start browser in a separate thread
608
+
browser_thread = threading.Thread(target=open_browser)
609
+
browser_thread.daemon = True
610
+
browser_thread.start()
612
+
console.print(f"\n[green]Starting web server at http://localhost:{port}[/green]")
613
+
console.print("[yellow]Press Ctrl+C to stop the server[/yellow]")
616
+
flask_app.run(host='0.0.0.0', port=port, debug=False)
617
+
except KeyboardInterrupt:
618
+
console.print("\n[green]Server stopped[/green]")
621
+
# HTML template for D3 force graph visualization
622
+
HTML_TEMPLATE = """
626
+
<meta charset="UTF-8">
627
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
628
+
<title>Thicket Thread Graph Visualization</title>
629
+
<script src="https://d3js.org/d3.v7.min.js"></script>
632
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
635
+
background-color: #f5f5f5;
639
+
text-align: center;
640
+
margin-bottom: 20px;
645
+
margin-bottom: 10px;
650
+
justify-content: center;
652
+
margin-bottom: 20px;
658
+
align-items: center;
662
+
select, input[type="range"] {
664
+
border: 1px solid #ddd;
665
+
border-radius: 4px;
670
+
justify-content: center;
672
+
margin-bottom: 20px;
679
+
padding: 10px 15px;
680
+
border-radius: 6px;
681
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
686
+
border-radius: 8px;
687
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
701
+
stroke-width: 1.5px;
712
+
stroke-opacity: 0.6;
724
+
.link.external-link {
729
+
position: absolute;
730
+
background: rgba(0, 0, 0, 0.9);
733
+
border-radius: 4px;
736
+
pointer-events: none;
747
+
border-radius: 6px;
748
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
754
+
margin: 0 0 10px 0;
761
+
align-items: center;
762
+
margin-bottom: 5px;
769
+
border-radius: 2px;
780
+
<div class="header">
781
+
<h1>Thicket Thread Graph Visualization</h1>
782
+
<p>Interactive visualization of blog post connections and conversations</p>
785
+
<div class="controls">
786
+
<div class="control-group">
787
+
<label for="userFilter">Filter by user:</label>
788
+
<select id="userFilter">
789
+
<option value="all">All Users</option>
793
+
<div class="control-group">
794
+
<label for="linkFilter">Show links:</label>
795
+
<select id="linkFilter">
796
+
<option value="all">All Links</option>
797
+
<option value="user">User Links Only</option>
798
+
<option value="self">Self Links Only</option>
799
+
<option value="external">External Links Only</option>
803
+
<div class="control-group">
804
+
<label for="forceStrength">Force Strength:</label>
805
+
<input type="range" id="forceStrength" min="0.1" max="2" step="0.1" value="0.3">
808
+
<div class="control-group">
809
+
<label for="nodeSize">Node Size:</label>
810
+
<input type="range" id="nodeSize" min="3" max="15" step="1" value="6">
814
+
<div class="stats" id="stats"></div>
816
+
<div id="graph-container">
817
+
<svg id="graph"></svg>
820
+
<div class="legend">
821
+
<h3>Link Types</h3>
822
+
<div class="legend-item">
823
+
<div class="legend-line" style="background: #2ca02c;"></div>
824
+
<span>Self References</span>
826
+
<div class="legend-item">
827
+
<div class="legend-line" style="background: #ff7f0e;"></div>
828
+
<span>User References</span>
830
+
<div class="legend-item">
831
+
<div class="legend-line" style="background: #d62728;"></div>
832
+
<span>External References</span>
835
+
<h3 style="margin-top: 15px;">Interactions</h3>
836
+
<div style="font-size: 11px; color: #666;">
837
+
• Hover: Show details<br>
838
+
• Click: Pin/unpin node<br>
839
+
• Drag: Move nodes<br>
840
+
• Zoom: Mouse wheel
844
+
<div class="tooltip" id="tooltip" style="display: none;"></div>
849
+
let svg, g, link, node;
850
+
let width = window.innerWidth - 40;
851
+
let height = window.innerHeight - 200;
853
+
// Initialize the visualization
854
+
async function init() {
855
+
// Fetch graph data
856
+
const response = await fetch('/api/graph');
857
+
graphData = await response.json();
860
+
svg = d3.select("#graph")
861
+
.attr("width", width)
862
+
.attr("height", height);
864
+
// Add zoom behavior
865
+
const zoom = d3.zoom()
866
+
.scaleExtent([0.1, 4])
867
+
.on("zoom", (event) => {
868
+
g.attr("transform", event.transform);
873
+
// Create main group for all elements
874
+
g = svg.append("g");
880
+
updateVisualization();
885
+
// Handle window resize
886
+
window.addEventListener('resize', () => {
887
+
width = window.innerWidth - 40;
888
+
height = window.innerHeight - 200;
889
+
svg.attr("width", width).attr("height", height);
890
+
simulation.force("center", d3.forceCenter(width / 2, height / 2));
891
+
simulation.restart();
895
+
function setupControls() {
896
+
// Populate user filter
897
+
const userFilter = d3.select("#userFilter");
898
+
graphData.stats.users.forEach(user => {
899
+
userFilter.append("option").attr("value", user).text(user);
902
+
// Add event listeners
903
+
d3.select("#userFilter").on("change", updateVisualization);
904
+
d3.select("#linkFilter").on("change", updateVisualization);
905
+
d3.select("#forceStrength").on("input", updateForces);
906
+
d3.select("#nodeSize").on("input", updateNodeSizes);
909
+
function updateVisualization() {
910
+
// Filter data based on controls
911
+
const userFilter = d3.select("#userFilter").property("value");
912
+
const linkFilter = d3.select("#linkFilter").property("value");
914
+
let filteredNodes = graphData.nodes;
915
+
let filteredLinks = graphData.links;
917
+
if (userFilter !== "all") {
918
+
filteredNodes = graphData.nodes.filter(n => n.username === userFilter);
919
+
const nodeIds = new Set(filteredNodes.map(n => n.id));
920
+
filteredLinks = graphData.links.filter(l =>
921
+
nodeIds.has(l.source.id || l.source) && nodeIds.has(l.target.id || l.target)
925
+
if (linkFilter !== "all") {
926
+
filteredLinks = filteredLinks.filter(l => l.type === linkFilter);
929
+
// Clear existing elements
930
+
g.selectAll(".link").remove();
931
+
g.selectAll(".node").remove();
933
+
// Create force simulation
934
+
simulation = d3.forceSimulation(filteredNodes)
935
+
.force("link", d3.forceLink(filteredLinks).id(d => d.id)
937
+
// Get source and target nodes
938
+
const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source));
939
+
const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target));
941
+
// If nodes are from different users, make them attract more (shorter distance)
942
+
if (sourceNode && targetNode && sourceNode.username !== targetNode.username) {
943
+
return 30; // Shorter distance = stronger attraction
946
+
// Same user posts have normal distance
950
+
// Get source and target nodes
951
+
const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source));
952
+
const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target));
954
+
// If nodes are from different users, make the link stronger
955
+
if (sourceNode && targetNode && sourceNode.username !== targetNode.username) {
956
+
return 1.5; // Stronger link force
959
+
// Same user posts have normal strength
962
+
.force("charge", d3.forceManyBody().strength(-200))
963
+
.force("center", d3.forceCenter(width / 2, height / 2))
964
+
.force("collision", d3.forceCollide().radius(15));
967
+
link = g.append("g")
968
+
.selectAll(".link")
969
+
.data(filteredLinks)
970
+
.enter().append("line")
971
+
.attr("class", d => `link ${d.type}-link`)
972
+
.attr("stroke-width", d => {
973
+
// Get source and target nodes
974
+
const sourceNode = filteredNodes.find(n => n.id === (d.source.id || d.source));
975
+
const targetNode = filteredNodes.find(n => n.id === (d.target.id || d.target));
977
+
// If nodes are from different users, make the line thicker
978
+
if (sourceNode && targetNode && sourceNode.username !== targetNode.username) {
979
+
return 2.5; // Thicker line for cross-user connections
982
+
// Same user posts have normal thickness
987
+
node = g.append("g")
988
+
.selectAll(".node")
989
+
.data(filteredNodes)
990
+
.enter().append("circle")
991
+
.attr("class", "node")
992
+
.attr("r", d => Math.max(4, Math.log(d.outbound_count + d.backlink_count + 1) * 3))
993
+
.attr("fill", d => d.color)
995
+
.on("start", dragstarted)
996
+
.on("drag", dragged)
997
+
.on("end", dragended))
998
+
.on("mouseover", showTooltip)
999
+
.on("mouseout", hideTooltip)
1000
+
.on("click", togglePin);
1002
+
// Update force simulation
1003
+
simulation.on("tick", () => {
1005
+
.attr("x1", d => d.source.x)
1006
+
.attr("y1", d => d.source.y)
1007
+
.attr("x2", d => d.target.x)
1008
+
.attr("y2", d => d.target.y);
1011
+
.attr("cx", d => d.x)
1012
+
.attr("cy", d => d.y);
1015
+
updateStats(filteredNodes, filteredLinks);
1018
+
function updateForces() {
1019
+
const strength = +d3.select("#forceStrength").property("value");
1021
+
simulation.force("charge").strength(-200 * strength);
1022
+
simulation.alpha(0.3).restart();
1026
+
function updateNodeSizes() {
1027
+
const size = +d3.select("#nodeSize").property("value");
1029
+
node.attr("r", d => Math.max(size * 0.5, Math.log(d.outbound_count + d.backlink_count + 1) * size * 0.5));
1033
+
function dragstarted(event, d) {
1034
+
if (!event.active) simulation.alphaTarget(0.3).restart();
1039
+
function dragged(event, d) {
1044
+
function dragended(event, d) {
1045
+
if (!event.active) simulation.alphaTarget(0);
1052
+
function togglePin(event, d) {
1053
+
d.pinned = !d.pinned;
1063
+
function showTooltip(event, d) {
1064
+
const tooltip = d3.select("#tooltip");
1065
+
tooltip.style("display", "block")
1067
+
<strong>${d.title}</strong><br>
1068
+
<strong>User:</strong> ${d.username}<br>
1069
+
<strong>Date:</strong> ${d.date}<br>
1070
+
<strong>Outbound Links:</strong> ${d.outbound_count}<br>
1071
+
<strong>Backlinks:</strong> ${d.backlink_count}<br>
1072
+
<strong>Link Types:</strong> Self: ${d.link_types.self}, User: ${d.link_types.user}, External: ${d.link_types.external}
1073
+
${d.summary ? '<br><br>' + d.summary : ''}
1075
+
.style("left", (event.pageX + 10) + "px")
1076
+
.style("top", (event.pageY - 10) + "px");
1079
+
function hideTooltip() {
1080
+
d3.select("#tooltip").style("display", "none");
1083
+
function updateStats(nodes = graphData.nodes, links = graphData.links) {
1084
+
const stats = d3.select("#stats");
1085
+
const userCounts = {};
1086
+
nodes.forEach(n => {
1087
+
userCounts[n.username] = (userCounts[n.username] || 0) + 1;
1091
+
<div class="stat-item">
1092
+
<strong>${nodes.length}</strong> Nodes
1094
+
<div class="stat-item">
1095
+
<strong>${links.length}</strong> Links
1097
+
<div class="stat-item">
1098
+
<strong>${Object.keys(userCounts).length}</strong> Users
1100
+
<div class="stat-item">
1101
+
Users: ${Object.entries(userCounts).map(([user, count]) => `${user} (${count})`).join(', ')}
1106
+
// Initialize when page loads