From 23df17233da3f16db5166cf8a05b2089bd1f006a Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Mon, 10 Mar 2025 18:39:08 +0000
Subject: [PATCH] fix(graph): make graph non-singleton, proper cleanup, fix radial

---
 quartz/components/Graph.tsx                |   10 +-
 quartz/components/styles/graph.scss        |    6 +-
 quartz/components/scripts/search.inline.ts |    4 
 quartz/components/scripts/graph.inline.ts  |  108 ++++++++++++++++++++++++-----------
 quartz/components/scripts/toc.inline.ts    |    2 
 5 files changed, 84 insertions(+), 46 deletions(-)

diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx
index e8b462d..907372e 100644
--- a/quartz/components/Graph.tsx
+++ b/quartz/components/Graph.tsx
@@ -48,7 +48,7 @@
     depth: -1,
     scale: 0.9,
     repelForce: 0.5,
-    centerForce: 0.3,
+    centerForce: 0.2,
     linkDistance: 30,
     fontSize: 0.6,
     opacityScale: 1,
@@ -67,8 +67,8 @@
       <div class={classNames(displayClass, "graph")}>
         <h3>{i18n(cfg.locale).components.graph.title}</h3>
         <div class="graph-outer">
-          <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
-          <button id="global-graph-icon" aria-label="Global Graph">
+          <div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
+          <button class="global-graph-icon" aria-label="Global Graph">
             <svg
               version="1.1"
               xmlns="http://www.w3.org/2000/svg"
@@ -95,8 +95,8 @@
             </svg>
           </button>
         </div>
-        <div id="global-graph-outer">
-          <div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
+        <div class="global-graph-outer">
+          <div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
         </div>
       </div>
     )
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index 8342460..fca7bd2 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -68,11 +68,9 @@
   stop: () => void
 }
 
-async function renderGraph(container: string, fullSlug: FullSlug) {
+async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
   const slug = simplifySlug(fullSlug)
   const visited = getVisited()
-  const graph = document.getElementById(container)
-  if (!graph) return
   removeAllChildren(graph)
 
   let {
@@ -167,16 +165,14 @@
   const height = Math.max(graph.offsetHeight, 250)
 
   // 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))
 
-  if (enableRadial)
-    simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
+  const radius = (Math.min(width, height) / 2) * 0.8
+  if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
 
   // precompute style prop strings as pixi doesn't support css variables
   const cssVars = [
@@ -524,7 +520,9 @@
     )
   }
 
+  let stopAnimation = false
   function animate(time: number) {
+    if (stopAnimation) return
     for (const n of nodeRenderData) {
       const { x, y } = n.simulationData
       if (!x || !y) continue
@@ -548,61 +546,101 @@
     requestAnimationFrame(animate)
   }
 
-  const graphAnimationFrameHandle = requestAnimationFrame(animate)
-  window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
+  requestAnimationFrame(animate)
+  return () => {
+    stopAnimation = true
+    app.destroy()
+  }
+}
+
+let localGraphCleanups: (() => void)[] = []
+let globalGraphCleanups: (() => void)[] = []
+
+function cleanupLocalGraphs() {
+  for (const cleanup of localGraphCleanups) {
+    cleanup()
+  }
+  localGraphCleanups = []
+}
+
+function cleanupGlobalGraphs() {
+  for (const cleanup of globalGraphCleanups) {
+    cleanup()
+  }
+  globalGraphCleanups = []
 }
 
 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)
+  async function renderLocalGraph() {
+    cleanupLocalGraphs()
+    const localGraphContainers = document.getElementsByClassName("graph-container")
+    for (const container of localGraphContainers) {
+      localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
+    }
   }
 
-  // event listener for theme change
-  document.addEventListener("themechange", handleThemeChange)
+  await renderLocalGraph()
+  const handleThemeChange = () => {
+    void renderLocalGraph()
+  }
 
-  // cleanup for the event listener
+  document.addEventListener("themechange", handleThemeChange)
   window.addCleanup(() => {
     document.removeEventListener("themechange", handleThemeChange)
   })
 
