learn and share notes on atproto (wip) 馃 malfestio.stormlightlabs.org/
readability solid axum atproto srs
at main 179 lines 6.2 kB view raw
1import type { Note } from "$lib/model"; 2import { extractWikilinkTitles, resolveWikilink } from "$lib/wikilink"; 3import { useNavigate } from "@solidjs/router"; 4import { drag } from "d3-drag"; 5import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from "d3-force"; 6import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from "d3-force"; 7import { select } from "d3-selection"; 8import { zoom, type ZoomBehavior, zoomIdentity } from "d3-zoom"; 9import type { Component } from "solid-js"; 10import { createEffect, createMemo, onCleanup, onMount } from "solid-js"; 11 12export type GraphNode = SimulationNodeDatum & { id: string; title: string; tags: string[]; linkCount: number }; 13 14export type GraphLink = SimulationLinkDatum<GraphNode> & { source: string | GraphNode; target: string | GraphNode }; 15 16type NotesGraphProps = { 17 notes: Note[]; 18 currentNoteId?: string; 19 onNodeClick?: (noteId: string) => void; 20 width?: number; 21 height?: number; 22}; 23 24const NODE_RADIUS = 8; 25const LINK_DISTANCE = 100; 26const CHARGE_STRENGTH = -300; 27 28export const NotesGraph: Component<NotesGraphProps> = (props) => { 29 const navigate = useNavigate(); 30 let containerRef: HTMLDivElement | undefined; 31 let simulation: Simulation<GraphNode, GraphLink> | undefined; 32 let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | undefined; 33 34 const width = () => props.width ?? 800; 35 const height = () => props.height ?? 600; 36 37 const graphData = createMemo(() => { 38 const notes = props.notes; 39 const nodeMap = new Map<string, GraphNode>(); 40 const links: GraphLink[] = []; 41 42 for (const note of notes) { 43 nodeMap.set(note.id, { id: note.id, title: note.title, tags: note.tags, linkCount: 0 }); 44 } 45 46 for (const note of notes) { 47 const titles = extractWikilinkTitles(note.body); 48 for (const title of titles) { 49 const targetNote = resolveWikilink(title, notes); 50 if (targetNote && targetNote.id !== note.id) { 51 const sourceNode = nodeMap.get(note.id); 52 const targetNode = nodeMap.get(targetNote.id); 53 if (sourceNode && targetNode) { 54 sourceNode.linkCount++; 55 targetNode.linkCount++; 56 links.push({ source: note.id, target: targetNote.id }); 57 } 58 } 59 } 60 } 61 62 return { nodes: Array.from(nodeMap.values()), links }; 63 }); 64 65 // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 const handleNodeClick = (_: any, d: GraphNode) => { 67 if (props.onNodeClick) { 68 props.onNodeClick(d.id); 69 } else { 70 navigate(`/notes/${d.id}`); 71 } 72 }; 73 74 const computeColor = (d: GraphNode) => 75 d.id === props.currentNoteId 76 ? "fill-blue-500" 77 : d.linkCount > 0 78 ? "fill-emerald-500" 79 : "fill-slate-400 dark:fill-slate-500"; 80 const initializeGraph = () => { 81 if (!containerRef) return; 82 83 const svg = select(containerRef).select<SVGSVGElement>("svg").attr("viewBox", `0 0 ${width()} ${height()}`); 84 const g = svg.select<SVGGElement>(".graph-container"); 85 const data = graphData(); 86 87 zoomBehavior = zoom<SVGSVGElement, unknown>().scaleExtent([0.1, 4]).on("zoom", (event) => { 88 g.attr("transform", event.transform); 89 }); 90 91 svg.call(zoomBehavior); 92 svg.call(zoomBehavior.transform, zoomIdentity.translate(width() / 2, height() / 2)); 93 94 simulation = forceSimulation<GraphNode, GraphLink>(data.nodes).force( 95 "link", 96 forceLink<GraphNode, GraphLink>(data.links).id((d) => d.id).distance(LINK_DISTANCE), 97 ).force("charge", forceManyBody().strength(CHARGE_STRENGTH)).force("center", forceCenter(0, 0)).force( 98 "collision", 99 forceCollide().radius(NODE_RADIUS * 2), 100 ); 101 102 const linkSelection = g.select<SVGGElement>(".links").selectAll<SVGLineElement, GraphLink>("line").data( 103 data.links, 104 (d) => `${(d.source as GraphNode).id ?? d.source}-${(d.target as GraphNode).id ?? d.target}`, 105 ).join("line").attr("class", "stroke-slate-600 dark:stroke-slate-500").attr("stroke-opacity", 0.6).attr( 106 "stroke-width", 107 1.5, 108 ); 109 110 const nodeSelection = g.select<SVGGElement>(".nodes").selectAll<SVGGElement, GraphNode>(".node").data( 111 data.nodes, 112 (d) => d.id, 113 ).join("g").attr("class", "node cursor-pointer").on("click", handleNodeClick); 114 115 nodeSelection.selectAll("circle").data((d) => [d]).join("circle").attr( 116 "r", 117 (d) => NODE_RADIUS + Math.min(d.linkCount, 5), 118 ).attr("class", computeColor).attr("stroke", "var(--color-slate-900)").attr("stroke-width", 2); 119 120 nodeSelection.selectAll("text").data((d) => [d]).join("text").text((d) => 121 d.title.length > 20 ? d.title.slice(0, 20) + "..." : d.title 122 ).attr("x", 14).attr("y", 4).attr( 123 "class", 124 "text-xs fill-slate-300 dark:fill-slate-400 pointer-events-none select-none", 125 ); 126 127 const dragBehavior = drag<SVGGElement, GraphNode>().on("start", (event, d) => { 128 if (!event.active) simulation?.alphaTarget(0.3).restart(); 129 d.fx = d.x; 130 d.fy = d.y; 131 }).on("drag", (event, d) => { 132 d.fx = event.x; 133 d.fy = event.y; 134 }).on("end", (event, d) => { 135 if (!event.active) simulation?.alphaTarget(0); 136 d.fx = null; 137 d.fy = null; 138 }); 139 140 nodeSelection.call(dragBehavior); 141 142 simulation.on("tick", () => { 143 linkSelection.attr("x1", (d) => (d.source as GraphNode).x ?? 0).attr("y1", (d) => (d.source as GraphNode).y ?? 0) 144 .attr("x2", (d) => (d.target as GraphNode).x ?? 0).attr("y2", (d) => (d.target as GraphNode).y ?? 0); 145 146 nodeSelection.attr("transform", (d) => `translate(${d.x ?? 0}, ${d.y ?? 0})`); 147 }); 148 }; 149 150 onMount(() => { 151 initializeGraph(); 152 }); 153 154 createEffect(() => { 155 graphData(); 156 if (simulation) { 157 simulation.stop(); 158 } 159 initializeGraph(); 160 }); 161 162 onCleanup(() => { 163 simulation?.stop(); 164 }); 165 166 return ( 167 <div 168 ref={containerRef} 169 class="w-full h-full bg-slate-900/50 rounded-xl border border-slate-700 overflow-hidden" 170 data-testid="notes-graph"> 171 <svg class="w-full h-full" style={{ "min-height": `${height()}px` }}> 172 <g class="graph-container"> 173 <g class="links" /> 174 <g class="nodes" /> 175 </g> 176 </svg> 177 </div> 178 ); 179};