From fbc45548f7ee80715ec74d8c249c662a26f7feae Mon Sep 17 00:00:00 2001
From: Aaron Pham <contact@aarnphm.xyz>
Date: Sat, 01 Feb 2025 21:22:29 +0000
Subject: [PATCH] feat(graph): enable radial mode (#1738)
---
quartz/components/scripts/graph.inline.ts | 663 ++++++++++++++++++++++++++++++++++++++++---------------
1 files changed, 482 insertions(+), 181 deletions(-)
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts
index e589217..16ee33f 100644
--- a/quartz/components/scripts/graph.inline.ts
+++ b/quartz/components/scripts/graph.inline.ts
@@ -1,19 +1,57 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
-import * as d3 from "d3"
+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: SimpleSlug
text: string
tags: string[]
-} & d3.SimulationNodeDatum
+} & SimulationNodeDatum
-type LinkData = {
+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) ?? "[]"))
@@ -25,6 +63,11 @@
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()
@@ -42,17 +85,41 @@
linkDistance,
fontSize,
opacityScale,
- } = JSON.parse(graph.dataset["cfg"]!)
+ 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 source = simplifySlug(src as FullSlug)
+ const tweens = new Map<string, TweenNode>()
+ for (const [source, details] of data.entries()) {
const outgoing = details.links ?? []
+
for (const dest of outgoing) {
- if (dest in data) {
- links.push({ source, 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 })
}
}
}
@@ -74,234 +141,468 @@
}
}
} else {
- Object.keys(data).forEach((id) => neighbourhood.add(simplifySlug(id as FullSlug)))
+ validLinks.forEach((id) => neighbourhood.add(id))
+ if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
}
- const graphData: { nodes: NodeData[]; links: LinkData[] } = {
- nodes: [...neighbourhood].map((url) => ({
+ const nodes = [...neighbourhood].map((url) => {
+ const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
+ return {
id: url,
- text: data[url]?.title ?? url,
- tags: data[url]?.tags ?? [],
- })),
- links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
+ 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
+ // 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))
- // 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)
+ if (enableRadial)
+ simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3))
- // 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 = resolveRelative(fullSlug, d.id)
- window.spaNavigate(new URL(targ, window.location.toString()))
+ 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: SimpleSlug[] = data[fullSlug].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
- const bigFont = fontSize * 1.5
+ 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,
+ ),
+ )
+ }
+ }
- // 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("-", " "),
+ 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()))
+ }
+ }),
)
- .style("opacity", (opacityScale - 1) / 3.75)
- .style("pointer-events", "none")
- .style("font-size", fontSize + "em")
- .raise()
- // @ts-ignore
- .call(drag(simulation))
+ } 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()))
+ })
+ }
+ }
- // set panning
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)
+ 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
- const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
- labels.attr("transform", transform).style("opacity", scaledOpacity)
+ 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)
- })
-}
+ 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)
+ }
+ }
-function renderGlobalGraph() {
- const slug = getFullSlug(window)
- 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)
+ 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")
- const graph = document.getElementById("global-graph-container")
if (sidebar) {
- sidebar.style.zIndex = "unset"
+ sidebar.style.zIndex = ""
}
- if (!graph) return
- removeAllChildren(graph)
}
- registerEscapeHandler(container, hideGlobalGraph)
-}
-
-document.addEventListener("nav", async (e: unknown) => {
- const slug = (e as CustomEventMap["nav"]).detail.url
- addToVisited(slug)
- await renderGraph("graph-container", slug)
+ 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?.removeEventListener("click", renderGlobalGraph)
containerIcon?.addEventListener("click", renderGlobalGraph)
+ window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
+
+ document.addEventListener("keydown", shortcutHandler)
+ window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
})
--
Gitblit v1.10.0