From 9e35d582050edc076ba8c7f91337de84e38b02eb Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 9 Apr 2025 09:08:15 -0500 Subject: [PATCH 01/22] Update heading snippet for commonality with Preview component --- src/lib/snippets/PublicationSnippets.svelte | 31 ++++----------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/lib/snippets/PublicationSnippets.svelte b/src/lib/snippets/PublicationSnippets.svelte index 26645fa..8f9e0cc 100644 --- a/src/lib/snippets/PublicationSnippets.svelte +++ b/src/lib/snippets/PublicationSnippets.svelte @@ -5,31 +5,12 @@ {#snippet sectionHeading(title: string, depth: number)} - {#if depth === 0} -

- {title} -

- {:else if depth === 1} -

- {title} -

- {:else if depth === 2} -

- {title} -

- {:else if depth === 3} -

- {title} -

- {:else if depth === 4} -
- {title} -
- {:else} -
- {title} -
- {/if} + {@const headingLevel = Math.min(depth + 1, 6)} + + + + {title} + {/snippet} {#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} From 5486c7368576acfb57d6c45b1b27ee8c4c8aff6a Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 9 Apr 2025 09:16:41 -0500 Subject: [PATCH 02/22] Instanatiate PublicationTree and make it available to components --- src/app.d.ts | 5 +++++ src/routes/publication/+page.svelte | 7 ++++++- src/routes/publication/+page.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app.d.ts b/src/app.d.ts index 731967b..418205d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,4 +1,8 @@ // See https://kit.svelte.dev/docs/types#app + +import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; +import Pharos from "./lib/parser.ts"; + // for information about these interfaces declare global { namespace App { @@ -9,6 +13,7 @@ declare global { parser?: Pharos; waitable?: Promise; publicationType?: string; + publicationRootEvent?: NDKEvent; } // interface Platform {} } diff --git a/src/routes/publication/+page.svelte b/src/routes/publication/+page.svelte index dbd673f..b566965 100644 --- a/src/routes/publication/+page.svelte +++ b/src/routes/publication/+page.svelte @@ -2,10 +2,15 @@ import Article from "$lib/components/Publication.svelte"; import { TextPlaceholder } from "flowbite-svelte"; import type { PageData } from "./$types"; - import { onDestroy } from "svelte"; + import { onDestroy, setContext } from "svelte"; + import { PublicationTree } from "$lib/data_structures/publication_tree"; let { data }: { data: PageData } = $props(); + const publicationTree = new PublicationTree(data.publicationRootEvent, data.ndk); + + setContext('publicationTree', publicationTree); + onDestroy(() => data.parser.reset()); diff --git a/src/routes/publication/+page.ts b/src/routes/publication/+page.ts index 286063a..72116ec 100644 --- a/src/routes/publication/+page.ts +++ b/src/routes/publication/+page.ts @@ -102,5 +102,6 @@ export const load: PageLoad = async ({ url, parent }: { url: URL; parent: () => return { waitable: fetchPromise, publicationType, + publicationRootEvent: indexEvent, }; }; From 5229e807c714c979bbfa307a4b5ffa89917d3fa4 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 14 Apr 2025 08:57:50 -0500 Subject: [PATCH 03/22] Require SvelteKit >= 2.16.0 --- deno.lock | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deno.lock b/deno.lock index f113237..9438da1 100644 --- a/deno.lock +++ b/deno.lock @@ -2887,7 +2887,7 @@ "npm:@sveltejs/adapter-auto@3", "npm:@sveltejs/adapter-node@^5.2.12", "npm:@sveltejs/adapter-static@3", - "npm:@sveltejs/kit@2", + "npm:@sveltejs/kit@^2.16.0", "npm:@sveltejs/vite-plugin-svelte@4", "npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/typography@0.5", diff --git a/package.json b/package.json index 2323efa..ca9cd67 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@sveltejs/adapter-auto": "3.x", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-static": "3.x", - "@sveltejs/kit": "2.x", + "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "4.x", "@types/he": "1.2.x", "@types/node": "22.x", From c63360d1f0b8b43decf23197c6d26dbd9c70b5f6 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 14 Apr 2025 09:05:56 -0500 Subject: [PATCH 04/22] Update publication page load data handling --- src/app.d.ts | 3 ++- src/routes/publication/+page.svelte | 17 ++++------------- src/routes/publication/+page.ts | 3 ++- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index 418205d..10e50ba 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,7 +13,8 @@ declare global { parser?: Pharos; waitable?: Promise; publicationType?: string; - publicationRootEvent?: NDKEvent; + indexEvent?: NDKEvent; + url?: URL; } // interface Platform {} } diff --git a/src/routes/publication/+page.svelte b/src/routes/publication/+page.svelte index e6f532d..032b2f3 100644 --- a/src/routes/publication/+page.svelte +++ b/src/routes/publication/+page.svelte @@ -1,28 +1,19 @@ + + +
+ {#await Promise.all([sectionEvent, publicationType])} + + {:then [sectionEvent, publicationType]} + + {@render contentParagraph(sectionEvent?.content ?? '', publicationType ?? 'article', false)} + {/await} +
+ From 46f61da01dbcae8dc51630246dc0332798c02035 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 14 Apr 2025 23:11:57 -0500 Subject: [PATCH 07/22] Begin to implement import on visibility for publication content --- src/lib/components/Publication.svelte | 82 ++++++++++++++++---- src/lib/components/PublicationSection.svelte | 22 +++++- src/routes/publication/+page.svelte | 6 +- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 4ff9c7c..167cf67 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -9,25 +9,64 @@ TextPlaceholder, Tooltip, } from "flowbite-svelte"; - import { onMount } from "svelte"; + import { getContext, onMount } from "svelte"; import { BookOutline } from "flowbite-svelte-icons"; - import Preview from "./Preview.svelte"; - import { pharosInstance } from "$lib/parser"; import { page } from "$app/state"; - import { ndkInstance } from "$lib/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import PublicationSection from "./PublicationSection.svelte"; + import type { PublicationTree } from "$lib/data_structures/publication_tree"; - let { rootId, publicationType, indexEvent } = $props<{ - rootId: string, + let { rootAddress, publicationType, indexEvent } = $props<{ + rootAddress: string, publicationType: string, indexEvent: NDKEvent }>(); - if (rootId !== $pharosInstance.getRootIndexId()) { - console.error("Root ID does not match parser root index ID"); + const publicationTree = getContext('publicationTree') as PublicationTree; + + // #region Loading + + // TODO: Test load handling. + + let leaves = $state([]); + let isLoading = $state(false); + let lastElementRef = $state(null); + + let observer: IntersectionObserver; + + async function loadMore(count: number) { + isLoading = true; + + for (let i = 0; i < count; i++) { + const nextItem = await publicationTree.next(); + if (nextItem.done) { + break; + } + leaves.push(nextItem.value); + } + + isLoading = false; + } + + function setLastElementRef(el: HTMLElement, i: number) { + if (i === leaves.length - 1) { + lastElementRef = el; + } } + $effect(() => { + if (!lastElementRef) { + return; + } + + observer.observe(lastElementRef!); + return () => observer.unobserve(lastElementRef!); + }); + + // #endregion + + // #region ToC + const tocBreakpoint = 1140; let activeHash = $state(page.url.hash); @@ -82,21 +121,33 @@ } } + // #endregion + onMount(() => { // Always check whether the TOC sidebar should be visible. setTocVisibilityOnResize(); - window.addEventListener("hashchange", scrollToElementWithOffset); // Also handle the case where the user lands on the page with a hash in the URL scrollToElementWithOffset(); - window.addEventListener("resize", setTocVisibilityOnResize); window.addEventListener("click", hideTocOnClick); + // Set up the intersection observer. + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isLoading) { + loadMore(8); + } + }); + }, { threshold: 0.5 }); + loadMore(16); + return () => { window.removeEventListener("hashchange", scrollToElementWithOffset); window.removeEventListener("resize", setTocVisibilityOnResize); window.removeEventListener("click", hideTocOnClick); + + observer.disconnect(); }; }); @@ -115,7 +166,7 @@ Show Table of Contents {/if} - +
- - + {#each leaves as leaf, i} + setLastElementRef(el, i)} + /> + {/each}
diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index cb6f779..4aedc8e 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,20 +1,33 @@ + -
-
+
+ {#if hasError} +
+

Error

+

{errorMessage}

+ +
+ {/if} + +
+ + +
+ + + +
{#if tooltipVisible && tooltipNode} @@ -327,7 +564,7 @@ selected={tooltipNode.id === selectedNodeId} x={tooltipX} y={tooltipY} - on:close={handleTooltipClose} + onclose={handleTooltipClose} /> {/if} diff --git a/src/lib/navigator/EventNetwork/types.ts b/src/lib/navigator/EventNetwork/types.ts index 276b871..db2d46b 100644 --- a/src/lib/navigator/EventNetwork/types.ts +++ b/src/lib/navigator/EventNetwork/types.ts @@ -1,35 +1,79 @@ +/** + * Type definitions for the Event Network visualization + * + * This module defines the core data structures used in the D3 force-directed + * graph visualization of Nostr events. + */ + import type { NDKEvent } from "@nostr-dev-kit/ndk"; -export interface NetworkNode extends d3.SimulationNodeDatum { - id: string; - event?: NDKEvent; - level: number; - kind: number; - title: string; - content: string; - author: string; - type: "Index" | "Content"; - naddr?: string; - nevent?: string; - x?: number; - y?: number; - isContainer?: boolean; +/** + * Base interface for nodes in a D3 force simulation + * Represents the physical properties of a node in the simulation + */ +export interface SimulationNodeDatum { + index?: number; // Node index in the simulation + x?: number; // X position + y?: number; // Y position + vx?: number; // X velocity + vy?: number; // Y velocity + fx?: number | null; // Fixed X position (when node is pinned) + fy?: number | null; // Fixed Y position (when node is pinned) +} + +/** + * Base interface for links in a D3 force simulation + * Represents connections between nodes + */ +export interface SimulationLinkDatum { + source: NodeType | string | number; // Source node or identifier + target: NodeType | string | number; // Target node or identifier + index?: number; // Link index in the simulation } -export interface NetworkLink extends d3.SimulationLinkDatum { - source: NetworkNode; - target: NetworkNode; - isSequential: boolean; +/** + * Represents a node in the event network visualization + * Extends the base simulation node with Nostr event-specific properties + */ +export interface NetworkNode extends SimulationNodeDatum { + id: string; // Unique identifier (event ID) + event?: NDKEvent; // Reference to the original NDK event + level: number; // Hierarchy level in the network + kind: number; // Nostr event kind (30040 for index, 30041/30818 for content) + title: string; // Event title + content: string; // Event content + author: string; // Author's public key + type: "Index" | "Content"; // Node type classification + naddr?: string; // NIP-19 naddr identifier + nevent?: string; // NIP-19 nevent identifier + isContainer?: boolean; // Whether this node is a container (index) } +/** + * Represents a link between nodes in the event network + * Extends the base simulation link with event-specific properties + */ +export interface NetworkLink extends SimulationLinkDatum { + source: NetworkNode; // Source node (overridden to be more specific) + target: NetworkNode; // Target node (overridden to be more specific) + isSequential: boolean; // Whether this link represents a sequential relationship +} + +/** + * Represents the complete graph data for visualization + */ export interface GraphData { - nodes: NetworkNode[]; - links: NetworkLink[]; + nodes: NetworkNode[]; // All nodes in the graph + links: NetworkLink[]; // All links in the graph } +/** + * Represents the internal state of the graph during construction + * Used to track relationships and build the final graph + */ export interface GraphState { - nodeMap: Map; - links: NetworkLink[]; - eventMap: Map; - referencedIds: Set; -} \ No newline at end of file + nodeMap: Map; // Maps event IDs to nodes + links: NetworkLink[]; // All links in the graph + eventMap: Map; // Maps event IDs to original events + referencedIds: Set; // Set of event IDs referenced by other events +} diff --git a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts index 2ba8e90..34731b3 100644 --- a/src/lib/navigator/EventNetwork/utils/forceSimulation.ts +++ b/src/lib/navigator/EventNetwork/utils/forceSimulation.ts @@ -1,27 +1,100 @@ /** - * D3 force simulation utilities for the event network + * D3 Force Simulation Utilities + * + * This module provides utilities for creating and managing D3 force-directed + * graph simulations for the event network visualization. */ import type { NetworkNode, NetworkLink } from "../types"; -import type { Simulation } from "d3"; import * as d3 from "d3"; +// Configuration +const DEBUG = false; // Set to true to enable debug logging +const GRAVITY_STRENGTH = 0.05; // Strength of global gravity +const CONNECTED_GRAVITY_STRENGTH = 0.3; // Strength of gravity between connected nodes + +/** + * Debug logging function that only logs when DEBUG is true + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[ForceSimulation]", ...args); + } +} + +/** + * Type definition for D3 force simulation + * Provides type safety for simulation operations + */ +export interface Simulation { + nodes(): NodeType[]; + nodes(nodes: NodeType[]): this; + alpha(): number; + alpha(alpha: number): this; + alphaTarget(): number; + alphaTarget(target: number): this; + restart(): this; + stop(): this; + tick(): this; + on(type: string, listener: (this: this) => void): this; + force(name: string): any; + force(name: string, force: any): this; +} + /** - * Updates a node's velocity + * Type definition for D3 drag events + * Provides type safety for drag operations + */ +export interface D3DragEvent { + active: number; + sourceEvent: any; + subject: Subject; + x: number; + y: number; + dx: number; + dy: number; + identifier: string | number; +} + +/** + * Updates a node's velocity by applying a force + * + * @param node - The node to update + * @param deltaVx - Change in x velocity + * @param deltaVy - Change in y velocity */ export function updateNodeVelocity( node: NetworkNode, deltaVx: number, deltaVy: number ) { + debug("Updating node velocity", { + nodeId: node.id, + currentVx: node.vx, + currentVy: node.vy, + deltaVx, + deltaVy + }); + if (typeof node.vx === "number" && typeof node.vy === "number") { node.vx = node.vx - deltaVx; node.vy = node.vy - deltaVy; + debug("New velocity", { nodeId: node.id, vx: node.vx, vy: node.vy }); + } else { + debug("Node velocity not defined", { nodeId: node.id }); } } /** - * Applies a logarithmic gravity force to a node + * Applies a logarithmic gravity force pulling the node toward the center + * + * The logarithmic scale ensures that nodes far from the center experience + * stronger gravity, preventing them from drifting too far away. + * + * @param node - The node to apply gravity to + * @param centerX - X coordinate of the center + * @param centerY - Y coordinate of the center + * @param alpha - Current simulation alpha (cooling factor) */ export function applyGlobalLogGravity( node: NetworkNode, @@ -35,102 +108,128 @@ export function applyGlobalLogGravity( if (distance === 0) return; - const force = Math.log(distance + 1) * 0.05 * alpha; + const force = Math.log(distance + 1) * GRAVITY_STRENGTH * alpha; updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); } /** * Applies gravity between connected nodes + * + * This creates a cohesive force that pulls connected nodes toward their + * collective center of gravity, creating more meaningful clusters. + * + * @param node - The node to apply connected gravity to + * @param links - All links in the network + * @param alpha - Current simulation alpha (cooling factor) */ export function applyConnectedGravity( node: NetworkNode, links: NetworkLink[], alpha: number, ) { + // Find all nodes connected to this node 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)); + .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); + // Calculate center of gravity of connected nodes + const cogX = d3.mean(connectedNodes, (n: NetworkNode) => n.x); + const cogY = d3.mean(connectedNodes, (n: NetworkNode) => n.y); if (cogX === undefined || cogY === undefined) return; + // Calculate force direction and magnitude 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; + // Apply force proportional to distance + const force = distance * CONNECTED_GRAVITY_STRENGTH * alpha; updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); } /** * Sets up drag behavior for nodes + * + * This enables interactive dragging of nodes in the visualization. + * + * @param simulation - The D3 force simulation + * @param warmupClickEnergy - Alpha target when dragging starts (0-1) + * @returns D3 drag behavior configured for the simulation */ export function setupDragHandlers( simulation: Simulation, warmupClickEnergy: number = 0.9 ) { return d3 - .drag() - .on( - "start", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - if (!event.active) - simulation.alphaTarget(warmupClickEnergy).restart(); - d.fx = d.x; - d.fy = d.y; - }, - ) - .on( - "drag", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - d.fx = event.x; - d.fy = event.y; - }, - ) - .on( - "end", - ( - event: d3.D3DragEvent, - d: NetworkNode, - ) => { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - }, - ); + .drag() + .on("start", (event: D3DragEvent, d: NetworkNode) => { + // Warm up simulation if it's cooled down + if (!event.active) { + simulation.alphaTarget(warmupClickEnergy).restart(); + } + // Fix node position at current location + d.fx = d.x; + d.fy = d.y; + }) + .on("drag", (event: D3DragEvent, d: NetworkNode) => { + // Update fixed position to mouse position + d.fx = event.x; + d.fy = event.y; + }) + .on("end", (event: D3DragEvent, d: NetworkNode) => { + // Cool down simulation when drag ends + if (!event.active) { + simulation.alphaTarget(0); + } + // Release fixed position + d.fx = null; + d.fy = null; + }); } /** * Creates a D3 force simulation for the network + * + * @param nodes - Array of network nodes + * @param links - Array of network links + * @param nodeRadius - Radius of node circles + * @param linkDistance - Desired distance between linked nodes + * @returns Configured D3 force simulation */ export function createSimulation( nodes: NetworkNode[], links: NetworkLink[], nodeRadius: number, linkDistance: number -) { - return d3 - .forceSimulation(nodes) - .force( - "link", - d3 - .forceLink(links) - .id((d) => d.id) - .distance(linkDistance * 0.1), - ) - .force("collide", d3.forceCollide().radius(nodeRadius * 4)); -} \ No newline at end of file +): Simulation { + debug("Creating simulation", { + nodeCount: nodes.length, + linkCount: links.length, + nodeRadius, + linkDistance + }); + + try { + // Create the simulation with nodes + const simulation = d3 + .forceSimulation(nodes) + .force( + "link", + d3.forceLink(links) + .id((d: NetworkNode) => d.id) + .distance(linkDistance * 0.1) + ) + .force("collide", d3.forceCollide().radius(nodeRadius * 4)); + + debug("Simulation created successfully"); + return simulation; + } catch (error) { + console.error("Error creating simulation:", error); + throw error; + } +} diff --git a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts index d9daa58..4f27c1f 100644 --- a/src/lib/navigator/EventNetwork/utils/networkBuilder.ts +++ b/src/lib/navigator/EventNetwork/utils/networkBuilder.ts @@ -1,17 +1,49 @@ +/** + * Network Builder Utilities + * + * This module provides utilities for building a network graph from Nostr events. + * It handles the creation of nodes and links, and the processing of event relationships. + */ + import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; import { nip19 } from "nostr-tools"; import { standardRelays } from "$lib/consts"; +// Configuration +const DEBUG = false; // Set to true to enable debug logging +const INDEX_EVENT_KIND = 30040; +const CONTENT_EVENT_KIND = 30041; + +/** + * Debug logging function that only logs when DEBUG is true + */ +function debug(...args: any[]) { + if (DEBUG) { + console.log("[NetworkBuilder]", ...args); + } +} + /** * Creates a NetworkNode from an NDKEvent + * + * Extracts relevant information from the event and creates a node representation + * for the visualization. + * + * @param event - The Nostr event to convert to a node + * @param level - The hierarchy level of the node (default: 0) + * @returns A NetworkNode object representing the event */ export function createNetworkNode( event: NDKEvent, level: number = 0 ): NetworkNode { - const isContainer = event.kind === 30040; + debug("Creating network node", { eventId: event.id, kind: event.kind, level }); + + const isContainer = event.kind === INDEX_EVENT_KIND; + const nodeType = isContainer ? "Index" : "Content"; + // Create the base node with essential properties const node: NetworkNode = { id: event.id, event, @@ -20,13 +52,16 @@ export function createNetworkNode( title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", content: event.content || "", author: event.pubkey || "", - kind: event.kind, - type: event?.kind === 30040 ? "Index" : "Content", + kind: event.kind || CONTENT_EVENT_KIND, // Default to content event kind if undefined + type: nodeType, }; + // Add NIP-19 identifiers if possible if (event.kind && event.pubkey) { try { const dTag = event.getMatchingTags("d")?.[0]?.[1] || ""; + + // Create naddr (NIP-19 address) for the event node.naddr = nip19.naddrEncode({ pubkey: event.pubkey, identifier: dTag, @@ -34,6 +69,7 @@ export function createNetworkNode( relays: standardRelays, }); + // Create nevent (NIP-19 event reference) for the event node.nevent = nip19.neventEncode({ id: event.id, relays: standardRelays, @@ -47,50 +83,93 @@ export function createNetworkNode( return node; } +/** + * Creates a map of event IDs to events for quick lookup + * + * @param events - Array of Nostr events + * @returns Map of event IDs to events + */ export function createEventMap(events: NDKEvent[]): Map { + debug("Creating event map", { eventCount: events.length }); + const eventMap = new Map(); events.forEach((event) => { if (event.id) { eventMap.set(event.id, event); } }); + + debug("Event map created", { mapSize: eventMap.size }); return eventMap; } +/** + * Extracts an event ID from an 'a' tag + * + * @param tag - The tag array from a Nostr event + * @returns The event ID or null if not found + */ export function extractEventIdFromATag(tag: string[]): string | null { return tag[3] || null; } /** - * Generates a color for an event based on its ID + * Generates a deterministic color for an event based on its ID + * + * This creates visually distinct colors for different index events + * while ensuring the same event always gets the same color. + * + * @param eventId - The event ID to generate a color for + * @returns An HSL color string */ export function getEventColor(eventId: string): string { + // Use first 4 characters of event ID as a hex number const num = parseInt(eventId.slice(0, 4), 16); + // Convert to a hue value (0-359) const hue = num % 360; + // Use fixed saturation and lightness for pastel colors const saturation = 70; const lightness = 75; return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } +/** + * Initializes the graph state from a set of events + * + * Creates nodes for all events and identifies referenced events. + * + * @param events - Array of Nostr events + * @returns Initial graph state + */ export function initializeGraphState(events: NDKEvent[]): GraphState { + debug("Initializing graph state", { eventCount: events.length }); + const nodeMap = new Map(); const eventMap = createEventMap(events); - // Create initial nodes + // Create initial nodes for all events events.forEach((event) => { if (!event.id) return; const node = createNetworkNode(event); nodeMap.set(event.id, node); }); + debug("Node map created", { nodeCount: nodeMap.size }); - // Build referenced IDs set + // Build set of referenced event IDs to identify root events const referencedIds = new Set(); events.forEach((event) => { - event.getMatchingTags("a").forEach((tag) => { + const aTags = event.getMatchingTags("a"); + debug("Processing a-tags for event", { + eventId: event.id, + aTagCount: aTags.length + }); + + aTags.forEach((tag) => { const id = extractEventIdFromATag(tag); if (id) referencedIds.add(id); }); }); + debug("Referenced IDs set created", { referencedCount: referencedIds.size }); return { nodeMap, @@ -100,6 +179,18 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { }; } +/** + * Processes a sequence of nodes referenced by an index event + * + * Creates links between the index and its content, and between sequential content nodes. + * Also processes nested indices recursively up to the maximum level. + * + * @param sequence - Array of nodes in the sequence + * @param indexEvent - The index event referencing the sequence + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processSequence( sequence: NetworkNode[], indexEvent: NDKEvent, @@ -107,14 +198,15 @@ export function processSequence( state: GraphState, maxLevel: number, ): void { + // Stop if we've reached max level or have no nodes if (level >= maxLevel || sequence.length === 0) return; - // Set levels for sequence nodes + // Set levels for all nodes in the sequence sequence.forEach((node) => { node.level = level + 1; }); - // Create initial link from index to first content + // Create link from index to first content node const indexNode = state.nodeMap.get(indexEvent.id); if (indexNode && sequence[0]) { state.links.push({ @@ -124,7 +216,7 @@ export function processSequence( }); } - // Create sequential links + // Create sequential links between content nodes for (let i = 0; i < sequence.length - 1; i++) { const currentNode = sequence[i]; const nextNode = sequence[i + 1]; @@ -135,16 +227,27 @@ export function processSequence( isSequential: true, }); - processNestedIndex(currentNode, level + 1, state, maxLevel); + // Process nested indices recursively + if (currentNode.isContainer) { + processNestedIndex(currentNode, level + 1, state, maxLevel); + } } - // Process final node if it's an index + // Process the last node if it's an index const lastNode = sequence[sequence.length - 1]; if (lastNode?.isContainer) { processNestedIndex(lastNode, level + 1, state, maxLevel); } } +/** + * Processes a nested index node + * + * @param node - The index node to process + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processNestedIndex( node: NetworkNode, level: number, @@ -159,6 +262,14 @@ export function processNestedIndex( } } +/** + * Processes an index event and its referenced content + * + * @param indexEvent - The index event to process + * @param level - Current hierarchy level + * @param state - Current graph state + * @param maxLevel - Maximum hierarchy level to process + */ export function processIndexEvent( indexEvent: NDKEvent, level: number, @@ -167,6 +278,7 @@ export function processIndexEvent( ): void { if (level >= maxLevel) return; + // Extract the sequence of nodes referenced by this index const sequence = indexEvent .getMatchingTags("a") .map((tag) => extractEventIdFromATag(tag)) @@ -177,19 +289,53 @@ export function processIndexEvent( processSequence(sequence, indexEvent, level, state, maxLevel); } +/** + * Generates a complete graph from a set of events + * + * This is the main entry point for building the network visualization. + * + * @param events - Array of Nostr events + * @param maxLevel - Maximum hierarchy level to process + * @returns Complete graph data for visualization + */ export function generateGraph( events: NDKEvent[], maxLevel: number ): GraphData { + debug("Generating graph", { eventCount: events.length, maxLevel }); + + // Initialize the graph state const state = initializeGraphState(events); - // Process root indices - events - .filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id)) - .forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel)); + // Find root index events (those not referenced by other events) + const rootIndices = events.filter( + (e) => e.kind === INDEX_EVENT_KIND && e.id && !state.referencedIds.has(e.id) + ); + + debug("Found root indices", { + rootCount: rootIndices.length, + rootIds: rootIndices.map(e => e.id) + }); + + // Process each root index + rootIndices.forEach((rootIndex) => { + debug("Processing root index", { + rootId: rootIndex.id, + aTags: rootIndex.getMatchingTags("a").length + }); + processIndexEvent(rootIndex, 0, state, maxLevel); + }); - return { + // Create the final graph data + const result = { nodes: Array.from(state.nodeMap.values()), links: state.links, }; -} \ No newline at end of file + + debug("Graph generation complete", { + nodeCount: result.nodes.length, + linkCount: result.links.length + }); + + return result; +} diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index f1f7d33..39eaa06 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -1,3 +1,9 @@ +
-
+ +

Publication Network

- + {#if !loading && !error}
+ {#if !loading && !error && showSettings} -
{/if} + {#if loading}
@@ -140,12 +188,14 @@ Loading...
+ {:else if error} + {:else} - -
+
+ + +
{/if}
diff --git a/src/styles/visualize.css b/src/styles/visualize.css index 59360c9..456a4c1 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -1,4 +1,5 @@ @layer components { + /* Legend styles - specific to visualization */ .legend-list { @apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300; } @@ -21,6 +22,84 @@ } .legend-letter { - @apply absolute inset-0 flex items-center justify-center text-black text-xs; + @apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; + } + + .legend-text { + @apply text-sm; + } + + /* Network visualization styles - specific to visualization */ + .network-container { + @apply flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4; + } + + .network-svg-container { + @apply h-[calc(100%-130px)] min-h-[300px]; + } + + .network-svg { + @apply w-full h-full border border-gray-300 dark:border-gray-700 rounded; + } + + .network-error { + @apply w-full p-4 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded-lg mb-4; + } + + .network-error-title { + @apply font-bold text-lg; + } + + .network-error-retry { + @apply mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700; + } + + .network-debug { + @apply mt-4 text-sm text-gray-500; + } + + /* Zoom controls */ + .network-controls { + @apply absolute bottom-4 right-4 flex flex-col gap-2 z-10; + } + + .network-control-button { + @apply bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 + shadow-md hover:shadow-lg transition-shadow duration-200 + border border-gray-300 dark:border-gray-700; + } + + /* Tooltip styles - specific to visualization tooltips */ + .tooltip-close-btn { + @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 + rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200; + } + + .tooltip-content { + @apply space-y-2 pr-6; + } + + .tooltip-title { + @apply font-bold text-base; + } + + .tooltip-title-link { + @apply text-gray-800 hover:text-blue-600 dark:text-gray-200 dark:hover:text-blue-400; + } + + .tooltip-metadata { + @apply text-gray-600 dark:text-gray-400 text-sm; + } + + .tooltip-summary { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-content-preview { + @apply mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-auto max-h-40; + } + + .tooltip-help-text { + @apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic; } } diff --git a/src/types/d3.d.ts b/src/types/d3.d.ts new file mode 100644 index 0000000..3d230f5 --- /dev/null +++ b/src/types/d3.d.ts @@ -0,0 +1,19 @@ +/** + * Type declarations for D3.js and related modules + * + * These declarations allow TypeScript to recognize D3 imports without requiring + * detailed type definitions. For a project requiring more type safety, consider + * using the @types/d3 package and its related sub-packages. + */ + +// Core D3 library +declare module 'd3'; + +// Force simulation module for graph layouts +declare module 'd3-force'; + +// DOM selection and manipulation module +declare module 'd3-selection'; + +// Drag behavior module +declare module 'd3-drag'; From 94842e2326e15cf61912f6ea0a8ea4f0bf26ad0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sat, 19 Apr 2025 14:05:18 +0200 Subject: [PATCH 11/22] Moved buttons and consolidated stylesheet --- src/lib/navigator/EventNetwork/Legend.svelte | 9 --------- src/styles/visualize.css | 10 +++++++++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index 76be770..d8e79b4 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -50,12 +50,3 @@
- diff --git a/src/styles/visualize.css b/src/styles/visualize.css index 456a4c1..ef7f33b 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -21,6 +21,14 @@ background-color: #d6c1a8; } + .legend-circle.content { + background-color: var(--content-color, #d6c1a8); + } + + :global(.dark) .legend-circle.content { + background-color: var(--content-color-dark, #FFFFFF); + } + .legend-letter { @apply absolute inset-0 flex items-center justify-center text-black text-xs font-bold; } @@ -35,7 +43,7 @@ } .network-svg-container { - @apply h-[calc(100%-130px)] min-h-[300px]; + @apply relative; } .network-svg { From 1df1da95ce8386904febadf42e0805085e4254b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sun, 20 Apr 2025 21:29:18 +0200 Subject: [PATCH 12/22] Changed the styles of the visualization page, moved the settings --- src/app.css | 10 +- src/lib/components/EventLimitControl.svelte | 6 +- .../components/EventRenderLevelLimit.svelte | 6 +- src/lib/navigator/EventNetwork/Legend.svelte | 119 +++++++++++------- .../navigator/EventNetwork/Settings.svelte | 52 ++++++++ src/lib/navigator/EventNetwork/index.svelte | 38 ++++-- src/routes/visualize/+page.svelte | 55 +------- src/styles/visualize.css | 13 +- 8 files changed, 173 insertions(+), 126 deletions(-) create mode 100644 src/lib/navigator/EventNetwork/Settings.svelte diff --git a/src/app.css b/src/app.css index 235f36b..314408a 100644 --- a/src/app.css +++ b/src/app.css @@ -206,8 +206,9 @@ @layer components { /* Legend */ .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; + @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 rounded; + @apply shadow-none text-primary-1000 border border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; + @apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; } /* Tooltip */ @@ -219,8 +220,7 @@ z-index: 1000; } - /* Heading for leather components */ - h3.h-leather { - @apply text-gray-800 dark:text-gray-200 text-lg font-bold mb-2; + .leather-legend button { + @apply dark:text-white; } } diff --git a/src/lib/components/EventLimitControl.svelte b/src/lib/components/EventLimitControl.svelte index aafd91f..d8c28be 100644 --- a/src/lib/components/EventLimitControl.svelte +++ b/src/lib/components/EventLimitControl.svelte @@ -30,7 +30,7 @@
- diff --git a/src/lib/components/EventRenderLevelLimit.svelte b/src/lib/components/EventRenderLevelLimit.svelte index bbfdc87..3a7d8a8 100644 --- a/src/lib/components/EventRenderLevelLimit.svelte +++ b/src/lib/components/EventRenderLevelLimit.svelte @@ -29,16 +29,16 @@
- - + + + -
-

Legend

-
    - -
  • -
    - - I - -
    - Index events (kind 30040) - Each with a unique pastel color -
  • - - -
  • -
    - - C - -
    - Content events (kinds 30041, 30818) - Publication sections -
  • - - -
  • - - - - Arrows indicate reading/sequence order -
  • -
-
+
+
+

Legend

+ +
+ {#if expanded} +
    + +
  • +
    + + I + +
    + Index events (kind 30040) - Each with a unique pastel color +
  • + + +
  • +
    + + C + +
    + Content events (kinds 30041, 30818) - Publication sections +
  • + + +
  • + + + + Arrows indicate reading/sequence order +
  • +
+ {/if} +
diff --git a/src/lib/navigator/EventNetwork/Settings.svelte b/src/lib/navigator/EventNetwork/Settings.svelte new file mode 100644 index 0000000..390931c --- /dev/null +++ b/src/lib/navigator/EventNetwork/Settings.svelte @@ -0,0 +1,52 @@ + + + +
+
+

Settings

+ +
+ + {#if expanded} +
+ + Showing {count} events from {$networkFetchLimit} headers + + + +
+ {/if} +
diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index d43719a..7742475 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -21,6 +21,8 @@ import Legend from "./Legend.svelte"; import NodeTooltip from "./NodeTooltip.svelte"; import type { NetworkNode, NetworkLink } from "./types"; + import Settings from "./Settings.svelte"; + import {Button} from 'flowbite-svelte'; // Type alias for D3 selections type Selection = any; @@ -43,7 +45,7 @@ } // Component props - let { events = [] } = $props<{ events?: NDKEvent[] }>(); + let { events = [], onupdate } = $props<{ events?: NDKEvent[], onupdate: () => void }>(); // Error state let errorMessage = $state(null); @@ -498,6 +500,17 @@ ); } } + + /** + * Legend interactions + */ + let graphInteracted = $state(false); + + function handleGraphClick() { + if (!graphInteracted) { + graphInteracted = true; + } + }
@@ -514,15 +527,23 @@
{/if} -
+
+ + + + + + +
- - - +
@@ -568,5 +589,4 @@ /> {/if} -
diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 39eaa06..142eeb5 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -10,13 +10,10 @@ import { ndkInstance } from "$lib/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import { filterValidIndexEvents } from "$lib/utils"; - import EventLimitControl from "$lib/components/EventLimitControl.svelte"; - import EventRenderLevelLimit from "$lib/components/EventRenderLevelLimit.svelte"; import { networkFetchLimit } from "$lib/state"; - import { fly } from "svelte/transition"; - import { quintOut } from "svelte/easing"; import { CogSolid } from "flowbite-svelte-icons"; import { Button } from "flowbite-svelte"; + import Settings from "$lib/navigator/EventNetwork/Settings.svelte"; // Configuration const DEBUG = false; // Set to true to enable debug logging @@ -110,12 +107,6 @@ } } - /** - * Handles updates to visualization settings - */ - function handleLimitUpdate() { - fetchEvents(); - } // Fetch events when component mounts onMount(() => { @@ -127,44 +118,8 @@
-

Publication Network

- - - {#if !loading && !error} - - {/if} +

Publication Network

- - - {#if !loading && !error && showSettings} -
-
-

- Visualization Settings -

- -
- - Showing {events.length} events from {$networkFetchLimit} headers - - - -
-
-
- {/if} - {#if loading}
@@ -206,9 +161,7 @@
{:else} -
- - -
+ + {/if}
diff --git a/src/styles/visualize.css b/src/styles/visualize.css index ef7f33b..1ff732d 100644 --- a/src/styles/visualize.css +++ b/src/styles/visualize.css @@ -1,7 +1,7 @@ @layer components { /* Legend styles - specific to visualization */ .legend-list { - @apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300; + @apply list-disc mt-2 space-y-2 text-gray-800 dark:text-gray-300; } .legend-item { @@ -39,15 +39,16 @@ /* Network visualization styles - specific to visualization */ .network-container { - @apply flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4; + @apply flex flex-col w-full h-[calc(100vh-138px)] min-h-[400px] max-h-[900px]; } .network-svg-container { - @apply relative; + @apply relative sm:h-[100%]; } .network-svg { - @apply w-full h-full border border-gray-300 dark:border-gray-700 rounded; + @apply w-full sm:h-[100%] border; + @apply border border-primary-200 has-[:hover]:border-primary-700 dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500 rounded; } .network-error { @@ -72,9 +73,7 @@ } .network-control-button { - @apply bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 - shadow-md hover:shadow-lg transition-shadow duration-200 - border border-gray-300 dark:border-gray-700; + @apply bg-white; } /* Tooltip styles - specific to visualization tooltips */ From 787eb0402876041b56dd0033195dc6e4a68ad201 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 30 Apr 2025 00:07:33 -0500 Subject: [PATCH 13/22] Add Cursor rules These are used as instructions for all LLMs working on this repo within the Cursor IDE. --- .cursor/rules/alexandria.mdc | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .cursor/rules/alexandria.mdc diff --git a/.cursor/rules/alexandria.mdc b/.cursor/rules/alexandria.mdc new file mode 100644 index 0000000..4fe24b0 --- /dev/null +++ b/.cursor/rules/alexandria.mdc @@ -0,0 +1,62 @@ +--- +description: +globs: +alwaysApply: true +--- +# Project Alexandria + +You are senior full-stack software engineer with 20 years of experience writing web apps. You have been working with the Svelte web development framework for 8 years, since it was first released, and you currently are a leading expert on Svelte 5 and SvelteKit 2. Additionally, you are a pioneer developer on the Nostr protocol, and have developing production-quality Nostr apps for 4 years. + +## Project Overview + +Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays. It revolves around breaking long AsciiDoc documents into Nostr events, with each event containing a paragraph or so of text from the document. These individual content events are organized by index events into publications. An index contains an ordered list of references to other index events or content events, forming a tree. + +### Reader Features + +In reader mode, Alexandria loads a document tree from a root publication index event. The AsciiDoc text content of the various content events, along with headers specified by tags in the index events, is composed and rendered as a single document from the user's point of view. + +### Tech Stack + +Svelte components in Alexandria use TypeScript exclusively over plain JavaScript. Styles are defined via Tailwind 4 utility classes, and some custom utility classes are defined in [app.css](mdc:src/app.css). The app runs on Deno, but maintains compatibility with Node.js. + +## General Guidelines + +When responding to prompts, adhere to the following rules: + +- Avoid making apologetic or conciliatory statements. +- Avoid verbose responses; be direct and to the point. +- Provide links to relevant documentation so that I can do further reading on the tools or techniques discussed and used in your responses. +- When I tell you a response is incorrect, avoid simply agreeing with me; think about the points raised and provide well-reasoned explanations for your subsequent responses. +- Avoid proposing code edits unless I specifically tell you to do so. +- When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily. + +## Code Style + +Observe the following style guidelines when writing code: + +### General Guidance + +- Use PascalCase names for Svelte 5 components and their files. +- Use snake_case names for plain TypeScript files. +- Use comments sparingly; code should be self-documenting. + +### JavaScript/TypeScript + +- Use an indentation size of 2 spaces. +- Use camelCase names for variables, classes, and functions. +- Give variables, classes, and functions descriptive names that reflect their content and purpose. +- Use Svelte 5 features, such as runes. Avoid using legacy Svelte 4 features. +- Write JSDoc comments for all functions. +- Use blocks enclosed by curly brackets when writing control flow expressions such as `for` and `while` loops, and `if` and `switch` statements. +- Begin `case` expressions in a `switch` statement at the same indentation level as the `switch` itself. Indent code within a `case` block. +- Limit line length to 100 characters; break statements across lines if necessary. +- Default to single quotes. + +### HTML + +- Use an indentation size of 2 spaces. +- Break long tags across multiple lines. +- Use Tailwind 4 utility classes for styling. +- Default to single quotes. + + From 9e35e0b229581ffe925d50fbb22a3237f18079ca Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 30 Apr 2025 00:07:43 -0500 Subject: [PATCH 14/22] Update D3 --- deno.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/deno.lock b/deno.lock index 9438da1..c97022c 100644 --- a/deno.lock +++ b/deno.lock @@ -2891,6 +2891,7 @@ "npm:@sveltejs/vite-plugin-svelte@4", "npm:@tailwindcss/forms@0.5", "npm:@tailwindcss/typography@0.5", + "npm:@types/d3@^7.4.3", "npm:@types/he@1.2", "npm:@types/node@22", "npm:asciidoctor@3.0", From 1ecc6bccbd7be6efb5ee138ad064fb69cfcb184e Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 30 Apr 2025 00:11:48 -0500 Subject: [PATCH 15/22] Get rendering to work properly with new loader Load-on-scroll can stand to be improved, but it works at a basic level. Event content is rendered correctly, with headings, and many content block types have proper styling. --- src/app.css | 107 ++++++++++++++++++- src/lib/components/PublicationSection.svelte | 40 ++++--- src/lib/snippets/PublicationSnippets.svelte | 12 +-- 3 files changed, 128 insertions(+), 31 deletions(-) diff --git a/src/app.css b/src/app.css index 314408a..4c538de 100644 --- a/src/app.css +++ b/src/app.css @@ -186,11 +186,6 @@ @apply bg-gray-200 dark:bg-gray-700; } - /* Unordered list */ - .ul-leather li a { - @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; - } - /* Network visualization */ .network-link-leather { @apply stroke-gray-400 fill-gray-400; @@ -203,6 +198,43 @@ } } +/* Utilities can be applied via the @apply directive. */ +@layer utilities { + .h-leather { + @apply text-gray-800 dark:text-gray-300 pt-4; + } + + .h1-leather { + @apply text-4xl font-bold; + } + + .h2-leather { + @apply text-3xl font-bold; + } + + .h3-leather { + @apply text-2xl font-bold; + } + + .h4-leather { + @apply text-xl font-bold; + } + + .h5-leather { + @apply text-lg font-semibold; + } + + .h6-leather { + @apply text-base font-semibold; + } + + /* Lists */ + .ol-leather li a, + .ul-leather li a { + @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; + } +} + @layer components { /* Legend */ .leather-legend { @@ -223,4 +255,69 @@ .leather-legend button { @apply dark:text-white; } + + /* Rendered publication content */ + .publication-leather { + @apply flex flex-col space-y-4; + + h1, h2, h3, h4, h5, h6 { + @apply h-leather; + } + + h1 { + @apply h1-leather; + } + + h2 { + @apply h2-leather; + } + + h3 { + @apply h3-leather; + } + + h4 { + @apply h4-leather; + } + + h5 { + @apply h5-leather; + } + + h6 { + @apply h6-leather; + } + + div { + @apply flex flex-col space-y-4; + } + + .olist { + @apply flex flex-col space-y-4; + + ol { + @apply ol-leather list-decimal px-6 flex flex-col space-y-2; + + li { + .paragraph { + @apply py-2; + } + } + } + } + + .ulist { + @apply flex flex-col space-y-4; + + ul { + @apply ul-leather list-disc px-6 flex flex-col space-y-2; + + li { + .paragraph { + @apply py-2; + } + } + } + } + } } diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte index 0ea64f1..4cafb7c 100644 --- a/src/lib/components/PublicationSection.svelte +++ b/src/lib/components/PublicationSection.svelte @@ -5,6 +5,7 @@ import { TextPlaceholder } from "flowbite-svelte"; import { getContext } from "svelte"; import type { Asciidoctor, Document } from "asciidoctor"; + let { address, rootAddress, @@ -20,7 +21,7 @@ const publicationTree: PublicationTree = getContext('publicationTree'); const asciidoctor: Asciidoctor = getContext('asciidoctor'); - let leafEvent: Promise = $derived.by(async () => + let leafEvent: Promise = $derived.by(async () => await publicationTree.getEvent(address)); let rootEvent: Promise = $derived.by(async () => await publicationTree.getEvent(rootAddress)); @@ -28,6 +29,8 @@ (await rootEvent)?.getMatchingTags('type')[0]?.[1]); let leafHierarchy: Promise = $derived.by(async () => await publicationTree.getHierarchy(address)); + let leafTitle: Promise = $derived.by(async () => + (await leafEvent)?.getMatchingTags('title')[0]?.[1]); let leafContent: Promise = $derived.by(async () => asciidoctor.convert((await leafEvent)?.content ?? '')); @@ -39,40 +42,40 @@ return leaves[index - 1]; }); let previousLeafHierarchy: Promise = $derived.by(async () => { - const previousLeaf = await previousLeafEvent; - if (!previousLeaf) { + console.debug('Finding previous leaf hierarchy for ', address); + if (!previousLeafEvent) { return null; } - return await publicationTree.getHierarchy(previousLeafEvent?.tagAddress() ?? '') + return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); }); let divergingBranches = $derived.by(async () => { - const currentHierarchy = await leafHierarchy; - const previousHierarchy = await previousLeafHierarchy; + console.debug('Finding diverging branches for ', address); + let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); const branches: [NDKEvent, number][] = []; - if (!previousHierarchy) { - for (let i = 0; i < currentHierarchy.length - 1; i++) { - branches.push([currentHierarchy[i], i]); + if (!previousLeafHierarchyValue) { + for (let i = 0; i < leafHierarchyValue.length - 1; i++) { + branches.push([leafHierarchyValue[i], i]); } return branches; } - const minLength = Math.min(currentHierarchy.length, previousHierarchy.length); + const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); // Find the first diverging node. let divergingIndex = 0; while ( divergingIndex < minLength && - currentHierarchy[divergingIndex].tagAddress() === previousHierarchy[divergingIndex].tagAddress() + leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() ) { divergingIndex++; } // Add all branches from the first diverging node to the current leaf. - for (let i = divergingIndex; i < currentHierarchy.length - 1; i++) { - branches.push([currentHierarchy[i], i]); + for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { + branches.push([leafHierarchyValue[i], i]); } return branches; @@ -90,15 +93,18 @@ -
- {#await Promise.all([leafContent, publicationType, divergingBranches])} +
+ {#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} - {:then [leafContent, publicationType, divergingBranches]} + {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {#each divergingBranches as [branch, depth]} {@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)} {/each} + {#if leafTitle} + {@const leafDepth = leafHierarchy.length - 1} + {@render sectionHeading(leafTitle, leafDepth)} + {/if} {@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)} {/await}
- diff --git a/src/lib/snippets/PublicationSnippets.svelte b/src/lib/snippets/PublicationSnippets.svelte index 8f9e0cc..802edfd 100644 --- a/src/lib/snippets/PublicationSnippets.svelte +++ b/src/lib/snippets/PublicationSnippets.svelte @@ -14,13 +14,7 @@ {/snippet} {#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} - {#if publicationType === 'novel'} -

- {@html content} -

- {:else} -

- {@html content} -

- {/if} +
+ {@html content} +
{/snippet} From 3f479169a7c191bf4c04ff6b9b12fe2ba16b3ac6 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 30 Apr 2025 00:12:20 -0500 Subject: [PATCH 16/22] Remove leftover console debug statements --- src/lib/components/PublicationSection.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/components/PublicationSection.svelte b/src/lib/components/PublicationSection.svelte index 4cafb7c..5eb4f24 100644 --- a/src/lib/components/PublicationSection.svelte +++ b/src/lib/components/PublicationSection.svelte @@ -42,7 +42,6 @@ return leaves[index - 1]; }); let previousLeafHierarchy: Promise = $derived.by(async () => { - console.debug('Finding previous leaf hierarchy for ', address); if (!previousLeafEvent) { return null; } @@ -50,7 +49,6 @@ }); let divergingBranches = $derived.by(async () => { - console.debug('Finding diverging branches for ', address); let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); const branches: [NDKEvent, number][] = []; From 823d5223952a709028522fcab4b99b960cc7951e Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 30 Apr 2025 00:17:55 -0500 Subject: [PATCH 17/22] Comment out ToC placeholder --- src/lib/components/Publication.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 8bcf5a4..7d53987 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -155,7 +155,7 @@ {#if showTocButton && !showToc} - - Show Table of Contents + Show Table of Contents --> {/if}