feat: add interactive architecture diagram page

This commit is contained in:
Ajax Davis 2026-01-20 19:52:31 +10:00
parent 474e7f9cda
commit ad974239fd
11 changed files with 2924 additions and 0 deletions

View file

@ -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 (
<div className="min-h-screen flex flex-col bg-background">
<AppHeader />
<main className="flex-1">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4 text-foreground">
TPMJS System Architecture
</h1>
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto">
See how tools flow from npm to execution. Click any node to learn more.
</p>
</div>
{/* Diagram */}
<SystemOverviewDiagram />
{/* Legend */}
<div className="mt-8 sm:mt-12 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[
{ 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) => (
<div key={item.label} className="flex items-center gap-2">
<div className={`w-4 h-4 rounded border-2 ${item.color}`} />
<span className="text-sm text-foreground-secondary">{item.label}</span>
</div>
))}
</div>
</div>
</main>
</div>
);
}

View file

@ -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<string, React.ComponentType> = {
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 */}
<div
className={`fixed inset-0 z-50 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-4 md:inset-8 lg:inset-12 z-50 flex items-center justify-center pointer-events-none">
{/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: stopPropagation prevents backdrop click-to-close */}
<div
className={`relative w-full max-w-6xl max-h-full bg-background border border-border rounded-2xl shadow-2xl overflow-hidden pointer-events-auto flex flex-col transition-all duration-250 ease-out ${
isVisible ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-5'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-surface/50">
<h2 className="text-xl font-bold text-foreground">{title}</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-surface transition-colors text-foreground-secondary hover:text-foreground"
aria-label="Close"
>
<Icon icon="x" size="md" />
</button>
</div>
{/* Content - Two column layout */}
<div className="flex-1 overflow-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[500px]">
{/* Left: Diagram */}
<div className="p-6 bg-surface/30 border-b lg:border-b-0 lg:border-r border-border flex flex-col">
<h3 className="text-sm font-semibold text-foreground uppercase tracking-wide mb-4">
How It Works
</h3>
<div className="flex-1 bg-background rounded-xl border border-border p-4 min-h-[350px]">
{DiagramComponent && <DiagramComponent />}
</div>
<p className="text-xs text-foreground-tertiary italic mt-3 text-center">
Hover over elements for more details
</p>
</div>
{/* Right: Details */}
<div className="p-6 flex flex-col gap-6">
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-foreground uppercase tracking-wide mb-3">
Overview
</h3>
<p className="text-foreground-secondary leading-relaxed">{description}</p>
</div>
{/* Bullet points */}
<div>
<h3 className="text-sm font-semibold text-foreground uppercase tracking-wide mb-3">
Key Features
</h3>
<ul className="space-y-2">
{bullets.map((bullet, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: bullets are static strings without unique IDs
<li key={i} className="flex items-start gap-3">
<Icon
icon="check"
size="sm"
className="flex-shrink-0 mt-0.5 text-primary"
/>
<span className="text-sm text-foreground-secondary">{bullet}</span>
</li>
))}
</ul>
</div>
{/* Links */}
{links && links.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-foreground uppercase tracking-wide mb-3">
Learn More
</h3>
<div className="flex flex-wrap gap-2">
{links.map((link) => (
<Link key={link.href} href={link.href}>
<Button variant="outline" size="sm">
{link.label}
<Icon icon="arrowRight" size="sm" className="ml-1" />
</Button>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end px-6 py-4 border-t border-border bg-surface/50">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
</>
);
}

View file

@ -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<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 280 });
const [selectedNode, setSelectedNode] = useState<string | null>(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<string, { x: number; y: number; width: number; height: number }>;
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 (
<div ref={containerRef} className="w-full">
<div className="p-4 md:p-6 border border-border rounded-xl bg-surface/50 min-h-[280px]" />
</div>
);
}
const selectedDetail = selectedNode ? nodeDetails[selectedNode] : null;
return (
<div ref={containerRef} className="w-full">
<div className="relative p-4 md:p-6 border border-border rounded-xl bg-surface/50 backdrop-blur overflow-hidden">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.02] dark:opacity-[0.05]"
style={{
backgroundImage: `
linear-gradient(to right, currentColor 1px, transparent 1px),
linear-gradient(to bottom, currentColor 1px, transparent 1px)
`,
backgroundSize: '32px 32px',
}}
/>
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="mx-auto relative"
style={{ maxWidth: '100%', height: 'auto' }}
role="img"
aria-label="TPMJS System Overview diagram showing the flow from Tools to npm Registry to TPMJS Platform to Users to Executors to Outputs"
/>
</div>
{/* Node Detail Overlay */}
{selectedDetail && selectedNode && (
<NodeDetailOverlay
open={!!selectedNode}
onClose={handleCloseDrawer}
nodeId={selectedNode}
title={selectedDetail.title}
description={selectedDetail.description}
bullets={selectedDetail.bullets}
links={selectedDetail.links}
/>
)}
</div>
);
}

