Merge branch 'uroybd-graph-revamp'

This commit is contained in:
Ole Eskild Steensen 2023-02-03 16:25:24 +01:00
commit a98720e8e3
10 changed files with 415 additions and 274 deletions

View File

@ -1,108 +1,92 @@
const wikilink = /\[\[(.*?\|.*?)\]\]/g const wikiLinkRegex = /\[\[(.*?\|.*?)\]\]/g;
const internalLinkRegex = /href="\/(.*?)"/g; const internalLinkRegex = /href="\/(.*?)"/g;
function caselessCompare(a, b) { function caselessCompare(a, b) {
return a.toLowerCase() === b.toLowerCase(); return a.toLowerCase() === b.toLowerCase();
} }
function extractLinks(content) { function extractLinks(content) {
return[...(content.match(wikilink) || []).map(link => ( return [
link.slice(2, -2) ...(content.match(wikiLinkRegex) || []).map(
.split("|")[0] (link) =>
.replace(/.(md|markdown)\s?$/i, "") link
.replace("\\", "") .slice(2, -2)
.trim() .split("|")[0]
)), ...(content.match(internalLinkRegex) || []).map( .replace(/.(md|markdown)\s?$/i, "")
(link) => .replace("\\", "")
link .trim()
.slice(6, -1) .split("#")[0]
.split("|")[0] ),
.replace(/.(md|markdown)\s?$/i, "") ...(content.match(internalLinkRegex) || []).map(
.replace("\\", "") (link) =>
.trim() link
)]; .slice(6, -1)
.split("|")[0]
.replace(/.(md|markdown)\s?$/i, "")
.replace("\\", "")
.trim()
.split("#")[0]
),
];
} }
function getBacklinks(data) { function getGraph(data) {
const notes = data.collections.note; let nodes = {};
if (!notes) { let links = [];
return []; let stemURLs = {};
let homeAlias = "/";
data.collections.note.forEach((v, idx) => {
let fpath = v.filePathStem.replace("/notes/", "");
let parts = fpath.split("/");
let group = "none";
if (parts.length >= 3) {
group = parts[parts.length - 2];
} }
const currentFileSlug = data.page.filePathStem.replace('/notes/', ''); nodes[v.url] = {
const currentURL = data.page.url; id: idx,
title: v.data.title || v.fileSlug,
let backlinks = []; url: v.url,
let uniqueLinks = new Set(); group,
let counter = 1; home: v.data["dg-home"] || (v.data.tags && v.data.tags.indexOf("gardenEntry") > -1)|| false,
outBound: extractLinks(v.template.frontMatter.content),
for (const otherNote of notes) { neighbors: new Set(),
const noteContent = otherNote.template.frontMatter.content; backLinks: new Set(),
const backLinks = extractLinks(noteContent); };
stemURLs[fpath] = v.url;
if (!uniqueLinks.has(otherNote.url) && backLinks.some(link => caselessCompare(link, currentFileSlug) || if (v.data["dg-home"] || (v.data.tags && v.data.tags.indexOf("gardenEntry") > -1)) {
currentURL == link.split("#")[0])) { homeAlias = v.url;
let preview = noteContent.slice(0, 240);
backlinks.push({
url: otherNote.url,
title: otherNote.data.title || otherNote.data.page.fileSlug,
preview,
id: counter++
})
uniqueLinks.add(otherNote.url);
}
} }
return backlinks; });
Object.values(nodes).forEach((node) => {
let outBound = new Set();
node.outBound.forEach((olink) => {
let link = (stemURLs[olink] || olink).split("#")[0];
outBound.add(link);
});
node.outBound = Array.from(outBound);
node.outBound.forEach((link) => {
let n = nodes[link];
if (n) {
n.neighbors.add(node.url);
n.backLinks.add(node.url);
node.neighbors.add(n.url);
links.push({ source: node.id, target: n.id });
}
});
});
Object.keys(nodes).map((k) => {
nodes[k].neighbors = Array.from(nodes[k].neighbors);
nodes[k].backLinks = Array.from(nodes[k].backLinks);
nodes[k].size = nodes[k].neighbors.length;
});
return {
homeAlias,
nodes,
links,
};
} }
function getOutboundLinks(data, isHome=false){ exports.wikiLinkRegex = wikiLinkRegex;
const notes = data.collections.note;
if (!notes || notes.length == 0) {
return [];
}
let currentNote;
if (isHome) {
currentNote = data.collections.gardenEntry && data.collections.gardenEntry[0];
} else {
const currentFileSlug = data.page.filePathStem.replace('/notes/', '');
currentNote = notes.find(x => x.data.page.filePathStem && caselessCompare(x.data.page.filePathStem.replace('/notes/', ''), currentFileSlug));
}
if (!currentNote) {
return [];
}
let counter = 1;
let uniqueLinks = new Set();
const outboundLinks = extractLinks(currentNote.template.frontMatter.content);
let outbound = outboundLinks.map(fileslug => {
var outboundNote = notes.find(x => caselessCompare(x.data.page.filePathStem.replace("/notes/", ""), fileslug) || x.data.page.url == fileslug.split("#")[0]);
if (!outboundNote) {
return null;
}
if (!uniqueLinks.has(outboundNote.url)) {
uniqueLinks.add(outboundNote.url);
return {
url: outboundNote.url,
title: outboundNote.data.title || outboundNote.data.page.fileSlug,
id: counter++,
};
} else {
return null;
}
}).filter(x => x);
return outbound;
}
exports.wikilink = wikilink;
exports.internalLinkRegex = internalLinkRegex; exports.internalLinkRegex = internalLinkRegex;
exports.getBacklinks = getBacklinks; exports.extractLinks = extractLinks;
exports.getOutboundLinks = getOutboundLinks; exports.getGraph = getGraph;
exports.caselessCompare = caselessCompare;
exports.extractLinks = extractLinks;

