From efddd798e83705b0e17e074fe344b2d491051ba2 Mon Sep 17 00:00:00 2001
From: Emile Bangma <github@emilebangma.com>
Date: Wed, 30 Jul 2025 16:43:36 +0000
Subject: [PATCH] revert(graph): roll back changes due to issues with Safari (#2067)
---
quartz/components/scripts/graph.inline.ts | 753 ++++++++++++++++++++++++++++++++++++++++++---------------
1 files changed, 547 insertions(+), 206 deletions(-)
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index 6682d70..a669b05 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -1,34 +1,76 @@
-import { ContentDetails } from "../../plugins/emitters/contentIndex"
-import * as d3 from 'd3'
-import { registerEscapeHandler, clientSideRelativePath, removeAllChildren } from "./util"
-import { CanonicalSlug } from "../../path"
+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: CanonicalSlug,
- text: string,
+ id: SimpleSlug
+ text: string
tags: string[]
-} & d3.SimulationNodeDatum
+} & SimulationNodeDatum
+
+type SimpleLinkData = {
+ source: SimpleSlug
+ target: SimpleSlug
+}
type LinkData = {
- source: CanonicalSlug,
- target: CanonicalSlug
+ 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<CanonicalSlug> {
+function getVisited(): Set<SimpleSlug> {
return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
}
-function addToVisited(slug: CanonicalSlug) {
+function addToVisited(slug: SimpleSlug) {
const visited = getVisited()
visited.add(slug)
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
}
-async function renderGraph(container: string, slug: string) {
+type TweenNode = {
+ update: (time: number) => void
+ stop: () => void
+}
+
+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 {
@@ -40,269 +82,568 @@
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 {
- Object.keys(data).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
+ 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", 1)
+ const radius = (Math.min(width, height) / 2) * 0.8
+ if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
- // 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 color
const color = (d: NodeData) => {
const isCurrent = d.id === slug
if (isCurrent) {
- return "var(--secondary)"
- } else if (visited.has(d.id)) {
- return "var(--tertiary)"
+ return computedStyleMap["--secondary"]
+ } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
+ return computedStyleMap["--tertiary"]
} else {
- return "var(--gray)"
+ return computedStyleMap["--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
- }
-
- 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 = clientSideRelativePath(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)")
- .attr("stroke-width", 1)
+ 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,
+ ),
+ )
+ }
+ }
- const bigFont = fontSize * 1.5
-
- // 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) + "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, isRenderGroup: true })
+ const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
+ const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
+ 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) })
+ .on("pointerover", (e) => {
+ updateHoverInfo(e.target.label)
+ oldLabelOpacity = label.alpha
+ if (!dragging) {
+ renderPixiFromD3()
+ }
+ })
+ .on("pointerleave", () => {
+ updateHoverInfo(null)
+ label.alpha = oldLabelOpacity
+ if (!dragging) {
+ renderPixiFromD3()
+ }
+ })
+
+ if (isTagNode) {
+ gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
+ }
+
+ 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)
- })
-}
+ let stopAnimation = false
+ function animate(time: number) {
+ if (stopAnimation) return
+ 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"]!
- const container = document.getElementById("global-graph-outer")
- const sidebar = container?.closest(".sidebar") as HTMLElement
- container?.classList.add("active")
- if (sidebar) {
- sidebar.style.zIndex = "1"
+ 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)
}
- renderGraph("global-graph-container", slug)
+ 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))
+
+ async function renderLocalGraph() {
+ cleanupLocalGraphs()
+ const localGraphContainers = document.getElementsByClassName("graph-container")
+ for (const container of localGraphContainers) {
+ localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
+ }
+ }
+
+ await renderLocalGraph()
+ const handleThemeChange = () => {
+ void renderLocalGraph()
+ }
+
+ document.addEventListener("themechange", handleThemeChange)
+ window.addCleanup(() => {
+ document.removeEventListener("themechange", handleThemeChange)
+ })
+
+ const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
+ async function renderGlobalGraph() {
+ const slug = getFullSlug(window)
+ for (const container of containers) {
+ container.classList.add("active")
+ const sidebar = container.closest(".sidebar") as HTMLElement
+ if (sidebar) {
+ sidebar.style.zIndex = "1"
+ }
+
+ 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")
- const graph = document.getElementById("global-graph-container")
- if (sidebar) {
- sidebar.style.zIndex = "unset"
+ cleanupGlobalGraphs()
+ for (const container of containers) {
+ container.classList.remove("active")
+ const sidebar = container.closest(".sidebar") as HTMLElement
+ if (sidebar) {
+ sidebar.style.zIndex = ""
+ }
}
- if (!graph) return
- removeAllChildren(graph)
}
- registerEscapeHandler(container, hideGlobalGraph)
-}
+ async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
+ if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
+ e.preventDefault()
+ const anyGlobalGraphOpen = containers.some((container) =>
+ container.classList.contains("active"),
+ )
+ anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
+ }
+ }
-document.addEventListener("nav", async (e: unknown) => {
- const slug = (e as CustomEventMap["nav"]).detail.url
- addToVisited(slug)
- await renderGraph("graph-container", slug)
+ const containerIcons = document.getElementsByClassName("global-graph-icon")
+ Array.from(containerIcons).forEach((icon) => {
+ icon.addEventListener("click", renderGlobalGraph)
+ window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
+ })
- const containerIcon = document.getElementById("global-graph-icon")
- containerIcon?.removeEventListener("click", renderGlobalGraph)
- containerIcon?.addEventListener("click", renderGlobalGraph)
+ document.addEventListener("keydown", shortcutHandler)
+ window.addCleanup(() => {
+ document.removeEventListener("keydown", shortcutHandler)
+ cleanupLocalGraphs()
+ cleanupGlobalGraphs()
+ })
})
-
--
Gitblit v1.10.0