mirror of
				https://github.com/zopiya/x-eden-quartz.git
				synced 2025-11-04 06:46:48 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			602 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			602 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
 | 
						|
import {
 | 
						|
  SimulationNodeDatum,
 | 
						|
  SimulationLinkDatum,
 | 
						|
  Simulation,
 | 
						|
  forceSimulation,
 | 
						|
  forceManyBody,
 | 
						|
  forceCenter,
 | 
						|
  forceLink,
 | 
						|
  forceCollide,
 | 
						|
  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: SimpleSlug
 | 
						|
  text: string
 | 
						|
  tags: string[]
 | 
						|
} & SimulationNodeDatum
 | 
						|
 | 
						|
type SimpleLinkData = {
 | 
						|
  source: SimpleSlug
 | 
						|
  target: SimpleSlug
 | 
						|
}
 | 
						|
 | 
						|
type LinkData = {
 | 
						|
  source: NodeData
 | 
						|
  target: NodeData
 | 
						|
} & SimulationLinkDatum<NodeData>
 | 
						|
 | 
						|
type LinkRenderData = GraphicsInfo & {
 | 
						|
  simulationData: LinkData
 | 
						|
}
 | 
						|
 | 
						|
type NodeRenderData = GraphicsInfo & {
 | 
						|
  simulationData: NodeData
 | 
						|
  label: Text
 | 
						|
}
 | 
						|
 | 
						|
const localStorageKey = "graph-visited"
 | 
						|
function getVisited(): Set<SimpleSlug> {
 | 
						|
  return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
 | 
						|
}
 | 
						|
 | 
						|
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 {
 | 
						|
    drag: enableDrag,
 | 
						|
    zoom: enableZoom,
 | 
						|
    depth,
 | 
						|
    scale,
 | 
						|
    repelForce,
 | 
						|
    centerForce,
 | 
						|
    linkDistance,
 | 
						|
    fontSize,
 | 
						|
    opacityScale,
 | 
						|
    removeTags,
 | 
						|
    showTags,
 | 
						|
    focusOnHover,
 | 
						|
  } = JSON.parse(graph.dataset["cfg"]!) as D3Config
 | 
						|
 | 
						|
  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 tweens = new Map<string, TweenNode>()
 | 
						|
  for (const [source, details] of data.entries()) {
 | 
						|
    const outgoing = details.links ?? []
 | 
						|
 | 
						|
    for (const dest of outgoing) {
 | 
						|
      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<SimpleSlug>()
 | 
						|
  const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
 | 
						|
  if (depth >= 0) {
 | 
						|
    while (depth >= 0 && wl.length > 0) {
 | 
						|
      // compute neighbours
 | 
						|
      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)
 | 
						|
        wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
 | 
						|
      }
 | 
						|
    }
 | 
						|
  } else {
 | 
						|
    validLinks.forEach((id) => neighbourhood.add(id))
 | 
						|
    if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
 | 
						|
  }
 | 
						|
 | 
						|
  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)!,
 | 
						|
      })),
 | 
						|
  }
 | 
						|
 | 
						|
  // we virtualize the simulation and use pixi to actually render it
 | 
						|
  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))
 | 
						|
 | 
						|
  const width = graph.offsetWidth
 | 
						|
  const height = Math.max(graph.offsetHeight, 250)
 | 
						|
 | 
						|
  // 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 color
 | 
						|
  const color = (d: NodeData) => {
 | 
						|
    const isCurrent = d.id === slug
 | 
						|
    if (isCurrent) {
 | 
						|
      return computedStyleMap["--secondary"]
 | 
						|
    } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
 | 
						|
      return computedStyleMap["--tertiary"]
 | 
						|
    } else {
 | 
						|
      return computedStyleMap["--gray"]
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  function nodeRadius(d: NodeData) {
 | 
						|
    const numLinks = graphData.links.filter(
 | 
						|
      (l) => l.source.id === d.id || l.target.id === d.id,
 | 
						|
    ).length
 | 
						|
    return 2 + Math.sqrt(numLinks)
 | 
						|
  }
 | 
						|
 | 
						|
  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())
 | 
						|
      },
 | 
						|
    })
 | 
						|
  }
 | 
						|
 | 
						|
  function renderLabels() {
 | 
						|
    tweens.get("label")?.stop()
 | 
						|
    const tweenGroup = new TweenGroup()
 | 
						|
 | 
						|
    const defaultScale = 1 / scale
 | 
						|
    const activeScale = defaultScale * 1.1
 | 
						|
    for (const n of nodeRenderData) {
 | 
						|
      const nodeId = n.simulationData.id
 | 
						|
 | 
						|
      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,
 | 
						|
          ),
 | 
						|
        )
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    tweenGroup.getAll().forEach((tw) => tw.start())
 | 
						|
    tweens.set("label", {
 | 
						|
      update: tweenGroup.update.bind(tweenGroup),
 | 
						|
      stop() {
 | 
						|
        tweenGroup.getAll().forEach((tw) => tw.stop())
 | 
						|
      },
 | 
						|
    })
 | 
						|
  }
 | 
						|
 | 
						|
  function renderNodes() {
 | 
						|
    tweens.get("hover")?.stop()
 | 
						|
 | 
						|
    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())
 | 
						|
      },
 | 
						|
    })
 | 
						|
  }
 | 
						|
 | 
						|
  function renderPixiFromD3() {
 | 
						|
    renderNodes()
 | 
						|
    renderLinks()
 | 
						|
    renderLabels()
 | 
						|
  }
 | 
						|
 | 
						|
  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) {
 | 
						|
    select<HTMLCanvasElement, NodeData>(app.canvas).call(
 | 
						|
      zoom<HTMLCanvasElement, NodeData>()
 | 
						|
        .extent([
 | 
						|
          [0, 0],
 | 
						|
          [width, height],
 | 
						|
        ])
 | 
						|
        .scaleExtent([0.25, 4])
 | 
						|
        .on("zoom", ({ transform }) => {
 | 
						|
          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
 | 
						|
            }
 | 
						|
          }
 | 
						|
        }),
 | 
						|
    )
 | 
						|
  }
 | 
						|
 | 
						|
  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)
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    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 })
 | 
						|
    }
 | 
						|
 | 
						|
    tweens.forEach((t) => t.update(time))
 | 
						|
    app.renderer.render(stage)
 | 
						|
    requestAnimationFrame(animate)
 | 
						|
  }
 | 
						|
 | 
						|
  const graphAnimationFrameHandle = requestAnimationFrame(animate)
 | 
						|
  window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
 | 
						|
}
 | 
						|
 | 
						|
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 = "unset"
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  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?.addEventListener("click", renderGlobalGraph)
 | 
						|
  window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
 | 
						|
 | 
						|
  document.addEventListener("keydown", shortcutHandler)
 | 
						|
  window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
 | 
						|
})
 |