import React, { useEffect, useRef, useState } from "react"; import { FetchGraph } from "../Api.js"; // The graph view, like Obsidian's: every directory and file under a // directory as a dot (directories in the heading colours by depth, files // White), joined to its parent directory, and to every file that references // it (Backend/Graph.py). With directories turned off (Settings > Graph) only // the files and the references between them are left. // The layout is a small force simulation: dots push each other apart and // links pull them together until it settles. One mouse button pans (Settings // > Graph), the other opens the dot it clicks or moves it when dragged. // Scroll zooms. const ChildLength = 50; const ReferenceLength = 80; const Repulsion = 900; const CellSize = 160; // dots further apart than this don't push each other const LabelZoom = 0.7; // below this, only hovered labels are drawn const ClickSlop = 4; // pixels a click may wander and still count as a click // Directories by depth, like the headings: h1 Purple, h2 Blue, ... const DepthRoles = ["Purple", "Blue", "Green", "Yellow", "Orange", "Red"]; // With ShrinkByDepth, directory dots shrink in even steps from their usual // size to a file dot's size (a file with one link) at this depth (the red // ones), and stay that size below it. const ShrinkDepth = 5; const FileDotRadius = 3.8; function ReadRole(Name) { return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim(); } // Starts each dot near its parent, spread around it, so the simulation // begins close to a tree and settles quickly. Seeded, so the same directory // always lays out the same way. function InitialLayout(Nodes, Links) { let Seed = 12345; const Random = () => { Seed = (Seed * 16807) % 2147483647; return Seed / 2147483647; }; const ById = new Map(Nodes.map((Node) => [Node.Id, Node])); const ChildrenOf = new Map(); for (const Link of Links) { if (Link.Kind !== "Child") continue; if (!ChildrenOf.has(Link.Source)) ChildrenOf.set(Link.Source, []); ChildrenOf.get(Link.Source).push(Link.Target); } const Root = Nodes[0]; Root.x = 0; Root.y = 0; const Queue = [Root]; while (Queue.length) { const Parent = Queue.shift(); const Children = ChildrenOf.get(Parent.Id) || []; const Start = Random() * Math.PI * 2; Children.forEach((ChildId, Index) => { const Child = ById.get(ChildId); const Angle = Start + (Index / Children.length) * Math.PI * 2; const Distance = ChildLength * (1 + Random() * 0.5); Child.x = Parent.x + Math.cos(Angle) * Distance; Child.y = Parent.y + Math.sin(Angle) * Distance; Queue.push(Child); }); } for (const Node of Nodes) { Node.vx = 0; Node.vy = 0; if (Node.x === undefined) { Node.x = (Random() - 0.5) * 200; Node.y = (Random() - 0.5) * 200; } } } function Tick(Nodes, Links, Alpha) { // Repulsion between dots in the same or neighbouring grid cells. const Grid = new Map(); for (const Node of Nodes) { const Key = `${Math.floor(Node.x / CellSize)},${Math.floor(Node.y / CellSize)}`; if (!Grid.has(Key)) Grid.set(Key, []); Grid.get(Key).push(Node); } for (const Node of Nodes) { const CellX = Math.floor(Node.x / CellSize); const CellY = Math.floor(Node.y / CellSize); for (let DX = -1; DX <= 1; DX++) { for (let DY = -1; DY <= 1; DY++) { const Others = Grid.get(`${CellX + DX},${CellY + DY}`); if (!Others) continue; for (const Other of Others) { if (Other === Node) continue; let X = Node.x - Other.x; let Y = Node.y - Other.y; let DistanceSquared = X * X + Y * Y; if (DistanceSquared > CellSize * CellSize) continue; if (DistanceSquared < 1) { X = Math.random() - 0.5; Y = Math.random() - 0.5; DistanceSquared = 1; } const Force = (Repulsion * Alpha) / DistanceSquared; Node.vx += X * Force; Node.vy += Y * Force; } } } } // Links pull towards their rest length. for (const Link of Links) { const A = Link.SourceNode; const B = Link.TargetNode; const X = B.x - A.x; const Y = B.y - A.y; const Distance = Math.sqrt(X * X + Y * Y) || 1; const Rest = Link.Kind === "Child" ? ChildLength : ReferenceLength; const Strength = Link.Kind === "Child" ? 0.08 : 0.03; const Pull = ((Distance - Rest) / Distance) * Strength * Alpha; A.vx += X * Pull; A.vy += Y * Pull; B.vx -= X * Pull; B.vy -= Y * Pull; } // A gentle pull to the middle keeps separate clusters from drifting off. for (const Node of Nodes) { Node.vx -= Node.x * 0.002 * Alpha; Node.vy -= Node.y * 0.002 * Alpha; if (Node.Pinned) { Node.vx = 0; Node.vy = 0; continue; } Node.vx *= 0.6; Node.vy *= 0.6; Node.x += Node.vx; Node.y += Node.vy; } } export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, PanButton, ColourByDepth, ShrinkByDepth, OnOpenNode, OnClose, }) { const [Scope, SetScope] = useState("Root"); const [Graph, SetGraph] = useState(null); const [Error, SetError] = useState(null); const CanvasRef = useRef(null); const StateRef = useRef(null); const ScopePath = Scope === "Root" ? "" : DirectoryPath; useEffect(() => { SetGraph(null); SetError(null); FetchGraph(RootName, ScopePath) .then(SetGraph) .catch((Failure) => SetError(Failure.message)); }, [RootName, ScopePath]); useEffect(() => { if (!Graph) return undefined; const Canvas = CanvasRef.current; const Context = Canvas.getContext("2d"); const Nodes = Graph.Nodes.filter((Node) => ShowDirectories || !Node.IsDirectory).map((Node) => ({ ...Node, Degree: 0 })); const ById = new Map(Nodes.map((Node) => [Node.Id, Node])); const Links = Graph.Links.filter( (Link) => (ShowDirectories || Link.Kind === "Reference") && ById.has(Link.Source) && ById.has(Link.Target), ).map((Link) => ({ ...Link, SourceNode: ById.get(Link.Source), TargetNode: ById.get(Link.Target), })); const Neighbours = new Map(Nodes.map((Node) => [Node, new Set()])); for (const Link of Links) { Link.SourceNode.Degree += 1; Link.TargetNode.Degree += 1; Neighbours.get(Link.SourceNode).add(Link.TargetNode); Neighbours.get(Link.TargetNode).add(Link.SourceNode); } for (const Node of Nodes) { Node.Radius = Node.IsDirectory ? 5 + Math.sqrt(Node.Degree) * 1.6 : 3 + Math.sqrt(Node.Degree) * 0.8; } if (!Nodes.length) return undefined; InitialLayout(Nodes, Links); const Colours = { Background: ReadRole("Background"), Directory: ReadRole("Purple"), File: ReadRole("White"), Child: ReadRole("Border"), Reference: ReadRole("Blue"), Text: ReadRole("Text"), Muted: ReadRole("TextMuted"), }; const FontFamily = ReadRole("FontFamily"); const DepthColours = DepthRoles.map(ReadRole); const BaseDepth = Graph.Path ? Graph.Path.split("/").length : 0; for (const Node of Nodes) { const Depth = (Node.Id ? Node.Id.split("/").length : 0) - BaseDepth; if (!Node.IsDirectory) { Node.Colour = Colours.File; continue; } Node.Colour = ColourByDepth ? DepthColours[Depth % DepthColours.length] : Colours.Directory; if (ShrinkByDepth) { const Step = Math.min(Depth, ShrinkDepth) / ShrinkDepth; Node.Radius += (FileDotRadius - Node.Radius) * Step; } } // Anything but "Left" (including an old "Middle") pans with Right. const PanNumber = PanButton === "Left" ? 0 : 2; const OpenNumber = PanNumber === 0 ? 2 : 0; const State = { Zoom: 1, PanX: 0, PanY: 0, Alpha: 1, Hovered: null, Dragging: null, Panning: null, Moved: false, Frame: 0, Width: 0, Height: 0, }; StateRef.current = State; function ToScreen(Node) { return [Node.x * State.Zoom + State.PanX, Node.y * State.Zoom + State.PanY]; } function ToWorld(X, Y) { return [(X - State.PanX) / State.Zoom, (Y - State.PanY) / State.Zoom]; } function Resize() { const Rect = Canvas.getBoundingClientRect(); const Ratio = window.devicePixelRatio || 1; const First = State.Width === 0; State.Width = Rect.width; State.Height = Rect.height; Canvas.width = Math.round(Rect.width * Ratio); Canvas.height = Math.round(Rect.height * Ratio); Context.setTransform(Ratio, 0, 0, Ratio, 0, 0); if (First) { State.PanX = Rect.width / 2; State.PanY = Rect.height / 2; } } function Draw() { const Focus = State.Dragging || State.Hovered; const Lit = Focus ? Neighbours.get(Focus) : null; Context.fillStyle = Colours.Background; Context.fillRect(0, 0, State.Width, State.Height); for (const Link of Links) { const Touching = Focus && (Link.SourceNode === Focus || Link.TargetNode === Focus); Context.globalAlpha = Focus && !Touching ? 0.15 : Link.Kind === "Child" ? 0.8 : 0.9; Context.strokeStyle = Touching ? Colours.Directory : Link.Kind === "Child" ? Colours.Child : Colours.Reference; Context.lineWidth = Touching ? 1.5 : 1; const [X1, Y1] = ToScreen(Link.SourceNode); const [X2, Y2] = ToScreen(Link.TargetNode); Context.beginPath(); Context.moveTo(X1, Y1); Context.lineTo(X2, Y2); Context.stroke(); } Context.font = `12px ${FontFamily}`; Context.textAlign = "center"; Context.textBaseline = "top"; for (const Node of Nodes) { const IsLit = !Focus || Node === Focus || Lit.has(Node); const [X, Y] = ToScreen(Node); if (X < -50 || Y < -50 || X > State.Width + 50 || Y > State.Height + 50) continue; const Radius = Math.max(2, Node.Radius * Math.sqrt(State.Zoom)); Context.globalAlpha = IsLit ? 1 : 0.2; Context.fillStyle = Node.Colour; Context.beginPath(); Context.arc(X, Y, Radius, 0, Math.PI * 2); Context.fill(); if (Node === Focus) { Context.strokeStyle = Colours.Directory; Context.lineWidth = 2; Context.beginPath(); Context.arc(X, Y, Radius + 3, 0, Math.PI * 2); Context.stroke(); } const ShowLabel = Focus ? IsLit : State.Zoom >= LabelZoom || Node.IsDirectory; if (ShowLabel) { Context.fillStyle = Node === Focus ? Colours.Text : Colours.Muted; Context.fillText(Node.Name, X, Y + Radius + 3); } } Context.globalAlpha = 1; } function Loop() { if (State.Alpha > 0.005 || State.Dragging) { Tick(Nodes, Links, Math.max(State.Alpha, State.Dragging ? 0.1 : 0)); State.Alpha *= 0.985; } Draw(); State.Frame = requestAnimationFrame(Loop); } function NodeAt(X, Y) { let Best = null; let BestDistance = Infinity; for (const Node of Nodes) { const [NX, NY] = ToScreen(Node); const Distance = Math.hypot(NX - X, NY - Y); const Reach = Math.max(6, Node.Radius * Math.sqrt(State.Zoom) + 4); if (Distance < Reach && Distance < BestDistance) { Best = Node; BestDistance = Distance; } } return Best; } function LocalPoint(Event) { const Rect = Canvas.getBoundingClientRect(); return [Event.clientX - Rect.left, Event.clientY - Rect.top]; } function OnMouseDown(Event) { const [X, Y] = LocalPoint(Event); const Node = NodeAt(X, Y); State.Moved = false; State.DownAt = [X, Y]; Event.preventDefault(); if (Event.button === OpenNumber && Node) { // The open button on a dot: a click opens it, a drag moves it. State.Dragging = Node; Node.Pinned = true; } else if (Event.button === PanNumber) { State.Panning = [X, Y, State.PanX, State.PanY]; } } function OnMouseMove(Event) { const [X, Y] = LocalPoint(Event); if (State.Dragging) { State.Moved = State.Moved || Math.hypot(X - State.DownAt[0], Y - State.DownAt[1]) > ClickSlop; if (State.Moved) { const [WX, WY] = ToWorld(X, Y); State.Dragging.x = WX; State.Dragging.y = WY; State.Alpha = Math.max(State.Alpha, 0.3); } return; } if (State.Panning) { const [StartX, StartY, PanX, PanY] = State.Panning; State.PanX = PanX + X - StartX; State.PanY = PanY + Y - StartY; return; } State.Hovered = NodeAt(X, Y); Canvas.style.cursor = State.Hovered ? "pointer" : "grab"; } function OnMouseUp() { const Clicked = State.Dragging && !State.Moved ? State.Dragging : null; if (State.Dragging) State.Dragging.Pinned = false; State.Dragging = null; State.Panning = null; if (Clicked) OnOpenNode(Clicked); } function OnWheel(Event) { Event.preventDefault(); const [X, Y] = LocalPoint(Event); const [WX, WY] = ToWorld(X, Y); State.Zoom = Math.min(5, Math.max(0.1, State.Zoom * Math.exp(-Event.deltaY * 0.0015))); State.PanX = X - WX * State.Zoom; State.PanY = Y - WY * State.Zoom; } function OnContextMenu(Event) { Event.preventDefault(); // right-click pans or opens here, never the browser menu } function OnLeave() { State.Hovered = null; OnMouseUp(); } Resize(); const Observer = new ResizeObserver(Resize); Observer.observe(Canvas); Canvas.addEventListener("mousedown", OnMouseDown); Canvas.addEventListener("mousemove", OnMouseMove); window.addEventListener("mouseup", OnMouseUp); Canvas.addEventListener("mouseleave", OnLeave); Canvas.addEventListener("contextmenu", OnContextMenu); Canvas.addEventListener("wheel", OnWheel, { passive: false }); State.Frame = requestAnimationFrame(Loop); return () => { cancelAnimationFrame(State.Frame); Observer.disconnect(); Canvas.removeEventListener("mousedown", OnMouseDown); Canvas.removeEventListener("mousemove", OnMouseMove); window.removeEventListener("mouseup", OnMouseUp); Canvas.removeEventListener("mouseleave", OnLeave); Canvas.removeEventListener("contextmenu", OnContextMenu); Canvas.removeEventListener("wheel", OnWheel); }; }, [Graph, ShowDirectories, PanButton, ColourByDepth, ShrinkByDepth]); const FileCount = Graph ? Graph.Nodes.filter((Node) => !Node.IsDirectory).length : 0; const DirectoryCount = Graph ? Graph.Nodes.length - FileCount : 0; return (
{ShowDirectories && !ColourByDepth && ( <> ● Directory )} {ShowDirectories && ColourByDepth && ( <> ● ● ● Directories by depth )} ● File ─ Reference {Graph && ( {ShowDirectories ? `${DirectoryCount} directories, ` : ""} {FileCount} files {Graph.Truncated ? " (cut short: too many to show)" : ""} )}
{Error &&
{Error}
} {!Graph && !Error &&
Building the graph…
} {Graph && }
{PanButton === "Left" ? "Drag to pan, right-click a dot to open it (right-drag moves it), scroll to zoom." : "Right-drag to pan, click a dot to open it (drag moves it), scroll to zoom."}
); }