2166 lines
82 KiB
HTML
2166 lines
82 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Claude Code 2025 - Activity Graph</title>
|
|
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0a0a0f;
|
|
--text-primary: #e0e0e0;
|
|
--text-accent: #d97706;
|
|
--agent-color: #f59e0b;
|
|
--mcp-color: #8b5cf6;
|
|
--command-color: #10b981;
|
|
--skill-color: #ec4899;
|
|
--tool-color: #3b82f6;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
height: 100vh;
|
|
}
|
|
|
|
#canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
|
|
.hud {
|
|
position: absolute;
|
|
top: 20px;
|
|
left: 20px;
|
|
background: rgba(10, 10, 15, 0.8);
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
backdrop-filter: blur(10px);
|
|
pointer-events: auto;
|
|
min-width: 280px;
|
|
}
|
|
|
|
.current-date {
|
|
display: none;
|
|
}
|
|
|
|
.stat-line {
|
|
font-size: 16px;
|
|
margin: 8px 0;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.stat-value {
|
|
color: var(--text-accent);
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.legend {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: rgba(10, 10, 15, 0.8);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
backdrop-filter: blur(10px);
|
|
font-size: 12px;
|
|
max-height: calc(100vh - 200px);
|
|
overflow-y: auto;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
/* Custom scrollbar */
|
|
.legend::-webkit-scrollbar,
|
|
#toolsList::-webkit-scrollbar,
|
|
#componentsList::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.legend::-webkit-scrollbar-track,
|
|
#toolsList::-webkit-scrollbar-track,
|
|
#componentsList::-webkit-scrollbar-track {
|
|
background: rgba(255,255,255,0.05);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.legend::-webkit-scrollbar-thumb,
|
|
#toolsList::-webkit-scrollbar-thumb,
|
|
#componentsList::-webkit-scrollbar-thumb {
|
|
background: rgba(255,255,255,0.2);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.legend::-webkit-scrollbar-thumb:hover,
|
|
#toolsList::-webkit-scrollbar-thumb:hover,
|
|
#componentsList::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255,255,255,0.3);
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
box-shadow: 0 0 8px currentColor;
|
|
}
|
|
|
|
.timeline {
|
|
display: none;
|
|
}
|
|
|
|
.timeline-bar {
|
|
height: 3px;
|
|
background: rgba(255,255,255,0.1);
|
|
border-radius: 2px;
|
|
position: relative;
|
|
}
|
|
|
|
.timeline-progress {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--text-accent), #fbbf24);
|
|
width: 0%;
|
|
box-shadow: 0 0 10px var(--text-accent);
|
|
}
|
|
|
|
.timeline-labels {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 8px;
|
|
font-size: 10px;
|
|
color: rgba(255,255,255,0.4);
|
|
}
|
|
|
|
.controls {
|
|
position: absolute;
|
|
bottom: 90px;
|
|
right: 20px;
|
|
display: flex;
|
|
gap: 8px;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.control-btn {
|
|
background: rgba(26, 26, 46, 0.8);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
color: var(--text-primary);
|
|
padding: 8px 16px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 11px;
|
|
transition: all 0.2s;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: var(--text-accent);
|
|
border-color: var(--text-accent);
|
|
}
|
|
|
|
.control-btn.active {
|
|
background: var(--text-accent);
|
|
border-color: var(--text-accent);
|
|
}
|
|
|
|
.intro-screen {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(10, 10, 15, 0.95);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
backdrop-filter: blur(20px);
|
|
}
|
|
|
|
.intro-title {
|
|
font-size: 64px;
|
|
font-weight: 700;
|
|
color: var(--text-accent);
|
|
margin-bottom: 15px;
|
|
text-shadow: 0 0 30px var(--text-accent);
|
|
}
|
|
|
|
.intro-subtitle {
|
|
font-size: 20px;
|
|
color: var(--text-primary);
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.start-btn {
|
|
background: var(--text-accent);
|
|
border: none;
|
|
color: white;
|
|
padding: 18px 36px;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
box-shadow: 0 0 20px var(--text-accent);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.start-btn:hover {
|
|
transform: scale(1.05);
|
|
box-shadow: 0 0 30px var(--text-accent);
|
|
}
|
|
|
|
.loading {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid rgba(255,255,255,0.1);
|
|
border-top-color: var(--text-accent);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 15px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.event-toast {
|
|
position: absolute;
|
|
top: 80px;
|
|
left: 50%;
|
|
transform: translate(-50%, -100%);
|
|
background: rgba(26, 26, 46, 0.95);
|
|
border: 2px solid var(--text-accent);
|
|
border-radius: 8px;
|
|
padding: 15px 35px;
|
|
text-align: center;
|
|
box-shadow: 0 0 30px var(--text-accent);
|
|
z-index: 50;
|
|
backdrop-filter: blur(20px);
|
|
opacity: 0;
|
|
}
|
|
|
|
.event-toast.show {
|
|
animation: toastSlide 2.5s ease forwards;
|
|
}
|
|
|
|
@keyframes toastSlide {
|
|
0% {
|
|
transform: translate(-50%, -100%);
|
|
opacity: 0;
|
|
}
|
|
10% {
|
|
transform: translate(-50%, 0);
|
|
opacity: 1;
|
|
}
|
|
90% {
|
|
transform: translate(-50%, 0);
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
transform: translate(-50%, -100%);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.event-toast h3 {
|
|
font-size: 18px;
|
|
color: var(--text-accent);
|
|
margin: 0;
|
|
}
|
|
|
|
.tooltip {
|
|
position: absolute;
|
|
background: rgba(26, 26, 46, 0.95);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
border-radius: 8px;
|
|
padding: 10px 15px;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
pointer-events: none;
|
|
z-index: 100;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
backdrop-filter: blur(10px);
|
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tooltip.show {
|
|
opacity: 1;
|
|
}
|
|
|
|
.tooltip-title {
|
|
font-weight: 600;
|
|
color: var(--text-accent);
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.tooltip-info {
|
|
color: var(--text-secondary);
|
|
font-size: 11px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Intro Screen -->
|
|
<div id="introScreen" class="intro-screen">
|
|
<div class="loading" id="loading">
|
|
<div class="spinner"></div>
|
|
<p>Loading your journey...</p>
|
|
</div>
|
|
<div id="introContent" style="display: none;">
|
|
<h1 class="intro-title">2025</h1>
|
|
<p class="intro-subtitle">Your Year with Claude Code</p>
|
|
<button class="start-btn" onclick="startAnimation()">▶ Start</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Canvas -->
|
|
<canvas id="canvas"></canvas>
|
|
|
|
<!-- Overlay -->
|
|
<div class="overlay">
|
|
<div class="hud">
|
|
<div class="current-date" id="currentDate">Jan 1, 2025</div>
|
|
<div class="stat-line">Conversations: <span class="stat-value" id="statConversations">0</span></div>
|
|
<div class="stat-line">Components: <span class="stat-value" id="statComponents">0</span></div>
|
|
<div class="stat-line">Tool Calls: <span class="stat-value" id="statTools">0</span></div>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<div style="font-weight: 600; margin-bottom: 8px;">Models Used</div>
|
|
<div id="modelsList">
|
|
<!-- Models will be added dynamically -->
|
|
</div>
|
|
|
|
<div style="font-weight: 600; margin-bottom: 8px; margin-top: 16px;">Tools Used</div>
|
|
<div id="toolsList">
|
|
<!-- Tools will be added dynamically -->
|
|
</div>
|
|
|
|
<div style="font-weight: 600; margin-bottom: 8px; margin-top: 16px;">Components Used</div>
|
|
<div id="componentsList">
|
|
<!-- Components will be added dynamically -->
|
|
</div>
|
|
</div>
|
|
|
|
<div class="timeline">
|
|
<div class="timeline-bar">
|
|
<div class="timeline-progress" id="timelineProgress"></div>
|
|
</div>
|
|
<div class="timeline-labels">
|
|
<span>Jan</span><span>Feb</span><span>Mar</span><span>Apr</span>
|
|
<span>May</span><span>Jun</span><span>Jul</span><span>Aug</span>
|
|
<span>Sep</span><span>Oct</span><span>Nov</span><span>Dec</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button class="control-btn" onclick="restartAnimation()">🔄 Restart</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Toast -->
|
|
<div id="eventToast" class="event-toast"></div>
|
|
|
|
<!-- Tooltip -->
|
|
<div id="tooltip" class="tooltip"></div>
|
|
|
|
<script>
|
|
// Canvas setup
|
|
const canvas = document.getElementById('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// State
|
|
let animationData = null;
|
|
let isPlaying = false;
|
|
let speedMultiplier = 5; // Fixed at 5x speed
|
|
let startTime = null;
|
|
let currentDayIndex = 0;
|
|
let stats = { conversations: 0, components: 0, tools: 0 };
|
|
let statsTarget = { conversations: 0, components: 0, tools: 0 }; // Target values for animation
|
|
let statsDisplay = { conversations: 0, components: 0, tools: 0 }; // Current animated values
|
|
let processedEvents = new Set();
|
|
let shownMilestones = new Set();
|
|
let toolNodes = new Map(); // Map of tool name -> tool node
|
|
let uniqueTools = new Map(); // Track unique tools with their colors
|
|
let modelNodes = new Map(); // Map of model name -> model node
|
|
let toolQueue = []; // Queue for gradual tool processing
|
|
|
|
// Mouse tracking
|
|
let mouseX = 0;
|
|
let mouseY = 0;
|
|
let hoveredNode = null;
|
|
let componentNodes = new Map(); // Map of component name -> component node (second layer)
|
|
|
|
// Zoom and pan state
|
|
let zoom = 1;
|
|
let panX = 0;
|
|
let panY = 0;
|
|
let isDragging = false;
|
|
let draggedNode = null;
|
|
let isPanning = false;
|
|
let lastMouseX = 0;
|
|
let lastMouseY = 0;
|
|
|
|
// Graph structure
|
|
const centerNode = { x: 0, y: 0, name: 'Claude', color: '#d97706' };
|
|
const branches = {
|
|
agents: { nodes: [], angle: 0, color: '#f59e0b' },
|
|
mcps: { nodes: [], angle: Math.PI / 2, color: '#8b5cf6' },
|
|
commands: { nodes: [], angle: Math.PI, color: '#10b981' },
|
|
skills: { nodes: [], angle: 3 * Math.PI / 2, color: '#ec4899' }
|
|
};
|
|
|
|
// Active beams (tool calls) - animating beams
|
|
let beams = [];
|
|
// Beam queue for performance (limit active beams)
|
|
let beamQueue = [];
|
|
const MAX_ACTIVE_BEAMS = 10; // OPTIMIZED: Limit simultaneous beams for performance
|
|
// Permanent connections (one per node) - Map of nodeId -> beam
|
|
let permanentConnections = new Map();
|
|
|
|
// Resize canvas
|
|
function resizeCanvas() {
|
|
canvas.width = window.innerWidth;
|
|
canvas.height = window.innerHeight;
|
|
centerNode.x = canvas.width / 2;
|
|
centerNode.y = canvas.height / 2;
|
|
}
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
// Mouse tracking for tooltips
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
mouseX = e.clientX - rect.left;
|
|
mouseY = e.clientY - rect.top;
|
|
|
|
// Transform mouse coordinates to account for zoom and pan
|
|
const transformedMouseX = (mouseX - panX) / zoom;
|
|
const transformedMouseY = (mouseY - panY) / zoom;
|
|
|
|
// Find hovered node
|
|
hoveredNode = null;
|
|
|
|
// Check model nodes (pie slices in center)
|
|
const dx = transformedMouseX - centerNode.x;
|
|
const dy = transformedMouseY - centerNode.y;
|
|
const distanceFromCenter = Math.sqrt(dx * dx + dy * dy);
|
|
const mouseAngle = Math.atan2(dy, dx) + Math.PI / 2; // Offset by -90 degrees to match pie drawing
|
|
const normalizedAngle = (mouseAngle + Math.PI * 2) % (Math.PI * 2); // Normalize to 0-2π
|
|
|
|
modelNodes.forEach((node) => {
|
|
if (distanceFromCenter < node.size + 10) {
|
|
const startAngle = (node.index / node.total) * Math.PI * 2;
|
|
const endAngle = ((node.index + 1) / node.total) * Math.PI * 2;
|
|
|
|
// Check if mouse angle is within this slice's angle range
|
|
if (normalizedAngle >= startAngle && normalizedAngle <= endAngle) {
|
|
hoveredNode = { type: 'model', node };
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check tool nodes
|
|
if (!hoveredNode) {
|
|
toolNodes.forEach((node) => {
|
|
const dx = transformedMouseX - node.x;
|
|
const dy = transformedMouseY - node.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
if (distance < node.size + 5) {
|
|
hoveredNode = { type: 'tool', node };
|
|
}
|
|
});
|
|
}
|
|
|
|
// Check component nodes (second layer)
|
|
if (!hoveredNode) {
|
|
componentNodes.forEach((node) => {
|
|
const dx = transformedMouseX - node.x;
|
|
const dy = transformedMouseY - node.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
if (distance < node.size + 5) {
|
|
hoveredNode = { type: 'component', node };
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update tooltip
|
|
updateTooltip();
|
|
|
|
// Update cursor based on hover state
|
|
if (!isDragging && !isPanning) {
|
|
if (hoveredNode && (hoveredNode.type === 'tool' || hoveredNode.type === 'component')) {
|
|
canvas.style.cursor = 'grab';
|
|
} else {
|
|
canvas.style.cursor = 'default';
|
|
}
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mouseleave', () => {
|
|
hoveredNode = null;
|
|
isDragging = false;
|
|
draggedNode = null;
|
|
isPanning = false;
|
|
updateTooltip();
|
|
});
|
|
|
|
// Zoom with mouse wheel
|
|
canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mouseXCanvas = e.clientX - rect.left;
|
|
const mouseYCanvas = e.clientY - rect.top;
|
|
|
|
// Calculate zoom factor
|
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
const newZoom = Math.max(0.3, Math.min(3, zoom * zoomFactor));
|
|
|
|
// Adjust pan to zoom towards mouse position
|
|
const zoomRatio = newZoom / zoom;
|
|
panX = mouseXCanvas - (mouseXCanvas - panX) * zoomRatio;
|
|
panY = mouseYCanvas - (mouseYCanvas - panY) * zoomRatio;
|
|
|
|
zoom = newZoom;
|
|
}, { passive: false });
|
|
|
|
// Mouse down - start drag or pan
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
lastMouseX = e.clientX - rect.left;
|
|
lastMouseY = e.clientY - rect.top;
|
|
|
|
// Check if clicking on a node
|
|
if (hoveredNode && (hoveredNode.type === 'tool' || hoveredNode.type === 'component')) {
|
|
isDragging = true;
|
|
draggedNode = hoveredNode.node;
|
|
canvas.style.cursor = 'grabbing';
|
|
} else {
|
|
// Start panning
|
|
isPanning = true;
|
|
canvas.style.cursor = 'move';
|
|
}
|
|
});
|
|
|
|
// Mouse move - drag node or pan canvas
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
if (!isDragging && !isPanning) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const currentX = e.clientX - rect.left;
|
|
const currentY = e.clientY - rect.top;
|
|
const deltaX = (currentX - lastMouseX) / zoom;
|
|
const deltaY = (currentY - lastMouseY) / zoom;
|
|
|
|
if (isDragging && draggedNode) {
|
|
// Move the dragged node
|
|
draggedNode.x += deltaX;
|
|
draggedNode.y += deltaY;
|
|
draggedNode.targetX = draggedNode.x;
|
|
draggedNode.targetY = draggedNode.y;
|
|
// Update random angle to match new position (for future percentage updates)
|
|
draggedNode.randomAngle = Math.atan2(
|
|
draggedNode.y - centerNode.y,
|
|
draggedNode.x - centerNode.x
|
|
);
|
|
} else if (isPanning) {
|
|
// Pan the canvas
|
|
panX += (currentX - lastMouseX);
|
|
panY += (currentY - lastMouseY);
|
|
}
|
|
|
|
lastMouseX = currentX;
|
|
lastMouseY = currentY;
|
|
});
|
|
|
|
// Mouse up - stop drag or pan
|
|
canvas.addEventListener('mouseup', () => {
|
|
isDragging = false;
|
|
draggedNode = null;
|
|
isPanning = false;
|
|
canvas.style.cursor = 'default';
|
|
});
|
|
|
|
// Double click to reset zoom and pan
|
|
canvas.addEventListener('dblclick', () => {
|
|
zoom = 1;
|
|
panX = 0;
|
|
panY = 0;
|
|
});
|
|
|
|
// Update tooltip display
|
|
function updateTooltip() {
|
|
const tooltip = document.getElementById('tooltip');
|
|
|
|
if (!hoveredNode) {
|
|
tooltip.classList.remove('show');
|
|
return;
|
|
}
|
|
|
|
const node = hoveredNode.node;
|
|
let content = '';
|
|
|
|
if (hoveredNode.type === 'model') {
|
|
const percentage = node.count > 0 ? node.percentage || 0 : 0;
|
|
const percentDisplay = percentage >= 1 ? Math.round(percentage) + '%' : percentage.toFixed(1) + '%';
|
|
content = `
|
|
<div class="tooltip-title">${node.displayName}</div>
|
|
<div class="tooltip-info">Model • ${percentDisplay} of activity (${node.count.toLocaleString()} calls)</div>
|
|
`;
|
|
} else if (hoveredNode.type === 'tool') {
|
|
const percentDisplay = node.percentage >= 1 ? Math.round(node.percentage) + '%' : node.percentage.toFixed(1) + '%';
|
|
content = `
|
|
<div class="tooltip-title">${node.toolName}</div>
|
|
<div class="tooltip-info">Tool • ${percentDisplay} of tool calls (${node.count.toLocaleString()})</div>
|
|
`;
|
|
} else if (hoveredNode.type === 'component') {
|
|
const typeLabels = {
|
|
'command': 'Command',
|
|
'skill': 'Skill',
|
|
'mcp': 'MCP',
|
|
'subagent': 'Subagent'
|
|
};
|
|
const typeLabel = typeLabels[node.type] || node.type;
|
|
const percentDisplay = node.percentage >= 1 ? Math.round(node.percentage) + '%' : node.percentage.toFixed(1) + '%';
|
|
content = `
|
|
<div class="tooltip-title">${node.name}</div>
|
|
<div class="tooltip-info">${typeLabel} • ${percentDisplay} of components (${node.count})</div>
|
|
`;
|
|
}
|
|
|
|
tooltip.innerHTML = content;
|
|
tooltip.style.left = (mouseX + 15) + 'px';
|
|
tooltip.style.top = (mouseY - 10) + 'px';
|
|
tooltip.classList.add('show');
|
|
}
|
|
|
|
// Node class
|
|
class Node {
|
|
constructor(name, type, branch, index) {
|
|
this.name = name;
|
|
this.type = type;
|
|
this.branch = branch;
|
|
this.index = index;
|
|
this.scale = 0;
|
|
this.targetScale = 1;
|
|
this.pulse = 0;
|
|
this.usageCount = 0;
|
|
|
|
// Position based on branch
|
|
const branchData = branches[branch];
|
|
const radius = 150 + (index * 30);
|
|
const angleOffset = (index * 0.3) - (branchData.nodes.length * 0.15);
|
|
|
|
this.x = centerNode.x + Math.cos(branchData.angle + angleOffset) * radius;
|
|
this.y = centerNode.y + Math.sin(branchData.angle + angleOffset) * radius;
|
|
|
|
this.color = branchData.color;
|
|
}
|
|
|
|
update() {
|
|
// Smooth scale growth
|
|
this.scale += (this.targetScale - this.scale) * 0.1;
|
|
|
|
// Pulse decay
|
|
if (this.pulse > 0) {
|
|
this.pulse -= 0.05;
|
|
}
|
|
|
|
// Gentle floating
|
|
const time = Date.now() * 0.001;
|
|
this.floatY = Math.sin(time + this.index) * 3;
|
|
}
|
|
|
|
draw(ctx) {
|
|
const x = this.x;
|
|
const y = this.y + (this.floatY || 0);
|
|
const size = 8 * this.scale;
|
|
|
|
// Glow
|
|
const glowSize = size + this.pulse * 10;
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.3 + this.pulse * 0.4;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 15 + this.pulse * 15;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, glowSize, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Node
|
|
ctx.fillStyle = this.color;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Label
|
|
if (this.scale > 0.5) {
|
|
ctx.save();
|
|
ctx.font = '11px Monaco';
|
|
ctx.fillStyle = '#e0e0e0';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(this.name, x, y + size + 5);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Connection line to center
|
|
if (this.scale > 0.3) {
|
|
ctx.save();
|
|
ctx.strokeStyle = this.color;
|
|
ctx.globalAlpha = 0.2 * this.scale;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerNode.x, centerNode.y);
|
|
ctx.lineTo(x, y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
activate() {
|
|
this.pulse = 1;
|
|
this.usageCount++;
|
|
}
|
|
}
|
|
|
|
// ModelNode class (pie slice in center for each model)
|
|
class ModelNode {
|
|
constructor(modelName, color, index, total) {
|
|
this.modelName = modelName;
|
|
this.displayName = this.formatModelName(modelName);
|
|
this.color = color;
|
|
this.count = 0; // Number of conversations using this model
|
|
this.percentage = 0; // Percentage of total model activity
|
|
this.size = 0;
|
|
this.targetSize = 50; // Base size, will grow with usage
|
|
this.alpha = 0;
|
|
this.targetAlpha = 1;
|
|
this.pulsePhase = Math.random() * Math.PI * 2;
|
|
|
|
// Position in pie (angle range)
|
|
this.index = index;
|
|
this.total = total;
|
|
}
|
|
|
|
formatModelName(modelName) {
|
|
const nameMap = {
|
|
'claude-sonnet-4-5-20250929': 'Sonnet 4.5',
|
|
'claude-haiku-4-5-20251001': 'Haiku 4.5',
|
|
'claude-3-5-sonnet': 'Sonnet 3.5',
|
|
'claude-3-opus': 'Opus 3',
|
|
'claude-3-haiku': 'Haiku 3',
|
|
'Unknown': 'Unknown'
|
|
};
|
|
return nameMap[modelName] || modelName;
|
|
}
|
|
|
|
updatePercentage(totalModelActivity) {
|
|
this.percentage = totalModelActivity > 0 ? (this.count / totalModelActivity) * 100 : 0;
|
|
}
|
|
|
|
addUse() {
|
|
this.count++;
|
|
// Grow size with each use (cap at 100)
|
|
this.targetSize = Math.min(50 + this.count * 3, 100);
|
|
}
|
|
|
|
updatePosition(index, total) {
|
|
this.index = index;
|
|
this.total = total;
|
|
}
|
|
|
|
update() {
|
|
// Smooth size transition
|
|
if (Math.abs(this.size - this.targetSize) > 0.5) {
|
|
this.size += (this.targetSize - this.size) * 0.1;
|
|
}
|
|
|
|
// Fade in
|
|
if (this.alpha < this.targetAlpha) {
|
|
this.alpha += 0.05;
|
|
}
|
|
|
|
this.pulsePhase += 0.05;
|
|
}
|
|
|
|
draw(ctx) {
|
|
if (this.size < 1) return;
|
|
|
|
const pulse = Math.sin(this.pulsePhase) * 0.15 + 0.85;
|
|
const startAngle = (this.index / this.total) * Math.PI * 2 - Math.PI / 2;
|
|
const endAngle = ((this.index + 1) / this.total) * Math.PI * 2 - Math.PI / 2;
|
|
|
|
// Draw pie slice with glow
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha * 0.4 * pulse;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 30;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerNode.x, centerNode.y);
|
|
ctx.arc(centerNode.x, centerNode.y, this.size + 10, startAngle, endAngle);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Draw main pie slice
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 15;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerNode.x, centerNode.y);
|
|
ctx.arc(centerNode.x, centerNode.y, this.size, startAngle, endAngle);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Draw label (if slice is large enough)
|
|
if (this.size > 40 && this.total <= 3) {
|
|
const midAngle = (startAngle + endAngle) / 2;
|
|
const labelRadius = this.size * 0.6;
|
|
const labelX = centerNode.x + Math.cos(midAngle) * labelRadius;
|
|
const labelY = centerNode.y + Math.sin(midAngle) * labelRadius;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.font = 'bold 12px Monaco';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(this.displayName, labelX, labelY);
|
|
ctx.restore();
|
|
|
|
// Count badge
|
|
if (this.count > 1) {
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha * 0.8;
|
|
ctx.font = 'bold 10px Monaco';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(this.count, labelX, labelY + 15);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// Draw outer label (if too many models)
|
|
if (this.total > 3 || this.size <= 40) {
|
|
const midAngle = (startAngle + endAngle) / 2;
|
|
const labelRadius = this.size + 20;
|
|
const labelX = centerNode.x + Math.cos(midAngle) * labelRadius;
|
|
const labelY = centerNode.y + Math.sin(midAngle) * labelRadius;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.font = 'bold 11px Monaco';
|
|
ctx.fillStyle = this.color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(this.displayName, labelX, labelY);
|
|
|
|
if (this.count > 1) {
|
|
ctx.font = 'bold 9px Monaco';
|
|
ctx.fillText(`(${this.count})`, labelX, labelY + 12);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ToolNode class (persistent growing node for each tool type)
|
|
class ToolNode {
|
|
constructor(toolName, color, index, total) {
|
|
this.toolName = toolName;
|
|
this.color = color;
|
|
this.count = 0; // Number of times this tool was used
|
|
this.percentage = 0; // Percentage of total tool calls
|
|
this.size = 0;
|
|
this.targetSize = 10; // Will grow with each use
|
|
this.alpha = 0;
|
|
this.targetAlpha = 1;
|
|
this.pulsePhase = Math.random() * Math.PI * 2;
|
|
|
|
// Random angle for this node (stays fixed)
|
|
this.randomAngle = Math.random() * Math.PI * 2;
|
|
// Small random offset for organic feel
|
|
this.randomOffset = {
|
|
x: (Math.random() - 0.5) * 40,
|
|
y: (Math.random() - 0.5) * 40
|
|
};
|
|
|
|
// Initial position (will be updated based on percentage)
|
|
const radius = 350; // Start far, will move closer based on %
|
|
this.x = centerNode.x + Math.cos(this.randomAngle) * radius + this.randomOffset.x;
|
|
this.y = centerNode.y + Math.sin(this.randomAngle) * radius + this.randomOffset.y;
|
|
this.targetX = this.x;
|
|
this.targetY = this.y;
|
|
}
|
|
|
|
addUse() {
|
|
this.count++;
|
|
// Grow size with each use (cap at 40)
|
|
this.targetSize = Math.min(10 + this.count * 2, 40);
|
|
}
|
|
|
|
updatePercentage(totalToolCalls) {
|
|
this.percentage = totalToolCalls > 0 ? (this.count / totalToolCalls) * 100 : 0;
|
|
// Recalculate position based on percentage
|
|
// Higher percentage = closer to center (min 150px, max 350px)
|
|
const minRadius = 150;
|
|
const maxRadius = 350;
|
|
// Invert: high % -> low radius (closer to center)
|
|
const radius = maxRadius - (this.percentage / 100) * (maxRadius - minRadius) * 2;
|
|
const clampedRadius = Math.max(minRadius, Math.min(maxRadius, radius));
|
|
|
|
this.targetX = centerNode.x + Math.cos(this.randomAngle) * clampedRadius + this.randomOffset.x;
|
|
this.targetY = centerNode.y + Math.sin(this.randomAngle) * clampedRadius + this.randomOffset.y;
|
|
|
|
// Also scale size based on percentage
|
|
this.targetSize = Math.min(15 + this.percentage * 1.5, 50);
|
|
}
|
|
|
|
update() {
|
|
// Smooth size growth
|
|
if (this.size < this.targetSize) {
|
|
this.size += (this.targetSize - this.size) * 0.1;
|
|
}
|
|
|
|
// Fade in
|
|
if (this.alpha < this.targetAlpha) {
|
|
this.alpha += 0.05;
|
|
}
|
|
|
|
this.pulsePhase += 0.05;
|
|
|
|
// Smooth position transition
|
|
this.x += (this.targetX - this.x) * 0.05;
|
|
this.y += (this.targetY - this.y) * 0.05;
|
|
}
|
|
|
|
updatePosition(index, total) {
|
|
// Keep for compatibility, but position is now based on percentage
|
|
}
|
|
|
|
draw(ctx) {
|
|
if (this.size < 1) return;
|
|
|
|
const pulse = Math.sin(this.pulsePhase) * 0.2 + 0.8;
|
|
|
|
// Glow
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha * 0.3 * pulse;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 20;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.size + 5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Node
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 10;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Label (always visible once node appears)
|
|
if (this.size > 5) {
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.font = 'bold 11px Monaco';
|
|
ctx.fillStyle = '#e0e0e0';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(this.toolName, this.x, this.y + this.size + 5);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Percentage badge (show when percentage > 0)
|
|
if (this.percentage > 0 && this.size > 5) {
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.font = 'bold 10px Monaco';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const displayPercent = this.percentage >= 1 ?
|
|
Math.round(this.percentage) + '%' :
|
|
this.percentage.toFixed(1) + '%';
|
|
ctx.fillText(displayPercent, this.x, this.y);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ComponentNode class (second layer: commands, skills, MCPs, subagents)
|
|
class ComponentNode {
|
|
constructor(name, type, color, index, total) {
|
|
this.name = name;
|
|
this.type = type; // 'command', 'skill', 'mcp', 'subagent'
|
|
this.color = color;
|
|
this.count = 0;
|
|
this.percentage = 0; // Percentage within its type category
|
|
this.size = 0;
|
|
this.targetSize = 8; // Smaller base size for second layer
|
|
this.alpha = 0;
|
|
this.targetAlpha = 1;
|
|
this.pulsePhase = Math.random() * Math.PI * 2;
|
|
|
|
// Random angle for this node (stays fixed)
|
|
this.randomAngle = Math.random() * Math.PI * 2;
|
|
// Small random offset for organic feel
|
|
this.randomOffset = {
|
|
x: (Math.random() - 0.5) * 30,
|
|
y: (Math.random() - 0.5) * 30
|
|
};
|
|
|
|
// Position in circle around center (farther than tools)
|
|
this.index = index;
|
|
this.total = total;
|
|
const radius = 450; // Start far, will move closer based on %
|
|
this.x = centerNode.x + Math.cos(this.randomAngle) * radius + this.randomOffset.x;
|
|
this.y = centerNode.y + Math.sin(this.randomAngle) * radius + this.randomOffset.y;
|
|
this.targetX = this.x;
|
|
this.targetY = this.y;
|
|
}
|
|
|
|
addUse() {
|
|
this.count++;
|
|
// Grow size with each use (smaller max than tools)
|
|
this.targetSize = Math.min(8 + this.count * 1.5, 30);
|
|
}
|
|
|
|
updatePercentage(totalInCategory) {
|
|
this.percentage = totalInCategory > 0 ? (this.count / totalInCategory) * 100 : 0;
|
|
// Recalculate position based on percentage
|
|
// Higher percentage = closer to center (min 250px, max 450px)
|
|
const minRadius = 250;
|
|
const maxRadius = 450;
|
|
// Invert: high % -> low radius (closer to center)
|
|
const radius = maxRadius - (this.percentage / 100) * (maxRadius - minRadius) * 2;
|
|
const clampedRadius = Math.max(minRadius, Math.min(maxRadius, radius));
|
|
|
|
this.targetX = centerNode.x + Math.cos(this.randomAngle) * clampedRadius + this.randomOffset.x;
|
|
this.targetY = centerNode.y + Math.sin(this.randomAngle) * clampedRadius + this.randomOffset.y;
|
|
|
|
// Also scale size based on percentage
|
|
this.targetSize = Math.min(10 + this.percentage * 1.2, 40);
|
|
}
|
|
|
|
updatePosition(index, total) {
|
|
// Keep for compatibility, but position is now based on percentage
|
|
this.index = index;
|
|
this.total = total;
|
|
}
|
|
|
|
update() {
|
|
// Smooth transitions
|
|
if (Math.abs(this.size - this.targetSize) > 0.5) {
|
|
this.size += (this.targetSize - this.size) * 0.1;
|
|
}
|
|
|
|
if (this.alpha < this.targetAlpha) {
|
|
this.alpha += 0.05;
|
|
}
|
|
|
|
this.pulsePhase += 0.05;
|
|
|
|
// Smooth position transition
|
|
this.x += (this.targetX - this.x) * 0.05;
|
|
this.y += (this.targetY - this.y) * 0.05;
|
|
}
|
|
|
|
draw(ctx) {
|
|
if (this.size < 1) return;
|
|
|
|
const pulse = Math.sin(this.pulsePhase) * 0.2 + 0.8;
|
|
|
|
// Glow
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha * 0.3 * pulse;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 15;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.size + 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Node
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.fillStyle = this.color;
|
|
ctx.shadowBlur = 8;
|
|
ctx.shadowColor = this.color;
|
|
ctx.beginPath();
|
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Label (show for larger nodes)
|
|
if (this.size > 5) {
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.font = 'bold 9px Monaco';
|
|
ctx.fillStyle = '#e0e0e0';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
// Clean up name (remove slashes and long paths)
|
|
const displayName = this.name.startsWith('/') ? this.name : this.name.split('@')[0];
|
|
ctx.fillText(displayName, this.x, this.y + this.size + 3);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Percentage badge (show when percentage > 0)
|
|
if (this.percentage > 0 && this.size > 5) {
|
|
ctx.save();
|
|
ctx.globalAlpha = this.alpha;
|
|
ctx.font = 'bold 8px Monaco';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
const displayPercent = this.percentage >= 1 ?
|
|
Math.round(this.percentage) + '%' :
|
|
this.percentage.toFixed(1) + '%';
|
|
ctx.fillText(displayPercent, this.x, this.y);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Beam class (tool call visualization)
|
|
class Beam {
|
|
constructor(targetNode, toolName, isPermanent = false) {
|
|
this.targetNode = targetNode;
|
|
this.toolName = toolName;
|
|
this.progress = isPermanent ? 1 : 0; // Permanent starts complete
|
|
this.speed = 0.1; // OPTIMIZED: 5x faster (was 0.02)
|
|
this.color = '#3b82f6';
|
|
this.pointCreated = isPermanent; // Permanent already counted
|
|
this.isComplete = isPermanent;
|
|
this.shouldDie = false; // Flag to remove animating beam
|
|
this.pulsePhase = Math.random() * Math.PI * 2; // For subtle animation
|
|
|
|
// Random color based on tool type
|
|
const toolColors = {
|
|
'Read': '#60a5fa',
|
|
'Write': '#34d399',
|
|
'Edit': '#fbbf24',
|
|
'Bash': '#f87171',
|
|
'TodoWrite': '#a78bfa',
|
|
'Task': '#fb923c',
|
|
'Glob': '#2dd4bf',
|
|
'Grep': '#c084fc'
|
|
};
|
|
this.color = toolColors[toolName] || '#3b82f6';
|
|
}
|
|
|
|
update() {
|
|
if (!this.isComplete) {
|
|
this.progress += this.speed;
|
|
|
|
// When beam reaches destination
|
|
if (this.progress >= 1 && !this.pointCreated) {
|
|
this.pointCreated = true;
|
|
this.isComplete = true;
|
|
|
|
// OPTIMIZED: Counter already updated before beam creation, don't duplicate
|
|
// Just create permanent connection
|
|
const nodeId = this.targetNode.toolName || this.targetNode.name || 'unknown';
|
|
permanentConnections.set(nodeId, new Beam(this.targetNode, this.toolName, true));
|
|
|
|
// Mark this animating beam to be removed
|
|
this.shouldDie = true;
|
|
}
|
|
}
|
|
|
|
// Subtle pulse animation for permanent connections
|
|
this.pulsePhase += 0.02;
|
|
}
|
|
|
|
draw(ctx) {
|
|
const startX = centerNode.x;
|
|
const startY = centerNode.y;
|
|
const endX = this.targetNode.x;
|
|
const endY = this.targetNode.y + (this.targetNode.floatY || 0);
|
|
|
|
const currentX = startX + (endX - startX) * Math.min(this.progress, 1);
|
|
const currentY = startY + (endY - startY) * Math.min(this.progress, 1);
|
|
|
|
// Calculate opacity based on state
|
|
let opacity;
|
|
if (this.isComplete) {
|
|
// Permanent connection: subtle pulse effect
|
|
const pulse = Math.sin(this.pulsePhase) * 0.1 + 0.25;
|
|
opacity = pulse;
|
|
} else {
|
|
// Animating beam: full opacity
|
|
opacity = 0.6;
|
|
}
|
|
|
|
// Beam line - OPTIMIZED: No shadows for better performance
|
|
ctx.save();
|
|
ctx.strokeStyle = this.color;
|
|
ctx.globalAlpha = opacity;
|
|
ctx.lineWidth = this.isComplete ? 1 : 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(startX, startY);
|
|
ctx.lineTo(currentX, currentY);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// OPTIMIZED: Removed tool label text for performance
|
|
// OPTIMIZED: Removed particle at tip for performance
|
|
}
|
|
|
|
isDead() {
|
|
return this.shouldDie;
|
|
}
|
|
}
|
|
|
|
// Add component node
|
|
function addComponent(name, type) {
|
|
const branchName = type === 'agent' ? 'agents' :
|
|
type === 'mcp' ? 'mcps' :
|
|
type === 'command' ? 'commands' : 'skills';
|
|
|
|
const branch = branches[branchName];
|
|
const index = branch.nodes.length;
|
|
const node = new Node(name, type, branchName, index);
|
|
branch.nodes.push(node);
|
|
|
|
stats.components++;
|
|
}
|
|
|
|
// Get or create tool node
|
|
function getOrCreateToolNode(toolName, color) {
|
|
if (!toolNodes.has(toolName)) {
|
|
// Create new tool node
|
|
const index = toolNodes.size;
|
|
const total = toolNodes.size + 1;
|
|
const node = new ToolNode(toolName, color, index, total);
|
|
toolNodes.set(toolName, node);
|
|
|
|
// Reposition all nodes to distribute evenly
|
|
let i = 0;
|
|
toolNodes.forEach((node, name) => {
|
|
node.updatePosition(i, toolNodes.size);
|
|
i++;
|
|
});
|
|
|
|
// Add to unique tools for legend
|
|
if (!uniqueTools.has(toolName)) {
|
|
uniqueTools.set(toolName, color);
|
|
updateToolsList();
|
|
}
|
|
}
|
|
|
|
return toolNodes.get(toolName);
|
|
}
|
|
|
|
// Get or create model node
|
|
function getOrCreateModelNode(modelName) {
|
|
if (!modelNodes.has(modelName)) {
|
|
// Assign colors to models
|
|
const modelColors = {
|
|
'claude-sonnet-4-5-20250929': '#3b82f6', // Blue for Sonnet 4.5
|
|
'claude-haiku-4-5-20251001': '#10b981', // Green for Haiku 4.5
|
|
'claude-3-5-sonnet': '#8b5cf6', // Purple for Sonnet 3.5
|
|
'claude-3-opus': '#f59e0b', // Orange for Opus 3
|
|
'claude-3-haiku': '#14b8a6', // Teal for Haiku 3
|
|
'Unknown': '#6b7280' // Gray for Unknown
|
|
};
|
|
const color = modelColors[modelName] || '#6b7280';
|
|
|
|
// Create new model node
|
|
const index = modelNodes.size;
|
|
const total = modelNodes.size + 1;
|
|
const node = new ModelNode(modelName, color, index, total);
|
|
modelNodes.set(modelName, node);
|
|
|
|
// Reposition all nodes to distribute evenly in pie
|
|
let i = 0;
|
|
modelNodes.forEach((node, name) => {
|
|
node.updatePosition(i, modelNodes.size);
|
|
i++;
|
|
});
|
|
|
|
console.log(`🎨 New model detected: ${node.displayName} (${modelName})`);
|
|
|
|
// Update models list in legend
|
|
updateModelsList();
|
|
}
|
|
|
|
return modelNodes.get(modelName);
|
|
}
|
|
|
|
// Get or create component node (second layer)
|
|
function getOrCreateComponentNode(name, type) {
|
|
const key = `${type}:${name}`;
|
|
|
|
if (!componentNodes.has(key)) {
|
|
// Assign colors by type
|
|
const typeColors = {
|
|
'command': '#ec4899', // Pink for commands
|
|
'skill': '#8b5cf6', // Purple for skills
|
|
'mcp': '#f59e0b', // Orange for MCPs
|
|
'subagent': '#06b6d4' // Cyan for subagents
|
|
};
|
|
const color = typeColors[type] || '#6b7280';
|
|
|
|
// Create new component node
|
|
const index = componentNodes.size;
|
|
const total = componentNodes.size + 1;
|
|
const node = new ComponentNode(name, type, color, index, total);
|
|
componentNodes.set(key, node);
|
|
|
|
// Reposition all nodes to distribute evenly
|
|
let i = 0;
|
|
componentNodes.forEach((node, key) => {
|
|
node.updatePosition(i, componentNodes.size);
|
|
i++;
|
|
});
|
|
|
|
console.log(`🔷 New ${type} detected: ${name}`);
|
|
|
|
// Update components list
|
|
updateComponentsList();
|
|
}
|
|
|
|
return componentNodes.get(key);
|
|
}
|
|
|
|
// Recalculate all tool percentages and update positions
|
|
function recalculateToolPercentages() {
|
|
// Calculate total tool calls
|
|
let totalToolCalls = 0;
|
|
toolNodes.forEach(node => {
|
|
totalToolCalls += node.count;
|
|
});
|
|
|
|
// Update each tool node's percentage and position
|
|
toolNodes.forEach(node => {
|
|
node.updatePercentage(totalToolCalls);
|
|
});
|
|
}
|
|
|
|
// Recalculate all model percentages
|
|
function recalculateModelPercentages() {
|
|
// Calculate total model activity
|
|
let totalModelActivity = 0;
|
|
modelNodes.forEach(node => {
|
|
totalModelActivity += node.count;
|
|
});
|
|
|
|
// Update each model node's percentage
|
|
modelNodes.forEach(node => {
|
|
node.updatePercentage(totalModelActivity);
|
|
});
|
|
}
|
|
|
|
// Recalculate all component percentages and update positions
|
|
function recalculateComponentPercentages() {
|
|
// Calculate totals by type
|
|
const typeTotals = {
|
|
'command': 0,
|
|
'skill': 0,
|
|
'mcp': 0,
|
|
'subagent': 0
|
|
};
|
|
|
|
componentNodes.forEach(node => {
|
|
if (typeTotals[node.type] !== undefined) {
|
|
typeTotals[node.type] += node.count;
|
|
}
|
|
});
|
|
|
|
// Calculate grand total for all components
|
|
const grandTotal = Object.values(typeTotals).reduce((a, b) => a + b, 0);
|
|
|
|
// Update each component node's percentage and position
|
|
// Using grand total so all components compete for center position
|
|
componentNodes.forEach(node => {
|
|
node.updatePercentage(grandTotal);
|
|
});
|
|
}
|
|
|
|
// Add tool call beam
|
|
function addToolBeam(toolName) {
|
|
// Get color for this tool
|
|
const toolColors = {
|
|
'Read': '#60a5fa',
|
|
'Write': '#34d399',
|
|
'Edit': '#fbbf24',
|
|
'Bash': '#f87171',
|
|
'TodoWrite': '#a78bfa',
|
|
'Task': '#fb923c',
|
|
'Glob': '#2dd4bf',
|
|
'Grep': '#c084fc',
|
|
'WebFetch': '#f472b6',
|
|
'WebSearch': '#818cf8',
|
|
'KillShell': '#ef4444',
|
|
'TaskOutput': '#06b6d4',
|
|
'AskUserQuestion': '#fbbf24',
|
|
'EnterPlanMode': '#a78bfa',
|
|
'ExitPlanMode': '#8b5cf6'
|
|
};
|
|
const color = toolColors[toolName] || '#3b82f6';
|
|
|
|
// Get or create the tool node
|
|
const targetNode = getOrCreateToolNode(toolName, color);
|
|
|
|
// Create beam to this tool node - OPTIMIZED: Add to queue
|
|
const beam = new Beam(targetNode, toolName);
|
|
beamQueue.push(beam);
|
|
}
|
|
|
|
// Load data from API ONLY (no mock data)
|
|
async function loadData() {
|
|
try {
|
|
console.log('🔄 Fetching real data from /api/2025...');
|
|
const response = await fetch('/api/2025');
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('✅ Data received:', {
|
|
conversations: data.totalConversations,
|
|
tools: data.toolsCount,
|
|
components: data.componentInstalls?.length || 0,
|
|
heatmapWeeks: data.activityHeatmap?.length || 0
|
|
});
|
|
|
|
// Verify we have real data
|
|
const activeDays = [];
|
|
if (data.activityHeatmap) {
|
|
data.activityHeatmap.forEach(week => {
|
|
week.forEach(day => {
|
|
if (day.count > 0) {
|
|
activeDays.push(day);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
console.log(`📊 Active days found: ${activeDays.length}`);
|
|
if (activeDays.length > 0) {
|
|
console.log(`📅 First: ${activeDays[0].date}, Last: ${activeDays[activeDays.length - 1].date}`);
|
|
console.log(`🔧 Sample tools:`, activeDays[0].tools);
|
|
}
|
|
|
|
animationData = processData(data);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Error loading data:', error);
|
|
document.getElementById('loading').innerHTML = `
|
|
<p style="color: #ef4444;">Failed to load data</p>
|
|
<p style="margin-top: 10px; font-size: 12px;">${error.message}</p>
|
|
<p style="margin-top: 10px; font-size: 12px;">Make sure analytics server is running on port 3333</p>
|
|
`;
|
|
return; // Don't proceed without data
|
|
}
|
|
|
|
document.getElementById('loading').style.display = 'none';
|
|
document.getElementById('introContent').style.display = 'block';
|
|
}
|
|
|
|
// Process data into timeline
|
|
function processData(data) {
|
|
const timeline = [];
|
|
|
|
if (data.componentInstalls) {
|
|
data.componentInstalls.forEach(install => {
|
|
const date = new Date(install.date);
|
|
timeline.push({
|
|
type: 'component',
|
|
componentType: install.type,
|
|
name: install.name,
|
|
date,
|
|
dayOfYear: getDayOfYear(date)
|
|
});
|
|
});
|
|
}
|
|
|
|
// Collect all tools and conversations
|
|
const allTools = new Map(); // tool name -> total count
|
|
let totalConversations = 0;
|
|
const allModels = [];
|
|
|
|
if (data.activityHeatmap) {
|
|
data.activityHeatmap.forEach(week => {
|
|
week.forEach(day => {
|
|
if (day.count > 0) {
|
|
totalConversations += day.count;
|
|
|
|
// Collect models
|
|
if (day.models) {
|
|
day.models.forEach(model => {
|
|
if (!allModels.includes(model)) {
|
|
allModels.push(model);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Aggregate all tools
|
|
if (day.toolCounts) {
|
|
Object.entries(day.toolCounts).forEach(([toolName, count]) => {
|
|
allTools.set(toolName, (allTools.get(toolName) || 0) + count);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Create ONE conversation event at the beginning with all models
|
|
timeline.push({
|
|
type: 'conversation',
|
|
date: new Date(),
|
|
count: totalConversations,
|
|
models: allModels,
|
|
dayOfYear: 0 // First thing to process
|
|
});
|
|
|
|
// Create separate timeline events for each tool, distributed over time
|
|
let toolDayOffset = 10; // Start after models (which are at dayOfYear 0-5)
|
|
const toolIncrement = 3; // Larger increment for more spacing
|
|
|
|
allTools.forEach((totalCount, toolName) => {
|
|
timeline.push({
|
|
type: 'tool-use',
|
|
toolName: toolName,
|
|
count: totalCount,
|
|
date: new Date(),
|
|
dayOfYear: toolDayOffset
|
|
});
|
|
toolDayOffset += toolIncrement; // Distribute tools gradually
|
|
});
|
|
|
|
// Remember where tools ended
|
|
const componentsStartDay = toolDayOffset;
|
|
|
|
// Add components sequentially after tools (no delays)
|
|
let componentDayOffset = componentsStartDay;
|
|
const componentIncrement = 3; // Same spacing as tools
|
|
|
|
if (data.commands && data.commands.events) {
|
|
console.log(`📋 Adding ${data.commands.events.length} command events`);
|
|
data.commands.events.forEach((event) => {
|
|
timeline.push({
|
|
type: 'component-layer2',
|
|
componentType: 'command',
|
|
name: event.name,
|
|
date: new Date(),
|
|
dayOfYear: componentDayOffset
|
|
});
|
|
componentDayOffset += componentIncrement;
|
|
});
|
|
}
|
|
|
|
if (data.skills && data.skills.events) {
|
|
console.log(`📚 Adding ${data.skills.events.length} skill events`);
|
|
data.skills.events.forEach((event) => {
|
|
timeline.push({
|
|
type: 'component-layer2',
|
|
componentType: 'skill',
|
|
name: event.name,
|
|
date: new Date(),
|
|
dayOfYear: componentDayOffset
|
|
});
|
|
componentDayOffset += componentIncrement;
|
|
});
|
|
}
|
|
|
|
if (data.mcps && data.mcps.events) {
|
|
console.log(`🔌 Adding ${data.mcps.events.length} MCP events`);
|
|
data.mcps.events.forEach((event) => {
|
|
timeline.push({
|
|
type: 'component-layer2',
|
|
componentType: 'mcp',
|
|
name: event.name,
|
|
date: new Date(),
|
|
dayOfYear: componentDayOffset
|
|
});
|
|
componentDayOffset += componentIncrement;
|
|
});
|
|
}
|
|
|
|
if (data.subagents && data.subagents.events) {
|
|
console.log(`🤖 Adding ${data.subagents.events.length} subagent events`);
|
|
data.subagents.events.forEach((event) => {
|
|
timeline.push({
|
|
type: 'component-layer2',
|
|
componentType: 'subagent',
|
|
name: event.name,
|
|
date: new Date(),
|
|
dayOfYear: componentDayOffset,
|
|
count: event.count || 1
|
|
});
|
|
componentDayOffset += componentIncrement;
|
|
});
|
|
}
|
|
|
|
timeline.sort((a, b) => a.dayOfYear - b.dayOfYear);
|
|
|
|
console.log('🔍 DEBUG: First 10 events in sorted timeline:');
|
|
timeline.slice(0, 10).forEach((event, i) => {
|
|
console.log(` ${i}: type=${event.type}, name=${event.name || event.toolName}, dayOfYear=${event.dayOfYear?.toFixed(3)}, date=${event.date?.toLocaleDateString()}`);
|
|
});
|
|
|
|
// Calculate actual activity range
|
|
const firstActivityDay = timeline.length > 0 ? timeline[0].dayOfYear : 0;
|
|
const lastActivityDay = timeline.length > 0 ? timeline[timeline.length - 1].dayOfYear : 365;
|
|
const totalDays = lastActivityDay - firstActivityDay + 1;
|
|
|
|
const layer2Events = timeline.filter(e => e.type === 'component-layer2').length;
|
|
console.log(`🔷 Total component-layer2 events in timeline: ${layer2Events}`);
|
|
console.log(`📊 Activity range: Day ${firstActivityDay} to ${lastActivityDay} (${totalDays} days)`);
|
|
console.log(`📅 First activity: ${timeline[0]?.date.toLocaleDateString()}`);
|
|
console.log(`📅 Last activity: ${timeline[timeline.length - 1]?.date.toLocaleDateString()}`);
|
|
|
|
return {
|
|
timeline,
|
|
totalDays,
|
|
firstActivityDay,
|
|
lastActivityDay,
|
|
startDate: timeline[0]?.date || new Date('2025-01-01')
|
|
};
|
|
}
|
|
|
|
function getDayOfYear(date) {
|
|
const start = new Date(date.getFullYear(), 0, 0);
|
|
const diff = date - start;
|
|
const oneDay = 1000 * 60 * 60 * 24;
|
|
return Math.floor(diff / oneDay);
|
|
}
|
|
|
|
// Start animation
|
|
function startAnimation() {
|
|
console.log('🎬 STARTING ANIMATION');
|
|
console.log('Timeline events:', animationData.timeline.length);
|
|
console.log('Activity range: Day', animationData.firstActivityDay, 'to', animationData.lastActivityDay);
|
|
console.log('Total days:', animationData.totalDays);
|
|
console.log('First 5 events:', animationData.timeline.slice(0, 5));
|
|
|
|
document.getElementById('introScreen').style.display = 'none';
|
|
isPlaying = true;
|
|
startTime = Date.now();
|
|
|
|
// Update timeline labels based on actual data range
|
|
if (animationData && animationData.startDate) {
|
|
const startDate = new Date(animationData.startDate);
|
|
const endDate = new Date(animationData.timeline[animationData.timeline.length - 1]?.date || startDate);
|
|
|
|
const labels = document.querySelector('.timeline-labels');
|
|
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
|
|
// Show range in timeline
|
|
const rangeText = `${monthNames[startDate.getMonth()]} ${startDate.getDate()} - ${monthNames[endDate.getMonth()]} ${endDate.getDate()}, 2025`;
|
|
labels.innerHTML = `<span>${rangeText}</span>`;
|
|
labels.style.justifyContent = 'center';
|
|
}
|
|
|
|
animate();
|
|
}
|
|
|
|
// Frame counter for periodic recalculations
|
|
let frameCount = 0;
|
|
|
|
// Animation loop
|
|
// Collision detection and resolution
|
|
function resolveCollisions() {
|
|
const allNodes = [];
|
|
|
|
// Collect all nodes
|
|
toolNodes.forEach(node => allNodes.push(node));
|
|
componentNodes.forEach(node => allNodes.push(node));
|
|
modelNodes.forEach(node => allNodes.push(node));
|
|
|
|
// Check each pair for collision
|
|
for (let i = 0; i < allNodes.length; i++) {
|
|
for (let j = i + 1; j < allNodes.length; j++) {
|
|
const nodeA = allNodes[i];
|
|
const nodeB = allNodes[j];
|
|
|
|
const dx = nodeB.x - nodeA.x;
|
|
const dy = nodeB.y - nodeA.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
const minDistance = nodeA.size + nodeB.size + 10; // 10px padding
|
|
|
|
if (distance < minDistance && distance > 0) {
|
|
// Nodes are overlapping, push them apart
|
|
const overlap = minDistance - distance;
|
|
const angle = Math.atan2(dy, dx);
|
|
|
|
// Move each node half the overlap distance
|
|
const moveX = Math.cos(angle) * overlap * 0.5;
|
|
const moveY = Math.sin(angle) * overlap * 0.5;
|
|
|
|
nodeA.x -= moveX;
|
|
nodeA.y -= moveY;
|
|
nodeB.x += moveX;
|
|
nodeB.y += moveY;
|
|
|
|
// Also update target positions to prevent snapping back
|
|
nodeA.targetX -= moveX * 0.1;
|
|
nodeA.targetY -= moveY * 0.1;
|
|
nodeB.targetX += moveX * 0.1;
|
|
nodeB.targetY += moveY * 0.1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
frameCount++;
|
|
|
|
// Clear canvas (before transform)
|
|
ctx.fillStyle = '#0a0a0f';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (isPlaying) {
|
|
updateAnimationState();
|
|
}
|
|
|
|
// Recalculate percentages periodically (every 30 frames ~0.5s)
|
|
if (frameCount % 30 === 0) {
|
|
recalculateToolPercentages();
|
|
recalculateComponentPercentages();
|
|
recalculateModelPercentages();
|
|
}
|
|
|
|
// Apply zoom and pan transformations
|
|
ctx.save();
|
|
ctx.translate(panX, panY);
|
|
ctx.scale(zoom, zoom);
|
|
|
|
// Draw grid lines (subtle)
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.02)';
|
|
ctx.lineWidth = 1 / zoom; // Keep grid lines same thickness regardless of zoom
|
|
for (let i = -1000; i < canvas.width / zoom + 1000; i += 50) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(i, -1000);
|
|
ctx.lineTo(i, canvas.height / zoom + 1000);
|
|
ctx.stroke();
|
|
}
|
|
for (let i = 0; i < canvas.height; i += 50) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, i);
|
|
ctx.lineTo(canvas.width, i);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
|
|
// Update and draw permanent connections first (behind everything)
|
|
permanentConnections.forEach(beam => {
|
|
beam.update();
|
|
beam.draw(ctx);
|
|
});
|
|
|
|
// OPTIMIZED: Process beam queue - add beams from queue if under limit
|
|
while (beams.length < MAX_ACTIVE_BEAMS && beamQueue.length > 0) {
|
|
const queuedBeam = beamQueue.shift();
|
|
beams.push(queuedBeam);
|
|
}
|
|
|
|
// Update and draw animating beams
|
|
for (let i = beams.length - 1; i >= 0; i--) {
|
|
beams[i].update();
|
|
beams[i].draw(ctx);
|
|
if (beams[i].isDead()) {
|
|
beams.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Update and draw tool nodes
|
|
toolNodes.forEach(node => {
|
|
node.update();
|
|
node.draw(ctx);
|
|
});
|
|
|
|
// Update and draw model nodes (pie slices in center)
|
|
modelNodes.forEach(node => {
|
|
node.update();
|
|
node.draw(ctx);
|
|
});
|
|
|
|
// Update and draw component nodes (second layer)
|
|
componentNodes.forEach(node => {
|
|
node.update();
|
|
node.draw(ctx);
|
|
});
|
|
|
|
// Resolve collisions between all nodes
|
|
resolveCollisions();
|
|
|
|
// Update and draw all nodes
|
|
Object.values(branches).forEach(branch => {
|
|
branch.nodes.forEach(node => {
|
|
node.update();
|
|
node.draw(ctx);
|
|
});
|
|
});
|
|
|
|
// Draw center node only if no models detected yet
|
|
if (modelNodes.size === 0) {
|
|
ctx.save();
|
|
ctx.fillStyle = centerNode.color;
|
|
ctx.shadowBlur = 30;
|
|
ctx.shadowColor = centerNode.color;
|
|
ctx.beginPath();
|
|
ctx.arc(centerNode.x, centerNode.y, 35, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// Center label
|
|
ctx.save();
|
|
ctx.font = 'bold 16px Monaco';
|
|
ctx.fillStyle = '#e0e0e0';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('Claude', centerNode.x, centerNode.y);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Restore zoom/pan transformation
|
|
ctx.restore();
|
|
|
|
// Draw zoom indicator (outside transform so it stays in corner)
|
|
if (zoom !== 1 || panX !== 0 || panY !== 0) {
|
|
ctx.save();
|
|
ctx.fillStyle = 'rgba(255,255,255,0.5)';
|
|
ctx.font = '11px Monaco';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`Zoom: ${Math.round(zoom * 100)}% | Double-click to reset`, 20, canvas.height - 20);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// Update animation state
|
|
function updateAnimationState() {
|
|
if (!animationData) return;
|
|
|
|
const elapsed = (Date.now() - startTime) * speedMultiplier;
|
|
const duration = 40000; // 40 seconds
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Map progress to actual activity range
|
|
const rangeStart = animationData.firstActivityDay || 0;
|
|
const rangeEnd = animationData.lastActivityDay || 365;
|
|
const rangeDuration = rangeEnd - rangeStart;
|
|
|
|
currentDayIndex = rangeStart + Math.floor(progress * rangeDuration);
|
|
|
|
// Timeline and date display removed - no need to update
|
|
// document.getElementById('timelineProgress').style.width = `${progress * 100}%`;
|
|
// const date = new Date(animationData.startDate);
|
|
// const dayOffset = currentDayIndex - rangeStart;
|
|
// date.setDate(date.getDate() + dayOffset);
|
|
// const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
// document.getElementById('currentDate').textContent = `${monthNames[date.getMonth()]} ${date.getDate()}`;
|
|
|
|
// Process events
|
|
let eventsThisFrame = 0;
|
|
animationData.timeline.forEach((event, index) => {
|
|
if (event.dayOfYear <= currentDayIndex && !processedEvents.has(index)) {
|
|
processedEvents.add(index);
|
|
eventsThisFrame++;
|
|
|
|
if (event.type === 'conversation') {
|
|
console.log(`🎬 Processing conversation: ${event.count} conversations`);
|
|
statsTarget.conversations += event.count;
|
|
|
|
// Process models using actual counts
|
|
if (event.models && event.models.length > 0) {
|
|
event.models.forEach(modelName => {
|
|
// Skip Unknown models
|
|
if (modelName === 'Unknown') return;
|
|
|
|
const modelNode = getOrCreateModelNode(modelName);
|
|
|
|
// Add actual usage count from modelCounts
|
|
const modelCount = event.modelCounts ? (event.modelCounts[modelName] || 1) : 1;
|
|
for (let i = 0; i < modelCount; i++) {
|
|
modelNode.addUse();
|
|
}
|
|
|
|
console.log(` 🎨 Model used: ${modelNode.displayName} - added ${modelCount} uses (total: ${modelNode.count})`);
|
|
});
|
|
|
|
// Update models list after processing all models
|
|
updateModelsList();
|
|
}
|
|
|
|
} else if (event.type === 'component') {
|
|
console.log(`📦 Processing component: ${event.name} (${event.componentType})`);
|
|
addComponent(event.name, event.componentType);
|
|
// showEvent(`Installed: ${event.name}`); // Disabled - notifications removed
|
|
} else if (event.type === 'tool-use') {
|
|
// Process tool event (one tool at a time, like components)
|
|
const toolColors = {
|
|
'Read': '#60a5fa', 'Write': '#34d399', 'Edit': '#fbbf24',
|
|
'Bash': '#f87171', 'TodoWrite': '#a78bfa', 'Task': '#fb923c',
|
|
'Glob': '#2dd4bf', 'Grep': '#c084fc', 'WebFetch': '#f472b6',
|
|
'WebSearch': '#818cf8', 'KillShell': '#ef4444', 'TaskOutput': '#06b6d4'
|
|
};
|
|
const color = toolColors[event.toolName] || '#3b82f6';
|
|
const node = getOrCreateToolNode(event.toolName, color);
|
|
|
|
// Add all uses for this tool
|
|
for (let j = 0; j < event.count; j++) {
|
|
node.addUse();
|
|
}
|
|
|
|
// Update stats target (will animate gradually)
|
|
statsTarget.tools += event.count;
|
|
|
|
// Create beam - OPTIMIZED: Add to queue instead of directly to beams
|
|
const beam = new Beam(node, event.toolName);
|
|
beamQueue.push(beam);
|
|
|
|
console.log(`🔷 tool: ${event.toolName} (added: ${event.count}, total: ${node.count})`);
|
|
|
|
} else if (event.type === 'component-layer2') {
|
|
const node = getOrCreateComponentNode(event.name, event.componentType);
|
|
|
|
// Add uses based on event count (for grouped subagents)
|
|
const useCount = event.count || 1;
|
|
for (let j = 0; j < useCount; j++) {
|
|
node.addUse();
|
|
}
|
|
|
|
// Update stats target (will animate gradually)
|
|
statsTarget.components += useCount;
|
|
|
|
// Create beam to this component node - OPTIMIZED: Add to queue
|
|
const beam = new Beam(node, event.name);
|
|
beamQueue.push(beam);
|
|
|
|
console.log(`🔷 ${event.componentType}: ${event.name} at ${event.date?.toLocaleDateString()} (added: ${useCount}, total: ${node.count}, size: ${node.targetSize.toFixed(1)})`);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (eventsThisFrame > 0) {
|
|
console.log(`✅ Processed ${eventsThisFrame} events this frame. Current day: ${currentDayIndex}`);
|
|
|
|
// Recalculate percentages and positions after processing events
|
|
recalculateToolPercentages();
|
|
recalculateComponentPercentages();
|
|
recalculateModelPercentages();
|
|
}
|
|
|
|
// Animate stats display towards target values (smooth counting up)
|
|
const animationSpeed = 0.1; // Speed of counter animation
|
|
statsDisplay.conversations += (statsTarget.conversations - statsDisplay.conversations) * animationSpeed;
|
|
statsDisplay.components += (statsTarget.components - statsDisplay.components) * animationSpeed;
|
|
statsDisplay.tools += (statsTarget.tools - statsDisplay.tools) * animationSpeed;
|
|
|
|
// Update stats display (rounded to integers)
|
|
document.getElementById('statConversations').textContent = Math.floor(statsDisplay.conversations);
|
|
document.getElementById('statComponents').textContent = Math.floor(statsDisplay.components);
|
|
document.getElementById('statTools').textContent = Math.floor(statsDisplay.tools);
|
|
|
|
// Check milestones
|
|
const milestones = [
|
|
{ threshold: 10, msg: 'First 10 conversations! 🎯' },
|
|
{ threshold: 50, msg: 'Half century! 🔥' },
|
|
{ threshold: 100, msg: '100 conversations! 💯' },
|
|
{ threshold: 500, msg: 'Power user! ⚡' }
|
|
];
|
|
|
|
milestones.forEach(m => {
|
|
if (statsTarget.conversations >= m.threshold && !shownMilestones.has(m.threshold)) {
|
|
shownMilestones.add(m.threshold);
|
|
// showEvent(m.msg); // Disabled - notifications removed
|
|
}
|
|
});
|
|
|
|
if (progress >= 1) {
|
|
isPlaying = false;
|
|
// showEvent('🎉 2025 Complete!'); // Disabled - notifications removed
|
|
}
|
|
}
|
|
|
|
function showEvent(message) {
|
|
const toast = document.getElementById('eventToast');
|
|
toast.innerHTML = `<h3>${message}</h3>`;
|
|
toast.className = 'event-toast show';
|
|
setTimeout(() => toast.className = 'event-toast', 2500);
|
|
}
|
|
|
|
// Update models list in the legend
|
|
function updateModelsList() {
|
|
const modelsList = document.getElementById('modelsList');
|
|
modelsList.innerHTML = '';
|
|
|
|
// Get models with counts from modelNodes
|
|
const modelsWithCounts = [];
|
|
modelNodes.forEach((node, modelName) => {
|
|
modelsWithCounts.push({
|
|
name: node.displayName,
|
|
rawName: modelName,
|
|
color: node.color,
|
|
count: node.count
|
|
});
|
|
});
|
|
|
|
// Sort models by count (most used first)
|
|
modelsWithCounts.sort((a, b) => b.count - a.count);
|
|
|
|
modelsWithCounts.forEach(model => {
|
|
const item = document.createElement('div');
|
|
item.className = 'legend-item';
|
|
item.innerHTML = `
|
|
<div class="legend-dot" style="background: ${model.color};"></div>
|
|
<span>${model.name}</span>
|
|
`;
|
|
modelsList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// Update tools list in the legend
|
|
function updateToolsList() {
|
|
const toolsList = document.getElementById('toolsList');
|
|
toolsList.innerHTML = '';
|
|
|
|
// Get tools with counts from toolNodes
|
|
const toolsWithCounts = [];
|
|
toolNodes.forEach((node, toolName) => {
|
|
toolsWithCounts.push({
|
|
name: toolName,
|
|
color: uniqueTools.get(toolName) || '#888',
|
|
count: node.count
|
|
});
|
|
});
|
|
|
|
// Sort tools alphabetically
|
|
toolsWithCounts.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
toolsWithCounts.forEach(tool => {
|
|
const item = document.createElement('div');
|
|
item.className = 'legend-item';
|
|
item.innerHTML = `
|
|
<div class="legend-dot" style="background: ${tool.color};"></div>
|
|
<span>${tool.name}</span>
|
|
`;
|
|
toolsList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// Update components list in the legend
|
|
function updateComponentsList() {
|
|
const componentsList = document.getElementById('componentsList');
|
|
componentsList.innerHTML = '';
|
|
|
|
// Get all components from componentNodes map with counts
|
|
const components = [];
|
|
componentNodes.forEach((node, key) => {
|
|
components.push({
|
|
name: node.name,
|
|
color: node.color,
|
|
type: node.type,
|
|
count: node.count
|
|
});
|
|
});
|
|
|
|
// Sort by type, then by name
|
|
components.sort((a, b) => {
|
|
const typeOrder = { 'command': 1, 'skill': 2, 'mcp': 3, 'subagent': 4 };
|
|
const typeA = typeOrder[a.type] || 5;
|
|
const typeB = typeOrder[b.type] || 5;
|
|
if (typeA !== typeB) return typeA - typeB;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
components.forEach(component => {
|
|
const item = document.createElement('div');
|
|
item.className = 'legend-item';
|
|
item.innerHTML = `
|
|
<div class="legend-dot" style="background: ${component.color};"></div>
|
|
<span>${component.name}</span>
|
|
`;
|
|
componentsList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// Controls
|
|
function restartAnimation() {
|
|
stats = { conversations: 0, components: 0, tools: 0 };
|
|
statsTarget = { conversations: 0, components: 0, tools: 0 };
|
|
statsDisplay = { conversations: 0, components: 0, tools: 0 };
|
|
processedEvents.clear();
|
|
shownMilestones.clear();
|
|
currentDayIndex = 0;
|
|
beams = [];
|
|
beamQueue = []; // OPTIMIZED: Clear beam queue
|
|
permanentConnections.clear(); // Clear permanent connection beams
|
|
toolNodes.clear(); // Clear tool nodes
|
|
uniqueTools.clear(); // Clear unique tools
|
|
modelNodes.clear(); // Clear model nodes
|
|
componentNodes.clear(); // Clear component nodes (second layer)
|
|
updateModelsList(); // Clear the models list display
|
|
updateToolsList(); // Clear the tools list display
|
|
updateComponentsList(); // Clear the components list display
|
|
Object.values(branches).forEach(b => b.nodes = []);
|
|
startTime = Date.now();
|
|
isPlaying = true;
|
|
}
|
|
|
|
// Initialize
|
|
loadData();
|
|
</script>
|
|
</body>
|
|
</html>
|