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
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user