Graph functionality op basis van Cytoscape.js

This commit is contained in:
2026-08-14 23:30:48 +02:00
parent c54216fae2
commit caa16a0ce8
5 changed files with 211 additions and 3 deletions
+145 -1
View File
@@ -19,7 +19,7 @@
const $ = (id) => document.getElementById(id);
function show(viewId) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "todo-view", "history-view", "admin-view", "user-admin-view"]) {
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view"]) {
$(id).classList.toggle("hidden", id !== viewId);
}
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
@@ -501,6 +501,7 @@
if (item.href) {
const link = document.createElement("a");
link.href = item.href;
if (item.href === "/") link.dataset.home = "true";
link.textContent = item.label;
breadcrumbs.append(link);
} else {
@@ -545,6 +546,21 @@
document.title = state.siteTitle;
}
async function goHome() {
const firstPage = startPage();
if (!firstPage) {
await route();
return;
}
const targetHash = `#/${encodeURIComponent(firstPage.slug)}`;
if (location.hash === targetHash) {
await openPage(firstPage.slug);
} else {
location.hash = targetHash;
}
}
function renderPageList() {
const list = $("page-list");
list.replaceChildren();
@@ -1134,6 +1150,123 @@
}
}
function pageLinks(markdown) {
const container = document.createElement("div");
container.innerHTML = renderMarkdown(markdown || "");
const links = new Set();
for (const link of container.querySelectorAll("a[href]")) {
const slug = wikiSlugFromHref(link.getAttribute("href"));
if (slug) links.add(slug);
}
return links;
}
async function loadGraphData() {
const pageSlugs = new Set(state.pages.map((page) => page.slug));
const edges = [];
const seen = new Set();
for (const page of state.pages) {
const full = await api(`/api/pages/${encodeURIComponent(page.slug)}`);
for (const target of pageLinks(full.markdown)) {
if (!pageSlugs.has(target) || target === page.slug) continue;
const key = `${page.slug}\u0000${target}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({ from: page.slug, to: target });
}
}
return { nodes: state.pages, edges };
}
function renderWikiGraph(data) {
const svg = $("wiki-graph");
svg.replaceChildren();
const width = 1000;
const height = 700;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.36;
const positions = new Map();
const nodes = data.nodes;
nodes.forEach((page, index) => {
const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index / nodes.length) - Math.PI / 2;
positions.set(page.slug, {
x: nodes.length <= 1 ? centerX : centerX + Math.cos(angle) * radius,
y: nodes.length <= 1 ? centerY : centerY + Math.sin(angle) * radius
});
});
const edgeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
edgeLayer.setAttribute("class", "wiki-graph-edges");
for (const edge of data.edges) {
const from = positions.get(edge.from);
const to = positions.get(edge.to);
if (!from || !to) continue;
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", from.x);
line.setAttribute("y1", from.y);
line.setAttribute("x2", to.x);
line.setAttribute("y2", to.y);
edgeLayer.append(line);
}
svg.append(edgeLayer);
const nodeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
nodeLayer.setAttribute("class", "wiki-graph-nodes");
for (const page of nodes) {
const position = positions.get(page.slug);
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("class", "wiki-graph-node");
group.setAttribute("transform", `translate(${position.x} ${position.y})`);
group.setAttribute("tabindex", "0");
group.setAttribute("role", "link");
group.setAttribute("aria-label", page.title);
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("r", "11");
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
text.setAttribute("x", "17");
text.setAttribute("y", "4");
text.textContent = page.title.length > 34 ? `${page.title.slice(0, 31)}` : page.title;
const open = () => { location.hash = `#/${encodeURIComponent(page.slug)}`; };
group.addEventListener("click", open);
group.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
group.append(circle, text);
nodeLayer.append(group);
}
svg.append(nodeLayer);
}
async function showGraph() {
state.previousView = state.currentPage ? "page-view" : "graph-view";
renderBreadcrumbs([
{ label: state.siteTitle, href: "/" },
{ label: tr("graph", "Graph") }
]);
renderToc([], () => {});
show("graph-view");
const data = await loadGraphData();
$("graph-summary").textContent = tr("graph-summary", "{pages} pages, {links} links")
.replace("{pages}", String(data.nodes.length))
.replace("{links}", String(data.edges.length));
renderWikiGraph(data);
}
function setConnectionStatus(status) {
const node = $("connection-status");
node.classList.remove("hidden", "online", "offline");
@@ -1194,7 +1327,18 @@
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
$("wiki-brand").addEventListener("click", (event) => {
event.preventDefault();
goHome().catch((error) => console.error(error));
});
$("breadcrumbs").addEventListener("click", (event) => {
const home = event.target.closest("a[data-home='true']");
if (!home) return;
event.preventDefault();
goHome().catch((error) => console.error(error));
});
$("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); });
$("graph-link").addEventListener("click", (event) => { event.preventDefault(); showGraph().catch((error) => console.error(error)); });
$("logout-link").addEventListener("click", async (event) => {
event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" });