From ad974239fd3a061596368ddc297b8936c9753929 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Tue, 20 Jan 2026 19:52:31 +1000 Subject: [PATCH] feat: add interactive architecture diagram page --- apps/web/src/app/architecture/page.tsx | 53 ++ apps/web/src/components/NodeDetailOverlay.tsx | 178 +++++ .../src/components/SystemOverviewDiagram.tsx | 626 ++++++++++++++++++ .../architecture/ExecutorsDiagram.tsx | 292 ++++++++ .../architecture/NpmRegistryDiagram.tsx | 231 +++++++ .../architecture/OutputsDiagram.tsx | 291 ++++++++ .../components/architecture/ToolsDiagram.tsx | 220 ++++++ .../components/architecture/TpmjsDiagram.tsx | 308 +++++++++ .../components/architecture/UsersDiagram.tsx | 271 ++++++++ apps/web/src/components/architecture/types.ts | 62 ++ .../architecture/useDiagramSetup.ts | 392 +++++++++++ 11 files changed, 2924 insertions(+) create mode 100644 apps/web/src/app/architecture/page.tsx create mode 100644 apps/web/src/components/NodeDetailOverlay.tsx create mode 100644 apps/web/src/components/SystemOverviewDiagram.tsx create mode 100644 apps/web/src/components/architecture/ExecutorsDiagram.tsx create mode 100644 apps/web/src/components/architecture/NpmRegistryDiagram.tsx create mode 100644 apps/web/src/components/architecture/OutputsDiagram.tsx create mode 100644 apps/web/src/components/architecture/ToolsDiagram.tsx create mode 100644 apps/web/src/components/architecture/TpmjsDiagram.tsx create mode 100644 apps/web/src/components/architecture/UsersDiagram.tsx create mode 100644 apps/web/src/components/architecture/types.ts create mode 100644 apps/web/src/components/architecture/useDiagramSetup.ts diff --git a/apps/web/src/app/architecture/page.tsx b/apps/web/src/app/architecture/page.tsx new file mode 100644 index 0000000..0f87a0b --- /dev/null +++ b/apps/web/src/app/architecture/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from 'next'; +import { AppHeader } from '~/components/AppHeader'; +import { SystemOverviewDiagram } from '~/components/SystemOverviewDiagram'; + +export const metadata: Metadata = { + title: 'System Architecture | TPMJS', + description: + 'Interactive visualization of the TPMJS system architecture. See how tools flow from npm to execution.', +}; + +export default function ArchitecturePage(): React.ReactElement { + return ( +
+ +
+
+ {/* Header */} +
+

+ TPMJS System Architecture +

+

+ See how tools flow from npm to execution. Click any node to learn more. +

+
+ + {/* Diagram */} + + + {/* Legend */} +
+ {[ + { label: 'Tools', color: 'bg-blue-100 dark:bg-blue-900/30 border-blue-500' }, + { + label: 'npm Registry', + color: 'bg-orange-100 dark:bg-orange-900/30 border-orange-500', + }, + { label: 'TPMJS', color: 'bg-green-100 dark:bg-green-900/30 border-green-500' }, + { label: 'Users', color: 'bg-purple-100 dark:bg-purple-900/30 border-purple-500' }, + { label: 'Executors', color: 'bg-pink-100 dark:bg-pink-900/30 border-pink-500' }, + { label: 'Outputs', color: 'bg-green-100 dark:bg-green-900/30 border-green-500' }, + ].map((item) => ( +
+
+ {item.label} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/NodeDetailOverlay.tsx b/apps/web/src/components/NodeDetailOverlay.tsx new file mode 100644 index 0000000..46b336b --- /dev/null +++ b/apps/web/src/components/NodeDetailOverlay.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { ExecutorsDiagram } from './architecture/ExecutorsDiagram'; +import { NpmRegistryDiagram } from './architecture/NpmRegistryDiagram'; +import { OutputsDiagram } from './architecture/OutputsDiagram'; +import { ToolsDiagram } from './architecture/ToolsDiagram'; +import { TpmjsDiagram } from './architecture/TpmjsDiagram'; +import { UsersDiagram } from './architecture/UsersDiagram'; + +// Map node IDs to their diagram components +const diagramComponents: Record = { + tools: ToolsDiagram, + npm: NpmRegistryDiagram, + tpmjs: TpmjsDiagram, + users: UsersDiagram, + executors: ExecutorsDiagram, + outputs: OutputsDiagram, +}; + +interface NodeDetailOverlayProps { + open: boolean; + onClose: () => void; + nodeId: string; + title: string; + description: string; + bullets: string[]; + links?: { label: string; href: string }[]; +} + +export function NodeDetailOverlay({ + open, + onClose, + nodeId, + title, + description, + bullets, + links, +}: NodeDetailOverlayProps): React.ReactElement | null { + const DiagramComponent = diagramComponents[nodeId]; + const [isVisible, setIsVisible] = useState(false); + + // Handle escape key and animation + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (open) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + // Trigger animation after mount + requestAnimationFrame(() => setIsVisible(true)); + } else { + setIsVisible(false); + } + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = ''; + }; + }, [open, onClose]); + + if (!open) return null; + + return ( + <> + {/* Backdrop */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: backdrop click-to-close handled by escape key */} +
+ + {/* Modal */} +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: stopPropagation prevents backdrop click-to-close */} +
e.stopPropagation()} + > + {/* Header */} +
+

{title}

+ +
+ + {/* Content - Two column layout */} +
+
+ {/* Left: Diagram */} +
+

+ How It Works +

+
+ {DiagramComponent && } +
+

+ Hover over elements for more details +

+
+ + {/* Right: Details */} +
+ {/* Description */} +
+

+ Overview +

+

{description}

+
+ + {/* Bullet points */} +
+

+ Key Features +

+
    + {bullets.map((bullet, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: bullets are static strings without unique IDs +
  • + + {bullet} +
  • + ))} +
+
+ + {/* Links */} + {links && links.length > 0 && ( +
+

+ Learn More +

+
+ {links.map((link) => ( + + + + ))} +
+
+ )} +
+
+
+ + {/* Footer */} +
+ +
+
+
+ + ); +} diff --git a/apps/web/src/components/SystemOverviewDiagram.tsx b/apps/web/src/components/SystemOverviewDiagram.tsx new file mode 100644 index 0000000..2157508 --- /dev/null +++ b/apps/web/src/components/SystemOverviewDiagram.tsx @@ -0,0 +1,626 @@ +'use client'; + +import * as d3 from 'd3'; +import { useTheme } from 'next-themes'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { NodeDetailOverlay } from './NodeDetailOverlay'; + +// Node types for the diagram +type NodeType = 'tools' | 'npm' | 'tpmjs' | 'users' | 'executors' | 'outputs'; + +interface DiagramNode { + id: string; + label: string; + sublabel?: string; + type: NodeType; + children?: { id: string; label: string }[]; +} + +interface DiagramConnection { + from: string; + to: string; + label: string; +} + +// Color schemes for light and dark modes +const colorSchemes = { + light: { + tools: { fill: '#e3f2fd', stroke: '#1976d2', text: '#0d47a1' }, + npm: { fill: '#fff3e0', stroke: '#f57c00', text: '#e65100' }, + tpmjs: { fill: '#e8f5e9', stroke: '#388e3c', text: '#1b5e20' }, + users: { fill: '#f3e5f5', stroke: '#7b1fa2', text: '#4a148c' }, + executors: { fill: '#fce4ec', stroke: '#c2185b', text: '#880e4f' }, + outputs: { fill: '#e8f5e9', stroke: '#388e3c', text: '#1b5e20' }, + }, + dark: { + tools: { fill: '#1e3a5f', stroke: '#64b5f6', text: '#90caf9' }, + npm: { fill: '#4a3000', stroke: '#ffb74d', text: '#ffe0b2' }, + tpmjs: { fill: '#1b4332', stroke: '#66bb6a', text: '#a5d6a7' }, + users: { fill: '#3a1f5c', stroke: '#ba68c8', text: '#ce93d8' }, + executors: { fill: '#4a1f35', stroke: '#f06292', text: '#f48fb1' }, + outputs: { fill: '#1b4332', stroke: '#66bb6a', text: '#a5d6a7' }, + }, +}; + +// Node detail content for the drawer +export const nodeDetails: Record< + string, + { + title: string; + description: string; + bullets: string[]; + links?: { label: string; href: string }[]; + } +> = { + tools: { + title: 'TPMJS Tools', + description: + 'Tools are npm packages that export AI SDK-compatible functions. Any package can become a TPMJS tool by adding the tpmjs keyword and field to package.json.', + bullets: [ + 'Tools are standard npm packages with AI SDK tool exports', + 'Each tool has a description, parameters schema, and execute function', + 'Examples: weather-tool, calculator-tool, web-scraper', + ], + links: [ + { label: 'Browse Tools', href: '/tool/tool-search' }, + { label: 'Publish a Tool', href: '/publish' }, + ], + }, + npm: { + title: 'npm Registry', + description: + 'The npm registry serves as the source of truth for TPMJS tools. Packages with the "tpmjs" keyword are automatically discovered and indexed.', + bullets: [ + 'Packages must have the "tpmjs" keyword in package.json', + 'Metadata is extracted from the "tpmjs" field', + 'Tools are dynamically loaded from esm.sh at runtime', + ], + links: [ + { label: 'TPMJS Specification', href: '/spec' }, + { label: 'Publishing Guide', href: '/publish' }, + ], + }, + tpmjs: { + title: 'TPMJS Platform', + description: + 'TPMJS continuously syncs with npm to discover new tools, extract schemas, and calculate quality scores. The Tool Registry stores metadata for fast search and discovery.', + bullets: [ + 'Sync Workers poll npm every 2-15 minutes', + 'Schema extraction automatically analyzes tool signatures', + 'Quality scores based on metadata, downloads, and stars', + 'PostgreSQL database stores all tool metadata', + ], + links: [ + { label: 'How It Works', href: '/how-it-works' }, + { label: 'API Documentation', href: '/docs/api' }, + ], + }, + users: { + title: 'Users & Collections', + description: + 'Users create Collections to group related tools, build AI Agents that use those tools, or connect MCP Servers directly to AI clients like Claude Desktop.', + bullets: [ + 'Collections group tools for specific use cases', + 'Agents are conversational AI assistants with tool access', + 'MCP Servers provide JSON-RPC endpoints for AI clients', + 'Share collections and agents via public URLs', + ], + links: [ + { label: 'Browse Collections', href: '/collections' }, + { label: 'Browse Agents', href: '/agents' }, + { label: 'MCP Documentation', href: '/docs#mcp-overview' }, + ], + }, + executors: { + title: 'Tool Executors', + description: + 'Executors run tool code in secure sandboxes. The official executor runs on Railway with Deno, but you can deploy your own custom executor.', + bullets: [ + 'Official Sandbox: Deno runtime on Railway', + 'Tools loaded dynamically from esm.sh', + 'API keys passed per-request, never stored', + 'Custom executors can be self-hosted', + ], + links: [ + { label: 'Custom Executors', href: '/docs/executors' }, + { label: 'Security Model', href: '/docs#security' }, + ], + }, + outputs: { + title: 'Response Formats', + description: + 'Tool execution results are returned in two formats: SSE for streaming playground responses, and JSON-RPC for MCP protocol compatibility.', + bullets: [ + 'SSE Response: Streaming text chunks for real-time UI', + 'JSON-RPC Response: MCP protocol for AI clients', + 'Both formats include tool outputs and metadata', + 'Execution time and token usage tracked', + ], + links: [ + { label: 'API Reference', href: '/docs/api' }, + { label: 'MCP Protocol', href: '/docs#mcp-protocol' }, + ], + }, +}; + +// Diagram data +const nodes: DiagramNode[] = [ + { + id: 'tools', + label: 'Tools', + sublabel: 'npm packages', + type: 'tools', + children: [ + { id: 'weather', label: 'weather-tool' }, + { id: 'calculator', label: 'calculator' }, + { id: 'scraper', label: 'web-scraper' }, + ], + }, + { + id: 'npm', + label: 'npm Registry', + sublabel: "packages with 'tpmjs' keyword", + type: 'npm', + }, + { + id: 'tpmjs', + label: 'TPMJS', + sublabel: 'Platform', + type: 'tpmjs', + children: [ + { id: 'sync', label: 'Sync Workers' }, + { id: 'registry', label: 'Tool Registry' }, + ], + }, + { + id: 'users', + label: 'Users', + sublabel: 'collect & share', + type: 'users', + children: [ + { id: 'collections', label: 'Collections' }, + { id: 'agents', label: 'Agents' }, + { id: 'mcp', label: 'MCP Servers' }, + ], + }, + { + id: 'executors', + label: 'Executors', + sublabel: 'run tools', + type: 'executors', + children: [ + { id: 'sandbox', label: 'Official Sandbox' }, + { id: 'custom', label: 'Custom Executor' }, + ], + }, + { + id: 'outputs', + label: 'Outputs', + sublabel: 'responses', + type: 'outputs', + children: [ + { id: 'sse', label: 'SSE Response' }, + { id: 'jsonrpc', label: 'JSON-RPC' }, + ], + }, +]; + +const connections: DiagramConnection[] = [ + { from: 'tools', to: 'npm', label: 'publish' }, + { from: 'npm', to: 'tpmjs', label: 'sync' }, + { from: 'tpmjs', to: 'users', label: 'discover' }, + { from: 'users', to: 'executors', label: 'run' }, + { from: 'executors', to: 'outputs', label: 'return' }, +]; + +export function SystemOverviewDiagram(): React.ReactElement { + const svgRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 800, height: 280 }); + const [selectedNode, setSelectedNode] = useState(null); + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Handle mounting for theme + useEffect(() => { + setMounted(true); + }, []); + + // Handle resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const width = containerRef.current.clientWidth; + const isMobile = width < 768; + // Horizontal layout needs less height but more width + const height = isMobile ? 1100 : 280; + setDimensions({ width, height }); + } + }; + + updateDimensions(); + window.addEventListener('resize', updateDimensions); + return () => window.removeEventListener('resize', updateDimensions); + }, []); + + const handleNodeClick = useCallback((nodeId: string) => { + setSelectedNode(nodeId); + }, []); + + const handleCloseDrawer = useCallback(() => { + setSelectedNode(null); + }, []); + + // D3 rendering + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + const { width, height } = dimensions; + const isMobile = width < 768; + const isDark = resolvedTheme === 'dark'; + const colors = isDark ? colorSchemes.dark : colorSchemes.light; + + // Calculate layout - LEFT TO RIGHT for desktop, TOP TO BOTTOM for mobile + const padding = isMobile ? 20 : 30; + const nodeCount = nodes.length; + + let nodePositions: Record; + + if (isMobile) { + // Mobile: vertical layout (top to bottom) + const nodeHeight = 120; + const nodeWidth = width - padding * 2; + const verticalGap = 170; + const startY = padding + nodeHeight / 2 + 10; + const centerX = width / 2; + + nodePositions = {}; + nodes.forEach((node, i) => { + nodePositions[node.id] = { + x: centerX, + y: startY + i * verticalGap, + width: nodeWidth, + height: nodeHeight, + }; + }); + } else { + // Desktop: horizontal layout (left to right) + const nodeWidth = 160; + const nodeHeight = 160; + const totalNodesWidth = nodeCount * nodeWidth; + const totalGapWidth = width - padding * 2 - totalNodesWidth; + const gap = totalGapWidth / (nodeCount - 1); + const startX = padding + nodeWidth / 2; + const centerY = height / 2; + + nodePositions = {}; + nodes.forEach((node, i) => { + nodePositions[node.id] = { + x: startX + i * (nodeWidth + gap), + y: centerY, + width: nodeWidth, + height: nodeHeight, + }; + }); + } + + // Create defs for filters and markers + const defs = svg.append('defs'); + + // Drop shadow filter + const shadow = defs + .append('filter') + .attr('id', 'overview-shadow') + .attr('x', '-20%') + .attr('y', '-20%') + .attr('width', '140%') + .attr('height', '140%'); + shadow + .append('feDropShadow') + .attr('dx', '0') + .attr('dy', '3') + .attr('stdDeviation', '4') + .attr('flood-color', isDark ? '#000' : '#000') + .attr('flood-opacity', isDark ? '0.4' : '0.15'); + + // Glow filter for hover + const glow = defs + .append('filter') + .attr('id', 'overview-glow') + .attr('x', '-50%') + .attr('y', '-50%') + .attr('width', '200%') + .attr('height', '200%'); + glow.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'coloredBlur'); + const glowMerge = glow.append('feMerge'); + glowMerge.append('feMergeNode').attr('in', 'coloredBlur'); + glowMerge.append('feMergeNode').attr('in', 'SourceGraphic'); + + // Arrow marker + defs + .append('marker') + .attr('id', 'overview-arrow') + .attr('viewBox', '0 -5 10 10') + .attr('refX', 8) + .attr('refY', 0) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', isDark ? '#666' : '#999'); + + const mainGroup = svg.append('g'); + + // Draw connections with animation + connections.forEach((conn, i) => { + const fromPos = nodePositions[conn.from]; + const toPos = nodePositions[conn.to]; + if (!fromPos || !toPos) return; + + let pathData: string; + let labelX: number; + let labelY: number; + + if (isMobile) { + // Vertical path (top to bottom) + const startY = fromPos.y + fromPos.height / 2; + const endY = toPos.y - toPos.height / 2; + pathData = `M ${fromPos.x} ${startY} L ${toPos.x} ${endY - 10}`; + labelX = fromPos.x + 8; + labelY = (startY + endY) / 2; + } else { + // Horizontal path (left to right) + const startX = fromPos.x + fromPos.width / 2; + const endX = toPos.x - toPos.width / 2; + pathData = `M ${startX} ${fromPos.y} L ${endX - 10} ${toPos.y}`; + labelX = (startX + endX) / 2; + labelY = fromPos.y - 12; + } + + // Background path (static) + mainGroup + .append('path') + .attr('d', pathData) + .attr('fill', 'none') + .attr('stroke', isDark ? '#333' : '#e5e5e5') + .attr('stroke-width', 2); + + // Animated path + const animatedPath = mainGroup + .append('path') + .attr('d', pathData) + .attr('fill', 'none') + .attr('stroke', isDark ? '#555' : '#aaa') + .attr('stroke-width', 2) + .attr('stroke-dasharray', '8,8') + .attr('stroke-linecap', 'round') + .attr('marker-end', 'url(#overview-arrow)'); + + // Get path length for animation + const pathNode = animatedPath.node(); + if (pathNode) { + const pathLength = (pathNode as SVGPathElement).getTotalLength(); + + // Initial state - path not drawn + animatedPath + .attr('stroke-dasharray', `${pathLength} ${pathLength}`) + .attr('stroke-dashoffset', pathLength); + + // Animate path drawing on load + animatedPath + .transition() + .delay(300 + i * 150) + .duration(600) + .ease(d3.easeCubicOut) + .attr('stroke-dashoffset', 0) + .on('end', () => { + // After draw animation, start flow animation + animatedPath.attr('stroke-dasharray', '8,8').attr('stroke-dashoffset', 0); + + const animateFlow = () => { + animatedPath + .attr('stroke-dashoffset', 0) + .transition() + .duration(1500) + .ease(d3.easeLinear) + .attr('stroke-dashoffset', -32) + .on('end', animateFlow); + }; + animateFlow(); + }); + } + + // Connection label + mainGroup + .append('text') + .attr('x', labelX) + .attr('y', labelY) + .attr('text-anchor', 'middle') + .attr('fill', isDark ? '#888' : '#666') + .attr('font-size', '10px') + .attr('font-family', 'ui-sans-serif, system-ui, sans-serif') + .attr('opacity', 0) + .text(conn.label) + .transition() + .delay(400 + i * 150) + .duration(400) + .attr('opacity', 1); + }); + + // Draw nodes + nodes.forEach((node, i) => { + const pos = nodePositions[node.id]; + if (!pos) return; + const color = colors[node.type]; + + const nodeGroup = mainGroup + .append('g') + .attr('transform', `translate(${pos.x}, ${pos.y})`) + .style('cursor', 'pointer') + .on('mouseenter', function () { + d3.select(this) + .select('.node-rect') + .transition() + .duration(200) + .attr('stroke-width', 3) + .attr('filter', 'url(#overview-glow)'); + }) + .on('mouseleave', function () { + d3.select(this) + .select('.node-rect') + .transition() + .duration(200) + .attr('stroke-width', 2) + .attr('filter', 'url(#overview-shadow)'); + }) + .on('click', () => handleNodeClick(node.id)); + + // Main container rectangle + nodeGroup + .append('rect') + .attr('class', 'node-rect') + .attr('x', -pos.width / 2) + .attr('y', -pos.height / 2) + .attr('width', pos.width) + .attr('height', pos.height) + .attr('rx', 12) + .attr('fill', color.fill) + .attr('stroke', color.stroke) + .attr('stroke-width', 2) + .attr('filter', 'url(#overview-shadow)'); + + // Node title + nodeGroup + .append('text') + .attr('x', 0) + .attr('y', node.children ? -pos.height / 2 + 22 : -4) + .attr('text-anchor', 'middle') + .attr('fill', color.text) + .attr('font-size', isMobile ? '14px' : '15px') + .attr('font-weight', '700') + .attr('font-family', 'ui-sans-serif, system-ui, sans-serif') + .text(node.label); + + // Node sublabel + if (node.sublabel) { + nodeGroup + .append('text') + .attr('x', 0) + .attr('y', node.children ? -pos.height / 2 + 38 : 14) + .attr('text-anchor', 'middle') + .attr('fill', color.text) + .attr('font-size', '11px') + .attr('font-family', 'ui-sans-serif, system-ui, sans-serif') + .attr('opacity', 0.7) + .text(node.sublabel); + } + + // Child items (if any) + if (node.children) { + const childStartY = -pos.height / 2 + 50; + const childHeight = 22; + const childGap = 4; + + node.children.forEach((child, ci) => { + const childY = childStartY + ci * (childHeight + childGap); + + // Child pill background + nodeGroup + .append('rect') + .attr('x', -pos.width / 2 + 12) + .attr('y', childY) + .attr('width', pos.width - 24) + .attr('height', childHeight) + .attr('rx', 4) + .attr('fill', isDark ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.6)') + .attr('stroke', isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') + .attr('stroke-width', 1); + + // Child label + nodeGroup + .append('text') + .attr('x', 0) + .attr('y', childY + childHeight / 2 + 4) + .attr('text-anchor', 'middle') + .attr('fill', color.text) + .attr('font-size', '11px') + .attr('font-family', 'ui-monospace, monospace') + .text(child.label); + }); + } + + // Entrance animation + const startTransform = isMobile + ? `translate(${pos.x}, ${pos.y - 20})` + : `translate(${pos.x - 20}, ${pos.y})`; + const endTransform = `translate(${pos.x}, ${pos.y})`; + + nodeGroup + .attr('opacity', 0) + .attr('transform', startTransform) + .transition() + .delay(100 + i * 60) + .duration(400) + .ease(d3.easeCubicOut) + .attr('opacity', 1) + .attr('transform', endTransform); + }); + }, [dimensions, mounted, resolvedTheme, handleNodeClick]); + + // Don't render until mounted (to avoid hydration mismatch) + if (!mounted) { + return ( +
+
+
+ ); + } + + const selectedDetail = selectedNode ? nodeDetails[selectedNode] : null; + + return ( +
+
+ {/* Subtle grid background */} +
+ + +
+ + {/* Node Detail Overlay */} + {selectedDetail && selectedNode && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/architecture/ExecutorsDiagram.tsx b/apps/web/src/components/architecture/ExecutorsDiagram.tsx new file mode 100644 index 0000000..de39c43 --- /dev/null +++ b/apps/web/src/components/architecture/ExecutorsDiagram.tsx @@ -0,0 +1,292 @@ +'use client'; + +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { useDiagramSetup } from './useDiagramSetup'; + +/** + * ExecutorsDiagram - Visualizes tool execution flow + * Shows: Request → Executor → Sandbox → Tool Loading → Result + */ +export function ExecutorsDiagram(): React.ReactElement { + const { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + isDark, + setupSvg, + drawConnection, + drawNode, + } = useDiagramSetup({ defaultHeight: 400 }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: svgRef.current is a ref and doesn't need to be in deps + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + const mainGroup = setupSvg(svg); + + const { width, height } = dimensions; + + // Layout - left to right flow + const col1 = Math.max(80, width * 0.12); + const col2 = Math.max(190, width * 0.32); + const col3 = Math.max(320, width * 0.55); + const col4 = Math.min(width - 80, width * 0.85); + + const topY = 55; + const midY = height / 2; + const bottomY = height - 55; + + // === Request (left) === + drawNode(mainGroup, col1, midY - 50, 120, 60, { + label: 'API Request', + sublabel: 'POST /execute', + type: 'primary', + tooltip: { + title: 'Execution Request', + description: 'Client sends tool ID, parameters, and optional environment variables.', + }, + delay: 0, + }); + + // Request components + const requestParts = [ + { + label: 'toolId', + tooltip: { title: 'Tool ID', description: 'Package name and export identifier.' }, + }, + { + label: 'params', + tooltip: { title: 'Parameters', description: 'Input arguments matching tool schema.' }, + }, + { + label: 'env', + tooltip: { title: 'Environment', description: 'API keys and secrets (per-request).' }, + }, + ]; + + requestParts.forEach((part, i) => { + drawNode(mainGroup, col1, midY + 35 + i * 38, 85, 30, { + label: part.label, + type: 'neutral', + tooltip: part.tooltip, + delay: 50 + i * 30, + }); + }); + + // === Executor (center-left) === + drawNode(mainGroup, col2, topY, 150, 60, { + label: 'Official Executor', + sublabel: 'Railway (Deno)', + type: 'danger', + tooltip: { + title: 'Official Executor', + description: 'Hosted on Railway. Sandboxed Deno runtime for secure execution.', + }, + delay: 150, + }); + + drawNode(mainGroup, col2, topY + 90, 150, 60, { + label: 'Custom Executor', + sublabel: 'Self-hosted', + type: 'neutral', + tooltip: { + title: 'Custom Executor', + description: 'Deploy your own executor. Same API contract, your infrastructure.', + }, + delay: 180, + }); + + // === Sandbox (center-right) === + drawNode(mainGroup, col3, midY - 25, 160, 70, { + label: 'Deno Sandbox', + sublabel: 'isolated runtime', + type: 'info', + tooltip: { + title: 'Deno Sandbox', + description: + 'Secure V8 isolate. No filesystem access. Network requests allowed for tool APIs.', + }, + delay: 250, + }); + + // Sandbox features + const sandboxFeatures = [ + { + label: 'No FS access', + tooltip: { title: 'No Filesystem', description: 'Tools cannot read or write local files.' }, + }, + { + label: 'Time limit', + tooltip: { title: 'Time Limit', description: '60 second max execution time.' }, + }, + { + label: 'Memory cap', + tooltip: { title: 'Memory Cap', description: 'Limited memory per execution.' }, + }, + ]; + + sandboxFeatures.forEach((f, i) => { + drawNode(mainGroup, col3, midY + 60 + i * 38, 105, 30, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 300 + i * 30, + }); + }); + + // === Tool Loading (top right) === + drawNode(mainGroup, col4, topY + 35, 130, 60, { + label: 'Dynamic Import', + sublabel: 'esm.sh', + type: 'success', + tooltip: { + title: 'Dynamic Import', + description: 'Tool code loaded from esm.sh CDN. No pre-installation required.', + }, + delay: 400, + }); + + // === Result (bottom right) === + drawNode(mainGroup, col4, bottomY - 40, 130, 60, { + label: 'Result', + sublabel: 'JSON response', + type: 'success', + tooltip: { + title: 'Execution Result', + description: 'Tool output, execution time, and any errors returned to client.', + }, + delay: 450, + }); + + // Result fields + const resultFields = [ + { label: 'output', tooltip: { title: 'Output', description: 'Tool return value.' } }, + { label: 'timeMs', tooltip: { title: 'Timing', description: 'Execution duration.' } }, + { label: 'error?', tooltip: { title: 'Error', description: 'Error message if failed.' } }, + ]; + + resultFields.forEach((f, i) => { + const x = col4 - 55 + i * 55; + drawNode(mainGroup, x, bottomY + 15, 50, 28, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 500 + i * 30, + }); + }); + + // === Connections === + // Request to executor + drawConnection(mainGroup, col1 + 60, midY - 50, col2 - 75, topY + 15, { + animated: true, + delay: 580, + }); + drawConnection(mainGroup, col1 + 60, midY - 30, col2 - 75, topY + 105, { + animated: true, + dashed: true, + delay: 600, + }); + + // Request to parts + drawConnection(mainGroup, col1, midY - 20, col1, midY + 20, { + animated: true, + dashed: true, + delay: 620, + }); + + // Executors to sandbox + drawConnection(mainGroup, col2 + 75, topY + 25, col3 - 80, midY - 40, { + animated: true, + delay: 640, + }); + drawConnection(mainGroup, col2 + 75, topY + 105, col3 - 80, midY - 15, { + animated: true, + dashed: true, + delay: 660, + }); + + // Sandbox to features + drawConnection(mainGroup, col3, midY + 10, col3, midY + 45, { + animated: true, + dashed: true, + delay: 680, + }); + + // Sandbox to tool loading + drawConnection(mainGroup, col3 + 65, midY - 45, col4 - 65, topY + 50, { + label: 'import', + animated: true, + curved: true, + delay: 700, + }); + + // Tool loading to result + drawConnection(mainGroup, col4, topY + 65, col4, bottomY - 70, { + label: 'execute', + animated: true, + delay: 720, + }); + + // Result to fields + drawConnection(mainGroup, col4, bottomY - 10, col4, bottomY + 1, { + animated: true, + dashed: true, + delay: 740, + }); + + // Security badge + const securityGroup = mainGroup + .append('g') + .attr('transform', `translate(${col3}, ${bottomY + 5})`); + + securityGroup + .append('rect') + .attr('x', -60) + .attr('y', -14) + .attr('width', 120) + .attr('height', 28) + .attr('rx', 14) + .attr('fill', isDark ? '#1b4332' : '#e8f5e9') + .attr('stroke', isDark ? '#66bb6a' : '#388e3c'); + + securityGroup + .append('text') + .attr('text-anchor', 'middle') + .attr('y', 5) + .attr('fill', isDark ? '#a5d6a7' : '#1b5e20') + .attr('font-size', '12px') + .attr('font-weight', '600') + .text('🔒 Sandboxed'); + }, [dimensions, mounted, isDark, setupSvg, drawConnection, drawNode]); + + if (!mounted) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/components/architecture/NpmRegistryDiagram.tsx b/apps/web/src/components/architecture/NpmRegistryDiagram.tsx new file mode 100644 index 0000000..62d8fb4 --- /dev/null +++ b/apps/web/src/components/architecture/NpmRegistryDiagram.tsx @@ -0,0 +1,231 @@ +'use client'; + +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { useDiagramSetup } from './useDiagramSetup'; + +/** + * NpmRegistryDiagram - Visualizes npm package discovery and loading + * Shows: npm publish → registry → esm.sh → runtime loading + */ +export function NpmRegistryDiagram(): React.ReactElement { + const { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + isDark, + setupSvg, + drawConnection, + drawNode, + } = useDiagramSetup({ defaultHeight: 380 }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: svgRef.current is a ref and doesn't need to be in deps + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + const mainGroup = setupSvg(svg); + + const { width, height } = dimensions; + const centerY = height / 2; + + // Layout - horizontal flow with more spacing + const col1 = Math.max(90, width * 0.12); + const col2 = Math.max(200, width * 0.32); + const col3 = Math.max(340, width * 0.55); + const col4 = Math.min(width - 90, width * 0.85); + + // === Developer publishes === + drawNode(mainGroup, col1, centerY - 60, 120, 55, { + label: 'Developer', + sublabel: 'npm publish', + type: 'neutral', + tooltip: { + title: 'Developer Publishes', + description: 'Run "npm publish" to upload your package to the npm registry.', + }, + delay: 0, + }); + + // === npm Registry === + drawNode(mainGroup, col2, centerY - 60, 150, 65, { + label: 'npm Registry', + sublabel: 'registry.npmjs.org', + type: 'warning', + tooltip: { + title: 'npm Registry', + description: 'The world\'s largest software registry. Hosts packages with "tpmjs" keyword.', + }, + delay: 100, + }); + + // Registry features + const features = [ + { + label: '_changes feed', + tooltip: { title: 'Changes Feed', description: 'Real-time stream of package updates.' }, + }, + { + label: 'search API', + tooltip: { + title: 'Search API', + description: 'Query packages by keyword, name, or description.', + }, + }, + { + label: 'tarball CDN', + tooltip: { + title: 'Tarball CDN', + description: 'Download package source code as compressed archives.', + }, + }, + ]; + + features.forEach((f, i) => { + drawNode(mainGroup, col2, centerY + 35 + i * 42, 120, 34, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 150 + i * 50, + }); + }); + + // === esm.sh CDN === + drawNode(mainGroup, col3, centerY - 60, 140, 65, { + label: 'esm.sh', + sublabel: 'ESM CDN', + type: 'info', + tooltip: { + title: 'esm.sh CDN', + description: 'Transforms npm packages to ES modules on-the-fly. Zero build step required.', + }, + delay: 300, + }); + + // esm.sh features + const esmFeatures = [ + { + label: 'auto-bundling', + tooltip: { title: 'Auto Bundling', description: 'Dependencies bundled automatically.' }, + }, + { + label: 'TypeScript', + tooltip: { title: 'TypeScript Support', description: 'Type definitions included.' }, + }, + { + label: 'tree-shaking', + tooltip: { title: 'Tree Shaking', description: 'Only imports what you use.' }, + }, + ]; + + esmFeatures.forEach((f, i) => { + drawNode(mainGroup, col3, centerY + 35 + i * 42, 115, 34, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 350 + i * 50, + }); + }); + + // === Runtime Loading === + drawNode(mainGroup, col4, centerY, 130, 70, { + label: 'Runtime', + sublabel: 'dynamic import()', + type: 'success', + tooltip: { + title: 'Runtime Loading', + description: 'Tools loaded dynamically at execution time. No pre-installation needed.', + }, + delay: 500, + }); + + // === Connections === + // Developer to npm + drawConnection(mainGroup, col1 + 60, centerY - 60, col2 - 75, centerY - 60, { + label: 'publish', + animated: true, + delay: 550, + }); + + // npm to features + drawConnection(mainGroup, col2, centerY - 27, col2, centerY + 18, { + animated: true, + dashed: true, + delay: 600, + }); + + // npm to esm.sh + drawConnection(mainGroup, col2 + 75, centerY - 60, col3 - 70, centerY - 60, { + label: 'sync', + animated: true, + delay: 650, + }); + + // esm.sh to features + drawConnection(mainGroup, col3, centerY - 27, col3, centerY + 18, { + animated: true, + dashed: true, + delay: 700, + }); + + // esm.sh to runtime + drawConnection(mainGroup, col3 + 70, centerY - 35, col4 - 65, centerY - 20, { + label: 'import', + animated: true, + delay: 750, + }); + + // Decorative: URL example + const urlGroup = mainGroup + .append('g') + .attr('transform', `translate(${(col3 + col4) / 2}, ${centerY + 100})`); + + urlGroup + .append('rect') + .attr('x', -140) + .attr('y', -15) + .attr('width', 280) + .attr('height', 30) + .attr('rx', 6) + .attr('fill', isDark ? '#1a1a2e' : '#f8f9fa') + .attr('stroke', isDark ? '#333' : '#e9ecef'); + + urlGroup + .append('text') + .attr('text-anchor', 'middle') + .attr('y', 5) + .attr('fill', isDark ? '#90caf9' : '#1976d2') + .attr('font-size', '11px') + .attr('font-family', 'ui-monospace, monospace') + .text('esm.sh/@tpmjs/weather-tool@latest'); + }, [dimensions, mounted, isDark, setupSvg, drawConnection, drawNode]); + + if (!mounted) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/components/architecture/OutputsDiagram.tsx b/apps/web/src/components/architecture/OutputsDiagram.tsx new file mode 100644 index 0000000..776ea0b --- /dev/null +++ b/apps/web/src/components/architecture/OutputsDiagram.tsx @@ -0,0 +1,291 @@ +'use client'; + +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { useDiagramSetup } from './useDiagramSetup'; + +/** + * OutputsDiagram - Visualizes response format options + * Shows: Executor Output → SSE (streaming) / JSON-RPC (MCP) + */ +export function OutputsDiagram(): React.ReactElement { + const { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + isDark, + setupSvg, + drawConnection, + drawNode, + } = useDiagramSetup({ defaultHeight: 400 }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: svgRef.current is a ref and doesn't need to be in deps + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + const mainGroup = setupSvg(svg); + + const { width, height } = dimensions; + + // Layout - split view for two formats + const centerX = width / 2; + const leftX = Math.max(110, width * 0.28); + const rightX = Math.min(width - 110, width * 0.72); + + const topY = 60; + const midY = height / 2; + const bottomY = height - 55; + + // === Source: Tool Execution Result (top center) === + drawNode(mainGroup, centerX, topY, 170, 60, { + label: 'Execution Result', + sublabel: 'from sandbox', + type: 'primary', + tooltip: { + title: 'Execution Result', + description: + 'Tool output from sandboxed Deno runtime. Contains result data, timing, and errors.', + }, + delay: 0, + }); + + // Result fields + const resultFields = [ + { label: 'data', tooltip: { title: 'Data', description: "The tool's return value." } }, + { label: 'timing', tooltip: { title: 'Timing', description: 'Execution duration in ms.' } }, + { label: 'status', tooltip: { title: 'Status', description: 'Success or error state.' } }, + ]; + + resultFields.forEach((f, i) => { + const x = centerX - 60 + i * 60; + drawNode(mainGroup, x, topY + 55, 55, 28, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 50 + i * 25, + }); + }); + + // === SSE Response (left side) === + drawNode(mainGroup, leftX, midY, 150, 65, { + label: 'SSE Response', + sublabel: 'Server-Sent Events', + type: 'success', + tooltip: { + title: 'SSE Streaming', + description: + 'Real-time streaming for AI agents. Events flow as they are generated. Best for chat UIs.', + }, + delay: 150, + }); + + // SSE features + const sseFeatures = [ + { + label: 'text/event-stream', + tooltip: { title: 'Content Type', description: 'Standard SSE MIME type for browsers.' }, + }, + { + label: 'Real-time chunks', + tooltip: { + title: 'Streaming Chunks', + description: 'Data sent incrementally as tool executes.', + }, + }, + { + label: 'Vercel AI SDK', + tooltip: { + title: 'SDK Compatibility', + description: 'Works with useChat, streamText, and AI SDK primitives.', + }, + }, + ]; + + sseFeatures.forEach((f, i) => { + drawNode(mainGroup, leftX, midY + 55 + i * 38, 125, 30, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 200 + i * 30, + }); + }); + + // === JSON-RPC Response (right side) === + drawNode(mainGroup, rightX, midY, 150, 65, { + label: 'JSON-RPC', + sublabel: 'MCP Protocol', + type: 'info', + tooltip: { + title: 'JSON-RPC 2.0', + description: + 'Model Context Protocol standard. Used by Claude Desktop, Cursor, and MCP clients.', + }, + delay: 300, + }); + + // JSON-RPC features + const rpcFeatures = [ + { + label: 'application/json', + tooltip: { title: 'Content Type', description: 'Standard JSON MIME type.' }, + }, + { + label: 'Request/Response', + tooltip: { + title: 'Request Pattern', + description: 'Single request, single response model.', + }, + }, + { + label: 'MCP Clients', + tooltip: { + title: 'Client Support', + description: 'Claude Desktop, Cursor IDE, and any MCP-compatible client.', + }, + }, + ]; + + rpcFeatures.forEach((f, i) => { + drawNode(mainGroup, rightX, midY + 55 + i * 38, 125, 30, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 350 + i * 30, + }); + }); + + // === Use Case Labels (bottom) === + drawNode(mainGroup, leftX, bottomY, 130, 50, { + label: 'AI Agents', + sublabel: 'Chat interfaces', + type: 'secondary', + tooltip: { + title: 'Agent Use Case', + description: 'TPMJS Agents use SSE for streaming chat responses to the browser.', + }, + delay: 450, + }); + + drawNode(mainGroup, rightX, bottomY, 130, 50, { + label: 'MCP Servers', + sublabel: 'Tool serving', + type: 'secondary', + tooltip: { + title: 'MCP Use Case', + description: 'MCP servers expose tools via JSON-RPC for desktop AI applications.', + }, + delay: 480, + }); + + // === Connections === + // Result to fields + drawConnection(mainGroup, centerX, topY + 30, centerX, topY + 41, { + animated: true, + dashed: true, + delay: 500, + }); + + // Result to SSE + drawConnection(mainGroup, centerX - 70, topY + 45, leftX + 60, midY - 35, { + label: 'stream', + animated: true, + curved: true, + delay: 520, + }); + + // Result to JSON-RPC + drawConnection(mainGroup, centerX + 70, topY + 45, rightX - 60, midY - 35, { + label: 'respond', + animated: true, + curved: true, + delay: 540, + }); + + // SSE to features + drawConnection(mainGroup, leftX, midY + 32, leftX, midY + 40, { + animated: true, + dashed: true, + delay: 560, + }); + + // JSON-RPC to features + drawConnection(mainGroup, rightX, midY + 32, rightX, midY + 40, { + animated: true, + dashed: true, + delay: 580, + }); + + // SSE to use case + drawConnection(mainGroup, leftX, midY + 55 + 2 * 38 + 15, leftX, bottomY - 25, { + animated: true, + delay: 600, + }); + + // JSON-RPC to use case + drawConnection(mainGroup, rightX, midY + 55 + 2 * 38 + 15, rightX, bottomY - 25, { + animated: true, + delay: 620, + }); + + // Protocol badges + const badges = [ + { x: leftX, label: '📡 Streaming', color: isDark ? '#1b4332' : '#e8f5e9' }, + { x: rightX, label: '📦 Batched', color: isDark ? '#1a3a5c' : '#e3f2fd' }, + ]; + + badges.forEach((badge) => { + const badgeGroup = mainGroup + .append('g') + .attr('transform', `translate(${badge.x}, ${midY - 55})`); + + badgeGroup + .append('rect') + .attr('x', -50) + .attr('y', -12) + .attr('width', 100) + .attr('height', 24) + .attr('rx', 12) + .attr('fill', badge.color) + .attr('stroke', isDark ? '#444' : '#ccc'); + + badgeGroup + .append('text') + .attr('text-anchor', 'middle') + .attr('y', 5) + .attr('fill', isDark ? '#e0e0e0' : '#333') + .attr('font-size', '11px') + .attr('font-weight', '500') + .text(badge.label); + }); + }, [dimensions, mounted, isDark, setupSvg, drawConnection, drawNode]); + + if (!mounted) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/components/architecture/ToolsDiagram.tsx b/apps/web/src/components/architecture/ToolsDiagram.tsx new file mode 100644 index 0000000..22dd716 --- /dev/null +++ b/apps/web/src/components/architecture/ToolsDiagram.tsx @@ -0,0 +1,220 @@ +'use client'; + +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { useDiagramSetup } from './useDiagramSetup'; + +/** + * ToolsDiagram - Visualizes the anatomy of a TPMJS tool package + * Shows: package.json structure → exports → AI SDK tool format + */ +export function ToolsDiagram(): React.ReactElement { + const { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + isDark, + setupSvg, + drawConnection, + drawNode, + } = useDiagramSetup({ defaultHeight: 400 }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: svgRef.current is a ref and doesn't need to be in deps + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + const mainGroup = setupSvg(svg); + + const { width, height } = dimensions; + const centerX = width / 2; + + // Layout constants - more spacious + const leftX = Math.max(100, width * 0.22); + const rightX = Math.min(width - 100, width * 0.78); + const topY = 70; + const midY = height / 2; + const bottomY = height - 70; + + // === Package.json section (left) === + drawNode(mainGroup, leftX, topY, 160, 60, { + label: 'package.json', + sublabel: 'npm package', + type: 'warning', + tooltip: { + title: 'Package Manifest', + description: + 'Standard npm package.json with "tpmjs" keyword and tpmjs configuration field.', + }, + delay: 0, + }); + + // Keywords box + drawNode(mainGroup, leftX - 55, midY - 15, 100, 44, { + label: '"tpmjs"', + sublabel: 'keyword', + type: 'neutral', + tooltip: { + title: 'Required Keyword', + description: 'Add "tpmjs" to keywords array for automatic discovery by the registry.', + }, + delay: 100, + }); + + // tpmjs field box + drawNode(mainGroup, leftX + 55, midY - 15, 100, 44, { + label: 'tpmjs: {...}', + sublabel: 'config', + type: 'neutral', + tooltip: { + title: 'TPMJS Configuration', + description: 'Category, frameworks, environment variables, and optional tool definitions.', + }, + delay: 150, + }); + + // === Tool Export (center) === + drawNode(mainGroup, centerX, midY + 50, 180, 65, { + label: 'Tool Export', + sublabel: 'ES Module', + type: 'primary', + tooltip: { + title: 'Tool Export', + description: + 'Named export with description, parameters (Zod schema), and execute function.', + }, + delay: 200, + }); + + // === AI SDK Format (right) === + drawNode(mainGroup, rightX, topY, 150, 60, { + label: 'AI SDK Tool', + sublabel: 'Vercel AI', + type: 'success', + tooltip: { + title: 'AI SDK Compatible', + description: + 'Tools work with Vercel AI SDK, LangChain, and any framework supporting the tool format.', + }, + delay: 250, + }); + + // Tool properties + const props = [ + { + label: 'description', + sublabel: 'string', + tooltip: { title: 'Description', description: 'Human-readable description for the AI.' }, + }, + { + label: 'parameters', + sublabel: 'z.object()', + tooltip: { title: 'Parameters', description: 'Zod schema defining input validation.' }, + }, + { + label: 'execute()', + sublabel: 'async fn', + tooltip: { title: 'Execute Function', description: 'Async function that runs the tool.' }, + }, + ]; + + props.forEach((prop, i) => { + const y = bottomY - 30 + (i - 1) * 44; + drawNode(mainGroup, rightX, y, 120, 36, { + label: prop.label, + sublabel: prop.sublabel, + type: i === 1 ? 'info' : 'neutral', + tooltip: prop.tooltip, + delay: 300 + i * 50, + }); + }); + + // === Connections === + // package.json to keywords/config + drawConnection(mainGroup, leftX - 35, topY + 30, leftX - 55, midY - 37, { + animated: true, + delay: 400, + }); + drawConnection(mainGroup, leftX + 35, topY + 30, leftX + 55, midY - 37, { + animated: true, + delay: 450, + }); + + // Config to tool export + drawConnection(mainGroup, leftX + 55, midY + 7, centerX - 90, midY + 50, { + label: 'exports', + animated: true, + delay: 500, + }); + + // Tool export to AI SDK + drawConnection(mainGroup, centerX + 90, midY + 35, rightX - 75, topY + 25, { + label: 'compatible', + animated: true, + curved: true, + delay: 550, + }); + + // AI SDK to properties + drawConnection(mainGroup, rightX, topY + 30, rightX, bottomY - 74, { + animated: true, + dashed: true, + delay: 600, + }); + + // Code example annotation + const codeGroup = mainGroup + .append('g') + .attr('transform', `translate(${centerX}, ${bottomY + 10})`); + + codeGroup + .append('rect') + .attr('x', -120) + .attr('y', -18) + .attr('width', 240) + .attr('height', 36) + .attr('rx', 6) + .attr('fill', isDark ? '#1a1a2e' : '#f8f9fa') + .attr('stroke', isDark ? '#333' : '#e9ecef') + .attr('stroke-width', 1); + + codeGroup + .append('text') + .attr('x', 0) + .attr('y', 5) + .attr('text-anchor', 'middle') + .attr('fill', isDark ? '#a5d6a7' : '#2e7d32') + .attr('font-size', '12px') + .attr('font-family', 'ui-monospace, monospace') + .text('export const myTool = tool({...})'); + }, [dimensions, mounted, isDark, setupSvg, drawConnection, drawNode]); + + if (!mounted) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/components/architecture/TpmjsDiagram.tsx b/apps/web/src/components/architecture/TpmjsDiagram.tsx new file mode 100644 index 0000000..5a2cb26 --- /dev/null +++ b/apps/web/src/components/architecture/TpmjsDiagram.tsx @@ -0,0 +1,308 @@ +'use client'; + +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { useDiagramSetup } from './useDiagramSetup'; + +/** + * TpmjsDiagram - Visualizes the TPMJS sync pipeline + * Shows: Sync workers → Validation → Database → Quality Score → API + */ +export function TpmjsDiagram(): React.ReactElement { + const { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + setupSvg, + drawConnection, + drawNode, + } = useDiagramSetup({ defaultHeight: 420 }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: svgRef.current is a ref and doesn't need to be in deps + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + const mainGroup = setupSvg(svg); + + const { width, height } = dimensions; + + // Layout - 3 columns + const leftX = Math.max(90, width * 0.18); + const centerX = width / 2; + const rightX = Math.min(width - 90, width * 0.82); + + const topY = 55; + const bottomY = height - 55; + + // === Sync Workers (top left) === + drawNode(mainGroup, leftX, topY, 150, 60, { + label: 'Sync Workers', + sublabel: 'Vercel Cron', + type: 'info', + tooltip: { + title: 'Sync Workers', + description: 'Automated jobs running on Vercel Cron to discover and sync npm packages.', + }, + delay: 0, + }); + + // Sync types + const syncTypes = [ + { + label: 'Changes Feed', + sublabel: 'every 2 min', + tooltip: { + title: 'Changes Feed Sync', + description: 'Monitors npm _changes endpoint for real-time package updates.', + }, + }, + { + label: 'Keyword Search', + sublabel: 'every 15 min', + tooltip: { + title: 'Keyword Search', + description: 'Searches npm for packages with "tpmjs" keyword. Catches any missed.', + }, + }, + { + label: 'Metrics Sync', + sublabel: 'hourly', + tooltip: { + title: 'Metrics Sync', + description: 'Updates download counts and calculates quality scores.', + }, + }, + ]; + + syncTypes.forEach((sync, i) => { + drawNode(mainGroup, leftX, topY + 80 + i * 48, 130, 40, { + label: sync.label, + sublabel: sync.sublabel, + type: 'neutral', + tooltip: sync.tooltip, + delay: 50 + i * 40, + }); + }); + + // === Validation (center top) === + drawNode(mainGroup, centerX, topY + 30, 160, 60, { + label: 'Schema Validation', + sublabel: 'Zod + TPMJS Spec', + type: 'primary', + tooltip: { + title: 'Schema Validation', + description: + 'Validates package.json tpmjs field against specification. Invalid packages rejected.', + }, + delay: 200, + }); + + // Validation steps + const validationSteps = [ + { + label: 'Parse metadata', + tooltip: { + title: 'Parse Metadata', + description: 'Extract tpmjs field and validate structure.', + }, + }, + { + label: 'Import package', + tooltip: { + title: 'Import Package', + description: 'Dynamically import via esm.sh to verify it loads.', + }, + }, + { + label: 'Extract schemas', + tooltip: { + title: 'Extract Schemas', + description: 'Automatically extract Zod schemas from tool exports.', + }, + }, + ]; + + validationSteps.forEach((step, i) => { + drawNode(mainGroup, centerX, topY + 110 + i * 44, 125, 36, { + label: step.label, + type: 'neutral', + tooltip: step.tooltip, + delay: 250 + i * 40, + }); + }); + + // === Database (center bottom) === + drawNode(mainGroup, centerX, bottomY - 55, 160, 60, { + label: 'PostgreSQL', + sublabel: 'Neon Database', + type: 'success', + tooltip: { + title: 'PostgreSQL Database', + description: + 'Hosted on Neon with connection pooling. Stores tool metadata, health status, logs.', + }, + delay: 400, + }); + + // Database tables + const tables = ['Tool', 'Package', 'SyncLog']; + tables.forEach((table, i) => { + const x = centerX - 70 + i * 70; + drawNode(mainGroup, x, bottomY, 60, 30, { + label: table, + type: 'neutral', + tooltip: { + title: `${table} Table`, + description: `Stores ${table.toLowerCase()} records in the registry database.`, + }, + delay: 450 + i * 30, + }); + }); + + // === Quality Score (right top) === + drawNode(mainGroup, rightX, topY + 40, 150, 60, { + label: 'Quality Score', + sublabel: '0.00 - 1.00', + type: 'warning', + tooltip: { + title: 'Quality Score', + description: 'Calculated from metadata tier, npm downloads, and GitHub stars.', + }, + delay: 500, + }); + + // Score components + const scoreComponents = [ + { + label: 'Tier (60%)', + tooltip: { + title: 'Metadata Tier', + description: 'Rich metadata = 0.6, Minimal = 0.4 base score.', + }, + }, + { + label: 'Downloads (30%)', + tooltip: { + title: 'npm Downloads', + description: 'Logarithmic scale of monthly downloads, max 0.3.', + }, + }, + { + label: 'Stars (10%)', + tooltip: { title: 'GitHub Stars', description: 'Logarithmic scale of stars, max 0.1.' }, + }, + ]; + + scoreComponents.forEach((comp, i) => { + drawNode(mainGroup, rightX, topY + 120 + i * 42, 110, 34, { + label: comp.label, + type: 'neutral', + tooltip: comp.tooltip, + delay: 550 + i * 30, + }); + }); + + // === REST API (right bottom) === + drawNode(mainGroup, rightX, bottomY - 40, 140, 60, { + label: 'REST API', + sublabel: '/api/tools', + type: 'primary', + tooltip: { + title: 'REST API', + description: 'Public API for searching and retrieving tool metadata.', + }, + delay: 650, + }); + + // === Connections === + // Sync workers to validation + drawConnection(mainGroup, leftX + 75, topY + 60, centerX - 80, topY + 30, { + animated: true, + delay: 700, + }); + + // Sync to details + drawConnection(mainGroup, leftX, topY + 60, leftX, topY + 56 + 80, { + animated: true, + dashed: true, + delay: 720, + }); + + // Validation to steps + drawConnection(mainGroup, centerX, topY + 60, centerX, topY + 92, { + animated: true, + dashed: true, + delay: 740, + }); + + // Validation to database + drawConnection(mainGroup, centerX, topY + 110 + 2 * 44 + 18, centerX, bottomY - 85, { + label: 'store', + animated: true, + delay: 760, + }); + + // Database to tables + drawConnection(mainGroup, centerX, bottomY - 25, centerX, bottomY - 15, { + animated: true, + dashed: true, + delay: 780, + }); + + // Validation to quality score + drawConnection(mainGroup, centerX + 80, topY + 40, rightX - 75, topY + 40, { + animated: true, + delay: 800, + }); + + // Quality to components + drawConnection(mainGroup, rightX, topY + 70, rightX, topY + 103, { + animated: true, + dashed: true, + delay: 820, + }); + + // Database to API + drawConnection(mainGroup, centerX + 80, bottomY - 55, rightX - 70, bottomY - 45, { + label: 'query', + animated: true, + delay: 840, + }); + + // Quality to API + drawConnection(mainGroup, rightX, topY + 120 + 2 * 42 + 17, rightX, bottomY - 70, { + animated: true, + delay: 860, + }); + }, [dimensions, mounted, setupSvg, drawConnection, drawNode]); + + if (!mounted) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/components/architecture/UsersDiagram.tsx b/apps/web/src/components/architecture/UsersDiagram.tsx new file mode 100644 index 0000000..5f91751 --- /dev/null +++ b/apps/web/src/components/architecture/UsersDiagram.tsx @@ -0,0 +1,271 @@ +'use client'; + +import * as d3 from 'd3'; +import { useEffect } from 'react'; +import { useDiagramSetup } from './useDiagramSetup'; + +/** + * UsersDiagram - Visualizes user workflows and entities + * Shows: User → Collections → Agents / MCP Servers → Sharing + */ +export function UsersDiagram(): React.ReactElement { + const { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + setupSvg, + drawConnection, + drawNode, + } = useDiagramSetup({ defaultHeight: 400 }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: svgRef.current is a ref and doesn't need to be in deps + useEffect(() => { + if (!svgRef.current || !mounted) return; + + const svg = d3.select(svgRef.current); + const mainGroup = setupSvg(svg); + + const { width, height } = dimensions; + + // Layout - hub and spoke from user + const centerX = width / 2; + const userY = 55; + const midY = height / 2 - 10; + const bottomY = height - 70; + + // Responsive column positions + const leftX = Math.max(100, width * 0.2); + const rightX = Math.min(width - 100, width * 0.8); + + // === User (top center) === + drawNode(mainGroup, centerX, userY, 120, 60, { + label: 'User', + sublabel: 'authenticated', + type: 'secondary', + tooltip: { + title: 'Authenticated User', + description: 'Sign in with GitHub or email to create and manage your tools.', + }, + delay: 0, + }); + + // === Collections (center) === + drawNode(mainGroup, centerX, midY, 150, 65, { + label: 'Collections', + sublabel: 'group tools', + type: 'primary', + tooltip: { + title: 'Tool Collections', + description: 'Curate tools for specific use cases. Share with your team or the community.', + }, + delay: 100, + }); + + // Collection features + const collectionFeatures = [ + { + label: 'Add tools', + tooltip: { + title: 'Add Tools', + description: 'Browse registry and add any tool to your collection.', + }, + }, + { + label: 'Configure env', + tooltip: { + title: 'Environment Variables', + description: 'Set API keys that are injected when tools execute.', + }, + }, + { + label: 'Share URL', + tooltip: { + title: 'Shareable URL', + description: 'Each collection gets a unique public URL.', + }, + }, + ]; + + collectionFeatures.forEach((f, i) => { + const x = centerX - 85 + i * 85; + drawNode(mainGroup, x, midY + 60, 75, 32, { + label: f.label, + type: 'neutral', + tooltip: f.tooltip, + delay: 150 + i * 30, + }); + }); + + // === Agents (bottom left) === + drawNode(mainGroup, leftX, bottomY, 140, 65, { + label: 'AI Agents', + sublabel: 'conversational', + type: 'success', + tooltip: { + title: 'AI Agents', + description: 'Create custom AI assistants with tool access. Multi-provider support.', + }, + delay: 250, + }); + + // Agent features + drawNode(mainGroup, leftX - 55, bottomY + 58, 95, 32, { + label: 'Chat UI', + type: 'neutral', + tooltip: { + title: 'Chat Interface', + description: 'Built-in conversation UI with message history.', + }, + delay: 280, + }); + + drawNode(mainGroup, leftX + 55, bottomY + 58, 95, 32, { + label: 'API Access', + type: 'neutral', + tooltip: { title: 'API Access', description: 'Programmatic access to agents via REST API.' }, + delay: 310, + }); + + // === MCP Servers (bottom right) === + drawNode(mainGroup, rightX, bottomY, 140, 65, { + label: 'MCP Servers', + sublabel: 'protocol', + type: 'info', + tooltip: { + title: 'MCP Servers', + description: + 'Auto-generated MCP endpoints. Connect Claude Desktop, Cursor, or any MCP client.', + }, + delay: 340, + }); + + // MCP clients + const mcpClients = [ + { + label: 'Claude', + tooltip: { title: 'Claude Desktop', description: "Anthropic's desktop AI." }, + }, + { label: 'Cursor', tooltip: { title: 'Cursor IDE', description: 'AI-powered code editor.' } }, + { + label: 'Custom', + tooltip: { title: 'Custom Client', description: 'Any MCP-compatible client.' }, + }, + ]; + + mcpClients.forEach((client, i) => { + drawNode(mainGroup, rightX - 65 + i * 65, bottomY + 58, 60, 32, { + label: client.label, + type: 'neutral', + tooltip: client.tooltip, + delay: 370 + i * 30, + }); + }); + + // === Public sharing (top sides) === + drawNode(mainGroup, leftX, userY + 35, 110, 50, { + label: 'Fork', + sublabel: 'clone & customize', + type: 'neutral', + tooltip: { + title: 'Fork Collections', + description: 'Clone public collections to customize for your needs.', + }, + delay: 450, + }); + + drawNode(mainGroup, rightX, userY + 35, 110, 50, { + label: 'Browse', + sublabel: 'discover tools', + type: 'neutral', + tooltip: { + title: 'Browse Public', + description: 'Explore public collections and agents from the community.', + }, + delay: 480, + }); + + // === Connections === + // User to collections + drawConnection(mainGroup, centerX, userY + 30, centerX, midY - 32, { + label: 'create', + animated: true, + delay: 500, + }); + + // User to fork/browse + drawConnection(mainGroup, centerX - 50, userY + 20, leftX + 55, userY + 25, { + animated: true, + delay: 520, + }); + drawConnection(mainGroup, centerX + 50, userY + 20, rightX - 55, userY + 25, { + animated: true, + delay: 540, + }); + + // Collections to features + drawConnection(mainGroup, centerX, midY + 32, centerX, midY + 44, { + animated: true, + dashed: true, + delay: 560, + }); + + // Collections to Agents + drawConnection(mainGroup, centerX - 60, midY + 25, leftX + 60, bottomY - 35, { + label: 'powers', + animated: true, + curved: true, + delay: 580, + }); + + // Collections to MCP + drawConnection(mainGroup, centerX + 60, midY + 25, rightX - 60, bottomY - 35, { + label: 'exposes', + animated: true, + curved: true, + delay: 600, + }); + + // Agents to features + drawConnection(mainGroup, leftX, bottomY + 32, leftX, bottomY + 42, { + animated: true, + dashed: true, + delay: 620, + }); + + // MCP to clients + drawConnection(mainGroup, rightX, bottomY + 32, rightX, bottomY + 42, { + animated: true, + dashed: true, + delay: 640, + }); + }, [dimensions, mounted, setupSvg, drawConnection, drawNode]); + + if (!mounted) { + return ( +
+
+
+ ); + } + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/src/components/architecture/types.ts b/apps/web/src/components/architecture/types.ts new file mode 100644 index 0000000..e56779f --- /dev/null +++ b/apps/web/src/components/architecture/types.ts @@ -0,0 +1,62 @@ +// Shared types for architecture diagrams + +export interface DiagramNode { + id: string; + label: string; + sublabel?: string; + x: number; + y: number; + width: number; + height: number; + type: string; + tooltip?: { + title: string; + description: string; + }; +} + +export interface DiagramConnection { + from: string; + to: string; + label?: string; + animated?: boolean; + dashed?: boolean; +} + +export interface ColorScheme { + fill: string; + stroke: string; + text: string; +} + +export interface ThemeColors { + [key: string]: ColorScheme; +} + +export interface DiagramDimensions { + width: number; + height: number; +} + +// Color palettes for different diagram types +export const lightColors = { + primary: { fill: '#e3f2fd', stroke: '#1976d2', text: '#0d47a1' }, + secondary: { fill: '#f3e5f5', stroke: '#7b1fa2', text: '#4a148c' }, + success: { fill: '#e8f5e9', stroke: '#388e3c', text: '#1b5e20' }, + warning: { fill: '#fff3e0', stroke: '#f57c00', text: '#e65100' }, + danger: { fill: '#fce4ec', stroke: '#c2185b', text: '#880e4f' }, + neutral: { fill: '#f5f5f5', stroke: '#616161', text: '#212121' }, + info: { fill: '#e0f7fa', stroke: '#00838f', text: '#006064' }, + code: { fill: '#263238', stroke: '#546e7a', text: '#eceff1' }, +}; + +export const darkColors = { + primary: { fill: '#1e3a5f', stroke: '#64b5f6', text: '#90caf9' }, + secondary: { fill: '#3a1f5c', stroke: '#ba68c8', text: '#ce93d8' }, + success: { fill: '#1b4332', stroke: '#66bb6a', text: '#a5d6a7' }, + warning: { fill: '#4a3000', stroke: '#ffb74d', text: '#ffe0b2' }, + danger: { fill: '#4a1f35', stroke: '#f06292', text: '#f48fb1' }, + neutral: { fill: '#2d2d2d', stroke: '#9e9e9e', text: '#e0e0e0' }, + info: { fill: '#004d5a', stroke: '#4dd0e1', text: '#b2ebf2' }, + code: { fill: '#1a1a2e', stroke: '#4fc3f7', text: '#e0e0e0' }, +}; diff --git a/apps/web/src/components/architecture/useDiagramSetup.ts b/apps/web/src/components/architecture/useDiagramSetup.ts new file mode 100644 index 0000000..e15e3b1 --- /dev/null +++ b/apps/web/src/components/architecture/useDiagramSetup.ts @@ -0,0 +1,392 @@ +'use client'; + +import * as d3 from 'd3'; +import { useTheme } from 'next-themes'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { DiagramDimensions, ThemeColors } from './types'; +import { darkColors, lightColors } from './types'; + +interface UseDiagramSetupOptions { + defaultWidth?: number; + defaultHeight?: number; + minHeight?: number; +} + +export function useDiagramSetup(options: UseDiagramSetupOptions = {}) { + const { defaultWidth = 500, defaultHeight = 300, minHeight = 200 } = options; + + const svgRef = useRef(null); + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [dimensions, setDimensions] = useState({ + width: defaultWidth, + height: defaultHeight, + }); + const [mounted, setMounted] = useState(false); + const { resolvedTheme } = useTheme(); + + // Handle mounting + useEffect(() => { + setMounted(true); + }, []); + + // Handle resize + useEffect(() => { + const updateDimensions = () => { + if (containerRef.current) { + const width = containerRef.current.clientWidth; + const height = Math.max(minHeight, defaultHeight); + setDimensions({ width, height }); + } + }; + + updateDimensions(); + window.addEventListener('resize', updateDimensions); + return () => window.removeEventListener('resize', updateDimensions); + }, [defaultHeight, minHeight]); + + const isDark = resolvedTheme === 'dark'; + const colors: ThemeColors = isDark ? darkColors : lightColors; + + // Show tooltip + const showTooltip = useCallback((event: MouseEvent, title: string, description: string) => { + if (!tooltipRef.current) return; + + const tooltip = tooltipRef.current; + tooltip.innerHTML = ` +
${title}
+
${description}
+ `; + tooltip.style.opacity = '1'; + tooltip.style.visibility = 'visible'; + + // Position tooltip + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + tooltip.style.left = `${x + 10}px`; + tooltip.style.top = `${y - 10}px`; + } + }, []); + + // Hide tooltip + const hideTooltip = useCallback(() => { + if (!tooltipRef.current) return; + tooltipRef.current.style.opacity = '0'; + tooltipRef.current.style.visibility = 'hidden'; + }, []); + + // Setup SVG with common definitions (filters, markers) + const setupSvg = useCallback( + (svg: d3.Selection) => { + svg.selectAll('*').remove(); + + const defs = svg.append('defs'); + + // Drop shadow filter + const shadow = defs + .append('filter') + .attr('id', 'section-shadow') + .attr('x', '-20%') + .attr('y', '-20%') + .attr('width', '140%') + .attr('height', '140%'); + shadow + .append('feDropShadow') + .attr('dx', '0') + .attr('dy', '2') + .attr('stdDeviation', '3') + .attr('flood-color', isDark ? '#000' : '#000') + .attr('flood-opacity', isDark ? '0.3' : '0.1'); + + // Glow filter for hover + const glow = defs + .append('filter') + .attr('id', 'section-glow') + .attr('x', '-50%') + .attr('y', '-50%') + .attr('width', '200%') + .attr('height', '200%'); + glow.append('feGaussianBlur').attr('stdDeviation', '3').attr('result', 'coloredBlur'); + const glowMerge = glow.append('feMerge'); + glowMerge.append('feMergeNode').attr('in', 'coloredBlur'); + glowMerge.append('feMergeNode').attr('in', 'SourceGraphic'); + + // Arrow marker + defs + .append('marker') + .attr('id', 'section-arrow') + .attr('viewBox', '0 -5 10 10') + .attr('refX', 8) + .attr('refY', 0) + .attr('markerWidth', 5) + .attr('markerHeight', 5) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', isDark ? '#666' : '#999'); + + // Gradient definitions for visual interest + const gradientPrimary = defs + .append('linearGradient') + .attr('id', 'gradient-primary') + .attr('x1', '0%') + .attr('y1', '0%') + .attr('x2', '100%') + .attr('y2', '100%'); + gradientPrimary + .append('stop') + .attr('offset', '0%') + .attr('stop-color', colors.primary?.fill ?? '#e3f2fd'); + gradientPrimary + .append('stop') + .attr('offset', '100%') + .attr('stop-color', isDark ? '#2a4a7f' : '#bbdefb'); + + return svg.append('g').attr('class', 'main-group'); + }, + [isDark, colors] + ); + + // Draw animated connection line + const drawConnection = useCallback( + ( + group: d3.Selection, + x1: number, + y1: number, + x2: number, + y2: number, + options: { + label?: string; + animated?: boolean; + dashed?: boolean; + curved?: boolean; + delay?: number; + } = {} + ) => { + const { label, animated = true, dashed = false, curved = false, delay = 0 } = options; + + let pathData: string; + if (curved) { + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + const ctrlY = midY - Math.abs(x2 - x1) * 0.3; + pathData = `M ${x1} ${y1} Q ${midX} ${ctrlY}, ${x2} ${y2}`; + } else { + pathData = `M ${x1} ${y1} L ${x2} ${y2}`; + } + + // Background path + group + .append('path') + .attr('d', pathData) + .attr('fill', 'none') + .attr('stroke', isDark ? '#333' : '#e5e5e5') + .attr('stroke-width', 2); + + // Animated path + const path = group + .append('path') + .attr('d', pathData) + .attr('fill', 'none') + .attr('stroke', isDark ? '#555' : '#aaa') + .attr('stroke-width', 2) + .attr('stroke-linecap', 'round') + .attr('marker-end', 'url(#section-arrow)'); + + if (dashed) { + path.attr('stroke-dasharray', '6,4'); + } + + // Animate the path drawing + const pathNode = path.node(); + if (pathNode && animated) { + const pathLength = pathNode.getTotalLength(); + path + .attr('stroke-dasharray', `${pathLength} ${pathLength}`) + .attr('stroke-dashoffset', pathLength) + .transition() + .delay(delay) + .duration(600) + .ease(d3.easeCubicOut) + .attr('stroke-dashoffset', 0) + .on('end', () => { + if (dashed) { + path.attr('stroke-dasharray', '6,4').attr('stroke-dashoffset', 0); + // Start flow animation + const animateFlow = () => { + path + .transition() + .duration(1000) + .ease(d3.easeLinear) + .attr('stroke-dashoffset', -20) + .on('end', () => { + path.attr('stroke-dashoffset', 0); + animateFlow(); + }); + }; + animateFlow(); + } + }); + } + + // Label + if (label) { + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; + group + .append('text') + .attr('x', midX) + .attr('y', midY - 8) + .attr('text-anchor', 'middle') + .attr('fill', isDark ? '#888' : '#666') + .attr('font-size', '10px') + .attr('font-family', 'ui-sans-serif, system-ui, sans-serif') + .attr('opacity', 0) + .text(label) + .transition() + .delay(delay + 300) + .duration(300) + .attr('opacity', 1); + } + }, + [isDark] + ); + + // Draw a node box + const drawNode = useCallback( + ( + group: d3.Selection, + x: number, + y: number, + width: number, + height: number, + options: { + label: string; + sublabel?: string; + type?: string; + tooltip?: { title: string; description: string }; + delay?: number; + icon?: string; + } + ) => { + const { label, sublabel, type = 'neutral', tooltip, delay = 0, icon } = options; + const color = colors[type as keyof ThemeColors] ?? + colors.neutral ?? { + fill: '#f5f5f5', + stroke: '#9e9e9e', + text: '#424242', + }; + + const nodeGroup = group + .append('g') + .attr('transform', `translate(${x}, ${y})`) + .style('cursor', tooltip ? 'pointer' : 'default'); + + // Main rectangle + const rect = nodeGroup + .append('rect') + .attr('class', 'node-rect') + .attr('x', -width / 2) + .attr('y', -height / 2) + .attr('width', width) + .attr('height', height) + .attr('rx', 8) + .attr('fill', color.fill) + .attr('stroke', color.stroke) + .attr('stroke-width', 1.5) + .attr('filter', 'url(#section-shadow)'); + + // Hover effects + if (tooltip) { + nodeGroup + .on('mouseenter', (event) => { + rect + .transition() + .duration(150) + .attr('stroke-width', 2.5) + .attr('filter', 'url(#section-glow)'); + showTooltip(event as MouseEvent, tooltip.title, tooltip.description); + }) + .on('mousemove', (event) => { + showTooltip(event as MouseEvent, tooltip.title, tooltip.description); + }) + .on('mouseleave', () => { + rect + .transition() + .duration(150) + .attr('stroke-width', 1.5) + .attr('filter', 'url(#section-shadow)'); + hideTooltip(); + }); + } + + // Icon (if provided) + if (icon) { + nodeGroup + .append('text') + .attr('x', 0) + .attr('y', sublabel ? -height / 2 + 20 : -4) + .attr('text-anchor', 'middle') + .attr('font-size', '16px') + .text(icon); + } + + // Label + nodeGroup + .append('text') + .attr('x', 0) + .attr('y', sublabel ? (icon ? 0 : -6) : 4) + .attr('text-anchor', 'middle') + .attr('fill', color.text) + .attr('font-size', '12px') + .attr('font-weight', '600') + .attr('font-family', 'ui-sans-serif, system-ui, sans-serif') + .text(label); + + // Sublabel + if (sublabel) { + nodeGroup + .append('text') + .attr('x', 0) + .attr('y', icon ? 16 : 10) + .attr('text-anchor', 'middle') + .attr('fill', color.text) + .attr('font-size', '10px') + .attr('font-family', 'ui-sans-serif, system-ui, sans-serif') + .attr('opacity', 0.7) + .text(sublabel); + } + + // Entrance animation + nodeGroup + .attr('opacity', 0) + .attr('transform', `translate(${x}, ${y - 10})`) + .transition() + .delay(delay) + .duration(400) + .ease(d3.easeCubicOut) + .attr('opacity', 1) + .attr('transform', `translate(${x}, ${y})`); + + return nodeGroup; + }, + [colors, showTooltip, hideTooltip] + ); + + return { + svgRef, + containerRef, + tooltipRef, + dimensions, + mounted, + isDark, + colors, + setupSvg, + drawConnection, + drawNode, + showTooltip, + hideTooltip, + }; +}