Browse Source

Merge pull request #1 from limina1/master

Add d3, visualize, EventNetwork.svelte
master
limina1 1 year ago committed by GitHub
parent
commit
104a259011
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      package.json
  2. 29
      src/app.css
  3. 55
      src/lib/components/Article.svelte
  4. 588
      src/lib/components/EventNetwork.svelte
  5. 10
      src/routes/+layout.ts
  6. 12
      src/routes/publication/+page.svelte
  7. 112
      src/routes/visualize/+page.svelte
  8. 20
      svelte.config.js

1
package.json

@ -19,6 +19,7 @@
"@tailwindcss/forms": "0.5.x", "@tailwindcss/forms": "0.5.x",
"@tailwindcss/typography": "0.5.x", "@tailwindcss/typography": "0.5.x",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"d3": "^7.9.0",
"he": "1.2.x", "he": "1.2.x",
"nostr-tools": "2.10.x" "nostr-tools": "2.10.x"
}, },

29
src/app.css

@ -8,15 +8,6 @@
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
} }
/* Button */
button.btn-leather.text-white {
@apply text-primary-0;
}
.btn-leather span svg {
@apply fill-gray-800 hover:fill-primary-400 dark:fill-gray-300 dark:hover:fill-primary-500;
}
.btn-leather.text-xs { .btn-leather.text-xs {
@apply w-7 h-7; @apply w-7 h-7;
} }
@ -62,7 +53,7 @@
div.note-leather:hover:not(:has(.note-leather:hover)), div.note-leather:hover:not(:has(.note-leather:hover)),
p.note-leather:hover:not(:has(.note-leather:hover)), p.note-leather:hover:not(:has(.note-leather:hover)),
section.note-leather:hover:not(:has(.note-leather:hover)) { section.note-leather:hover:not(:has(.note-leather:hover)) {
@apply hover:bg-primary-100 dark:hover:bg-primary-800 ; @apply hover:bg-primary-100 dark:hover:bg-primary-800;
} }
/* Heading */ /* Heading */
@ -147,4 +138,22 @@
.ul-leather li a { .ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
.network-link-leather {
@apply stroke-gray-400 fill-gray-400;
}
.network-node-leather {
@apply stroke-gray-800;
}
.network-node-content {
@apply fill-[#d6c1a8];
}
}
@layer components {
.leather-legend {
@apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow
border border-gray-200 dark:border-gray-800;
}
.tooltip-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
}
} }

55
src/lib/components/Article.svelte

