diff --git a/assets/js/graph.js b/assets/js/graph.js index d7e85343..c6c87185 100644 --- a/assets/js/graph.js +++ b/assets/js/graph.js @@ -1,221 +1,220 @@ async function drawGraph(url, baseUrl, pathColors, depth, enableDrag, enableLegend, enableZoom) { - const { index, links, content } = await fetchData - const curPage = url.replace(baseUrl, "") - - const parseIdsFromLinks = (links) => [...(new Set(links.flatMap(link => ([link.source, link.target]))))] - - const neighbours = new Set() - const wl = [curPage || "/", "__SENTINEL"] - if (depth >= 0) { - while (depth >= 0 && wl.length > 0) { - // compute neighbours - const cur = wl.shift() - if (cur === "__SENTINEL") { - depth-- - 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)) - } + const { index, links, content } = await fetchData + const curPage = url.replace(baseUrl, "") + + const parseIdsFromLinks = (links) => [...(new Set(links.flatMap(link => ([link.source, link.target]))))] + + const neighbours = new Set() + const wl = [curPage || "/", "__SENTINEL"] + if (depth >= 0) { + while (depth >= 0 && wl.length > 0) { + // compute neighbours + const cur = wl.shift() + if (cur === "__SENTINEL") { + depth-- + 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)) } - } else { - parseIdsFromLinks(links).forEach(id => neighbours.add(id)) } - - const data = { - nodes: [...neighbours].map(id => ({id})), - links: links.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)" - } - - for (const pathColor of pathColors) { - const path = Object.keys(pathColor)[0] - const colour = pathColor[path] - if (d.id.startsWith(path)) { - return colour - } - } - - return "var(--g-node)" - } - - const drag = simulation => { - function dragstarted(event, d) { - if (!event.active) simulation.alphaTarget(1).restart(); - d.fx = d.x; - d.fy = d.y; - } - - function dragged(event,d) { - d.fx = event.x; - d.fy = event.y; - } - - function dragended(event,d) { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - } - - const noop = () => {} - return d3.drag() - .on("start", enableDrag ? dragstarted : noop) - .on("drag", enableDrag ? dragged : noop) - .on("end", enableDrag ? dragended : noop); - } - - const height = 250 - const width = document.getElementById("graph-container").offsetWidth - - const simulation = d3.forceSimulation(data.nodes) - .force("charge", d3.forceManyBody().strength(-30)) - .force("link", d3.forceLink(data.links).id(d => d.id)) - .force("center", d3.forceCenter()); - - const svg = d3.select('#graph-container') - .append('svg') - .attr('width', width) - .attr('height', height) - .attr("viewBox", [-width / 2, -height / 2, width, height]); - - if (enableLegend) { - const legend = [ - {"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") - }) - } - - // draw links between nodes - 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) - - // svg groups - const graphNode = svg.append("g") - .selectAll("g") - .data(data.nodes) - .enter().append("g") - - // draw individual nodes - const node = graphNode.append("circle") - .attr("class", "node") - .attr("id", (d) => d.id) - .attr("r", (d) => { - const numOut = index.links[d.id]?.length || 0 - const numIn = index.backlinks[d.id]?.length || 0 - return 3 + (numOut + numIn) / 4 - }) - .attr("fill", color) - .style("cursor", "pointer") - .on("click", (_, d) => { - window.location.href = baseUrl + '/' + decodeURI(d.id).replace(/\s+/g, '-') - }) - .on("mouseover", function (_, d) { - d3.selectAll(".node") - .transition() - .duration(100) - .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 currentId = d.id - 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) - - // highlight links - linkNodes - .transition() - .duration(200) - .attr("stroke", "var(--g-link-active)") - - // show text for self - d3.select(this.parentNode) - .select("text") - .raise() - .transition() - .duration(200) - .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) - - linkNodes - .transition() - .duration(200) - .attr("stroke", "var(--g-link)") - - d3.select(this.parentNode) - .select("text") - .transition() - .duration(200) - .style("opacity", 0) - }) - .call(drag(simulation)); - - // draw labels - const labels = graphNode.append("text") - .attr("dx", 12) - .attr("dy", ".35em") - .text((d) => content[decodeURI(d.id).replace(/\s+/g, '-')]?.title || "Untitled") - .style("opacity", 0) - .style("pointer-events", "none") - .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); - labels.attr("transform", transform); - })); - } - - // progress the simulation - 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) - }); + } else { + parseIdsFromLinks(links).forEach(id => neighbours.add(id)) } - \ No newline at end of file + + const data = { + nodes: [...neighbours].map(id => ({ id })), + links: links.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)" + } + + for (const pathColor of pathColors) { + const path = Object.keys(pathColor)[0] + const colour = pathColor[path] + if (d.id.startsWith(path)) { + return colour + } + } + + return "var(--g-node)" + } + + const drag = simulation => { + function dragstarted(event, d) { + if (!event.active) simulation.alphaTarget(1).restart(); + d.fx = d.x; + d.fy = d.y; + } + + function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; + } + + function dragended(event, d) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + } + + const noop = () => { } + return d3.drag() + .on("start", enableDrag ? dragstarted : noop) + .on("drag", enableDrag ? dragged : noop) + .on("end", enableDrag ? dragended : noop); + } + + const height = 250 + const width = document.getElementById("graph-container").offsetWidth + + const simulation = d3.forceSimulation(data.nodes) + .force("charge", d3.forceManyBody().strength(-30)) + .force("link", d3.forceLink(data.links).id(d => d.id)) + .force("center", d3.forceCenter()); + + const svg = d3.select('#graph-container') + .append('svg') + .attr('width', width) + .attr('height', height) + .attr("viewBox", [-width / 2, -height / 2, width, height]); + + if (enableLegend) { + const legend = [ + { "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") + }) + } + + // draw links between nodes + 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) + + // svg groups + const graphNode = svg.append("g") + .selectAll("g") + .data(data.nodes) + .enter().append("g") + + // draw individual nodes + const node = graphNode.append("circle") + .attr("class", "node") + .attr("id", (d) => d.id) + .attr("r", (d) => { + const numOut = index.links[d.id]?.length || 0 + const numIn = index.backlinks[d.id]?.length || 0 + return 3 + (numOut + numIn) / 4 + }) + .attr("fill", color) + .style("cursor", "pointer") + .on("click", (_, d) => { + window.location.href = baseUrl + '/' + decodeURI(d.id).replace(/\s+/g, '-') + }) + .on("mouseover", function (_, d) { + d3.selectAll(".node") + .transition() + .duration(100) + .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 currentId = d.id + 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) + + // highlight links + linkNodes + .transition() + .duration(200) + .attr("stroke", "var(--g-link-active)") + + // show text for self + d3.select(this.parentNode) + .select("text") + .raise() + .transition() + .duration(200) + .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) + + linkNodes + .transition() + .duration(200) + .attr("stroke", "var(--g-link)") + + d3.select(this.parentNode) + .select("text") + .transition() + .duration(200) + .style("opacity", 0) + }) + .call(drag(simulation)); + + // draw labels + const labels = graphNode.append("text") + .attr("dx", 12) + .attr("dy", ".35em") + .text((d) => content[decodeURI(d.id).replace(/\s+/g, '-')]?.title || "Untitled") + .style("opacity", 0) + .style("pointer-events", "none") + .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); + labels.attr("transform", transform); + })); + } + + // progress the simulation + 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) + }); +} diff --git a/assets/js/popover.js b/assets/js/popover.js index 6dfd2d2d..cf6f84b7 100644 --- a/assets/js/popover.js +++ b/assets/js/popover.js @@ -5,29 +5,29 @@ function htmlToElement(html) { return template.content.firstChild } -function initPopover(base) { - const baseUrl = base.replace(window.location.origin, "") // is this useless? +function initPopover(baseURL) { + const basePath = baseURL.replace(window.location.origin, "") document.addEventListener("DOMContentLoaded", () => { - fetchData.then(({content}) => { - const links = [...document.getElementsByClassName("internal-link")] - links.forEach(li => { - const linkDest = content[li.dataset.src.replace(baseUrl, "")] - // const linkDest = content[li.dataset.src] - if (linkDest) { - const popoverElement = `
+ fetchData.then(({ content }) => { + const links = [...document.getElementsByClassName("internal-link")] + links.forEach(li => { + const linkDest = content[li.dataset.src.replace(basePath, "")] + // const linkDest = content[li.dataset.src] + if (linkDest) { + const popoverElement = `

${linkDest.title}

${removeMarkdown(linkDest.content).split(" ", 20).join(" ")}...

${new Date(linkDest.lastmodified).toLocaleDateString()}

` - const el = htmlToElement(popoverElement) - li.appendChild(el) - li.addEventListener("mouseover", () => { - el.classList.add("visible") - }) - li.addEventListener("mouseout", () => { - el.classList.remove("visible") - }) - } + const el = htmlToElement(popoverElement) + li.appendChild(el) + li.addEventListener("mouseover", () => { + el.classList.add("visible") + }) + li.addEventListener("mouseout", () => { + el.classList.remove("visible") + }) + } }) }) }) diff --git a/assets/js/search.js b/assets/js/search.js index 592d8595..34b7b621 100644 --- a/assets/js/search.js +++ b/assets/js/search.js @@ -58,190 +58,190 @@ const removeMarkdown = ( }; // ----- -(async function() { +(async function () { const contentIndex = new FlexSearch.Document({ - cache: true, - charset: "latin:extra", - optimize: true, - worker: true, - document: { - index: [{ - field: "content", - tokenize: "strict", - context: { - resolution: 5, - depth: 3, - bidirectional: true - }, - suggest: true, - }, { - field: "title", - tokenize: "forward", - }] - } + cache: true, + charset: "latin:extra", + optimize: true, + worker: true, + document: { + index: [{ + field: "content", + tokenize: "strict", + context: { + resolution: 5, + depth: 3, + bidirectional: true + }, + suggest: true, + }, { + field: "title", + tokenize: "forward", + }] + } }) const { content } = await fetchData for (const [key, value] of Object.entries(content)) { - contentIndex.add({ - id: key, - title: value.title, - content: removeMarkdown(value.content), - }) + contentIndex.add({ + id: key, + title: value.title, + content: removeMarkdown(value.content), + }) } 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 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 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++) { - const window = occurrencesIndices.slice(i, i + highlightWindow) - const windowSum = window.reduce((total, cur) => total + cur, 0) - if (windowSum >= bestSum) { - bestSum = windowSum - bestIndex = i - } - } - - const startIndex = Math.max(bestIndex - highlightWindow, 0) - const endIndex = Math.min(startIndex + 2 * highlightWindow, splitText.length) - const mappedText = splitText - .slice(startIndex, endIndex) - .map(token => { - if (includesCheck(token)) { - return `${token}` + // calculate best index + let bestSum = 0 + let bestIndex = 0 + 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) { + bestSum = windowSum + bestIndex = i + } } - return token - }) - .join(" ") - .replaceAll(' ', " ") - return `${startIndex === 0 ? "" : "..."}${mappedText}${endIndex === splitText.length ? "" : "..."}` + + const startIndex = Math.max(bestIndex - highlightWindow, 0) + const endIndex = Math.min(startIndex + 2 * highlightWindow, splitText.length) + const mappedText = splitText + .slice(startIndex, endIndex) + .map(token => { + if (includesCheck(token)) { + return `${token}` + } + return token + }) + .join(" ") + .replaceAll(' ', " ") + return `${startIndex === 0 ? "" : "..."}${mappedText}${endIndex === splitText.length ? "" : "..."}` } - const resultToHTML = ({url, title, content, term}) => { - const text = removeMarkdown(content) - const resultTitle = highlight(title, term) - const resultText = highlight(text, term) - return `` } const redir = (id, term) => { - window.location.href = BASE_URL + `${id}#:~:text=${encodeURIComponent(term)}` + window.location.href = BASE_URL + `${id}#:~:text=${encodeURIComponent(term)}` } const formatForDisplay = id => ({ - id, - url: id, - title: content[id].title, - content: content[id].content + id, + url: id, + title: content[id].title, + content: content[id].content }) const source = document.getElementById('search-bar') const results = document.getElementById("results-container") let term source.addEventListener("keyup", (e) => { - if (e.key === "Enter") { - const anchor = document.getElementsByClassName("result-card")[0] - redir(anchor.id, term) - } + if (e.key === "Enter") { + const anchor = document.getElementsByClassName("result-card")[0] + redir(anchor.id, term) + } }) source.addEventListener('input', (e) => { - term = e.target.value - contentIndex.search(term, [ - { - field: "content", - limit: 10, - suggest: true, - }, - { - field: "title", - limit: 5, - } - ]).then(searchResults => { - const getByField = field => { - const results = searchResults.filter(x => x.field === field) - if (results.length === 0) { - return [] - } else { - return [...results[0].result] - } - } - const allIds = new Set([...getByField('title'), ...getByField('content')]) - const finalResults = [...allIds].map(formatForDisplay) + term = e.target.value + contentIndex.search(term, [ + { + field: "content", + limit: 10, + suggest: true, + }, + { + field: "title", + limit: 5, + } + ]).then(searchResults => { + const getByField = field => { + const results = searchResults.filter(x => x.field === field) + if (results.length === 0) { + return [] + } else { + return [...results[0].result] + } + } + const allIds = new Set([...getByField('title'), ...getByField('content')]) + const finalResults = [...allIds].map(formatForDisplay) - // display - if (finalResults.length === 0) { - results.innerHTML = `` - } else { - results.innerHTML = finalResults - .map(result => resultToHTML({ - ...result, - term, - })) - .join("\n") - const anchors = document.getElementsByClassName("result-card"); - [...anchors].forEach(anchor => { - anchor.onclick = () => redir(anchor.id, term) + } else { + results.innerHTML = finalResults + .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") function openSearch() { - if (searchContainer.style.display === "none" || searchContainer.style.display === "") { - source.value = "" - results.innerHTML = "" - searchContainer.style.display = "block" - source.focus() - } else { - searchContainer.style.display = "none" - } + if (searchContainer.style.display === "none" || searchContainer.style.display === "") { + source.value = "" + results.innerHTML = "" + searchContainer.style.display = "block" + source.focus() + } else { + searchContainer.style.display = "none" + } } function closeSearch() { - searchContainer.style.display = "none" + searchContainer.style.display = "none" } document.addEventListener('keydown', (event) => { - if (event.key === "/") { - event.preventDefault() - openSearch() - } - if (event.key === "Escape") { - event.preventDefault() - closeSearch() - } + if (event.key === "/") { + event.preventDefault() + openSearch() + } + if (event.key === "Escape") { + event.preventDefault() + closeSearch() + } }) const searchButton = document.getElementById("search-icon") searchButton.addEventListener('click', (evt) => { - openSearch() + openSearch() }) searchButton.addEventListener('keydown', (evt) => { - openSearch() + openSearch() }) searchContainer.addEventListener('click', (evt) => { - closeSearch() + closeSearch() }) document.getElementById("search-space").addEventListener('click', (evt) => { - evt.stopPropagation() + evt.stopPropagation() }) })()