| | |
| | | import { ContentDetails } from "../../plugins/emitters/contentIndex" |
| | | import * as d3 from 'd3' |
| | | import { registerEscapeHandler, relative, removeAllChildren } from "./util" |
| | | 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: string, |
| | | text: string, |
| | | id: SimpleSlug |
| | | text: string |
| | | tags: string[] |
| | | } & d3.SimulationNodeDatum |
| | | } & SimulationNodeDatum |
| | | |
| | | type SimpleLinkData = { |
| | | source: SimpleSlug |
| | | target: SimpleSlug |
| | | } |
| | | |
| | | type LinkData = { |
| | | source: string, |
| | | target: string |
| | | 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<string> { |
| | | function getVisited(): Set<SimpleSlug> { |
| | | return new Set(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")) |
| | | } |
| | | |
| | | function addToVisited(slug: string) { |
| | | function addToVisited(slug: SimpleSlug) { |
| | | const visited = getVisited() |
| | | visited.add(slug) |
| | | localStorage.setItem(localStorageKey, JSON.stringify([...visited])) |
| | | } |
| | | |
| | | async function renderGraph(container: string, slug: string) { |
| | | type TweenNode = { |
| | | update: (time: number) => void |
| | | stop: () => void |
| | | } |
| | | |
| | | async function renderGraph(container: string, fullSlug: FullSlug) { |
| | | const slug = simplifySlug(fullSlug) |
| | | const visited = getVisited() |
| | | const graph = document.getElementById(container) |
| | | if (!graph) return |
| | |
| | | centerForce, |
| | | linkDistance, |
| | | fontSize, |
| | | opacityScale |
| | | } = JSON.parse(graph.dataset["cfg"]!) |
| | | opacityScale, |
| | | removeTags, |
| | | showTags, |
| | | focusOnHover, |
| | | enableRadial, |
| | | } = JSON.parse(graph.dataset["cfg"]!) as D3Config |
| | | |
| | | const data = await fetchData |
| | | 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 links: LinkData[] = [] |
| | | for (const [src, details] of Object.entries<ContentDetails>(data)) { |
| | | const tweens = new Map<string, TweenNode>() |
| | | for (const [source, details] of data.entries()) { |
| | | const outgoing = details.links ?? [] |
| | | |
| | | for (const dest of outgoing) { |
| | | if (src in data && dest in data) { |
| | | links.push({ source: src, target: dest }) |
| | | 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() |
| | | |
| | | const wl = [slug, "__SENTINEL"] |
| | | 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() |
| | | 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) |
| | | 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 { |
| | | links.flatMap(l => [l.source, l.target]).forEach((id) => neighbourhood.add(id)) |
| | | validLinks.forEach((id) => neighbourhood.add(id)) |
| | | if (showTags) tags.forEach((tag) => neighbourhood.add(tag)) |
| | | } |
| | | |
| | | const graphData: { nodes: NodeData[], links: LinkData[] } = { |
| | | nodes: Object.keys(data).filter(id => neighbourhood.has(id)).map(url => ({ id: url, text: data[url]?.title ?? url, tags: data[url]?.tags ?? [] })), |
| | | links: links.filter((l) => neighbourhood.has(l.source) && neighbourhood.has(l.target)) |
| | | 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 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)) |
| | | |
| | | 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]) |
| | | // we virtualize the simulation and use pixi to actually render it |
| | | // Calculate the radius of the container circle |
| | | const radius = Math.min(width, height) / 2 - 40 // 40px padding |
| | | 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)) |
| | | |
| | | // 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", 2) |
| | | if (enableRadial) |
| | | simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3)) |
| | | |
| | | // 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 radius |
| | | // calculate color |
| | | const color = (d: NodeData) => { |
| | | const isCurrent = d.id === slug |
| | | if (isCurrent) { |
| | | return "var(--secondary)" |
| | | } else if (visited.has(d.id)) { |
| | | return "var(--tertiary)" |
| | | return computedStyleMap["--secondary"] |
| | | } else if (visited.has(d.id) || d.id.startsWith("tags/")) { |
| | | 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) |
| | | } |
| | | |
| | | // 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 = relative(slug, d.id) |
| | | window.spaNavigate(new URL(targ)) |
| | | 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()) |
| | | }, |
| | | }) |
| | | .on("mouseover", function(_, d) { |
| | | const neighbours: string[] = data[slug].links ?? [] |
| | | const neighbourNodes = d3.selectAll<HTMLElement, NodeData>(".node").filter((d) => neighbours.includes(d.id)) |
| | | const currentId = d.id |
| | | const linkNodes = d3 |
| | | .selectAll(".link") |
| | | .filter((d: any) => d.source.id === currentId || d.target.id === currentId) |
| | | } |
| | | |
| | | // highlight neighbour nodes |
| | | neighbourNodes.transition().duration(200).attr("fill", color) |
| | | function renderLabels() { |
| | | tweens.get("label")?.stop() |
| | | const tweenGroup = new TweenGroup() |
| | | |
| | | // highlight links |
| | | linkNodes.transition().duration(200).attr("stroke", "var(--gray)") |
| | | const defaultScale = 1 / scale |
| | | const activeScale = defaultScale * 1.1 |
| | | for (const n of nodeRenderData) { |
| | | const nodeId = n.simulationData.id |
| | | |
| | | const bigFont = fontSize * 1.5 |
| | | 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, |
| | | ), |
| | | ) |
| | | } |
| | | } |
| | | |
| | | // 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') |
| | | tweenGroup.getAll().forEach((tw) => tw.start()) |
| | | tweens.set("label", { |
| | | update: tweenGroup.update.bind(tweenGroup), |
| | | stop() { |
| | | tweenGroup.getAll().forEach((tw) => tw.stop()) |
| | | }, |
| | | }) |
| | | .on("mouseleave", function(_, d) { |
| | | const currentId = d.id |
| | | const linkNodes = d3 |
| | | .selectAll(".link") |
| | | .filter((d: any) => d.source.id === currentId || d.target.id === currentId) |
| | | } |
| | | |
| | | linkNodes.transition().duration(200).attr("stroke", "var(--lightgray)") |
| | | function renderNodes() { |
| | | tweens.get("hover")?.stop() |
| | | |
| | | 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') |
| | | 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()) |
| | | }, |
| | | }) |
| | | // @ts-ignore |
| | | .call(drag(simulation)) |
| | | } |
| | | |
| | | // draw labels |
| | | const labels = graphNode |
| | | .append("text") |
| | | .attr("dx", 0) |
| | | .attr("dy", (d) => nodeRadius(d) + 8 + "px") |
| | | .attr("text-anchor", "middle") |
| | | .text((d) => data[d.id]?.title || (d.id.charAt(1).toUpperCase() + d.id.slice(2)).replace("-", " ")) |
| | | .style('opacity', (opacityScale - 1) / 3.75) |
| | | .style("pointer-events", "none") |
| | | .style('font-size', fontSize + 'em') |
| | | .raise() |
| | | // @ts-ignore |
| | | .call(drag(simulation)) |
| | | function renderPixiFromD3() { |
| | | renderNodes() |
| | | renderLinks() |
| | | renderLabels() |
| | | } |
| | | |
| | | // set panning |
| | | 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) }) |
| | | .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) |
| | | const scale = transform.k * opacityScale; |
| | | const scaledOpacity = Math.max((scale - 1) / 3.75, 0) |
| | | labels.attr("transform", transform).style("opacity", scaledOpacity) |
| | | 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 |
| | | } |
| | | } |
| | | }), |
| | | ) |
| | | } |
| | | |
| | | // 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 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) |
| | | } |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | |
| | | const graphAnimationFrameHandle = requestAnimationFrame(animate) |
| | | window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle)) |
| | | } |
| | | |
| | | async function renderGlobalGraph() { |
| | | const slug = document.body.dataset["slug"]! |
| | | await renderGraph("global-graph-container", slug) |
| | | const container = document.getElementById("global-graph-outer") |
| | | container?.classList.add("active") |
| | | document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { |
| | | const slug = e.detail.url |
| | | addToVisited(simplifySlug(slug)) |
| | | await renderGraph("graph-container", slug) |
| | | |
| | | // Function to re-render the graph when the theme changes |
| | | const handleThemeChange = () => { |
| | | renderGraph("graph-container", slug) |
| | | } |
| | | |
| | | // event listener for theme change |
| | | document.addEventListener("themechange", handleThemeChange) |
| | | |
| | | // cleanup for the event listener |
| | | window.addCleanup(() => { |
| | | document.removeEventListener("themechange", handleThemeChange) |
| | | }) |
| | | |
| | | 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") |
| | | const graph = document.getElementById("global-graph-container") |
| | | if (!graph) return |
| | | removeAllChildren(graph) |
| | | if (sidebar) { |
| | | sidebar.style.zIndex = "" |
| | | } |
| | | } |
| | | |
| | | registerEscapeHandler(container, hideGlobalGraph) |
| | | } |
| | | |
| | | document.addEventListener("nav", async (e: unknown) => { |
| | | const slug = (e as CustomEventMap["nav"]).detail.url |
| | | addToVisited(slug) |
| | | await renderGraph("graph-container", slug) |
| | | 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?.removeEventListener("click", renderGlobalGraph) |
| | | containerIcon?.addEventListener("click", renderGlobalGraph) |
| | | }) |
| | | window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) |
| | | |
| | | let resizeEventDebounce: number | undefined = undefined |
| | | window.addEventListener('resize', () => { |
| | | if (resizeEventDebounce) { |
| | | clearTimeout(resizeEventDebounce) |
| | | } |
| | | |
| | | resizeEventDebounce = window.setTimeout(async () => { |
| | | const slug = document.body.dataset["slug"]! |
| | | await renderGraph("graph-container", slug) |
| | | }, 50) |
| | | document.addEventListener("keydown", shortcutHandler) |
| | | window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) |
| | | }) |