feat: add canvas render support

This commit is contained in:
icepro 2024-06-11 13:27:09 +08:00
parent 3faf2ff6f5
commit ad291a2d85
9 changed files with 912 additions and 227 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.DS_Store
.gitignore
.idea
node_modules
public
prof

71
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.5",
"@napi-rs/simple-git": "0.1.16",
"@tweenjs/tween.js": "^23.1.2",
"async-mutex": "^0.5.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
@ -32,6 +33,7 @@
"mdast-util-to-hast": "^13.1.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"pixi.js": "^8.2.0",
"preact": "^10.22.0",
"preact-render-to-string": "^6.5.5",
"pretty-bytes": "^6.1.1",
@ -810,6 +812,11 @@
"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=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -835,6 +842,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.2",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.2.tgz",
"integrity": "sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ=="
},
"node_modules/@types/cli-spinner": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz",
@ -844,6 +856,11 @@
"@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=="
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -1105,6 +1122,11 @@
"@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=="
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -1227,6 +1249,19 @@
"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.43",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.43.tgz",
"integrity": "sha512-HoP+d+m+Kuq8CsE63BZ3+BYBKAemrqbHUNrCalxrUju5XW+q/094Q3oeIa+2pTraEbO8ckJmGpibzyGT4OV4YQ=="
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"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",
@ -2127,6 +2162,11 @@
"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=="
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2245,6 +2285,11 @@
"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=="
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -3130,6 +3175,11 @@
"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=="
},
"node_modules/jackspeak": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
@ -4619,6 +4669,11 @@
"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=="
},
"node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
@ -4695,6 +4750,22 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixi.js": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.2.0.tgz",
"integrity": "sha512-hwPZ3sBYNbgrTyRmYAjQUyqZehiTxc1Qy0SI9GGrHLwtC15AtY7JaAk3ZVjnUo04wz9b0BHLjpRYnEUuIOENMw==",
"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.22.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.22.0.tgz",

View File

@ -38,6 +38,7 @@
"@clack/prompts": "^0.7.0",
"@floating-ui/dom": "^1.6.5",
"@napi-rs/simple-git": "0.1.16",
"@tweenjs/tween.js": "^23.1.2",
"async-mutex": "^0.5.0",
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
@ -58,6 +59,7 @@
"mdast-util-to-hast": "^13.1.0",
"mdast-util-to-string": "^4.0.0",
"micromorph": "^0.4.5",
"pixi.js": "^8.2.0",
"preact": "^10.22.0",
"preact-render-to-string": "^6.5.5",
"pretty-bytes": "^6.1.1",

View File

@ -29,7 +29,11 @@ export const defaultContentPageLayout: PageLayout = {
Component.DesktopOnly(Component.Explorer()),
],
right: [
Component.Graph(),
Component.Graph({
localGraph: {},
globalGraph: {},
useCanvas: true,
}),
Component.DesktopOnly(Component.TableOfContents()),
Component.Backlinks(),
],

View File

