mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Graph colours by depth, pan button and directory settings
This commit is contained in:
1 parent
57a07874ee
commit
d97f82beb2
10 files changed
+288
-58
No files matched your search
@@ -3,17 +3,23 @@ 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 Purple, files White), joined to its parent
|
||||
// directory, and to every file that references it (Backend/Graph.py).
|
||||
// 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. Drag the background to pan,
|
||||
// scroll to zoom, drag a dot to move it, click one to open it.
|
||||
// links pull them together until it settles. Pan with the button picked in
|
||||
// Settings > Graph, scroll to zoom, drag a dot to move it, click one to open it.
|
||||
|
||||
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"];
|
||||
const ButtonNumbers = { Left: 0, Middle: 1, Right: 2 };
|
||||
|
||||
function ReadRole(Name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(`--${Name}`).trim();
|
||||
@@ -126,7 +132,7 @@ function Tick(Nodes, Links, Alpha) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose }) {
|
||||
export default function GraphPane({ RootName, DirectoryPath, ShowDirectories, PanButton, OnOpenNode, OnClose }) {
|
||||
const [Scope, SetScope] = useState("Root");
|
||||
const [Graph, SetGraph] = useState(null);
|
||||
const [Error, SetError] = useState(null);
|
||||
@@ -147,9 +153,11 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
if (!Graph) return undefined;
|
||||
const Canvas = CanvasRef.current;
|
||||
const Context = Canvas.getContext("2d");
|
||||
const Nodes = Graph.Nodes.map((Node) => ({ ...Node, Degree: 0 }));
|
||||
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) => ById.has(Link.Source) && ById.has(Link.Target)).map((Link) => ({
|
||||
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),
|
||||
@@ -164,6 +172,7 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
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 = {
|
||||
@@ -176,6 +185,13 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
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;
|
||||
Node.Colour = Node.IsDirectory ? DepthColours[Depth % DepthColours.length] : Colours.File;
|
||||
}
|
||||
const PanNumber = ButtonNumbers[PanButton] ?? 1;
|
||||
|
||||
const State = {
|
||||
Zoom: 1,
|
||||
@@ -243,7 +259,7 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
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.IsDirectory ? Colours.Directory : Colours.File;
|
||||
Context.fillStyle = Node.Colour;
|
||||
Context.beginPath();
|
||||
Context.arc(X, Y, Radius, 0, Math.PI * 2);
|
||||
Context.fill();
|
||||
@@ -296,11 +312,15 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
const [X, Y] = LocalPoint(Event);
|
||||
const Node = NodeAt(X, Y);
|
||||
State.Moved = false;
|
||||
if (Node) {
|
||||
State.DownAt = [X, Y];
|
||||
if (Event.button !== 0 || Event.button === PanNumber) {
|
||||
Event.preventDefault(); // no middle-click autoscroll or right-click menu
|
||||
}
|
||||
if (Event.button === 0 && Node) {
|
||||
// Left on a dot: a click opens it, a drag moves it.
|
||||
State.Dragging = Node;
|
||||
Node.Pinned = true;
|
||||
State.Alpha = Math.max(State.Alpha, 0.3);
|
||||
} else {
|
||||
} else if (Event.button === PanNumber) {
|
||||
State.Panning = [X, Y, State.PanX, State.PanY];
|
||||
}
|
||||
}
|
||||
@@ -308,17 +328,19 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
function OnMouseMove(Event) {
|
||||
const [X, Y] = LocalPoint(Event);
|
||||
if (State.Dragging) {
|
||||
const [WX, WY] = ToWorld(X, Y);
|
||||
State.Dragging.x = WX;
|
||||
State.Dragging.y = WY;
|
||||
State.Moved = true;
|
||||
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;
|
||||
State.Moved = State.Moved || Math.hypot(X - StartX, Y - StartY) > 3;
|
||||
return;
|
||||
}
|
||||
State.Hovered = NodeAt(X, Y);
|
||||
@@ -342,6 +364,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
State.PanY = Y - WY * State.Zoom;
|
||||
}
|
||||
|
||||
function OnContextMenu(Event) {
|
||||
if (PanNumber === 2) Event.preventDefault();
|
||||
}
|
||||
|
||||
function OnLeave() {
|
||||
State.Hovered = null;
|
||||
OnMouseUp();
|
||||
@@ -354,6 +380,7 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
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);
|
||||
|
||||
@@ -364,9 +391,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
Canvas.removeEventListener("mousemove", OnMouseMove);
|
||||
window.removeEventListener("mouseup", OnMouseUp);
|
||||
Canvas.removeEventListener("mouseleave", OnLeave);
|
||||
Canvas.removeEventListener("contextmenu", OnContextMenu);
|
||||
Canvas.removeEventListener("wheel", OnWheel);
|
||||
};
|
||||
}, [Graph]);
|
||||
}, [Graph, ShowDirectories, PanButton]);
|
||||
|
||||
const FileCount = Graph ? Graph.Nodes.filter((Node) => !Node.IsDirectory).length : 0;
|
||||
const DirectoryCount = Graph ? Graph.Nodes.length - FileCount : 0;
|
||||
@@ -382,13 +410,20 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
</option>
|
||||
</select>
|
||||
<span className="GraphLegend">
|
||||
<span className="GraphLegendDirectory">●</span> Directory
|
||||
{ShowDirectories && (
|
||||
<>
|
||||
<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">
|
||||
{DirectoryCount} directories, {FileCount} files
|
||||
{ShowDirectories ? `${DirectoryCount} directories, ` : ""}
|
||||
{FileCount} files
|
||||
{Graph.Truncated ? " (cut short: too many to show)" : ""}
|
||||
</span>
|
||||
)}
|
||||
@@ -396,6 +431,10 @@ export default function GraphPane({ RootName, DirectoryPath, OnOpenNode, OnClose
|
||||
{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" : `${PanButton}-drag`} the background to pan, scroll to zoom, drag a dot to move it,
|
||||
click one to open it.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -401,6 +401,25 @@ export default function SettingsPanel({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Graph</div>
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show directories (off: only files and the links between them)</label>
|
||||
<Switch
|
||||
Checked={Settings.GraphShowDirectories}
|
||||
OnChange={(Event) => UpdateField("GraphShowDirectories", Event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Pan with mouse button</label>
|
||||
<select value={Settings.GraphPanButton} onChange={(Event) => UpdateField("GraphPanButton", Event.target.value)}>
|
||||
<option value="Left">Left</option>
|
||||
<option value="Middle">Middle</option>
|
||||
<option value="Right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Confirm before delete</label>
|
||||
<Switch Checked={Settings.ConfirmBeforeDelete} OnChange={(Event) => UpdateField("ConfirmBeforeDelete", Event.target.checked)} />
|
||||
|
||||
Reference in new issue
Block a user