-  const container = document.getElementById("global-graph-outer")
-  const sidebar = container?.closest(".sidebar") as HTMLElement
-
-  function renderGlobalGraph() {
+  const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
+  async function renderGlobalGraph() {
     const slug = getFullSlug(window)
-    container?.classList.add("active")
-    if (sidebar) {
-      sidebar.style.zIndex = "1"
-    }
+    for (const container of containers) {
+      container.classList.add("active")
+      const sidebar = container.closest(".sidebar") as HTMLElement
+      if (sidebar) {
+        sidebar.style.zIndex = "1"
+      }
 
-    renderGraph("global-graph-container", slug)
-    registerEscapeHandler(container, hideGlobalGraph)
+      const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
+      registerEscapeHandler(container, hideGlobalGraph)
+      if (graphContainer) {
+        globalGraphCleanups.push(await renderGraph(graphContainer, slug))
+      }
+    }
   }
 
   function hideGlobalGraph() {
-    container?.classList.remove("active")
-    if (sidebar) {
-      sidebar.style.zIndex = ""
+    cleanupGlobalGraphs()
+    for (const container of containers) {
+      container.classList.remove("active")
+      const sidebar = container.closest(".sidebar") as HTMLElement
+      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 anyGlobalGraphOpen = containers.some((container) =>
+        container.classList.contains("active"),
+      )
+      anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
     }
   }
 
-  const containerIcon = document.getElementById("global-graph-icon")
-  containerIcon?.addEventListener("click", renderGlobalGraph)
-  window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
+  const containerIcons = document.getElementsByClassName("global-graph-icon")
+  Array.from(containerIcons).forEach((icon) => {
+    icon.addEventListener("click", renderGlobalGraph)
+    window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
+  })
 
   document.addEventListener("keydown", shortcutHandler)
-  window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
+  window.addCleanup(() => {
+    document.removeEventListener("keydown", shortcutHandler)
+    cleanupLocalGraphs()
+    cleanupGlobalGraphs()
+  })
 })
diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts
index c9bbcce..1f4c009 100644
--- a/quartz/components/scripts/search.inline.ts
+++ b/quartz/components/scripts/search.inline.ts
@@ -384,7 +384,7 @@
     preview.replaceChildren(previewInner)
 
     // scroll to longest
-    const highlights = [...preview.querySelectorAll(".highlight")].sort(
+    const highlights = [...preview.getElementsByClassName("highlight")].sort(
       (a, b) => b.innerHTML.length - a.innerHTML.length,
     )
     highlights[0]?.scrollIntoView({ block: "start" })
@@ -488,7 +488,7 @@
 document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
   const currentSlug = e.detail.url
   const data = await fetchData
-  const searchElement = document.querySelectorAll(".search")
+  const searchElement = document.getElementsByClassName("search")
   for (const element of searchElement) {
     await setupSearch(element, currentSlug, data)
   }
diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts
index a63da8d..6c5ad1c 100644
--- a/quartz/components/scripts/toc.inline.ts
+++ b/quartz/components/scripts/toc.inline.ts
@@ -25,7 +25,7 @@
 }
 
 function setupToc() {
-  for (const toc of document.querySelectorAll(".toc")) {
+  for (const toc of document.getElementsByClassName("toc")) {
     const button = toc.querySelector(".toc-header")
     const content = toc.querySelector(".toc-content")
     if (!button || !content) return
diff --git a/quartz/components/styles/graph.scss b/quartz/components/styles/graph.scss
index 1b19f13..cb1b7b4 100644
--- a/quartz/components/styles/graph.scss
+++ b/quartz/components/styles/graph.scss
@@ -15,7 +15,7 @@
     position: relative;
     overflow: hidden;
 
-    & > #global-graph-icon {
+    & > .global-graph-icon {
       cursor: pointer;
       background: none;
       border: none;
@@ -38,7 +38,7 @@
     }
   }
 
-  & > #global-graph-outer {
+  & > .global-graph-outer {
     position: fixed;
     z-index: 9999;
     left: 0;
@@ -53,7 +53,7 @@
       display: inline-block;
     }
 
-    & > #global-graph-container {
+    & > .global-graph-container {
       border: 1px solid var(--lightgray);
       background-color: var(--light);
       border-radius: 5px;

--
Gitblit v1.10.0