clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

588 lines
16 KiB

<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: SVGSVGElement;
let isDarkMode = false;
const nodeRadius = 20;
const linkDistance = 10;
const arrowDistance = 10;
const warmupClickEnergy = 0.9; // Energy to restart simulation on drag
let container: HTMLDivElement;
let width: number = 1000;
let height: number = 600;
let windowHeight: number;
$: graphHeight = windowHeight ? Math.max(windowHeight * 0.2, 400) : 400;
$: if (container) {
width = container.clientWidth || width;
height = container.clientHeight || height;
}
interface NetworkNode extends d3.SimulationNodeDatum {
id: string;
event?: NDKEvent;
index?: number;
isContainer: boolean;
title: string;
content: string;
author: string;
type: "Index" | "Content";
x?: number;
y?: number;
fx?: number | null;
fy?: number | null;
vx?: number;
vy?: number;
}
interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> {
source: NetworkNode;
target: NetworkNode;
isSequential: boolean;
}
function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> {
return new Map(events.map((event) => [event.id, event]));
}
function updateNodeVelocity(
node: NetworkNode,
deltaVx: number,
deltaVy: number,
) {
if (typeof node.vx === "number" && typeof node.vy === "number") {
node.vx = node.vx - deltaVx;
node.vy = node.vy - deltaVy;
}
}
function applyGlobalLogGravity(
node: NetworkNode,
centerX: number,
centerY: number,
alpha: number,
) {
const dx = (node.x ?? 0) - centerX;
const dy = (node.y ?? 0) - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = Math.log(distance + 1) * 0.05 * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
function applyConnectedGravity(
node: NetworkNode,
links: NetworkLink[],
alpha: number,
) {
const connectedNodes = links
.filter(
(link) => link.source.id === node.id || link.target.id === node.id,
)
.map((link) => (link.source.id === node.id ? link.target : link.source));
if (connectedNodes.length === 0) return;
const cogX = d3.mean(connectedNodes, (n) => n.x);
const cogY = d3.mean(connectedNodes, (n) => n.y);
if (cogX === undefined || cogY === undefined) return;
const dx = (node.x ?? 0) - cogX;
const dy = (node.y ?? 0) - cogY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const force = distance * 0.3 * alpha;
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force);
}
function getNode(
id: string,
nodeMap: Map<string, NetworkNode>,
event?: NDKEvent,
index?: number,
): NetworkNode | null {
if (!id) return null;
if (!nodeMap.has(id)) {
const node: NetworkNode = {
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",
x: width / 2 + (Math.random() - 0.5) * 100,
y: height / 2 + (Math.random() - 0.5) * 100,
};
nodeMap.set(id, node);
}
return nodeMap.get(id) || null;
}
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[]): {
nodes: NetworkNode[];
links: NetworkLink[];
} {
const nodes: NetworkNode[] = [];
const links: NetworkLink[] = [];
const nodeMap = new Map<string, NetworkNode>();
// Create event lookup map - O(n) operation done once
const eventMap = createEventMap(events);
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, nodeMap, index);
if (!sourceNode) return;
nodes.push(sourceNode);
contentRefs.forEach((tag, idx) => {
if (!tag[1]) return;
// O(1) lookup instead of O(n) search
const targetEvent = eventMap.get(tag[1]);
if (!targetEvent) return;
const targetNode = getNode(tag[1], nodeMap, targetEvent, idx);
if (!targetNode) return;
nodes.push(targetNode);
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 setupDragHandlers(
simulation: d3.Simulation<NetworkNode, NetworkLink>,
) {
// Create drag behavior with proper typing
const dragBehavior = d3
.drag<SVGGElement, NetworkNode>()
.on(
"start",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Warm up simulation when drag starts
if (!event.active)
simulation.alphaTarget(warmupClickEnergy).restart();
// Fix node position during drag
d.fx = d.x;
d.fy = d.y;
},
)
.on(
"drag",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Update fixed position to drag position
d.fx = event.x;
d.fy = event.y;
},
)
.on(
"end",
(
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>,
d: NetworkNode,
) => {
// Cool down simulation when drag ends
if (!event.active) simulation.alphaTarget(0);
// Release fixed position, allowing forces to take over
d.fx = null;
d.fy = null;
},
);
return dragBehavior;
}
function drawNetwork() {
if (!svg || !events?.length) return;
const { nodes, links } = generateGraph(events);
if (!nodes.length) return;
const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`);
// Set up zoom behavior
let g = svgElement.append("g");
const zoom = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 9])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svgElement.call(zoom);
if (g.empty()) {
g = svgElement.append("g");
// Define arrow marker with black fill
}
svgElement.select("defs").remove();
const defs = svgElement.append("defs");
defs
.append("marker")
.attr("id", "arrowhead")
.attr("markerUnits", "strokeWidth") // Added this
.attr("viewBox", "-10 -5 10 10")
.attr("refX", 0)
.attr("refY", 0)
.attr("markerWidth", 5)
.attr("markerHeight", 5)
.attr("orient", "auto")
.append("path")
.attr("d", "M -10 -5 L 0 0 L -10 5 z")
.attr("class", "network-link-leather")
.attr("fill", "none")
.attr("stroke-width", 1); // Added stroke
// Force simulation setup
const simulation = d3
.forceSimulation<NetworkNode>(nodes)
.force(
"link",
d3
.forceLink<NetworkNode, NetworkLink>(links)
.id((d) => d.id)
.distance(linkDistance * 0.1),
)
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4));
simulation.on("end", () => {
// Get the bounds of the graph
const bounds = g.node()?.getBBox();
if (bounds) {
const dx = bounds.width;
const dy = bounds.height;
const x = bounds.x;
const y = bounds.y;
// Calculate scale to fit
const scale = 1.25 / Math.max(dx / width, dy / height);
const translate = [
width / 2 - scale * (x + dx / 2),
height / 2 - scale * (y + dy / 2),
];
// Apply the initial transform
svgElement
.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale),
);
}
});
const dragHandler = setupDragHandlers(simulation);
// Create links
// First, make sure we're selecting and creating links correctly
const link = g
.selectAll("path") // Changed from "path.link" to just "path"
.data(links)
.join(
(enter) =>
enter
.append("path")
.attr("stroke-width", 2)
.attr("marker-end", "url(#arrowhead)") // This should now be applied
.attr("class", "network-link-leather"), // Add class if needed
(update) => update,
(exit) => exit.remove(),
);
// Create nodes
const node = g
.selectAll<SVGGElement, NetworkNode>("g.node")
.data(nodes, (d: NetworkNode) => d.id)
.join(
(enter) => {
const nodeEnter = enter
.append("g")
.attr("class", "node network-node-leather")
.call(dragHandler);
// add drag circle
nodeEnter
.append("circle")
.attr("r", nodeRadius * 2.5)
.attr("fill", "transparent")
.attr("stroke", "transparent")
.style("cursor", "move");
// add visual circle, stroke based on current theme
nodeEnter
.append("circle")
.attr("r", nodeRadius)
.attr("class", (d: NetworkNode) =>
!d.isContainer
? "network-node-leather network-node-content"
: "network-node-leather",
)
.attr("stroke-width", 2);
// add text labels
nodeEnter
.append("text")
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-size", "12px");
// .attr("font-weight", "bold");
return nodeEnter;
},
(update) => update,
(exit) => exit.remove(),
);
// Add text labels
node
.select("circle:nth-child(2)")
.attr("fill", (d: NetworkNode) =>
!d.isContainer
? isDarkMode
? "#FFFFFF"
: "network-link-leather"
: getEventColor(d.id),
);
node.select("text").text((d: NetworkNode) => (d.isContainer ? "I" : "C"));
// Add tooltips
const tooltip = d3
.select("body")
.append("div")
.attr(
"class",
"tooltip-leather fixed hidden p-4 rounded shadow-lg " +
"bg-primary-0 dark:bg-primary-800 " +
"border border-gray-200 dark:border-gray-800 " +
"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", () => {
nodes.forEach((node) => {
applyGlobalLogGravity(node, width / 2, height / 2, simulation.alpha());
applyConnectedGravity(node, links, simulation.alpha());
});
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);
const sourceGap = nodeRadius;
const targetGap = nodeRadius + arrowDistance; // Increased gap for arrowhead
const startX = d.source.x! + sourceGap * Math.cos(angle);
const startY = d.source.y! + sourceGap * Math.sin(angle);
const endX = d.target.x! - targetGap * Math.cos(angle);
const endY = d.target.y! - targetGap * Math.sin(angle);
return `M${startX},${startY}L${endX},${endY}`;
});
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
});
}
onMount(() => {
isDarkMode = document.body.classList.contains("dark");
// Add window resize listener
const handleResize = () => {
windowHeight = window.innerHeight;
};
// Initial resize
windowHeight = window.innerHeight;
window.addEventListener("resize", handleResize);
// Watch for theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
const newIsDarkMode = document.body.classList.contains("dark");
if (newIsDarkMode !== isDarkMode) {
isDarkMode = newIsDarkMode;
// drawNetwork();
}
}
});
});
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
width = entry.contentRect.width;
height = graphHeight;
}
// first remove all nodes and links
d3.select(svg).selectAll("*").remove();
drawNetwork();
});
// Start observers
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
resizeObserver.observe(container);
// Clean up
return () => {
themeObserver.disconnect();
resizeObserver.disconnect();
};
});
// Reactive redaw
$: {
if (svg && events?.length) {
drawNetwork();
}
}
</script>
<div
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4"
>
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}>
<svg
bind:this={svg}
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded"
/>
</div>
<!-- Legend -->
<div class="leather-legend">
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3>
<ul class="legend-list">
<li class="legend-item">
<div class="legend-icon">
<span
class="legend-circle"
style="background-color: hsl(200, 70%, 75%)"
>
</span>
<span class="legend-letter">I</span>
</div>
<span>Index events (kind 30040) - Each with a unique pastel color</span>
</li>
<li class="legend-item">
<div class="legend-icon">
<span class="legend-circle content"></span>
<span class="legend-letter">C</span>
</div>
<span>Content events (kind 30041) - Publication sections</span>
</li>
<li class="legend-item">
<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" />
</svg>
<span>Arrows indicate reading/sequence order</span>
</li>
</ul>
</div>
</div>
<style>
.legend-list {
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300;
}
.legend-item {
@apply flex items-center;
}
.legend-icon {
@apply relative w-6 h-6 mr-2;
}
.legend-circle {
@apply absolute inset-0 rounded-full border-2 border-black;
}
.legend-circle.content {
@apply bg-gray-700 dark:bg-gray-300;
background-color: #d6c1a8;
}
.legend-letter {
@apply absolute inset-0 flex items-center justify-center text-black text-xs;
}
</style>