From bca74623a393c6957074e8adab90d056299e4c03 Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Sun, 25 Aug 2024 03:33:14 -0400 Subject: [PATCH] perf(graph): canvas implementation (#1328) * perf(graph): initial canvas layout include nodes and links drawn Signed-off-by: Aaron Pham * fix(graph): update persistent for nodeGfx Signed-off-by: Aaron Pham * chore(graph): add canvas element to avoid rerendering glitch Signed-off-by: Aaron Pham * fix(spa): only render graph once in global Signed-off-by: Aaron Pham * fix(graph): change svg as button render global graph on toggle Signed-off-by: Aaron Pham * fix(graph): fix anchor position and zIndex behaviour Signed-off-by: Aaron Pham * chore(graph): increase linkDistance Signed-off-by: Aaron Pham * refactor * fmt * pkg --------- Signed-off-by: Aaron Pham Co-authored-by: Jacky Zhao --- package-lock.json | 86 ++- package.json | 4 +- quartz/components/Graph.tsx | 51 +- quartz/components/scripts/graph.inline.ts | 654 ++++++++++++++-------- quartz/components/styles/graph.scss | 11 +- 5 files changed, 556 insertions(+), 250 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97813ea33..d104192e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "@jackyzha0/quartz", - "version": "4.3.0", + "version": "4.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackyzha0/quartz", - "version": "4.3.0", + "version": "4.3.1", "license": "MIT", "dependencies": { "@clack/prompts": "^0.7.0", "@floating-ui/dom": "^1.6.10", "@napi-rs/simple-git": "0.1.17", + "@tweenjs/tween.js": "^25.0.0", "async-mutex": "^0.5.0", "chalk": "^5.3.0", "chokidar": "^3.6.0", @@ -32,6 +33,7 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", + "pixi.js": "^8.3.3", "preact": "^10.23.2", "preact-render-to-string": "^6.5.9", "pretty-bytes": "^6.1.1", @@ -874,6 +876,12 @@ "node": ">= 8" } }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -902,6 +910,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/cli-spinner": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz", @@ -911,6 +925,12 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1172,6 +1192,12 @@ "@types/ms": "*" } }, + "node_modules/@types/earcut": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1294,6 +1320,21 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@webgpu/types": { + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz", + "integrity": "sha512-JDpYJN5E/asw84LTYhKyvPpxGnD+bAKPtpW9Ilurf7cZpxaTbxkQcGwOd7jgB9BPBrTYQ+32ufo4HiuomTjHNQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", @@ -2194,6 +2235,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2312,6 +2359,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3176,6 +3229,12 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, "node_modules/jackspeak": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", @@ -4698,6 +4757,12 @@ "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -4774,6 +4839,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixi.js": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz", + "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==", + "license": "MIT", + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/css-font-loading-module": "^0.0.12", + "@types/earcut": "^2.1.4", + "@webgpu/types": "^0.1.40", + "@xmldom/xmldom": "^0.8.10", + "earcut": "^2.2.4", + "eventemitter3": "^5.0.1", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.1.2" + } + }, "node_modules/preact": { "version": "10.23.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.23.2.tgz", diff --git a/package.json b/package.json index 99c965b22..58235354e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@jackyzha0/quartz", "description": "🌱 publish your digital garden and notes as a website", "private": true, - "version": "4.3.0", + "version": "4.3.1", "type": "module", "author": "jackyzha0 ", "license": "MIT", @@ -38,6 +38,7 @@ "@clack/prompts": "^0.7.0", "@floating-ui/dom": "^1.6.10", "@napi-rs/simple-git": "0.1.17", + "@tweenjs/tween.js": "^25.0.0", "async-mutex": "^0.5.0", "chalk": "^5.3.0", "chokidar": "^3.6.0", @@ -58,6 +59,7 @@ "mdast-util-to-hast": "^13.2.0", "mdast-util-to-string": "^4.0.0", "micromorph": "^0.4.5", + "pixi.js": "^8.3.3", "preact": "^10.23.2", "preact-render-to-string": "^6.5.9", "pretty-bytes": "^6.1.1", diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index f7ebcc9a2..ec3475d1e 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -65,31 +65,32 @@ export default ((opts?: GraphOptions) => {

{i18n(cfg.locale).components.graph.title}

- - - +
diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index cda6fb558..6bf43aa84 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -1,19 +1,56 @@ import type { ContentDetails } from "../../plugins/emitters/contentIndex" -import * as d3 from "d3" +import { + SimulationNodeDatum, + 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 { 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 + +type LinkRenderData = GraphicsInfo & { + simulationData: LinkData +} + +type NodeRenderData = GraphicsInfo & { + simulationData: NodeData + label: Text +} + const localStorageKey = "graph-visited" function getVisited(): Set { return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) @@ -25,6 +62,11 @@ function addToVisited(slug: SimpleSlug) { 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() @@ -45,7 +87,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) { removeTags, showTags, focusOnHover, - } = JSON.parse(graph.dataset["cfg"]!) + } = JSON.parse(graph.dataset["cfg"]!) as D3Config const data: Map = new Map( Object.entries(await fetchData).map(([k, v]) => [ @@ -53,10 +95,11 @@ async function renderGraph(container: string, fullSlug: FullSlug) { v, ]), ) - const links: LinkData[] = [] + const links: SimpleLinkData[] = [] const tags: SimpleSlug[] = [] - const validLinks = new Set(data.keys()) + + const tweens = new Map() for (const [source, details] of data.entries()) { const outgoing = details.links ?? [] @@ -100,263 +143,406 @@ async function renderGraph(container: string, fullSlug: FullSlug) { if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) } + const nodes = [...neighbourhood].map((url) => { + const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url) + return { + id: url, + text, + tags: data.get(url)?.tags ?? [], + } + }) const graphData: { nodes: NodeData[]; links: LinkData[] } = { - nodes: [...neighbourhood].map((url) => { - const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url) - return { - id: url, - text: text, - tags: data.get(url)?.tags ?? [], - } - }), - links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), + 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 = 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)) + // we virtualize the simulation and use pixi to actually render it + const simulation: Simulation = forceSimulation(graphData.nodes) + .force("charge", forceManyBody().strength(-100 * repelForce)) + .force("center", forceCenter().strength(centerForce)) + .force("link", forceLink(graphData.links).distance(linkDistance)) + .force("collide", forceCollide((n) => nodeRadius(n)).iterations(3)) - const height = Math.max(graph.offsetHeight, 250) const width = graph.offsetWidth + const height = Math.max(graph.offsetHeight, 250) - const svg = d3 - .select("#" + container) - .append("svg") - .attr("width", width) - .attr("height", height) - .attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale]) - - // 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) - - // 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)" + return computedStyleMap["--secondary"] } else if (visited.has(d.id) || d.id.startsWith("tags/")) { - return "var(--tertiary)" + return computedStyleMap["--tertiary"] } else { - return "var(--gray)" + return computedStyleMap["--gray"] } } - const drag = (simulation: d3.Simulation) => { - 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() - .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) } - let connectedNodes: SimpleSlug[] = [] + let hoveredNodeId: string | null = null + let hoveredNeighbours: Set = new Set() + const linkRenderData: LinkRenderData[] = [] + const nodeRenderData: NodeRenderData[] = [] + function updateHoverInfo(newHoveredId: string | null) { + hoveredNodeId = newHoveredId - // 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())) - }) - .on("mouseover", function (_, d) { - const currentId = d.id - const linkNodes = d3 - .selectAll(".link") - .filter((d: any) => d.source.id === currentId || d.target.id === currentId) - - if (focusOnHover) { - // fade out non-neighbour nodes - connectedNodes = linkNodes.data().flatMap((d: any) => [d.source.id, d.target.id]) - - d3.selectAll(".link") - .transition() - .duration(200) - .style("opacity", 0.2) - d3.selectAll(".node") - .filter((d) => !connectedNodes.includes(d.id)) - .transition() - .duration(200) - .style("opacity", 0.2) - - d3.selectAll(".node") - .filter((d) => !connectedNodes.includes(d.id)) - .nodes() - .map((it) => d3.select(it.parentNode as HTMLElement).select("text")) - .forEach((it) => { - let opacity = parseFloat(it.style("opacity")) - it.transition() - .duration(200) - .attr("opacityOld", opacity) - .style("opacity", Math.min(opacity, 0.2)) - }) + if (newHoveredId === null) { + hoveredNeighbours = new Set() + for (const n of nodeRenderData) { + n.active = false } - // highlight links - linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1) - - const bigFont = fontSize * 1.5 - - // show text for self - const parent = this.parentNode as HTMLElement - d3.select(parent) - .raise() - .select("text") - .transition() - .duration(200) - .attr("opacityOld", d3.select(parent).select("text").style("opacity")) - .style("opacity", 1) - .style("font-size", bigFont + "em") - }) - .on("mouseleave", function (_, d) { - if (focusOnHover) { - d3.selectAll(".link").transition().duration(200).style("opacity", 1) - d3.selectAll(".node").transition().duration(200).style("opacity", 1) - - d3.selectAll(".node") - .filter((d) => !connectedNodes.includes(d.id)) - .nodes() - .map((it) => d3.select(it.parentNode as HTMLElement).select("text")) - .forEach((it) => it.transition().duration(200).style("opacity", it.attr("opacityOld"))) + for (const l of linkRenderData) { + l.active = false } - const currentId = d.id - const linkNodes = d3 - .selectAll(".link") - .filter((d: any) => d.source.id === currentId || d.target.id === currentId) + } 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) + } - linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)") + l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId + } - const parent = this.parentNode as HTMLElement - d3.select(parent) - .select("text") - .transition() - .duration(200) - .style("opacity", d3.select(parent).select("text").attr("opacityOld")) - .style("font-size", fontSize + "em") + 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(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()) + }, }) - // @ts-ignore - .call(drag(simulation)) + } - // make tags hollow circles - node - .filter((d) => d.id.startsWith("tags/")) - .attr("stroke", color) - .attr("stroke-width", 2) - .attr("fill", "var(--light)") + function renderLabels() { + tweens.get("label")?.stop() + const tweenGroup = new TweenGroup() - // draw labels - const labels = graphNode - .append("text") - .attr("dx", 0) - .attr("dy", (d) => -nodeRadius(d) + "px") - .attr("text-anchor", "middle") - .text((d) => d.text) - .style("opacity", (opacityScale - 1) / 3.75) - .style("pointer-events", "none") - .style("font-size", fontSize + "em") - .raise() - // @ts-ignore - .call(drag(simulation)) + const defaultScale = 1 / scale + const activeScale = defaultScale * 1.1 + for (const n of nodeRenderData) { + const nodeId = n.simulationData.id + + if (hoveredNodeId === nodeId) { + tweenGroup.add( + new Tweened(n.label).to( + { + alpha: 1, + scale: { x: activeScale, y: activeScale }, + }, + 100, + ), + ) + } else { + tweenGroup.add( + new Tweened(n.label).to( + { + alpha: n.label.alpha, + scale: { x: defaultScale, y: defaultScale }, + }, + 100, + ), + ) + } + } + + tweenGroup.getAll().forEach((tw) => tw.start()) + tweens.set("label", { + update: tweenGroup.update.bind(tweenGroup), + stop() { + tweenGroup.getAll().forEach((tw) => tw.stop()) + }, + }) + } + + function renderNodes() { + tweens.get("hover")?.stop() + + 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(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()) + }, + }) + } + + 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({ zIndex: 3 }) + const nodesContainer = new Container({ zIndex: 2 }) + const linkContainer = new Container({ 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(app.canvas).call( + drag() + .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())) + } + }), + ) + } 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() + select(app.canvas).call( + zoom() .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 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" - } - - renderGraph("global-graph-container", slug) - - function hideGlobalGraph() { - container?.classList.remove("active") - const graph = document.getElementById("global-graph-container") - if (sidebar) { - sidebar.style.zIndex = "unset" + 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) + } } - if (!graph) return - removeAllChildren(graph) + + 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) } - registerEscapeHandler(container, hideGlobalGraph) + const graphAnimationFrameHandle = requestAnimationFrame(animate) + window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle)) } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { @@ -364,7 +550,39 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { addToVisited(simplifySlug(slug)) await renderGraph("graph-container", slug) + 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") + if (sidebar) { + sidebar.style.zIndex = "unset" + } + } + + 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?.addEventListener("click", renderGlobalGraph) window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) + + document.addEventListener("keydown", shortcutHandler) + window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) }) diff --git a/quartz/components/styles/graph.scss b/quartz/components/styles/graph.scss index 3deaa1feb..188907d1b 100644 --- a/quartz/components/styles/graph.scss +++ b/quartz/components/styles/graph.scss @@ -16,10 +16,13 @@ overflow: hidden; & > #global-graph-icon { + cursor: pointer; + background: none; + border: none; color: var(--dark); opacity: 0.5; - width: 18px; - height: 18px; + width: 24px; + height: 24px; position: absolute; padding: 0.2rem; margin: 0.3rem; @@ -59,8 +62,8 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - height: 60vh; - width: 50vw; + height: 80vh; + width: 80vw; @media all and (max-width: $fullPageWidth) { width: 90%;