chore(graph): add canvas element to avoid rerendering glitch

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
This commit is contained in:
Aaron Pham 2024-08-15 03:33:30 -04:00
parent d7b986f3f5
commit dd4b1cd604
No known key found for this signature in database
GPG Key ID: 18974753009D2BFA
2 changed files with 53 additions and 68 deletions

View File

@ -34,7 +34,7 @@ const defaultOptions: GraphOptions = {
repelForce: 0.5, repelForce: 0.5,
centerForce: 0.1, centerForce: 0.1,
linkDistance: 30, linkDistance: 30,
fontSize: 6, fontSize: 8,
opacityScale: 1, opacityScale: 1,
showTags: true, showTags: true,
removeTags: [], removeTags: [],
@ -44,10 +44,10 @@ const defaultOptions: GraphOptions = {
drag: true, drag: true,
zoom: true, zoom: true,
depth: -1, depth: -1,
scale: 1.25, scale: 1.1,
repelForce: 1, repelForce: 0.5,
centerForce: 0.1, centerForce: 0.3,
linkDistance: 50, linkDistance: 30,
fontSize: 12, fontSize: 12,
opacityScale: 1, opacityScale: 1,
showTags: true, showTags: true,
@ -64,7 +64,7 @@ export default ((opts?: 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> <canvas id="graph-container" data-cfg={JSON.stringify(localGraph)}></canvas>
<svg <svg
version="1.1" version="1.1"
id="global-graph-icon" id="global-graph-icon"
@ -92,7 +92,7 @@ export default ((opts?: GraphOptions) => {
</svg> </svg>
</div> </div>
<div id="global-graph-outer"> <div id="global-graph-outer">
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div> <canvas id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></canvas>
</div> </div>
</div> </div>
) )

View File

@ -2,7 +2,7 @@ import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3" import * as d3 from "d3"
import * as PIXI from "pixi.js" import * as PIXI from "pixi.js"
import * as TWEEN from "@tweenjs/tween.js" import * as TWEEN from "@tweenjs/tween.js"
import { registerEscapeHandler, removeAllChildren } from "./util" import { registerEscapeHandler } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
type NodeData = { type NodeData = {
@ -59,11 +59,11 @@ function animate(time: number) {
requestAnimationFrame(animate) requestAnimationFrame(animate)
async function renderGraph(container: string, fullSlug: FullSlug) { async function renderGraph(container: string, fullSlug: FullSlug) {
const canvas = document.getElementById(container) as HTMLCanvasElement | null
if (!canvas) return
const slug = simplifySlug(fullSlug) const slug = simplifySlug(fullSlug)
const visited = getVisited() const visited = getVisited()
const graph = document.getElementById(container)
if (!graph) return
removeAllChildren(graph)
let { let {
drag: enableDrag, drag: enableDrag,
@ -78,7 +78,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags, removeTags,
showTags, showTags,
focusOnHover, focusOnHover,
} = JSON.parse(graph.dataset["cfg"]!) } = JSON.parse(canvas.dataset["cfg"]!)
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]) => [
@ -89,8 +89,6 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
const links: LinkData[] = [] const links: LinkData[] = []
const tags: SimpleSlug[] = [] const tags: SimpleSlug[] = []
const validLinks = new Set(data.keys()) const validLinks = new Set(data.keys())
const height = Math.max(graph.offsetHeight, 250)
const width = graph.offsetWidth
for (const [source, details] of data.entries()) { for (const [source, details] of data.entries()) {
const outgoing = details.links ?? [] const outgoing = details.links ?? []
@ -151,6 +149,24 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
) as unknown as LinkNodes[], ) as unknown as LinkNodes[],
} }
const simulation: d3.Simulation<NodeData, LinkNodes> = d3
.forceSimulation(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce))
.force("center", d3.forceCenter().strength(centerForce))
.force(
"link",
d3
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force(
"collide",
d3.forceCollide((n) => nodeRadius(n)),
)
const width = canvas.offsetWidth
const height = Math.max(canvas.offsetHeight, 250)
const computedStyleMap = new Map<string, string>() const computedStyleMap = new Map<string, string>()
for (let i of [ for (let i of [
"--secondary", "--secondary",
@ -162,7 +178,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
"--darkgray", "--darkgray",
"--bodyFont", "--bodyFont",
]) { ]) {
computedStyleMap.set(i, getComputedStyle(graph).getPropertyValue(i)) computedStyleMap.set(i, getComputedStyle(canvas).getPropertyValue(i))
} }
// calculate color // calculate color
@ -182,7 +198,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
return 2 + Math.sqrt(numLinks) return 2 + Math.sqrt(numLinks)
} }
function renderLinks(data: LinkNodes[]) { function renderLinks(data: LinkNodes[], currentNodeId?: string | null) {
tweens.get("link")?.stop() tweens.get("link")?.stop()
const Group = new TWEEN.Group() const Group = new TWEEN.Group()
@ -204,7 +220,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}) })
} }
function renderLabels(data: NodeData[]) { function renderLabels(data: NodeData[], currentNodeId?: string | null) {
tweens.get("label")?.stop() tweens.get("label")?.stop()
const Group = new TWEEN.Group() const Group = new TWEEN.Group()
@ -237,15 +253,10 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}) })
} }
function renderCurrentNode({ function renderCurrentNode(props: { nodeId: string | null; focusOnHover: boolean }) {
nodeId, const { nodeId, focusOnHover } = props
focusOnHover,
}: {
nodeId: string | null
focusOnHover: boolean
}) {
tweens.get("hover")?.stop() tweens.get("hover")?.stop()
currentNodeId = nodeId
// NOTE: we need to create a new copy here // NOTE: we need to create a new copy here
const connectedNodes: Set<SimpleSlug> = new Set() const connectedNodes: Set<SimpleSlug> = new Set()
@ -257,9 +268,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
connectedNodes.add(l.target.id) connectedNodes.add(l.target.id)
} }
}) })
if (nodeId) {
connectedNodes.add(nodeId as SimpleSlug)
}
const Group = new TWEEN.Group() const Group = new TWEEN.Group()
graphData.nodes.forEach((n) => { graphData.nodes.forEach((n) => {
@ -277,8 +286,8 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
} }
}) })
renderLabels(graphData.nodes) renderLabels(graphData.nodes, nodeId)
renderLinks(graphData.links) renderLinks(graphData.links, nodeId)
Group.getAll().forEach((tw) => tw.start()) Group.getAll().forEach((tw) => tw.start())
tweens.set("hover", { tweens.set("hover", {
@ -295,6 +304,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
await app.init({ await app.init({
width, width,
height, height,
canvas: canvas,
antialias: true, antialias: true,
autoStart: false, autoStart: false,
autoDensity: true, autoDensity: true,
@ -303,11 +313,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
resolution: window.devicePixelRatio, resolution: window.devicePixelRatio,
eventMode: "static", eventMode: "static",
}) })
graph.appendChild(app.canvas)
const stage = app.stage const stage = app.stage
stage.interactive = false stage.interactive = false
stage.scale.set(1 / scale)
const nodesContainer = new PIXI.Container<PIXI.Graphics>({ zIndex: 1 }) const nodesContainer = new PIXI.Container<PIXI.Graphics>({ zIndex: 1 })
const labelsContainer = new PIXI.Container<PIXI.Text>({ zIndex: 2 }) const labelsContainer = new PIXI.Container<PIXI.Text>({ zIndex: 2 })
@ -316,25 +324,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
stage.addChild(nodesContainer, labelsContainer) stage.addChild(nodesContainer, labelsContainer)
nodesContainer.addChild(linkGraphic) nodesContainer.addChild(linkGraphic)
const simulation: d3.Simulation<NodeData, LinkNodes> = d3 let currentHoverNodeId: string | undefined
.forceSimulation(graphData.nodes)
.force("charge", d3.forceManyBody().strength(-100 * repelForce))
.force("center", d3.forceCenter().strength(centerForce))
.force(
"link",
d3
.forceLink(graphData.links)
.id((d: any) => d.id)
.distance(linkDistance),
)
.force(
"collide",
d3.forceCollide((n) => nodeRadius(n)),
)
let currentNodeId: string | null = null
let currentNodeGfx: PIXI.Graphics | undefined
let dragStartTime = 0 let dragStartTime = 0
let dragging = false
graphData.nodes.forEach((n) => { graphData.nodes.forEach((n) => {
const nodeId = n.id const nodeId = n.id
@ -362,7 +354,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}) })
.circle(0, 0, nodeRadius(n)) .circle(0, 0, nodeRadius(n))
.on("pointerover", () => { .on("pointerover", () => {
if (!currentNodeGfx) { if (!dragging) {
tweens.get(nodeId)?.stop() tweens.get(nodeId)?.stop()
const tweenScale = { x: 1, y: 1 } const tweenScale = { x: 1, y: 1 }
const tween = new TWEEN.Tween(tweenScale) const tween = new TWEEN.Tween(tweenScale)
@ -379,16 +371,16 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
} }
}) })
.on("pointerdown", (e) => { .on("pointerdown", (e) => {
currentNodeGfx = e.target as PIXI.Graphics currentHoverNodeId = e.target.label
}) })
.on("pointerup", () => { .on("pointerup", () => {
currentNodeGfx = undefined currentHoverNodeId = undefined
}) })
.on("pointerupoutside", () => { .on("pointerupoutside", () => {
currentNodeGfx = undefined currentHoverNodeId = undefined
}) })
.on("pointerleave", () => { .on("pointerleave", () => {
if (!currentNodeGfx) { if (!dragging) {
tweens.get(nodeId)?.stop() tweens.get(nodeId)?.stop()
const tweenScale = { const tweenScale = {
x: gfx.scale.x, x: gfx.scale.x,
@ -430,11 +422,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
d3 d3
.drag<HTMLCanvasElement, NodeData | undefined>() .drag<HTMLCanvasElement, NodeData | undefined>()
.container(() => app.canvas) .container(() => app.canvas)
.subject(() => { .subject(() => graphData.nodes.find((n) => n.id === currentHoverNodeId))
// get the item in graphData such that item.gfx === currentNodeGfx
const target = graphData.nodes.filter((j) => j.gfx === currentNodeGfx)[0]
return target
})
.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,20 +434,20 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
fy: event.subject.fy, fy: event.subject.fy,
} }
dragStartTime = Date.now() dragStartTime = Date.now()
dragging = true
}) })
.on("drag", function dragged(event) { .on("drag", function dragged(event) {
const k = currentTransform.k
const initPos = event.subject.__initialDragPos const initPos = event.subject.__initialDragPos
const dragPos = event event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
event.subject.fx = initPos.x + (dragPos.x - initPos.x) / k event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
event.subject.fy = initPos.y + (dragPos.y - initPos.y) / k
}) })
.on("end", function dragended(event) { .on("end", function dragended(event) {
if (!event.active) simulation.alphaTarget(0) if (!event.active) simulation.alphaTarget(0)
event.subject.fx = null event.subject.fx = null
event.subject.fy = null event.subject.fy = null
dragging = false
// Check for node click event here. // Check for node click event here.
if (Date.now() - dragStartTime < 200) { if (Date.now() - dragStartTime < 100) {
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()))
@ -539,12 +527,9 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
function hideGlobalGraph() { function hideGlobalGraph() {
container?.classList.remove("active") container?.classList.remove("active")
const graph = document.getElementById("global-graph-container")
if (sidebar) { if (sidebar) {
sidebar.style.zIndex = "unset" sidebar.style.zIndex = "unset"
} }
if (!graph) return
removeAllChildren(graph)
} }
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {