mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 02:38:11 +02:00
469 lines
16 KiB
React
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>
|
|
);
|
|
}
|