@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.
- package/README.md +38 -0
- package/package.json +46 -0
- package/src/compositions/WorkshopDefaultSidebar.jsx +89 -0
- package/src/compositions/WorkshopSidebar.jsx +180 -0
- package/src/compositions/index.js +8 -0
- package/src/docs/DocsArticle.jsx +7 -0
- package/src/docs/DocsFrontmatter.jsx +74 -0
- package/src/docs/DocsHeader.jsx +19 -0
- package/src/docs/DocumentationReader.jsx +417 -0
- package/src/docs/index.js +14 -0
- package/src/docs/render-tokens.jsx +98 -0
- package/src/engine/__check.mjs +72 -0
- package/src/engine/build-inventory.js +62 -0
- package/src/engine/doc-helpers.js +132 -0
- package/src/engine/frontmatter.js +41 -0
- package/src/engine/index.js +20 -0
- package/src/engine/parse-markdown.js +352 -0
- package/src/engine/search.js +36 -0
- package/src/engine/tags.js +75 -0
- package/src/index.js +15 -0
- package/src/shell/ShellLayout.jsx +197 -0
- package/src/shell/ShellSidebar.jsx +146 -0
- package/src/shell/index.js +11 -0
- package/src/tags/TagGraph.jsx +243 -0
- package/src/tags/TagModeContext.jsx +105 -0
- package/src/tags/TagModeGate.jsx +17 -0
- package/src/tags/TagModeOverlay.jsx +172 -0
- package/src/tags/index.js +11 -0
|
@@ -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
|