| | |
| | | forceCenter, |
| | | forceLink, |
| | | forceCollide, |
| | | forceRadial, |
| | | zoomIdentity, |
| | | select, |
| | | drag, |
| | |
| | | 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 { |
| | |
| | | removeTags, |
| | | showTags, |
| | | focusOnHover, |
| | | enableRadial, |
| | | } = JSON.parse(graph.dataset["cfg"]!) as D3Config |
| | | |
| | | const data: Map<SimpleSlug, ContentDetails> = new Map( |
| | |
| | | })), |
| | | } |
| | | |
| | | const width = graph.offsetWidth |
| | | const height = Math.max(graph.offsetHeight, 250) |
| | | |
| | | // 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("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) |
| | | 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 = [ |
| | |
| | | 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 }) |
| | | 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) { |
| | |
| | | }) |
| | | .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 (isTagNode) { |
| | | gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] }) |
| | | } |
| | | |
| | | nodesContainer.addChild(gfx) |
| | | labelsContainer.addChild(label) |
| | | |
| | |
| | | ) |
| | | } |
| | | |
| | | let stopAnimation = false |
| | | function animate(time: number) { |
| | | if (stopAnimation) return |
| | | for (const n of nodeRenderData) { |
| | | const { x, y } = n.simulationData |
| | | if (!x || !y) continue |
| | |
| | | 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() |
| | | }) |
| | | }) |