|
| 1 | +import * as React from 'react' |
| 2 | +import { |
| 3 | + forceSimulation, |
| 4 | + forceLink, |
| 5 | + forceManyBody, |
| 6 | + forceCenter, |
| 7 | + forceCollide, |
| 8 | + type SimulationNodeDatum, |
| 9 | + type SimulationLinkDatum, |
| 10 | +} from 'd3' |
| 11 | +import { Link } from '@tanstack/react-router' |
| 12 | +import { SKILL_TYPE_STYLES } from '~/routes/intent/registry/$packageName' |
| 13 | + |
| 14 | +interface SkillNode { |
| 15 | + name: string |
| 16 | + type: string | null |
| 17 | + requires: Array<string> | null |
| 18 | +} |
| 19 | + |
| 20 | +interface GraphNode extends SimulationNodeDatum { |
| 21 | + id: string |
| 22 | + type: string | null |
| 23 | + hasIncoming: boolean |
| 24 | + hasOutgoing: boolean |
| 25 | +} |
| 26 | + |
| 27 | +interface GraphLink extends SimulationLinkDatum<GraphNode> { |
| 28 | + source: string | GraphNode |
| 29 | + target: string | GraphNode |
| 30 | +} |
| 31 | + |
| 32 | +const TYPE_COLORS: Record<string, { fill: string; stroke: string }> = { |
| 33 | + core: { fill: '#8b5cf6', stroke: '#7c3aed' }, |
| 34 | + 'sub-skill': { fill: '#6b7280', stroke: '#4b5563' }, |
| 35 | + framework: { fill: '#f59e0b', stroke: '#d97706' }, |
| 36 | + lifecycle: { fill: '#10b981', stroke: '#059669' }, |
| 37 | + composition: { fill: '#3b82f6', stroke: '#2563eb' }, |
| 38 | + security: { fill: '#ef4444', stroke: '#dc2626' }, |
| 39 | +} |
| 40 | + |
| 41 | +const DEFAULT_COLOR = { fill: '#9ca3af', stroke: '#6b7280' } |
| 42 | + |
| 43 | +export function SkillDependencyGraph({ |
| 44 | + skills, |
| 45 | + packageName, |
| 46 | +}: { |
| 47 | + readonly skills: Array<SkillNode> |
| 48 | + readonly packageName: string |
| 49 | +}) { |
| 50 | + const svgRef = React.useRef<SVGSVGElement>(null) |
| 51 | + const [nodes, setNodes] = React.useState<Array<GraphNode>>([]) |
| 52 | + const [links, setLinks] = React.useState<Array<GraphLink>>([]) |
| 53 | + const [dimensions, setDimensions] = React.useState({ width: 0, height: 0 }) |
| 54 | + |
| 55 | + // Build graph data |
| 56 | + const { graphNodes, graphLinks, hasEdges } = React.useMemo(() => { |
| 57 | + const skillNames = new Set(skills.map((s) => s.name)) |
| 58 | + const gNodes: Array<GraphNode> = skills.map((s) => ({ |
| 59 | + id: s.name, |
| 60 | + type: s.type, |
| 61 | + hasIncoming: false, |
| 62 | + hasOutgoing: |
| 63 | + (s.requires?.filter((r) => skillNames.has(r)).length ?? 0) > 0, |
| 64 | + })) |
| 65 | + |
| 66 | + const gLinks: Array<GraphLink> = [] |
| 67 | + const incomingSet = new Set<string>() |
| 68 | + |
| 69 | + for (const skill of skills) { |
| 70 | + if (!skill.requires) continue |
| 71 | + for (const req of skill.requires) { |
| 72 | + if (skillNames.has(req)) { |
| 73 | + gLinks.push({ source: skill.name, target: req }) |
| 74 | + incomingSet.add(req) |
| 75 | + } |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + for (const node of gNodes) { |
| 80 | + node.hasIncoming = incomingSet.has(node.id) |
| 81 | + } |
| 82 | + |
| 83 | + return { |
| 84 | + graphNodes: gNodes, |
| 85 | + graphLinks: gLinks, |
| 86 | + hasEdges: gLinks.length > 0, |
| 87 | + } |
| 88 | + }, [skills]) |
| 89 | + |
| 90 | + // Measure container |
| 91 | + const containerRef = React.useRef<HTMLDivElement>(null) |
| 92 | + |
| 93 | + React.useEffect(() => { |
| 94 | + if (!containerRef.current) return |
| 95 | + const observer = new ResizeObserver((entries) => { |
| 96 | + const entry = entries[0] |
| 97 | + if (!entry) return |
| 98 | + const { width } = entry.contentRect |
| 99 | + const height = Math.min(Math.max(width * 0.6, 200), 400) |
| 100 | + setDimensions({ width, height }) |
| 101 | + }) |
| 102 | + observer.observe(containerRef.current) |
| 103 | + return () => observer.disconnect() |
| 104 | + }, []) |
| 105 | + |
| 106 | + // Run force simulation |
| 107 | + React.useEffect(() => { |
| 108 | + if (dimensions.width === 0 || graphNodes.length === 0) return |
| 109 | + |
| 110 | + const nodesCopy = graphNodes.map((n) => ({ ...n })) |
| 111 | + const linksCopy = graphLinks.map((l) => ({ ...l })) |
| 112 | + |
| 113 | + const sim = forceSimulation(nodesCopy) |
| 114 | + .force( |
| 115 | + 'link', |
| 116 | + forceLink<GraphNode, GraphLink>(linksCopy) |
| 117 | + .id((d) => d.id) |
| 118 | + .distance(80), |
| 119 | + ) |
| 120 | + .force('charge', forceManyBody().strength(-200)) |
| 121 | + .force('center', forceCenter(dimensions.width / 2, dimensions.height / 2)) |
| 122 | + .force('collide', forceCollide(35)) |
| 123 | + |
| 124 | + sim.on('end', () => { |
| 125 | + setNodes([...nodesCopy]) |
| 126 | + setLinks( |
| 127 | + linksCopy.map((l) => ({ |
| 128 | + ...l, |
| 129 | + source: l.source as GraphNode, |
| 130 | + target: l.target as GraphNode, |
| 131 | + })), |
| 132 | + ) |
| 133 | + }) |
| 134 | + |
| 135 | + // Run synchronously for small graphs |
| 136 | + sim.tick(200) |
| 137 | + sim.stop() |
| 138 | + setNodes([...nodesCopy]) |
| 139 | + setLinks( |
| 140 | + linksCopy.map((l) => ({ |
| 141 | + ...l, |
| 142 | + source: l.source as GraphNode, |
| 143 | + target: l.target as GraphNode, |
| 144 | + })), |
| 145 | + ) |
| 146 | + |
| 147 | + return () => { |
| 148 | + sim.stop() |
| 149 | + } |
| 150 | + }, [graphNodes, graphLinks, dimensions]) |
| 151 | + |
| 152 | + if (!hasEdges) return null |
| 153 | + |
| 154 | + const NODE_RADIUS = 6 |
| 155 | + |
| 156 | + return ( |
| 157 | + <div |
| 158 | + ref={containerRef} |
| 159 | + className="rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden bg-gray-50/50 dark:bg-gray-900/30" |
| 160 | + > |
| 161 | + {dimensions.width > 0 && nodes.length > 0 && ( |
| 162 | + <svg |
| 163 | + ref={svgRef} |
| 164 | + width={dimensions.width} |
| 165 | + height={dimensions.height} |
| 166 | + className="block" |
| 167 | + > |
| 168 | + <defs> |
| 169 | + <marker |
| 170 | + id="arrowhead" |
| 171 | + markerWidth="8" |
| 172 | + markerHeight="6" |
| 173 | + refX="7" |
| 174 | + refY="3" |
| 175 | + orient="auto" |
| 176 | + > |
| 177 | + <path |
| 178 | + d="M0,0 L8,3 L0,6" |
| 179 | + fill="none" |
| 180 | + stroke="currentColor" |
| 181 | + strokeWidth="1" |
| 182 | + className="text-gray-400 dark:text-gray-600" |
| 183 | + /> |
| 184 | + </marker> |
| 185 | + </defs> |
| 186 | + |
| 187 | + {/* Links */} |
| 188 | + {links.map((link, i) => { |
| 189 | + const source = link.source as GraphNode |
| 190 | + const target = link.target as GraphNode |
| 191 | + if ( |
| 192 | + source.x == null || |
| 193 | + source.y == null || |
| 194 | + target.x == null || |
| 195 | + target.y == null |
| 196 | + ) |
| 197 | + return null |
| 198 | + |
| 199 | + // Shorten line to stop at node edge |
| 200 | + const dx = target.x - source.x |
| 201 | + const dy = target.y - source.y |
| 202 | + const dist = Math.sqrt(dx * dx + dy * dy) |
| 203 | + if (dist === 0) return null |
| 204 | + const offsetX = (dx / dist) * (NODE_RADIUS + 4) |
| 205 | + const offsetY = (dy / dist) * (NODE_RADIUS + 4) |
| 206 | + |
| 207 | + return ( |
| 208 | + <line |
| 209 | + key={i} |
| 210 | + x1={source.x + offsetX} |
| 211 | + y1={source.y + offsetY} |
| 212 | + x2={target.x - offsetX} |
| 213 | + y2={target.y - offsetY} |
| 214 | + stroke="currentColor" |
| 215 | + strokeWidth={1.5} |
| 216 | + className="text-gray-300 dark:text-gray-700" |
| 217 | + markerEnd="url(#arrowhead)" |
| 218 | + /> |
| 219 | + ) |
| 220 | + })} |
| 221 | + |
| 222 | + {/* Nodes */} |
| 223 | + {nodes.map((node) => { |
| 224 | + if (node.x == null || node.y == null) return null |
| 225 | + const colors = TYPE_COLORS[node.type ?? ''] ?? DEFAULT_COLOR |
| 226 | + |
| 227 | + return ( |
| 228 | + <Link |
| 229 | + key={node.id} |
| 230 | + to="/intent/registry/$packageName/$skillName" |
| 231 | + params={{ packageName, skillName: node.id }} |
| 232 | + > |
| 233 | + <g className="cursor-pointer group"> |
| 234 | + <circle |
| 235 | + cx={node.x} |
| 236 | + cy={node.y} |
| 237 | + r={NODE_RADIUS} |
| 238 | + fill={colors.fill} |
| 239 | + stroke={colors.stroke} |
| 240 | + strokeWidth={1.5} |
| 241 | + opacity={0.85} |
| 242 | + className="transition-opacity group-hover:opacity-100" |
| 243 | + /> |
| 244 | + <text |
| 245 | + x={node.x} |
| 246 | + y={node.y + NODE_RADIUS + 12} |
| 247 | + textAnchor="middle" |
| 248 | + className="text-[10px] fill-gray-500 dark:fill-gray-400 font-mono group-hover:fill-gray-900 dark:group-hover:fill-gray-100 transition-colors" |
| 249 | + > |
| 250 | + {node.id} |
| 251 | + </text> |
| 252 | + </g> |
| 253 | + </Link> |
| 254 | + ) |
| 255 | + })} |
| 256 | + </svg> |
| 257 | + )} |
| 258 | + |
| 259 | + {/* Legend */} |
| 260 | + <div className="flex items-center gap-3 px-3 py-2 border-t border-gray-200 dark:border-gray-800 text-[10px] text-gray-400 dark:text-gray-500"> |
| 261 | + <span className="uppercase tracking-wider font-medium"> |
| 262 | + Dependencies |
| 263 | + </span> |
| 264 | + <span className="text-gray-300 dark:text-gray-700">|</span> |
| 265 | + {Object.entries(SKILL_TYPE_STYLES) |
| 266 | + .filter(([type]) => skills.some((s) => s.type === type)) |
| 267 | + .map(([type]) => { |
| 268 | + const colors = TYPE_COLORS[type] ?? DEFAULT_COLOR |
| 269 | + return ( |
| 270 | + <span key={type} className="flex items-center gap-1"> |
| 271 | + <span |
| 272 | + className="w-2 h-2 rounded-full" |
| 273 | + style={{ backgroundColor: colors.fill }} |
| 274 | + /> |
| 275 | + {type} |
| 276 | + </span> |
| 277 | + ) |
| 278 | + })} |
| 279 | + </div> |
| 280 | + </div> |
| 281 | + ) |
| 282 | +} |
0 commit comments