575 lines
16 KiB
Vue
575 lines
16 KiB
Vue
<script setup lang="ts">
|
|
/* The link map, Obsidian-style: every article is a node, every link from
|
|
one article's text into another is an edge between them. A small
|
|
hand-rolled force simulation (pairwise repulsion, springs along the
|
|
edges, a gentle pull to the centre) settles the nodes into a shape,
|
|
and everything is painted on one canvas: drag the background to pan,
|
|
the wheel to zoom, a node to pull it around, and let go of a node
|
|
where it was pressed to open that article. */
|
|
|
|
export interface GraphNodeDto {
|
|
slug: string
|
|
title: string
|
|
tags?: string[]
|
|
links: number
|
|
}
|
|
|
|
export interface GraphEdgeDto {
|
|
source: string
|
|
target: string
|
|
}
|
|
|
|
const props = defineProps<{ nodes: GraphNodeDto[]; edges: GraphEdgeDto[] }>()
|
|
|
|
const router = useRouter()
|
|
const wrapEl = ref<HTMLElement | null>(null)
|
|
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
|
const cursor = ref<'grab' | 'grabbing' | 'pointer'>('grab')
|
|
const hoverTitle = ref('')
|
|
const { t } = useI18n()
|
|
|
|
/* the simulation is plain mutable state — it ticks every frame, and
|
|
Vue would only slow it down */
|
|
interface SimNode {
|
|
slug: string
|
|
title: string
|
|
deg: number
|
|
x: number
|
|
y: number
|
|
vx: number
|
|
vy: number
|
|
r: number
|
|
fx: number | null
|
|
fy: number | null
|
|
}
|
|
interface SimLink {
|
|
a: SimNode
|
|
b: SimNode
|
|
rest: number
|
|
strength: number
|
|
bias: number
|
|
}
|
|
|
|
let simNodes: SimNode[] = []
|
|
let simLinks: SimLink[] = []
|
|
let bySlug = new Map<string, SimNode>()
|
|
let neighbours = new Map<string, Set<string>>()
|
|
let hubCut = 0 // articles with at least this many links always wear their label
|
|
|
|
let width = 0
|
|
let height = 0
|
|
let dpr = 1
|
|
|
|
/* the view: world point p draws at p * view.k + view.x/y */
|
|
const view = { k: 1, x: 0, y: 0 }
|
|
let viewTarget: { k: number; x: number; y: number } | null = null
|
|
|
|
const ALPHA_MIN = 0.002
|
|
const ALPHA_DECAY = 0.021
|
|
const VEL_DECAY = 0.55
|
|
const REPULSE = 1100
|
|
const GRAVITY = 0.055
|
|
const MIN_K = 0.15
|
|
const MAX_K = 5
|
|
|
|
let alpha = 1
|
|
let alphaTarget = 0
|
|
let raf = 0
|
|
|
|
let hoverNode: SimNode | null = null
|
|
let dragNode: SimNode | null = null
|
|
let panning = false
|
|
let movedFar = false
|
|
let lastPt = { x: 0, y: 0 }
|
|
let downPt = { x: 0, y: 0 }
|
|
|
|
/* canvas colours follow the site theme, including accent switches */
|
|
const colors = {
|
|
edge: '#888',
|
|
accent: '#4f5fe8',
|
|
accent2: '#0ea5c9',
|
|
halo: '#ffffff',
|
|
label: '#5b6478',
|
|
text: '#1b2130',
|
|
}
|
|
function readColors() {
|
|
const cs = getComputedStyle(document.documentElement)
|
|
const at = (name: string, fallback: string) =>
|
|
cs.getPropertyValue(name).trim() || fallback
|
|
colors.edge = at('--border-strong', colors.edge)
|
|
colors.accent = at('--accent', colors.accent)
|
|
colors.accent2 = at('--accent-2', colors.accent2)
|
|
colors.halo = at('--bg', colors.halo)
|
|
colors.label = at('--muted', colors.label)
|
|
colors.text = at('--text', colors.text)
|
|
}
|
|
|
|
const toWorldX = (px: number) => (px - view.x) / view.k
|
|
const toWorldY = (py: number) => (py - view.y) / view.k
|
|
|
|
function build() {
|
|
bySlug = new Map()
|
|
neighbours = new Map()
|
|
simNodes = props.nodes.map((n, i) => {
|
|
/* a golden-angle spiral start: no two nodes on top of each other,
|
|
and the repulsion unfolds it into a disc from there */
|
|
const angle = i * 2.399963229728653
|
|
const radius = 26 * Math.sqrt(i + 1)
|
|
const node: SimNode = {
|
|
slug: n.slug,
|
|
title: n.title,
|
|
deg: Math.max(n.links ?? 0, 0),
|
|
x: radius * Math.cos(angle),
|
|
y: radius * Math.sin(angle),
|
|
vx: 0,
|
|
vy: 0,
|
|
r: 4 + Math.sqrt(Math.max(n.links ?? 0, 0)) * 2.4,
|
|
fx: null,
|
|
fy: null,
|
|
}
|
|
bySlug.set(n.slug, node)
|
|
neighbours.set(n.slug, new Set())
|
|
return node
|
|
})
|
|
|
|
simLinks = []
|
|
for (const e of props.edges) {
|
|
const a = bySlug.get(e.source)
|
|
const b = bySlug.get(e.target)
|
|
if (!a || !b) continue
|
|
neighbours.get(a.slug)?.add(b.slug)
|
|
neighbours.get(b.slug)?.add(a.slug)
|
|
simLinks.push({
|
|
a,
|
|
b,
|
|
rest: 58 + a.r + b.r,
|
|
/* d3's rule: an edge pulls hardest when both ends are lonely,
|
|
and hardly at all when either is a hub */
|
|
strength: 1 / Math.max(Math.min(a.deg, b.deg), 1),
|
|
bias: a.deg / Math.max(a.deg + b.deg, 1),
|
|
})
|
|
}
|
|
|
|
/* small graphs wear every label; bigger ones only the well-linked
|
|
hubs — until the zoom gets close enough to read the rest */
|
|
if (simNodes.length <= 24) hubCut = 0
|
|
else {
|
|
const degs = simNodes.map((n) => n.deg).sort((x, y) => y - x)
|
|
hubCut = Math.max(2, degs[11] ?? 2)
|
|
}
|
|
}
|
|
|
|
function tick() {
|
|
alpha += (alphaTarget - alpha) * ALPHA_DECAY
|
|
|
|
for (let i = 0; i < simNodes.length; i++) {
|
|
const a = simNodes[i]!
|
|
for (let j = i + 1; j < simNodes.length; j++) {
|
|
const b = simNodes[j]!
|
|
let dx = a.x - b.x
|
|
let dy = a.y - b.y
|
|
let d2 = dx * dx + dy * dy
|
|
if (d2 === 0) {
|
|
dx = Math.random() - 0.5
|
|
dy = Math.random() - 0.5
|
|
d2 = dx * dx + dy * dy
|
|
}
|
|
const w = (REPULSE * alpha) / Math.max(d2, 40)
|
|
a.vx += dx * w
|
|
a.vy += dy * w
|
|
b.vx -= dx * w
|
|
b.vy -= dy * w
|
|
}
|
|
}
|
|
|
|
for (const l of simLinks) {
|
|
const dx = l.b.x - l.a.x
|
|
const dy = l.b.y - l.a.y
|
|
const d = Math.max(Math.hypot(dx, dy), 0.01)
|
|
const f = ((d - l.rest) / d) * alpha * l.strength
|
|
const fx = dx * f
|
|
const fy = dy * f
|
|
l.b.vx -= fx * l.bias
|
|
l.b.vy -= fy * l.bias
|
|
l.a.vx += fx * (1 - l.bias)
|
|
l.a.vy += fy * (1 - l.bias)
|
|
}
|
|
|
|
for (const n of simNodes) {
|
|
n.vx -= n.x * GRAVITY * alpha
|
|
n.vy -= n.y * GRAVITY * alpha
|
|
if (n.fx !== null) {
|
|
n.x = n.fx
|
|
n.vx = 0
|
|
} else {
|
|
n.vx *= VEL_DECAY
|
|
n.x += n.vx
|
|
}
|
|
if (n.fy !== null) {
|
|
n.y = n.fy
|
|
n.vy = 0
|
|
} else {
|
|
n.vy *= VEL_DECAY
|
|
n.y += n.vy
|
|
}
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
const ctx = canvasEl.value?.getContext('2d')
|
|
if (!ctx || width === 0) return
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
ctx.clearRect(0, 0, width, height)
|
|
|
|
const sx = (wx: number) => wx * view.k + view.x
|
|
const sy = (wy: number) => wy * view.k + view.y
|
|
const focus = hoverNode ?? dragNode
|
|
const lit = focus ? new Set([focus.slug, ...(neighbours.get(focus.slug) ?? [])]) : null
|
|
|
|
/* edges first: under the focus all others recede into the background */
|
|
ctx.lineCap = 'round'
|
|
for (const l of simLinks) {
|
|
const active = !!focus && (l.a === focus || l.b === focus)
|
|
if (!active) {
|
|
ctx.globalAlpha = focus ? 0.05 : 0.5
|
|
ctx.strokeStyle = colors.edge
|
|
ctx.lineWidth = 1
|
|
} else {
|
|
ctx.globalAlpha = 0.9
|
|
ctx.strokeStyle = colors.accent
|
|
ctx.lineWidth = 1.5
|
|
}
|
|
ctx.beginPath()
|
|
ctx.moveTo(sx(l.a.x), sy(l.a.y))
|
|
ctx.lineTo(sx(l.b.x), sy(l.b.y))
|
|
ctx.stroke()
|
|
}
|
|
|
|
const paintNode = (n: SimNode) => {
|
|
const x = sx(n.x)
|
|
const y = sy(n.y)
|
|
if (x < -30 || y < -30 || x > width + 30 || y > height + 30) return
|
|
const r = Math.max(n.r * view.k, 2.5)
|
|
const dimmed = !!focus && !lit?.has(n.slug)
|
|
const isFocus = n === focus
|
|
|
|
ctx.globalAlpha = dimmed ? 0.16 : 1
|
|
ctx.fillStyle = isFocus ? colors.accent2 : colors.accent
|
|
ctx.beginPath()
|
|
ctx.arc(x, y, r, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
ctx.globalAlpha = dimmed ? 0.1 : 1
|
|
ctx.lineWidth = 1.5
|
|
ctx.strokeStyle = colors.halo
|
|
ctx.stroke()
|
|
if (isFocus) {
|
|
ctx.globalAlpha = 0.35
|
|
ctx.strokeStyle = colors.accent
|
|
ctx.beginPath()
|
|
ctx.arc(x, y, r + 4.5, 0, Math.PI * 2)
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
|
|
/* the nodes in a glow sit above the receded ones */
|
|
for (const n of simNodes) if (!lit?.has(n.slug)) paintNode(n)
|
|
if (focus && lit) {
|
|
for (const n of simNodes) if (lit.has(n.slug) && n !== focus) paintNode(n)
|
|
paintNode(focus)
|
|
}
|
|
|
|
/* labels in screen space so they stay the same size at every zoom */
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'top'
|
|
for (const n of simNodes) {
|
|
const isLit = !!focus && lit?.has(n.slug)
|
|
if (!isLit && n.deg < hubCut && view.k < 1.35) continue
|
|
const x = sx(n.x)
|
|
const y = sy(n.y)
|
|
if (x < -60 || y < -30 || x > width + 60 || y > height + 20) continue
|
|
const r = Math.max(n.r * view.k, 2.5)
|
|
ctx.globalAlpha = focus && !isLit ? 0.25 : 1
|
|
ctx.font = `500 ${n === focus ? 13 : 11.5}px 'Computer Modern', Georgia, serif`
|
|
ctx.lineWidth = 3.5
|
|
ctx.lineJoin = 'round'
|
|
ctx.strokeStyle = colors.halo
|
|
ctx.strokeText(n.title, x, y + r + 4)
|
|
ctx.fillStyle = n === focus ? colors.text : colors.label
|
|
ctx.fillText(n.title, x, y + r + 4)
|
|
}
|
|
ctx.globalAlpha = 1
|
|
}
|
|
|
|
function frame() {
|
|
if (viewTarget) {
|
|
view.k += (viewTarget.k - view.k) * 0.18
|
|
view.x += (viewTarget.x - view.x) * 0.18
|
|
view.y += (viewTarget.y - view.y) * 0.18
|
|
const settled =
|
|
Math.abs(viewTarget.k - view.k) < 0.001 &&
|
|
Math.abs(viewTarget.x - view.x) < 0.5 &&
|
|
Math.abs(viewTarget.y - view.y) < 0.5
|
|
if (settled) viewTarget = null
|
|
}
|
|
if (alpha > ALPHA_MIN || alphaTarget > 0 || dragNode) tick()
|
|
draw()
|
|
raf = requestAnimationFrame(frame)
|
|
}
|
|
|
|
/* the view fits the whole graph, labels given room, capped so a lone
|
|
dot doesn't fill the screen */
|
|
function fitView() {
|
|
if (!simNodes.length || !width || !height) return
|
|
let minX = Infinity
|
|
let minY = Infinity
|
|
let maxX = -Infinity
|
|
let maxY = -Infinity
|
|
for (const n of simNodes) {
|
|
minX = Math.min(minX, n.x - n.r)
|
|
minY = Math.min(minY, n.y - n.r)
|
|
maxX = Math.max(maxX, n.x + n.r)
|
|
maxY = Math.max(maxY, n.y + n.r)
|
|
}
|
|
const padX = 90
|
|
const padTop = 40
|
|
const padBottom = 72
|
|
const bw = Math.max(maxX - minX, 40)
|
|
const bh = Math.max(maxY - minY, 40)
|
|
const k = Math.min(
|
|
(width - padX * 2) / bw,
|
|
(height - padTop - padBottom) / bh,
|
|
1.8
|
|
)
|
|
viewTarget = {
|
|
k,
|
|
x: width / 2 - ((minX + maxX) / 2) * k,
|
|
y: (padTop + height) / 2 - ((minY + maxY) / 2) * k,
|
|
}
|
|
}
|
|
|
|
function zoomAt(px: number, py: number, factor: number) {
|
|
const k = Math.min(Math.max(view.k * factor, MIN_K), MAX_K)
|
|
/* keep the world point under (px, py) anchored — the offsets are
|
|
computed against the old scale, so k goes in last */
|
|
view.x = px - ((px - view.x) / view.k) * k
|
|
view.y = py - ((py - view.y) / view.k) * k
|
|
view.k = k
|
|
viewTarget = null
|
|
}
|
|
|
|
function zoomBy(factor: number) {
|
|
zoomAt(width / 2, height / 2, factor)
|
|
}
|
|
|
|
function reheat() {
|
|
alpha = 1
|
|
alphaTarget = 0
|
|
}
|
|
|
|
function localPoint(e: PointerEvent | WheelEvent) {
|
|
const rect = canvasEl.value!.getBoundingClientRect()
|
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
}
|
|
|
|
function pick(px: number, py: number): SimNode | null {
|
|
for (let i = simNodes.length - 1; i >= 0; i--) {
|
|
const n = simNodes[i]!
|
|
const r = Math.max(n.r * view.k, 2.5) + 3
|
|
const dx = px - (n.x * view.k + view.x)
|
|
const dy = py - (n.y * view.k + view.y)
|
|
if (dx * dx + dy * dy <= r * r) return n
|
|
}
|
|
return null
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
const p = localPoint(e)
|
|
canvasEl.value?.setPointerCapture(e.pointerId)
|
|
lastPt = downPt = p
|
|
movedFar = false
|
|
const hit = pick(p.x, p.y)
|
|
if (hit) {
|
|
dragNode = hit
|
|
hit.fx = toWorldX(p.x)
|
|
hit.fy = toWorldY(p.y)
|
|
alphaTarget = 0.3
|
|
alpha = Math.max(alpha, 0.12)
|
|
cursor.value = 'grabbing'
|
|
} else {
|
|
panning = true
|
|
cursor.value = 'grabbing'
|
|
}
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
const p = localPoint(e)
|
|
if (dragNode) {
|
|
dragNode.fx = toWorldX(p.x)
|
|
dragNode.fy = toWorldY(p.y)
|
|
} else if (panning) {
|
|
view.x += p.x - lastPt.x
|
|
view.y += p.y - lastPt.y
|
|
viewTarget = null
|
|
} else {
|
|
hoverNode = pick(p.x, p.y)
|
|
hoverTitle.value = hoverNode?.title ?? ''
|
|
cursor.value = hoverNode ? 'pointer' : 'grab'
|
|
return
|
|
}
|
|
if (Math.hypot(p.x - downPt.x, p.y - downPt.y) > 4) movedFar = true
|
|
lastPt = p
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
const p = localPoint(e)
|
|
if (dragNode) {
|
|
const opened = dragNode
|
|
dragNode.fx = null
|
|
dragNode.fy = null
|
|
dragNode = null
|
|
alphaTarget = 0
|
|
/* pressed and released without a drag: open the article, the way
|
|
Obsidian jumps to the note behind the node */
|
|
if (!movedFar) {
|
|
e.preventDefault()
|
|
router.push(`/wiki/${opened.slug}`)
|
|
return
|
|
}
|
|
}
|
|
panning = false
|
|
const still = pick(p.x, p.y)
|
|
hoverNode = still
|
|
hoverTitle.value = still?.title ?? ''
|
|
cursor.value = still ? 'pointer' : 'grab'
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
e.preventDefault()
|
|
const p = localPoint(e)
|
|
let dy = e.deltaY
|
|
if (e.deltaMode === 1) dy *= 16
|
|
else if (e.deltaMode === 2) dy *= 100
|
|
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
|
|
const step = e.ctrlKey ? 0.004 : 0.0016
|
|
zoomAt(p.x, p.y, Math.exp(-dy * step))
|
|
}
|
|
|
|
function onDoubleClick() {
|
|
fitView()
|
|
}
|
|
|
|
function clearHover() {
|
|
if (dragNode || panning) return
|
|
hoverNode = null
|
|
hoverTitle.value = ''
|
|
cursor.value = 'grab'
|
|
}
|
|
|
|
function onPointerCancel() {
|
|
/* the browser took the gesture over — release everything, never open */
|
|
if (dragNode) {
|
|
dragNode.fx = null
|
|
dragNode.fy = null
|
|
dragNode = null
|
|
}
|
|
alphaTarget = 0
|
|
panning = false
|
|
clearHover()
|
|
}
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
let themeObserver: MutationObserver | null = null
|
|
|
|
onMounted(() => {
|
|
readColors()
|
|
const wrap = wrapEl.value!
|
|
const canvas = canvasEl.value!
|
|
|
|
const resize = () => {
|
|
const rect = wrap.getBoundingClientRect()
|
|
width = Math.max(rect.width, 1)
|
|
height = Math.max(rect.height, 1)
|
|
dpr = window.devicePixelRatio || 1
|
|
canvas.width = Math.round(width * dpr)
|
|
canvas.height = Math.round(height * dpr)
|
|
}
|
|
resize()
|
|
resizeObserver = new ResizeObserver(resize)
|
|
resizeObserver.observe(wrap)
|
|
|
|
themeObserver = new MutationObserver(readColors)
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme', 'data-accent'],
|
|
})
|
|
build()
|
|
/* a few silent ticks so the first painted frame is already a shape */
|
|
for (let i = 0; i < 40; i++) tick()
|
|
fitView()
|
|
raf = requestAnimationFrame(frame)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
cancelAnimationFrame(raf)
|
|
resizeObserver?.disconnect()
|
|
themeObserver?.disconnect()
|
|
})
|
|
|
|
/* a fresh fetch (a new article, a new link) rebuilds the map */
|
|
watch(
|
|
() => [props.nodes, props.edges],
|
|
() => {
|
|
if (!import.meta.client) return
|
|
build()
|
|
alpha = 1
|
|
alphaTarget = 0
|
|
fitView()
|
|
}
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="wrapEl" class="graph-canvas-wrap">
|
|
<canvas
|
|
ref="canvasEl"
|
|
class="graph-canvas"
|
|
:style="{ cursor }"
|
|
:title="hoverTitle"
|
|
@pointerdown="onPointerDown"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="onPointerUp"
|
|
@pointercancel="onPointerCancel"
|
|
@wheel="onWheel"
|
|
@dblclick="onDoubleClick"
|
|
@pointerleave="clearHover"
|
|
/>
|
|
<div class="graph-toolbar">
|
|
<button type="button" class="panel-icon-btn" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="fitView">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
|
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
|
|
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
|
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
</svg>
|
|
</button>
|
|
<button type="button" class="panel-icon-btn" :title="t('graph.zoomIn')" :aria-label="t('graph.zoomIn')" @click="zoomBy(1.35)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
<button type="button" class="panel-icon-btn" :title="t('graph.zoomOut')" :aria-label="t('graph.zoomOut')" @click="zoomBy(1 / 1.35)">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
<path d="M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
<button type="button" class="panel-icon-btn" :title="t('graph.relayout')" :aria-label="t('graph.relayout')" @click="reheat">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
|
|
<path d="M21 3v5h-5" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|