@ -18,11 +18,13 @@ export interface D3Config {
removeTags: string[]
showTags: boolean
focusOnHover?: boolean
alwaysShowLabels?: boolean
}
interface GraphOptions {
localGraph: Partial<D3Config> | undefined
globalGraph: Partial<D3Config> | undefined
useCanvas?: boolean
}
const defaultOptions: GraphOptions = {
@ -58,8 +60,16 @@ const defaultOptions: GraphOptions = {
export default ((opts?: GraphOptions) => {
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
const localGraph = {
...defaultOptions.localGraph,
...opts?.localGraph,
useCanvas: opts?.useCanvas,
}
const globalGraph = {
...defaultOptions.globalGraph,
...opts?.globalGraph,
useCanvas: opts?.useCanvas,
}
return (
<div class={classNames(displayClass, "graph")}>
<h3>{i18n(cfg.locale).components.graph.title}</h3>

View File

@ -1,13 +1,14 @@
import type { ContentDetails, ContentIndex } from "../../plugins/emitters/contentIndex"
import * as d3 from "d3"
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
import type { SimulationNodeDatum } from "d3"
import { registerEscapeHandler, removeAllChildren } from "./util"
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
import { renderCanvasGraph } from "./graphCanvasRender.inline"
import { renderSvgGraph } from "./graphSvgRender.inline"
type NodeData = {
id: SimpleSlug
text: string
tags: string[]
} & d3.SimulationNodeDatum
} & SimulationNodeDatum
type LinkData = {
source: SimpleSlug
@ -45,6 +46,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
removeTags,
showTags,
focusOnHover,
useCanvas,
} = JSON.parse(graph.dataset["cfg"]!)
const data: Map<SimpleSlug, ContentDetails> = new Map(
@ -111,227 +113,24 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
}),
links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(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 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])
// 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")
// calculate color
const color = (d: NodeData) => {
const isCurrent = d.id === slug
if (isCurrent) {
return "var(--secondary)"
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)"
} else {
return "var(--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
return 2 + Math.sqrt(numLinks)
}
let connectedNodes: SimpleSlug[] = []
// 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)
;(useCanvas ? renderCanvasGraph : renderSvgGraph)(graph, {
onNodeClick: (node) => {
const targ = resolveRelative(fullSlug, node.id as FullSlug)
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<HTMLElement, NodeData>(".link")
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => !connectedNodes.includes(d.id))
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".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))
})
}
// 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<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")
})
.on("mouseleave", function (_, d) {
if (focusOnHover) {
d3.selectAll<HTMLElement, NodeData>(".link").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".node").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".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")))
}
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)")
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")
})
// @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)")
// 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))
// set panning
if (enableZoom) {
svg.call(
d3
.zoom<SVGSVGElement, NodeData>()
.extent([
[0, 0],
[width, height],
])
.scaleExtent([0.25, 4])
.on("zoom", ({ transform }) => {
link.attr("transform", transform)
node.attr("transform", transform)
const scale = transform.k * opacityScale
const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity)
}),
)
}
// 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)
},
graphData,
slug,
visited,
enableDrag: enableDrag,
enableZoom: enableZoom,
depth,
scale,
repelForce,
centerForce,
linkDistance,
fontSize,
opacityScale,
focusOnHover,
})
}

View File

