Jacky Zhao
2025-03-10 23df17233da3f16db5166cf8a05b2089bd1f006a
fix(graph): make graph non-singleton, proper cleanup, fix radial
5 files modified
130 ■■■■■ changed files
quartz/components/Graph.tsx 10 ●●●● patch | view | raw | blame | history
quartz/components/scripts/graph.inline.ts 108 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/search.inline.ts 4 ●●●● patch | view | raw | blame | history
quartz/components/scripts/toc.inline.ts 2 ●●● patch | view | raw | blame | history
quartz/components/styles/graph.scss 6 ●●●● patch | view | raw | blame | history
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>
    )
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()
  })
})
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)
  }
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
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;