View File

@ -0,0 +1,5 @@
const { getGraph } = require("../../helpers/linkUtils");
module.exports = {
graph: (data) => getGraph(data),
}

View File

@ -1,113 +1,86 @@
<script> <script>
const getCssVar = (variable) => getComputedStyle(document.body).getPropertyValue(variable); const getCssVar = (variable) => getComputedStyle(document.body).getPropertyValue(variable);
function htmlDecode(input) { function htmlDecode(input) {
var doc = new DOMParser().parseFromString(input, "text/html"); var doc = new DOMParser().parseFromString(input, "text/html");
return doc.documentElement.textContent; return doc.documentElement.textContent;
} }
window.graphData = null;
window.maxGraphDepth = 1;
const backLinks = [ function getNextLevelNeighbours(existing, remaining) {
const keys = Object.values(existing).map((n) => n.neighbors).flat();
const n_remaining = Object.keys(remaining).reduce((acc, key) => {
if (keys.indexOf(key) != -1) {
existing[key] = remaining[key];
} else {
acc[key] = remaining[key];
}
return acc;
}, {});
return existing, n_remaining;
}
{%- for backlink in backlinks -%} function filterToDepth(data) {
{ let remaining = JSON.parse(JSON.stringify(data.nodes));
id: {{backlink.id}}, let currentLink = window.location.pathname;
title: "{{backlink.title | safe}}", let currentNode = remaining[currentLink] || Object.values(remaining).find((v) => v.home);
url: "{{backlink.url}}" delete remaining[currentNode.url];
}, if (!currentNode.home) {
{%- endfor -%} let home = Object.values(remaining).find((v) => v.home);
]; delete remaining[home.url];
const outbound = [
{%- for out in outbound -%}
{
id: {{out.id}},
title: "{{out.title | safe}}",
url: "{{out.url}}"
},
{%- endfor -%}
].map(x => {
x.id += backLinks.length;
return x;
});
const outboundDuplicatesRemoved = outbound.filter(x => !backLinks.find(b => b.url === x.url));
const title = "{{page.fileSlug}}" || "Home"
const currentNode = {
title,
id: 0,
url: "{{page.url}}"
};
const gData = {
nodes: [
currentNode,
...backLinks,
...outboundDuplicatesRemoved
],
links: [
...backLinks.map(backlink => ({source: backlink.id, target: 0})),
...outboundDuplicatesRemoved.map(outlink => ({source: 0, target: outlink.id}))
]
};
gData
.links
.forEach(link => {
const a = gData
.nodes
.find(x => x.id === link.source);
const b = gData
.nodes
.find(x => x.id === link.target);
if (a && b) {
!a.neighbors && (a.neighbors = []);
!b.neighbors && (b.neighbors = []);
a
.neighbors
.push(b);
b
.neighbors
.push(a);
!a.links && (a.links = []);
!b.links && (b.links = []);
a
.links
.push(link);
b
.links
.push(link);
}
});
let Graph;
function renderGraph(width, height) {
if (Graph) {
Graph
.width(width)
.height(height);
Graph.zoomToFit()
Graph.zoom(3)
return;
} }
currentNode.current = true;
let existing = {};
existing[currentNode.url] = currentNode;
for (let i = 0; i < window.maxGraphDepth; i++) {
existing, remaining = getNextLevelNeighbours(existing, remaining);
}
nodes = Object.values(existing);
if (!currentNode.home) {
nodes = nodes.filter(n => !n.home);
}
let ids = nodes.map((n) => n.id);
let graphData = {
nodes,
links: data.links.filter((con) => ids.indexOf(con.target) > -1 && ids.indexOf(con.source) > -1),
}
return graphData;
}
Graph = ForceGraph()(document.getElementById('link-graph')) var Graph;
function renderGraph(graphData, id, width, height, delay) {
let Graph = ForceGraph()
(document.getElementById(id))
.graphData(graphData)
.nodeId('id')
.nodeLabel('title')
.linkSource('source')
.linkTarget('target')
.d3AlphaDecay(0.10)
.width(width) .width(width)
.height(height) .height(height)
.linkDirectionalArrowLength(2)
.linkDirectionalArrowRelPos(0.5)
.linkColor(() => getCssVar("--text-muted") || getCssVar("--text-normal"))
.nodeCanvasObject((node, ctx) => { .nodeCanvasObject((node, ctx) => {
const numberOfLinks = (node.links && node.links.length) || 2; const color = getCssVar("--text-accent");
const numberOfNeighbours = (node.neighbors && node.neighbors.length) || 2; const numberOfNeighbours = (node.neighbors && node.neighbors.length) || 2;
const nodeR = Math.min(7, Math.max((numberOfLinks + numberOfNeighbours) / 2, 2)); const nodeR = Math.min(7, Math.max(numberOfNeighbours / 2, 2));
ctx.beginPath(); ctx.beginPath();
ctx.arc(node.x, node.y, nodeR, 0, 2 * Math.PI, false); ctx.arc(node.x, node.y, nodeR, 0, 2 * Math.PI, false);
ctx.fillStyle = getCssVar("--text-accent"); ctx.fillStyle = color;
ctx.fill(); ctx.fill();
if (node.current) {
ctx.beginPath();
ctx.arc(node.x, node.y, nodeR + 1, 0, 2 * Math.PI, false);
ctx.lineWidth = 0.5;
ctx.strokeStyle = color;
ctx.stroke();
}
const label = htmlDecode(node.title) const label = htmlDecode(node.title)
const fontSize = 3.5; const fontSize = 3.5;
@ -117,17 +90,65 @@
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.fillText(label, node.x, node.y + nodeR + 2); ctx.fillText(label, node.x, node.y + nodeR + 2);
}) })
.linkColor(() => getCssVar("--text-muted") || getCssVar("--text-normal"))
.graphData(gData)
.onNodeClick(node => { .onNodeClick(node => {
window.location = node.url; window.location = node.url;
}); });
if (delay != null && graphData.nodes.length > 2) {
setTimeout(() => { setTimeout(() => {
Graph.zoomToFit(); Graph.zoomToFit(5, 75);
Graph.zoom(3); }, delay);
}, 10); }
return Graph;
} }
renderGraph(320,320); function fetchGraphData() {
</script> fetch('/graph.json').then(res => res.json()).then(data => {
window.graphData = data;
Graph = renderGraph(filterToDepth(JSON.parse(JSON.stringify(data))), "link-graph", 320, 320, 1);
});
}
fetchGraphData();
window.document.getElementById('graph-depth').value = window.maxGraphDepth;
window.document.getElementById('depth-display').innerText = window.maxGraphDepth;
window.document.getElementById('graph-depth').addEventListener('input', (evt) => {
window.maxGraphDepth = evt.target.value;
window.document.getElementById('depth-display').innerText = window.maxGraphDepth;
if (Graph != null) {
Graph._destructor();
Graph = null;
}
renderGraph(filterToDepth(JSON.parse(JSON.stringify(window.graphData))), "link-graph", 330, 330, 1);
})
window.fullGraph = null;
function renderFullGraph() {
if (!window.fullGraph) {
const graphData = {
links: JSON.parse(JSON.stringify(window.graphData.links)),
nodes: [...Object.values(window.graphData.nodes)]
}
let g = document.createElement('div');
g.id = 'full-graph';
g.classList.add('show');
document.body.appendChild(g);
g.innerHTML = '<i class="fa fa-times" id="full-graph-close" aria-hidden="true"></i><div id="full-graph-container"></div>';
window.fullGraph = renderGraph(graphData, "full-graph-container", g.offsetWidth, g.offsetHeight, 200);
document.getElementById('full-graph-close').addEventListener('click', (evt) => {
g.classList.remove('show');
window.fullGraph._destructor();
window.fullGraph = null;
document.getElementById('full-graph').remove()
});
}
}
document.getElementById('graph-full-btn').addEventListener('click', (evt) => {
if (!fullGraph) {
renderFullGraph();
}
});
</script>