@ -0,0 +1,516 @@
import * as d3 from "d3"
import type { Graphics, Text } from "pixi.js"
type NodeData = {
id: string
text: string
tags: string[]
r?: number
} & d3.SimulationNodeDatum
type LinkData = {
source: string
target: string
}
type D3NodeData = d3.SimulationNodeDatum &
NodeData & {
gfx?: Graphics
label?: Text
active?: boolean
}
type D3LinkData = d3.SimulationLinkDatum<D3NodeData> & {
active?: boolean
alpha?: number
color?: string
}
let tweens = new Map<
string,
{
update: (time: number) => void
stop: () => void
}
>()
function animate(time: number) {
tweens.forEach((tween) => tween.update(time))
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
export async function renderCanvasGraph(
container: HTMLElement,
cfg: {
graphData: {
nodes: NodeData[]
links: LinkData[]
}
alwaysShowLabels?: boolean
slug: string
onNodeClick: (node: NodeData) => void
visited: Set<string>
enableDrag?: boolean
enableZoom?: boolean
/**
* not implemented yet
*/
depth: number
/**
* not implemented yet
*/
scale?: number
repelForce: number
centerForce: number
linkDistance: number
/**
* not implemented yet
*/
fontSize: number
/**
* not implemented yet
*/
opacityScale?: number
focusOnHover: boolean
},
) {
const { Application, Container, Point, Graphics, Text } = await import(
"https://cdnjs.cloudflare.com/ajax/libs/pixi.js/8.1.6/pixi.min.mjs"
)
const TWEEN = await import(
"https://cdnjs.cloudflare.com/ajax/libs/tween.js/23.1.2/tween.esm.min.js"
)
// clear all tweens when re-rendering
tweens.forEach((tween) => tween.stop())
tweens.clear()
// test if the user is on a mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
)
// set the maximum scale based on the device
const MAX_SCALE = isMobile ? 2 : 4
const SIZE_BASE = MAX_SCALE
let stage = new Container()
stage.scale.set(1 / MAX_SCALE, 1 / MAX_SCALE)
let nodeContainer = new Container()
let labelContainer = new Container()
function getColor(cssVar: string) {
return getComputedStyle(container!).getPropertyValue(cssVar)
}
/**
* pre-calculate colors, never call getComputedStyle in the render loop, it's slow
* For svg render we can use css variables to get the color
* When using canvas render, we need to pre-calculate the colors from css variables
*/
const colorMap = new Map<string, string>()
colorMap.set("--secondary", getColor("--secondary"))
colorMap.set("--tertiary", getColor("--tertiary"))
colorMap.set("--gray", getColor("--gray"))
colorMap.set("--dark", getColor("--dark"))
colorMap.set("--light", getColor("--light"))
colorMap.set("--lightgray", getColor("--lightgray"))
nodeContainer.zIndex = 1
labelContainer.zIndex = 2
stage.addChild(nodeContainer)
stage.addChild(labelContainer)
const height = Math.max(container.offsetHeight, 250)
const width = container.offsetWidth
const app = new Application()
await app.init({
width: width,
height: height,
backgroundAlpha: 0,
resolution: window.devicePixelRatio,
autoDensity: true,
autoStart: false,
})
container.appendChild(app.canvas)
let simulation = d3
.forceSimulation<D3NodeData>()
.force(
"link",
d3.forceLink<D3NodeData, D3LinkData>().id((d) => d.id),
)
.force("charge", d3.forceManyBody().strength(-100 * cfg.repelForce))
.force("center", d3.forceCenter(width / 2, height / 2).strength(cfg.centerForce / 2))
.force(
"collide",
d3.forceCollide(() => 20),
)
const colour = (d: D3NodeData) => {
const isCurrent = d.id === cfg.slug
if (isCurrent) {
return colorMap.get("--secondary")
} else if (cfg.visited.has(d.id) || d.id.startsWith("tags/")) {
return colorMap.get("--tertiary")
} else {
return colorMap.get("--gray")
}
}
let links = new Graphics()
nodeContainer.addChild(links)
function nodeRadius(d: NodeData) {
const numLinks = (cfg.graphData.links as D3LinkData[]).filter(
(l: any) => l.source === d.id || l.target === d.id,
).length
return 2 + Math.sqrt(numLinks)
}
let currentHoverNodeId: string | null = null
function setupLinkAnimation(links: D3LinkData[]) {
tweens.get("link")?.stop()
const tweenGroup = new TWEEN.Group()
links.forEach((link) => {
let alpha = 1
if (currentHoverNodeId) {
alpha = link.active ? 1 : 0.2
}
tweenGroup.add(
new TWEEN.Tween(link).to(
{
alpha,
},
200,
),
)
link.color = link.active ? colorMap.get("--gray") : colorMap.get("--lightgray")
})
tweenGroup.getAll().forEach((tween) => {
tween.start()
})
tweens.set("link", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tween) => {
tween.stop()
})
},
})
}
function setupLabelAnimation(nodes: D3NodeData[]) {
const { connectedNodes } = getConnectedNodesAndLinks(currentHoverNodeId!)
tweens.get("label")?.stop()
const tweenGroup = new TWEEN.Group()
nodes.forEach((node) => {
if (!node.label) return
if (currentHoverNodeId === node.id) {
// highlight label and scale up
tweenGroup.add(
new TWEEN.Tween(node.label!).to(
{
alpha: 1,
scale: {
x: 1.25,
y: 1.25,
},
},
200,
),
)
} else {
let alpha = node.active ? 0.3 : 0
if (currentTransform.k > 0.5) {
alpha = 0.3
}
if (currentHoverNodeId && connectedNodes.has(node.id)) {
alpha = 0.7
}
if (!cfg.alwaysShowLabels) {
alpha = 0
}
tweenGroup.add(
new TWEEN.Tween(node.label!).to(
{
alpha,
scale: {
x: 1,
y: 1,
},
},
200,
),
)
}
})
tweenGroup.getAll().forEach((tween) => {
tween.start()
})
tweens.set("label", {
update: tweenGroup.update.bind(tweenGroup),
stop() {
tweenGroup.getAll().forEach((tween) => {
tween.stop()
})
},
})
}
function getConnectedNodesAndLinks(nodeId: string) {
const connectedNodes = new Set<string>([])
const links: D3LinkData[] = []
;(cfg.graphData.links as D3LinkData[]).forEach((link) => {
const source = link.source as D3NodeData
const target = link.target as D3NodeData
if (source.id === nodeId || target.id === nodeId) {
connectedNodes.add(source.id)
connectedNodes.add(target.id)
}
links.push(link)
})
return {
connectedNodes,
links,
}
}
function setCurrentHoverNodeId(nodeId: string | null) {
currentHoverNodeId = nodeId
if (tweens.get("hover")) tweens.get("hover")?.stop()
// Find all nodes connected to the current node.
const { connectedNodes, links } = getConnectedNodesAndLinks(nodeId!)
if (nodeId) {
connectedNodes.add(nodeId)
}
const groupTween = new TWEEN.Group()
// Hide all non-connected nodes.
;(cfg.graphData.nodes as D3NodeData[]).forEach((node) => {
if (nodeId) {
node.active = connectedNodes.has(node.id)
if (node.id !== nodeId) {
// If a node is connected, set its alpha to 1; otherwise, set it to 0.2
groupTween.add(
new TWEEN.Tween(node.gfx!, groupTween).to(
{ alpha: connectedNodes.has(node.id) ? 1 : 0.2 },
200,
),
)
}
} else {
// Restore all nodes
node.active = false
groupTween.add(new TWEEN.Tween(node.gfx!, groupTween).to({ alpha: 1 }, 200))
}
})
// Set connection status.
links.forEach((link) => {
const source = link.source as D3NodeData
const target = link.target as D3NodeData
link.active = source.id === nodeId || target.id === nodeId
})
setupLabelAnimation(cfg.graphData.nodes as D3NodeData[])
setupLinkAnimation(links)
groupTween.getAll().forEach((tween) => {
tween.start()
})
tweens.set("hover", {
update: groupTween.update.bind(groupTween),
stop() {
groupTween.getAll().forEach((tween) => {
tween.stop()
})
},
})
}
let dragStartTime = 0
;(cfg.graphData.nodes as D3NodeData[]).forEach((node) => {
const gfx = new Graphics()
const nodeSize = nodeRadius(node)
if (node.id.startsWith("tags/")) {
gfx.circle(0, 0, nodeSize * SIZE_BASE)
gfx.fill({
color: colour(node),
})
gfx.circle(0, 0, (nodeSize - 2) * SIZE_BASE)
gfx.fill({
color: colorMap.get("--light"),
})
gfx.stroke()
} else {
gfx.circle(0, 0, nodeSize * SIZE_BASE)
gfx.fill({
color: colour(node),
})
gfx.stroke()
}
gfx.eventMode = "static"
gfx.on("pointerover", () => {
tweens.get(node.id)?.stop()
const scale = {
x: 1,
y: 1,
}
const tween = new TWEEN.Tween(scale, false)
.to({ x: 1.5, y: 1.5 }, 100)
.onUpdate(() => {
gfx.scale.set(scale.x, scale.y)
})
.onStop(() => {
tweens.delete(node.id)
})
.start()
tweens.set(node.id, tween)
setCurrentHoverNodeId(node.id)
})
gfx.cursor = "pointer"
gfx.on("pointerleave", () => {
tweens.get(node.id)?.stop()
const scale = {
x: gfx.scale.x,
y: gfx.scale.y,
}
const tween = new TWEEN.Tween(scale, false)
.to({ x: 1, y: 1 }, 100)
.onUpdate(() => {
gfx.scale.set(scale.x, scale.y)
})
.onStop(() => {
tweens.delete(node.id)
})
.start()
tweens.set(node.id, tween)
setCurrentHoverNodeId(null)
})
node.gfx = gfx
node.r = nodeSize
const label = new Text({
// Display up to 9 characters of text; if it exceeds 9 characters, show "..." instead
// in order to prevent the text from overflowing the node.
// not good solution for english text
text: node.text.length > 9 ? node.text.slice(0, 9) + "..." : node.text,
style: {
fontSize: 12 * SIZE_BASE,
fill: colorMap.get("--dark"),
},
})
label.scale.set(1, 1)
label.anchor.set(0.5, 1)
label.alpha = cfg.alwaysShowLabels ? 0.3 : 0
node.label = label
labelContainer.addChild(label)
nodeContainer.addChild(gfx)
})
let currentTransform = d3.zoomIdentity
let d3canvas = d3.select<HTMLCanvasElement, D3NodeData | undefined>(app.canvas)
if (cfg.enableDrag) {
d3canvas = d3canvas.call(
d3
.drag<HTMLCanvasElement, D3NodeData | undefined>()
.container(() => app.canvas)
.subject((e) => {
const x = currentTransform.invertX(e.x)
const y = currentTransform.invertY(e.y)
for (let i = cfg.graphData.nodes.length - 1; i >= 0; --i) {
const node = cfg.graphData.nodes[i]
const dx = (x - node.x!) * SIZE_BASE
const dy = (y - node.y!) * SIZE_BASE
let r = (node.r! + 5) * SIZE_BASE
if (dx * dx + dy * dy < r * r) {
return node
}
}
})
.on("start", function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).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()
})
.on("drag", function dragged(event) {
const k = currentTransform.k
const initPos = event.subject.__initialDragPos
const dragPos = event
event.subject.fx = initPos.x + (dragPos.x - initPos.x) / k
event.subject.fy = initPos.y + (dragPos.y - initPos.y) / k
})
.on("end", function dragended(event) {
if (!event.active) simulation.alphaTarget(0)
event.subject.fx = null
event.subject.fy = null
// If the drag time is less than 200ms, it is considered a click event, never use pixi event to handle click event
if (Date.now() - dragStartTime < 200) {
cfg.onNodeClick(
cfg.graphData.nodes.find((node) => node.id === event.subject.id) as NodeData,
)
}
}),
)
}
if (cfg.enableZoom) {
d3canvas.call(
d3
.zoom<HTMLCanvasElement, D3NodeData | undefined>()
.extent([
[0, 0],
[width, height],
])
.scaleExtent([0.25, MAX_SCALE])
.on("zoom", ({ transform }) => {
currentTransform = transform
stage.scale.set(currentTransform.k / SIZE_BASE, currentTransform.k / SIZE_BASE)
stage.position.set(currentTransform.x, currentTransform.y)
setupLabelAnimation(cfg.graphData.nodes as D3NodeData[])
}),
)
}
simulation.nodes(cfg.graphData.nodes)
simulation.force(
"link",
d3
.forceLink<D3NodeData, D3LinkData>(cfg.graphData.links)
.id((d) => d.id)
.distance(cfg.linkDistance * 1.8),
)
;(cfg.graphData.links as D3LinkData[]).forEach((link) => {
link.alpha = 1
link.color = colorMap.get("--lightgray")
})
function animate() {
;(cfg.graphData.nodes as D3NodeData[]).forEach((node) => {
let { x, y, gfx, label, r } = node
if (!gfx) return
gfx.position = new Point((x || 0) * SIZE_BASE, (y || 0) * SIZE_BASE)
if (label) {
label.position.set(node.x! * SIZE_BASE, (node.y! - (r || 5)) * SIZE_BASE)
}
gfx.zIndex = node.active ? 2 : 1
})
links.clear()
;(cfg.graphData.links as D3LinkData[])
.sort((a, b) => {
// active links should be drawn on top of inactive links
if (a.active && !b.active) return 1
if (!a.active && b.active) return -1
return 0
})
.forEach((link) => {
const source = link.source as D3NodeData
const target = link.target as D3NodeData
const color = link.color
links.moveTo(source.x! * SIZE_BASE, source.y! * SIZE_BASE)
links.lineTo(target.x! * SIZE_BASE, target.y! * SIZE_BASE)
links.stroke({
width: 1 * SIZE_BASE,
color,
alpha: link.alpha,
})
})
links.fill()
app.renderer.render(stage)
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
}

