mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
Add graph view
This commit is contained in:
1 parent
f851ecd820
commit
57a07874ee
12 files changed
+1152
-2
No files matched your search
@@ -0,0 +1,160 @@
|
||||
"""The graph view's data (like Obsidian's graph): every directory and file
|
||||
under one directory, joined to its parent directory, plus a link from a
|
||||
file to every file or directory it references.
|
||||
|
||||
References are read from Markdown and text files:
|
||||
- Markdown links and images: [Text](Other.md), 
|
||||
- Wiki links: [[Other]], [[Other|Shown]], ![[Cat.png]] (matched by name,
|
||||
like Obsidian, preferring the nearest one)
|
||||
- Portals: {Path ***Name}, {Path head 3}, {Path '12'}
|
||||
|
||||
Listing follows the same rules as /api/tree (hidden files, .sv_ notes,
|
||||
locks), so the graph never shows what the file list wouldn't. The desktop
|
||||
client builds the same graph for local files (Client/Graph.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import unquote
|
||||
|
||||
from Core import NormalizeRelativePath, ResolvePath
|
||||
from Explorer import ListDirectory
|
||||
from Locks import IsPathAccessible
|
||||
from Preview import ReadTextFile
|
||||
from VarFunctions import MarkerPattern
|
||||
|
||||
# Past this, the graph is cut short (and says so) rather than taking ages
|
||||
# to build and draw.
|
||||
MaxNodes = 2000
|
||||
# Bigger files aren't read for references.
|
||||
MaxReadBytes = 1_000_000
|
||||
ReferenceFileTypes = {"markdown", "text"}
|
||||
|
||||
FencedCodePattern = re.compile(r"^ {0,3}(```|~~~).*?^ {0,3}\1", re.MULTILINE | re.DOTALL)
|
||||
MarkdownLinkPattern = re.compile(r"!?\[[^\]\n]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
|
||||
WikiLinkPattern = re.compile(r"!?\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
|
||||
ExternalPattern = re.compile(r"^([a-z][a-z0-9+.-]*:|//|#)", re.IGNORECASE)
|
||||
|
||||
|
||||
def FindReferences(Content: str) -> list[tuple[str, str]]:
|
||||
"""("Path", relative path) and ("Name", wiki name) references in a file.
|
||||
Code blocks are skipped, since links in them are examples."""
|
||||
Content = FencedCodePattern.sub("", Content)
|
||||
Found: list[tuple[str, str]] = []
|
||||
for Match in MarkdownLinkPattern.finditer(Content):
|
||||
Target = Match.group(1)
|
||||
if ExternalPattern.match(Target):
|
||||
continue
|
||||
Target = unquote(Target.split("#", 1)[0].split("?", 1)[0])
|
||||
if Target:
|
||||
Found.append(("Path", Target))
|
||||
for Match in WikiLinkPattern.finditer(Content):
|
||||
Found.append(("Name", Match.group(1).strip()))
|
||||
for Match in MarkerPattern.finditer(Content):
|
||||
Target = Match.group("CrossPath") or Match.group("PipePath") or Match.group("LinePath")
|
||||
if Target:
|
||||
Found.append(("Path", Target))
|
||||
return Found
|
||||
|
||||
|
||||
def ResolveReferences(
|
||||
References: list[tuple[str, str]], FileId: str, Ids: set[str], ByName: dict[str, list[str]]
|
||||
) -> set[str]:
|
||||
"""Node ids (paths relative to the root) that a file's references point to."""
|
||||
Directory = FileId.rsplit("/", 1)[0] if "/" in FileId else ""
|
||||
Targets: set[str] = set()
|
||||
for Kind, Target in References:
|
||||
if Kind == "Path":
|
||||
Base = "" if Target.startswith("/") else Directory
|
||||
Resolved = NormalizeRelativePath(Base, Target)
|
||||
if Resolved in Ids:
|
||||
Targets.add(Resolved)
|
||||
continue
|
||||
# Wiki link: a path ("Sub/Note"), else a name anywhere, nearest first.
|
||||
for Candidate in (Target, f"{Target}.md"):
|
||||
for Base in (Directory, ""):
|
||||
Resolved = NormalizeRelativePath(Base, Candidate)
|
||||
if Resolved in Ids:
|
||||
Targets.add(Resolved)
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
else:
|
||||
Matches = ByName.get(Target.rsplit("/", 1)[-1].lower(), [])
|
||||
if Matches:
|
||||
Targets.add(min(Matches, key=lambda Id: (_Distance(Directory, Id), Id)))
|
||||
Targets.discard(FileId)
|
||||
return Targets
|
||||
|
||||
|
||||
def _Distance(Directory: str, Id: str) -> int:
|
||||
"""How many directories apart a file in Directory and Id are."""
|
||||
Here = Directory.split("/") if Directory else []
|
||||
There = Id.split("/")[:-1]
|
||||
Shared = 0
|
||||
for A, B in zip(Here, There):
|
||||
if A != B:
|
||||
break
|
||||
Shared += 1
|
||||
return len(Here) + len(There) - 2 * Shared
|
||||
|
||||
|
||||
def IndexByName(Nodes: list[dict]) -> dict[str, list[str]]:
|
||||
"""Files by lowercased name and by name without its extension, for wiki links."""
|
||||
ByName: dict[str, list[str]] = {}
|
||||
for Node in Nodes:
|
||||
if Node["IsDirectory"]:
|
||||
continue
|
||||
Name = Node["Name"].lower()
|
||||
ByName.setdefault(Name, []).append(Node["Id"])
|
||||
if "." in Name:
|
||||
ByName.setdefault(Name.rsplit(".", 1)[0], []).append(Node["Id"])
|
||||
return ByName
|
||||
|
||||
|
||||
def BuildGraph(
|
||||
Root: str, Path: str, ShowHidden: bool, ShowSvFiles: bool, Username: str | None, UserGroups: list[str]
|
||||
) -> dict:
|
||||
Path = NormalizeRelativePath("", Path)
|
||||
Nodes: list[dict] = [{"Id": Path, "Name": Path.rsplit("/", 1)[-1] if Path else Root,
|
||||
"IsDirectory": True, "FileType": None}]
|
||||
Links: list[dict] = []
|
||||
Truncated = False
|
||||
Pending = [Path]
|
||||
|
||||
while Pending and not Truncated:
|
||||
Directory = Pending.pop(0)
|
||||
try:
|
||||
Entries = ListDirectory(Root, Directory, ShowHidden, ShowSvFiles)
|
||||
except (ValueError, NotADirectoryError, FileNotFoundError, PermissionError):
|
||||
continue
|
||||
for Entry in Entries:
|
||||
Id = f"{Directory}/{Entry['Name']}" if Directory else Entry["Name"]
|
||||
if not IsPathAccessible(Root, Id, Username, UserGroups):
|
||||
continue
|
||||
if len(Nodes) >= MaxNodes:
|
||||
Truncated = True
|
||||
break
|
||||
Nodes.append({"Id": Id, "Name": Entry["Name"], "IsDirectory": Entry["IsDirectory"],
|
||||
"FileType": Entry.get("FileType")})
|
||||
Links.append({"Source": Directory, "Target": Id, "Kind": "Child"})
|
||||
if Entry["IsDirectory"]:
|
||||
Pending.append(Id)
|
||||
|
||||
Ids = {Node["Id"] for Node in Nodes}
|
||||
ByName = IndexByName(Nodes)
|
||||
for Node in Nodes:
|
||||
if Node["IsDirectory"] or Node["FileType"] not in ReferenceFileTypes:
|
||||
continue
|
||||
try:
|
||||
FilePath = ResolvePath(Root, Node["Id"])
|
||||
if FilePath.stat().st_size > MaxReadBytes:
|
||||
continue
|
||||
Content = ReadTextFile(FilePath)
|
||||
except (ValueError, OSError, UnicodeDecodeError):
|
||||
continue
|
||||
for Target in sorted(ResolveReferences(FindReferences(Content), Node["Id"], Ids, ByName)):
|
||||
Links.append({"Source": Node["Id"], "Target": Target, "Kind": "Reference"})
|
||||
|
||||
return {"Root": Root, "Path": Path, "Nodes": Nodes, "Links": Links, "Truncated": Truncated}
|
||||
@@ -60,6 +60,7 @@ from Locks import (
|
||||
RequirePathAccessible,
|
||||
SetLock,
|
||||
)
|
||||
from Graph import BuildGraph
|
||||
from Preview import GetMimeType, ReadTextFile, StreamBinaryFile, TextFileTypes
|
||||
from Settings import (
|
||||
DefaultSettings,
|
||||
@@ -406,6 +407,27 @@ def GetTreeEndpoint(
|
||||
]
|
||||
|
||||
|
||||
@Application.get("/api/graph")
|
||||
def GetGraphEndpoint(
|
||||
Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUserOrGuest)
|
||||
) -> dict:
|
||||
"""Directories, files and the links between them under Path (see Graph.py)."""
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
UserSettings = DefaultSettings if CurrentUser["Username"] is None else LoadSettings(CurrentUser["Username"])
|
||||
|
||||
try:
|
||||
return BuildGraph(
|
||||
Root,
|
||||
Path,
|
||||
UserSettings.get("ShowHiddenFolders", False),
|
||||
UserSettings.get("ShowSvFiles", False),
|
||||
CurrentUser["Username"],
|
||||
CurrentUser.get("Groups", []),
|
||||
)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
|
||||
@Application.post("/api/note")
|
||||
def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
@@ -13,6 +13,7 @@ import flet as ft
|
||||
from App import App
|
||||
from Core import ChipButtonStyle, FileIcons, HumanSize, OpenExternally, Palette
|
||||
from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError
|
||||
from Graph import GraphView
|
||||
from Viewer import FileView
|
||||
|
||||
DoubleClickWindow = 0.4 # seconds
|
||||
@@ -41,6 +42,7 @@ class Browser(ft.Column):
|
||||
self.BackStack: list[tuple[Optional[str], str]] = []
|
||||
self.ForwardStack: list[tuple[Optional[str], str]] = []
|
||||
self.OpenView: Optional[FileView] = None
|
||||
self.GraphView: Optional[GraphView] = None
|
||||
self.ShortcutsPaused = False # a text field has focus
|
||||
self.LastClickName: Optional[str] = None
|
||||
self.LastClickTime = 0.0
|
||||
@@ -71,6 +73,8 @@ class Browser(ft.Column):
|
||||
self.NewFileButton,
|
||||
self.NewDirectoryButton,
|
||||
self.UploadButton,
|
||||
ft.IconButton(icon=ft.Icons.HUB_OUTLINED, icon_color=Palette.Mauve, tooltip="Graph (Ctrl+G)",
|
||||
on_click=lambda e: self.ShowGraph()),
|
||||
ft.IconButton(icon=ft.Icons.REFRESH, icon_color=Palette.Mauve, tooltip="Refresh (Ctrl+R)",
|
||||
on_click=lambda e: self.Refresh()),
|
||||
self.ConnectionButton,
|
||||
@@ -168,6 +172,7 @@ class Browser(ft.Column):
|
||||
self.Root, self.Path = Root, RelativePath
|
||||
self.SelectedName = None
|
||||
self.OpenView = None
|
||||
self.GraphView = None
|
||||
self.ShortcutsPaused = False
|
||||
self._LoadEntries()
|
||||
self._ShowList()
|
||||
@@ -194,6 +199,9 @@ class Browser(ft.Column):
|
||||
def Refresh(self) -> None:
|
||||
if self.OpenView is not None:
|
||||
return # reloading would throw away edits; close the file first
|
||||
if self.GraphView is not None:
|
||||
self.GraphView.Reload()
|
||||
return
|
||||
self._LoadEntries()
|
||||
self._ShowList()
|
||||
|
||||
@@ -358,6 +366,33 @@ class Browser(ft.Column):
|
||||
self.EntryRows[RowName].update()
|
||||
self.SelectedName = Name
|
||||
|
||||
def ShowGraph(self) -> None:
|
||||
"""The graph of the root being browsed, in place of the file list."""
|
||||
if self.OpenView is not None:
|
||||
self.OpenView.RequestClose(Then=self.ShowGraph)
|
||||
return
|
||||
Root = self.Root
|
||||
if Root is None:
|
||||
Roots = self._Roots()
|
||||
if not Roots:
|
||||
return
|
||||
Root = Roots[0]
|
||||
self.GraphView = GraphView(self.TheApp, Root, self.Path if Root == self.Root else "",
|
||||
OnOpenNode=lambda Node: self._OpenGraphNode(Root, Node), OnClose=self.CloseGraph)
|
||||
self.FileArea.content = self.GraphView
|
||||
self.FileArea.update()
|
||||
|
||||
def CloseGraph(self) -> None:
|
||||
self.GraphView = None
|
||||
self._ShowList()
|
||||
|
||||
def _OpenGraphNode(self, Root: str, Node: dict) -> None:
|
||||
if Node["IsDirectory"]:
|
||||
self.NavigateTo(Root, Node["Id"])
|
||||
else:
|
||||
self.GraphView = None
|
||||
self.OpenFile(Root, Node["Id"])
|
||||
|
||||
def OpenFile(self, Root: str, RelativePath: str) -> None:
|
||||
"""Opens a file from anywhere (the sidebar tree): goes to its directory
|
||||
first, so Back and Esc land in the list it's in."""
|
||||
@@ -626,6 +661,12 @@ class Browser(ft.Column):
|
||||
if not View.HandleEscape():
|
||||
View.RequestClose()
|
||||
return
|
||||
if Event.key == "Escape" and self.GraphView is not None:
|
||||
self.CloseGraph()
|
||||
return
|
||||
if Event.key == "G" and Event.ctrl and not self.ShortcutsPaused:
|
||||
self.CloseGraph() if self.GraphView is not None else self.ShowGraph()
|
||||
return
|
||||
if self.ShortcutsPaused:
|
||||
return
|
||||
|
||||
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
"""The graph view, like Obsidian's (and the web view's GraphPane.jsx): every
|
||||
directory and file under a directory as a dot (directories Mauve, files
|
||||
White), joined to its parent directory and to every file that references it.
|
||||
|
||||
On a server the graph comes from /api/graph; locally BuildLocalGraph makes
|
||||
the same one with the same reference rules as Backend/Graph.py. The layout is
|
||||
the web view's force simulation (same constants), in numpy, drawn as it
|
||||
settles. Scroll or pinch to zoom, drag to pan, hover a dot to light up its
|
||||
links, click it to open it."""
|
||||
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
import flet as ft
|
||||
import flet.canvas as cv
|
||||
import numpy as np
|
||||
|
||||
import Core
|
||||
from App import App
|
||||
from Core import Palette
|
||||
from Sources import JoinPath, NormalizeRelativePath, SourceError
|
||||
|
||||
MaxNodes = 2000
|
||||
MaxReadBytes = 1_000_000
|
||||
ReferenceFileTypes = {"markdown", "text"}
|
||||
|
||||
# Same patterns as Backend/Graph.py.
|
||||
FencedCodePattern = re.compile(r"^ {0,3}(```|~~~).*?^ {0,3}\1", re.MULTILINE | re.DOTALL)
|
||||
MarkdownLinkPattern = re.compile(r"!?\[[^\]\n]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
|
||||
WikiLinkPattern = re.compile(r"!?\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]")
|
||||
ExternalPattern = re.compile(r"^([a-z][a-z0-9+.-]*:|//|#)", re.IGNORECASE)
|
||||
# The path part of a portal: {Path ***Name}, {Path head 3}, {Path '12'}
|
||||
# (Backend/VarFunctions.py's MarkerPattern, just the parts with a path).
|
||||
PortalPathPattern = re.compile(r"\{([^\s{}'*]+)\s+(?:\*\*\*[^\s*{}]+|(?:head|tail)\s+\d+|'\d+')[^{}]*\}")
|
||||
|
||||
# Layout, same as GraphPane.jsx.
|
||||
ChildLength = 50
|
||||
ReferenceLength = 80
|
||||
Repulsion = 900
|
||||
CellSize = 160
|
||||
TicksPerDraw = 12
|
||||
|
||||
|
||||
# --- Building the graph (local files) -------------------------------------
|
||||
|
||||
def FindReferences(Content: str) -> list[tuple[str, str]]:
|
||||
Content = FencedCodePattern.sub("", Content)
|
||||
Found: list[tuple[str, str]] = []
|
||||
for Match in MarkdownLinkPattern.finditer(Content):
|
||||
Target = Match.group(1)
|
||||
if ExternalPattern.match(Target):
|
||||
continue
|
||||
Target = unquote(Target.split("#", 1)[0].split("?", 1)[0])
|
||||
if Target:
|
||||
Found.append(("Path", Target))
|
||||
for Match in WikiLinkPattern.finditer(Content):
|
||||
Found.append(("Name", Match.group(1).strip()))
|
||||
for Match in PortalPathPattern.finditer(Content):
|
||||
Found.append(("Path", Match.group(1)))
|
||||
return Found
|
||||
|
||||
|
||||
def _Distance(Directory: str, Id: str) -> int:
|
||||
Here = Directory.split("/") if Directory else []
|
||||
There = Id.split("/")[:-1]
|
||||
Shared = 0
|
||||
for A, B in zip(Here, There):
|
||||
if A != B:
|
||||
break
|
||||
Shared += 1
|
||||
return len(Here) + len(There) - 2 * Shared
|
||||
|
||||
|
||||
def ResolveReferences(References: list[tuple[str, str]], FileId: str, Ids: set[str],
|
||||
ByName: dict[str, list[str]]) -> set[str]:
|
||||
Directory = FileId.rsplit("/", 1)[0] if "/" in FileId else ""
|
||||
Targets: set[str] = set()
|
||||
for Kind, Target in References:
|
||||
if Kind == "Path":
|
||||
Resolved = NormalizeRelativePath("" if Target.startswith("/") else Directory, Target)
|
||||
if Resolved in Ids:
|
||||
Targets.add(Resolved)
|
||||
continue
|
||||
Found = next((NormalizeRelativePath(Base, Candidate)
|
||||
for Candidate in (Target, f"{Target}.md") for Base in (Directory, "")
|
||||
if NormalizeRelativePath(Base, Candidate) in Ids), None)
|
||||
if Found is None:
|
||||
Matches = ByName.get(Target.rsplit("/", 1)[-1].lower(), [])
|
||||
if Matches:
|
||||
Found = min(Matches, key=lambda Id: (_Distance(Directory, Id), Id))
|
||||
if Found is not None:
|
||||
Targets.add(Found)
|
||||
Targets.discard(FileId)
|
||||
return Targets
|
||||
|
||||
|
||||
def BuildLocalGraph(Source, Root: str, Path: str) -> dict:
|
||||
"""Backend/Graph.py's BuildGraph, through a Source's own listing (so the
|
||||
same files are hidden as in the file list)."""
|
||||
Nodes = [{"Id": Path, "Name": Path.rsplit("/", 1)[-1] if Path else Root, "IsDirectory": True, "FileType": None}]
|
||||
Links: list[dict] = []
|
||||
Truncated = False
|
||||
Pending = [Path]
|
||||
while Pending and not Truncated:
|
||||
Directory = Pending.pop(0)
|
||||
try:
|
||||
Items = Source.List(Root, Directory)
|
||||
except SourceError:
|
||||
continue
|
||||
for Item in Items:
|
||||
if len(Nodes) >= MaxNodes:
|
||||
Truncated = True
|
||||
break
|
||||
Id = JoinPath(Directory, Item.Name)
|
||||
Nodes.append({"Id": Id, "Name": Item.Name, "IsDirectory": Item.IsDirectory, "FileType": Item.FileType})
|
||||
Links.append({"Source": Directory, "Target": Id, "Kind": "Child"})
|
||||
if Item.IsDirectory:
|
||||
Pending.append(Id)
|
||||
|
||||
Ids = {Node["Id"] for Node in Nodes}
|
||||
ByName: dict[str, list[str]] = {}
|
||||
for Node in Nodes:
|
||||
if not Node["IsDirectory"]:
|
||||
Name = Node["Name"].lower()
|
||||
ByName.setdefault(Name, []).append(Node["Id"])
|
||||
if "." in Name:
|
||||
ByName.setdefault(Name.rsplit(".", 1)[0], []).append(Node["Id"])
|
||||
for Node in Nodes:
|
||||
if Node["IsDirectory"] or Node["FileType"] not in ReferenceFileTypes:
|
||||
continue
|
||||
try:
|
||||
if Source.LocalFile(Root, Node["Id"]).stat().st_size > MaxReadBytes:
|
||||
continue
|
||||
Content = Source.ReadText(Root, Node["Id"])
|
||||
except (SourceError, OSError):
|
||||
continue
|
||||
for Target in sorted(ResolveReferences(FindReferences(Content), Node["Id"], Ids, ByName)):
|
||||
Links.append({"Source": Node["Id"], "Target": Target, "Kind": "Reference"})
|
||||
return {"Root": Root, "Path": Path, "Nodes": Nodes, "Links": Links, "Truncated": Truncated}
|
||||
|
||||
|
||||
# --- Layout ----------------------------------------------------------------
|
||||
|
||||
class Layout:
|
||||
def __init__(self, Graph: dict):
|
||||
self.Nodes = Graph["Nodes"]
|
||||
Index = {Node["Id"]: Number for Number, Node in enumerate(self.Nodes)}
|
||||
Links = [Link for Link in Graph["Links"] if Link["Source"] in Index and Link["Target"] in Index]
|
||||
self.Kinds = [Link["Kind"] for Link in Links]
|
||||
self.Sources = np.array([Index[Link["Source"]] for Link in Links], dtype=int)
|
||||
self.Targets = np.array([Index[Link["Target"]] for Link in Links], dtype=int)
|
||||
IsChild = np.array([Kind == "Child" for Kind in self.Kinds], dtype=bool)
|
||||
self.Rest = np.where(IsChild, ChildLength, ReferenceLength).astype(float)
|
||||
self.Strength = np.where(IsChild, 0.08, 0.03)
|
||||
Count = len(self.Nodes)
|
||||
self.Degree = np.bincount(np.concatenate([self.Sources, self.Targets]), minlength=Count) if Links else np.zeros(Count)
|
||||
self.Neighbours: list[set[int]] = [set() for _ in range(Count)]
|
||||
for A, B in zip(self.Sources, self.Targets):
|
||||
self.Neighbours[A].add(int(B))
|
||||
self.Neighbours[B].add(int(A))
|
||||
self.Position = self._Initial(Links, Index)
|
||||
self.Velocity = np.zeros_like(self.Position)
|
||||
self.Alpha = 1.0
|
||||
|
||||
def _Initial(self, Links: list[dict], Index: dict[str, int]) -> np.ndarray:
|
||||
"""Each dot near its parent, spread around it (seeded, like the web view)."""
|
||||
Random = np.random.default_rng(12345)
|
||||
Position = np.zeros((len(self.Nodes), 2))
|
||||
Placed = np.zeros(len(self.Nodes), dtype=bool)
|
||||
Placed[0] = True
|
||||
Children: dict[int, list[int]] = {}
|
||||
for Link in Links:
|
||||
if Link["Kind"] == "Child":
|
||||
Children.setdefault(Index[Link["Source"]], []).append(Index[Link["Target"]])
|
||||
Queue = [0]
|
||||
while Queue:
|
||||
Parent = Queue.pop(0)
|
||||
Kids = Children.get(Parent, [])
|
||||
Start = Random.random() * math.tau
|
||||
for Number, Child in enumerate(Kids):
|
||||
Angle = Start + Number / len(Kids) * math.tau
|
||||
Distance = ChildLength * (1 + Random.random() * 0.5)
|
||||
Position[Child] = Position[Parent] + (math.cos(Angle) * Distance, math.sin(Angle) * Distance)
|
||||
Placed[Child] = True
|
||||
Queue.append(Child)
|
||||
Loose = ~Placed
|
||||
Position[Loose] = (Random.random((Loose.sum(), 2)) - 0.5) * 200
|
||||
return Position
|
||||
|
||||
@property
|
||||
def Settled(self) -> bool:
|
||||
return self.Alpha <= 0.005
|
||||
|
||||
def Tick(self) -> None:
|
||||
Position, Velocity, Alpha = self.Position, self.Velocity, self.Alpha
|
||||
Count = len(Position)
|
||||
for Start in range(0, Count, 256): # in chunks, so memory stays small for big graphs
|
||||
Delta = Position[Start:Start + 256, None, :] - Position[None, :, :]
|
||||
DistanceSquared = np.maximum((Delta ** 2).sum(-1), 1.0)
|
||||
Force = Repulsion * Alpha / DistanceSquared
|
||||
Force[DistanceSquared > CellSize * CellSize] = 0
|
||||
Velocity[Start:Start + 256] += (Delta * Force[..., None]).sum(1)
|
||||
if len(self.Sources):
|
||||
Delta = Position[self.Targets] - Position[self.Sources]
|
||||
Distance = np.maximum(np.sqrt((Delta ** 2).sum(-1)), 1e-6)
|
||||
Pull = ((Distance - self.Rest) / Distance * self.Strength * Alpha)[:, None] * Delta
|
||||
np.add.at(Velocity, self.Sources, Pull)
|
||||
np.add.at(Velocity, self.Targets, -Pull)
|
||||
Velocity -= Position * 0.002 * Alpha
|
||||
Velocity *= 0.6
|
||||
Position += Velocity
|
||||
self.Alpha *= 0.985
|
||||
|
||||
|
||||
# --- View --------------------------------------------------------------------
|
||||
|
||||
class GraphView(ft.Container):
|
||||
def __init__(self, TheApp: App, Root: str, DirectoryPath: str,
|
||||
OnOpenNode: Callable[[dict], None], OnClose: Callable[[], None]):
|
||||
self.TheApp = TheApp
|
||||
self.Root = Root
|
||||
self.DirectoryPath = DirectoryPath
|
||||
self.OnOpenNode = OnOpenNode
|
||||
self.Layout: Optional[Layout] = None
|
||||
self.Size = (800.0, 600.0)
|
||||
self.Screen = np.zeros((0, 2)) # each dot's position on the canvas
|
||||
self.Radius = np.zeros(0)
|
||||
self.Hovered: Optional[int] = None
|
||||
self.Closed = False
|
||||
self.Generation = 0 # bumps when the scope changes, so an old layout thread stops
|
||||
|
||||
self.ScopePicker = ft.Dropdown(
|
||||
value="Root",
|
||||
options=[ft.DropdownOption(key="Root", text="Whole root"),
|
||||
ft.DropdownOption(key="Directory", text=f"This directory ({DirectoryPath})" if DirectoryPath else "This directory",
|
||||
disabled=not DirectoryPath)],
|
||||
dense=True, width=260, text_size=13,
|
||||
on_select=lambda e: self.Reload(),
|
||||
)
|
||||
self.CountText = ft.Text("", size=12, color=Palette.Overlay0)
|
||||
Legend = ft.Row([
|
||||
ft.Text("●", color=Palette.Mauve), ft.Text("Directory", size=12, color=Palette.Overlay0),
|
||||
ft.Text("●", color=Palette.White), ft.Text("File", size=12, color=Palette.Overlay0),
|
||||
ft.Text("─", color=Palette.Blue), ft.Text("Reference", size=12, color=Palette.Overlay0),
|
||||
], spacing=4, tight=True)
|
||||
self.BaseCanvas = cv.Canvas(shapes=[], expand=True, on_resize=self._OnResize, resize_interval=100)
|
||||
self.LitCanvas = cv.Canvas(shapes=[], expand=True)
|
||||
self.Status = ft.Text("Building the graph…", color=Palette.Overlay0)
|
||||
Surface = ft.GestureDetector(
|
||||
content=ft.Stack([self.BaseCanvas, self.LitCanvas], expand=True),
|
||||
on_tap_up=self._OnTap,
|
||||
on_hover=self._OnHover,
|
||||
hover_interval=50,
|
||||
mouse_cursor=ft.MouseCursor.BASIC,
|
||||
expand=True,
|
||||
)
|
||||
self.Viewer = ft.InteractiveViewer(
|
||||
content=Surface, min_scale=0.2, max_scale=8, boundary_margin=ft.Margin.all(4000),
|
||||
expand=True,
|
||||
)
|
||||
super().__init__(
|
||||
content=ft.Column([
|
||||
ft.Row([
|
||||
ft.IconButton(icon=ft.Icons.ARROW_BACK, tooltip="Back to the file list (Esc)", on_click=lambda e: OnClose()),
|
||||
ft.Text("Graph", weight=ft.FontWeight.BOLD),
|
||||
self.ScopePicker, Legend, ft.Container(expand=True), self.CountText,
|
||||
], spacing=12),
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
ft.Stack([self.Viewer, ft.Container(self.Status, alignment=ft.Alignment.CENTER)], expand=True),
|
||||
], spacing=8, expand=True),
|
||||
expand=True, bgcolor=Palette.Base, padding=12,
|
||||
)
|
||||
|
||||
def did_mount(self) -> None:
|
||||
self.Reload()
|
||||
|
||||
def will_unmount(self) -> None:
|
||||
self.Closed = True
|
||||
|
||||
# --- Data and layout ----------------------------------------------------
|
||||
|
||||
def Reload(self) -> None:
|
||||
self.Generation += 1
|
||||
Generation = self.Generation
|
||||
Path = self.DirectoryPath if self.ScopePicker.value == "Directory" else ""
|
||||
self.Status.value = "Building the graph…"
|
||||
self.Status.visible = True
|
||||
self.BaseCanvas.shapes = []
|
||||
self.LitCanvas.shapes = []
|
||||
self.Layout = None
|
||||
self.update()
|
||||
self.page.run_thread(self._Build, Generation, Path)
|
||||
|
||||
def _Build(self, Generation: int, Path: str) -> None:
|
||||
try:
|
||||
Graph = self.TheApp.Source.Graph(self.Root, Path)
|
||||
except SourceError as Error:
|
||||
if Generation == self.Generation and not self.Closed:
|
||||
self.Status.value = f"Couldn't build the graph: {Error}"
|
||||
self.Status.update()
|
||||
return
|
||||
if Generation != self.Generation or self.Closed:
|
||||
return
|
||||
Files = sum(1 for Node in Graph["Nodes"] if not Node["IsDirectory"])
|
||||
self.CountText.value = (f"{len(Graph['Nodes']) - Files} directories, {Files} files"
|
||||
+ (" (cut short: too many to show)" if Graph.get("Truncated") else ""))
|
||||
self.CountText.update()
|
||||
self.Layout = Layout(Graph)
|
||||
self.Status.visible = False
|
||||
self.Status.update()
|
||||
# Settle in steps, drawing as it goes, like the web view's animation.
|
||||
while not self.Layout.Settled:
|
||||
for _ in range(TicksPerDraw):
|
||||
self.Layout.Tick()
|
||||
if Generation != self.Generation or self.Closed:
|
||||
return
|
||||
self._Draw()
|
||||
self._Draw()
|
||||
|
||||
# --- Drawing ------------------------------------------------------------
|
||||
|
||||
def _Fit(self) -> None:
|
||||
"""Maps the layout onto the canvas, so the whole graph fits at zoom 1
|
||||
(the viewer's own zoom and pan go from there)."""
|
||||
Position = self.Layout.Position
|
||||
Low, High = Position.min(0), Position.max(0)
|
||||
Width, Height = self.Size
|
||||
Margin = 40
|
||||
Scale = min((Width - 2 * Margin) / max(High[0] - Low[0], 1), (Height - 2 * Margin) / max(High[1] - Low[1], 1), 1.5)
|
||||
Middle = (Low + High) / 2
|
||||
self.Screen = (Position - Middle) * Scale + (Width / 2, Height / 2)
|
||||
Zoom = math.sqrt(Scale)
|
||||
IsDirectory = np.array([Node["IsDirectory"] for Node in self.Layout.Nodes])
|
||||
Degree = np.sqrt(self.Layout.Degree)
|
||||
self.Radius = np.maximum(2, np.where(IsDirectory, 5 + Degree * 1.6, 3 + Degree * 0.8) * Zoom)
|
||||
|
||||
def _Label(self, Number: int, Colour: str) -> cv.Text:
|
||||
X, Y = self.Screen[Number]
|
||||
return cv.Text(X, Y + self.Radius[Number] + 2, self.Layout.Nodes[Number]["Name"],
|
||||
style=ft.TextStyle(size=10, color=Colour, font_family=Core.ActiveFontFamily),
|
||||
alignment=ft.Alignment.TOP_CENTER)
|
||||
|
||||
def _Dot(self, Number: int) -> cv.Circle:
|
||||
X, Y = self.Screen[Number]
|
||||
Colour = Palette.Mauve if self.Layout.Nodes[Number]["IsDirectory"] else Palette.White
|
||||
return cv.Circle(X, Y, self.Radius[Number], paint=ft.Paint(color=Colour))
|
||||
|
||||
def _Line(self, LinkNumber: int, Colour: str, Width: float = 1) -> cv.Line:
|
||||
A, B = self.Screen[self.Layout.Sources[LinkNumber]], self.Screen[self.Layout.Targets[LinkNumber]]
|
||||
return cv.Line(A[0], A[1], B[0], B[1], paint=ft.Paint(color=Colour, stroke_width=Width, style=ft.PaintingStyle.STROKE))
|
||||
|
||||
def _Draw(self) -> None:
|
||||
if self.Layout is None or self.Closed:
|
||||
return
|
||||
self._Fit()
|
||||
Shapes: list = []
|
||||
for Number, Kind in enumerate(self.Layout.Kinds):
|
||||
Shapes.append(self._Line(Number, Palette.Surface1 if Kind == "Child" else Palette.Blue))
|
||||
Nodes = self.Layout.Nodes
|
||||
ShowFileLabels = len(Nodes) <= 300
|
||||
for Number, Node in enumerate(Nodes):
|
||||
Shapes.append(self._Dot(Number))
|
||||
if Node["IsDirectory"] or ShowFileLabels:
|
||||
Shapes.append(self._Label(Number, Palette.Overlay0))
|
||||
self.BaseCanvas.shapes = Shapes
|
||||
self.BaseCanvas.update()
|
||||
self._DrawHighlight()
|
||||
|
||||
def _DrawHighlight(self) -> None:
|
||||
"""The hovered dot and its links, over a veil that dims the rest."""
|
||||
Number = self.Hovered
|
||||
if Number is None or self.Layout is None:
|
||||
self.LitCanvas.shapes = []
|
||||
else:
|
||||
Width, Height = self.Size
|
||||
Shapes: list = [cv.Rect(-4000, -4000, Width + 8000, Height + 8000,
|
||||
paint=ft.Paint(color=ft.Colors.with_opacity(0.75, Palette.Base)))]
|
||||
Lit = {Number} | self.Layout.Neighbours[Number]
|
||||
for LinkNumber in range(len(self.Layout.Kinds)):
|
||||
if Number in (self.Layout.Sources[LinkNumber], self.Layout.Targets[LinkNumber]):
|
||||
Shapes.append(self._Line(LinkNumber, Palette.Mauve, 1.5))
|
||||
for Other in Lit:
|
||||
Shapes.append(self._Dot(Other))
|
||||
Shapes.append(self._Label(Other, Palette.Text if Other == Number else Palette.Overlay0))
|
||||
X, Y = self.Screen[Number]
|
||||
Shapes.append(cv.Circle(X, Y, self.Radius[Number] + 3,
|
||||
paint=ft.Paint(color=Palette.Mauve, stroke_width=2, style=ft.PaintingStyle.STROKE)))
|
||||
self.LitCanvas.shapes = Shapes
|
||||
self.LitCanvas.update()
|
||||
|
||||
# --- Input --------------------------------------------------------------
|
||||
|
||||
def _NodeAt(self, X: float, Y: float) -> Optional[int]:
|
||||
if self.Layout is None or not len(self.Screen):
|
||||
return None
|
||||
Distance = np.hypot(self.Screen[:, 0] - X, self.Screen[:, 1] - Y)
|
||||
Number = int(Distance.argmin())
|
||||
return Number if Distance[Number] <= max(6, self.Radius[Number] + 4) else None
|
||||
|
||||
def _OnResize(self, Event) -> None:
|
||||
self.Size = (Event.width, Event.height)
|
||||
self._Draw()
|
||||
|
||||
def _OnHover(self, Event) -> None:
|
||||
Number = self._NodeAt(Event.local_position.x, Event.local_position.y)
|
||||
if Number != self.Hovered:
|
||||
self.Hovered = Number
|
||||
self._DrawHighlight()
|
||||
|
||||
def _OnTap(self, Event) -> None:
|
||||
Number = self._NodeAt(Event.local_position.x, Event.local_position.y)
|
||||
if Number is not None:
|
||||
self.OnOpenNode(self.Layout.Nodes[Number])
|
||||
@@ -113,6 +113,7 @@ class Source:
|
||||
def Upload(self, Root: str, DirectoryPath: str, LocalFile: Path, OnProgress: Optional[Callable[[float], None]] = None) -> str: ...
|
||||
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
|
||||
def Book(self, Root: str, RelativePath: str) -> dict: ...
|
||||
def Graph(self, Root: str, RelativePath: str) -> dict: ...
|
||||
|
||||
@property
|
||||
def CanWrite(self) -> bool:
|
||||
@@ -268,6 +269,12 @@ class LocalSource(Source):
|
||||
def Book(self, Root: str, RelativePath: str) -> dict:
|
||||
return {} # SaraTag tags are only read through a server for now
|
||||
|
||||
def Graph(self, Root: str, RelativePath: str) -> dict:
|
||||
"""The graph view's data, built here the way the server builds it."""
|
||||
from Graph import BuildLocalGraph # Graph.py imports this module
|
||||
|
||||
return BuildLocalGraph(self, Root, RelativePath)
|
||||
|
||||
|
||||
class ServerSource(Source):
|
||||
"""A SaraView server, through the same /api endpoints the web frontend
|
||||
@@ -453,6 +460,10 @@ class ServerSource(Source):
|
||||
except SourceError:
|
||||
return {} # tags are extra; a file without them still opens
|
||||
|
||||
def Graph(self, Root: str, RelativePath: str) -> dict:
|
||||
# A big root takes the server a while to read through.
|
||||
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
|
||||
|
||||
|
||||
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
||||
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
||||
@@ -3,3 +3,4 @@ flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first
|
||||
tomlkit
|
||||
httpx
|
||||
send2trash
|
||||
numpy
|
||||
@@ -141,6 +141,11 @@ export function FetchTree(RootName, RelativePath) {
|
||||
return FetchJson(`/tree?${Params}`);
|
||||
}
|
||||
|
||||
export function FetchGraph(RootName, RelativePath) {
|
||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||
return FetchJson(`/graph?${Params}`);
|
||||
}
|
||||
|
||||
export function FetchFile(RootName, RelativePath) {
|
||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||
return FetchText(`/file?${Params}`);
|
||||
|
||||
+22
-2
@@ -6,6 +6,7 @@ import PreviewPane from "./Components/PreviewPane.jsx";
|
||||
import DirectoryPane from "./Components/DirectoryPane.jsx";
|
||||
import TagPanel from "./Components/TagPanel.jsx";
|
||||
import SettingsPanel from "./Components/SettingsPanel.jsx";
|
||||
import GraphPane from "./Components/GraphPane.jsx";
|
||||
import LoginScreen from "./Components/LoginScreen.jsx";
|
||||
import ErrorToast from "./Components/ErrorToast.jsx";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ export default function App() {
|
||||
const [SidebarKey, SetSidebarKey] = useState(0);
|
||||
const [Settings, SetSettings] = useState(null);
|
||||
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
|
||||
const [IsGraphOpen, SetIsGraphOpen] = useState(false);
|
||||
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -254,6 +256,15 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function HandleOpenGraphNode(Node) {
|
||||
SetIsGraphOpen(false);
|
||||
if (Node.IsDirectory) {
|
||||
HandleBreadcrumbNavigate(Node.Id);
|
||||
} else {
|
||||
HandleSelectFile(Node.Id, Node.FileType);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleEntrySelect(EntryPath, IsDirectory, FileType, Note) {
|
||||
if (IsDirectory) {
|
||||
OpenDirectoryNote(EntryPath, Note);
|
||||
@@ -328,6 +339,8 @@ export default function App() {
|
||||
OnEntryRenamed={HandleEntryRenamed}
|
||||
OnEntryDeleted={HandleEntryDeleted}
|
||||
OnOpenSettings={() => SetIsSettingsOpen(true)}
|
||||
OnToggleGraph={() => SetIsGraphOpen(!IsGraphOpen)}
|
||||
IsGraphOpen={IsGraphOpen}
|
||||
OnRequestLogin={HandleRequestLogin}
|
||||
OnEntryCreated={RefreshSidebar}
|
||||
UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true}
|
||||
@@ -365,7 +378,14 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
<div className="MainBody">
|
||||
{ViewIsDirectory ? (
|
||||
{IsGraphOpen ? (
|
||||
<GraphPane
|
||||
RootName={ActiveRoot}
|
||||
DirectoryPath={CurrentDirectoryPath}
|
||||
OnOpenNode={HandleOpenGraphNode}
|
||||
OnClose={() => SetIsGraphOpen(false)}
|
||||
/>
|
||||
) : ViewIsDirectory ? (
|
||||
<DirectoryPane
|
||||
RootName={ActiveRoot}
|
||||
DirectoryPath={ViewPath}
|
||||
@@ -391,7 +411,7 @@ export default function App() {
|
||||
IsGuest={IsGuest}
|
||||
/>
|
||||
)}
|
||||
{!ViewIsDirectory && !ViewNoteFilePath && (
|
||||
{!IsGraphOpen && !ViewIsDirectory && !ViewNoteFilePath && (
|
||||
<TagPanel RootName={ActiveRoot} FilePath={ViewPath} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
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).
|
||||
// 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.
|
||||
|
||||
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
|
||||
|
||||
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, 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.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) => ({
|
||||
...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;
|
||||
}
|
||||
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 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.IsDirectory ? Colours.Directory : Colours.File;
|
||||
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;
|
||||
if (Node) {
|
||||
State.Dragging = Node;
|
||||
Node.Pinned = true;
|
||||
State.Alpha = Math.max(State.Alpha, 0.3);
|
||||
} else {
|
||||
State.Panning = [X, Y, State.PanX, State.PanY];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
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 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("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("wheel", OnWheel);
|
||||
};
|
||||
}, [Graph]);
|
||||
|
||||
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">
|
||||
<span className="GraphLegendDirectory">●</span> Directory
|
||||
<span className="GraphLegendFile">●</span> File
|
||||
<span className="GraphLegendReference">─</span> Reference
|
||||
</span>
|
||||
{Graph && (
|
||||
<span className="GraphCount">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -286,6 +286,8 @@ export default function Sidebar({
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
OnOpenSettings,
|
||||
OnToggleGraph,
|
||||
IsGraphOpen,
|
||||
OnRequestLogin,
|
||||
OnEntryCreated,
|
||||
UseHyphenBullets,
|
||||
@@ -450,6 +452,13 @@ export default function Sidebar({
|
||||
</span>
|
||||
<span className="SidebarHeaderRight">
|
||||
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} OnRequestLogin={OnRequestLogin} />
|
||||
<button
|
||||
className={`SettingsButton${IsGraphOpen ? " SettingsButtonActive" : ""}`}
|
||||
onClick={OnToggleGraph}
|
||||
title="Graph view"
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
{!IsGuest && (
|
||||
<button className="SettingsButton" onClick={OnOpenSettings}>
|
||||
⚙
|
||||
|
||||
@@ -164,6 +164,10 @@ img,
|
||||
background: var(--SurfaceAlt);
|
||||
}
|
||||
|
||||
.SettingsButtonActive {
|
||||
background: var(--SurfaceAlt);
|
||||
}
|
||||
|
||||
.SidebarError {
|
||||
padding: 10px 16px;
|
||||
color: var(--Red);
|
||||
@@ -551,6 +555,59 @@ img,
|
||||
color: var(--Blue);
|
||||
}
|
||||
|
||||
/* Graph view (GraphPane.jsx). The canvas reads its colours from the same
|
||||
role variables. */
|
||||
.GraphPane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.GraphPane .PreviewToolbar {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.GraphPane select {
|
||||
background: var(--SurfaceAlt);
|
||||
color: var(--Text);
|
||||
border: 1px solid var(--Purple);
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.GraphCanvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.GraphLegend,
|
||||
.GraphCount {
|
||||
color: var(--TextMuted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.GraphLegend span {
|
||||
margin: 0 4px 0 10px;
|
||||
}
|
||||
|
||||
.GraphLegendDirectory {
|
||||
color: var(--Purple);
|
||||
}
|
||||
|
||||
.GraphLegendFile {
|
||||
color: var(--White);
|
||||
}
|
||||
|
||||
.GraphLegendReference {
|
||||
color: var(--Blue);
|
||||
}
|
||||
|
||||
/* Live Markdown editing (LivePreview.js). The editor runs without a
|
||||
CodeMirror theme here, so it's styled entirely from the roles. */
|
||||
.LiveEditor .cm-editor {
|
||||
|
||||
@@ -296,6 +296,13 @@ it back), with Source (Ctrl+E) for the plain text; **Separate** keeps View
|
||||
and Edit as two modes. Images show in the app;
|
||||
videos and other files open in their default program (Open Externally).
|
||||
|
||||
**Graph** (the toolbar's graph button or Ctrl+G here, the Graph button at
|
||||
the top of the web view's sidebar) shows everything under a root, or under
|
||||
the current directory, as dots like Obsidian's graph: directories purple,
|
||||
files white, each joined to its parent directory and to the files that
|
||||
reference it (Markdown links and images, `[[wiki links]]` and portals).
|
||||
Hover a dot to light up its links, click it to open it.
|
||||
|
||||
Rename takes a `/` to move, like the web view (`../Archive/Notes.md`).
|
||||
Shortcuts: Enter opens, Backspace goes up, F2 renames, Delete deletes,
|
||||
Ctrl+N / Ctrl+Shift+N new file / directory, Ctrl+R refreshes, Ctrl+B
|
||||
|
||||
Reference in new issue
Block a user