local and global graph
This commit is contained in:
parent
8bfee04c8c
commit
c4cf0dcb02
23 changed files with 1288 additions and 110 deletions
|
@ -2,7 +2,6 @@ import { PluginTypes } from "./plugins/types"
|
|||
import { Theme } from "./theme"
|
||||
|
||||
export interface GlobalConfiguration {
|
||||
siteTitle: string,
|
||||
/** Whether to enable single-page-app style rendering. this prevents flashes of unstyled content and improves smoothness of Quartz */
|
||||
enableSPA: boolean,
|
||||
/** Glob patterns to not search */
|
||||
|
|
|
@ -4,10 +4,15 @@ function ArticleTitle({ fileData }: QuartzComponentProps) {
|
|||
const title = fileData.frontmatter?.title
|
||||
const displayTitle = fileData.slug === "index" ? undefined : title
|
||||
if (displayTitle) {
|
||||
return <h1>{displayTitle}</h1>
|
||||
return <h1 class="article-title">{displayTitle}</h1>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
ArticleTitle.css = `
|
||||
.article-title {
|
||||
margin: 2rem 0 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
export default (() => ArticleTitle) satisfies QuartzComponentConstructor
|
||||
|
|
81
quartz/components/Graph.tsx
Normal file
81
quartz/components/Graph.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { QuartzComponentConstructor } from "./types"
|
||||
// @ts-ignore
|
||||
import script from "./scripts/graph.inline"
|
||||
import style from "./styles/graph.scss"
|
||||
|
||||
export interface D3Config {
|
||||
drag: boolean,
|
||||
zoom: boolean,
|
||||
depth: number,
|
||||
scale: number,
|
||||
repelForce: number,
|
||||
centerForce: number,
|
||||
linkDistance: number,
|
||||
fontSize: number,
|
||||
opacityScale: number
|
||||
}
|
||||
|
||||
interface GraphOptions {
|
||||
localGraph: Partial<D3Config>,
|
||||
globalGraph: Partial<D3Config> | undefined
|
||||
}
|
||||
|
||||
const defaultOptions: GraphOptions = {
|
||||
localGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: 1,
|
||||
scale: 1.2,
|
||||
repelForce: 2,
|
||||
centerForce: 1,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.6,
|
||||
opacityScale: 3
|
||||
},
|
||||
globalGraph: {
|
||||
drag: true,
|
||||
zoom: true,
|
||||
depth: -1,
|
||||
scale: 1.2,
|
||||
repelForce: 1,
|
||||
centerForce: 1,
|
||||
linkDistance: 30,
|
||||
fontSize: 0.5,
|
||||
opacityScale: 3
|
||||
}
|
||||
}
|
||||
|
||||
export default ((opts?: GraphOptions) => {
|
||||
function Graph() {
|
||||
const localGraph = { ...opts?.localGraph, ...defaultOptions.localGraph }
|
||||
const globalGraph = { ...opts?.globalGraph, ...defaultOptions.globalGraph }
|
||||
return <div class="graph">
|
||||
<h3>Interactive Graph</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>
|
||||
</div>
|
||||
<div id="global-graph-outer">
|
||||
<div id="global-graph-container" data-cfg={JSON.stringify(globalGraph)}></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Graph.css = style
|
||||
Graph.afterDOMLoaded = script
|
||||
|
||||
return Graph
|
||||
}) satisfies QuartzComponentConstructor
|
|
@ -2,32 +2,47 @@ import { resolveToRoot } from "../path"
|
|||
import { JSResourceToScriptElement } from "../resources"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
function Head({ fileData, externalResources }: QuartzComponentProps) {
|
||||
const slug = fileData.slug!
|
||||
const title = fileData.frontmatter?.title ?? "Untitled"
|
||||
const description = fileData.description ?? "No description provided"
|
||||
const { css, js } = externalResources
|
||||
const baseDir = resolveToRoot(slug)
|
||||
const iconPath = baseDir + "/static/icon.png"
|
||||
const ogImagePath = baseDir + "/static/og-image.png"
|
||||
|
||||
return <head>
|
||||
<title>{title}</title>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={title} />
|
||||
<meta property="og:image" content={ogImagePath} />
|
||||
<meta property="og:width" content="1200" />
|
||||
<meta property="og:height" content="675" />
|
||||
<link rel="icon" href={iconPath} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content="Quartz" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
{css.map(href => <link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />)}
|
||||
{js.filter(resource => resource.loadTime === "beforeDOMReady").map(res => JSResourceToScriptElement(res, true))}
|
||||
</head>
|
||||
interface Options {
|
||||
prefetchContentIndex: boolean
|
||||
}
|
||||
|
||||
export default (() => Head) satisfies QuartzComponentConstructor
|
||||
const defaultOptions: Options = {
|
||||
prefetchContentIndex: true
|
||||
}
|
||||
|
||||
export default ((opts?: Options) => {
|
||||
function Head({ fileData, externalResources }: QuartzComponentProps) {
|
||||
const slug = fileData.slug!
|
||||
const title = fileData.frontmatter?.title ?? "Untitled"
|
||||
const description = fileData.description ?? "No description provided"
|
||||
const { css, js } = externalResources
|
||||
const baseDir = resolveToRoot(slug)
|
||||
const iconPath = baseDir + "/static/icon.png"
|
||||
const ogImagePath = baseDir + "/static/og-image.png"
|
||||
|
||||
const prefetchContentIndex = opts?.prefetchContentIndex ?? defaultOptions.prefetchContentIndex
|
||||
const contentIndexPath = baseDir + "/static/contentIndex.json"
|
||||
const contentIndexScript = `const fetchData = fetch(\`${contentIndexPath}\`).then(data => data.json())`
|
||||
|
||||
return <head>
|
||||
<title>{title}</title>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={title} />
|
||||
<meta property="og:image" content={ogImagePath} />
|
||||
<meta property="og:width" content="1200" />
|
||||
<meta property="og:height" content="675" />
|
||||
<link rel="icon" href={iconPath} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content="Quartz" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
{prefetchContentIndex && <script spa-preserve>{contentIndexScript}</script>}
|
||||
{css.map(href => <link key={href} href={href} rel="stylesheet" type="text/css" spa-preserve />)}
|
||||
{js.filter(resource => resource.loadTime === "beforeDOMReady").map(res => JSResourceToScriptElement(res, true))}
|
||||
</head>
|
||||
}
|
||||
|
||||
return Head
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
function Header({ children }: QuartzComponentProps) {
|
||||
return <header>
|
||||
return (children.length > 0) ? <header>
|
||||
{children}
|
||||
</header>
|
||||
</header> : null
|
||||
}
|
||||
|
||||
Header.css = `
|
||||
|
@ -11,12 +11,10 @@ header {
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 1em 0 2em 0;
|
||||
& > h1 {
|
||||
}
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
header > h1 {
|
||||
header h1 {
|
||||
margin: 0;
|
||||
flex: auto;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,22 @@
|
|||
import { resolveToRoot } from "../path"
|
||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||
|
||||
function PageTitle({ cfg, fileData }: QuartzComponentProps) {
|
||||
const title = cfg.siteTitle
|
||||
const slug = fileData.slug!
|
||||
const baseDir = resolveToRoot(slug)
|
||||
return <h1><a href={baseDir}>{title}</a></h1>
|
||||
interface Options {
|
||||
title: string
|
||||
}
|
||||
|
||||
export default (() => PageTitle) satisfies QuartzComponentConstructor
|
||||
export default ((opts?: Options) => {
|
||||
const title = opts?.title ?? "Untitled Quartz"
|
||||
function PageTitle({ fileData }: QuartzComponentProps) {
|
||||
const slug = fileData.slug!
|
||||
const baseDir = resolveToRoot(slug)
|
||||
return <h1 class="page-title"><a href={baseDir}>{title}</a></h1>
|
||||
}
|
||||
PageTitle.css = `
|
||||
.page-title {
|
||||
margin: 0;
|
||||
}
|
||||
`
|
||||
|
||||
return PageTitle
|
||||
}) satisfies QuartzComponentConstructor
|
||||
|
|
|
@ -18,7 +18,7 @@ function TableOfContents({ fileData }: QuartzComponentProps) {
|
|||
return null
|
||||
}
|
||||
|
||||
return <>
|
||||
return <div>
|
||||
<button type="button" id="toc">
|
||||
<h3>Table of Contents</h3>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold">
|
||||
|
@ -32,7 +32,7 @@ function TableOfContents({ fileData }: QuartzComponentProps) {
|
|||
</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
TableOfContents.css = modernStyle
|
||||
TableOfContents.afterDOMLoaded = script
|
||||
|
|
|
@ -7,6 +7,7 @@ import ReadingTime from "./ReadingTime"
|
|||
import Spacer from "./Spacer"
|
||||
import TableOfContents from "./TableOfContents"
|
||||
import TagList from "./TagList"
|
||||
import Graph from "./Graph"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
|
@ -17,5 +18,6 @@ export {
|
|||
ReadingTime,
|
||||
Spacer,
|
||||
TableOfContents,
|
||||
TagList
|
||||
TagList,
|
||||
Graph
|
||||
}
|
||||
|
|
287
quartz/components/scripts/graph.inline.ts
Normal file
287
quartz/components/scripts/graph.inline.ts
Normal file
|
@ -0,0 +1,287 @@
|
|||
import { ContentDetails } from "../../plugins/emitters/contentIndex"
|
||||
import * as d3 from 'd3'
|
||||
|
||||
type NodeData = {
|
||||
id: string,
|
||||
text: string,
|
||||
tags: string[]
|
||||
} & d3.SimulationNodeDatum
|
||||
|
||||
type LinkData = {
|
||||
source: string,
|
||||
target: string
|
||||
}
|
||||
|
||||
function relative(from: string, to: string) {
|
||||
const pieces = [location.protocol, '//', location.host, location.pathname]
|
||||
const url = pieces.join('').slice(0, -from.length) + to
|
||||
return url
|
||||
}
|
||||
|
||||
function removeAllChildren(node: HTMLElement) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderGraph(container: string, slug: string) {
|
||||
const graph = document.getElementById(container)!
|
||||
removeAllChildren(graph)
|
||||
|
||||
let {
|
||||
drag: enableDrag,
|
||||
zoom: enableZoom,
|
||||
depth,
|
||||
scale,
|
||||
repelForce,
|
||||
centerForce,
|
||||
linkDistance,
|
||||
fontSize,
|
||||
opacityScale
|
||||
} = JSON.parse(graph.dataset["cfg"]!)
|
||||
|
||||
const data = await fetchData
|
||||
|
||||
const links: LinkData[] = []
|
||||
for (const [src, details] of Object.entries<ContentDetails>(data)) {
|
||||
const outgoing = details.links ?? []
|
||||
for (const dest of outgoing) {
|
||||
if (src in data && dest in data) {
|
||||
links.push({ source: src, target: dest })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neighbourhood = new Set()
|
||||
|
||||
const wl = [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 {
|
||||
links.flatMap(l => [l.source, l.target]).forEach((id) => neighbourhood.add(id))
|
||||
}
|
||||
|
||||
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 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 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", 2)
|
||||
|
||||
// svg groups
|
||||
const graphNode = svg.append("g").selectAll("g").data(graphData.nodes).enter().append("g")
|
||||
|
||||
// calculate radius
|
||||
const color = (d: NodeData) => {
|
||||
// TODO: does this handle the index page
|
||||
const isCurrent = d.id === slug
|
||||
return isCurrent ? "var(--secondary)" : "var(--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
|
||||
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))
|
||||
})
|
||||
.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)
|
||||
|
||||
// highlight links
|
||||
linkNodes.transition().duration(200).attr("stroke", "var(--gray)")
|
||||
|
||||
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) {
|
||||
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)")
|
||||
|
||||
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')
|
||||
})
|
||||
// @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))
|
||||
|
||||
// set panning
|
||||
if (enableZoom) {
|
||||
svg.call(
|
||||
d3
|
||||
.zoom<SVGSVGElement, 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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = document.body.dataset["slug"]!
|
||||
renderGraph("global-graph-container", slug)
|
||||
const container = document.getElementById("global-graph-outer")
|
||||
container?.classList.add("active")
|
||||
|
||||
function hideGlobalGraph(this: HTMLElement, e: HTMLElementEventMap["click"]) {
|
||||
if (e.target !== this) return
|
||||
|
||||
container?.classList.remove("active")
|
||||
const graph = document.getElementById("global-graph-container")!
|
||||
removeAllChildren(graph)
|
||||
}
|
||||
|
||||
container?.removeEventListener("click", hideGlobalGraph)
|
||||
container?.addEventListener("click", hideGlobalGraph)
|
||||
}
|
||||
|
||||
document.addEventListener("nav", async (e: unknown) => {
|
||||
const slug = (e as CustomEventMap["nav"]).detail.url
|
||||
await renderGraph("graph-container", slug)
|
||||
|
||||
const containerIcon = document.getElementById("global-graph-icon")
|
||||
containerIcon?.removeEventListener("click", renderGlobalGraph)
|
||||
containerIcon?.addEventListener("click", renderGlobalGraph)
|
||||
})
|
|
@ -1,39 +1,50 @@
|
|||
import { computePosition, inline, shift, autoPlacement } from "@floating-ui/dom"
|
||||
import { computePosition, flip, inline, shift } from "@floating-ui/dom"
|
||||
|
||||
document.addEventListener("nav", () => {
|
||||
const links = [...document.getElementsByClassName("internal")] as HTMLLinkElement[]
|
||||
const p = new DOMParser()
|
||||
for (const link of links) {
|
||||
link.addEventListener("mouseenter", async ({ clientX, clientY }) => {
|
||||
if (link.dataset.fetchedPopover === "true") return
|
||||
async function setPosition(popoverElement: HTMLElement) {
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
middleware: [inline({
|
||||
x: clientX,
|
||||
y: clientY
|
||||
}), shift(), flip()]
|
||||
})
|
||||
Object.assign(popoverElement.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
})
|
||||
}
|
||||
|
||||
if (link.dataset.fetchedPopover === "true") {
|
||||
return setPosition(link.lastChild as HTMLElement)
|
||||
}
|
||||
|
||||
const url = link.href
|
||||
const anchor = new URL(url).hash
|
||||
if (anchor.startsWith("#")) return
|
||||
|
||||
const contents = await fetch(`${url}`)
|
||||
.then((res) => res.text())
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
if (!contents) return
|
||||
const html = p.parseFromString(contents, "text/html")
|
||||
const elts = [...html.getElementsByClassName("popover-hint")]
|
||||
if (elts.length === 0) return
|
||||
|
||||
|
||||
const popoverElement = document.createElement("div")
|
||||
popoverElement.classList.add("popover")
|
||||
elts.forEach(elt => popoverElement.appendChild(elt))
|
||||
|
||||
const { x, y } = await computePosition(link, popoverElement, {
|
||||
middleware: [inline({
|
||||
x: clientX,
|
||||
y: clientY
|
||||
}), shift(), autoPlacement()]
|
||||
})
|
||||
|
||||
Object.assign(popoverElement.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
})
|
||||
const popoverInner = document.createElement("div")
|
||||
popoverInner.classList.add("popover-inner")
|
||||
popoverElement.appendChild(popoverInner)
|
||||
elts.forEach(elt => popoverInner.appendChild(elt))
|
||||
|
||||
setPosition(popoverElement)
|
||||
link.appendChild(popoverElement)
|
||||
link.dataset.fetchedPopover = "true"
|
||||
})
|
||||
|
|
|
@ -29,8 +29,8 @@ const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined
|
|||
return { url: new URL(href), scroll: 'routerNoscroll' in a.dataset ? false : undefined }
|
||||
}
|
||||
|
||||
function notifyNav(slug: string) {
|
||||
const event = new CustomEvent("nav", { detail: { slug } })
|
||||
function notifyNav(url: string) {
|
||||
const event: CustomEventMap["nav"] = new CustomEvent("nav", { detail: { url } })
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
|
@ -73,6 +73,8 @@ async function navigate(url: URL, isBack: boolean = false) {
|
|||
delete announcer.dataset.persist
|
||||
}
|
||||
|
||||
window.spaNavigate = navigate
|
||||
|
||||
function createRouter() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", async (event) => {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
.darkmode {
|
||||
float: right;
|
||||
padding: 1rem;
|
||||
min-width: 30px;
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
& > .toggle {
|
||||
display: none;
|
||||
|
@ -16,7 +15,6 @@
|
|||
width: 20px;
|
||||
height: 20px;
|
||||
top: calc(50% - 10px);
|
||||
margin: 0 7px;
|
||||
fill: var(--darkgray);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
|
68
quartz/components/styles/graph.scss
Normal file
68
quartz/components/styles/graph.scss
Normal file
|
@ -0,0 +1,68 @@
|
|||
.graph {
|
||||
& > h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
& > .graph-outer {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--lightgray);
|
||||
box-sizing: border-box;
|
||||
height: 250px;
|
||||
width: 300px;
|
||||
margin: 0.5em 0;
|
||||
position: relative;
|
||||
|
||||
& > #global-graph-icon {
|
||||
color: var(--dark);
|
||||
opacity: 0.5;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
& > #graph-container > svg {
|
||||
margin-bottom: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
& > #global-graph-outer {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
overflow: scroll;
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
|
||||
&.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: 60vh;
|
||||
width: 50vw;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
50% {
|
||||
1% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
|
@ -15,21 +15,24 @@
|
|||
.popover {
|
||||
z-index: 999;
|
||||
position: absolute;
|
||||
overflow: scroll;
|
||||
width: 30rem;
|
||||
height: 20rem;
|
||||
padding: 0 1rem;
|
||||
margin-top: -1rem;
|
||||
border: 1px solid var(--lightgray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-shadow: 6px 6px 36px 0 rgba(0,0,0,0.25);
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
|
||||
font-weight: initial;
|
||||
& > .popover-inner {
|
||||
width: 30rem;
|
||||
height: 20rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
font-weight: initial;
|
||||
border: 1px solid var(--gray);
|
||||
background-color: var(--light);
|
||||
border-radius: 5px;
|
||||
box-shadow: 6px 6px 36px 0 rgba(0,0,0,0.25);
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
|
||||
@media all and (max-width: 600px) {
|
||||
display: none !important;
|
||||
|
@ -37,7 +40,7 @@
|
|||
}
|
||||
|
||||
a:hover .popover, .popover:hover {
|
||||
animation: dropin 0.5s ease;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
animation: dropin 0.3s ease;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ button#toc {
|
|||
overflow: hidden;
|
||||
max-height: none;
|
||||
transition: max-height 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
|
@ -38,7 +39,7 @@ button#toc {
|
|||
& > li > a {
|
||||
color: var(--dark);
|
||||
opacity: 0.35;
|
||||
transition: 0.5s ease opacity;
|
||||
transition: 0.5s ease opacity, 0.3s ease color;
|
||||
&.in-view {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
|
|
@ -13,10 +13,6 @@ export function trimPathSuffix(fp: string): string {
|
|||
cleanPath = cleanPath.slice(0, -"index".length)
|
||||
}
|
||||
|
||||
if (cleanPath === "") {
|
||||
cleanPath = "./"
|
||||
}
|
||||
|
||||
return cleanPath + anchor
|
||||
}
|
||||
|
||||
|
@ -36,7 +32,7 @@ export function slugify(s: string): string {
|
|||
export function resolveToRoot(slug: string): string {
|
||||
let fp = trimPathSuffix(slug)
|
||||
|
||||
if (fp === "./") {
|
||||
if (fp === "") {
|
||||
return "."
|
||||
}
|
||||
|
||||
|
|
|
@ -14,19 +14,20 @@ const defaultOptions: Options = {
|
|||
indexExternalLinks: false,
|
||||
}
|
||||
|
||||
type ContentIndex = Map<string, {
|
||||
export type ContentIndex = Map<string, ContentDetails>
|
||||
export type ContentDetails = {
|
||||
title: string,
|
||||
links?: string[],
|
||||
tags?: string[],
|
||||
content: string,
|
||||
}>
|
||||
}
|
||||
|
||||
export const ContentIndex: QuartzEmitterPlugin<Options> = (userOpts) => {
|
||||
const opts = { ...userOpts, ...defaultOptions }
|
||||
return {
|
||||
name: "ContentIndex",
|
||||
async emit(_contentDir, _cfg, content, _resources, emit) {
|
||||
const fp = "contentIndex"
|
||||
const fp = path.join("static", "contentIndex")
|
||||
const linkIndex: ContentIndex = new Map()
|
||||
for (const [tree, file] of content) {
|
||||
let slug = trimPathSuffix(file.data.slug!)
|
||||
|
|
|
@ -2,7 +2,7 @@ import { JSResourceToScriptElement, StaticResources } from "../../resources"
|
|||
import { QuartzEmitterPlugin } from "../types"
|
||||
import { render } from "preact-render-to-string"
|
||||
import { QuartzComponent } from "../../components/types"
|
||||
import { resolveToRoot } from "../../path"
|
||||
import { resolveToRoot, trimPathSuffix } from "../../path"
|
||||
import HeaderConstructor from "../../components/Header"
|
||||
import { QuartzComponentProps } from "../../components/types"
|
||||
import BodyConstructor from "../../components/Body"
|
||||
|
@ -56,7 +56,7 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
|
|||
const Content = opts.content
|
||||
const doc = <html>
|
||||
<Head {...componentData} />
|
||||
<body data-slug={file.data.slug}>
|
||||
<body data-slug={trimPathSuffix(file.data.slug ?? "")}>
|
||||
<div id="quartz-root" class="page">
|
||||
<Header {...componentData} >
|
||||
{header.map(HeaderComponent => <HeaderComponent {...componentData} />)}
|
||||
|
|
|
@ -10,8 +10,6 @@ html {
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--light);
|
||||
|
@ -48,31 +46,39 @@ a {
|
|||
}
|
||||
|
||||
.page {
|
||||
padding: 4rem 30vw;
|
||||
margin: 0 auto;
|
||||
margin: 6rem 35vw 6rem 20vw;
|
||||
max-width: 1000px;
|
||||
position: relative;
|
||||
|
||||
& .left, & .right {
|
||||
position: fixed;
|
||||
padding: 0 4rem 0 6rem;
|
||||
max-width: 30vw;
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
box-sizing: border-box;
|
||||
top: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0;
|
||||
gap: 2rem;
|
||||
padding: 6rem;
|
||||
}
|
||||
|
||||
& .left {
|
||||
left: 0;
|
||||
padding-left: 10vw;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
& .right {
|
||||
right: 0;
|
||||
padding-right: 10vw;
|
||||
width: 35vw;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1200px) {
|
||||
padding: 25px 5vw;
|
||||
margin: 25px 5vw;
|
||||
& .left, & .right {
|
||||
padding: 0;
|
||||
height: initial;
|
||||
max-width: none;
|
||||
position: initial;
|
||||
}
|
||||
|
@ -247,3 +253,7 @@ audio, video {
|
|||
width: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue