fix(graph): make graph non-singleton, proper cleanup, fix radial

This commit is contained in:
Jacky Zhao 2025-03-10 11:39:08 -07:00
parent 8d33608808
commit 23df17233d
5 changed files with 84 additions and 46 deletions

View file

@ -48,7 +48,7 @@ const defaultOptions: GraphOptions = {
depth: -1, depth: -1,
scale: 0.9, scale: 0.9,
repelForce: 0.5, repelForce: 0.5,
centerForce: 0.3, centerForce: 0.2,
linkDistance: 30, linkDistance: 30,
fontSize: 0.6, fontSize: 0.6,
opacityScale: 1, opacityScale: 1,
@ -67,8 +67,8 @@ export default ((opts?: Partial<GraphOptions>) => {
<div class={classNames(displayClass, "graph")}> <div class={classNames(displayClass, "graph")}>
<h3>{i18n(cfg.locale).components.graph.title}</h3> <h3>{i18n(cfg.locale).components.graph.title}</h3>
<div class="graph-outer"> <div class="graph-outer">
<div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> <div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
<button id="global-graph-icon" aria-label="Global Graph"> <button class="global-graph-icon" aria-label="Global Graph">
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -95,8 +95,8 @@ export default ((opts?: Partial<GraphOptions>) => {
</svg> </svg>
</button> </button>
</div> </div>
<div id="global-graph-outer"> <div class="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div> <div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
</div> </div>
</div> </div>
) )

View file

@ -68,11 +68,9 @@ type TweenNode = {
stop: () => void stop: () => void
} }
async function renderGraph(container: string, fullSlug: FullSlug) { async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug) const slug = simplifySlug(fullSlug)
const visited = getVisited() const visited = getVisited()
const graph = document.getElementById(container)
if (!graph) return
removeAllChildren(graph) removeAllChildren(graph)
let { let {
@ -167,16 +165,14 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
const height = Math.max(graph.offsetHeight, 250) const height = Math.max(graph.offsetHeight, 250)
// we virtualize the simulation and use pixi to actually render it // 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) const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
.force("charge", forceManyBody().strength(-100 * repelForce)) .force("charge", forceManyBody().strength(-100 * repelForce))
.force("center", forceCenter().strength(centerForce)) .force("center", forceCenter().strength(centerForce))
.force("link", forceLink(graphData.links).distance(linkDistance)) .force("link", forceLink(graphData.links).distance(linkDistance))
.force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3)) .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
if (enableRadial) const radius = (Math.min(width, height) / 2) * 0.8
simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3)) if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
// precompute style prop strings as pixi doesn't support css variables // precompute style prop strings as pixi doesn't support css variables
const cssVars = [ const cssVars = [
@ -524,7 +520,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
) )
} }
let stopAnimation = false
function animate(time: number) { function animate(time: number) {
if (stopAnimation) return
for (const n of nodeRenderData) { for (const n of nodeRenderData) {
const { x, y } = n.simulationData const { x, y } = n.simulationData
if (!x || !y) continue if (!x || !y) continue
@ -548,61 +546,101 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
requestAnimationFrame(animate) requestAnimationFrame(animate)
} }
const graphAnimationFrameHandle = requestAnimationFrame(animate) requestAnimationFrame(animate)
window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle)) 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"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const slug = e.detail.url const slug = e.detail.url
addToVisited(simplifySlug(slug)) addToVisited(simplifySlug(slug))
await renderGraph("graph-container", slug)
// Function to re-render the graph when the theme changes async function renderLocalGraph() {
const handleThemeChange = () => { cleanupLocalGraphs()
renderGraph("graph-container", slug) const localGraphContainers = document.getElementsByClassName("graph-container")
for (const container of localGraphContainers) {
localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
}
} }
// event listener for theme change await renderLocalGraph()
document.addEventListener("themechange", handleThemeChange) const handleThemeChange = () => {
void renderLocalGraph()
}
// cleanup for the event listener document.addEventListener("themechange", handleThemeChange)
window.addCleanup(() => { window.addCleanup(() => {
document.removeEventListener("themechange", handleThemeChange) document.removeEventListener("themechange", handleThemeChange)
}) })
const container = document.getElementById("global-graph-outer") const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
const sidebar = container?.closest(".sidebar") as HTMLElement async function renderGlobalGraph() {
function renderGlobalGraph() {
const slug = getFullSlug(window) const slug = getFullSlug(window)
container?.classList.add("active") for (const container of containers) {
container.classList.add("active")
const sidebar = container.closest(".sidebar") as HTMLElement
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "1" sidebar.style.zIndex = "1"
} }
renderGraph("global-graph-container", slug) const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
registerEscapeHandler(container, hideGlobalGraph) registerEscapeHandler(container, hideGlobalGraph)
if (graphContainer) {
globalGraphCleanups.push(await renderGraph(graphContainer, slug))
}
}
} }
function hideGlobalGraph() { function hideGlobalGraph() {
container?.classList.remove("active") cleanupGlobalGraphs()
for (const container of containers) {
container.classList.remove("active")
const sidebar = container.closest(".sidebar") as HTMLElement
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "" sidebar.style.zIndex = ""
} }
} }
}
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault() e.preventDefault()
const globalGraphOpen = container?.classList.contains("active") const anyGlobalGraphOpen = containers.some((container) =>
globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph() container.classList.contains("active"),
)
anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
} }
} }
const containerIcon = document.getElementById("global-graph-icon") const containerIcons = document.getElementsByClassName("global-graph-icon")
containerIcon?.addEventListener("click", renderGlobalGraph) Array.from(containerIcons).forEach((icon) => {
window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) icon.addEventListener("click", renderGlobalGraph)
window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
})
document.addEventListener("keydown", shortcutHandler) document.addEventListener("keydown", shortcutHandler)
window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) window.addCleanup(() => {
document.removeEventListener("keydown", shortcutHandler)
cleanupLocalGraphs()
cleanupGlobalGraphs()
})
}) })

