diff --git a/assets/js/graph.js b/assets/js/graph.js index 282df97..c5829e9 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -1,37 +1,58 @@ -async function drawGraph(url, baseUrl, pathColors, depth, enableDrag, enableLegend, enableZoom) { - const { index, links, content } = await fetchData - const curPage = url.replace(baseUrl, "") +async function drawGraph( + url, + baseUrl, + pathColors, + depth, + enableDrag, + enableLegend, + enableZoom +) { + const container = document.getElementById('graph-container') - const parseIdsFromLinks = (links) => [...(new Set(links.flatMap(link => ([link.source, link.target]))))] + const { index, links, content } = await fetchData + const curPage = url.replace(baseUrl, '') + + const parseIdsFromLinks = (links) => [ + ...new Set(links.flatMap((link) => [link.source, link.target])), + ] + + // Links is mutated by d3. We want to use links later on, so we make a copy and pass that one to d3 + // Note: shallow cloning does not work because it copies over references from the original array + const copyLinks = JSON.parse(JSON.stringify(links)) const neighbours = new Set() - const wl = [curPage || "/", "__SENTINEL"] + const wl = [curPage || '/', '__SENTINEL'] if (depth >= 0) { while (depth >= 0 && wl.length > 0) { // compute neighbours const cur = wl.shift() - if (cur === "__SENTINEL") { + if (cur === '__SENTINEL') { depth-- - wl.push("__SENTINEL") + wl.push('__SENTINEL') } else { neighbours.add(cur) const outgoing = index.links[cur] || [] const incoming = index.backlinks[cur] || [] - wl.push(...outgoing.map(l => l.target), ...incoming.map(l => l.source)) + wl.push( + ...outgoing.map((l) => l.target), + ...incoming.map((l) => l.source) + ) } } } else { - parseIdsFromLinks(links).forEach(id => neighbours.add(id)) + parseIdsFromLinks(copyLinks).forEach((id) => neighbours.add(id)) } const data = { - nodes: [...neighbours].map(id => ({ id })), - links: links.filter(l => neighbours.has(l.source) && neighbours.has(l.target)), + nodes: [...neighbours].map((id) => ({ id })), + links: copyLinks.filter( + (l) => neighbours.has(l.source) && neighbours.has(l.target) + ), } const color = (d) => { - if (d.id === curPage || (d.id === "/" && curPage === "")) { - return "var(--g-node-active)" + if (d.id === curPage || (d.id === '/' && curPage === '')) { + return 'var(--g-node-active)' } for (const pathColor of pathColors) { @@ -42,10 +63,10 @@ async function drawGraph(url, baseUrl, pathColors, depth, enableDrag, enableLege } } - return "var(--g-node)" + return 'var(--g-node)' } - const drag = simulation => { + const drag = (simulation) => { function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(1).restart() d.fx = d.x @@ -64,56 +85,79 @@ async function drawGraph(url, baseUrl, pathColors, depth, enableDrag, enableLege } const noop = () => { } - return d3.drag() - .on("start", enableDrag ? dragstarted : noop) - .on("drag", enableDrag ? dragged : noop) - .on("end", enableDrag ? dragended : noop) + return d3 + .drag() + .on('start', enableDrag ? dragstarted : noop) + .on('drag', enableDrag ? dragged : noop) + .on('end', enableDrag ? dragended : noop) } - const height = Math.max(document.getElementById("graph-container").offsetHeight, 250) - const width = document.getElementById("graph-container").offsetWidth + const height = Math.max(container.offsetHeight, 250) + const width = container.offsetWidth - const simulation = d3.forceSimulation(data.nodes) - .force("charge", d3.forceManyBody().strength(-30)) - .force("link", d3.forceLink(data.links).id(d => d.id).distance(40)) - .force("center", d3.forceCenter()) + const simulation = d3 + .forceSimulation(data.nodes) + .force('charge', d3.forceManyBody().strength(-30)) + .force( + 'link', + d3 + .forceLink(data.links) + .id((d) => d.id) + .distance(40) + ) + .force('center', d3.forceCenter()) - const svg = d3.select('#graph-container') + const svg = d3 + .select('#graph-container') .append('svg') .attr('width', width) .attr('height', height) - .attr("viewBox", [-width / 2, -height / 2, width, height]) + .attr('viewBox', [-width / 2, -height / 2, width, height]) if (enableLegend) { const legend = [ - { "Current": "var(--g-node-active)" }, - { "Note": "var(--g-node)" }, - ...pathColors + { Current: 'var(--g-node-active)' }, + { Note: 'var(--g-node)' }, + ...pathColors, ] legend.forEach((legendEntry, i) => { const key = Object.keys(legendEntry)[0] const colour = legendEntry[key] - svg.append("circle").attr("cx", -width / 2 + 20).attr("cy", height / 2 - 30 * (i + 1)).attr("r", 6).style("fill", colour) - svg.append("text").attr("x", -width / 2 + 40).attr("y", height / 2 - 30 * (i + 1)).text(key).style("font-size", "15px").attr("alignment-baseline", "middle") + svg + .append('circle') + .attr('cx', -width / 2 + 20) + .attr('cy', height / 2 - 30 * (i + 1)) + .attr('r', 6) + .style('fill', colour) + svg + .append('text') + .attr('x', -width / 2 + 40) + .attr('y', height / 2 - 30 * (i + 1)) + .text(key) + .style('font-size', '15px') + .attr('alignment-baseline', 'middle') }) } // draw links between nodes - const link = svg.append("g") - .selectAll("line") + const link = svg + .append('g') + .selectAll('line') .data(data.links) - .join("line") - .attr("class", "link") - .attr("stroke", "var(--g-link)") - .attr("stroke-width", 2) - .attr("data-source", d => d.source.id) - .attr("data-target", d => d.target.id) + .join('line') + .attr('class', 'link') + .attr('stroke', 'var(--g-link)') + .attr('stroke-width', 2) + .attr('data-source', (d) => d.source.id) + .attr('data-target', (d) => d.target.id) // svg groups - const graphNode = svg.append("g") - .selectAll("g") + const graphNode = svg + .append('g') + .selectAll('g') .data(data.nodes) - .enter().append("g") + .enter() + .append('g') // calculate radius const nodeRadius = (d) => { @@ -123,109 +167,115 @@ async function drawGraph(url, baseUrl, pathColors, depth, enableDrag, enableLege } // draw individual nodes - const node = graphNode.append("circle") - .attr("class", "node") - .attr("id", (d) => d.id) - .attr("r", nodeRadius) - .attr("fill", color) - .style("cursor", "pointer") - .on("click", (_, d) => { - window.location.href = `${baseUrl}/${decodeURI(d.id).replace(/\s+/g, '-')}/` + const node = graphNode + .append('circle') + .attr('class', 'node') + .attr('id', (d) => d.id) + .attr('r', nodeRadius) + .attr('fill', color) + .style('cursor', 'pointer') + .on('click', (_, d) => { + // SPA navigation + window.navigate( + new URL(`${baseUrl}${decodeURI(d.id).replace(/\s+/g, '-')}/`), + '.singlePage' + ) }) - .on("mouseover", function(_, d) { - d3.selectAll(".node") + .on('mouseover', function(_, d) { + d3.selectAll('.node') .transition() .duration(100) - .attr("fill", "var(--g-node-inactive)") + .attr('fill', 'var(--g-node-inactive)') - const neighbours = parseIdsFromLinks([...(index.links[d.id] || []), ...(index.backlinks[d.id] || [])]) - const neighbourNodes = d3.selectAll(".node").filter(d => neighbours.includes(d.id)) + const neighbours = parseIdsFromLinks([ + ...(index.links[d.id] || []), + ...(index.backlinks[d.id] || []), + ]) + const neighbourNodes = d3 + .selectAll('.node') + .filter((d) => neighbours.includes(d.id)) const currentId = d.id - const linkNodes = d3.selectAll(".link").filter(d => d.source.id === currentId || d.target.id === currentId) + const linkNodes = d3 + .selectAll('.link') + .filter((d) => d.source.id === currentId || d.target.id === currentId) // highlight neighbour nodes - neighbourNodes - .transition() - .duration(200) - .attr("fill", color) + neighbourNodes.transition().duration(200).attr('fill', color) // highlight links linkNodes .transition() .duration(200) - .attr("stroke", "var(--g-link-active)") + .attr('stroke', 'var(--g-link-active)') // show text for self d3.select(this.parentNode) .raise() - .select("text") + .select('text') .transition() .duration(200) - .style("opacity", 1) - .raise() - }).on("mouseleave", function(_, d) { - d3.selectAll(".node") - .transition() - .duration(200) - .attr("fill", color) + .style('opacity', 1) + }) + .on('mouseleave', function(_, d) { + d3.selectAll('.node').transition().duration(200).attr('fill', color) const currentId = d.id - const linkNodes = d3.selectAll(".link").filter(d => d.source.id === currentId || d.target.id === currentId) + const linkNodes = d3 + .selectAll('.link') + .filter((d) => d.source.id === currentId || d.target.id === currentId) - linkNodes - .transition() - .duration(200) - .attr("stroke", "var(--g-link)") + linkNodes.transition().duration(200).attr('stroke', 'var(--g-link)') d3.select(this.parentNode) - .select("text") + .select('text') .transition() .duration(200) - .style("opacity", 0) + .style('opacity', 0) }) .call(drag(simulation)) // draw labels - const labels = graphNode.append("text") - .attr("dx", 0) - .attr("dy", d => nodeRadius(d) + 8 + "px") - .attr("text-anchor", "middle") - .text((d) => content[d.id]?.title || d.id.replace("-", " ")) - .style("opacity", 0) - .style("pointer-events", "none") - .style("font-size", "0.4em") + const labels = graphNode + .append('text') + .attr('dx', 0) + .attr('dy', (d) => nodeRadius(d) + 8 + 'px') + .attr('text-anchor', 'middle') + .text((d) => content[d.id]?.title || d.id.replace('-', ' ')) + .style('opacity', 0) + .style('pointer-events', 'none') + .style('font-size', '0.4em') .raise() .call(drag(simulation)) // set panning if (enableZoom) { - svg.call(d3.zoom() - .extent([[0, 0], [width, height]]) - .scaleExtent([0.25, 4]) - .on("zoom", ({ transform }) => { - link.attr("transform", transform) - node.attr("transform", transform) - const scale = transform.k - const scaledOpacity = Math.max((scale - 1) / 3.75, 0) - labels - .attr("transform", transform) - .style("opacity", scaledOpacity) - })) + svg.call( + d3 + .zoom() + .extent([ + [0, 0], + [width, height], + ]) + .scaleExtent([0.25, 4]) + .on('zoom', ({ transform }) => { + link.attr('transform', transform) + node.attr('transform', transform) + const scale = transform.k + const scaledOpacity = Math.max((scale - 1) / 3.75, 0) + labels.attr('transform', transform).style('opacity', scaledOpacity) + }) + ) } // progress the simulation - simulation.on("tick", () => { + simulation.on('tick', () => { link - .attr("x1", d => d.source.x) - .attr("y1", d => d.source.y) - .attr("x2", d => d.target.x) - .attr("y2", d => d.target.y) - node - .attr("cx", d => d.x) - .attr("cy", d => d.y) - labels - .attr("x", d => d.x) - .attr("y", d => d.y) + .attr('x1', (d) => d.source.x) + .attr('y1', (d) => d.source.y) + .attr('x2', (d) => d.target.x) + .attr('y2', (d) => d.target.y) + node.attr('cx', (d) => d.x).attr('cy', (d) => d.y) + labels.attr('x', (d) => d.x).attr('y', (d) => d.y) }) } diff --git a/assets/js/search.js b/assets/js/search.js index 9612e6c..999efc8 100644 --- a/assets/js/search.js +++ b/assets/js/search.js @@ -9,47 +9,43 @@ const removeMarkdown = ( preserveLinks: false, } ) => { - let output = markdown || "" - output = output.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, "") + let output = markdown || '' + output = output.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, '') try { if (options.stripListLeaders) { if (options.listUnicodeChar) output = output.replace( /^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, - options.listUnicodeChar + " $1" + options.listUnicodeChar + ' $1' ) - else output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, "$1") + else output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, '$1') } if (options.gfm) { output = output - .replace(/\n={2,}/g, "\n") - .replace(/~{3}.*\n/g, "") - .replace(/~~/g, "") - .replace(/`{3}.*\n/g, "") + .replace(/\n={2,}/g, '\n') + .replace(/~{3}.*\n/g, '') + .replace(/~~/g, '') + .replace(/`{3}.*\n/g, '') } if (options.preserveLinks) { - output = output.replace(/\[(.*?)\][\[\(](.*?)[\]\)]/g, "$1 ($2)") + output = output.replace(/\[(.*?)\][\[\(](.*?)[\]\)]/g, '$1 ($2)') } output = output - .replace(/<[^>]*>/g, "") - .replace(/^[=\-]{2,}\s*$/g, "") - .replace(/\[\^.+?\](\: .*?$)?/g, "") - .replace(/\s{0,2}\[.*?\]: .*?$/g, "") - .replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? "$1" : "") - .replace(/\[(.*?)\][\[\(].*?[\]\)]/g, "$1") - .replace(/^\s{0,3}>\s?/g, "") - .replace(/(^|\n)\s{0,3}>\s?/g, "\n\n") - .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "") - .replace( - /^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} {0,}(\n)?\s{0,}$/gm, - "$1$2$3" - ) - .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2") - .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2") - .replace(/(`{3,})(.*?)\1/gm, "$2") - .replace(/`(.+?)`/g, "$1") - .replace(/\n{2,}/g, "\n\n") + .replace(/<[^>]*>/g, '') + .replace(/^[=\-]{2,}\s*$/g, '') + .replace(/\[\^.+?\](\: .*?$)?/g, '') + .replace(/\s{0,2}\[.*?\]: .*?$/g, '') + .replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? '$1' : '') + .replace(/\[(.*?)\][\[\(].*?[\]\)]/g, '$1') + .replace(/^\s{0,3}>\s?/g, '') + .replace(/(^|\n)\s{0,3}>\s?/g, '\n\n') + .replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, '') + .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, '$2') + .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, '$2') + .replace(/(`{3,})(.*?)\1/gm, '$2') + .replace(/`(.+?)`/g, '$1') + .replace(/\n{2,}/g, '\n\n') } catch (e) { console.error(e) return markdown @@ -59,20 +55,23 @@ const removeMarkdown = ( // ----- (async function() { - const encoder = str => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/) + const encoder = (str) => str.toLowerCase().split(/([^a-z]|[^\x00-\x7F])+/) const contentIndex = new FlexSearch.Document({ cache: true, - charset: "latin:extra", + charset: 'latin:extra', optimize: true, - index: [{ - field: "content", - tokenize: "reverse", - encode: encoder, - }, { - field: "title", - tokenize: "forward", - encode: encoder, - }] + index: [ + { + field: 'content', + tokenize: 'reverse', + encode: encoder, + }, + { + field: 'title', + tokenize: 'forward', + encode: encoder, + }, + ], }) const { content } = await fetchData @@ -86,17 +85,23 @@ const removeMarkdown = ( const highlight = (content, term) => { const highlightWindow = 20 - const tokenizedTerm = term.split(/\s+/).filter(t => t !== "") - const splitText = content.split(/\s+/).filter(t => t !== "") - const includesCheck = (token) => tokenizedTerm.some(term => token.toLowerCase().startsWith(term.toLowerCase())) + const tokenizedTerm = term.split(/\s+/).filter((t) => t !== '') + const splitText = content.split(/\s+/).filter((t) => t !== '') + const includesCheck = (token) => + tokenizedTerm.some((term) => + token.toLowerCase().startsWith(term.toLowerCase()) + ) - const occurrencesIndices = splitText - .map(includesCheck) + const occurrencesIndices = splitText.map(includesCheck) // calculate best index let bestSum = 0 let bestIndex = 0 - for (let i = 0; i < Math.max(occurrencesIndices.length - highlightWindow, 0); i++) { + for ( + let i = 0; + i < Math.max(occurrencesIndices.length - highlightWindow, 0); + i++ + ) { const window = occurrencesIndices.slice(i, i + highlightWindow) const windowSum = window.reduce((total, cur) => total + cur, 0) if (windowSum >= bestSum) { @@ -106,18 +111,22 @@ const removeMarkdown = ( } const startIndex = Math.max(bestIndex - highlightWindow, 0) - const endIndex = Math.min(startIndex + 2 * highlightWindow, splitText.length) + const endIndex = Math.min( + startIndex + 2 * highlightWindow, + splitText.length + ) const mappedText = splitText .slice(startIndex, endIndex) - .map(token => { + .map((token) => { if (includesCheck(token)) { return `${token}` } return token }) - .join(" ") - .replaceAll(' ', " ") - return `${startIndex === 0 ? "" : "..."}${mappedText}${endIndex === splitText.length ? "" : "..."}` + .join(' ') + .replaceAll(' ', ' ') + return `${startIndex === 0 ? '' : '...'}${mappedText}${endIndex === splitText.length ? '' : '...' + }` } const resultToHTML = ({ url, title, content, term }) => { @@ -131,22 +140,29 @@ const removeMarkdown = ( } const redir = (id, term) => { - window.location.href = `${BASE_URL}${id}#:~:text=${encodeURIComponent(term)}/` + // SPA navigation + window.navigate( + new URL( + `${BASE_URL.slice(0, -1)}${id}#:~:text=${encodeURIComponent(term)}/` + ), + '.singlePage' + ) + closeSearch() } - const formatForDisplay = id => ({ + const formatForDisplay = (id) => ({ id, url: id, title: content[id].title, - content: content[id].content + content: content[id].content, }) const source = document.getElementById('search-bar') - const results = document.getElementById("results-container") + const results = document.getElementById('results-container') let term - source.addEventListener("keyup", (e) => { - if (e.key === "Enter") { - const anchor = document.getElementsByClassName("result-card")[0] + source.addEventListener('keyup', (e) => { + if (e.key === 'Enter') { + const anchor = document.getElementsByClassName('result-card')[0] redir(anchor.id, term) } }) @@ -154,16 +170,16 @@ const removeMarkdown = ( term = e.target.value const searchResults = contentIndex.search(term, [ { - field: "content", + field: 'content', limit: 10, }, { - field: "title", + field: 'title', limit: 5, - } + }, ]) - const getByField = field => { - const results = searchResults.filter(x => x.field === field) + const getByField = (field) => { + const results = searchResults.filter((x) => x.field === field) if (results.length === 0) { return [] } else { @@ -181,48 +197,52 @@ const removeMarkdown = ( ` } else { results.innerHTML = finalResults - .map(result => resultToHTML({ - ...result, - term, - })) - .join("\n") - const anchors = [...document.getElementsByClassName("result-card")] - anchors.forEach(anchor => { + .map((result) => + resultToHTML({ + ...result, + term, + }) + ) + .join('\n') + const anchors = [...document.getElementsByClassName('result-card')] + anchors.forEach((anchor) => { anchor.onclick = () => redir(anchor.id, term) }) } }) - - const searchContainer = document.getElementById("search-container") + const searchContainer = document.getElementById('search-container') function openSearch() { - if (searchContainer.style.display === "none" || searchContainer.style.display === "") { - source.value = "" - results.innerHTML = "" - searchContainer.style.display = "block" + if ( + searchContainer.style.display === 'none' || + searchContainer.style.display === '' + ) { + source.value = '' + results.innerHTML = '' + searchContainer.style.display = 'block' source.focus() } else { - searchContainer.style.display = "none" + searchContainer.style.display = 'none' } } function closeSearch() { - searchContainer.style.display = "none" + searchContainer.style.display = 'none' } document.addEventListener('keydown', (event) => { - if (event.key === "k" && (event.ctrlKey || event.metaKey)) { + if (event.key === 'k' && (event.ctrlKey || event.metaKey)) { event.preventDefault() openSearch() } - if (event.key === "Escape") { + if (event.key === 'Escape') { event.preventDefault() closeSearch() } }) - const searchButton = document.getElementById("search-icon") + const searchButton = document.getElementById('search-icon') searchButton.addEventListener('click', (evt) => { openSearch() }) @@ -232,8 +252,7 @@ const removeMarkdown = ( searchContainer.addEventListener('click', (evt) => { closeSearch() }) - document.getElementById("search-space").addEventListener('click', (evt) => { + document.getElementById('search-space').addEventListener('click', (evt) => { evt.stopPropagation() }) })() - diff --git a/layouts/partials/graph.html b/layouts/partials/graph.html index ca37968..b9f7976 100644 --- a/layouts/partials/graph.html +++ b/layouts/partials/graph.html @@ -1,25 +1,18 @@ - +

Interactive Graph

{{ $js := resources.Get "js/graph.js" | resources.Fingerprint "md5" }} - diff --git a/layouts/partials/head.html b/layouts/partials/head.html index 195cade..1dabaf6 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -1,46 +1,93 @@ - - - - {{ if .Title }}{{ .Title }}{{ else }}{{ $.Site.Data.config.page_title }}{{ end }} - - + + + + + {{ if .Title }}{{ .Title }}{{ else }}{{ $.Site.Data.config.page_title }}{{ + end }} + + + - - - {{$sass := resources.Match "styles/[!_]*.scss" }} - {{$css := slice }} - {{range $sass}} - {{$scss := . | resources.ToCSS (dict "outputStyle" "compressed") }} - {{$css = $css | append $scss}} - {{end}} - {{$finalCss := $css | resources.Concat "styles.css" | resources.Fingerprint "md5" | resources.Minify }} - + + + {{$sass := resources.Match "styles/[!_]*.scss" }} + {{$css := slice }} + {{range $sass}} + {{$scss := . | resources.ToCSS (dict "outputStyle" "compressed") }} + {{$css = $css | append $scss}} + {{end}} + {{$finalCss := $css | resources.Concat "styles.css" | resources.Fingerprint "md5" | resources.Minify }} + - {{ $darkMode := resources.Get "js/darkmode.js" | resources.Fingerprint "md5" | resources.Minify }} - - {{partial "katex.html" .}} + {{ $darkMode := resources.Get "js/darkmode.js" | resources.Fingerprint "md5" | + resources.Minify }} + + {{partial "katex.html" .}} - - {{$linkIndex := resources.Get "indices/linkIndex.json" | resources.Fingerprint "md5" | resources.Minify | }} - {{$contentIndex := resources.Get "indices/contentIndex.json" | resources.Fingerprint "md5" | resources.Minify }} - + + {{$linkIndex := resources.Get "indices/linkIndex.json" | resources.Fingerprint + "md5" | resources.Minify | }} {{$contentIndex := resources.Get + "indices/contentIndex.json" | resources.Fingerprint "md5" | resources.Minify + }} + + {{ template "_internal/google_analytics.html" . }}