diff --git a/.gitignore b/.gitignore index 25d07db1c..6cd7812e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .gitignore +.idea node_modules public prof diff --git a/package-lock.json b/package-lock.json index 1e42c45cb..eff9bb7a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 64604588a..565b4451d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/quartz.layout.ts b/quartz.layout.ts index b5a1639eb..dae1117b9 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -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(), ], diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index f7ebcc9a2..75532505e 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -18,11 +18,13 @@ export interface D3Config { removeTags: string[] showTags: boolean focusOnHover?: boolean + alwaysShowLabels?: boolean } interface GraphOptions { localGraph: Partial | undefined globalGraph: Partial | 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 (

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

diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 1a4140b48..2df6887e1 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -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 = 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 = 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("#" + 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) => { - 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 - 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(".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)) - }) - } - - // 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"))) - } - 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(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() - .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, }) } diff --git a/quartz/components/scripts/graphCanvasRender.inline.ts b/quartz/components/scripts/graphCanvasRender.inline.ts new file mode 100644 index 000000000..65cc94926 --- /dev/null +++ b/quartz/components/scripts/graphCanvasRender.inline.ts @@ -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 & { + 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 + 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() + 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() + .force( + "link", + d3.forceLink().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([]) + 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(app.canvas) + if (cfg.enableDrag) { + d3canvas = d3canvas.call( + d3 + .drag() + .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() + .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(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) +} diff --git a/quartz/components/scripts/graphSvgRender.inline.ts b/quartz/components/scripts/graphSvgRender.inline.ts new file mode 100644 index 000000000..564e064dc --- /dev/null +++ b/quartz/components/scripts/graphSvgRender.inline.ts @@ -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 + 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 = 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(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) => { + 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 + 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(".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)) + }) + } + + // 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"))) + } + 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(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() + .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) + }) +} diff --git a/quartz/urlImport.d.ts b/quartz/urlImport.d.ts new file mode 100644 index 000000000..2fa2fb53d --- /dev/null +++ b/quartz/urlImport.d.ts @@ -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" +}