@kolkrabbi/kol-workshop 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -8
- package/src/docs/DocsFrontmatter.jsx +10 -4
- package/src/docs/DocumentationReader.jsx +21 -4
- package/src/exhibit/ExhibitCard.jsx +1 -1
- package/src/exhibit/ExhibitLinkCard.jsx +1 -1
- package/src/exhibit/ExhibitOverview.jsx +1 -1
- package/src/exhibit/ExhibitSidebar.jsx +1 -1
- package/src/search/ResultRow.jsx +6 -4
- package/src/search/SearchPage.jsx +103 -68
- package/src/shell/RailSection.jsx +5 -3
- package/src/shell/RightRail.jsx +43 -22
- package/src/shell/ShellLayout.jsx +54 -12
- package/src/shell/ShellSidebar.jsx +89 -57
- package/src/tags/TagGraph.jsx +180 -165
package/src/tags/TagGraph.jsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useRef, useMemo, useState } from 'react'
|
|
2
2
|
import * as d3 from 'd3'
|
|
3
|
+
import { gsap } from 'gsap'
|
|
3
4
|
import { useNavigate } from 'react-router-dom'
|
|
4
5
|
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
5
6
|
import { tagGraph } from '@kolkrabbi/kol-search'
|
|
7
|
+
import { EASE, DURATION, s } from '@kolkrabbi/kol-component/utilities/motion'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* TagGraph - Obsidian-style tag graph
|
|
@@ -12,209 +14,227 @@ import { tagGraph } from '@kolkrabbi/kol-search'
|
|
|
12
14
|
*
|
|
13
15
|
* `tagHref(tag)` builds the navigation target when no `onTagClick` is provided
|
|
14
16
|
* (route-decoupled — no hardcoded workshop paths here).
|
|
17
|
+
*
|
|
18
|
+
* REBUILT (the showcase review W14, 2026-09-30 — user: *"the node graph is fucked, it needs better
|
|
19
|
+
* gsap and physics. asap."*). What was wrong, and what replaced it:
|
|
20
|
+
*
|
|
21
|
+
* - ONE SIMULATION. Every hover and every active-tag change wiped the SVG and restarted the
|
|
22
|
+
* simulation from random positions, so the graph re-exploded on each interaction. The layout now
|
|
23
|
+
* builds once per data/size; highlight is a separate, tweened pass over the same elements.
|
|
24
|
+
* - PHYSICS THAT SCALES. Fixed numbers (charge −150, link 60, collide 30) whatever the graph held.
|
|
25
|
+
* Now: charge by node size and capped in range, link length and pull by how many pages two tags
|
|
26
|
+
* share, a gentle gravity so the cluster stays centred, collision from the drawn radius. Seeded on
|
|
27
|
+
* a phyllotaxis spiral and settled BEFORE the first frame, so it opens composed, not exploding.
|
|
28
|
+
* - MOTION ON THE HOUSE CURVE. Nodes grow in from the centre outward, edges fade in, the view eases
|
|
29
|
+
* to fit; hovering a tag brings it and its neighbours forward and dims the rest. Durations and
|
|
30
|
+
* curves are kol-component's motion constants; reduced motion skips the tweens.
|
|
31
|
+
* - LABELS WHERE THEY HELP. Every node printed its name; now the larger tags carry a label at rest
|
|
32
|
+
* and the rest show theirs when focused.
|
|
15
33
|
*/
|
|
16
34
|
const defaultTagHref = (tag) => `/workshop/design-system/documentation?tag=${encodeURIComponent(tag)}`
|
|
17
35
|
|
|
36
|
+
const radiusOf = (d) => Math.max(5, Math.min(14, 4 + Math.sqrt(d.count) * 2.2))
|
|
37
|
+
const fillOf = (d) => {
|
|
38
|
+
/* palette tokens, not copies; `dark` is a surface role with no palette entry */
|
|
39
|
+
const key = getTagColor(d.id)
|
|
40
|
+
return key === 'dark' ? 'var(--kol-surface-on-primary)' : `var(--kol-palette-${key}, var(--kol-palette-teal))`
|
|
41
|
+
}
|
|
42
|
+
const EDGE = 'color-mix(in srgb, var(--kol-surface-on-primary) 24%, transparent)'
|
|
43
|
+
const ACCENT = 'var(--kol-accent-primary)'
|
|
44
|
+
|
|
18
45
|
const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHref }) => {
|
|
19
46
|
const svgRef = useRef(null)
|
|
20
47
|
const containerRef = useRef(null)
|
|
48
|
+
const sceneRef = useRef(null)
|
|
21
49
|
const navigate = useNavigate()
|
|
22
50
|
const [dimensions, setDimensions] = useState({ width: 300, height: 300 })
|
|
23
51
|
const [hoveredNode, setHoveredNode] = useState(null)
|
|
52
|
+
const activeRef = useRef(activeTag)
|
|
53
|
+
activeRef.current = activeTag
|
|
24
54
|
|
|
25
|
-
// Use allDocs if provided, otherwise fall back to docs
|
|
26
55
|
const sourceDocs = allDocs || docs
|
|
27
|
-
|
|
28
|
-
// Compute graph data: ALL tags with connections (pure — see engine/tags.js)
|
|
29
56
|
/* the engine's graph (kol-search `tagGraph`, 2026-09-29) over the docs' tags */
|
|
30
57
|
const graphData = useMemo(() => tagGraph(sourceDocs.map((d) => ({ id: d.id, tags: d.metadata?.tags ?? [] }))), [sourceDocs])
|
|
31
58
|
|
|
32
|
-
// Update dimensions on resize
|
|
33
59
|
useEffect(() => {
|
|
34
|
-
const
|
|
35
|
-
if (containerRef.current)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
60
|
+
const update = () => {
|
|
61
|
+
if (!containerRef.current) return
|
|
62
|
+
const { width } = containerRef.current.getBoundingClientRect()
|
|
63
|
+
const size = Math.max(250, width)
|
|
64
|
+
setDimensions((d) => (d.width === size ? d : { width: size, height: size }))
|
|
40
65
|
}
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
if (containerRef.current)
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
return () => resizeObserver.disconnect()
|
|
66
|
+
update()
|
|
67
|
+
const ro = new ResizeObserver(update)
|
|
68
|
+
if (containerRef.current) ro.observe(containerRef.current)
|
|
69
|
+
return () => ro.disconnect()
|
|
47
70
|
}, [])
|
|
48
71
|
|
|
49
|
-
|
|
72
|
+
/* ── THE LAYOUT — once per data and size ─────────────────────────────────── */
|
|
50
73
|
useEffect(() => {
|
|
51
74
|
if (!svgRef.current || graphData.nodes.length === 0) return
|
|
52
|
-
|
|
53
75
|
const { width, height } = dimensions
|
|
76
|
+
const reduced = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
77
|
+
const t = (ms) => (reduced ? 0 : s(ms))
|
|
54
78
|
|
|
55
|
-
// Clear previous
|
|
56
79
|
const svg = d3.select(svgRef.current)
|
|
57
80
|
svg.selectAll('*').remove()
|
|
58
|
-
|
|
59
|
-
// Create container group
|
|
60
81
|
const g = svg.append('g')
|
|
82
|
+
const zoom = d3.zoom().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform))
|
|
83
|
+
svg.call(zoom).on('dblclick.zoom', null)
|
|
61
84
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const link = g.append('g')
|
|
83
|
-
.attr('class', 'links')
|
|
84
|
-
.selectAll('line')
|
|
85
|
-
.data(links)
|
|
86
|
-
.enter()
|
|
87
|
-
.append('line')
|
|
88
|
-
.attr('stroke', d => {
|
|
89
|
-
// Highlight edges connected to active tag
|
|
90
|
-
if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
|
|
91
|
-
return 'var(--kol-accent-primary)'
|
|
92
|
-
}
|
|
93
|
-
return 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)'
|
|
94
|
-
})
|
|
95
|
-
.attr('stroke-width', d => {
|
|
96
|
-
if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
|
|
97
|
-
return 2
|
|
98
|
-
}
|
|
99
|
-
return Math.min(2, d.weight * 0.5)
|
|
100
|
-
})
|
|
101
|
-
.attr('opacity', d => {
|
|
102
|
-
if (!activeTag) return 0.6
|
|
103
|
-
return (d.source.id === activeTag || d.target.id === activeTag) ? 1 : 0.2
|
|
104
|
-
})
|
|
85
|
+
const nodes = graphData.nodes.map((d) => ({ ...d }))
|
|
86
|
+
const links = graphData.edges.map((d) => ({ ...d }))
|
|
87
|
+
const maxW = d3.max(links, (l) => l.weight) || 1
|
|
88
|
+
const counts = nodes.map((d) => d.count).sort((a, b) => b - a)
|
|
89
|
+
/* the eight biggest tags keep their label at rest; the rest show on focus */
|
|
90
|
+
const labelFloor = counts[Math.min(7, counts.length - 1)] ?? 0
|
|
91
|
+
|
|
92
|
+
/* neighbours, for focus */
|
|
93
|
+
const near = new Map(nodes.map((d) => [d.id, new Set([d.id])]))
|
|
94
|
+
for (const l of graphData.edges) { near.get(l.source)?.add(l.target); near.get(l.target)?.add(l.source) }
|
|
95
|
+
|
|
96
|
+
/* seed on a phyllotaxis spiral, biggest first — deterministic, and already roughly in place */
|
|
97
|
+
const cx = width / 2
|
|
98
|
+
const cy = height / 2
|
|
99
|
+
;[...nodes].sort((a, b) => b.count - a.count).forEach((d, i) => {
|
|
100
|
+
const r = 10 * Math.sqrt(i + 0.5)
|
|
101
|
+
const a = i * 2.39996
|
|
102
|
+
d.x = cx + r * Math.cos(a)
|
|
103
|
+
d.y = cy + r * Math.sin(a)
|
|
104
|
+
})
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.
|
|
112
|
-
.
|
|
106
|
+
const span = Math.min(width, height)
|
|
107
|
+
const sim = d3.forceSimulation(nodes)
|
|
108
|
+
.force('link', d3.forceLink(links).id((d) => d.id)
|
|
109
|
+
.distance((l) => 45 + 95 * (1 - l.weight / maxW))
|
|
110
|
+
.strength((l) => 0.08 + 0.4 * (l.weight / maxW)))
|
|
111
|
+
.force('charge', d3.forceManyBody().strength((d) => -70 - radiusOf(d) * 16).distanceMax(span * 0.8))
|
|
112
|
+
.force('x', d3.forceX(cx).strength(0.07))
|
|
113
|
+
.force('y', d3.forceY(cy).strength(0.07))
|
|
114
|
+
.force('collide', d3.forceCollide((d) => radiusOf(d) + 9).iterations(2))
|
|
115
|
+
.velocityDecay(0.38)
|
|
116
|
+
.alphaDecay(0.035)
|
|
117
|
+
.stop()
|
|
118
|
+
/* settle before the first frame — it opens composed, not exploding */
|
|
119
|
+
for (let i = 0; i < 260; i++) sim.tick()
|
|
120
|
+
|
|
121
|
+
const link = g.append('g').attr('class', 'links').selectAll('line').data(links).enter().append('line')
|
|
122
|
+
.attr('stroke', EDGE)
|
|
123
|
+
.attr('stroke-width', (l) => 0.6 + 1.6 * (l.weight / maxW))
|
|
124
|
+
.attr('stroke-linecap', 'round')
|
|
125
|
+
.attr('opacity', 0)
|
|
126
|
+
|
|
127
|
+
const node = g.append('g').attr('class', 'nodes').selectAll('g').data(nodes).enter().append('g')
|
|
113
128
|
.attr('class', 'node')
|
|
114
129
|
.style('cursor', 'pointer')
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
* the day they were typed and frozen ever since, so a retuned palette
|
|
126
|
-
* would move every chip and leave the graph behind. The chip rules in
|
|
127
|
-
* kol-components-molecules.css resolve the variables; the graph now
|
|
128
|
-
* reads the same ones. `dark` has no palette entry — it is a surface
|
|
129
|
-
* role — so it falls back rather than emitting an invalid var(). */
|
|
130
|
-
.attr('fill', d => {
|
|
131
|
-
const key = getTagColor(d.id)
|
|
132
|
-
return key === 'dark'
|
|
133
|
-
? 'var(--kol-surface-on-primary)'
|
|
134
|
-
: `var(--kol-palette-${key}, var(--kol-palette-teal))`
|
|
135
|
-
})
|
|
136
|
-
.attr('opacity', d => activeTag && d.id !== activeTag ? 0.4 : 0.9)
|
|
137
|
-
.attr('stroke', d => d.id === activeTag ? 'var(--kol-accent-primary)' : 'none')
|
|
138
|
-
.attr('stroke-width', d => d.id === activeTag ? 2 : 0)
|
|
139
|
-
|
|
140
|
-
// Node labels
|
|
141
|
-
node.append('text')
|
|
142
|
-
.text(d => `#${d.id}`)
|
|
143
|
-
.attr('dy', d => Math.max(8, Math.min(16, 6 + d.count * 2)) + 12)
|
|
130
|
+
|
|
131
|
+
const circle = node.append('circle')
|
|
132
|
+
.attr('r', 0)
|
|
133
|
+
.attr('fill', fillOf)
|
|
134
|
+
.attr('stroke', 'var(--kol-surface-primary)')
|
|
135
|
+
.attr('stroke-width', 1.5)
|
|
136
|
+
|
|
137
|
+
const label = node.append('text')
|
|
138
|
+
.text((d) => `#${d.id}`)
|
|
139
|
+
.attr('dy', (d) => radiusOf(d) + 12)
|
|
144
140
|
.attr('text-anchor', 'middle')
|
|
145
|
-
.attr('fill', '
|
|
141
|
+
.attr('fill', 'var(--kol-fg-72)')
|
|
146
142
|
.attr('font-family', 'var(--kol-font-family-mono)')
|
|
147
143
|
.attr('font-size', '9px')
|
|
148
144
|
.attr('pointer-events', 'none')
|
|
145
|
+
.attr('opacity', 0)
|
|
149
146
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
147
|
+
const place = () => {
|
|
148
|
+
link.attr('x1', (d) => d.source.x).attr('y1', (d) => d.source.y).attr('x2', (d) => d.target.x).attr('y2', (d) => d.target.y)
|
|
149
|
+
node.attr('transform', (d) => `translate(${d.x}, ${d.y})`)
|
|
150
|
+
}
|
|
151
|
+
place()
|
|
152
|
+
sim.on('tick', place)
|
|
153
|
+
|
|
154
|
+
/* ── the resting look, and a focus — both tweened ── */
|
|
155
|
+
const restLabel = (d) => (d.count >= labelFloor ? 1 : 0)
|
|
156
|
+
const paint = (focusId) => {
|
|
157
|
+
const lit = focusId ? near.get(focusId) : null
|
|
158
|
+
const act = activeRef.current
|
|
159
|
+
gsap.to(circle.nodes(), {
|
|
160
|
+
duration: t(DURATION.slow),
|
|
161
|
+
ease: EASE.houseGsap,
|
|
162
|
+
opacity: (i) => (lit ? (lit.has(nodes[i].id) ? 1 : 0.15) : act && nodes[i].id !== act ? 0.45 : 0.95),
|
|
163
|
+
attr: { r: (i) => radiusOf(nodes[i]) * (nodes[i].id === focusId ? 1.3 : 1) },
|
|
164
|
+
overwrite: 'auto',
|
|
159
165
|
})
|
|
160
|
-
.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
.attr('stroke', l => l.source.id === d.id || l.target.id === d.id
|
|
166
|
-
? 'var(--kol-accent-primary)'
|
|
167
|
-
: 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)')
|
|
166
|
+
gsap.to(label.nodes(), {
|
|
167
|
+
duration: t(DURATION.slow),
|
|
168
|
+
ease: EASE.houseGsap,
|
|
169
|
+
opacity: (i) => (lit ? (lit.has(nodes[i].id) ? 1 : 0) : nodes[i].id === act ? 1 : restLabel(nodes[i])),
|
|
170
|
+
overwrite: 'auto',
|
|
168
171
|
})
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)'
|
|
181
|
-
})
|
|
172
|
+
gsap.to(link.nodes(), {
|
|
173
|
+
duration: t(DURATION.slow),
|
|
174
|
+
ease: EASE.houseGsap,
|
|
175
|
+
opacity: (i) => {
|
|
176
|
+
const l = links[i]
|
|
177
|
+
const hit = (id) => l.source.id === id || l.target.id === id
|
|
178
|
+
if (focusId) return hit(focusId) ? 1 : 0.06
|
|
179
|
+
if (act) return hit(act) ? 1 : 0.15
|
|
180
|
+
return 0.35
|
|
181
|
+
},
|
|
182
|
+
overwrite: 'auto',
|
|
182
183
|
})
|
|
184
|
+
/* the stroke is SET, not tweened — two color-mix()/var() strings do not interpolate */
|
|
185
|
+
link.attr('stroke', (l) => {
|
|
186
|
+
const id = focusId ?? act
|
|
187
|
+
return id && (l.source.id === id || l.target.id === id) ? ACCENT : EDGE
|
|
188
|
+
})
|
|
189
|
+
circle.attr('stroke', (d) => (d.id === act ? ACCENT : 'var(--kol-surface-primary)'))
|
|
190
|
+
}
|
|
183
191
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
/* ── the entrance: edges fade, nodes grow from the centre outward, the view eases to fit ── */
|
|
193
|
+
const dist = (d) => Math.hypot(d.x - cx, d.y - cy)
|
|
194
|
+
const maxDist = d3.max(nodes, dist) || 1
|
|
195
|
+
gsap.to(link.nodes(), { duration: t(DURATION.zoom), ease: EASE.houseGsap, opacity: 0.35 })
|
|
196
|
+
gsap.to(circle.nodes(), {
|
|
197
|
+
duration: t(DURATION.spring),
|
|
198
|
+
ease: 'back.out(1.6)',
|
|
199
|
+
attr: { r: (i) => radiusOf(nodes[i]) },
|
|
200
|
+
opacity: 0.95,
|
|
201
|
+
delay: (i) => (reduced ? 0 : (dist(nodes[i]) / maxDist) * 0.35),
|
|
202
|
+
onComplete: () => paint(null),
|
|
193
203
|
})
|
|
204
|
+
gsap.to(label.nodes(), { duration: t(DURATION.zoom), delay: t(300), ease: EASE.houseGsap, opacity: (i) => restLabel(nodes[i]) })
|
|
194
205
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
const [x0, x1] = d3.extent(nodes, (d) => d.x)
|
|
207
|
+
const [y0, y1] = d3.extent(nodes, (d) => d.y)
|
|
208
|
+
const pad = 36
|
|
209
|
+
const k = Math.min(3, 0.95 * Math.min(width / (x1 - x0 + pad * 2 || 1), height / (y1 - y0 + pad * 2 || 1)))
|
|
210
|
+
const fit = d3.zoomIdentity.translate(width / 2, height / 2).scale(k).translate(-(x0 + x1) / 2, -(y0 + y1) / 2)
|
|
211
|
+
svg.transition().duration(reduced ? 0 : DURATION.zoom).ease(d3.easeCubicInOut).call(zoom.transform, fit)
|
|
201
212
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
d.
|
|
210
|
-
|
|
211
|
-
|
|
213
|
+
/* ── interaction ── */
|
|
214
|
+
node
|
|
215
|
+
.on('click', (event, d) => {
|
|
216
|
+
event.stopPropagation()
|
|
217
|
+
if (onTagClick) onTagClick(d.id)
|
|
218
|
+
else navigate(tagHref(d.id))
|
|
219
|
+
})
|
|
220
|
+
.on('mouseenter', (event, d) => { setHoveredNode(d); paint(d.id) })
|
|
221
|
+
.on('mouseleave', () => { setHoveredNode(null); paint(null) })
|
|
222
|
+
.call(d3.drag()
|
|
223
|
+
.on('start', (event, d) => { if (!event.active) sim.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y })
|
|
224
|
+
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y })
|
|
225
|
+
.on('end', (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null }))
|
|
212
226
|
|
|
213
|
-
|
|
227
|
+
sceneRef.current = { paint }
|
|
214
228
|
return () => {
|
|
215
|
-
|
|
229
|
+
sim.stop()
|
|
230
|
+
gsap.killTweensOf([...circle.nodes(), ...label.nodes(), ...link.nodes()])
|
|
231
|
+
svg.interrupt()
|
|
232
|
+
sceneRef.current = null
|
|
216
233
|
}
|
|
217
|
-
}, [graphData, dimensions,
|
|
234
|
+
}, [graphData, dimensions, navigate, onTagClick, tagHref])
|
|
235
|
+
|
|
236
|
+
/* ── an active-tag change re-paints; it never rebuilds the layout ── */
|
|
237
|
+
useEffect(() => { sceneRef.current?.paint(null) }, [activeTag])
|
|
218
238
|
|
|
219
239
|
if (graphData.nodes.length === 0) {
|
|
220
240
|
return (
|
|
@@ -226,12 +246,7 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
|
|
|
226
246
|
|
|
227
247
|
return (
|
|
228
248
|
<div ref={containerRef} className="tag-graph-sidebar">
|
|
229
|
-
<svg
|
|
230
|
-
ref={svgRef}
|
|
231
|
-
width={dimensions.width}
|
|
232
|
-
height={dimensions.height}
|
|
233
|
-
className="tag-graph-svg"
|
|
234
|
-
/>
|
|
249
|
+
<svg ref={svgRef} width={dimensions.width} height={dimensions.height} className="tag-graph-svg" />
|
|
235
250
|
{hoveredNode && (
|
|
236
251
|
<div className="tag-graph-tooltip kol-helper-10 text-emphasis">
|
|
237
252
|
#{hoveredNode.id} ({hoveredNode.count} {hoveredNode.count === 1 ? 'doc' : 'docs'})
|