View File

@ -17,7 +17,7 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.25.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer"/> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.25.0/themes/prism-okaidia.min.css" integrity="sha512-mIs9kKbaw6JZFfSuo+MovjU+Ntggfoj8RwAmJbVXQ5mkAX5LlgETQEweFPI18humSPHymTb5iikEOKWF7I8ncQ==" crossorigin="anonymous" referrerpolicy="no-referrer"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@3.6.2/dist/fetch.umd.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script> <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<link href="/styles/digital-garden-base.css" rel="stylesheet"> <link href="/styles/digital-garden-base.css" rel="stylesheet">

View File

@ -6,6 +6,21 @@
<div class="graph"> <div class="graph">
<div class="graph-title-container"> <div class="graph-title-container">
<div class="graph-title">Connected Pages</div> <div class="graph-title">Connected Pages</div>
<div id="graph-controls">
<div class="depth-control">
<label for="graph-depth">Depth</label>
<div class="slider">
<input name="graph-depth" list="depthmarkers" type="range" step="1" min="1" max="3" id="graph-depth"/>
<datalist id="depthmarkers">
<option value="1" label="1"></option>
<option value="2" label="2"></option>
<option value="3" label="3"></option>
</datalist>
</div>
<span id="depth-display"></span>
</div>
<i class="fa fa-arrows-alt" id="graph-full-btn" aria-hidden="true"></i>
</div>
</div> </div>
<div id="link-graph"></div> <div id="link-graph"></div>
</div> </div>
@ -29,25 +44,41 @@
{%endif%} {%endif%}
{%if settings.dgShowBacklinks === true %} {%if settings.dgShowBacklinks === true %}
{%if settings.dgShowBacklinks === true %}
<div class="backlinks"> <div class="backlinks">
<div class="backlink-title" style="margin: 4px 0 !important;">Pages mentioning this page</div> <div class="backlink-title" style="margin: 4px 0 !important;">Pages mentioning this page</div>
<div class="backlink-list"> <div class="backlink-list">
{%- if page.url == "/" -%}
{%- if backlinks.length === 0 -%} {%- if graph.nodes[graph.homeAlias].backLinks.length === 0 -%}
<div class="backlink-card">
<span class="no-backlinks-message">No other pages mentions this page</span>
</div>
{%- endif -%}
{%- for backlink in graph.nodes[graph.homeAlias].backLinks -%}
{%- if graph.nodes[backlink].url != graph.homeAlias -%}
<div class="backlink-card"> <div class="backlink-card">
<span class="no-backlinks-message">No other pages mentions this page</span> <i class="fa fa-link"></i> <a href="{{graph.nodes[backlink].url}}">{{graph.nodes[backlink].title}}</a>
</div>
{%- endif -%}
{%- for backlink in backlinks -%}
<div class="backlink-card">
<i class="fa fa-link"></i> <a href="{{backlink.url}}">{{backlink.title}}</a>
</div> </div>
{%- endif -%}
{%- endfor -%} {%- endfor -%}
{%- else -%}
{%- if graph.nodes[page.url].backLinks.length === 0 -%}
<div class="backlink-card">
<span class="no-backlinks-message">No other pages mentions this page</span>
</div>
{%- endif -%}
{%- for backlink in graph.nodes[page.url].backLinks -%}
{%- if graph.nodes[backlink].url != page.url -%}
<div class="backlink-card">
<i class="fa fa-link"></i> <a href="{{graph.nodes[backlink].url}}">{{graph.nodes[backlink].title}}</a>
</div>
{%- endif -%}
{%- endfor -%}
{%- endif -%}
</div> </div>
</div> </div>
{%endif%} {%endif%}
{%endif%}
</div> </div>
</div> </div>
</div> </div>

