Claudio Yanes
2022-03-07 978d5ca1aee23d6663e508aa24b389b6c9003d04
Format JS
3 files modified
723 ■■■■ changed files
assets/js/graph.js 433 ●●●● patch | view | raw | blame | history
assets/js/popover.js 36 ●●●● patch | view | raw | blame | history
assets/js/search.js 254 ●●●● patch | view | raw | blame | history
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))
  }
  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)
  });
}
assets/js/popover.js
@@ -5,29 +5,29 @@
    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 = `<div class="popover">
        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 = `<div class="popover">
    <h3>${linkDest.title}</h3>
    <p>${removeMarkdown(linkDest.content).split(" ", 20).join(" ")}...</p>
    <p class="meta">${new Date(linkDest.lastmodified).toLocaleDateString()}</p>
</div>`
                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")
                    })
                }
            })
        })
    })
assets/js/search.js
@@ -58,190 +58,190 @@
};
// -----
(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 `<span class="search-highlight">${token}</span>`
        // 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('</span> <span class="search-highlight">', " ")
    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 `<span class="search-highlight">${token}</span>`
                }
                return token
            })
            .join(" ")
            .replaceAll('</span> <span class="search-highlight">', " ")
        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 `<button class="result-card" id="${url}">
    const resultToHTML = ({ url, title, content, term }) => {
        const text = removeMarkdown(content)
        const resultTitle = highlight(title, term)
        const resultText = highlight(text, term)
        return `<button class="result-card" id="${url}">
        <h3>${resultTitle}</h3>
        <p>${resultText}</p>
    </button>`
    }
    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 = `<button class="result-card">
            // display
            if (finalResults.length === 0) {
                results.innerHTML = `<button class="result-card">
                    <h3>No results.</h3>
                    <p>Try another search term?</p>
                </button>`
    } 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()
    })
})()