learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
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};