Files
SaraView/Frontend/src/Components/GraphPane.jsx
T

469 lines
16 KiB
React

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 (
<div className="GraphPane">
<div className="PreviewToolbar">
<button onClick={OnClose}>Close</button>
<select value={Scope} onChange={(Event) => SetScope(Event.target.value)}>
<option value="Root">Whole root</option>
<option value="Directory" disabled={!DirectoryPath}>
This directory{DirectoryPath ? ` (${DirectoryPath})` : ""}
</option>
</select>
<span className="GraphLegend">
{ShowDirectories && !ColourByDepth && (
<>
<span className="GraphLegendDirectory">●</span> Directory
</>
)}
{ShowDirectories && ColourByDepth && (
<>
<span className="GraphLegendDirectory">●</span>
<span className="GraphLegendDepth1">●</span>
<span className="GraphLegendDepth2">●</span> Directories by depth
</>
)}
<span className="GraphLegendFile">●</span> File
<span className="GraphLegendReference">─</span> Reference
</span>
{Graph && (
<span className="GraphCount">
{ShowDirectories ? `${DirectoryCount} directories, ` : ""}
{FileCount} files
{Graph.Truncated ? " (cut short: too many to show)" : ""}
</span>
)}
</div>
{Error && <div className="PreviewEmpty">{Error}</div>}
{!Graph && !Error && <div className="PreviewEmpty">Building the graph…</div>}
{Graph && <canvas ref={CanvasRef} className="GraphCanvas" />}
<div className="GraphHint">
{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."}
</div>
</div>
);
}