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
+5
View File
@@ -339,3 +339,8 @@ The special translations page is now one page named `wiki-translations`. It is p
## Version 0.2.13 ## Version 0.2.13
Version 0.2.13 centers the wiki name in the sidebar and makes it a link to the first/start page. The sticky top chrome now starts without top padding (`padding-top: 0`). Version 0.2.13 centers the wiki name in the sidebar and makes it a link to the first/start page. The sticky top chrome now starts without top padding (`padding-top: 0`).
## Version 0.2.14
Version 0.2.14 fixes the wiki-name/home navigation so clicking the wiki name, including while a special view such as Todo is open, always returns to the first/start page. It also adds a lightweight Wiki Graph view. The graph uses the existing pages and their internal Markdown links, requires no extra JavaScript dependency or database migration, and opens a page when its node is selected.
+48
View File
@@ -966,3 +966,51 @@ body.editor-mode .editor-metadata-row {
background: white; background: white;
} }
} }
/* Wiki graph ------------------------------------------------------------- */
#graph-view {
max-width: 1100px;
}
.wiki-graph-shell {
margin-top: 18px;
border: 1px solid var(--wiki-border);
background: #fafafa;
overflow: auto;
}
.wiki-graph {
display: block;
width: 100%;
min-width: 720px;
min-height: 520px;
}
.wiki-graph-edges line {
stroke: #b8b8b8;
stroke-width: 1.4;
}
.wiki-graph-node {
cursor: pointer;
outline: none;
}
.wiki-graph-node circle {
fill: #46639b;
stroke: white;
stroke-width: 2;
}
.wiki-graph-node text {
fill: var(--wiki-text);
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
}
.wiki-graph-node:hover circle,
.wiki-graph-node:focus circle {
fill: #1e2f6d;
stroke: #1e2f6d;
}
+11
View File
@@ -17,6 +17,7 @@
<aside id="sidebar"> <aside id="sidebar">
<a id="wiki-brand" class="brand" href="#">Racket Wiki</a> <a id="wiki-brand" class="brand" href="#">Racket Wiki</a>
<a id="todo-link" class="sidebar-primary-link" href="#">* <span data-tr="todo-list">Todo list</span></a> <a id="todo-link" class="sidebar-primary-link" href="#">* <span data-tr="todo-list">Todo list</span></a>
<a id="graph-link" class="sidebar-primary-link" href="#">* <span data-tr="graph">Graph</span></a>
<form id="search-form" class="wiki-search" role="search"> <form id="search-form" class="wiki-search" role="search">
<input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki"> <input id="search-input" type="search" placeholder="Search wiki" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search wiki">
</form> </form>
@@ -107,6 +108,16 @@
<div id="todo-list" class="todo-items"></div> <div id="todo-list" class="todo-items"></div>
</section> </section>
<section id="graph-view" class="hidden">
<header class="page-header">
<h1 data-tr="wiki-graph">Wiki graph</h1>
<div id="graph-summary" class="muted"></div>
</header>
<div class="wiki-graph-shell">
<svg id="wiki-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Wiki graph"></svg>
</div>
</section>
<section id="history-view" class="hidden"> <section id="history-view" class="hidden">
<header class="page-header"> <header class="page-header">
<h1 data-tr="page-history">Page history</h1> <h1 data-tr="page-history">Page history</h1>
+145 -1
View File
@@ -19,7 +19,7 @@
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
function show(viewId) { 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); $(id).classList.toggle("hidden", id !== viewId);
} }
$("page-action-links").classList.toggle("hidden", viewId !== "page-view"); $("page-action-links").classList.toggle("hidden", viewId !== "page-view");
@@ -501,6 +501,7 @@
if (item.href) { if (item.href) {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = item.href; link.href = item.href;
if (item.href === "/") link.dataset.home = "true";
link.textContent = item.label; link.textContent = item.label;
breadcrumbs.append(link); breadcrumbs.append(link);
} else { } else {
@@ -545,6 +546,21 @@
document.title = state.siteTitle; 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() { function renderPageList() {
const list = $("page-list"); const list = $("page-list");
list.replaceChildren(); 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) { function setConnectionStatus(status) {
const node = $("connection-status"); const node = $("connection-status");
node.classList.remove("hidden", "online", "offline"); node.classList.remove("hidden", "online", "offline");
@@ -1194,7 +1327,18 @@
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); }); $("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); }); $("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); }); $("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)); }); $("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) => { $("logout-link").addEventListener("click", async (event) => {
event.preventDefault(); event.preventDefault();
await api("/api/logout", { method: "POST", body: "{}" }); await api("/api/logout", { method: "POST", body: "{}" });
+2 -2
View File
@@ -16,7 +16,7 @@
(define english (define english
(hash (hash
'users "Users" 'translations "Translations" 'todo "Todo" 'todo-list "Todo list" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin" 'users "Users" 'translations "Translations" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages" 'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages"
'edit "Edit" 'history "History" 'delete "Delete" 'page-not-found "Page not found" 'edit "Edit" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)" 'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
@@ -49,7 +49,7 @@
(define dutch (define dutch
(hash (hash
'users "Gebruikers" 'translations "Vertalingen" 'todo "Todo" 'todo-list "Todo-lijst" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin" 'users "Gebruikers" 'translations "Vertalingen" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's"
'edit "Bewerken" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden" 'edit "Bewerken" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)" 'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"