From fbc45548f7ee80715ec74d8c249c662a26f7feae Mon Sep 17 00:00:00 2001
From: Aaron Pham <contact@aarnphm.xyz>
Date: Sat, 01 Feb 2025 21:22:29 +0000
Subject: [PATCH] feat(graph): enable radial mode (#1738)

---
 quartz/components/scripts/graph.inline.ts |  709 ++++++++++++++++++++++++++++++++++++++++++----------------
 1 files changed, 515 insertions(+), 194 deletions(-)

diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index 40665ca..16ee33f 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -1,31 +1,78 @@
-import { ContentDetails } from "../../plugins/emitters/contentIndex"
-import * as d3 from 'd3'
+import type { ContentDetails } from "../../plugins/emitters/contentIndex"
+import {
+  SimulationNodeDatum,
+  SimulationLinkDatum,
+  Simulation,
+  forceSimulation,
+  forceManyBody,
+  forceCenter,
+  forceLink,
+  forceCollide,
+  forceRadial,
+  zoomIdentity,
+  select,
+  drag,
+  zoom,
+} from "d3"
+import { Text, Graphics, Application, Container, Circle } from "pixi.js"
+import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
+import { registerEscapeHandler, removeAllChildren } from "./util"
+import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
+import { D3Config } from "../Graph"
+
+type GraphicsInfo = {
+  color: string
+  gfx: Graphics
+  alpha: number
+  active: boolean
+}
 
 type NodeData = {
-  id: string,
-  text: string,
+  id: SimpleSlug
+  text: string
   tags: string[]
-} & d3.SimulationNodeDatum
+} & SimulationNodeDatum
+
+type SimpleLinkData = {
+  source: SimpleSlug
+  target: SimpleSlug
+}
 
 type LinkData = {
-  source: string,
-  target: string
+  source: NodeData
+  target: NodeData
+} & SimulationLinkDatum<NodeData>
+
+type LinkRenderData = GraphicsInfo & {
+  simulationData: LinkData
 }
 
