The Trans Directory
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf(graph): canvas implementation (#1328)

* perf(graph): initial canvas layout

include nodes and links drawn

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(graph): update persistent for nodeGfx

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

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

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(spa): only render graph once in global

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(graph): change svg as button

render global graph on toggle

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* fix(graph): fix anchor position and zIndex behaviour

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* chore(graph): increase linkDistance

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>

* refactor

* fmt

* pkg

---------

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>

authored by

Aaron Pham
Jacky Zhao
and committed by
GitHub
bca74623 4c9e8601

+542 -236
+84 -2
package-lock.json
··· 1 1 { 2 2 "name": "@jackyzha0/quartz", 3 - "version": "4.3.0", 3 + "version": "4.3.1", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "@jackyzha0/quartz", 9 - "version": "4.3.0", 9 + "version": "4.3.1", 10 10 "license": "MIT", 11 11 "dependencies": { 12 12 "@clack/prompts": "^0.7.0", 13 13 "@floating-ui/dom": "^1.6.10", 14 14 "@napi-rs/simple-git": "0.1.17", 15 + "@tweenjs/tween.js": "^25.0.0", 15 16 "async-mutex": "^0.5.0", 16 17 "chalk": "^5.3.0", 17 18 "chokidar": "^3.6.0", ··· 32 33 "mdast-util-to-hast": "^13.2.0", 33 34 "mdast-util-to-string": "^4.0.0", 34 35 "micromorph": "^0.4.5", 36 + "pixi.js": "^8.3.3", 35 37 "preact": "^10.23.2", 36 38 "preact-render-to-string": "^6.5.9", 37 39 "pretty-bytes": "^6.1.1", ··· 874 876 "node": ">= 8" 875 877 } 876 878 }, 879 + "node_modules/@pixi/colord": { 880 + "version": "2.9.6", 881 + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", 882 + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", 883 + "license": "MIT" 884 + }, 877 885 "node_modules/@pkgjs/parseargs": { 878 886 "version": "0.11.0", 879 887 "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", ··· 902 910 "url": "https://github.com/sponsors/sindresorhus" 903 911 } 904 912 }, 913 + "node_modules/@tweenjs/tween.js": { 914 + "version": "25.0.0", 915 + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", 916 + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", 917 + "license": "MIT" 918 + }, 905 919 "node_modules/@types/cli-spinner": { 906 920 "version": "0.2.3", 907 921 "resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz", ··· 910 924 "dependencies": { 911 925 "@types/node": "*" 912 926 } 927 + }, 928 + "node_modules/@types/css-font-loading-module": { 929 + "version": "0.0.12", 930 + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", 931 + "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", 932 + "license": "MIT" 913 933 }, 914 934 "node_modules/@types/d3": { 915 935 "version": "7.4.3", ··· 1172 1192 "@types/ms": "*" 1173 1193 } 1174 1194 }, 1195 + "node_modules/@types/earcut": { 1196 + "version": "2.1.4", 1197 + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", 1198 + "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", 1199 + "license": "MIT" 1200 + }, 1175 1201 "node_modules/@types/estree": { 1176 1202 "version": "1.0.5", 1177 1203 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", ··· 1294 1320 "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", 1295 1321 "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" 1296 1322 }, 1323 + "node_modules/@webgpu/types": { 1324 + "version": "0.1.44", 1325 + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz", 1326 + "integrity": "sha512-JDpYJN5E/asw84LTYhKyvPpxGnD+bAKPtpW9Ilurf7cZpxaTbxkQcGwOd7jgB9BPBrTYQ+32ufo4HiuomTjHNQ==", 1327 + "license": "BSD-3-Clause" 1328 + }, 1329 + "node_modules/@xmldom/xmldom": { 1330 + "version": "0.8.10", 1331 + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", 1332 + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", 1333 + "license": "MIT", 1334 + "engines": { 1335 + "node": ">=10.0.0" 1336 + } 1337 + }, 1297 1338 "node_modules/agent-base": { 1298 1339 "version": "7.1.0", 1299 1340 "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", ··· 2194 2235 "url": "https://github.com/sponsors/wooorm" 2195 2236 } 2196 2237 }, 2238 + "node_modules/earcut": { 2239 + "version": "2.2.4", 2240 + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", 2241 + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", 2242 + "license": "ISC" 2243 + }, 2197 2244 "node_modules/eastasianwidth": { 2198 2245 "version": "0.2.0", 2199 2246 "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", ··· 2312 2359 "url": "https://opencollective.com/unified" 2313 2360 } 2314 2361 }, 2362 + "node_modules/eventemitter3": { 2363 + "version": "5.0.1", 2364 + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", 2365 + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", 2366 + "license": "MIT" 2367 + }, 2315 2368 "node_modules/extend": { 2316 2369 "version": "3.0.2", 2317 2370 "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", ··· 3175 3228 "version": "2.0.0", 3176 3229 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 3177 3230 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" 3231 + }, 3232 + "node_modules/ismobilejs": { 3233 + "version": "1.1.1", 3234 + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", 3235 + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", 3236 + "license": "MIT" 3178 3237 }, 3179 3238 "node_modules/jackspeak": { 3180 3239 "version": "4.0.1", ··· 4698 4757 "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", 4699 4758 "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" 4700 4759 }, 4760 + "node_modules/parse-svg-path": { 4761 + "version": "0.1.2", 4762 + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", 4763 + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", 4764 + "license": "MIT" 4765 + }, 4701 4766 "node_modules/parse5": { 4702 4767 "version": "7.1.2", 4703 4768 "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", ··· 4772 4837 }, 4773 4838 "funding": { 4774 4839 "url": "https://github.com/sponsors/jonschlinkert" 4840 + } 4841 + }, 4842 + "node_modules/pixi.js": { 4843 + "version": "8.3.3", 4844 + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz", 4845 + "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==", 4846 + "license": "MIT", 4847 + "dependencies": { 4848 + "@pixi/colord": "^2.9.6", 4849 + "@types/css-font-loading-module": "^0.0.12", 4850 + "@types/earcut": "^2.1.4", 4851 + "@webgpu/types": "^0.1.40", 4852 + "@xmldom/xmldom": "^0.8.10", 4853 + "earcut": "^2.2.4", 4854 + "eventemitter3": "^5.0.1", 4855 + "ismobilejs": "^1.1.1", 4856 + "parse-svg-path": "^0.1.2" 4775 4857 } 4776 4858 }, 4777 4859 "node_modules/preact": {
+3 -1
package.json
··· 2 2 "name": "@jackyzha0/quartz", 3 3 "description": "🌱 publish your digital garden and notes as a website", 4 4 "private": true, 5 - "version": "4.3.0", 5 + "version": "4.3.1", 6 6 "type": "module", 7 7 "author": "jackyzha0 <j.zhao2k19@gmail.com>", 8 8 "license": "MIT", ··· 38 38 "@clack/prompts": "^0.7.0", 39 39 "@floating-ui/dom": "^1.6.10", 40 40 "@napi-rs/simple-git": "0.1.17", 41 + "@tweenjs/tween.js": "^25.0.0", 41 42 "async-mutex": "^0.5.0", 42 43 "chalk": "^5.3.0", 43 44 "chokidar": "^3.6.0", ··· 58 59 "mdast-util-to-hast": "^13.2.0", 59 60 "mdast-util-to-string": "^4.0.0", 60 61 "micromorph": "^0.4.5", 62 + "pixi.js": "^8.3.3", 61 63 "preact": "^10.23.2", 62 64 "preact-render-to-string": "^6.5.9", 63 65 "pretty-bytes": "^6.1.1",
+26 -25
quartz/components/Graph.tsx
··· 65 65 <h3>{i18n(cfg.locale).components.graph.title}</h3> 66 66 <div class="graph-outer"> 67 67 <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> 68 - <svg 69 - version="1.1" 70 - id="global-graph-icon" 71 - xmlns="http://www.w3.org/2000/svg" 72 - xmlnsXlink="http://www.w3.org/1999/xlink" 73 - x="0px" 74 - y="0px" 75 - viewBox="0 0 55 55" 76 - fill="currentColor" 77 - xmlSpace="preserve" 78 - > 79 - <path 80 - d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17 81 - s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4 82 - c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562 83 - C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829 84 - c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91 85 - v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4 86 - s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665 87 - C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2 88 - S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4 89 - s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2 90 - s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z" 91 - /> 92 - </svg> 68 + <button id="global-graph-icon" aria-label="Global Graph"> 69 + <svg 70 + version="1.1" 71 + xmlns="http://www.w3.org/2000/svg" 72 + xmlnsXlink="http://www.w3.org/1999/xlink" 73 + x="0px" 74 + y="0px" 75 + viewBox="0 0 55 55" 76 + fill="currentColor" 77 + xmlSpace="preserve" 78 + > 79 + <path 80 + d="M49,0c-3.309,0-6,2.691-6,6c0,1.035,0.263,2.009,0.726,2.86l-9.829,9.829C32.542,17.634,30.846,17,29,17 81 + s-3.542,0.634-4.898,1.688l-7.669-7.669C16.785,10.424,17,9.74,17,9c0-2.206-1.794-4-4-4S9,6.794,9,9s1.794,4,4,4 82 + c0.74,0,1.424-0.215,2.019-0.567l7.669,7.669C21.634,21.458,21,23.154,21,25s0.634,3.542,1.688,4.897L10.024,42.562 83 + C8.958,41.595,7.549,41,6,41c-3.309,0-6,2.691-6,6s2.691,6,6,6s6-2.691,6-6c0-1.035-0.263-2.009-0.726-2.86l12.829-12.829 84 + c1.106,0.86,2.44,1.436,3.898,1.619v10.16c-2.833,0.478-5,2.942-5,5.91c0,3.309,2.691,6,6,6s6-2.691,6-6c0-2.967-2.167-5.431-5-5.91 85 + v-10.16c1.458-0.183,2.792-0.759,3.898-1.619l7.669,7.669C41.215,39.576,41,40.26,41,41c0,2.206,1.794,4,4,4s4-1.794,4-4 86 + s-1.794-4-4-4c-0.74,0-1.424,0.215-2.019,0.567l-7.669-7.669C36.366,28.542,37,26.846,37,25s-0.634-3.542-1.688-4.897l9.665-9.665 87 + C46.042,11.405,47.451,12,49,12c3.309,0,6-2.691,6-6S52.309,0,49,0z M11,9c0-1.103,0.897-2,2-2s2,0.897,2,2s-0.897,2-2,2 88 + S11,10.103,11,9z M6,51c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S8.206,51,6,51z M33,49c0,2.206-1.794,4-4,4s-4-1.794-4-4 89 + s1.794-4,4-4S33,46.794,33,49z M29,31c-3.309,0-6-2.691-6-6s2.691-6,6-6s6,2.691,6,6S32.309,31,29,31z M47,41c0,1.103-0.897,2-2,2 90 + s-2-0.897-2-2s0.897-2,2-2S47,39.897,47,41z M49,10c-2.206,0-4-1.794-4-4s1.794-4,4-4s4,1.794,4,4S51.206,10,49,10z" 91 + /> 92 + </svg> 93 + </button> 93 94 </div> 94 95 <div id="global-graph-outer"> 95 96 <div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
+422 -204
quartz/components/scripts/graph.inline.ts
··· 1 1 import type { ContentDetails } from "../../plugins/emitters/contentIndex" 2 - import * as d3 from "d3" 2 + import { 3 + SimulationNodeDatum, 4 + SimulationLinkDatum, 5 + Simulation, 6 + forceSimulation, 7 + forceManyBody, 8 + forceCenter, 9 + forceLink, 10 + forceCollide, 11 + zoomIdentity, 12 + select, 13 + drag, 14 + zoom, 15 + } from "d3" 16 + import { Text, Graphics, Application, Container, Circle } from "pixi.js" 17 + import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js" 3 18 import { registerEscapeHandler, removeAllChildren } from "./util" 4 19 import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path" 20 + import { D3Config } from "../Graph" 21 + 22 + type GraphicsInfo = { 23 + color: string 24 + gfx: Graphics 25 + alpha: number 26 + active: boolean 27 + } 5 28 6 29 type NodeData = { 7 30 id: SimpleSlug 8 31 text: string 9 32 tags: string[] 10 - } & d3.SimulationNodeDatum 33 + } & SimulationNodeDatum 11 34 12 - type LinkData = { 35 + type SimpleLinkData = { 13 36 source: SimpleSlug 14 37 target: SimpleSlug 15 38 } 16 39 40 + type LinkData = { 41 + source: NodeData 42 + target: NodeData 43 + } & SimulationLinkDatum<NodeData> 44 + 45 + type LinkRenderData = GraphicsInfo & { 46 + simulationData: LinkData 47 + } 48 + 49 + type NodeRenderData = GraphicsInfo & { 50 + simulationData: NodeData 51 + label: Text 52 + } 53 + 17 54 const localStorageKey = "graph-visited" 18 55 function getVisited(): Set<SimpleSlug> { 19 56 return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) ··· 23 60 const visited = getVisited() 24 61 visited.add(slug) 25 62 localStorage.setItem(localStorageKey, JSON.stringify([...visited])) 63 + } 64 + 65 + type TweenNode = { 66 + update: (time: number) => void 67 + stop: () => void 26 68 } 27 69 28 70 async function renderGraph(container: string, fullSlug: FullSlug) { ··· 45 87 removeTags, 46 88 showTags, 47 89 focusOnHover, 48 - } = JSON.parse(graph.dataset["cfg"]!) 90 + } = JSON.parse(graph.dataset["cfg"]!) as D3Config 49 91 50 92 const data: Map<SimpleSlug, ContentDetails> = new Map( 51 93 Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [ ··· 53 95 v, 54 96 ]), 55 97 ) 56 - const links: LinkData[] = [] 98 + const links: SimpleLinkData[] = [] 57 99 const tags: SimpleSlug[] = [] 100 + const validLinks = new Set(data.keys()) 58 101 59 - const validLinks = new Set(data.keys()) 102 + const tweens = new Map<string, TweenNode>() 60 103 for (const [source, details] of data.entries()) { 61 104 const outgoing = details.links ?? [] 62 105 ··· 100 143 if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) 101 144 } 102 145 146 + const nodes = [...neighbourhood].map((url) => { 147 + const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url) 148 + return { 149 + id: url, 150 + text, 151 + tags: data.get(url)?.tags ?? [], 152 + } 153 + }) 103 154 const graphData: { nodes: NodeData[]; links: LinkData[] } = { 104 - nodes: [...neighbourhood].map((url) => { 105 - const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url) 106 - return { 107 - id: url, 108 - text: text, 109 - tags: data.get(url)?.tags ?? [], 110 - } 111 - }), 112 - links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)), 155 + nodes, 156 + links: links 157 + .filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)) 158 + .map((l) => ({ 159 + source: nodes.find((n) => n.id === l.source)!, 160 + target: nodes.find((n) => n.id === l.target)!, 161 + })), 113 162 } 114 163 115 - const simulation: d3.Simulation<NodeData, LinkData> = d3 116 - .forceSimulation(graphData.nodes) 117 - .force("charge", d3.forceManyBody().strength(-100 * repelForce)) 118 - .force( 119 - "link", 120 - d3 121 - .forceLink(graphData.links) 122 - .id((d: any) => d.id) 123 - .distance(linkDistance), 124 - ) 125 - .force("center", d3.forceCenter().strength(centerForce)) 164 + // we virtualize the simulation and use pixi to actually render it 165 + const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes) 166 + .force("charge", forceManyBody().strength(-100 * repelForce)) 167 + .force("center", forceCenter().strength(centerForce)) 168 + .force("link", forceLink(graphData.links).distance(linkDistance)) 169 + .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3)) 126 170 171 + const width = graph.offsetWidth 127 172 const height = Math.max(graph.offsetHeight, 250) 128 - const width = graph.offsetWidth 129 173 130 - const svg = d3 131 - .select<HTMLElement, NodeData>("#" + container) 132 - .append("svg") 133 - .attr("width", width) 134 - .attr("height", height) 135 - .attr("viewBox", [-width / 2 / scale, -height / 2 / scale, width / scale, height / scale]) 136 - 137 - // draw links between nodes 138 - const link = svg 139 - .append("g") 140 - .selectAll("line") 141 - .data(graphData.links) 142 - .join("line") 143 - .attr("class", "link") 144 - .attr("stroke", "var(--lightgray)") 145 - .attr("stroke-width", 1) 146 - 147 - // svg groups 148 - const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g") 174 + // precompute style prop strings as pixi doesn't support css variables 175 + const cssVars = [ 176 + "--secondary", 177 + "--tertiary", 178 + "--gray", 179 + "--light", 180 + "--lightgray", 181 + "--dark", 182 + "--darkgray", 183 + "--bodyFont", 184 + ] as const 185 + const computedStyleMap = cssVars.reduce( 186 + (acc, key) => { 187 + acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key) 188 + return acc 189 + }, 190 + {} as Record<(typeof cssVars)[number], string>, 191 + ) 149 192 150 193 // calculate color 151 194 const color = (d: NodeData) => { 152 195 const isCurrent = d.id === slug 153 196 if (isCurrent) { 154 - return "var(--secondary)" 197 + return computedStyleMap["--secondary"] 155 198 } else if (visited.has(d.id) || d.id.startsWith("tags/")) { 156 - return "var(--tertiary)" 199 + return computedStyleMap["--tertiary"] 157 200 } else { 158 - return "var(--gray)" 201 + return computedStyleMap["--gray"] 159 202 } 160 203 } 161 204 162 - const drag = (simulation: d3.Simulation<NodeData, LinkData>) => { 163 - function dragstarted(event: any, d: NodeData) { 164 - if (!event.active) simulation.alphaTarget(1).restart() 165 - d.fx = d.x 166 - d.fy = d.y 205 + function nodeRadius(d: NodeData) { 206 + const numLinks = graphData.links.filter( 207 + (l) => l.source.id === d.id || l.target.id === d.id, 208 + ).length 209 + return 2 + Math.sqrt(numLinks) 210 + } 211 + 212 + let hoveredNodeId: string | null = null 213 + let hoveredNeighbours: Set<string> = new Set() 214 + const linkRenderData: LinkRenderData[] = [] 215 + const nodeRenderData: NodeRenderData[] = [] 216 + function updateHoverInfo(newHoveredId: string | null) { 217 + hoveredNodeId = newHoveredId 218 + 219 + if (newHoveredId === null) { 220 + hoveredNeighbours = new Set() 221 + for (const n of nodeRenderData) { 222 + n.active = false 223 + } 224 + 225 + for (const l of linkRenderData) { 226 + l.active = false 227 + } 228 + } else { 229 + hoveredNeighbours = new Set() 230 + for (const l of linkRenderData) { 231 + const linkData = l.simulationData 232 + if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) { 233 + hoveredNeighbours.add(linkData.source.id) 234 + hoveredNeighbours.add(linkData.target.id) 235 + } 236 + 237 + l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId 238 + } 239 + 240 + for (const n of nodeRenderData) { 241 + n.active = hoveredNeighbours.has(n.simulationData.id) 242 + } 167 243 } 244 + } 168 245 169 - function dragged(event: any, d: NodeData) { 170 - d.fx = event.x 171 - d.fy = event.y 172 - } 246 + let dragStartTime = 0 247 + let dragging = false 173 248 174 - function dragended(event: any, d: NodeData) { 175 - if (!event.active) simulation.alphaTarget(0) 176 - d.fx = null 177 - d.fy = null 249 + function renderLinks() { 250 + tweens.get("link")?.stop() 251 + const tweenGroup = new TweenGroup() 252 + 253 + for (const l of linkRenderData) { 254 + let alpha = 1 255 + 256 + // if we are hovering over a node, we want to highlight the immediate neighbours 257 + // with full alpha and the rest with default alpha 258 + if (hoveredNodeId) { 259 + alpha = l.active ? 1 : 0.2 260 + } 261 + 262 + l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"] 263 + tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200)) 178 264 } 179 265 180 - const noop = () => {} 181 - return d3 182 - .drag<Element, NodeData>() 183 - .on("start", enableDrag ? dragstarted : noop) 184 - .on("drag", enableDrag ? dragged : noop) 185 - .on("end", enableDrag ? dragended : noop) 266 + tweenGroup.getAll().forEach((tw) => tw.start()) 267 + tweens.set("link", { 268 + update: tweenGroup.update.bind(tweenGroup), 269 + stop() { 270 + tweenGroup.getAll().forEach((tw) => tw.stop()) 271 + }, 272 + }) 186 273 } 187 274 188 - function nodeRadius(d: NodeData) { 189 - const numLinks = links.filter((l: any) => l.source.id === d.id || l.target.id === d.id).length 190 - return 2 + Math.sqrt(numLinks) 191 - } 275 + function renderLabels() { 276 + tweens.get("label")?.stop() 277 + const tweenGroup = new TweenGroup() 192 278 193 - let connectedNodes: SimpleSlug[] = [] 279 + const defaultScale = 1 / scale 280 + const activeScale = defaultScale * 1.1 281 + for (const n of nodeRenderData) { 282 + const nodeId = n.simulationData.id 194 283 195 - // draw individual nodes 196 - const node = graphNode 197 - .append("circle") 198 - .attr("class", "node") 199 - .attr("id", (d) => d.id) 200 - .attr("r", nodeRadius) 201 - .attr("fill", color) 202 - .style("cursor", "pointer") 203 - .on("click", (_, d) => { 204 - const targ = resolveRelative(fullSlug, d.id) 205 - window.spaNavigate(new URL(targ, window.location.toString())) 284 + if (hoveredNodeId === nodeId) { 285 + tweenGroup.add( 286 + new Tweened<Text>(n.label).to( 287 + { 288 + alpha: 1, 289 + scale: { x: activeScale, y: activeScale }, 290 + }, 291 + 100, 292 + ), 293 + ) 294 + } else { 295 + tweenGroup.add( 296 + new Tweened<Text>(n.label).to( 297 + { 298 + alpha: n.label.alpha, 299 + scale: { x: defaultScale, y: defaultScale }, 300 + }, 301 + 100, 302 + ), 303 + ) 304 + } 305 + } 306 + 307 + tweenGroup.getAll().forEach((tw) => tw.start()) 308 + tweens.set("label", { 309 + update: tweenGroup.update.bind(tweenGroup), 310 + stop() { 311 + tweenGroup.getAll().forEach((tw) => tw.stop()) 312 + }, 206 313 }) 207 - .on("mouseover", function (_, d) { 208 - const currentId = d.id 209 - const linkNodes = d3 210 - .selectAll(".link") 211 - .filter((d: any) => d.source.id === currentId || d.target.id === currentId) 314 + } 212 315 213 - if (focusOnHover) { 214 - // fade out non-neighbour nodes 215 - connectedNodes = linkNodes.data().flatMap((d: any) => [d.source.id, d.target.id]) 316 + function renderNodes() { 317 + tweens.get("hover")?.stop() 216 318 217 - d3.selectAll<HTMLElement, NodeData>(".link") 218 - .transition() 219 - .duration(200) 220 - .style("opacity", 0.2) 221 - d3.selectAll<HTMLElement, NodeData>(".node") 222 - .filter((d) => !connectedNodes.includes(d.id)) 223 - .transition() 224 - .duration(200) 225 - .style("opacity", 0.2) 319 + const tweenGroup = new TweenGroup() 320 + for (const n of nodeRenderData) { 321 + let alpha = 1 226 322 227 - d3.selectAll<HTMLElement, NodeData>(".node") 228 - .filter((d) => !connectedNodes.includes(d.id)) 229 - .nodes() 230 - .map((it) => d3.select(it.parentNode as HTMLElement).select("text")) 231 - .forEach((it) => { 232 - let opacity = parseFloat(it.style("opacity")) 233 - it.transition() 234 - .duration(200) 235 - .attr("opacityOld", opacity) 236 - .style("opacity", Math.min(opacity, 0.2)) 237 - }) 323 + // if we are hovering over a node, we want to highlight the immediate neighbours 324 + if (hoveredNodeId !== null && focusOnHover) { 325 + alpha = n.active ? 1 : 0.2 238 326 } 239 327 240 - // highlight links 241 - linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1) 328 + tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200)) 329 + } 330 + 331 + tweenGroup.getAll().forEach((tw) => tw.start()) 332 + tweens.set("hover", { 333 + update: tweenGroup.update.bind(tweenGroup), 334 + stop() { 335 + tweenGroup.getAll().forEach((tw) => tw.stop()) 336 + }, 337 + }) 338 + } 339 + 340 + function renderPixiFromD3() { 341 + renderNodes() 342 + renderLinks() 343 + renderLabels() 344 + } 345 + 346 + tweens.forEach((tween) => tween.stop()) 347 + tweens.clear() 348 + 349 + const app = new Application() 350 + await app.init({ 351 + width, 352 + height, 353 + antialias: true, 354 + autoStart: false, 355 + autoDensity: true, 356 + backgroundAlpha: 0, 357 + preference: "webgpu", 358 + resolution: window.devicePixelRatio, 359 + eventMode: "static", 360 + }) 361 + graph.appendChild(app.canvas) 362 + 363 + const stage = app.stage 364 + stage.interactive = false 365 + 366 + const labelsContainer = new Container<Text>({ zIndex: 3 }) 367 + const nodesContainer = new Container<Graphics>({ zIndex: 2 }) 368 + const linkContainer = new Container<Graphics>({ zIndex: 1 }) 369 + stage.addChild(nodesContainer, labelsContainer, linkContainer) 242 370 243 - const bigFont = fontSize * 1.5 371 + for (const n of graphData.nodes) { 372 + const nodeId = n.id 373 + 374 + const label = new Text({ 375 + interactive: false, 376 + eventMode: "none", 377 + text: n.text, 378 + alpha: 0, 379 + anchor: { x: 0.5, y: 1.2 }, 380 + style: { 381 + fontSize: fontSize * 15, 382 + fill: computedStyleMap["--dark"], 383 + fontFamily: computedStyleMap["--bodyFont"], 384 + }, 385 + resolution: window.devicePixelRatio * 4, 386 + }) 387 + label.scale.set(1 / scale) 244 388 245 - // show text for self 246 - const parent = this.parentNode as HTMLElement 247 - d3.select<HTMLElement, NodeData>(parent) 248 - .raise() 249 - .select("text") 250 - .transition() 251 - .duration(200) 252 - .attr("opacityOld", d3.select(parent).select("text").style("opacity")) 253 - .style("opacity", 1) 254 - .style("font-size", bigFont + "em") 389 + let oldLabelOpacity = 0 390 + const isTagNode = nodeId.startsWith("tags/") 391 + const gfx = new Graphics({ 392 + interactive: true, 393 + label: nodeId, 394 + eventMode: "static", 395 + hitArea: new Circle(0, 0, nodeRadius(n)), 396 + cursor: "pointer", 255 397 }) 256 - .on("mouseleave", function (_, d) { 257 - if (focusOnHover) { 258 - d3.selectAll<HTMLElement, NodeData>(".link").transition().duration(200).style("opacity", 1) 259 - d3.selectAll<HTMLElement, NodeData>(".node").transition().duration(200).style("opacity", 1) 398 + .circle(0, 0, nodeRadius(n)) 399 + .fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) }) 400 + .stroke({ width: isTagNode ? 2 : 0, color: color(n) }) 401 + .on("pointerover", (e) => { 402 + updateHoverInfo(e.target.label) 403 + oldLabelOpacity = label.alpha 404 + if (!dragging) { 405 + renderPixiFromD3() 406 + } 407 + }) 408 + .on("pointerleave", () => { 409 + updateHoverInfo(null) 410 + label.alpha = oldLabelOpacity 411 + if (!dragging) { 412 + renderPixiFromD3() 413 + } 414 + }) 260 415 261 - d3.selectAll<HTMLElement, NodeData>(".node") 262 - .filter((d) => !connectedNodes.includes(d.id)) 263 - .nodes() 264 - .map((it) => d3.select(it.parentNode as HTMLElement).select("text")) 265 - .forEach((it) => it.transition().duration(200).style("opacity", it.attr("opacityOld"))) 266 - } 267 - const currentId = d.id 268 - const linkNodes = d3 269 - .selectAll(".link") 270 - .filter((d: any) => d.source.id === currentId || d.target.id === currentId) 416 + nodesContainer.addChild(gfx) 417 + labelsContainer.addChild(label) 271 418 272 - linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)") 419 + const nodeRenderDatum: NodeRenderData = { 420 + simulationData: n, 421 + gfx, 422 + label, 423 + color: color(n), 424 + alpha: 1, 425 + active: false, 426 + } 273 427 274 - const parent = this.parentNode as HTMLElement 275 - d3.select<HTMLElement, NodeData>(parent) 276 - .select("text") 277 - .transition() 278 - .duration(200) 279 - .style("opacity", d3.select(parent).select("text").attr("opacityOld")) 280 - .style("font-size", fontSize + "em") 281 - }) 282 - // @ts-ignore 283 - .call(drag(simulation)) 428 + nodeRenderData.push(nodeRenderDatum) 429 + } 430 + 431 + for (const l of graphData.links) { 432 + const gfx = new Graphics({ interactive: false, eventMode: "none" }) 433 + linkContainer.addChild(gfx) 434 + 435 + const linkRenderDatum: LinkRenderData = { 436 + simulationData: l, 437 + gfx, 438 + color: computedStyleMap["--lightgray"], 439 + alpha: 1, 440 + active: false, 441 + } 442 + 443 + linkRenderData.push(linkRenderDatum) 444 + } 284 445 285 - // make tags hollow circles 286 - node 287 - .filter((d) => d.id.startsWith("tags/")) 288 - .attr("stroke", color) 289 - .attr("stroke-width", 2) 290 - .attr("fill", "var(--light)") 446 + let currentTransform = zoomIdentity 447 + if (enableDrag) { 448 + select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call( 449 + drag<HTMLCanvasElement, NodeData | undefined>() 450 + .container(() => app.canvas) 451 + .subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId)) 452 + .on("start", function dragstarted(event) { 453 + if (!event.active) simulation.alphaTarget(1).restart() 454 + event.subject.fx = event.subject.x 455 + event.subject.fy = event.subject.y 456 + event.subject.__initialDragPos = { 457 + x: event.subject.x, 458 + y: event.subject.y, 459 + fx: event.subject.fx, 460 + fy: event.subject.fy, 461 + } 462 + dragStartTime = Date.now() 463 + dragging = true 464 + }) 465 + .on("drag", function dragged(event) { 466 + const initPos = event.subject.__initialDragPos 467 + event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k 468 + event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k 469 + }) 470 + .on("end", function dragended(event) { 471 + if (!event.active) simulation.alphaTarget(0) 472 + event.subject.fx = null 473 + event.subject.fy = null 474 + dragging = false 291 475 292 - // draw labels 293 - const labels = graphNode 294 - .append("text") 295 - .attr("dx", 0) 296 - .attr("dy", (d) => -nodeRadius(d) + "px") 297 - .attr("text-anchor", "middle") 298 - .text((d) => d.text) 299 - .style("opacity", (opacityScale - 1) / 3.75) 300 - .style("pointer-events", "none") 301 - .style("font-size", fontSize + "em") 302 - .raise() 303 - // @ts-ignore 304 - .call(drag(simulation)) 476 + // if the time between mousedown and mouseup is short, we consider it a click 477 + if (Date.now() - dragStartTime < 500) { 478 + const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData 479 + const targ = resolveRelative(fullSlug, node.id) 480 + window.spaNavigate(new URL(targ, window.location.toString())) 481 + } 482 + }), 483 + ) 484 + } else { 485 + for (const node of nodeRenderData) { 486 + node.gfx.on("click", () => { 487 + const targ = resolveRelative(fullSlug, node.simulationData.id) 488 + window.spaNavigate(new URL(targ, window.location.toString())) 489 + }) 490 + } 491 + } 305 492 306 - // set panning 307 493 if (enableZoom) { 308 - svg.call( 309 - d3 310 - .zoom<SVGSVGElement, NodeData>() 494 + select<HTMLCanvasElement, NodeData>(app.canvas).call( 495 + zoom<HTMLCanvasElement, NodeData>() 311 496 .extent([ 312 497 [0, 0], 313 498 [width, height], 314 499 ]) 315 500 .scaleExtent([0.25, 4]) 316 501 .on("zoom", ({ transform }) => { 317 - link.attr("transform", transform) 318 - node.attr("transform", transform) 502 + currentTransform = transform 503 + stage.scale.set(transform.k, transform.k) 504 + stage.position.set(transform.x, transform.y) 505 + 506 + // zoom adjusts opacity of labels too 319 507 const scale = transform.k * opacityScale 320 - const scaledOpacity = Math.max((scale - 1) / 3.75, 0) 321 - labels.attr("transform", transform).style("opacity", scaledOpacity) 508 + let scaleOpacity = Math.max((scale - 1) / 3.75, 0) 509 + const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label) 510 + 511 + for (const label of labelsContainer.children) { 512 + if (!activeNodes.includes(label)) { 513 + label.alpha = scaleOpacity 514 + } 515 + } 322 516 }), 323 517 ) 324 518 } 325 519 326 - // progress the simulation 327 - simulation.on("tick", () => { 328 - link 329 - .attr("x1", (d: any) => d.source.x) 330 - .attr("y1", (d: any) => d.source.y) 331 - .attr("x2", (d: any) => d.target.x) 332 - .attr("y2", (d: any) => d.target.y) 333 - node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y) 334 - labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y) 335 - }) 520 + function animate(time: number) { 521 + for (const n of nodeRenderData) { 522 + const { x, y } = n.simulationData 523 + if (!x || !y) continue 524 + n.gfx.position.set(x + width / 2, y + height / 2) 525 + if (n.label) { 526 + n.label.position.set(x + width / 2, y + height / 2) 527 + } 528 + } 529 + 530 + for (const l of linkRenderData) { 531 + const linkData = l.simulationData 532 + l.gfx.clear() 533 + l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2) 534 + l.gfx 535 + .lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2) 536 + .stroke({ alpha: l.alpha, width: 1, color: l.color }) 537 + } 538 + 539 + tweens.forEach((t) => t.update(time)) 540 + app.renderer.render(stage) 541 + requestAnimationFrame(animate) 542 + } 543 + 544 + const graphAnimationFrameHandle = requestAnimationFrame(animate) 545 + window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle)) 336 546 } 337 547 338 - function renderGlobalGraph() { 339 - const slug = getFullSlug(window) 548 + document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { 549 + const slug = e.detail.url 550 + addToVisited(simplifySlug(slug)) 551 + await renderGraph("graph-container", slug) 552 + 340 553 const container = document.getElementById("global-graph-outer") 341 554 const sidebar = container?.closest(".sidebar") as HTMLElement 342 - container?.classList.add("active") 343 - if (sidebar) { 344 - sidebar.style.zIndex = "1" 345 - } 346 555 347 - renderGraph("global-graph-container", slug) 556 + function renderGlobalGraph() { 557 + const slug = getFullSlug(window) 558 + container?.classList.add("active") 559 + if (sidebar) { 560 + sidebar.style.zIndex = "1" 561 + } 562 + 563 + renderGraph("global-graph-container", slug) 564 + registerEscapeHandler(container, hideGlobalGraph) 565 + } 348 566 349 567 function hideGlobalGraph() { 350 568 container?.classList.remove("active") 351 - const graph = document.getElementById("global-graph-container") 352 569 if (sidebar) { 353 570 sidebar.style.zIndex = "unset" 354 571 } 355 - if (!graph) return 356 - removeAllChildren(graph) 357 572 } 358 573 359 - registerEscapeHandler(container, hideGlobalGraph) 360 - } 361 - 362 - document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { 363 - const slug = e.detail.url 364 - addToVisited(simplifySlug(slug)) 365 - await renderGraph("graph-container", slug) 574 + async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { 575 + if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 576 + e.preventDefault() 577 + const globalGraphOpen = container?.classList.contains("active") 578 + globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph() 579 + } 580 + } 366 581 367 582 const containerIcon = document.getElementById("global-graph-icon") 368 583 containerIcon?.addEventListener("click", renderGlobalGraph) 369 584 window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) 585 + 586 + document.addEventListener("keydown", shortcutHandler) 587 + window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) 370 588 })
+7 -4
quartz/components/styles/graph.scss
··· 16 16 overflow: hidden; 17 17 18 18 & > #global-graph-icon { 19 + cursor: pointer; 20 + background: none; 21 + border: none; 19 22 color: var(--dark); 20 23 opacity: 0.5; 21 - width: 18px; 22 - height: 18px; 24 + width: 24px; 25 + height: 24px; 23 26 position: absolute; 24 27 padding: 0.2rem; 25 28 margin: 0.3rem; ··· 59 62 top: 50%; 60 63 left: 50%; 61 64 transform: translate(-50%, -50%); 62 - height: 60vh; 63 - width: 50vw; 65 + height: 80vh; 66 + width: 80vw; 64 67 65 68 @media all and (max-width: $fullPageWidth) { 66 69 width: 90%;