mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-22 22:15:42 -05:00
feat(plugins): graph as community plugin
This commit is contained in:
parent
e4ea2c96d4
commit
d11622f024
@ -9,6 +9,36 @@ Quartz features a graph-view that can show both a local graph view and a global
|
|||||||
- The local graph view shows files that either link to the current file or are linked from the current file. In other words, it shows all notes that are _at most_ one hop away.
|
- The local graph view shows files that either link to the current file or are linked from the current file. In other words, it shows all notes that are _at most_ one hop away.
|
||||||
- The global graph view can be toggled by clicking the graph icon on the top-right of the local graph view. It shows _all_ the notes in your graph and how they connect to each other.
|
- The global graph view can be toggled by clicking the graph icon on the top-right of the local graph view. It shows _all_ the notes in your graph and how they connect to each other.
|
||||||
|
|
||||||
|
> [!info]
|
||||||
|
> The Graph View is now a community plugin. This demonstrates how external plugins can extend Quartz functionality while serving as a reference implementation for plugin developers.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The Graph View is available as a community plugin from GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install github:quartz-community/graph --legacy-peer-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
Then import it in your `quartz.layout.ts`:
|
||||||
|
|
||||||
|
```typescript title="quartz.layout.ts"
|
||||||
|
import { Graph } from "@quartz-community/graph/components"
|
||||||
|
|
||||||
|
// Create once and reuse
|
||||||
|
const graphComponent = Graph()
|
||||||
|
|
||||||
|
export const defaultContentPageLayout: PageLayout = {
|
||||||
|
// ... other layout config
|
||||||
|
right: [
|
||||||
|
// ... other components
|
||||||
|
graphComponent,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
By default, the node radius is proportional to the total number of incoming and outgoing internal links from that file.
|
By default, the node radius is proportional to the total number of incoming and outgoing internal links from that file.
|
||||||
|
|
||||||
Additionally, similar to how browsers highlight visited links a different colour, the graph view will also show nodes that you have visited in a different colour.
|
Additionally, similar to how browsers highlight visited links a different colour, the graph view will also show nodes that you have visited in a different colour.
|
||||||
@ -18,12 +48,12 @@ Additionally, similar to how browsers highlight visited links a different colour
|
|||||||
|
|
||||||
## Customization
|
## Customization
|
||||||
|
|
||||||
Most configuration can be done by passing in options to `Component.Graph()`.
|
Most configuration can be done by passing in options to `Graph()`.
|
||||||
|
|
||||||
For example, here's what the default configuration looks like:
|
For example, here's what the default configuration looks like:
|
||||||
|
|
||||||
```typescript title="quartz.layout.ts"
|
```typescript title="quartz.layout.ts"
|
||||||
Component.Graph({
|
Graph({
|
||||||
localGraph: {
|
localGraph: {
|
||||||
drag: true, // whether to allow panning the view around
|
drag: true, // whether to allow panning the view around
|
||||||
zoom: true, // whether to allow zooming in and out
|
zoom: true, // whether to allow zooming in and out
|
||||||
@ -50,6 +80,7 @@ Component.Graph({
|
|||||||
opacityScale: 1,
|
opacityScale: 1,
|
||||||
removeTags: [], // what tags to remove from the graph
|
removeTags: [], // what tags to remove from the graph
|
||||||
showTags: true, // whether to show tags in the graph
|
showTags: true, // whether to show tags in the graph
|
||||||
|
focusOnHover: true, // dim non-connected nodes on hover
|
||||||
enableRadial: true, // whether to constrain the graph, similar to Obsidian
|
enableRadial: true, // whether to constrain the graph, similar to Obsidian
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -59,7 +90,5 @@ When passing in your own options, you can omit any or all of these fields if you
|
|||||||
|
|
||||||
Want to customize it even more?
|
Want to customize it even more?
|
||||||
|
|
||||||
- Removing graph view: delete all usages of `Component.Graph()` from `quartz.layout.ts`.
|
- Removing graph view: remove `graphComponent` from `quartz.layout.ts`
|
||||||
- Component: `quartz/components/Graph.tsx`
|
- Component source: https://github.com/quartz-community/graph
|
||||||
- Style: `quartz/components/styles/graph.scss`
|
|
||||||
- Script: `quartz/components/scripts/graph.inline.ts`
|
|
||||||
|
|||||||
1479
package-lock.json
generated
1479
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,7 @@
|
|||||||
"@myriaddreamin/rehype-typst": "^0.6.0",
|
"@myriaddreamin/rehype-typst": "^0.6.0",
|
||||||
"@napi-rs/simple-git": "0.1.22",
|
"@napi-rs/simple-git": "0.1.22",
|
||||||
"@quartz-community/explorer": "github:quartz-community/explorer",
|
"@quartz-community/explorer": "github:quartz-community/explorer",
|
||||||
|
"@quartz-community/graph": "github:quartz-community/graph",
|
||||||
"@tweenjs/tween.js": "^25.0.0",
|
"@tweenjs/tween.js": "^25.0.0",
|
||||||
"ansi-truncate": "^1.4.0",
|
"ansi-truncate": "^1.4.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
|
|||||||
@ -92,7 +92,7 @@ const config: QuartzConfig = {
|
|||||||
Plugin.CustomOgImages(),
|
Plugin.CustomOgImages(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
externalPlugins: ["@quartz-community/explorer"],
|
externalPlugins: ["@quartz-community/explorer", "@quartz-community/graph"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { PageLayout, SharedLayout } from "./quartz/cfg"
|
import { PageLayout, SharedLayout } from "./quartz/cfg"
|
||||||
import * as Component from "./quartz/components"
|
import * as Component from "./quartz/components"
|
||||||
import { Explorer } from "@quartz-community/explorer/components"
|
import { Explorer } from "@quartz-community/explorer/components"
|
||||||
|
import { Graph } from "@quartz-community/graph/components"
|
||||||
|
|
||||||
// Create Explorer once and reuse for both layouts
|
|
||||||
const explorerComponent = Explorer()
|
const explorerComponent = Explorer()
|
||||||
|
const graphComponent = Graph()
|
||||||
|
|
||||||
// components shared across all pages
|
// components shared across all pages
|
||||||
export const sharedPageComponents: SharedLayout = {
|
export const sharedPageComponents: SharedLayout = {
|
||||||
@ -45,7 +46,7 @@ export const defaultContentPageLayout: PageLayout = {
|
|||||||
explorerComponent,
|
explorerComponent,
|
||||||
],
|
],
|
||||||
right: [
|
right: [
|
||||||
Component.Graph(),
|
graphComponent,
|
||||||
Component.DesktopOnly(Component.TableOfContents()),
|
Component.DesktopOnly(Component.TableOfContents()),
|
||||||
Component.Backlinks(),
|
Component.Backlinks(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|
||||||
// @ts-ignore
|
|
||||||
import script from "./scripts/graph.inline"
|
|
||||||
import style from "./styles/graph.scss"
|
|
||||||
import { i18n } from "../i18n"
|
|
||||||
import { classNames } from "../util/lang"
|
|
||||||
|
|
||||||
export interface D3Config {
|
|
||||||
drag: boolean
|
|
||||||
zoom: boolean
|
|
||||||
depth: number
|
|
||||||
scale: number
|
|
||||||
repelForce: number
|
|
||||||
centerForce: number
|
|
||||||
linkDistance: number
|
|
||||||
fontSize: number
|
|
||||||
opacityScale: number
|
|
||||||
removeTags: string[]
|
|
||||||
showTags: boolean
|
|
||||||
focusOnHover?: boolean
|
|
||||||
enableRadial?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphOptions {
|
|
||||||
localGraph: Partial<D3Config> | undefined
|
|
||||||
globalGraph: Partial<D3Config> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOptions: GraphOptions = {
|
|
||||||
localGraph: {
|
|
||||||
drag: true,
|
|
||||||
zoom: true,
|
|
||||||
depth: 1,
|
|
||||||
scale: 1.1,
|
|
||||||
repelForce: 0.5,
|
|
||||||
centerForce: 0.3,
|
|
||||||
linkDistance: 30,
|
|
||||||
fontSize: 0.6,
|
|
||||||
opacityScale: 1,
|
|
||||||
showTags: true,
|
|
||||||
removeTags: [],
|
|
||||||
focusOnHover: false,
|
|
||||||
enableRadial: false,
|
|
||||||
},
|
|
||||||
globalGraph: {
|
|
||||||
drag: true,
|
|
||||||
zoom: true,
|
|
||||||
depth: -1,
|
|
||||||
scale: 0.9,
|
|
||||||
repelForce: 0.5,
|
|
||||||
centerForce: 0.2,
|
|
||||||
linkDistance: 30,
|
|
||||||
fontSize: 0.6,
|
|
||||||
opacityScale: 1,
|
|
||||||
showTags: true,
|
|
||||||
removeTags: [],
|
|
||||||
focusOnHover: true,
|
|
||||||
enableRadial: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ((opts?: Partial<GraphOptions>) => {
|
|
||||||
const Graph: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => {
|
|
||||||
const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph }
|
|
||||||
const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
|
|
||||||
return (
|
|
||||||
<div class={classNames(displayClass, "graph")}>
|
|
||||||
<h3>{i18n(cfg.locale).components.graph.title}</h3>
|
|
||||||
<div class="graph-outer">
|
|
||||||
<div class="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
|
|
||||||
<button class="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 class="global-graph-outer">
|
|
||||||
<div class="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Graph.css = style
|
|
||||||
Graph.afterDOMLoaded = script
|
|
||||||
|
|
||||||
return Graph
|
|
||||||
}) satisfies QuartzComponentConstructor
|
|
||||||
@ -11,7 +11,6 @@ import ContentMeta from "./ContentMeta"
|
|||||||
import Spacer from "./Spacer"
|
import Spacer from "./Spacer"
|
||||||
import TableOfContents from "./TableOfContents"
|
import TableOfContents from "./TableOfContents"
|
||||||
import TagList from "./TagList"
|
import TagList from "./TagList"
|
||||||
import Graph from "./Graph"
|
|
||||||
import Backlinks from "./Backlinks"
|
import Backlinks from "./Backlinks"
|
||||||
import Search from "./Search"
|
import Search from "./Search"
|
||||||
import Footer from "./Footer"
|
import Footer from "./Footer"
|
||||||
@ -41,7 +40,6 @@ export {
|
|||||||
Spacer,
|
Spacer,
|
||||||
TableOfContents,
|
TableOfContents,
|
||||||
TagList,
|
TagList,
|
||||||
Graph,
|
|
||||||
Backlinks,
|
Backlinks,
|
||||||
Search,
|
Search,
|
||||||
Footer,
|
Footer,
|
||||||
|
|||||||
@ -1,649 +0,0 @@
|
|||||||
import type { ContentDetails } from "../../plugins/emitters/contentIndex"
|
|
||||||
import {
|
|
||||||
SimulationNodeDatum,
|
|
||||||
SimulationLinkDatum,
|
|
||||||
Simulation,
|
|
||||||
forceSimulation,
|
|
||||||
forceManyBody,
|
|
||||||
forceCenter,
|
|
||||||
forceLink,
|
|
||||||
forceCollide,
|
|
||||||
forceRadial,
|
|
||||||
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[]
|
|
||||||
} & SimulationNodeDatum
|
|
||||||
|
|
||||||
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) ?? "[]"))
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToVisited(slug: SimpleSlug) {
|
|
||||||
const visited = getVisited()
|
|
||||||
visited.add(slug)
|
|
||||||
localStorage.setItem(localStorageKey, JSON.stringify([...visited]))
|
|
||||||
}
|
|
||||||
|
|
||||||
type TweenNode = {
|
|
||||||
update: (time: number) => void
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) {
|
|
||||||
const slug = simplifySlug(fullSlug)
|
|
||||||
const visited = getVisited()
|
|
||||||
removeAllChildren(graph)
|
|
||||||
|
|
||||||
let {
|
|
||||||
drag: enableDrag,
|
|
||||||
zoom: enableZoom,
|
|
||||||
depth,
|
|
||||||
scale,
|
|
||||||
repelForce,
|
|
||||||
centerForce,
|
|
||||||
linkDistance,
|
|
||||||
fontSize,
|
|
||||||
opacityScale,
|
|
||||||
removeTags,
|
|
||||||
showTags,
|
|
||||||
focusOnHover,
|
|
||||||
enableRadial,
|
|
||||||
} = JSON.parse(graph.dataset["cfg"]!) as D3Config
|
|
||||||
|
|
||||||
const data: Map<SimpleSlug, ContentDetails> = new Map(
|
|
||||||
Object.entries<ContentDetails>(await fetchData).map(([k, v]) => [
|
|
||||||
simplifySlug(k as FullSlug),
|
|
||||||
v,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
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 ?? []
|
|
||||||
|
|
||||||
for (const dest of outgoing) {
|
|
||||||
if (validLinks.has(dest)) {
|
|
||||||
links.push({ source: source, target: dest })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showTags) {
|
|
||||||
const localTags = details.tags
|
|
||||||
.filter((tag) => !removeTags.includes(tag))
|
|
||||||
.map((tag) => simplifySlug(("tags/" + tag) as FullSlug))
|
|
||||||
|
|
||||||
tags.push(...localTags.filter((tag) => !tags.includes(tag)))
|
|
||||||
|
|
||||||
for (const tag of localTags) {
|
|
||||||
links.push({ source: source, target: tag })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const neighbourhood = new Set<SimpleSlug>()
|
|
||||||
const wl: (SimpleSlug | "__SENTINEL")[] = [slug, "__SENTINEL"]
|
|
||||||
if (depth >= 0) {
|
|
||||||
while (depth >= 0 && wl.length > 0) {
|
|
||||||
// compute neighbours
|
|
||||||
const cur = wl.shift()!
|
|
||||||
if (cur === "__SENTINEL") {
|
|
||||||
depth--
|
|
||||||
wl.push("__SENTINEL")
|
|
||||||
} else {
|
|
||||||
neighbourhood.add(cur)
|
|
||||||
const outgoing = links.filter((l) => l.source === cur)
|
|
||||||
const incoming = links.filter((l) => l.target === cur)
|
|
||||||
wl.push(...outgoing.map((l) => l.target), ...incoming.map((l) => l.source))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
validLinks.forEach((id) => neighbourhood.add(id))
|
|
||||||
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,
|
|
||||||
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 width = graph.offsetWidth
|
|
||||||
const height = Math.max(graph.offsetHeight, 250)
|
|
||||||
|
|
||||||
// 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 radius = (Math.min(width, height) / 2) * 0.8
|
|
||||||
if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2))
|
|
||||||
|
|
||||||
// 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 computedStyleMap["--secondary"]
|
|
||||||
} else if (visited.has(d.id) || d.id.startsWith("tags/")) {
|
|
||||||
return computedStyleMap["--tertiary"]
|
|
||||||
} else {
|
|
||||||
return computedStyleMap["--gray"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeRadius(d: NodeData) {
|
|
||||||
const numLinks = graphData.links.filter(
|
|
||||||
(l) => l.source.id === d.id || l.target.id === d.id,
|
|
||||||
).length
|
|
||||||
return 2 + Math.sqrt(numLinks)
|
|
||||||
}
|
|
||||||
|
|
||||||
let hoveredNodeId: string | null = null
|
|
||||||
let hoveredNeighbours: Set<string> = new Set()
|
|
||||||
const linkRenderData: LinkRenderData[] = []
|
|
||||||
const nodeRenderData: NodeRenderData[] = []
|
|
||||||
function updateHoverInfo(newHoveredId: string | null) {
|
|
||||||
hoveredNodeId = newHoveredId
|
|
||||||
|
|
||||||
if (newHoveredId === null) {
|
|
||||||
hoveredNeighbours = new Set()
|
|
||||||
for (const n of nodeRenderData) {
|
|
||||||
n.active = false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const l of linkRenderData) {
|
|
||||||
l.active = false
|
|
||||||
}
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLabels() {
|
|
||||||
tweens.get("label")?.stop()
|
|
||||||
const tweenGroup = new TweenGroup()
|
|
||||||
|
|
||||||
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, isRenderGroup: true })
|
|
||||||
const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true })
|
|
||||||
const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true })
|
|
||||||
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) })
|
|
||||||
.on("pointerover", (e) => {
|
|
||||||
updateHoverInfo(e.target.label)
|
|
||||||
oldLabelOpacity = label.alpha
|
|
||||||
if (!dragging) {
|
|
||||||
renderPixiFromD3()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on("pointerleave", () => {
|
|
||||||
updateHoverInfo(null)
|
|
||||||
label.alpha = oldLabelOpacity
|
|
||||||
if (!dragging) {
|
|
||||||
renderPixiFromD3()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isTagNode) {
|
|
||||||
gfx.stroke({ width: 2, color: computedStyleMap["--tertiary"] })
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
select<HTMLCanvasElement, NodeData>(app.canvas).call(
|
|
||||||
zoom<HTMLCanvasElement, NodeData>()
|
|
||||||
.extent([
|
|
||||||
[0, 0],
|
|
||||||
[width, height],
|
|
||||||
])
|
|
||||||
.scaleExtent([0.25, 4])
|
|
||||||
.on("zoom", ({ 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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let stopAnimation = false
|
|
||||||
function animate(time: number) {
|
|
||||||
if (stopAnimation) return
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(animate)
|
|
||||||
return () => {
|
|
||||||
stopAnimation = true
|
|
||||||
app.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let localGraphCleanups: (() => void)[] = []
|
|
||||||
let globalGraphCleanups: (() => void)[] = []
|
|
||||||
|
|
||||||
function cleanupLocalGraphs() {
|
|
||||||
for (const cleanup of localGraphCleanups) {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
localGraphCleanups = []
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupGlobalGraphs() {
|
|
||||||
for (const cleanup of globalGraphCleanups) {
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
globalGraphCleanups = []
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
|
|
||||||
const slug = e.detail.url
|
|
||||||
addToVisited(simplifySlug(slug))
|
|
||||||
|
|
||||||
async function renderLocalGraph() {
|
|
||||||
cleanupLocalGraphs()
|
|
||||||
const localGraphContainers = document.getElementsByClassName("graph-container")
|
|
||||||
for (const container of localGraphContainers) {
|
|
||||||
localGraphCleanups.push(await renderGraph(container as HTMLElement, slug))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await renderLocalGraph()
|
|
||||||
const handleThemeChange = () => {
|
|
||||||
void renderLocalGraph()
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("themechange", handleThemeChange)
|
|
||||||
window.addCleanup(() => {
|
|
||||||
document.removeEventListener("themechange", handleThemeChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[]
|
|
||||||
async function renderGlobalGraph() {
|
|
||||||
const slug = getFullSlug(window)
|
|
||||||
for (const container of containers) {
|
|
||||||
container.classList.add("active")
|
|
||||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
||||||
if (sidebar) {
|
|
||||||
sidebar.style.zIndex = "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
const graphContainer = container.querySelector(".global-graph-container") as HTMLElement
|
|
||||||
registerEscapeHandler(container, hideGlobalGraph)
|
|
||||||
if (graphContainer) {
|
|
||||||
globalGraphCleanups.push(await renderGraph(graphContainer, slug))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideGlobalGraph() {
|
|
||||||
cleanupGlobalGraphs()
|
|
||||||
for (const container of containers) {
|
|
||||||
container.classList.remove("active")
|
|
||||||
const sidebar = container.closest(".sidebar") as HTMLElement
|
|
||||||
if (sidebar) {
|
|
||||||
sidebar.style.zIndex = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shortcutHandler(e: HTMLElementEventMap["keydown"]) {
|
|
||||||
if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
const anyGlobalGraphOpen = containers.some((container) =>
|
|
||||||
container.classList.contains("active"),
|
|
||||||
)
|
|
||||||
anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerIcons = document.getElementsByClassName("global-graph-icon")
|
|
||||||
Array.from(containerIcons).forEach((icon) => {
|
|
||||||
icon.addEventListener("click", renderGlobalGraph)
|
|
||||||
window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph))
|
|
||||||
})
|
|
||||||
|
|
||||||
document.addEventListener("keydown", shortcutHandler)
|
|
||||||
window.addCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", shortcutHandler)
|
|
||||||
cleanupLocalGraphs()
|
|
||||||
cleanupGlobalGraphs()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
@use "../../styles/variables.scss" as *;
|
|
||||||
|
|
||||||
.graph {
|
|
||||||
& > h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .graph-outer {
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--lightgray);
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 250px;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
& > .global-graph-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--dark);
|
|
||||||
opacity: 0.5;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
position: absolute;
|
|
||||||
padding: 0.2rem;
|
|
||||||
margin: 0.3rem;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.5s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--lightgray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .global-graph-outer {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9999;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100%;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > .global-graph-container {
|
|
||||||
border: 1px solid var(--lightgray);
|
|
||||||
background-color: var(--light);
|
|
||||||
border-radius: 5px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
height: 80vh;
|
|
||||||
width: 80vw;
|
|
||||||
|
|
||||||
@media all and not ($desktop) {
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user