This commit is contained in:
Jacky Zhao 2024-08-25 00:27:36 -07:00
parent 847354cf18
commit 34ecff05d2
2 changed files with 267 additions and 246 deletions

View File

@ -32,9 +32,9 @@ const defaultOptions: GraphOptions = {
depth: 1, depth: 1,
scale: 1.1, scale: 1.1,
repelForce: 0.5, repelForce: 0.5,
centerForce: 0.1, centerForce: 0.3,
linkDistance: 30, linkDistance: 30,
fontSize: 8, fontSize: 0.6,
opacityScale: 1, opacityScale: 1,
showTags: true, showTags: true,
removeTags: [], removeTags: [],
@ -44,11 +44,11 @@ const defaultOptions: GraphOptions = {
drag: true, drag: true,
zoom: true, zoom: true,
depth: -1, depth: -1,
scale: 1, scale: 0.9,
repelForce: 1.5, repelForce: 0.5,
centerForce: 0.3, centerForce: 0.3,
linkDistance: 90, linkDistance: 30,
fontSize: 12, fontSize: 0.6,
opacityScale: 1, opacityScale: 1,
showTags: true, showTags: true,
removeTags: [], removeTags: [],

View File

@ -1,39 +1,55 @@
import type { ContentDetails } from "../../plugins/emitters/contentIndex" import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3" import {
import * as PIXI from "pixi.js" SimulationNodeDatum,
import * as TWEEN from "@tweenjs/tween.js" 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 { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" 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 = { type NodeData = {
id: SimpleSlug id: SimpleSlug
text: string text: string
tags: string[] tags: string[]
} & SimulationNodeDatum
label?: PIXI.Text type SimpleLinkData = {
gfx?: PIXI.Graphics
alpha?: number
r?: number
active?: boolean
} & d3.SimulationNodeDatum
type LinkData = {
source: SimpleSlug source: SimpleSlug
target: SimpleSlug target: SimpleSlug
gfx?: PIXI.Graphics
alpha?: number
color?: string
active?: boolean
} }
type LinkNodes = Omit<LinkData, "source" | "target"> & { type LinkData = {
source: NodeData source: NodeData
target: NodeData target: NodeData
} & d3.SimulationLinkDatum<NodeData> } & SimulationLinkDatum<NodeData>
type LinkRenderData = GraphicsInfo & {
simulationData: LinkData
}
type NodeRenderData = GraphicsInfo & {
simulationData: NodeData
label: Text
}
const localStorageKey = "graph-visited" const localStorageKey = "graph-visited"
function getVisited(): Set<SimpleSlug> { function getVisited(): Set<SimpleSlug> {
@ -51,13 +67,6 @@ type TweenNode = {
stop: () => void stop: () => void
} }
let tweens = new Map<string, TweenNode>()
function animate(time: number) {
tweens.forEach((t) => t.update(time))
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
async function renderGraph(container: string, fullSlug: FullSlug) { async function renderGraph(container: string, fullSlug: FullSlug) {
const slug = simplifySlug(fullSlug) const slug = simplifySlug(fullSlug)
const visited = getVisited() const visited = getVisited()
@ -78,7 +87,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags, removeTags,
showTags, showTags,
focusOnHover, focusOnHover,
} = JSON.parse(graph.dataset["cfg"]!) } = JSON.parse(graph.dataset["cfg"]!) as D3Config
const data: Map<SimpleSlug, ContentDetails> = new Map( const data: Map<SimpleSlug, ContentDetails> = new Map(
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [ Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
@ -86,10 +95,11 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
v, v,
]), ]),
) )
const links: LinkData[] = [] const links: SimpleLinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(data.keys()) const validLinks = new Set(data.keys())
const tweens = new Map<string, TweenNode>()
for (const [source, details] of data.entries()) { for (const [source, details] of data.entries()) {
const outgoing = details.links ?? [] const outgoing = details.links ?? []
@ -133,37 +143,36 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
} }
const graphData: { nodes: NodeData[]; links: LinkNodes[] } = { const nodes = [...neighbourhood].map((url) => {
nodes: [...neighbourhood].map((url) => { const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url) return {
return { id: url,
id: url, text,
text: text, tags: data.get(url)?.tags ?? [],
tags: data.get(url)?.tags ?? [], }
} })
}), const graphData: { nodes: NodeData[]; links: LinkData[] } = {
nodes,
links: links.filter( links: links.filter(
(l) => neighbourhood.has(l.source) && neighbourhood.has(l.target), (l) => neighbourhood.has(l.source) && neighbourhood.has(l.target),
) as unknown as LinkNodes[], ).map((l) => ({
source: nodes.find((n) => n.id === l.source)!,
target: nodes.find((n) => n.id === l.target)!
})),
} }
const simulation: d3.Simulation<NodeData, LinkNodes> = d3 // we virtualize the simulation and use pixi to actually render it
.forceSimulation<NodeData>(graphData.nodes) const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce)) .force("charge", forceManyBody().strength(-100 * repelForce))
.force("center", d3.forceCenter().strength(centerForce)) .force("center", forceCenter().strength(centerForce))
.force( .force("link", forceLink(graphData.links).distance(linkDistance))
"link", .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
d3
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force("collide", d3.forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
const width = graph.offsetWidth const width = graph.offsetWidth
const height = Math.max(graph.offsetHeight, 250) const height = Math.max(graph.offsetHeight, 250)
const computedStyleMap = new Map<string, string>()
for (let i of [ // precompute style prop strings as pixi doesn't support css variables
const cssVars = [
"--secondary", "--secondary",
"--tertiary", "--tertiary",
"--gray", "--gray",
@ -172,130 +181,161 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
"--dark", "--dark",
"--darkgray", "--darkgray",
"--bodyFont", "--bodyFont",
]) { ] as const
computedStyleMap.set(i, getComputedStyle(graph).getPropertyValue(i)) const computedStyleMap = cssVars.reduce((acc, key) => {
} acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
return acc
}, {} as Record<typeof cssVars[number], string>)
// calculate color // calculate color
const color = (d: NodeData) => { const color = (d: NodeData) => {
const isCurrent = d.id === slug const isCurrent = d.id === slug
if (isCurrent) { if (isCurrent) {
return computedStyleMap.get("--secondary") return computedStyleMap["--secondary"]
} else if (visited.has(d.id) || d.id.startsWith("tags/")) { } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return computedStyleMap.get("--tertiary") return computedStyleMap["--tertiary"]
} else { } else {
return computedStyleMap.get("--gray") return computedStyleMap["--gray"]
} }
} }
function nodeRadius(d: NodeData) { 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) return 2 + Math.sqrt(numLinks)
} }
function renderLinks(data: LinkNodes[], currentNodeId?: string | null) { let hoveredNodeId: string | null = null
tweens.get("link")?.stop() let hoveredNeighbours: Set<string> = new Set()
const Group = new TWEEN.Group() const linkRenderData: LinkRenderData[] = []
const nodeRenderData: NodeRenderData[] = []
function updateHoverInfo(newHoveredId: string | null) {
hoveredNodeId = newHoveredId
data.forEach((l) => { if (newHoveredId === null) {
let alpha = 1 hoveredNeighbours = new Set()
if (currentNodeId) { for (const n of nodeRenderData) {
alpha = l.active ? 1 : 0.3 n.active = false
} }
l.color = l.active ? computedStyleMap.get("--gray") : computedStyleMap.get("--lightgray")
Group.add(new TWEEN.Tween<LinkNodes>(l).to({ alpha }, 200))
})
Group.getAll().forEach((tw) => tw.start()) 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", { tweens.set("link", {
update: Group.update.bind(Group), update: tweenGroup.update.bind(tweenGroup),
stop() { stop() {
Group.getAll().forEach((tw) => tw.stop()) tweenGroup.getAll().forEach((tw) => tw.stop())
}, },
}) })
} }
function renderLabels(data: NodeData[], currentNodeId?: string | null) { function renderLabels() {
tweens.get("label")?.stop() tweens.get("label")?.stop()
const Group = new TWEEN.Group() const tweenGroup = new TweenGroup()
data.forEach((n) => { const defaultScale = 1 / scale
if (!n.label) return const activeScale = defaultScale * 1.1
if (currentNodeId === n.id) { for (const n of nodeRenderData) {
Group.add( const nodeId = n.simulationData.id
new TWEEN.Tween<PIXI.Text>(n.label).to(
{ alpha: 1, scale: { x: (1 / scale) * 1.5, y: (1 / scale) * 1.5 } }, if (hoveredNodeId === nodeId) {
200, tweenGroup.add(
), new Tweened<Text>(n.label).to({
alpha: 1,
scale: { x: activeScale, y: activeScale }
}, 100)
) )
} else { } else {
let alpha = n.active ? 0.8 : 0 tweenGroup.add(
Group.add( new Tweened<Text>(n.label).to({
new TWEEN.Tween<PIXI.Text>(n.label).to( alpha: n.label.alpha,
{ alpha, scale: { x: 1 / scale, y: 1 / scale } }, scale: { x: defaultScale, y: defaultScale }
200, }, 100)
),
) )
} }
}) }
Group.getAll().forEach((tw) => tw.start()) tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("label", { tweens.set("label", {
update: Group.update.bind(Group), update: tweenGroup.update.bind(tweenGroup),
stop() { stop() {
Group.getAll().forEach((tw) => tw.stop()) tweenGroup.getAll().forEach((tw) => tw.stop())
}, },
}) })
} }
function renderCurrentNode(props: { nodeId: string | null; focusOnHover: boolean }) { function renderNodes() {
const { nodeId, focusOnHover } = props
tweens.get("hover")?.stop() tweens.get("hover")?.stop()
// NOTE: we need to create a new copy here const tweenGroup = new TweenGroup()
const connectedNodes: Set<SimpleSlug> = new Set() for (const n of nodeRenderData) {
graphData.links.forEach((l) => {
l.active = l.source.id === nodeId || l.target.id === nodeId
if (l.source.id === nodeId || l.target.id === nodeId) {
connectedNodes.add(l.source.id)
connectedNodes.add(l.target.id)
}
})
const Group = new TWEEN.Group()
graphData.nodes.forEach((n) => {
if (!n.gfx) return
let alpha = 1 let alpha = 1
if (nodeId !== null) {
n.active = connectedNodes.has(n.id) // if we are hovering over a node, we want to highlight the immediate neighbours
if (focusOnHover) alpha = connectedNodes.has(n.id) ? 1 : 0.2 if (hoveredNodeId !== null && focusOnHover) {
if (n.id !== nodeId) { alpha = n.active ? 1 : 0.2
Group.add(new TWEEN.Tween<PIXI.Graphics>(n.gfx, Group).to({ alpha }, 200))
}
} else {
n.active = false
Group.add(new TWEEN.Tween<PIXI.Graphics>(n.gfx, Group).to({ alpha }, 200))
} }
})
renderLabels(graphData.nodes, nodeId) tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
renderLinks(graphData.links, nodeId) }
Group.getAll().forEach((tw) => tw.start()) tweenGroup.getAll().forEach((tw) => tw.start())
tweens.set("hover", { tweens.set("hover", {
update: Group.update.bind(Group), update: tweenGroup.update.bind(tweenGroup),
stop() { stop() {
Group.getAll().forEach((tw) => tw.stop()) tweenGroup.getAll().forEach((tw) => tw.stop())
}, },
}) })
} }
function renderPixiFromD3() {
renderNodes()
renderLinks()
renderLabels()
}
tweens.forEach((tween) => tween.stop()) tweens.forEach((tween) => tween.stop())
tweens.clear() tweens.clear()
const app = new PIXI.Application()
const app = new Application()
await app.init({ await app.init({
width, width,
height, height,
@ -312,117 +352,92 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
const stage = app.stage const stage = app.stage
stage.interactive = false stage.interactive = false
const nodesContainer = new PIXI.Container<PIXI.Graphics>({ zIndex: 1 }) const labelsContainer = new Container<Text>({ zIndex: 3 })
const labelsContainer = new PIXI.Container<PIXI.Text>({ zIndex: 2 }) const nodesContainer = new Container<Graphics>({ zIndex: 2 })
const linkGraphic = new PIXI.Graphics() const linkContainer = new Container<Graphics>({ zIndex: 1 })
stage.addChild(nodesContainer, labelsContainer, linkContainer)
stage.addChild(nodesContainer, labelsContainer) for (const n of graphData.nodes) {
nodesContainer.addChild(linkGraphic)
let currentHoverNodeId: string | undefined
let dragStartTime = 0
let dragging = false
graphData.nodes.forEach((n) => {
const nodeId = n.id const nodeId = n.id
const label = new PIXI.Text({ const label = new Text({
interactive: false,
eventMode: "none",
text: n.text, text: n.text,
alpha: 0, alpha: 0,
anchor: { x: 0.5, y: -1 }, anchor: { x: 0.5, y: 1.2 },
style: { style: {
fontSize, fontSize: fontSize * 15,
fill: computedStyleMap.get("--dark"), fill: computedStyleMap["--dark"],
fontFamily: computedStyleMap.get("--bodyFont"), fontFamily: computedStyleMap["--bodyFont"],
}, },
resolution: window.devicePixelRatio, resolution: window.devicePixelRatio * 4,
}) })
label.scale.set(scale) label.scale.set(1 / scale)
n.label = label
const gfx = new PIXI.Graphics({ let oldLabelOpacity = 0;
const isTagNode = nodeId.startsWith("tags/")
const gfx = new Graphics({
interactive: true, interactive: true,
label: nodeId, label: nodeId,
eventMode: "static", eventMode: "static",
hitArea: new PIXI.Circle(0, 0, nodeRadius(n)), hitArea: new Circle(0, 0, nodeRadius(n)),
cursor: "pointer", cursor: "pointer",
}) })
.circle(0, 0, nodeRadius(n)) .circle(0, 0, nodeRadius(n))
.on("pointerover", () => { .fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
nodesContainer.zIndex = 2 .stroke({ width: isTagNode ? 2 : 0, color: color(n) })
labelsContainer.zIndex = 1 .on("pointerover", (e) => {
updateHoverInfo(e.target.label)
oldLabelOpacity = label.alpha
if (!dragging) { if (!dragging) {
tweens.get(nodeId)?.stop() renderPixiFromD3()
const tweenScale = { x: 1, y: 1 }
const tween = new TWEEN.Tween(tweenScale)
.to({ x: 1.5, y: 1.5 }, 100)
.onUpdate(() => {
gfx.scale.set(tweenScale.x, tweenScale.y)
})
.onStop(() => {
tweens.delete(nodeId)
})
.start()
tweens.set(nodeId, tween)
renderCurrentNode({ nodeId, focusOnHover })
} }
}) })
.on("pointerdown", (e) => {
currentHoverNodeId = e.target.label
})
.on("pointerup", () => {
currentHoverNodeId = undefined
})
.on("pointerupoutside", () => {
currentHoverNodeId = undefined
})
.on("pointerleave", () => { .on("pointerleave", () => {
nodesContainer.zIndex = 1 updateHoverInfo(null)
labelsContainer.zIndex = 2 label.alpha = oldLabelOpacity
if (!dragging) { if (!dragging) {
tweens.get(nodeId)?.stop() renderPixiFromD3()
const tweenScale = {
x: gfx.scale.x,
y: gfx.scale.y,
}
const tween = new TWEEN.Tween(tweenScale)
.to({ x: 1, y: 1 }, 100)
.onUpdate(() => {
gfx.scale.set(tweenScale.x, tweenScale.y)
})
.onStop(() => {
tweens.delete(nodeId)
})
.start()
tweens.set(nodeId, tween)
renderCurrentNode({ nodeId: null, focusOnHover })
} }
}) })
n.gfx = gfx
n.r = nodeRadius(n)
if (n.id.startsWith("tags/")) {
gfx.fill({ color: computedStyleMap.get("--light") }).stroke({ width: 0.5, color: color(n) })
} else {
gfx.fill(color(n)).stroke({ color: color(n) })
}
nodesContainer.addChild(gfx) nodesContainer.addChild(gfx)
labelsContainer.addChild(label) labelsContainer.addChild(label)
})
graphData.links.forEach((l) => { const nodeRenderDatum: NodeRenderData = {
l.alpha = 1 simulationData: n,
l.color = computedStyleMap.get("--lightgray") gfx,
}) label,
color: color(n),
alpha: 1,
active: false,
}
let currentTransform = d3.zoomIdentity 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) { if (enableDrag) {
d3.select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call( select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
d3 drag<HTMLCanvasElement, NodeData | undefined>()
.drag<HTMLCanvasElement, NodeData | undefined>()
.container(() => app.canvas) .container(() => app.canvas)
.subject(() => graphData.nodes.find((n) => n.id === currentHoverNodeId)) .subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
.on("start", function dragstarted(event) { .on("start", function dragstarted(event) {
if (!event.active) simulation.alphaTarget(1).restart() if (!event.active) simulation.alphaTarget(1).restart()
event.subject.fx = event.subject.x event.subject.fx = event.subject.x
@ -446,8 +461,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
event.subject.fx = null event.subject.fx = null
event.subject.fy = null event.subject.fy = null
dragging = false dragging = false
// Check for node click event here.
if (Date.now() - dragStartTime < 100) { // 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 node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
const targ = resolveRelative(fullSlug, node.id) const targ = resolveRelative(fullSlug, node.id)
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
@ -455,19 +471,17 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}), }),
) )
} else { } else {
graphData.nodes.forEach((node) => { for (const node of nodeRenderData) {
if (!node.gfx) return
node.gfx.on("click", () => { node.gfx.on("click", () => {
const targ = resolveRelative(fullSlug, node.id) const targ = resolveRelative(fullSlug, node.simulationData.id)
window.spaNavigate(new URL(targ, window.location.toString())) window.spaNavigate(new URL(targ, window.location.toString()))
}) })
}) }
} }
if (enableZoom) { if (enableZoom) {
d3.select<HTMLCanvasElement, NodeData>(app.canvas).call( select<HTMLCanvasElement, NodeData>(app.canvas).call(
d3 zoom<HTMLCanvasElement, NodeData>()
.zoom<HTMLCanvasElement, NodeData>()
.extent([ .extent([
[0, 0], [0, 0],
[width, height], [width, height],
@ -478,40 +492,48 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
stage.scale.set(transform.k, transform.k) stage.scale.set(transform.k, transform.k)
stage.position.set(transform.x, transform.y) stage.position.set(transform.x, transform.y)
// zoom adjusts opacity of labels too
const scale = transform.k * opacityScale const scale = transform.k * opacityScale
let scaleOpacity = Math.max((scale - 1) / 3.75, 0) let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
const activeNodes = graphData.nodes const activeNodes = nodeRenderData
.filter((n) => n.active) .filter((n) => n.active)
.flatMap((n) => n.label) as PIXI.Text[] .flatMap((n) => n.label)
labelsContainer.children.forEach((label) => {
for (const label of labelsContainer.children) {
if (!activeNodes.includes(label)) { if (!activeNodes.includes(label)) {
label.alpha = scaleOpacity label.alpha = scaleOpacity
} }
}) }
}), })
) )
} }
function animate() { function animate(time: number) {
graphData.nodes.forEach((n) => { for (const n of nodeRenderData) {
let { x, y, gfx, label, active } = n const { x, y } = n.simulationData
if (!gfx || !x || !y || !label) return if (!x || !y) continue
gfx.position.set(x + width / 2, y + height / 2) n.gfx.position.set(x + width / 2, y + height / 2)
label.position.set(x + width / 2, y + height / 2) if (n.label) {
gfx.zIndex = active ? 2 : 1 n.label.position.set(x + width / 2, y + height / 2)
}) }
}
linkGraphic.clear() for (const l of linkRenderData) {
graphData.links.forEach((l) => { const linkData = l.simulationData
linkGraphic l.gfx.clear()
.moveTo(l.source.x! + width / 2, l.source.y! + height / 2) l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
.lineTo(l.target.x! + width / 2, l.target.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 }) .stroke({ alpha: l.alpha, width: 1, color: l.color })
}) }
tweens.forEach((t) => t.update(time))
app.renderer.render(stage) app.renderer.render(stage)
requestAnimationFrame(animate) requestAnimationFrame(animate)
} }
requestAnimationFrame(animate)
const graphAnimationFrameHandle = requestAnimationFrame(animate)
window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
} }
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
@ -530,7 +552,6 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
} }
renderGraph("global-graph-container", slug) renderGraph("global-graph-container", slug)
registerEscapeHandler(container, hideGlobalGraph) registerEscapeHandler(container, hideGlobalGraph)
} }