-function relative(from: string, to: string) {
-  const pieces = [location.protocol, '//', location.host, location.pathname]
-  const url = pieces.join('').slice(0, -from.length) + to
-  return url
+type NodeRenderData = GraphicsInfo & {
+  simulationData: NodeData
+  label: Text
 }
 
-function removeAllChildren(node: HTMLElement) {
-  while (node.firstChild) {
-    node.removeChild(node.firstChild)
-  }
+const localStorageKey = "graph-visited"
+function getVisited(): Set<SimpleSlug> {
+  return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
 }
 
-async function renderGraph(container: string, slug: string) {
-  const graph = document.getElementById(container)!
+function addToVisited(slug: SimpleSlug) {
+  const visited = getVisited()
+  visited.add(slug)
+  localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
+}
+
+type TweenNode = {
+  update: (time: number) => void
+  stop: () => void
+}
+
+async function renderGraph(container: string, fullSlug: FullSlug) {
+  const slug = simplifySlug(fullSlug)
+  const visited = getVisited()
+  const graph = document.getElementById(container)
+  if (!graph) return
   removeAllChildren(graph)
 
   let {
@@ -37,251 +84,525 @@
     centerForce,
     linkDistance,
     fontSize,
-    opacityScale
-  } = JSON.parse(graph.dataset["cfg"]!)
+    opacityScale,
+    removeTags,
+    showTags,
+    focusOnHover,
+    enableRadial,
+  } = JSON.parse(graph.dataset["cfg"]!) as D3Config
 
-  const data = await fetchData
+  const data: Map<SimpleSlug, ContentDetails> = new Map(
+    Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
+      simplifySlug(k as FullSlug),
+      v,
+    ]),
+  )
+  const links: SimpleLinkData[] = []
+  const tags: SimpleSlug[] = []
+  const validLinks = new Set(data.keys())
 
-  const links: LinkData[] = []
-  for (const [src, details] of Object.entries<ContentDetails>(data)) {
+  const tweens = new Map<string, TweenNode>()
+  for (const [source, details] of data.entries()) {
     const outgoing = details.links ?? []
+
     for (const dest of outgoing) {
-      if (src in data && dest in data) {
-        links.push({ source: src, target: dest })
+      if (validLinks.has(dest)) {
+        links.push({ source: source, target: dest })
+      }
+    }
+
+    if (showTags) {
+      const localTags = details.tags
+        .filter((tag) => !removeTags.includes(tag))
+        .map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
+
+      tags.push(...localTags.filter((tag) => !tags.includes(tag)))
+
+      for (const tag of localTags) {
+        links.push({ source: source, target: tag })
       }
     }
   }
 
-  const neighbourhood = new Set()
-
-  const wl = [slug, "__SENTINEL"]
+  const neighbourhood = new Set<SimpleSlug>()
+  const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
   if (depth >= 0) {
     while (depth >= 0 && wl.length > 0) {
       // compute neighbours
-      const cur = wl.shift()
+      const cur = wl.shift()!
       if (cur === "__SENTINEL") {
         depth--
         wl.push("__SENTINEL")
       } else {
         neighbourhood.add(cur)
-        const outgoing = links.filter(l => l.source === cur)
-        const incoming = links.filter(l => l.target === cur)
+        const outgoing = links.filter((l) => l.source === cur)
+        const incoming = links.filter((l) => l.target === cur)
         wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
       }
     }
   } else {
-    links.flatMap(l => [l.source, l.target]).forEach((id) => neighbourhood.add(id))
+    validLinks.forEach((id) => neighbourhood.add(id))
+    if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
   }
 
-  const graphData: { nodes: NodeData[], links: LinkData[] } = {
-    nodes: Object.keys(data).filter(id => neighbourhood.has(id)).map(url => ({ id: url, text: data[url]?.title ?? url, tags: data[url]?.tags ?? [] })),
-    links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
+  const nodes = [...neighbourhood].map((url) => {
+    const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
+    return {
+      id: url,
+      text,
+      tags: data.get(url)?.tags ?? [],
+    }
+  })
+  const graphData: { nodes: NodeData[]; links: LinkData[] } = {
+    nodes,
+    links: links
+      .filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
+      .map((l) => ({
+        source: nodes.find((n) => n.id === l.source)!,
+        target: nodes.find((n) => n.id === l.target)!,
+      })),
   }
 
-  const simulation: d3.Simulation<NodeData, LinkData> = d3
-    .forceSimulation(graphData.nodes)
-    .force("charge", d3.forceManyBody().strength(-100 * repelForce))
-    .force(
-      "link",
-      d3
-        .forceLink(graphData.links)
-        .id((d: any) => d.id)
-        .distance(linkDistance),
-    )
-    .force("center", d3.forceCenter().strength(centerForce))
-
-  const height = Math.max(graph.offsetHeight, 250)
   const width = graph.offsetWidth
+  const height = Math.max(graph.offsetHeight, 250)
 
-  const svg = d3
-    .select<HTMLElement, NodeData>('#' + container)
-    .append("svg")
-    .attr("width", width)
-    .attr("height", height)
-    .attr('viewBox', [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale])
+  // we virtualize the simulation and use pixi to actually render it
+  // Calculate the radius of the container circle
+  const radius = Math.min(width, height) / 2 - 40 // 40px padding
+  const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
+    .force("charge", forceManyBody().strength(-100 * repelForce))
+    .force("center", forceCenter().strength(centerForce))
+    .force("link", forceLink(graphData.links).distance(linkDistance))
+    .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
 
-  // draw links between nodes
-  const link = svg
-    .append("g")
-    .selectAll("line")
-    .data(graphData.links)
-    .join("line")
-    .attr("class", "link")
-    .attr("stroke", "var(--lightgray)")
-    .attr("stroke-width", 2)
+  if (enableRadial)
+    simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
 
-  // svg groups
-  const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
+  // precompute style prop strings as pixi doesn't support css variables
+  const cssVars = [
+    "--secondary",
+    "--tertiary",
+    "--gray",
+    "--light",
+    "--lightgray",
+    "--dark",
+    "--darkgray",
+    "--bodyFont",
+  ] as const
+  const computedStyleMap = cssVars.reduce(
+    (acc, key) => {
+      acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
+      return acc
+    },
+    {} as Record<(typeof cssVars)[number], string>,
+  )
 
-  // calculate radius
+  // calculate color
   const color = (d: NodeData) => {
-    // TODO: does this handle the index page
     const isCurrent = d.id === slug
-    return isCurrent ? "var(--secondary)" : "var(--gray)"
-  }
-
-  const drag = (simulation: d3.Simulation<NodeData, LinkData>) => {
-    function dragstarted(event: any, d: NodeData) {
-      if (!event.active) simulation.alphaTarget(1).restart()
-      d.fx = d.x
-      d.fy = d.y
+    if (isCurrent) {
+      return computedStyleMap["--secondary"]
+    } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
+      return computedStyleMap["--tertiary"]
+    } else {
+      return computedStyleMap["--gray"]
     }
-
-    function dragged(event: any, d: NodeData) {
-      d.fx = event.x
-      d.fy = event.y
-    }
-
-    function dragended(event: any, d: NodeData) {
-      if (!event.active) simulation.alphaTarget(0)
-      d.fx = null
-      d.fy = null
-    }
-
-    const noop = () => { }
-    return d3
-      .drag<Element, NodeData>()
-      .on("start", enableDrag ? dragstarted : noop)
-      .on("drag", enableDrag ? dragged : noop)
-      .on("end", enableDrag ? dragended : noop)
   }
 
   function nodeRadius(d: NodeData) {
-    const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length
+    const numLinks = graphData.links.filter(
+      (l) => l.source.id === d.id || l.target.id === d.id,
+    ).length
     return 2 + Math.sqrt(numLinks)
   }
 
-  // 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) => {
-      const targ = relative(slug, d.id)
-      window.spaNavigate(new URL(targ))
+  let hoveredNodeId: string | null = null
+  let hoveredNeighbours: Set<string> = new Set()
+  const linkRenderData: LinkRenderData[] = []
+  const nodeRenderData: NodeRenderData[] = []
+  function updateHoverInfo(newHoveredId: string | null) {
+    hoveredNodeId = newHoveredId
+
+    if (newHoveredId === null) {
+      hoveredNeighbours = new Set()
+      for (const n of nodeRenderData) {
+        n.active = false
+      }
+
+      for (const l of linkRenderData) {
+        l.active = false
+      }
+    } else {
+      hoveredNeighbours = new Set()
+      for (const l of linkRenderData) {
+        const linkData = l.simulationData
+        if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
+          hoveredNeighbours.add(linkData.source.id)
+          hoveredNeighbours.add(linkData.target.id)
+        }
+
+        l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
+      }
+
+      for (const n of nodeRenderData) {
+        n.active = hoveredNeighbours.has(n.simulationData.id)
+      }
+    }
+  }
+
+  let dragStartTime = 0
+  let dragging = false
+
+  function renderLinks() {
+    tweens.get("link")?.stop()
+    const tweenGroup = new TweenGroup()
+
+    for (const l of linkRenderData) {
+      let alpha = 1
+
+      // if we are hovering over a node, we want to highlight the immediate neighbours
+      // with full alpha and the rest with default alpha
+      if (hoveredNodeId) {
+        alpha = l.active ? 1 : 0.2
+      }
+
+      l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
+      tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
+    }
+
+    tweenGroup.getAll().forEach((tw) => tw.start())
+    tweens.set("link", {
+      update: tweenGroup.update.bind(tweenGroup),
+      stop() {
+        tweenGroup.getAll().forEach((tw) => tw.stop())
+      },
     })
-    .on("mouseover", function(_, d) {
-      const neighbours: string[] = data[slug].links ?? []
-      const neighbourNodes = d3.selectAll<HTMLElement, NodeData>(".node").filter((d) => neighbours.includes(d.id))
-      const currentId = d.id
-      const linkNodes = d3
-        .selectAll(".link")
-        .filter((d: any) => d.source.id === currentId || d.target.id === currentId)
+  }
 
-      // highlight neighbour nodes
-      neighbourNodes.transition().duration(200).attr("fill", color)
+  function renderLabels() {
+    tweens.get("label")?.stop()
+    const tweenGroup = new TweenGroup()
 
-      // highlight links
-      linkNodes.transition().duration(200).attr("stroke", "var(--gray)")
+    const defaultScale = 1 / scale
+    const activeScale = defaultScale * 1.1
+    for (const n of nodeRenderData) {
+      const nodeId = n.simulationData.id
 
-      const bigFont = fontSize * 1.5
+      if (hoveredNodeId === nodeId) {
+        tweenGroup.add(
+          new Tweened<Text>(n.label).to(
+            {
+              alpha: 1,
+              scale: { x: activeScale, y: activeScale },
+            },
+            100,
+          ),
+        )
+      } else {
+        tweenGroup.add(
+          new Tweened<Text>(n.label).to(
+            {
+              alpha: n.label.alpha,
+              scale: { x: defaultScale, y: defaultScale },
+            },
+            100,
+          ),
+        )
+      }
+    }
 
-      // show text for self
-      const parent = this.parentNode as HTMLElement
-      d3.select<HTMLElement, NodeData>(parent)
-        .raise()
-        .select("text")
-        .transition()
-        .duration(200)
-        .attr('opacityOld', d3.select(parent).select('text').style("opacity"))
-        .style('opacity', 1)
-        .style('font-size', bigFont + 'em')
+    tweenGroup.getAll().forEach((tw) => tw.start())
+    tweens.set("label", {
+      update: tweenGroup.update.bind(tweenGroup),
+      stop() {
+        tweenGroup.getAll().forEach((tw) => tw.stop())
+      },
     })
-    .on("mouseleave", function(_, d) {
-      const currentId = d.id
-      const linkNodes = d3
-        .selectAll(".link")
-        .filter((d: any) => d.source.id === currentId || d.target.id === currentId)
+  }
 
-      linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)")
+  function renderNodes() {
+    tweens.get("hover")?.stop()
 
-      const parent = this.parentNode as HTMLElement
-      d3.select<HTMLElement, NodeData>(parent)
-        .select("text")
-        .transition()
-        .duration(200)
-        .style('opacity', d3.select(parent).select('text').attr("opacityOld"))
-        .style('font-size', fontSize + 'em')
+    const tweenGroup = new TweenGroup()
+    for (const n of nodeRenderData) {
+      let alpha = 1
+
+      // if we are hovering over a node, we want to highlight the immediate neighbours
+      if (hoveredNodeId !== null && focusOnHover) {
+        alpha = n.active ? 1 : 0.2
+      }
+
+      tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
+    }
+
+    tweenGroup.getAll().forEach((tw) => tw.start())
+    tweens.set("hover", {
+      update: tweenGroup.update.bind(tweenGroup),
+      stop() {
+        tweenGroup.getAll().forEach((tw) => tw.stop())
+      },
     })
-    // @ts-ignore
-    .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) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " "))
-    .style('opacity', (opacityScale - 1) / 3.75)
-    .style("pointer-events", "none")
-    .style('font-size', fontSize + 'em')
-    .raise()
-    // @ts-ignore
-    .call(drag(simulation))
+  function renderPixiFromD3() {
+    renderNodes()
+    renderLinks()
+    renderLabels()
+  }
 
-  // set panning
+  tweens.forEach((tween) => tween.stop())
+  tweens.clear()
+
+  const app = new Application()
+  await app.init({
+    width,
+    height,
+    antialias: true,
+    autoStart: false,
+    autoDensity: true,
+    backgroundAlpha: 0,
+    preference: "webgpu",
+    resolution: window.devicePixelRatio,
+    eventMode: "static",
+  })
+  graph.appendChild(app.canvas)
+
+  const stage = app.stage
+  stage.interactive = false
+
+  const labelsContainer = new Container<Text>({ zIndex: 3 })
+  const nodesContainer = new Container<Graphics>({ zIndex: 2 })
+  const linkContainer = new Container<Graphics>({ zIndex: 1 })
+  stage.addChild(nodesContainer, labelsContainer, linkContainer)
+
+  for (const n of graphData.nodes) {
+    const nodeId = n.id
+
+    const label = new Text({
+      interactive: false,
+      eventMode: "none",
+      text: n.text,
+      alpha: 0,
+      anchor: { x: 0.5, y: 1.2 },
+      style: {
+        fontSize: fontSize * 15,
+        fill: computedStyleMap["--dark"],
+        fontFamily: computedStyleMap["--bodyFont"],
+      },
+      resolution: window.devicePixelRatio * 4,
+    })
+    label.scale.set(1 / scale)
+
+    let oldLabelOpacity = 0
+    const isTagNode = nodeId.startsWith("tags/")
+    const gfx = new Graphics({
+      interactive: true,
+      label: nodeId,
+      eventMode: "static",
+      hitArea: new Circle(0, 0, nodeRadius(n)),
+      cursor: "pointer",
+    })
+      .circle(0, 0, nodeRadius(n))
+      .fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
+      .stroke({ width: isTagNode ? 2 : 0, color: color(n) })
+      .on("pointerover", (e) => {
+        updateHoverInfo(e.target.label)
+        oldLabelOpacity = label.alpha
+        if (!dragging) {
+          renderPixiFromD3()
+        }
+      })
+      .on("pointerleave", () => {
+        updateHoverInfo(null)
+        label.alpha = oldLabelOpacity
+        if (!dragging) {
+          renderPixiFromD3()
+        }
+      })
+
+    nodesContainer.addChild(gfx)
+    labelsContainer.addChild(label)
+
+    const nodeRenderDatum: NodeRenderData = {
+      simulationData: n,
+      gfx,
+      label,
+      color: color(n),
+      alpha: 1,
+      active: false,
+    }
+
+    nodeRenderData.push(nodeRenderDatum)
+  }
+
+  for (const l of graphData.links) {
+    const gfx = new Graphics({ interactive: false, eventMode: "none" })
+    linkContainer.addChild(gfx)
+
+    const linkRenderDatum: LinkRenderData = {
+      simulationData: l,
+      gfx,
+      color: computedStyleMap["--lightgray"],
+      alpha: 1,
+      active: false,
+    }
+
+    linkRenderData.push(linkRenderDatum)
+  }
+
+  let currentTransform = zoomIdentity
+  if (enableDrag) {
+    select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
+      drag<HTMLCanvasElement, NodeData | undefined>()
+        .container(() => app.canvas)
+        .subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
+        .on("start", function dragstarted(event) {
+          if (!event.active) simulation.alphaTarget(1).restart()
+          event.subject.fx = event.subject.x
+          event.subject.fy = event.subject.y
+          event.subject.__initialDragPos = {
+            x: event.subject.x,
+            y: event.subject.y,
+            fx: event.subject.fx,
+            fy: event.subject.fy,
+          }
+          dragStartTime = Date.now()
+          dragging = true
+        })
+        .on("drag", function dragged(event) {
+          const initPos = event.subject.__initialDragPos
+          event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
+          event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
+        })
+        .on("end", function dragended(event) {
+          if (!event.active) simulation.alphaTarget(0)
+          event.subject.fx = null
+          event.subject.fy = null
+          dragging = false
+
+          // if the time between mousedown and mouseup is short, we consider it a click
+          if (Date.now() - dragStartTime < 500) {
+            const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
+            const targ = resolveRelative(fullSlug, node.id)
+            window.spaNavigate(new URL(targ, window.location.toString()))
+          }
+        }),
+    )
+  } else {
+    for (const node of nodeRenderData) {
+      node.gfx.on("click", () => {
+        const targ = resolveRelative(fullSlug, node.simulationData.id)
+        window.spaNavigate(new URL(targ, window.location.toString()))
+      })
+    }
+  }
+
   if (enableZoom) {
-    svg.call(
-      d3
-        .zoom<SVGSVGElement, NodeData>()
+    select<HTMLCanvasElement, NodeData>(app.canvas).call(
+      zoom<HTMLCanvasElement, NodeData>()
         .extent([
           [0, 0],
           [width, height],
         ])
         .scaleExtent([0.25, 4])
         .on("zoom", ({ transform }) => {
-          link.attr("transform", transform)
-          node.attr("transform", transform)
-          const scale = transform.k * opacityScale;
-          const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
-          labels.attr("transform", transform).style("opacity", scaledOpacity)
+          currentTransform = transform
+          stage.scale.set(transform.k, transform.k)
+          stage.position.set(transform.x, transform.y)
+
+          // zoom adjusts opacity of labels too
+          const scale = transform.k * opacityScale
+          let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
+          const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
+
+          for (const label of labelsContainer.children) {
+            if (!activeNodes.includes(label)) {
+              label.alpha = scaleOpacity
+            }
+          }
         }),
     )
   }
 
-  // progress the simulation
-  simulation.on("tick", () => {
-    link
-      .attr("x1", (d: any) => d.source.x)
-      .attr("y1", (d: any) => d.source.y)
-      .attr("x2", (d: any) => d.target.x)
-      .attr("y2", (d: any) => d.target.y)
-    node
-      .attr("cx", (d: any) => d.x)
-      .attr("cy", (d: any) => d.y)
-    labels
-      .attr("x", (d: any) => d.x)
-      .attr("y", (d: any) => d.y)
-  })
-}
+  function animate(time: number) {
+    for (const n of nodeRenderData) {
+      const { x, y } = n.simulationData
+      if (!x || !y) continue
+      n.gfx.position.set(x + width / 2, y + height / 2)
+      if (n.label) {
+        n.label.position.set(x + width / 2, y + height / 2)
+      }
+    }
 
-function renderGlobalGraph() {
-  const slug = document.body.dataset["slug"]!
-  renderGraph("global-graph-container", slug)
-  const container = document.getElementById("global-graph-outer")
-  container?.classList.add("active")
+    for (const l of linkRenderData) {
+      const linkData = l.simulationData
+      l.gfx.clear()
+      l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
+      l.gfx
+        .lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
+        .stroke({ alpha: l.alpha, width: 1, color: l.color })
+    }
 
-  function hideGlobalGraph(this: HTMLElement, e: HTMLElementEventMap["click"]) {
-    if (e.target !== this) return
-
-    container?.classList.remove("active")
-    const graph = document.getElementById("global-graph-container")!
-    removeAllChildren(graph)
+    tweens.forEach((t) => t.update(time))
+    app.renderer.render(stage)
+    requestAnimationFrame(animate)
   }
 
-  container?.removeEventListener("click", hideGlobalGraph)
-  container?.addEventListener("click", hideGlobalGraph)
+  const graphAnimationFrameHandle = requestAnimationFrame(animate)
+  window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
 }
 
-document.addEventListener("nav", async (e: unknown) => {
-  const slug = (e as CustomEventMap["nav"]).detail.url
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+  const slug = e.detail.url
+  addToVisited(simplifySlug(slug))
   await renderGraph("graph-container", slug)
 
+  // Function to re-render the graph when the theme changes
+  const handleThemeChange = () => {
+    renderGraph("graph-container", slug)
+  }
+
+  // event listener for theme change
+  document.addEventListener("themechange", handleThemeChange)
+
+  // cleanup for the event listener
+  window.addCleanup(() => {
+    document.removeEventListener("themechange", handleThemeChange)
+  })
+
+  const container = document.getElementById("global-graph-outer")
+  const sidebar = container?.closest(".sidebar") as HTMLElement
+
+  function renderGlobalGraph() {
+    const slug = getFullSlug(window)
+    container?.classList.add("active")
+    if (sidebar) {
+      sidebar.style.zIndex = "1"
+    }
+
+    renderGraph("global-graph-container", slug)
+    registerEscapeHandler(container, hideGlobalGraph)
+  }
+
+  function hideGlobalGraph() {
+    container?.classList.remove("active")
+    if (sidebar) {
+      sidebar.style.zIndex = ""
+    }
+  }
+
+  async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
+    if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
+      e.preventDefault()
+      const globalGraphOpen = container?.classList.contains("active")
+      globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
+    }
+  }
+
   const containerIcon = document.getElementById("global-graph-icon")
-  containerIcon?.removeEventListener("click", renderGlobalGraph)
   containerIcon?.addEventListener("click", renderGlobalGraph)
+  window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
+
+  document.addEventListener("keydown", shortcutHandler)
+  window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
 })

--
Gitblit v1.10.0