3 changed files with 485 additions and 9 deletions
@ -0,0 +1,395 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import * as d3 from "d3"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
export let events: NDKEvent[] = []; |
||||||
|
|
||||||
|
let svg; |
||||||
|
let isDarkMode = false; |
||||||
|
const width = 1200; |
||||||
|
const height = 600; |
||||||
|
const nodeRadius = 20; |
||||||
|
const dragRadius = 45; |
||||||
|
const linkDistance = 120; |
||||||
|
|
||||||
|
function updateTheme() { |
||||||
|
isDarkMode = !isDarkMode; |
||||||
|
document.body.classList.toggle("dark"); |
||||||
|
if (svg) drawNetwork(); |
||||||
|
} |
||||||
|
|
||||||
|
function getEventColor(eventId: string): string { |
||||||
|
const num = parseInt(eventId.slice(0, 4), 16); |
||||||
|
const hue = num % 360; |
||||||
|
const saturation = 70; |
||||||
|
const lightness = 75; |
||||||
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; |
||||||
|
} |
||||||
|
|
||||||
|
function generateGraph(events: NDKEvent[]) { |
||||||
|
const nodes = []; |
||||||
|
const links = []; |
||||||
|
const nodeMap = new Map(); |
||||||
|
|
||||||
|
function getNode(id: string, event?: NDKEvent, index?: number) { |
||||||
|
if (!id) return null; |
||||||
|
|
||||||
|
if (!nodeMap.has(id)) { |
||||||
|
const node = { |
||||||
|
id, |
||||||
|
event, |
||||||
|
index, |
||||||
|
isContainer: event?.kind === 30040, |
||||||
|
title: event?.getMatchingTags("title")?.[0]?.[1] || "Untitled", |
||||||
|
content: event?.content || "", |
||||||
|
author: event?.pubkey, |
||||||
|
type: event?.kind === 30040 ? "Index" : "Content", |
||||||
|
}; |
||||||
|
nodes.push(node); |
||||||
|
nodeMap.set(id, node); |
||||||
|
} |
||||||
|
return nodeMap.get(id); |
||||||
|
} |
||||||
|
|
||||||
|
// Process index events first |
||||||
|
const indexEvents = events.filter((e) => e.kind === 30040); |
||||||
|
|
||||||
|
indexEvents.forEach((index) => { |
||||||
|
if (!index.id) return; |
||||||
|
|
||||||
|
const contentRefs = index.getMatchingTags("e"); |
||||||
|
const sourceNode = getNode(index.id, index); |
||||||
|
if (!sourceNode) return; |
||||||
|
|
||||||
|
// Create a linear chain of content events |
||||||
|
contentRefs.forEach((tag, idx) => { |
||||||
|
if (!tag[1]) return; |
||||||
|
|
||||||
|
const targetEvent = events.find((e) => e.id === tag[1]); |
||||||
|
if (!targetEvent) return; |
||||||
|
|
||||||
|
const targetNode = getNode(tag[1], targetEvent, idx); |
||||||
|
if (!targetNode) return; |
||||||
|
|
||||||
|
const prevNodeId = |
||||||
|
idx === 0 ? sourceNode.id : contentRefs[idx - 1]?.[1]; |
||||||
|
const prevNode = nodeMap.get(prevNodeId); |
||||||
|
|
||||||
|
if (prevNode && targetNode) { |
||||||
|
links.push({ |
||||||
|
source: prevNode, |
||||||
|
target: targetNode, |
||||||
|
isSequential: true, |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
return { nodes, links }; |
||||||
|
} |
||||||
|
|
||||||
|
function drawNetwork() { |
||||||
|
if (!svg || !events?.length) return; |
||||||
|
|
||||||
|
d3.select(svg).selectAll("*").remove(); |
||||||
|
|
||||||
|
const { nodes, links } = generateGraph(events); |
||||||
|
if (!nodes.length) return; |
||||||
|
|
||||||
|
const svgElement = d3 |
||||||
|
.select(svg) |
||||||
|
.attr( |
||||||
|
"class", |
||||||
|
"network-leather w-full border border-gray-300 dark:border-gray-700 rounded", |
||||||
|
) |
||||||
|
.attr("viewBox", [0, 0, width, height]); |
||||||
|
|
||||||
|
// Set up zoom behavior |
||||||
|
const zoom = d3 |
||||||
|
.zoom() |
||||||
|
.scaleExtent([0.1, 4]) |
||||||
|
.on("zoom", (event) => { |
||||||
|
g.attr("transform", event.transform); |
||||||
|
}); |
||||||
|
|
||||||
|
svgElement.call(zoom); |
||||||
|
|
||||||
|
const g = svgElement.append("g"); |
||||||
|
|
||||||
|
// Force simulation setup |
||||||
|
const simulation = d3 |
||||||
|
.forceSimulation(nodes) |
||||||
|
.force( |
||||||
|
"link", |
||||||
|
d3 |
||||||
|
.forceLink(links) |
||||||
|
.id((d) => d.id) |
||||||
|
.distance(linkDistance), |
||||||
|
) |
||||||
|
.force("charge", d3.forceManyBody().strength(-500)) |
||||||
|
.force("center", d3.forceCenter(width / 2, height / 2)) |
||||||
|
.force("x", d3.forceX(width / 2).strength(0.1)) |
||||||
|
.force("y", d3.forceY(height / 2).strength(0.1)) |
||||||
|
.force("collision", d3.forceCollide().radius(nodeRadius * 2.5)); |
||||||
|
|
||||||
|
// Define arrow marker with black fill |
||||||
|
const marker = g |
||||||
|
.append("defs") |
||||||
|
.selectAll("marker") |
||||||
|
.data(["arrowhead"]) |
||||||
|
.join("marker") |
||||||
|
.attr("id", "arrowhead") |
||||||
|
.attr("viewBox", "0 -5 20 20") |
||||||
|
.attr("refX", nodeRadius + 10) |
||||||
|
.attr("refY", 0) |
||||||
|
.attr("markerWidth", 8) |
||||||
|
.attr("markerHeight", 8) |
||||||
|
.attr("orient", "auto"); |
||||||
|
|
||||||
|
marker |
||||||
|
.append("path") |
||||||
|
.attr("d", "M -8,-5 L 0, 0 L -8, 5 Z") |
||||||
|
.attr("class", "network-link-leather"); // Black fill for arrowhead |
||||||
|
|
||||||
|
// Create links |
||||||
|
const link = g |
||||||
|
.selectAll("path") |
||||||
|
.data(links) |
||||||
|
.join("path") |
||||||
|
.attr("class", "network-link-leather") |
||||||
|
.attr("stroke-width", 2) |
||||||
|
.attr("fill", "none") |
||||||
|
.attr("marker-end", "url(#arrow)") |
||||||
|
.attr("fill", "none"); |
||||||
|
|
||||||
|
// Create nodes |
||||||
|
const node = g |
||||||
|
.selectAll("g") |
||||||
|
.data(nodes) |
||||||
|
.join("g") |
||||||
|
.attr("class", "node network-node-leather") |
||||||
|
.call( |
||||||
|
d3 |
||||||
|
.drag() |
||||||
|
.on("start", dragstarted) |
||||||
|
.on("drag", dragged) |
||||||
|
.on("end", dragended), |
||||||
|
); |
||||||
|
|
||||||
|
// Add invisible larger circle for better drag handling |
||||||
|
node |
||||||
|
.append("circle") |
||||||
|
.attr("r", dragRadius) |
||||||
|
.attr("fill", "transparent") |
||||||
|
.style("cursor", "move"); |
||||||
|
|
||||||
|
// Add visible node circle |
||||||
|
node |
||||||
|
.append("circle") |
||||||
|
.attr("r", nodeRadius) |
||||||
|
.attr("fill", (d) => { |
||||||
|
if (!d.isContainer) { |
||||||
|
return isDarkMode ? "#342718" : "#d6c1a8"; // primary-800 : primary-100 |
||||||
|
} |
||||||
|
return getEventColor(d.id); |
||||||
|
}) |
||||||
|
.attr("stroke", "#000000") // Black outline for all nodes |
||||||
|
.attr("stroke-width", 2); |
||||||
|
|
||||||
|
// Add text labels |
||||||
|
node |
||||||
|
.append("text") |
||||||
|
.attr("dy", "0.35em") |
||||||
|
.attr("text-anchor", "middle") |
||||||
|
.attr("fill", "#000000") // Always black |
||||||
|
.attr("font-size", "12px") |
||||||
|
.attr("font-weight", "bold") // Making it bold for better contrast |
||||||
|
.text((d) => (d.isContainer ? "I" : "C")); |
||||||
|
|
||||||
|
// Add tooltips |
||||||
|
const tooltip = d3 |
||||||
|
.select("body") |
||||||
|
.append("div") |
||||||
|
.attr( |
||||||
|
"class", |
||||||
|
"fixed hidden bg-primary-0 dark:bg-primary-1000 " + |
||||||
|
"text-gray-800 dark:text-gray-300 " + |
||||||
|
"p-4 rounded shadow-lg border border-gray-200 dark:border-gray-800 " + |
||||||
|
"transition-colors duration-200", |
||||||
|
) |
||||||
|
.style("z-index", 1000); |
||||||
|
|
||||||
|
node |
||||||
|
.on("mouseover", function (event, d) { |
||||||
|
tooltip |
||||||
|
.style("display", "block") |
||||||
|
.html( |
||||||
|
` |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="font-bold text-base">${d.title}</div> |
||||||
|
<div class="text-gray-600 dark:text-gray-400 text-sm"> |
||||||
|
${d.type} (${d.isContainer ? "30040" : "30041"}) |
||||||
|
</div> |
||||||
|
<div class="text-gray-600 dark:text-gray-400 text-sm overflow-hidden text-ellipsis"> |
||||||
|
ID: ${d.id} |
||||||
|
</div> |
||||||
|
${ |
||||||
|
d.content |
||||||
|
? ` |
||||||
|
<div class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40"> |
||||||
|
${d.content} |
||||||
|
</div> |
||||||
|
` |
||||||
|
: "" |
||||||
|
} |
||||||
|
</div> |
||||||
|
`, |
||||||
|
) |
||||||
|
.style("left", event.pageX + 10 + "px") |
||||||
|
.style("top", event.pageY - 10 + "px"); |
||||||
|
}) |
||||||
|
.on("mousemove", function (event) { |
||||||
|
tooltip |
||||||
|
.style("left", event.pageX + 10 + "px") |
||||||
|
.style("top", event.pageY - 10 + "px"); |
||||||
|
}) |
||||||
|
.on("mouseout", () => { |
||||||
|
tooltip.style("display", "none"); |
||||||
|
}); |
||||||
|
|
||||||
|
// Handle simulation ticks |
||||||
|
simulation.on("tick", () => { |
||||||
|
link.attr("d", (d) => { |
||||||
|
const dx = d.target.x - d.source.x; |
||||||
|
const dy = d.target.y - d.source.y; |
||||||
|
const angle = Math.atan2(dy, dx); |
||||||
|
|
||||||
|
// Adjust start and end points to prevent overlap with nodes |
||||||
|
const startX = d.source.x + nodeRadius * Math.cos(angle); |
||||||
|
const startY = d.source.y + nodeRadius * Math.sin(angle); |
||||||
|
const endX = d.target.x - nodeRadius * Math.cos(angle); |
||||||
|
const endY = d.target.y - nodeRadius * Math.sin(angle); |
||||||
|
|
||||||
|
return `M${startX},${startY}L${endX},${endY}`; |
||||||
|
}); |
||||||
|
node.attr("transform", (d) => `translate(${d.x},${d.y})`); |
||||||
|
}); |
||||||
|
|
||||||
|
// Drag handlers |
||||||
|
function dragstarted(event, d) { |
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart(); |
||||||
|
d.fx = d.x; |
||||||
|
d.fy = d.y; |
||||||
|
} |
||||||
|
|
||||||
|
function dragged(event, d) { |
||||||
|
d.fx = event.x; |
||||||
|
d.fy = event.y; |
||||||
|
} |
||||||
|
|
||||||
|
function dragended(event, d) { |
||||||
|
if (!event.active) simulation.alphaTarget(0); |
||||||
|
d.fx = null; |
||||||
|
d.fy = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$: { |
||||||
|
if (svg && events?.length) { |
||||||
|
drawNetwork(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
isDarkMode = document.body.classList.contains("dark"); |
||||||
|
|
||||||
|
// Watch for theme changes |
||||||
|
const observer = new MutationObserver((mutations) => { |
||||||
|
mutations.forEach((mutation) => { |
||||||
|
if (mutation.attributeName === "class") { |
||||||
|
const newIsDarkMode = document.body.classList.contains("dark"); |
||||||
|
if (newIsDarkMode !== isDarkMode) { |
||||||
|
isDarkMode = newIsDarkMode; |
||||||
|
drawNetwork(); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
observer.observe(document.documentElement, { |
||||||
|
attributes: true, |
||||||
|
attributeFilter: ["class"], |
||||||
|
}); |
||||||
|
|
||||||
|
return () => observer.disconnect(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
# /lib/components/EventNetwork.svelte |
||||||
|
<div class="w-full"> |
||||||
|
<svg |
||||||
|
bind:this={svg} |
||||||
|
class="w-full border border-gray-300 dark:border-gray-700 rounded" |
||||||
|
{width} |
||||||
|
{height} |
||||||
|
/> |
||||||
|
<div |
||||||
|
class="mt-4 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow border border-gray-200 dark:border-gray-800" |
||||||
|
> |
||||||
|
<h3 class="text-lg font-bold mb-2 text-gray-800 dark:text-gray-300"> |
||||||
|
Legend |
||||||
|
</h3> |
||||||
|
<ul class="list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300"> |
||||||
|
<li class="flex items-center"> |
||||||
|
<div class="relative w-6 h-6 mr-2"> |
||||||
|
<!-- Increased size to match network --> |
||||||
|
<span |
||||||
|
class="absolute inset-0 rounded-full border-2 border-black" |
||||||
|
style="background-color: hsl(200, 70%, 75%)" |
||||||
|
/> |
||||||
|
<span |
||||||
|
class="absolute inset-0 flex items-center justify-center text-black" |
||||||
|
style="font-size: 12px;">I</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<span>Index events (kind 30040) - Each with a unique pastel color</span> |
||||||
|
</li> |
||||||
|
<li class="flex items-center"> |
||||||
|
<div class="relative w-6 h-6 mr-2"> |
||||||
|
<span |
||||||
|
class="absolute inset-0 rounded-full border-2 border-black bg-gray-700 dark:bg-gray-300" |
||||||
|
style="background-color: #d6c1a8" |
||||||
|
/> |
||||||
|
<span |
||||||
|
class="absolute inset-0 flex items-center justify-center text-black" |
||||||
|
style="font-size: 12px; ">C</span |
||||||
|
> |
||||||
|
</div> |
||||||
|
<span>Content events (kind 30041) - Publication sections</span> |
||||||
|
</li> |
||||||
|
<li class="flex items-center"> |
||||||
|
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> |
||||||
|
<path |
||||||
|
d="M4 12h16M16 6l6 6-6 6" |
||||||
|
class="network-link-leather" |
||||||
|
stroke-width="2" |
||||||
|
stroke-linecap="round" |
||||||
|
marker-end="url(#arrowhead)" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
<span>Arrows indicate reading/sequence order</span> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
:global(.network-link-leather) { |
||||||
|
@apply stroke-gray-400 dark:stroke-gray-600 fill-gray-400 dark:fill-gray-600; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.text-leather) { |
||||||
|
@apply fill-gray-800 dark:fill-gray-300; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -1,12 +1,92 @@ |
|||||||
<script lang='ts'> |
<script lang="ts"> |
||||||
import { Heading } from "flowbite-svelte"; |
import { onMount } from "svelte"; |
||||||
|
import EventNetwork from "$lib/components/EventNetwork.svelte"; |
||||||
|
import { ndk } from "$lib/ndk"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { filterValidIndexEvents } from "$lib/utils"; |
||||||
|
|
||||||
|
let events: NDKEvent[] = []; |
||||||
|
let loading = true; |
||||||
|
let error: string | null = null; |
||||||
|
|
||||||
|
async function fetchEvents() { |
||||||
|
try { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
// Fetch both index and content events |
||||||
|
const indexEvents = await $ndk.fetchEvents( |
||||||
|
{ kinds: [30040] }, |
||||||
|
{ |
||||||
|
groupable: true, |
||||||
|
skipVerification: false, |
||||||
|
skipValidation: false, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
// Filter valid index events according to NIP-62 |
||||||
|
const validIndexEvents = filterValidIndexEvents(indexEvents); |
||||||
|
|
||||||
|
// Get all the content event IDs referenced by the index events |
||||||
|
const contentEventIds = new Set<string>(); |
||||||
|
validIndexEvents.forEach((event) => { |
||||||
|
event.getMatchingTags("e").forEach((tag) => { |
||||||
|
contentEventIds.add(tag[1]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Fetch the referenced content events |
||||||
|
const contentEvents = await $ndk.fetchEvents( |
||||||
|
{ |
||||||
|
kinds: [30041], |
||||||
|
ids: Array.from(contentEventIds), |
||||||
|
}, |
||||||
|
{ |
||||||
|
groupable: true, |
||||||
|
skipVerification: false, |
||||||
|
skipValidation: false, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
// Combine both sets of events |
||||||
|
events = [...Array.from(validIndexEvents), ...Array.from(contentEvents)]; |
||||||
|
} catch (e) { |
||||||
|
console.error("Error fetching events:", e); |
||||||
|
error = e.message; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
fetchEvents(); |
||||||
|
}); |
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class='w-full flex justify-center'> |
<div class="leather w-full p-4"> |
||||||
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> |
<h1 class="h-leather text-2xl font-bold mb-4">Publication Network</h1> |
||||||
<Heading tag='h1' class='h-leather mb-2'>Visualize</Heading> |
|
||||||
<p>Coming soon.</p> |
{#if loading} |
||||||
</main> |
<div class="flex justify-center items-center h-64"> |
||||||
</div> |
<p class="text-gray-600 dark:text-gray-400">Loading network data...</p> |
||||||
|
</div> |
||||||
|
{:else if error} |
||||||
|
<div |
||||||
|
class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 |
||||||
|
text-red-700 dark:text-red-300 px-4 py-3 rounded" |
||||||
|
> |
||||||
|
<p>Error loading network: {error}</p> |
||||||
|
<button class="btn-leather mt-2 px-4 py-2 rounded" on:click={fetchEvents}> |
||||||
|
Retry |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<EventNetwork {events} /> |
||||||
|
|
||||||
|
<div class="mt-8 prose dark:prose-invert max-w-none"> |
||||||
|
<!-- Legend section with proper styling --> |
||||||
|
<h2 class="h-leather">About This Visualization</h2> |
||||||
|
<!-- ... rest of the content ... --> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|||||||
Loading…
Reference in new issue