@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.
@@ -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 updateDimensions = () => {
35
- if (containerRef.current) {
36
- const { width } = containerRef.current.getBoundingClientRect()
37
- const size = Math.max(250, width)
38
- setDimensions({ width: size, height: size })
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
- updateDimensions()
42
- const resizeObserver = new ResizeObserver(updateDimensions)
43
- if (containerRef.current) {
44
- resizeObserver.observe(containerRef.current)
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
- // Render the force-directed graph
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
- // Create zoom behavior
63
- const zoom = d3.zoom()
64
- .scaleExtent([0.3, 3])
65
- .on('zoom', (event) => {
66
- g.attr('transform', event.transform)
67
- })
68
- svg.call(zoom)
69
-
70
- // Prepare data with D3-friendly structure
71
- const nodes = graphData.nodes.map(d => ({ ...d }))
72
- const links = graphData.edges.map(d => ({ ...d }))
73
-
74
- // Create force simulation
75
- const simulation = d3.forceSimulation(nodes)
76
- .force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.5))
77
- .force('charge', d3.forceManyBody().strength(-150))
78
- .force('center', d3.forceCenter(width / 2, height / 2))
79
- .force('collision', d3.forceCollide().radius(30))
80
-
81
- // Draw edges
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
- // Draw nodes
107
- const node = g.append('g')
108
- .attr('class', 'nodes')
109
- .selectAll('g')
110
- .data(nodes)
111
- .enter()
112
- .append('g')
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
- .call(d3.drag()
116
- .on('start', dragstarted)
117
- .on('drag', dragged)
118
- .on('end', dragended))
119
-
120
- // Node circles
121
- node.append('circle')
122
- .attr('r', d => Math.max(8, Math.min(16, 6 + d.count * 2)))
123
- /* Palette tokens, not copies. This held eight hardcoded hexes that were
124
- * transcriptions of --kol-palette-* (#3740D3 IS palette-blue) — correct
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', 'color-mix(in srgb, var(--kol-surface-on-primary) 64%, transparent)')
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
- // Node interactions
151
- node
152
- .on('click', (event, d) => {
153
- event.stopPropagation()
154
- if (onTagClick) {
155
- onTagClick(d.id)
156
- } else {
157
- navigate(tagHref(d.id))
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
- .on('mouseenter', (event, d) => {
161
- setHoveredNode(d)
162
- // Highlight connected edges
163
- link
164
- .attr('opacity', l => l.source.id === d.id || l.target.id === d.id ? 1 : 0.15)
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
- .on('mouseleave', () => {
170
- setHoveredNode(null)
171
- link
172
- .attr('opacity', d => {
173
- if (!activeTag) return 0.6
174
- return (d.source.id === activeTag || d.target.id === activeTag) ? 1 : 0.2
175
- })
176
- .attr('stroke', d => {
177
- if (activeTag && (d.source.id === activeTag || d.target.id === activeTag)) {
178
- return 'var(--kol-accent-primary)'
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
- // Simulation tick
185
- simulation.on('tick', () => {
186
- link
187
- .attr('x1', d => d.source.x)
188
- .attr('y1', d => d.source.y)
189
- .attr('x2', d => d.target.x)
190
- .attr('y2', d => d.target.y)
191
-
192
- node.attr('transform', d => `translate(${d.x}, ${d.y})`)
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
- // Drag functions
196
- function dragstarted(event, d) {
197
- if (!event.active) simulation.alphaTarget(0.3).restart()
198
- d.fx = d.x
199
- d.fy = d.y
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
- function dragged(event, d) {
203
- d.fx = event.x
204
- d.fy = event.y
205
- }
206
-
207
- function dragended(event, d) {
208
- if (!event.active) simulation.alphaTarget(0)
209
- d.fx = null
210
- d.fy = null
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
- // Cleanup
227
+ sceneRef.current = { paint }
214
228
  return () => {
215
- simulation.stop()
229
+ sim.stop()
230
+ gsap.killTweensOf([...circle.nodes(), ...label.nodes(), ...link.nodes()])
231
+ svg.interrupt()
232
+ sceneRef.current = null
216
233
  }
217
- }, [graphData, dimensions, activeTag, navigate, onTagClick, tagHref])
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'})