Files
mathew/frontend/app/components/GraphView.vue
2026-09-30 01:49:35 +02:00

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>