5
src/site/graph.njk Normal file
View File

@ -0,0 +1,5 @@
---
permalink: /graph.json
eleventyExcludeFromCollections: true
---
{{ graph | jsonify | safe }}

View File

@ -1,55 +1,59 @@
require("dotenv").config(); require("dotenv").config();
const settings = require("../helpers/constants"); const settings = require("../helpers/constants");
const markdownIt = require("markdown-it"); const markdownIt = require("markdown-it");
const { getBacklinks, getOutboundLinks } = require("../helpers/linkUtils");
const md = markdownIt({ const md = markdownIt({
html: true, html: true,
}).use(require("../helpers/utils").namedHeadingsFilter); }).use(require("../helpers/utils").namedHeadingsFilter);
const allSettings = settings.ALL_NOTE_SETTINGS; const allSettings = settings.ALL_NOTE_SETTINGS;
module.exports = { module.exports = {
eleventyComputed: { eleventyComputed: {
backlinks: (data) => getBacklinks(data), settings: (data) => {
outbound: (data) => getOutboundLinks(data, true), const currentnote =
settings: (data) => { data.collections.gardenEntry && data.collections.gardenEntry[0];
const currentnote = data.collections.gardenEntry && data.collections.gardenEntry[0]; if (currentnote && currentnote.data) {
if (currentnote && currentnote.data) { const noteSettings = {};
const noteSettings = {}; allSettings.forEach((setting) => {
allSettings.forEach(setting => { let noteSetting = currentnote.data[setting];
let noteSetting = currentnote.data[setting]; let globalSetting = process.env[setting];
let globalSetting = process.env[setting];
let settingValue = (noteSetting || (globalSetting === 'true' && noteSetting !== false)); let settingValue =
noteSettings[setting] = settingValue; noteSetting || (globalSetting === "true" && noteSetting !== false);
}); noteSettings[setting] = settingValue;
return noteSettings; });
return noteSettings;
} }
return {}; return {};
}, },
noteTitle: (data) => { noteTitle: (data) => {
const currentnote = data.collections.gardenEntry && data.collections.gardenEntry[0]; const currentnote =
if (currentnote && currentnote.data) { data.collections.gardenEntry && data.collections.gardenEntry[0];
return currentnote.data.title || currentnote.data.page.fileSlug; if (currentnote && currentnote.data) {
} return currentnote.data.title || currentnote.data.page.fileSlug;
return ""; }
}, return "";
tags: (data) => { },
const currentnote = data.collections.gardenEntry && data.collections.gardenEntry[0]; tags: (data) => {
if (currentnote && currentnote.data) { const currentnote =
return currentnote.data.tags; data.collections.gardenEntry && data.collections.gardenEntry[0];
} if (currentnote && currentnote.data) {
return []; return currentnote.data.tags;
}, }
content: (data) => { return [];
const currentnote = data.collections.gardenEntry && data.collections.gardenEntry[0]; },
if (currentnote && currentnote.template && currentnote.template.frontMatter && currentnote.template.frontMatter.content) { content: (data) => {
return md.render(currentnote.template.frontMatter.content); const currentnote =
} data.collections.gardenEntry && data.collections.gardenEntry[0];
return ""; if (
} currentnote &&
} currentnote.template &&
} currentnote.template.frontMatter &&
currentnote.template.frontMatter.content
) {
return md.render(currentnote.template.frontMatter.content);
}
return "";
},
},
};

