@kolkrabbi/kol-workshop 0.1.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.
@@ -0,0 +1,197 @@
1
+ import { createContext, useState, useEffect, Suspense } from 'react'
2
+ import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-icons'
4
+ import { ShellHeader } from '@kolkrabbi/kol-framework'
5
+ import ShellSidebar from './ShellSidebar.jsx'
6
+ import { ShellDrawer, ShellSearchOverlay } from '@kolkrabbi/kol-component'
7
+
8
+ // Pages can register right-rail TOC content via this context.
9
+ // Usage: const setTocContent = useContext(ShellTocContext)
10
+ // useLayoutEffect(() => { setTocContent(<MyToc />) ; return () => setTocContent(null) }, [])
11
+ export const ShellTocContext = createContext(null)
12
+
13
+ // Pages that need to fill the viewport (e.g. iframe embeds) can opt into full-height mode.
14
+ // Usage: const setFullHeight = useContext(ShellFullHeightContext)
15
+ // useLayoutEffect(() => { setFullHeight(true) ; return () => setFullHeight(false) }, [setFullHeight])
16
+ export const ShellFullHeightContext = createContext(null)
17
+
18
+ // Pages can request the right sidebar to start collapsed.
19
+ // Usage: const setTocCollapsed = useContext(ShellTocCollapsedContext)
20
+ // useLayoutEffect(() => { setTocCollapsed(true) ; return () => setTocCollapsed(false) }, [setTocCollapsed])
21
+ export const ShellTocCollapsedContext = createContext(null)
22
+
23
+ const NavColumn = ({ children }) => (
24
+ <aside className="hidden lg:block shrink-0 pt-6 md:pt-6 lg:pt-8">
25
+ <div className="shell-sidebar-sticky sticky top-8 max-h-[calc(100vh-8rem)] overflow-y-auto">
26
+ {children}
27
+ </div>
28
+ </aside>
29
+ )
30
+
31
+ const MainColumn = ({ children, fullHeight }) => (
32
+ <main className={`w-full min-w-0${fullHeight ? ' h-full flex flex-col' : ''}`}>
33
+ {fullHeight
34
+ ? children
35
+ : <div className="pt-6 md:pt-6 lg:pt-8 pb-8">{children}</div>
36
+ }
37
+ </main>
38
+ )
39
+
40
+ const TocColumn = ({ children }) => (
41
+ <aside className="hidden lg:block shrink-0 pt-6 md:pt-6 lg:pt-8">
42
+ <div className="sticky top-8 max-h-[calc(100vh-8rem)] overflow-y-auto">
43
+ {children}
44
+ </div>
45
+ </aside>
46
+ )
47
+
48
+ const ShellLayout = ({ routes = [], basePath = '/', brandLogoSrc, brandLogoAlt = '', renderSidebar, searchItems, defaultTocContent }) => {
49
+ const [isNavDrawerOpen, setIsNavDrawerOpen] = useState(false)
50
+ const [navCollapsed, setNavCollapsed] = useState(false)
51
+ const [tocCollapsed, setTocCollapsed] = useState(false)
52
+ const [isSearchOpen, setIsSearchOpen] = useState(false)
53
+ const [tocContent, setTocContent] = useState(null)
54
+ const [isFullHeight, setIsFullHeight] = useState(false)
55
+ const location = useLocation()
56
+ const navigate = useNavigate()
57
+
58
+ useEffect(() => {
59
+ const handleKeyDown = (e) => {
60
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
61
+ e.preventDefault()
62
+ setIsSearchOpen(true)
63
+ } else if ((e.altKey) && e.key === 'b') {
64
+ e.preventDefault()
65
+ setIsSearchOpen(true)
66
+ }
67
+ }
68
+ window.addEventListener('keydown', handleKeyDown)
69
+ return () => window.removeEventListener('keydown', handleKeyDown)
70
+ }, [])
71
+
72
+ const effectiveTocContent = tocContent ?? defaultTocContent
73
+ const hasToc = Boolean(effectiveTocContent)
74
+ const showNav = !navCollapsed
75
+ const showToc = hasToc && !tocCollapsed
76
+
77
+ const layoutType = showNav && showToc ? 'nav-toc' : showNav ? 'nav' : showToc ? 'toc' : 'none'
78
+
79
+ const gridCols = showNav
80
+ ? showToc
81
+ ? 'lg:grid-cols-[256px_minmax(0,1fr)] xl:grid-cols-[256px_minmax(0,1fr)_224px]'
82
+ : 'lg:grid-cols-[256px_minmax(0,1fr)]'
83
+ : showToc
84
+ ? 'xl:grid-cols-[minmax(0,1fr)_224px]'
85
+ : ''
86
+
87
+ // Adapt the old flat `routes` + callbacks to the DS ShellHeader API
88
+ // (brand node · nav[{label,href,icon}] · isActive · onNavigate · actions slot).
89
+ const joinPath = (p) => `${basePath.replace(/\/$/, '')}/${String(p ?? '').replace(/^\//, '')}`
90
+ const navItems = routes.map((r) => ({
91
+ label: r.label,
92
+ href: r.path ? joinPath(r.path) : basePath,
93
+ icon: r.icon,
94
+ }))
95
+ const isActive = (href) => location.pathname === href || location.pathname.startsWith(`${href}/`)
96
+ const handleNavigate = (event, item) => {
97
+ if (item.href) {
98
+ event.preventDefault()
99
+ navigate(item.href)
100
+ }
101
+ }
102
+ const brand = (
103
+ <Link to={basePath} className="shell-header-logo flex items-center gap-2">
104
+ {brandLogoSrc
105
+ ? <img src={brandLogoSrc} alt={brandLogoAlt} className="h-6 w-auto" />
106
+ : <span className="kol-helper-12 text-emphasis">{brandLogoAlt || 'Workshop'}</span>}
107
+ </Link>
108
+ )
109
+ const searchTrigger = (
110
+ <button
111
+ type="button"
112
+ onClick={() => setIsSearchOpen(true)}
113
+ aria-label="Search"
114
+ className="flex h-9 w-9 items-center justify-center rounded p-0 bg-transparent border-0 cursor-pointer transition-colors text-fg-64 hover:bg-fg-08 hover:text-emphasis"
115
+ >
116
+ <Icon name="search" size={18} />
117
+ </button>
118
+ )
119
+
120
+ return (
121
+ <ShellTocContext.Provider value={setTocContent}>
122
+ <ShellFullHeightContext.Provider value={setIsFullHeight}>
123
+ <ShellTocCollapsedContext.Provider value={setTocCollapsed}>
124
+ <div className="fixed inset-0 flex flex-col bg-surface-primary text-auto">
125
+ <ShellHeader
126
+ brand={brand}
127
+ nav={navItems}
128
+ isActive={isActive}
129
+ onNavigate={handleNavigate}
130
+ actions={searchTrigger}
131
+ onMenuClick={() => {
132
+ if (window.matchMedia('(min-width: 1024px)').matches) {
133
+ setNavCollapsed((p) => !p)
134
+ setTocCollapsed((p) => !p)
135
+ } else {
136
+ setIsNavDrawerOpen(true)
137
+ }
138
+ }}
139
+ onNavToggle={() => setNavCollapsed((p) => !p)}
140
+ onTocToggle={hasToc ? () => setTocCollapsed((p) => !p) : null}
141
+ navCollapsed={navCollapsed}
142
+ tocCollapsed={tocCollapsed}
143
+ />
144
+
145
+ <div className="flex-1 overflow-hidden">
146
+ <div className={`h-full ${isFullHeight ? 'overflow-hidden' : 'overflow-y-auto'}`} style={{ scrollbarGutter: 'stable' }}>
147
+ <div className={`w-full px-4 md:px-5 lg:px-6${isFullHeight ? ' h-full' : ' pb-16'}`}>
148
+ <div className={`shell-content-grid grid gap-8 ${gridCols}${isFullHeight ? ' h-full' : ''}`} data-layout={layoutType}>
149
+ {showNav && (
150
+ <NavColumn>
151
+ {renderSidebar ? renderSidebar({}) : <ShellSidebar routes={routes} basePath={basePath} />}
152
+ </NavColumn>
153
+ )}
154
+
155
+ <MainColumn fullHeight={isFullHeight}>
156
+ <div className={isFullHeight ? 'flex flex-col flex-1 min-h-0 [&>*]:flex-1 [&>*]:flex [&>*]:flex-col [&>*]:min-h-0' : ''}>
157
+ <Suspense fallback={<div className="flex items-center justify-center p-12 text-fg-48">Loading…</div>}>
158
+ <Outlet />
159
+ </Suspense>
160
+ </div>
161
+ </MainColumn>
162
+
163
+ {showToc && (
164
+ <TocColumn>
165
+ {effectiveTocContent}
166
+ </TocColumn>
167
+ )}
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <ShellDrawer
174
+ isOpen={isNavDrawerOpen}
175
+ onClose={() => setIsNavDrawerOpen(false)}
176
+ >
177
+ {renderSidebar
178
+ ? renderSidebar({ onNavigate: () => setIsNavDrawerOpen(false) })
179
+ : <ShellSidebar routes={routes} basePath={basePath} onNavigate={() => setIsNavDrawerOpen(false)} />
180
+ }
181
+ </ShellDrawer>
182
+
183
+ <ShellSearchOverlay
184
+ isOpen={isSearchOpen}
185
+ onClose={() => setIsSearchOpen(false)}
186
+ routes={routes}
187
+ basePath={basePath}
188
+ items={searchItems}
189
+ />
190
+ </div>
191
+ </ShellTocCollapsedContext.Provider>
192
+ </ShellFullHeightContext.Provider>
193
+ </ShellTocContext.Provider>
194
+ )
195
+ }
196
+
197
+ export default ShellLayout
@@ -0,0 +1,146 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { NavLink, Link, useLocation } from 'react-router-dom'
3
+ import { Icon } from '@kolkrabbi/kol-component'
4
+
5
+ const getSectionRootPath = (route, basePath) => {
6
+ if (route.path !== undefined && route.path !== null) {
7
+ const p = route.path
8
+ if (!p) return basePath
9
+ return p.startsWith('/') ? p : `${basePath}/${p}`
10
+ }
11
+ if (route.children?.length > 0) {
12
+ const cp = route.children[0].path
13
+ if (!cp) return basePath
14
+ return cp.startsWith('/') ? cp : `${basePath}/${cp}`
15
+ }
16
+ return basePath
17
+ }
18
+
19
+ const getChildPath = (child, basePath) => {
20
+ const p = child.path
21
+ if (p === undefined || p === null || p === '') return basePath
22
+ return p.startsWith('/') ? p : `${basePath}/${p}`
23
+ }
24
+
25
+ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle }) => {
26
+ const location = useLocation()
27
+ const normalizedPath = location.pathname.replace(/\/$/, '')
28
+
29
+ // Controlled mode: collapsed + onToggle from parent
30
+ // Uncontrolled mode: internal state
31
+ const [internalCollapsed, setInternalCollapsed] = useState(false)
32
+ const isControlled = collapsed !== undefined
33
+ const navCollapsed = isControlled ? collapsed : internalCollapsed
34
+ const handleToggle = isControlled ? onToggle : () => setInternalCollapsed(prev => !prev)
35
+
36
+ const [collapsedSections, setCollapsedSections] = useState(() => {
37
+ const initial = {}
38
+ routes.forEach((route) => {
39
+ const sectionPath = getSectionRootPath(route, basePath)
40
+ const isActive =
41
+ sectionPath === basePath
42
+ ? normalizedPath === basePath
43
+ : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
44
+ initial[route.id] = !isActive
45
+ })
46
+ return initial
47
+ })
48
+
49
+ useEffect(() => {
50
+ routes.forEach((route) => {
51
+ const sectionPath = getSectionRootPath(route, basePath)
52
+ const isActive =
53
+ sectionPath === basePath
54
+ ? normalizedPath === basePath
55
+ : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
56
+ if (isActive) {
57
+ setCollapsedSections((prev) => ({ ...prev, [route.id]: false }))
58
+ }
59
+ })
60
+ }, [normalizedPath, routes, basePath])
61
+
62
+ const handleSectionClick = (route) => {
63
+ setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
64
+ }
65
+
66
+ return (
67
+ <div className="space-y-4">
68
+ <div className="shell-sidebar-toggle shell-sidebar-label" style={{ justifyContent: 'space-between', paddingRight: '4px' }}>
69
+ {labelTo ? (
70
+ <Link to={labelTo} className="shell-sidebar-label" onClick={(e) => {
71
+ if (navCollapsed && handleToggle) handleToggle()
72
+ if (onNavigate) onNavigate(e)
73
+ }}>
74
+ {label}
75
+ </Link>
76
+ ) : (
77
+ <button type="button" onClick={handleToggle}>{label}</button>
78
+ )}
79
+ <button
80
+ type="button"
81
+ onClick={handleToggle}
82
+ aria-label={navCollapsed ? `Expand ${label}` : `Collapse ${label}`}
83
+ className="flex items-center justify-center"
84
+ style={{ height: '16.5px', marginBottom: '8px' }}
85
+ >
86
+ <Icon
87
+ name="chevron-down"
88
+ size={10}
89
+ className={`stroke-[2.5] transition-transform ${navCollapsed ? '' : 'rotate-180'}`}
90
+ />
91
+ </button>
92
+ </div>
93
+
94
+ {!navCollapsed && <div className="space-y-4">
95
+ {routes.map((route) => {
96
+ const isExpanded = !collapsedSections[route.id]
97
+
98
+ return (
99
+ <div key={route.id} className="shell-nav-group">
100
+ <button
101
+ type="button"
102
+ className="shell-nav-group-header w-full text-left"
103
+ onClick={() => handleSectionClick(route)}
104
+ >
105
+ <span className="flex items-center gap-2">
106
+ <Icon
107
+ name="chevron-right"
108
+ size={12}
109
+ className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
110
+ />
111
+ {route.label}
112
+ </span>
113
+ {route.children?.length > 0 && (
114
+ <span className="shell-nav-group-count">({route.children.length})</span>
115
+ )}
116
+ </button>
117
+
118
+ {isExpanded && route.children?.length > 0 && (
119
+ <div className="shell-nav-items">
120
+ {route.children.map((child) => {
121
+ const childPath = getChildPath(child, basePath)
122
+ return (
123
+ <NavLink
124
+ key={child.id}
125
+ to={childPath}
126
+ end
127
+ className={({ isActive }) =>
128
+ `shell-nav-item${isActive ? ' active' : ''}`
129
+ }
130
+ onClick={onNavigate}
131
+ >
132
+ <span className="shell-nav-item-title">{child.label}</span>
133
+ </NavLink>
134
+ )
135
+ })}
136
+ </div>
137
+ )}
138
+ </div>
139
+ )
140
+ })}
141
+ </div>}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ export default ShellSidebar
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @kolkrabbi/kol-workshop — shell.
3
+ *
4
+ * The docs-system shell: two-row header, collapsible nav sidebar, mobile
5
+ * drawer, and the layout shell that wires them to react-router. ShellLayout
6
+ * owns the TOC / full-height / TOC-collapsed contexts that pages register
7
+ * against.
8
+ */
9
+ export { default as ShellLayout } from './ShellLayout.jsx'
10
+ export { ShellTocContext, ShellFullHeightContext, ShellTocCollapsedContext } from './ShellLayout.jsx'
11
+ export { default as ShellSidebar } from './ShellSidebar.jsx'
@@ -0,0 +1,243 @@
1
+ import { useEffect, useRef, useMemo, useState } from 'react'
2
+ import * as d3 from 'd3'
3
+ import { useNavigate } from 'react-router-dom'
4
+ import { getTagColor } from '../engine/index.js'
5
+ import { buildTagCooccurrence } from '../engine/tags.js'
6
+
7
+ /**
8
+ * TagGraph - Obsidian-style tag graph
9
+ * All tags shown as interconnected nodes with force-directed layout
10
+ * Lines connect tags that share common docs
11
+ * Draggable nodes, click to navigate
12
+ *
13
+ * `tagHref(tag)` builds the navigation target when no `onTagClick` is provided
14
+ * (route-decoupled — no hardcoded workshop paths here).
15
+ */
16
+ const defaultTagHref = (tag) => `/workshop/design-system/documentation?tag=${encodeURIComponent(tag)}`
17
+
18
+ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHref }) => {
19
+ const svgRef = useRef(null)
20
+ const containerRef = useRef(null)
21
+ const navigate = useNavigate()
22
+ const [dimensions, setDimensions] = useState({ width: 300, height: 300 })
23
+ const [hoveredNode, setHoveredNode] = useState(null)
24
+
25
+ // Use allDocs if provided, otherwise fall back to docs
26
+ const sourceDocs = allDocs || docs
27
+
28
+ // Compute graph data: ALL tags with connections (pure — see engine/tags.js)
29
+ const graphData = useMemo(() => buildTagCooccurrence(sourceDocs), [sourceDocs])
30
+
31
+ // Update dimensions on resize
32
+ useEffect(() => {
33
+ const updateDimensions = () => {
34
+ if (containerRef.current) {
35
+ const { width } = containerRef.current.getBoundingClientRect()
36
+ const size = Math.max(250, width)
37
+ setDimensions({ width: size, height: size })
38
+ }
39
+ }
40
+ updateDimensions()
41
+ const resizeObserver = new ResizeObserver(updateDimensions)
42
+ if (containerRef.current) {
43
+ resizeObserver.observe(containerRef.current)
44
+ }
45
+ return () => resizeObserver.disconnect()
46
+ }, [])
47
+
48
+ // Render the force-directed graph
49
+ useEffect(() => {
50
+ if (!svgRef.current || graphData.nodes.length === 0) return
51
+
52
+ const { width, height } = dimensions
53
+
54
+ // Clear previous
55
+ const svg = d3.select(svgRef.current)
56
+ svg.selectAll('*').remove()
57
+
58
+ // Create container group
59
+ const g = svg.append('g')
60
+
61
+ // Create zoom behavior
62
+ const zoom = d3.zoom()
63
+ .scaleExtent([0.3, 3])
64
+ .on('zoom', (event) => {
65
+ g.attr('transform', event.transform)
66
+ })
67
+ svg.call(zoom)
68
+
69
+ // Prepare data with D3-friendly structure
70
+ const nodes = graphData.nodes.map(d => ({ ...d }))
71
+ const links = graphData.edges.map(d => ({ ...d }))
72
+
73
+ // Create force simulation
74
+ const simulation = d3.forceSimulation(nodes)
75
+ .force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.5))
76
+ .force('charge', d3.forceManyBody().strength(-150))
77
+ .force('center', d3.forceCenter(width / 2, height / 2))
78
+ .force('collision', d3.forceCollide().radius(30))
79
+
80
+ // Draw edges
81
+ const link = g.append('g')
82
+ .attr('class', 'links')
83
+ .selectAll('line')
84
+ .data(links)
85
+ .enter()
86
+ .append('line')
87
+ .attr('stroke', d => {
88
+ // Highlight edges connected to active tag
89
+ if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
90
+ return 'var(--kol-accent-primary)'
91
+ }
92
+ return 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)'
93
+ })
94
+ .attr('stroke-width', d => {
95
+ if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
96
+ return 2
97
+ }
98
+ return Math.min(2, d.weight * 0.5)
99
+ })
100
+ .attr('opacity', d => {
101
+ if (!activeTag) return 0.6
102
+ return (d.source.id === activeTag || d.target.id === activeTag) ? 1 : 0.2
103
+ })
104
+
105
+ // Draw nodes
106
+ const node = g.append('g')
107
+ .attr('class', 'nodes')
108
+ .selectAll('g')
109
+ .data(nodes)
110
+ .enter()
111
+ .append('g')
112
+ .attr('class', 'node')
113
+ .style('cursor', 'pointer')
114
+ .call(d3.drag()
115
+ .on('start', dragstarted)
116
+ .on('drag', dragged)
117
+ .on('end', dragended))
118
+
119
+ // Node circles
120
+ node.append('circle')
121
+ .attr('r', d => Math.max(8, Math.min(16, 6 + d.count * 2)))
122
+ .attr('fill', d => {
123
+ const colorMap = {
124
+ blue: '#3740D3',
125
+ teal: '#49a0a2',
126
+ green: '#66a44c',
127
+ yellow: '#ffe32e',
128
+ red: '#ce4646',
129
+ orange: '#db8000',
130
+ purple: '#9437FF',
131
+ dark: '#3a3a40'
132
+ }
133
+ return colorMap[getTagColor(d.id)] || '#49a0a2'
134
+ })
135
+ .attr('opacity', d => activeTag && d.id !== activeTag ? 0.4 : 0.9)
136
+ .attr('stroke', d => d.id === activeTag ? 'var(--kol-accent-primary)' : 'none')
137
+ .attr('stroke-width', d => d.id === activeTag ? 2 : 0)
138
+
139
+ // Node labels
140
+ node.append('text')
141
+ .text(d => `#${d.id}`)
142
+ .attr('dy', d => Math.max(8, Math.min(16, 6 + d.count * 2)) + 12)
143
+ .attr('text-anchor', 'middle')
144
+ .attr('fill', 'color-mix(in srgb, var(--kol-surface-on-primary) 64%, transparent)')
145
+ .attr('font-family', 'var(--kol-font-family-mono)')
146
+ .attr('font-size', '9px')
147
+ .attr('pointer-events', 'none')
148
+
149
+ // Node interactions
150
+ node
151
+ .on('click', (event, d) => {
152
+ event.stopPropagation()
153
+ if (onTagClick) {
154
+ onTagClick(d.id)
155
+ } else {
156
+ navigate(tagHref(d.id))
157
+ }
158
+ })
159
+ .on('mouseenter', (event, d) => {
160
+ setHoveredNode(d)
161
+ // Highlight connected edges
162
+ link
163
+ .attr('opacity', l => l.source.id === d.id || l.target.id === d.id ? 1 : 0.15)
164
+ .attr('stroke', l => l.source.id === d.id || l.target.id === d.id
165
+ ? 'var(--kol-accent-primary)'
166
+ : 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)')
167
+ })
168
+ .on('mouseleave', () => {
169
+ setHoveredNode(null)
170
+ link
171
+ .attr('opacity', d => {
172
+ if (!activeTag) return 0.6
173
+ return (d.source.id === activeTag || d.target.id === activeTag) ? 1 : 0.2
174
+ })
175
+ .attr('stroke', d => {
176
+ if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
177
+ return 'var(--kol-accent-primary)'
178
+ }
179
+ return 'color-mix(in srgb, var(--kol-surface-on-primary) 20%, transparent)'
180
+ })
181
+ })
182
+
183
+ // Simulation tick
184
+ simulation.on('tick', () => {
185
+ link
186
+ .attr('x1', d => d.source.x)
187
+ .attr('y1', d => d.source.y)
188
+ .attr('x2', d => d.target.x)
189
+ .attr('y2', d => d.target.y)
190
+
191
+ node.attr('transform', d => `translate(${d.x}, ${d.y})`)
192
+ })
193
+
194
+ // Drag functions
195
+ function dragstarted(event, d) {
196
+ if (!event.active) simulation.alphaTarget(0.3).restart()
197
+ d.fx = d.x
198
+ d.fy = d.y
199
+ }
200
+
201
+ function dragged(event, d) {
202
+ d.fx = event.x
203
+ d.fy = event.y
204
+ }
205
+
206
+ function dragended(event, d) {
207
+ if (!event.active) simulation.alphaTarget(0)
208
+ d.fx = null
209
+ d.fy = null
210
+ }
211
+
212
+ // Cleanup
213
+ return () => {
214
+ simulation.stop()
215
+ }
216
+ }, [graphData, dimensions, activeTag, navigate, onTagClick, tagHref])
217
+
218
+ if (graphData.nodes.length === 0) {
219
+ return (
220
+ <div className="text-fg-48 kol-mono-xs py-4 px-2">
221
+ No tags found.
222
+ </div>
223
+ )
224
+ }
225
+
226
+ return (
227
+ <div ref={containerRef} className="tag-graph-sidebar">
228
+ <svg
229
+ ref={svgRef}
230
+ width={dimensions.width}
231
+ height={dimensions.height}
232
+ className="tag-graph-svg"
233
+ />
234
+ {hoveredNode && (
235
+ <div className="tag-graph-tooltip">
236
+ #{hoveredNode.id} ({hoveredNode.count} {hoveredNode.count === 1 ? 'doc' : 'docs'})
237
+ </div>
238
+ )}
239
+ </div>
240
+ )
241
+ }
242
+
243
+ export default TagGraph