infinite-agents-public/claude_code_devtools/claude_devtool_7.html

1031 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Analytics Dashboard - Claude Code DevTools</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border-color: #30363d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-purple: #bc8cff;
--accent-orange: #ff9057;
--accent-red: #f85149;
--accent-yellow: #ffd666;
--hover-bg: #30363d;
--shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 20px;
}
header {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 30px;
margin-bottom: 20px;
box-shadow: var(--shadow);
}
h1 {
font-size: 2em;
font-weight: 600;
margin-bottom: 10px;
background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-purple) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.tagline {
color: var(--text-secondary);
font-size: 1.1em;
}
.control-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 20px;
margin-bottom: 20px;
display: grid;
grid-template-columns: 1fr auto;
gap: 20px;
align-items: start;
}
.file-upload-area {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type=file] {
position: absolute;
left: -9999px;
}
.file-input-label {
display: inline-block;
padding: 10px 20px;
background: var(--accent-blue);
color: #fff;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.file-input-label:hover {
background: #1f6feb;
transform: translateY(-1px);
}
.filter-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
}
.filter-btn:hover {
background: var(--hover-bg);
border-color: var(--accent-blue);
}
.filter-btn.active {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.export-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.export-btn {
padding: 8px 16px;
background: var(--accent-green);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.export-btn:hover {
background: #2ea043;
transform: translateY(-1px);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 20px;
transition: all 0.2s;
}
.stat-card:hover {
border-color: var(--accent-blue);
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9em;
margin-bottom: 5px;
}
.stat-value {
font-size: 2em;
font-weight: 600;
color: var(--accent-blue);
}
.stat-trend {
font-size: 0.85em;
color: var(--text-muted);
margin-top: 5px;
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 20px;
position: relative;
}
.chart-container.full-width {
grid-column: 1 / -1;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.chart-title {
font-size: 1.2em;
font-weight: 600;
color: var(--text-primary);
}
.chart-controls {
display: flex;
gap: 10px;
}
.sort-btn {
padding: 4px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s;
}
.sort-btn:hover,
.sort-btn.active {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.chart-svg {
width: 100%;
overflow: visible;
}
.tooltip {
position: absolute;
padding: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
box-shadow: var(--shadow);
font-size: 0.9em;
}
.tooltip.visible {
opacity: 1;
}
.tooltip-title {
font-weight: 600;
margin-bottom: 5px;
color: var(--accent-blue);
}
.tooltip-content {
color: var(--text-secondary);
}
.docs {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 30px;
margin-top: 20px;
}
.docs h2 {
font-size: 1.5em;
margin-bottom: 20px;
color: var(--accent-purple);
}
.docs h3 {
font-size: 1.2em;
margin-top: 20px;
margin-bottom: 10px;
color: var(--accent-blue);
}
.docs ul,
.docs ol {
margin-left: 20px;
color: var(--text-secondary);
}
.docs li {
margin-bottom: 8px;
}
.docs a {
color: var(--accent-blue);
text-decoration: none;
}
.docs a:hover {
text-decoration: underline;
}
footer {
margin-top: 40px;
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 0.9em;
border-top: 1px solid var(--border-color);
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.chart-grid {
grid-template-columns: 1fr;
}
.control-panel {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1>Analytics Dashboard</h1>
<p class="tagline">Comprehensive session insights with interactive D3 visualizations</p>
</header>
<div class="control-panel">
<div class="file-upload-area">
<div class="file-input-wrapper">
<input type="file" id="fileInput" accept=".jsonl,.json" multiple>
<label for="fileInput" class="file-input-label">Load Transcript(s)</label>
</div>
<div class="filter-controls">
<button class="filter-btn active" data-range="all">All Time</button>
<button class="filter-btn" data-range="day">Last Day</button>
<button class="filter-btn" data-range="week">Last Week</button>
<button class="filter-btn" data-range="month">Last Month</button>
</div>
</div>
<div class="export-controls">
<button class="export-btn" id="exportPng">Export Charts as PNG</button>
<button class="export-btn" id="exportSvg">Export Charts as SVG</button>
<button class="export-btn" id="exportData">Export Data as JSON</button>
</div>
</div>
<div class="stats-grid" id="statsGrid"></div>
<div class="chart-grid" id="chartGrid"></div>
<section class="docs">
<h2>About This Tool</h2>
<h3>Purpose</h3>
<p>Analytics Dashboard provides comprehensive session insights for Claude Code developers through interactive D3-powered visualizations. Track tool usage, productivity patterns, session metrics, and trends over time with sortable, filterable charts.</p>
<h3>Features</h3>
<ul>
<li>Interactive tool usage bar chart with sorting and tooltips</li>
<li>Session timeline line chart showing activity over time</li>
<li>Message type distribution donut chart</li>
<li>Productivity metrics with trend indicators</li>
<li>Time-based filtering (day, week, month, all time)</li>
<li>Sortable charts by value or alphabetically</li>
<li>Rich tooltips with detailed statistics</li>
<li>Export capabilities (PNG, SVG, JSON)</li>
<li>Dark theme optimized for long sessions</li>
<li>Multi-session aggregation support</li>
</ul>
<h3>Web Research Integration</h3>
<p><strong>Source:</strong> <a href="https://observablehq.com/@d3/bar-chart" target="_blank">D3.js Bar Chart - Observable</a></p>
<p><strong>Techniques Applied:</strong></p>
<ul>
<li><strong>scaleBand() for Ordinal Scales:</strong> Used D3's scaleBand to create discrete x-axis positioning for categorical data (tools, session dates) with configurable padding between bars</li>
<li><strong>Interactive Tooltips:</strong> Implemented hover-based tooltips with precise positioning that show detailed statistics including percentages, counts, and contextual information</li>
<li><strong>Dynamic Sorting:</strong> Applied D3's groupSort pattern to enable interactive sorting by value (descending/ascending) or alphabetically, re-rendering charts smoothly on demand</li>
<li><strong>Data Join Pattern:</strong> Leveraged D3's .data().join() pattern for efficient DOM updates when filtering or sorting data without full re-renders</li>
</ul>
<h3>Usage</h3>
<ol>
<li>Click "Load Transcript(s)" to select one or more JSONL transcript files</li>
<li>View overall statistics cards showing key metrics and trends</li>
<li>Interact with charts: hover for tooltips, click sort buttons to reorder</li>
<li>Use time range filters to focus on specific periods</li>
<li>Export individual charts as PNG/SVG or all data as JSON</li>
</ol>
<h3>Chart Types</h3>
<ul>
<li><strong>Tool Usage Bar Chart:</strong> Shows frequency of each tool used, sortable by usage count or tool name</li>
<li><strong>Session Timeline:</strong> Line chart displaying message activity over time</li>
<li><strong>Message Distribution:</strong> Donut chart breaking down messages by type (user, assistant, tool)</li>
<li><strong>Productivity Trends:</strong> Multi-metric visualization tracking messages per hour, tool diversity, session duration</li>
</ul>
</section>
<footer>
<p>Claude Code DevTools | Generated via web-enhanced infinite loop</p>
<p>Web Source: <a href="https://observablehq.com/@d3/bar-chart">https://observablehq.com/@d3/bar-chart</a></p>
</footer>
<div class="tooltip" id="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-content"></div>
</div>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
// Global state
let allMessages = [];
let filteredMessages = [];
let currentTimeRange = 'all';
let chartInstances = {};
// File handling
document.getElementById('fileInput').addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
allMessages = [];
for (const file of files) {
const content = await file.text();
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const msg = JSON.parse(line);
allMessages.push(msg);
} catch (err) {
console.warn('Failed to parse line:', err);
}
}
}
applyTimeFilter(currentTimeRange);
renderDashboard();
});
// Time range filtering
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
const range = e.target.dataset.range;
currentTimeRange = range;
applyTimeFilter(range);
renderDashboard();
});
});
function applyTimeFilter(range) {
const now = new Date();
const cutoff = new Date();
switch(range) {
case 'day':
cutoff.setDate(now.getDate() - 1);
break;
case 'week':
cutoff.setDate(now.getDate() - 7);
break;
case 'month':
cutoff.setMonth(now.getMonth() - 1);
break;
default:
filteredMessages = allMessages;
return;
}
filteredMessages = allMessages.filter(msg => {
const msgDate = new Date(msg.timestamp);
return msgDate >= cutoff;
});
}
// Analytics calculations
function calculateStats() {
if (filteredMessages.length === 0) {
return null;
}
const toolCounts = {};
const messageCounts = { user: 0, assistant: 0, tool: 0 };
const sessionIds = new Set();
const timestamps = filteredMessages.map(m => new Date(m.timestamp)).sort((a, b) => a - b);
filteredMessages.forEach(msg => {
sessionIds.add(msg.sessionId);
if (msg.type) {
messageCounts[msg.type] = (messageCounts[msg.type] || 0) + 1;
}
// Extract tool usage from content
if (msg.message && msg.message.content) {
const content = Array.isArray(msg.message.content)
? msg.message.content
: [msg.message.content];
content.forEach(block => {
if (typeof block === 'object' && block.type === 'tool_use') {
toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
}
});
}
});
const sessionCount = sessionIds.size;
const totalDuration = timestamps.length > 1
? (timestamps[timestamps.length - 1] - timestamps[0]) / (1000 * 60 * 60)
: 0;
const messagesPerHour = totalDuration > 0 ? filteredMessages.length / totalDuration : 0;
const toolDiversity = Object.keys(toolCounts).length;
return {
totalMessages: filteredMessages.length,
sessionCount,
toolCounts,
messageCounts,
messagesPerHour,
toolDiversity,
totalDuration,
avgMessagesPerSession: sessionCount > 0 ? filteredMessages.length / sessionCount : 0
};
}
// Render statistics cards
function renderStats(stats) {
const statsGrid = document.getElementById('statsGrid');
if (!stats) {
statsGrid.innerHTML = '<div class="no-data">Load transcript files to see analytics</div>';
return;
}
statsGrid.innerHTML = `
<div class="stat-card">
<div class="stat-label">Total Messages</div>
<div class="stat-value">${stats.totalMessages.toLocaleString()}</div>
<div class="stat-trend">Across ${stats.sessionCount} session${stats.sessionCount !== 1 ? 's' : ''}</div>
</div>
<div class="stat-card">
<div class="stat-label">Messages/Hour</div>
<div class="stat-value">${stats.messagesPerHour.toFixed(1)}</div>
<div class="stat-trend">${stats.avgMessagesPerSession.toFixed(0)} avg per session</div>
</div>
<div class="stat-card">
<div class="stat-label">Tool Diversity</div>
<div class="stat-value">${stats.toolDiversity}</div>
<div class="stat-trend">Unique tools used</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Duration</div>
<div class="stat-value">${stats.totalDuration.toFixed(1)}h</div>
<div class="stat-trend">${(stats.totalDuration / stats.sessionCount).toFixed(1)}h avg</div>
</div>
`;
}
// Render dashboard
function renderDashboard() {
const stats = calculateStats();
renderStats(stats);
if (!stats) {
document.getElementById('chartGrid').innerHTML = '';
return;
}
const chartGrid = document.getElementById('chartGrid');
chartGrid.innerHTML = `
<div class="chart-container full-width" id="toolUsageChart"></div>
<div class="chart-container" id="sessionTimelineChart"></div>
<div class="chart-container" id="messageDistributionChart"></div>
`;
renderToolUsageChart(stats.toolCounts);
renderSessionTimeline();
renderMessageDistribution(stats.messageCounts);
}
// Tool Usage Bar Chart with D3
function renderToolUsageChart(toolCounts) {
const container = document.getElementById('toolUsageChart');
const width = container.clientWidth;
const height = 400;
const margin = { top: 60, right: 20, bottom: 80, left: 60 };
let sortMode = 'value'; // 'value' or 'name'
container.innerHTML = `
<div class="chart-header">
<div class="chart-title">Tool Usage Frequency</div>
<div class="chart-controls">
<button class="sort-btn active" data-sort="value">Sort by Usage</button>
<button class="sort-btn" data-sort="name">Sort by Name</button>
</div>
</div>
`;
const svg = d3.select(container)
.append('svg')
.attr('class', 'chart-svg')
.attr('width', width)
.attr('height', height);
function render() {
svg.selectAll('*').remove();
const data = Object.entries(toolCounts);
if (sortMode === 'value') {
data.sort((a, b) => b[1] - a[1]);
} else {
data.sort((a, b) => a[0].localeCompare(b[0]));
}
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Scales
const xScale = d3.scaleBand()
.domain(data.map(d => d[0]))
.range([0, innerWidth])
.padding(0.2);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d[1])])
.range([innerHeight, 0])
.nice();
// Color scale
const colorScale = d3.scaleOrdinal()
.domain(data.map(d => d[0]))
.range(['#58a6ff', '#3fb950', '#bc8cff', '#ff9057', '#ffd666', '#f85149']);
// Axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale)
.ticks(5);
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(xAxis)
.selectAll('text')
.attr('transform', 'rotate(-45)')
.style('text-anchor', 'end')
.style('fill', '#8b949e')
.style('font-size', '12px');
g.append('g')
.call(yAxis)
.selectAll('text')
.style('fill', '#8b949e')
.style('font-size', '12px');
// Grid lines
g.append('g')
.attr('class', 'grid')
.attr('opacity', 0.1)
.call(d3.axisLeft(yScale)
.ticks(5)
.tickSize(-innerWidth)
.tickFormat(''));
// Bars
const bars = g.selectAll('.bar')
.data(data)
.join('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d[0]))
.attr('y', d => yScale(d[1]))
.attr('width', xScale.bandwidth())
.attr('height', d => innerHeight - yScale(d[1]))
.attr('fill', d => colorScale(d[0]))
.attr('opacity', 0.8)
.style('cursor', 'pointer');
// Tooltip
const tooltip = d3.select('#tooltip');
bars.on('mouseenter', function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('opacity', 1);
const total = d3.sum(data, d => d[1]);
const percentage = ((d[1] / total) * 100).toFixed(1);
tooltip.select('.tooltip-title').text(d[0]);
tooltip.select('.tooltip-content').html(`
<div>Count: ${d[1].toLocaleString()}</div>
<div>Percentage: ${percentage}%</div>
<div>Rank: ${data.indexOf(d) + 1} of ${data.length}</div>
`);
tooltip
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px')
.classed('visible', true);
})
.on('mouseleave', function() {
d3.select(this)
.transition()
.duration(200)
.attr('opacity', 0.8);
tooltip.classed('visible', false);
});
}
// Sort button handlers
container.querySelectorAll('.sort-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
container.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
sortMode = e.target.dataset.sort;
render();
});
});
render();
chartInstances.toolUsage = { svg, container };
}
// Session Timeline Line Chart
function renderSessionTimeline() {
const container = document.getElementById('sessionTimelineChart');
const width = container.clientWidth;
const height = 300;
const margin = { top: 40, right: 20, bottom: 60, left: 60 };
container.innerHTML = `
<div class="chart-header">
<div class="chart-title">Message Activity Timeline</div>
</div>
`;
const svg = d3.select(container)
.append('svg')
.attr('class', 'chart-svg')
.attr('width', width)
.attr('height', height);
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Group messages by hour
const messagesByHour = d3.rollup(
filteredMessages,
v => v.length,
d => {
const date = new Date(d.timestamp);
date.setMinutes(0, 0, 0);
return date;
}
);
const data = Array.from(messagesByHour, ([date, count]) => ({ date, count }))
.sort((a, b) => a.date - b.date);
if (data.length === 0) return;
// Scales
const xScale = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([0, innerWidth]);
const yScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.count)])
.range([innerHeight, 0])
.nice();
// Axes
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(6))
.selectAll('text')
.style('fill', '#8b949e')
.style('font-size', '11px');
g.append('g')
.call(d3.axisLeft(yScale).ticks(5))
.selectAll('text')
.style('fill', '#8b949e')
.style('font-size', '11px');
// Line
const line = d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.count))
.curve(d3.curveMonotoneX);
g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#58a6ff')
.attr('stroke-width', 2)
.attr('d', line);
// Area under line
const area = d3.area()
.x(d => xScale(d.date))
.y0(innerHeight)
.y1(d => yScale(d.count))
.curve(d3.curveMonotoneX);
g.append('path')
.datum(data)
.attr('fill', '#58a6ff')
.attr('opacity', 0.2)
.attr('d', area);
// Points
const tooltip = d3.select('#tooltip');
g.selectAll('.dot')
.data(data)
.join('circle')
.attr('class', 'dot')
.attr('cx', d => xScale(d.date))
.attr('cy', d => yScale(d.count))
.attr('r', 4)
.attr('fill', '#58a6ff')
.style('cursor', 'pointer')
.on('mouseenter', function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('r', 6);
tooltip.select('.tooltip-title').text('Activity Point');
tooltip.select('.tooltip-content').html(`
<div>Time: ${d.date.toLocaleString()}</div>
<div>Messages: ${d.count}</div>
`);
tooltip
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px')
.classed('visible', true);
})
.on('mouseleave', function() {
d3.select(this)
.transition()
.duration(200)
.attr('r', 4);
tooltip.classed('visible', false);
});
chartInstances.timeline = { svg, container };
}
// Message Distribution Donut Chart
function renderMessageDistribution(messageCounts) {
const container = document.getElementById('messageDistributionChart');
const width = container.clientWidth;
const height = 300;
container.innerHTML = `
<div class="chart-header">
<div class="chart-title">Message Type Distribution</div>
</div>
`;
const svg = d3.select(container)
.append('svg')
.attr('class', 'chart-svg')
.attr('width', width)
.attr('height', height);
const radius = Math.min(width, height) / 2 - 40;
const g = svg.append('g')
.attr('transform', `translate(${width / 2},${height / 2})`);
const data = Object.entries(messageCounts);
const total = d3.sum(data, d => d[1]);
const color = d3.scaleOrdinal()
.domain(data.map(d => d[0]))
.range(['#58a6ff', '#3fb950', '#bc8cff']);
const pie = d3.pie()
.value(d => d[1])
.sort(null);
const arc = d3.arc()
.innerRadius(radius * 0.6)
.outerRadius(radius);
const arcHover = d3.arc()
.innerRadius(radius * 0.6)
.outerRadius(radius * 1.1);
const tooltip = d3.select('#tooltip');
const arcs = g.selectAll('.arc')
.data(pie(data))
.join('g')
.attr('class', 'arc');
arcs.append('path')
.attr('d', arc)
.attr('fill', d => color(d.data[0]))
.attr('opacity', 0.8)
.style('cursor', 'pointer')
.on('mouseenter', function(event, d) {
d3.select(this)
.transition()
.duration(200)
.attr('d', arcHover)
.attr('opacity', 1);
const percentage = ((d.data[1] / total) * 100).toFixed(1);
tooltip.select('.tooltip-title').text(d.data[0]);
tooltip.select('.tooltip-content').html(`
<div>Count: ${d.data[1].toLocaleString()}</div>
<div>Percentage: ${percentage}%</div>
`);
tooltip
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 10) + 'px')
.classed('visible', true);
})
.on('mouseleave', function() {
d3.select(this)
.transition()
.duration(200)
.attr('d', arc)
.attr('opacity', 0.8);
tooltip.classed('visible', false);
});
// Labels
arcs.append('text')
.attr('transform', d => `translate(${arc.centroid(d)})`)
.attr('text-anchor', 'middle')
.style('fill', '#c9d1d9')
.style('font-size', '14px')
.style('font-weight', '600')
.text(d => d.data[0]);
chartInstances.distribution = { svg, container };
}
// Export functionality
document.getElementById('exportPng').addEventListener('click', () => {
alert('PNG export requires additional canvas rendering. Use SVG export for now.');
});
document.getElementById('exportSvg').addEventListener('click', () => {
Object.entries(chartInstances).forEach(([name, instance]) => {
const svgData = instance.svg.node().outerHTML;
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}-chart.svg`;
a.click();
URL.revokeObjectURL(url);
});
});
document.getElementById('exportData').addEventListener('click', () => {
const stats = calculateStats();
const exportData = {
stats,
messages: filteredMessages,
timestamp: new Date().toISOString(),
timeRange: currentTimeRange
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `analytics-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
});
// Initial render
renderStats(null);
</script>
</body>
</html>