feat: add interactive architecture diagram page
This commit is contained in:
parent
474e7f9cda
commit
ad974239fd
11 changed files with 2924 additions and 0 deletions
53
apps/web/src/app/architecture/page.tsx
Normal file
53
apps/web/src/app/architecture/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/web/src/components/NodeDetailOverlay.tsx
Normal file
178
apps/web/src/components/NodeDetailOverlay.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
626
apps/web/src/components/SystemOverviewDiagram.tsx
Normal file
626
apps/web/src/components/SystemOverviewDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
292
apps/web/src/components/architecture/ExecutorsDiagram.tsx
Normal file
292
apps/web/src/components/architecture/ExecutorsDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
apps/web/src/components/architecture/NpmRegistryDiagram.tsx
Normal file
231
apps/web/src/components/architecture/NpmRegistryDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
apps/web/src/components/architecture/OutputsDiagram.tsx
Normal file
291
apps/web/src/components/architecture/OutputsDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
apps/web/src/components/architecture/ToolsDiagram.tsx
Normal file
220
apps/web/src/components/architecture/ToolsDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
apps/web/src/components/architecture/TpmjsDiagram.tsx
Normal file
308
apps/web/src/components/architecture/TpmjsDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
271
apps/web/src/components/architecture/UsersDiagram.tsx
Normal file
271
apps/web/src/components/architecture/UsersDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/architecture/types.ts
Normal file
62
apps/web/src/components/architecture/types.ts
Normal 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' },
|
||||
};
|
||||
392
apps/web/src/components/architecture/useDiagramSetup.ts
Normal file
392
apps/web/src/components/architecture/useDiagramSetup.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue