Graph functionality op basis van Cytoscape.js
This commit is contained in:
@@ -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 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.
|
||||
|
||||
@@ -966,3 +966,51 @@ body.editor-mode .editor-metadata-row {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<aside id="sidebar">
|
||||
<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="graph-link" class="sidebar-primary-link" href="#">* <span data-tr="graph">Graph</span></a>
|
||||
<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">
|
||||
</form>
|
||||
@@ -107,6 +108,16 @@
|
||||
<div id="todo-list" class="todo-items"></div>
|
||||
</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">
|
||||
<header class="page-header">
|
||||
<h1 data-tr="page-history">Page history</h1>
|
||||
|
||||
+145
-1
@@ -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: "{}" });
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
|
||||
(define english
|
||||
(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"
|
||||
'edit "Edit" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
|
||||
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
(define dutch
|
||||
(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"
|
||||
'edit "Bewerken" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
|
||||
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
|
||||
|
||||
Reference in New Issue
Block a user