@ -1,15 +1,24 @@
<script lang='ts'> <script lang="ts">
import { Button, Sidebar, SidebarGroup, SidebarItem, SidebarWrapper, Skeleton, TextPlaceholder, Tooltip } from 'flowbite-svelte'; import {
import { onMount } from 'svelte'; Button,
import { BookOutline } from 'flowbite-svelte-icons'; Sidebar,
import Preview from './Preview.svelte'; SidebarGroup,
import { pharosInstance } from '$lib/parser'; SidebarItem,
import { page } from '$app/state'; SidebarWrapper,
Skeleton,
TextPlaceholder,
Tooltip,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { BookOutline } from "flowbite-svelte-icons";
import Preview from "./Preview.svelte";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/state";
let { rootId }: { rootId: string } = $props(); let { rootId }: { rootId: string } = $props();
if (rootId !== $pharosInstance.getRootIndexId()) { if (rootId !== $pharosInstance.getRootIndexId()) {
console.error('Root ID does not match parser root index ID'); console.error("Root ID does not match parser root index ID");
} }
let activeHash = $state(page.url.hash); let activeHash = $state(page.url.hash);
@ -17,8 +26,8 @@
function normalizeHashPath(str: string): string { function normalizeHashPath(str: string): string {
return str return str
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, "-")
.replace(/[^\w-]/g, ''); .replace(/[^\w-]/g, "");
} }
function scrollToElementWithOffset() { function scrollToElementWithOffset() {
@ -32,7 +41,7 @@
window.scrollTo({ window.scrollTo({
top: offsetPosition, top: offsetPosition,
behavior: 'auto', behavior: "auto",
}); });
} }
} }
@ -57,7 +66,7 @@
const hideTocOnClick = (ev: MouseEvent) => { const hideTocOnClick = (ev: MouseEvent) => {
const target = ev.target as HTMLElement; const target = ev.target as HTMLElement;
if (target.closest('.sidebar-leather') || target.closest('.btn-leather')) { if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return; return;
} }
@ -70,35 +79,33 @@
// Always check whether the TOC sidebar should be visible. // Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize(); setTocVisibilityOnResize();
window.addEventListener('hashchange', scrollToElementWithOffset); window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL // Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset(); scrollToElementWithOffset();
window.addEventListener('resize', setTocVisibilityOnResize); window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener('click', hideTocOnClick); window.addEventListener("click", hideTocOnClick);
return () => { return () => {
window.removeEventListener('hashchange', scrollToElementWithOffset); window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener('resize', setTocVisibilityOnResize); window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener('click', hideTocOnClick); window.removeEventListener("click", hideTocOnClick);
}; };
}); });
</script> </script>
{#if showTocButton && !showToc} {#if showTocButton && !showToc}
<Button <Button
class='btn-leather fixed top-20 left-4 h-6 w-6' class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true} outline={true}
on:click={ev => { on:click={(ev) => {
showToc = true; showToc = true;
ev.stopPropagation(); ev.stopPropagation();
}} }}
> >
<BookOutline /> <BookOutline />
</Button> </Button>
<Tooltip> <Tooltip>Show Table of Contents</Tooltip>
Show Table of Contents
</Tooltip>
{/if} {/if}
<!-- TODO: Get TOC from parser. --> <!-- TODO: Get TOC from parser. -->
<!-- {#if showToc} <!-- {#if showToc}
@ -116,7 +123,7 @@
</SidebarWrapper> </SidebarWrapper>
</Sidebar> </Sidebar>
{/if} --> {/if} -->
<div class='flex flex-col space-y-4 max-w-2xl'> <div class="flex flex-col space-y-4 max-w-2xl">
<Preview {rootId} /> <Preview {rootId} />
</div> </div>

588
src/lib/components/EventNetwork.svelte

@ -0,0 +1,588 @@
<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>

10
src/routes/+layout.ts

@ -1,7 +1,7 @@
import NDK from '@nostr-dev-kit/ndk'; import NDK from "@nostr-dev-kit/ndk";
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from "./$types";
import { standardRelays } from '$lib/consts'; import { standardRelays } from "$lib/consts";
import Pharos, { pharosInstance } from '$lib/parser'; import Pharos, { pharosInstance } from "$lib/parser";
export const ssr = false; export const ssr = false;
@ -11,7 +11,7 @@ export const load: LayoutLoad = () => {
enableOutboxModel: true, enableOutboxModel: true,
explicitRelayUrls: standardRelays, explicitRelayUrls: standardRelays,
}); });
ndk.connect().then(() => console.debug('ndk connected')); ndk.connect().then(() => console.debug("ndk connected"));
const parser = new Pharos(ndk); const parser = new Pharos(ndk);
pharosInstance.set(parser); pharosInstance.set(parser);

12
src/routes/publication/+page.svelte

@ -1,8 +1,8 @@
<script lang='ts'> <script lang="ts">
import Article from '$lib/components/Article.svelte'; import Article from "$lib/components/Article.svelte";
import { TextPlaceholder } from 'flowbite-svelte'; import { TextPlaceholder } from "flowbite-svelte";
import type { PageData } from './$types'; import type { PageData } from "./$types";
import { onDestroy } from 'svelte'; import { onDestroy } from "svelte";
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -11,7 +11,7 @@
<main> <main>
{#await data.waitable} {#await data.waitable}
<TextPlaceholder size='xxl' /> <TextPlaceholder size="xxl" />
{:then} {:then}
<Article rootId={data.parser.getRootIndexId()} /> <Article rootId={data.parser.getRootIndexId()} />
{/await} {/await}

112
src/routes/visualize/+page.svelte

@ -1,12 +1,110 @@
<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 role="status">
<svg
aria-hidden="true"
class="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
{:else if error}
<div
class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400"
role="alert"
>
<p>Error loading network: {error}</p>
<button
type="button"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mt-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800"
on:click={fetchEvents}
>
Retry
</button>
</div>
{:else}
<EventNetwork {events} />
<div class="mt-8 prose dark:prose-invert max-w-none"></div>
{/if}
</div> </div>

20
svelte.config.js

@ -1,5 +1,5 @@
import adapter from '@sveltejs/adapter-static'; import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@ -10,18 +10,18 @@ const config = {
kit: { kit: {
// Static adapter // Static adapter
adapter: adapter({ adapter: adapter({
pages: 'build', pages: "build",
assets: 'build', assets: "build",
fallback: 'index.html', fallback: "index.html",
precompress: false, precompress: false,
strict: true, strict: true,
}), }),
alias: { alias: {
$lib: 'src/lib', $lib: "src/lib",
$components: 'src/lib/components', $components: "src/lib/components",
$cards: 'src/lib/cards' $cards: "src/lib/cards",
} },
} },
}; };
export default config; export default config;

Loading…
Cancel
Save