View file

@ -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 (
<div ref={containerRef} className="w-full">
<div className="h-80 bg-surface/50 rounded-lg animate-pulse" />
</div>
);
}
return (
<div ref={containerRef} className="w-full relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="w-full"
role="img"
aria-label="Tool Execution flow diagram"
/>
<div
ref={tooltipRef}
className="absolute z-50 px-3 py-2 bg-background border border-border rounded-lg shadow-lg max-w-xs pointer-events-none transition-opacity duration-150"
style={{ opacity: 0, visibility: 'hidden' }}
/>
</div>
);
}

View file

@ -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 (
<div ref={containerRef} className="w-full">
<div className="h-80 bg-surface/50 rounded-lg animate-pulse" />
</div>
);
}
return (
<div ref={containerRef} className="w-full relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="w-full"
role="img"
aria-label="npm Registry discovery and loading diagram"
/>
<div
ref={tooltipRef}
className="absolute z-50 px-3 py-2 bg-background border border-border rounded-lg shadow-lg max-w-xs pointer-events-none transition-opacity duration-150"
style={{ opacity: 0, visibility: 'hidden' }}
/>
</div>
);
}

View file

@ -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 (
<div ref={containerRef} className="w-full">
<div className="h-80 bg-surface/50 rounded-lg animate-pulse" />
</div>
);
}
return (
<div ref={containerRef} className="w-full relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="w-full"
role="img"
aria-label="Output formats diagram showing SSE and JSON-RPC"
/>
<div
ref={tooltipRef}
className="absolute z-50 px-3 py-2 bg-background border border-border rounded-lg shadow-lg max-w-xs pointer-events-none transition-opacity duration-150"
style={{ opacity: 0, visibility: 'hidden' }}
/>
</div>
);
}

View file

@ -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 (
<div ref={containerRef} className="w-full">
<div className="h-80 bg-surface/50 rounded-lg animate-pulse" />
</div>
);
}
return (
<div ref={containerRef} className="w-full relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="w-full"
role="img"
aria-label="TPMJS Tool package structure diagram"
/>
<div
ref={tooltipRef}
className="absolute z-50 px-3 py-2 bg-background border border-border rounded-lg shadow-lg max-w-xs pointer-events-none transition-opacity duration-150"
style={{ opacity: 0, visibility: 'hidden' }}
/>
</div>
);
}

View file

@ -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 (
<div ref={containerRef} className="w-full">
<div className="h-80 bg-surface/50 rounded-lg animate-pulse" />
</div>
);
}
return (
<div ref={containerRef} className="w-full relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="w-full"
role="img"
aria-label="TPMJS Platform sync pipeline diagram"
/>
<div
ref={tooltipRef}
className="absolute z-50 px-3 py-2 bg-background border border-border rounded-lg shadow-lg max-w-xs pointer-events-none transition-opacity duration-150"
style={{ opacity: 0, visibility: 'hidden' }}
/>
</div>
);
}

View file

@ -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 (
<div ref={containerRef} className="w-full">
<div className="h-80 bg-surface/50 rounded-lg animate-pulse" />
</div>
);
}
return (
<div ref={containerRef} className="w-full relative">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className="w-full"
role="img"
aria-label="Users and Collections workflow diagram"
/>
<div
ref={tooltipRef}
className="absolute z-50 px-3 py-2 bg-background border border-border rounded-lg shadow-lg max-w-xs pointer-events-none transition-opacity duration-150"
style={{ opacity: 0, visibility: 'hidden' }}
/>
</div>
);
}

View file

@ -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' },
};

View file

@ -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<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState<DiagramDimensions>({
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 = `
<div class="font-semibold text-sm text-foreground mb-1">${title}</div>
<div class="text-xs text-foreground-secondary">${description}</div>
`;
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<SVGSVGElement, unknown, null, undefined>) => {
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<SVGGElement, unknown, null, undefined>,
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<SVGGElement, unknown, null, undefined>,
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,
};
}