View File

@ -1,24 +1,21 @@
require("dotenv").config(); require("dotenv").config();
const settings = require("../../helpers/constants"); const settings = require("../../helpers/constants");
const { getBacklinks, getOutboundLinks } = require("../../helpers/linkUtils");
const allSettings = settings.ALL_NOTE_SETTINGS; const allSettings = settings.ALL_NOTE_SETTINGS;
module.exports = { module.exports = {
eleventyComputed: { eleventyComputed: {
backlinks: (data) => getBacklinks(data), settings: (data) => {
outbound: (data) => getOutboundLinks(data), const noteSettings = {};
settings: (data) => { allSettings.forEach((setting) => {
const noteSettings = {}; let noteSetting = data[setting];
allSettings.forEach(setting => { let globalSetting = process.env[setting];
let noteSetting = data[setting];
let globalSetting = process.env[setting];
let settingValue = (noteSetting || (globalSetting === 'true' && noteSetting !== false)); let settingValue =
noteSettings[setting] = settingValue; noteSetting || (globalSetting === "true" && noteSetting !== false);
}); noteSettings[setting] = settingValue;
return noteSettings; });
} return noteSettings;
} },
} },
};

View File

@ -520,3 +520,96 @@ ul.task-list {
.callout-fold .lucide { .callout-fold .lucide {
transition: transform 100ms ease-in-out; transition: transform 100ms ease-in-out;
} }
// Graph Controls
.graph-title-container {
position: relative;
}
#full-graph {
position: fixed;
top: 50%;
left: 50%;
height: 60vh;
width: 60vw;
min-height: 400px;
min-width: 400px;
transform: translate(-50%, -50%);
z-index: 9999;
display: none;
background-color: var(--background-secondary);
#full-graph-close {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
z-index: 9;
}
}
#graph-full-btn {
margin-right: 10px;
}
#full-graph.show {
display: block;
}
#graph-controls {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 5px;
position: absolute;
top: 115%;
cursor: pointer;
right: 0px;
left: 10px;
color: var(--text-accent);
z-index: 9;
.depth-control {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 7px;
.slider {
datalist {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 0.6rem;
// padding: 2px 0px;
// width: 200px;
}
option {
padding: 0;
}
}
#depth-display {
background-color: var(--text-accent);
color: white;
width: 1rem;
height: 1rem;
font-size: 0.8rem;
display: flex;
justify-content: center;
align-items: center;
margin-top: 0.3rem;
border-radius: 50%;
}
}
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
margin-top: -10px;
}

View File

@ -7943,6 +7943,8 @@ body {
.graph-control-section.mod-display .setting-item:not(.mod-slider):last-child .setting-item-info { .graph-control-section.mod-display .setting-item:not(.mod-slider):last-child .setting-item-info {
display: none; display: none;
} }
.workspace-leaf-content[data-type='outline'] .view-content { .workspace-leaf-content[data-type='outline'] .view-content {
padding: 0; padding: 0;
} }
@ -10632,4 +10634,3 @@ body {
width: 100%; width: 100%;
} }