View file

@ -384,7 +384,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data:
preview.replaceChildren(previewInner) preview.replaceChildren(previewInner)
// scroll to longest // scroll to longest
const highlights = [...preview.querySelectorAll(".highlight")].sort( const highlights = [...preview.getElementsByClassName("highlight")].sort(
(a, b) => b.innerHTML.length - a.innerHTML.length, (a, b) => b.innerHTML.length - a.innerHTML.length,
) )
highlights[0]?.scrollIntoView({ block: "start" }) highlights[0]?.scrollIntoView({ block: "start" })
@ -488,7 +488,7 @@ async function fillDocument(data: ContentIndex) {
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
const currentSlug = e.detail.url const currentSlug = e.detail.url
const data = await fetchData const data = await fetchData
const searchElement = document.querySelectorAll(".search") const searchElement = document.getElementsByClassName("search")
for (const element of searchElement) { for (const element of searchElement) {
await setupSearch(element, currentSlug, data) await setupSearch(element, currentSlug, data)
} }

View file

@ -25,7 +25,7 @@ function toggleToc(this: HTMLElement) {
} }
function setupToc() { function setupToc() {
for (const toc of document.querySelectorAll(".toc")) { for (const toc of document.getElementsByClassName("toc")) {
const button = toc.querySelector(".toc-header") const button = toc.querySelector(".toc-header")
const content = toc.querySelector(".toc-content") const content = toc.querySelector(".toc-content")
if (!button || !content) return if (!button || !content) return

View file

@ -15,7 +15,7 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
& > #global-graph-icon { & > .global-graph-icon {
cursor: pointer; cursor: pointer;
background: none; background: none;
border: none; border: none;
@ -38,7 +38,7 @@
} }
} }
& > #global-graph-outer { & > .global-graph-outer {
position: fixed; position: fixed;
z-index: 9999; z-index: 9999;
left: 0; left: 0;
@ -53,7 +53,7 @@
display: inline-block; display: inline-block;
} }
& > #global-graph-container { & > .global-graph-container {
border: 1px solid var(--lightgray); border: 1px solid var(--lightgray);
background-color: var(--light); background-color: var(--light);
border-radius: 5px; border-radius: 5px;