View File

@ -0,0 +1,275 @@
import { SimpleSlug } from "../../util/path"
import * as d3 from "d3"
type NodeData = {
id: SimpleSlug
text: string
tags: string[]
} & d3.SimulationNodeDatum
type LinkData = {
source: SimpleSlug
target: SimpleSlug
}
export function renderSvgGraph(
container: HTMLElement,
cfg: {
graphData: {
nodes: NodeData[]
links: LinkData[]
}
slug: string
onNodeClick: (node: NodeData) => void
visited: Set<string>
enableDrag: boolean
enableZoom: boolean
depth: number
scale: number
repelForce: number
centerForce: number
linkDistance: number
fontSize: number
opacityScale: number
focusOnHover: boolean
},
) {
const {
slug,
visited,
enableDrag,
enableZoom,
depth,
scale,
repelForce,
centerForce,
linkDistance,
fontSize,
opacityScale,
focusOnHover,
graphData,
} = cfg
const links = graphData.links
const graph = container
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 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])
// 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")
// calculate color
const color = (d: NodeData) => {
const isCurrent = d.id === slug
if (isCurrent) {
return "var(--secondary)"
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
return "var(--tertiary)"
} else {
return "var(--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
return 2 + Math.sqrt(numLinks)
}
let connectedNodes: SimpleSlug[] = []
// 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) => {
cfg.onNodeClick(d)
})
.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<HTMLElement, NodeData>(".link")
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".node")
.filter((d) => !connectedNodes.includes(d.id))
.transition()
.duration(200)
.style("opacity", 0.2)
d3.selectAll<HTMLElement, NodeData>(".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))
})
}
// 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<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")
})
.on("mouseleave", function (_, d) {
if (focusOnHover) {
d3.selectAll<HTMLElement, NodeData>(".link").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".node").transition().duration(200).style("opacity", 1)
d3.selectAll<HTMLElement, NodeData>(".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")))
}
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)")
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")
})
// @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)")
// 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))
// set panning
if (enableZoom) {
svg.call(
d3
.zoom<SVGSVGElement, NodeData>()
.extent([
[0, 0],
[width, height],
])
.scaleExtent([0.25, 4])
.on("zoom", ({ transform }) => {
link.attr("transform", transform)
node.attr("transform", transform)
const scale = transform.k * opacityScale
const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
labels.attr("transform", transform).style("opacity", scaledOpacity)
}),
)
}
// 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)
})
}

7
quartz/urlImport.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare module "https://cdnjs.cloudflare.com/ajax/libs/tween.js/23.1.2/tween.esm.min.js" {
export * from "@tweenjs/tween.js"
}
declare module "https://cdnjs.cloudflare.com/ajax/libs/pixi.js/8.1.6/pixi.min.mjs" {
export * from "pixi.js"
}