1031 lines
35 KiB
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>
|