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>
This commit is contained in:
		
							
								
								
									
										86
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										86
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -1,17 +1,18 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "@jackyzha0/quartz",
 | 
			
		||||
  "version": "4.3.0",
 | 
			
		||||
  "version": "4.3.1",
 | 
			
		||||
  "lockfileVersion": 3,
 | 
			
		||||
  "requires": true,
 | 
			
		||||
  "packages": {
 | 
			
		||||
    "": {
 | 
			
		||||
      "name": "@jackyzha0/quartz",
 | 
			
		||||
      "version": "4.3.0",
 | 
			
		||||
      "version": "4.3.1",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@clack/prompts": "^0.7.0",
 | 
			
		||||
        "@floating-ui/dom": "^1.6.10",
 | 
			
		||||
        "@napi-rs/simple-git": "0.1.17",
 | 
			
		||||
        "@tweenjs/tween.js": "^25.0.0",
 | 
			
		||||
        "async-mutex": "^0.5.0",
 | 
			
		||||
        "chalk": "^5.3.0",
 | 
			
		||||
        "chokidar": "^3.6.0",
 | 
			
		||||
@@ -32,6 +33,7 @@
 | 
			
		||||
        "mdast-util-to-hast": "^13.2.0",
 | 
			
		||||
        "mdast-util-to-string": "^4.0.0",
 | 
			
		||||
        "micromorph": "^0.4.5",
 | 
			
		||||
        "pixi.js": "^8.3.3",
 | 
			
		||||
        "preact": "^10.23.2",
 | 
			
		||||
        "preact-render-to-string": "^6.5.9",
 | 
			
		||||
        "pretty-bytes": "^6.1.1",
 | 
			
		||||
@@ -874,6 +876,12 @@
 | 
			
		||||
        "node": ">= 8"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@pixi/colord": {
 | 
			
		||||
      "version": "2.9.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
 | 
			
		||||
      "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@pkgjs/parseargs": {
 | 
			
		||||
      "version": "0.11.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
 | 
			
		||||
@@ -902,6 +910,12 @@
 | 
			
		||||
        "url": "https://github.com/sponsors/sindresorhus"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tweenjs/tween.js": {
 | 
			
		||||
      "version": "25.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/cli-spinner": {
 | 
			
		||||
      "version": "0.2.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/cli-spinner/-/cli-spinner-0.2.3.tgz",
 | 
			
		||||
@@ -911,6 +925,12 @@
 | 
			
		||||
        "@types/node": "*"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/css-font-loading-module": {
 | 
			
		||||
      "version": "0.0.12",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
 | 
			
		||||
      "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/d3": {
 | 
			
		||||
      "version": "7.4.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
 | 
			
		||||
@@ -1172,6 +1192,12 @@
 | 
			
		||||
        "@types/ms": "*"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/earcut": {
 | 
			
		||||
      "version": "2.1.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
 | 
			
		||||
      "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/estree": {
 | 
			
		||||
      "version": "1.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
 | 
			
		||||
@@ -1294,6 +1320,21 @@
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
 | 
			
		||||
      "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@webgpu/types": {
 | 
			
		||||
      "version": "0.1.44",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz",
 | 
			
		||||
      "integrity": "sha512-JDpYJN5E/asw84LTYhKyvPpxGnD+bAKPtpW9Ilurf7cZpxaTbxkQcGwOd7jgB9BPBrTYQ+32ufo4HiuomTjHNQ==",
 | 
			
		||||
      "license": "BSD-3-Clause"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@xmldom/xmldom": {
 | 
			
		||||
      "version": "0.8.10",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
 | 
			
		||||
      "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=10.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/agent-base": {
 | 
			
		||||
      "version": "7.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
 | 
			
		||||
@@ -2194,6 +2235,12 @@
 | 
			
		||||
        "url": "https://github.com/sponsors/wooorm"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/earcut": {
 | 
			
		||||
      "version": "2.2.4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
 | 
			
		||||
      "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
 | 
			
		||||
      "license": "ISC"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/eastasianwidth": {
 | 
			
		||||
      "version": "0.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
 | 
			
		||||
@@ -2312,6 +2359,12 @@
 | 
			
		||||
        "url": "https://opencollective.com/unified"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/eventemitter3": {
 | 
			
		||||
      "version": "5.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/extend": {
 | 
			
		||||
      "version": "3.0.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
 | 
			
		||||
@@ -3176,6 +3229,12 @@
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ismobilejs": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/jackspeak": {
 | 
			
		||||
      "version": "4.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz",
 | 
			
		||||
@@ -4698,6 +4757,12 @@
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/parse-svg-path": {
 | 
			
		||||
      "version": "0.1.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
 | 
			
		||||
      "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/parse5": {
 | 
			
		||||
      "version": "7.1.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
 | 
			
		||||
@@ -4774,6 +4839,23 @@
 | 
			
		||||
        "url": "https://github.com/sponsors/jonschlinkert"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/pixi.js": {
 | 
			
		||||
      "version": "8.3.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.3.3.tgz",
 | 
			
		||||
      "integrity": "sha512-dpucBKAqEm0K51MQKlXvyIJ40bcxniP82uz4ZPEQejGtPp0P+vueuG5DyArHCkC48mkVE2FEDvyYvBa45/JlQg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@pixi/colord": "^2.9.6",
 | 
			
		||||
        "@types/css-font-loading-module": "^0.0.12",
 | 
			
		||||
        "@types/earcut": "^2.1.4",
 | 
			
		||||
        "@webgpu/types": "^0.1.40",
 | 
			
		||||
        "@xmldom/xmldom": "^0.8.10",
 | 
			
		||||
        "earcut": "^2.2.4",
 | 
			
		||||
        "eventemitter3": "^5.0.1",
 | 
			
		||||
        "ismobilejs": "^1.1.1",
 | 
			
		||||
        "parse-svg-path": "^0.1.2"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/preact": {
 | 
			
		||||
      "version": "10.23.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/preact/-/preact-10.23.2.tgz",
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
  "name": "@jackyzha0/quartz",
 | 
			
		||||
  "description": "🌱 publish your digital garden and notes as a website",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "version": "4.3.0",
 | 
			
		||||
  "version": "4.3.1",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "author": "jackyzha0 <j.zhao2k19@gmail.com>",
 | 
			
		||||
  "license": "MIT",
 | 
			
		||||
@@ -38,6 +38,7 @@
 | 
			
		||||
    "@clack/prompts": "^0.7.0",
 | 
			
		||||
    "@floating-ui/dom": "^1.6.10",
 | 
			
		||||
    "@napi-rs/simple-git": "0.1.17",
 | 
			
		||||
    "@tweenjs/tween.js": "^25.0.0",
 | 
			
		||||
    "async-mutex": "^0.5.0",
 | 
			
		||||
    "chalk": "^5.3.0",
 | 
			
		||||
    "chokidar": "^3.6.0",
 | 
			
		||||
@@ -58,6 +59,7 @@
 | 
			
		||||
    "mdast-util-to-hast": "^13.2.0",
 | 
			
		||||
    "mdast-util-to-string": "^4.0.0",
 | 
			
		||||
    "micromorph": "^0.4.5",
 | 
			
		||||
    "pixi.js": "^8.3.3",
 | 
			
		||||
    "preact": "^10.23.2",
 | 
			
		||||
    "preact-render-to-string": "^6.5.9",
 | 
			
		||||
    "pretty-bytes": "^6.1.1",
 | 
			
		||||
 
 | 
			
		||||
@@ -65,31 +65,32 @@ export default ((opts?: GraphOptions) => {
 | 
			
		||||
        <h3>{i18n(cfg.locale).components.graph.title}</h3>
 | 
			
		||||
        <div class="graph-outer">
 | 
			
		||||
          <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
 | 
			
		||||
          <svg
 | 
			
		||||
            version="1.1"
 | 
			
		||||
            id="global-graph-icon"
 | 
			
		||||
            xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
            xmlnsXlink="http://www.w3.org/1999/xlink"
 | 
			
		||||
            x="0px"
 | 
			
		||||
            y="0px"
 | 
			
		||||
            viewBox="0 0 55 55"
 | 
			
		||||
            fill="currentColor"
 | 
			
		||||
            xmlSpace="preserve"
 | 
			
		||||
          >
 | 
			
		||||
            <path
 | 
			
		||||
              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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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
 | 
			
		||||
	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"
 | 
			
		||||
            />
 | 
			
		||||
          </svg>
 | 
			
		||||
          <button id="global-graph-icon" aria-label="Global Graph">
 | 
			
		||||
            <svg
 | 
			
		||||
              version="1.1"
 | 
			
		||||
              xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
              xmlnsXlink="http://www.w3.org/1999/xlink"
 | 
			
		||||
              x="0px"
 | 
			
		||||
              y="0px"
 | 
			
		||||
              viewBox="0 0 55 55"
 | 
			
		||||
              fill="currentColor"
 | 
			
		||||
              xmlSpace="preserve"
 | 
			
		||||
            >
 | 
			
		||||
              <path
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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
 | 
			
		||||
                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"
 | 
			
		||||
              />
 | 
			
		||||
            </svg>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="global-graph-outer">
 | 
			
		||||
          <div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,19 +1,56 @@
 | 
			
		||||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
 | 
			
		||||
import * as d3 from "d3"
 | 
			
		||||
import {
 | 
			
		||||
  SimulationNodeDatum,
 | 
			
		||||
  SimulationLinkDatum,
 | 
			
		||||
  Simulation,
 | 
			
		||||
  forceSimulation,
 | 
			
		||||
  forceManyBody,
 | 
			
		||||
  forceCenter,
 | 
			
		||||
  forceLink,
 | 
			
		||||
  forceCollide,
 | 
			
		||||
  zoomIdentity,
 | 
			
		||||
  select,
 | 
			
		||||
  drag,
 | 
			
		||||
  zoom,
 | 
			
		||||
} from "d3"
 | 
			
		||||
import { Text, Graphics, Application, Container, Circle } from "pixi.js"
 | 
			
		||||
import { Group as TweenGroup, Tween as Tweened } from "@tweenjs/tween.js"
 | 
			
		||||
import { registerEscapeHandler, removeAllChildren } from "./util"
 | 
			
		||||
import { FullSlug, SimpleSlug, getFullSlug, resolveRelative, simplifySlug } from "../../util/path"
 | 
			
		||||
import { D3Config } from "../Graph"
 | 
			
		||||
 | 
			
		||||
type GraphicsInfo = {
 | 
			
		||||
  color: string
 | 
			
		||||
  gfx: Graphics
 | 
			
		||||
  alpha: number
 | 
			
		||||
  active: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type NodeData = {
 | 
			
		||||
  id: SimpleSlug
 | 
			
		||||
  text: string
 | 
			
		||||
  tags: string[]
 | 
			
		||||
} & d3.SimulationNodeDatum
 | 
			
		||||
} & SimulationNodeDatum
 | 
			
		||||
 | 
			
		||||
type LinkData = {
 | 
			
		||||
type SimpleLinkData = {
 | 
			
		||||
  source: SimpleSlug
 | 
			
		||||
  target: SimpleSlug
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type LinkData = {
 | 
			
		||||
  source: NodeData
 | 
			
		||||
  target: NodeData
 | 
			
		||||
} & SimulationLinkDatum<NodeData>
 | 
			
		||||
 | 
			
		||||
type LinkRenderData = GraphicsInfo & {
 | 
			
		||||
  simulationData: LinkData
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type NodeRenderData = GraphicsInfo & {
 | 
			
		||||
  simulationData: NodeData
 | 
			
		||||
  label: Text
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const localStorageKey = "graph-visited"
 | 
			
		||||
function getVisited(): Set<SimpleSlug> {
 | 
			
		||||
  return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]"))
 | 
			
		||||
@@ -25,6 +62,11 @@ function addToVisited(slug: SimpleSlug) {
 | 
			
		||||
  localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type TweenNode = {
 | 
			
		||||
  update: (time: number) => void
 | 
			
		||||
  stop: () => void
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
  const slug = simplifySlug(fullSlug)
 | 
			
		||||
  const visited = getVisited()
 | 
			
		||||
@@ -45,7 +87,7 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    removeTags,
 | 
			
		||||
    showTags,
 | 
			
		||||
    focusOnHover,
 | 
			
		||||
  } = JSON.parse(graph.dataset["cfg"]!)
 | 
			
		||||
  } = JSON.parse(graph.dataset["cfg"]!) as D3Config
 | 
			
		||||
 | 
			
		||||
  const data: Map<SimpleSlug, ContentDetails> = new Map(
 | 
			
		||||
    Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
 | 
			
		||||
@@ -53,10 +95,11 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
      v,
 | 
			
		||||
    ]),
 | 
			
		||||
  )
 | 
			
		||||
  const links: LinkData[] = []
 | 
			
		||||
  const links: SimpleLinkData[] = []
 | 
			
		||||
  const tags: SimpleSlug[] = []
 | 
			
		||||
 | 
			
		||||
  const validLinks = new Set(data.keys())
 | 
			
		||||
 | 
			
		||||
  const tweens = new Map<string, TweenNode>()
 | 
			
		||||
  for (const [source, details] of data.entries()) {
 | 
			
		||||
    const outgoing = details.links ?? []
 | 
			
		||||
 | 
			
		||||
@@ -100,263 +143,406 @@ async function renderGraph(container: string, fullSlug: FullSlug) {
 | 
			
		||||
    if (showTags) tags.forEach((tag) => neighbourhood.add(tag))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nodes = [...neighbourhood].map((url) => {
 | 
			
		||||
    const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
 | 
			
		||||
    return {
 | 
			
		||||
      id: url,
 | 
			
		||||
      text,
 | 
			
		||||
      tags: data.get(url)?.tags ?? [],
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
  const graphData: { nodes: NodeData[]; links: LinkData[] } = {
 | 
			
		||||
    nodes: [...neighbourhood].map((url) => {
 | 
			
		||||
      const text = url.startsWith("tags/") ? "#" + url.substring(5) : (data.get(url)?.title ?? url)
 | 
			
		||||
      return {
 | 
			
		||||
        id: url,
 | 
			
		||||
        text: text,
 | 
			
		||||
        tags: data.get(url)?.tags ?? [],
 | 
			
		||||
      }
 | 
			
		||||
    }),
 | 
			
		||||
    links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)),
 | 
			
		||||
    nodes,
 | 
			
		||||
    links: links
 | 
			
		||||
      .filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target))
 | 
			
		||||
      .map((l) => ({
 | 
			
		||||
        source: nodes.find((n) => n.id === l.source)!,
 | 
			
		||||
        target: nodes.find((n) => n.id === l.target)!,
 | 
			
		||||
      })),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const simulation: d3.Simulation<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))
 | 
			
		||||
  // we virtualize the simulation and use pixi to actually render it
 | 
			
		||||
  const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
 | 
			
		||||
    .force("charge", forceManyBody().strength(-100 * repelForce))
 | 
			
		||||
    .force("center", forceCenter().strength(centerForce))
 | 
			
		||||
    .force("link", forceLink(graphData.links).distance(linkDistance))
 | 
			
		||||
    .force("collide", forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3))
 | 
			
		||||
 | 
			
		||||
  const height = Math.max(graph.offsetHeight, 250)
 | 
			
		||||
  const width = graph.offsetWidth
 | 
			
		||||
  const height = Math.max(graph.offsetHeight, 250)
 | 
			
		||||
 | 
			
		||||
  const svg = d3
 | 
			
		||||
    .select<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")
 | 
			
		||||
  // precompute style prop strings as pixi doesn't support css variables
 | 
			
		||||
  const cssVars = [
 | 
			
		||||
    "--secondary",
 | 
			
		||||
    "--tertiary",
 | 
			
		||||
    "--gray",
 | 
			
		||||
    "--light",
 | 
			
		||||
    "--lightgray",
 | 
			
		||||
    "--dark",
 | 
			
		||||
    "--darkgray",
 | 
			
		||||
    "--bodyFont",
 | 
			
		||||
  ] as const
 | 
			
		||||
  const computedStyleMap = cssVars.reduce(
 | 
			
		||||
    (acc, key) => {
 | 
			
		||||
      acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
 | 
			
		||||
      return acc
 | 
			
		||||
    },
 | 
			
		||||
    {} as Record<(typeof cssVars)[number], string>,
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  // calculate color
 | 
			
		||||
  const color = (d: NodeData) => {
 | 
			
		||||
    const isCurrent = d.id === slug
 | 
			
		||||
    if (isCurrent) {
 | 
			
		||||
      return "var(--secondary)"
 | 
			
		||||
      return computedStyleMap["--secondary"]
 | 
			
		||||
    } else if (visited.has(d.id) || d.id.startsWith("tags/")) {
 | 
			
		||||
      return "var(--tertiary)"
 | 
			
		||||
      return computedStyleMap["--tertiary"]
 | 
			
		||||
    } else {
 | 
			
		||||
      return "var(--gray)"
 | 
			
		||||
      return computedStyleMap["--gray"]
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const drag = (simulation: d3.Simulation<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
 | 
			
		||||
    const numLinks = graphData.links.filter(
 | 
			
		||||
      (l) => l.source.id === d.id || l.target.id === d.id,
 | 
			
		||||
    ).length
 | 
			
		||||
    return 2 + Math.sqrt(numLinks)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let connectedNodes: SimpleSlug[] = []
 | 
			
		||||
  let hoveredNodeId: string | null = null
 | 
			
		||||
  let hoveredNeighbours: Set<string> = new Set()
 | 
			
		||||
  const linkRenderData: LinkRenderData[] = []
 | 
			
		||||
  const nodeRenderData: NodeRenderData[] = []
 | 
			
		||||
  function updateHoverInfo(newHoveredId: string | null) {
 | 
			
		||||
    hoveredNodeId = newHoveredId
 | 
			
		||||
 | 
			
		||||
  // draw individual nodes
 | 
			
		||||
  const node = graphNode
 | 
			
		||||
    .append("circle")
 | 
			
		||||
    .attr("class", "node")
 | 
			
		||||
    .attr("id", (d) => d.id)
 | 
			
		||||
    .attr("r", nodeRadius)
 | 
			
		||||
    .attr("fill", color)
 | 
			
		||||
    .style("cursor", "pointer")
 | 
			
		||||
    .on("click", (_, d) => {
 | 
			
		||||
      const targ = resolveRelative(fullSlug, d.id)
 | 
			
		||||
      window.spaNavigate(new URL(targ, window.location.toString()))
 | 
			
		||||
    })
 | 
			
		||||
    .on("mouseover", function (_, d) {
 | 
			
		||||
      const currentId = d.id
 | 
			
		||||
      const linkNodes = d3
 | 
			
		||||
        .selectAll(".link")
 | 
			
		||||
        .filter((d: any) => d.source.id === currentId || d.target.id === currentId)
 | 
			
		||||
 | 
			
		||||
      if (focusOnHover) {
 | 
			
		||||
        // fade out non-neighbour nodes
 | 
			
		||||
        connectedNodes = linkNodes.data().flatMap((d: any) => [d.source.id, d.target.id])
 | 
			
		||||
 | 
			
		||||
        d3.selectAll<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))
 | 
			
		||||
          })
 | 
			
		||||
    if (newHoveredId === null) {
 | 
			
		||||
      hoveredNeighbours = new Set()
 | 
			
		||||
      for (const n of nodeRenderData) {
 | 
			
		||||
        n.active = false
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // highlight links
 | 
			
		||||
      linkNodes.transition().duration(200).attr("stroke", "var(--gray)").attr("stroke-width", 1)
 | 
			
		||||
 | 
			
		||||
      const bigFont = fontSize * 1.5
 | 
			
		||||
 | 
			
		||||
      // show text for self
 | 
			
		||||
      const parent = this.parentNode as HTMLElement
 | 
			
		||||
      d3.select<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")))
 | 
			
		||||
      for (const l of linkRenderData) {
 | 
			
		||||
        l.active = false
 | 
			
		||||
      }
 | 
			
		||||
      const currentId = d.id
 | 
			
		||||
      const linkNodes = d3
 | 
			
		||||
        .selectAll(".link")
 | 
			
		||||
        .filter((d: any) => d.source.id === currentId || d.target.id === currentId)
 | 
			
		||||
    } else {
 | 
			
		||||
      hoveredNeighbours = new Set()
 | 
			
		||||
      for (const l of linkRenderData) {
 | 
			
		||||
        const linkData = l.simulationData
 | 
			
		||||
        if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
 | 
			
		||||
          hoveredNeighbours.add(linkData.source.id)
 | 
			
		||||
          hoveredNeighbours.add(linkData.target.id)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)")
 | 
			
		||||
        l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const parent = this.parentNode as HTMLElement
 | 
			
		||||
      d3.select<HTMLElement, NodeData>(parent)
 | 
			
		||||
        .select("text")
 | 
			
		||||
        .transition()
 | 
			
		||||
        .duration(200)
 | 
			
		||||
        .style("opacity", d3.select(parent).select("text").attr("opacityOld"))
 | 
			
		||||
        .style("font-size", fontSize + "em")
 | 
			
		||||
      for (const n of nodeRenderData) {
 | 
			
		||||
        n.active = hoveredNeighbours.has(n.simulationData.id)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let dragStartTime = 0
 | 
			
		||||
  let dragging = false
 | 
			
		||||
 | 
			
		||||
  function renderLinks() {
 | 
			
		||||
    tweens.get("link")?.stop()
 | 
			
		||||
    const tweenGroup = new TweenGroup()
 | 
			
		||||
 | 
			
		||||
    for (const l of linkRenderData) {
 | 
			
		||||
      let alpha = 1
 | 
			
		||||
 | 
			
		||||
      // if we are hovering over a node, we want to highlight the immediate neighbours
 | 
			
		||||
      // with full alpha and the rest with default alpha
 | 
			
		||||
      if (hoveredNodeId) {
 | 
			
		||||
        alpha = l.active ? 1 : 0.2
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      l.color = l.active ? computedStyleMap["--gray"] : computedStyleMap["--lightgray"]
 | 
			
		||||
      tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tweenGroup.getAll().forEach((tw) => tw.start())
 | 
			
		||||
    tweens.set("link", {
 | 
			
		||||
      update: tweenGroup.update.bind(tweenGroup),
 | 
			
		||||
      stop() {
 | 
			
		||||
        tweenGroup.getAll().forEach((tw) => tw.stop())
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    .call(drag(simulation))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // make tags hollow circles
 | 
			
		||||
  node
 | 
			
		||||
    .filter((d) => d.id.startsWith("tags/"))
 | 
			
		||||
    .attr("stroke", color)
 | 
			
		||||
    .attr("stroke-width", 2)
 | 
			
		||||
    .attr("fill", "var(--light)")
 | 
			
		||||
  function renderLabels() {
 | 
			
		||||
    tweens.get("label")?.stop()
 | 
			
		||||
    const tweenGroup = new TweenGroup()
 | 
			
		||||
 | 
			
		||||
  // draw labels
 | 
			
		||||
  const labels = graphNode
 | 
			
		||||
    .append("text")
 | 
			
		||||
    .attr("dx", 0)
 | 
			
		||||
    .attr("dy", (d) => -nodeRadius(d) + "px")
 | 
			
		||||
    .attr("text-anchor", "middle")
 | 
			
		||||
    .text((d) => d.text)
 | 
			
		||||
    .style("opacity", (opacityScale - 1) / 3.75)
 | 
			
		||||
    .style("pointer-events", "none")
 | 
			
		||||
    .style("font-size", fontSize + "em")
 | 
			
		||||
    .raise()
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    .call(drag(simulation))
 | 
			
		||||
    const defaultScale = 1 / scale
 | 
			
		||||
    const activeScale = defaultScale * 1.1
 | 
			
		||||
    for (const n of nodeRenderData) {
 | 
			
		||||
      const nodeId = n.simulationData.id
 | 
			
		||||
 | 
			
		||||
      if (hoveredNodeId === nodeId) {
 | 
			
		||||
        tweenGroup.add(
 | 
			
		||||
          new Tweened<Text>(n.label).to(
 | 
			
		||||
            {
 | 
			
		||||
              alpha: 1,
 | 
			
		||||
              scale: { x: activeScale, y: activeScale },
 | 
			
		||||
            },
 | 
			
		||||
            100,
 | 
			
		||||
          ),
 | 
			
		||||
        )
 | 
			
		||||
      } else {
 | 
			
		||||
        tweenGroup.add(
 | 
			
		||||
          new Tweened<Text>(n.label).to(
 | 
			
		||||
            {
 | 
			
		||||
              alpha: n.label.alpha,
 | 
			
		||||
              scale: { x: defaultScale, y: defaultScale },
 | 
			
		||||
            },
 | 
			
		||||
            100,
 | 
			
		||||
          ),
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tweenGroup.getAll().forEach((tw) => tw.start())
 | 
			
		||||
    tweens.set("label", {
 | 
			
		||||
      update: tweenGroup.update.bind(tweenGroup),
 | 
			
		||||
      stop() {
 | 
			
		||||
        tweenGroup.getAll().forEach((tw) => tw.stop())
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function renderNodes() {
 | 
			
		||||
    tweens.get("hover")?.stop()
 | 
			
		||||
 | 
			
		||||
    const tweenGroup = new TweenGroup()
 | 
			
		||||
    for (const n of nodeRenderData) {
 | 
			
		||||
      let alpha = 1
 | 
			
		||||
 | 
			
		||||
      // if we are hovering over a node, we want to highlight the immediate neighbours
 | 
			
		||||
      if (hoveredNodeId !== null && focusOnHover) {
 | 
			
		||||
        alpha = n.active ? 1 : 0.2
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tweenGroup.getAll().forEach((tw) => tw.start())
 | 
			
		||||
    tweens.set("hover", {
 | 
			
		||||
      update: tweenGroup.update.bind(tweenGroup),
 | 
			
		||||
      stop() {
 | 
			
		||||
        tweenGroup.getAll().forEach((tw) => tw.stop())
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function renderPixiFromD3() {
 | 
			
		||||
    renderNodes()
 | 
			
		||||
    renderLinks()
 | 
			
		||||
    renderLabels()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  tweens.forEach((tween) => tween.stop())
 | 
			
		||||
  tweens.clear()
 | 
			
		||||
 | 
			
		||||
  const app = new Application()
 | 
			
		||||
  await app.init({
 | 
			
		||||
    width,
 | 
			
		||||
    height,
 | 
			
		||||
    antialias: true,
 | 
			
		||||
    autoStart: false,
 | 
			
		||||
    autoDensity: true,
 | 
			
		||||
    backgroundAlpha: 0,
 | 
			
		||||
    preference: "webgpu",
 | 
			
		||||
    resolution: window.devicePixelRatio,
 | 
			
		||||
    eventMode: "static",
 | 
			
		||||
  })
 | 
			
		||||
  graph.appendChild(app.canvas)
 | 
			
		||||
 | 
			
		||||
  const stage = app.stage
 | 
			
		||||
  stage.interactive = false
 | 
			
		||||
 | 
			
		||||
  const labelsContainer = new Container<Text>({ zIndex: 3 })
 | 
			
		||||
  const nodesContainer = new Container<Graphics>({ zIndex: 2 })
 | 
			
		||||
  const linkContainer = new Container<Graphics>({ zIndex: 1 })
 | 
			
		||||
  stage.addChild(nodesContainer, labelsContainer, linkContainer)
 | 
			
		||||
 | 
			
		||||
  for (const n of graphData.nodes) {
 | 
			
		||||
    const nodeId = n.id
 | 
			
		||||
 | 
			
		||||
    const label = new Text({
 | 
			
		||||
      interactive: false,
 | 
			
		||||
      eventMode: "none",
 | 
			
		||||
      text: n.text,
 | 
			
		||||
      alpha: 0,
 | 
			
		||||
      anchor: { x: 0.5, y: 1.2 },
 | 
			
		||||
      style: {
 | 
			
		||||
        fontSize: fontSize * 15,
 | 
			
		||||
        fill: computedStyleMap["--dark"],
 | 
			
		||||
        fontFamily: computedStyleMap["--bodyFont"],
 | 
			
		||||
      },
 | 
			
		||||
      resolution: window.devicePixelRatio * 4,
 | 
			
		||||
    })
 | 
			
		||||
    label.scale.set(1 / scale)
 | 
			
		||||
 | 
			
		||||
    let oldLabelOpacity = 0
 | 
			
		||||
    const isTagNode = nodeId.startsWith("tags/")
 | 
			
		||||
    const gfx = new Graphics({
 | 
			
		||||
      interactive: true,
 | 
			
		||||
      label: nodeId,
 | 
			
		||||
      eventMode: "static",
 | 
			
		||||
      hitArea: new Circle(0, 0, nodeRadius(n)),
 | 
			
		||||
      cursor: "pointer",
 | 
			
		||||
    })
 | 
			
		||||
      .circle(0, 0, nodeRadius(n))
 | 
			
		||||
      .fill({ color: isTagNode ? computedStyleMap["--light"] : color(n) })
 | 
			
		||||
      .stroke({ width: isTagNode ? 2 : 0, color: color(n) })
 | 
			
		||||
      .on("pointerover", (e) => {
 | 
			
		||||
        updateHoverInfo(e.target.label)
 | 
			
		||||
        oldLabelOpacity = label.alpha
 | 
			
		||||
        if (!dragging) {
 | 
			
		||||
          renderPixiFromD3()
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .on("pointerleave", () => {
 | 
			
		||||
        updateHoverInfo(null)
 | 
			
		||||
        label.alpha = oldLabelOpacity
 | 
			
		||||
        if (!dragging) {
 | 
			
		||||
          renderPixiFromD3()
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
    nodesContainer.addChild(gfx)
 | 
			
		||||
    labelsContainer.addChild(label)
 | 
			
		||||
 | 
			
		||||
    const nodeRenderDatum: NodeRenderData = {
 | 
			
		||||
      simulationData: n,
 | 
			
		||||
      gfx,
 | 
			
		||||
      label,
 | 
			
		||||
      color: color(n),
 | 
			
		||||
      alpha: 1,
 | 
			
		||||
      active: false,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    nodeRenderData.push(nodeRenderDatum)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  for (const l of graphData.links) {
 | 
			
		||||
    const gfx = new Graphics({ interactive: false, eventMode: "none" })
 | 
			
		||||
    linkContainer.addChild(gfx)
 | 
			
		||||
 | 
			
		||||
    const linkRenderDatum: LinkRenderData = {
 | 
			
		||||
      simulationData: l,
 | 
			
		||||
      gfx,
 | 
			
		||||
      color: computedStyleMap["--lightgray"],
 | 
			
		||||
      alpha: 1,
 | 
			
		||||
      active: false,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    linkRenderData.push(linkRenderDatum)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let currentTransform = zoomIdentity
 | 
			
		||||
  if (enableDrag) {
 | 
			
		||||
    select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
 | 
			
		||||
      drag<HTMLCanvasElement, NodeData | undefined>()
 | 
			
		||||
        .container(() => app.canvas)
 | 
			
		||||
        .subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
 | 
			
		||||
        .on("start", function dragstarted(event) {
 | 
			
		||||
          if (!event.active) simulation.alphaTarget(1).restart()
 | 
			
		||||
          event.subject.fx = event.subject.x
 | 
			
		||||
          event.subject.fy = event.subject.y
 | 
			
		||||
          event.subject.__initialDragPos = {
 | 
			
		||||
            x: event.subject.x,
 | 
			
		||||
            y: event.subject.y,
 | 
			
		||||
            fx: event.subject.fx,
 | 
			
		||||
            fy: event.subject.fy,
 | 
			
		||||
          }
 | 
			
		||||
          dragStartTime = Date.now()
 | 
			
		||||
          dragging = true
 | 
			
		||||
        })
 | 
			
		||||
        .on("drag", function dragged(event) {
 | 
			
		||||
          const initPos = event.subject.__initialDragPos
 | 
			
		||||
          event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k
 | 
			
		||||
          event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k
 | 
			
		||||
        })
 | 
			
		||||
        .on("end", function dragended(event) {
 | 
			
		||||
          if (!event.active) simulation.alphaTarget(0)
 | 
			
		||||
          event.subject.fx = null
 | 
			
		||||
          event.subject.fy = null
 | 
			
		||||
          dragging = false
 | 
			
		||||
 | 
			
		||||
          // if the time between mousedown and mouseup is short, we consider it a click
 | 
			
		||||
          if (Date.now() - dragStartTime < 500) {
 | 
			
		||||
            const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData
 | 
			
		||||
            const targ = resolveRelative(fullSlug, node.id)
 | 
			
		||||
            window.spaNavigate(new URL(targ, window.location.toString()))
 | 
			
		||||
          }
 | 
			
		||||
        }),
 | 
			
		||||
    )
 | 
			
		||||
  } else {
 | 
			
		||||
    for (const node of nodeRenderData) {
 | 
			
		||||
      node.gfx.on("click", () => {
 | 
			
		||||
        const targ = resolveRelative(fullSlug, node.simulationData.id)
 | 
			
		||||
        window.spaNavigate(new URL(targ, window.location.toString()))
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // set panning
 | 
			
		||||
  if (enableZoom) {
 | 
			
		||||
    svg.call(
 | 
			
		||||
      d3
 | 
			
		||||
        .zoom<SVGSVGElement, NodeData>()
 | 
			
		||||
    select<HTMLCanvasElement, NodeData>(app.canvas).call(
 | 
			
		||||
      zoom<HTMLCanvasElement, NodeData>()
 | 
			
		||||
        .extent([
 | 
			
		||||
          [0, 0],
 | 
			
		||||
          [width, height],
 | 
			
		||||
        ])
 | 
			
		||||
        .scaleExtent([0.25, 4])
 | 
			
		||||
        .on("zoom", ({ transform }) => {
 | 
			
		||||
          link.attr("transform", transform)
 | 
			
		||||
          node.attr("transform", transform)
 | 
			
		||||
          currentTransform = transform
 | 
			
		||||
          stage.scale.set(transform.k, transform.k)
 | 
			
		||||
          stage.position.set(transform.x, transform.y)
 | 
			
		||||
 | 
			
		||||
          // zoom adjusts opacity of labels too
 | 
			
		||||
          const scale = transform.k * opacityScale
 | 
			
		||||
          const scaledOpacity = Math.max((scale - 1) / 3.75, 0)
 | 
			
		||||
          labels.attr("transform", transform).style("opacity", scaledOpacity)
 | 
			
		||||
          let scaleOpacity = Math.max((scale - 1) / 3.75, 0)
 | 
			
		||||
          const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label)
 | 
			
		||||
 | 
			
		||||
          for (const label of labelsContainer.children) {
 | 
			
		||||
            if (!activeNodes.includes(label)) {
 | 
			
		||||
              label.alpha = scaleOpacity
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }),
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // progress the simulation
 | 
			
		||||
  simulation.on("tick", () => {
 | 
			
		||||
    link
 | 
			
		||||
      .attr("x1", (d: any) => d.source.x)
 | 
			
		||||
      .attr("y1", (d: any) => d.source.y)
 | 
			
		||||
      .attr("x2", (d: any) => d.target.x)
 | 
			
		||||
      .attr("y2", (d: any) => d.target.y)
 | 
			
		||||
    node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y)
 | 
			
		||||
    labels.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y)
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function renderGlobalGraph() {
 | 
			
		||||
  const slug = getFullSlug(window)
 | 
			
		||||
  const container = document.getElementById("global-graph-outer")
 | 
			
		||||
  const sidebar = container?.closest(".sidebar") as HTMLElement
 | 
			
		||||
  container?.classList.add("active")
 | 
			
		||||
  if (sidebar) {
 | 
			
		||||
    sidebar.style.zIndex = "1"
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  renderGraph("global-graph-container", slug)
 | 
			
		||||
 | 
			
		||||
  function hideGlobalGraph() {
 | 
			
		||||
    container?.classList.remove("active")
 | 
			
		||||
    const graph = document.getElementById("global-graph-container")
 | 
			
		||||
    if (sidebar) {
 | 
			
		||||
      sidebar.style.zIndex = "unset"
 | 
			
		||||
  function animate(time: number) {
 | 
			
		||||
    for (const n of nodeRenderData) {
 | 
			
		||||
      const { x, y } = n.simulationData
 | 
			
		||||
      if (!x || !y) continue
 | 
			
		||||
      n.gfx.position.set(x + width / 2, y + height / 2)
 | 
			
		||||
      if (n.label) {
 | 
			
		||||
        n.label.position.set(x + width / 2, y + height / 2)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (!graph) return
 | 
			
		||||
    removeAllChildren(graph)
 | 
			
		||||
 | 
			
		||||
    for (const l of linkRenderData) {
 | 
			
		||||
      const linkData = l.simulationData
 | 
			
		||||
      l.gfx.clear()
 | 
			
		||||
      l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2)
 | 
			
		||||
      l.gfx
 | 
			
		||||
        .lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
 | 
			
		||||
        .stroke({ alpha: l.alpha, width: 1, color: l.color })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    tweens.forEach((t) => t.update(time))
 | 
			
		||||
    app.renderer.render(stage)
 | 
			
		||||
    requestAnimationFrame(animate)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  registerEscapeHandler(container, hideGlobalGraph)
 | 
			
		||||
  const graphAnimationFrameHandle = requestAnimationFrame(animate)
 | 
			
		||||
  window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
 | 
			
		||||
@@ -364,7 +550,39 @@ document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
 | 
			
		||||
  addToVisited(simplifySlug(slug))
 | 
			
		||||
  await renderGraph("graph-container", slug)
 | 
			
		||||
 | 
			
		||||
  const container = document.getElementById("global-graph-outer")
 | 
			
		||||
  const sidebar = container?.closest(".sidebar") as HTMLElement
 | 
			
		||||
 | 
			
		||||
  function renderGlobalGraph() {
 | 
			
		||||
    const slug = getFullSlug(window)
 | 
			
		||||
    container?.classList.add("active")
 | 
			
		||||
    if (sidebar) {
 | 
			
		||||
      sidebar.style.zIndex = "1"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderGraph("global-graph-container", slug)
 | 
			
		||||
    registerEscapeHandler(container, hideGlobalGraph)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function hideGlobalGraph() {
 | 
			
		||||
    container?.classList.remove("active")
 | 
			
		||||
    if (sidebar) {
 | 
			
		||||
      sidebar.style.zIndex = "unset"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
 | 
			
		||||
    if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
 | 
			
		||||
      e.preventDefault()
 | 
			
		||||
      const globalGraphOpen = container?.classList.contains("active")
 | 
			
		||||
      globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const containerIcon = document.getElementById("global-graph-icon")
 | 
			
		||||
  containerIcon?.addEventListener("click", renderGlobalGraph)
 | 
			
		||||
  window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph))
 | 
			
		||||
 | 
			
		||||
  document.addEventListener("keydown", shortcutHandler)
 | 
			
		||||
  window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler))
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -16,10 +16,13 @@
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
 | 
			
		||||
    & > #global-graph-icon {
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      background: none;
 | 
			
		||||
      border: none;
 | 
			
		||||
      color: var(--dark);
 | 
			
		||||
      opacity: 0.5;
 | 
			
		||||
      width: 18px;
 | 
			
		||||
      height: 18px;
 | 
			
		||||
      width: 24px;
 | 
			
		||||
      height: 24px;
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      padding: 0.2rem;
 | 
			
		||||
      margin: 0.3rem;
 | 
			
		||||
@@ -59,8 +62,8 @@
 | 
			
		||||
      top: 50%;
 | 
			
		||||
      left: 50%;
 | 
			
		||||
      transform: translate(-50%, -50%);
 | 
			
		||||
      height: 60vh;
 | 
			
		||||
      width: 50vw;
 | 
			
		||||
      height: 80vh;
 | 
			
		||||
      width: 80vw;
 | 
			
		||||
 | 
			
		||||
      @media all and (max-width: $fullPageWidth) {
 | 
			
		||||
        width: 90%;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user