First commit
This commit is contained in:
574
frontend/app/components/GraphView.vue
Normal file
574
frontend/app/components/GraphView.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user