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>
| | |
| | | { |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "@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", |
| | |
| | | "@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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "@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", |
| | |
| | | "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", |
| | |
| | | <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> |
| | |
| | | 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) ?? "[]")) |
| | |
| | | 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() |
| | |
| | | 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]) => [ |
| | |
| | | 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 ?? [] |
| | | |
| | |
| | | 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 |
| | | |
| | | // set panning |
| | | 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())) |
| | | }) |
| | | } |
| | | } |
| | | |
| | | 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"]) => { |
| | |
| | | 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)) |
| | | }) |
| | |
| | | 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; |
| | |
| | | top: 50%; |
| | | left: 50%; |
| | | transform: translate(-50%, -50%); |
| | | height: 60vh; |
| | | width: 50vw; |
| | | height: 80vh; |
| | | width: 80vw; |
| | | |
| | | @media all and (max-width: $fullPageWidth) { |
| | | width: 90%; |