<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Visualization</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--border-color: #30363d;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-purple: #a371f7;
--accent-orange: #d29922;
--accent-red: #f85149;
--accent-cyan: #79c0ff;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
padding: 20px;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 24px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 10px;
}
h1 .icon {
font-size: 28px;
}
.subtitle {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
}
.chart-type-toggle {
display: flex;
gap: 8px;
}
.toggle-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
border-color: var(--accent-blue);
color: var(--text-primary);
}
.toggle-btn.active {
background: rgba(88, 166, 255, 0.15);
border-color: var(--accent-blue);
color: var(--accent-blue);
}
.chart-area {
height: 300px;
position: relative;
}
svg {
width: 100%;
height: 100%;
}
.grid-line {
stroke: var(--border-color);
stroke-width: 1;
}
.axis-label {
fill: var(--text-secondary);
font-size: 11px;
}
.bar {
transition: opacity 0.2s;
}
.bar:hover {
opacity: 0.8;
}
.line-path {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.data-point {
transition: r 0.2s;
}
.data-point:hover {
r: 6;
}
.legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-change {
font-size: 12px;
margin-top: 4px;
}
.stat-change.positive {
color: var(--accent-green);
}
.stat-change.negative {
color: var(--accent-red);
}
/* Pie/Donut chart */
.pie-container {
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
}
.pie-chart {
width: 200px;
height: 200px;
}
.pie-legend {
display: flex;
flex-direction: column;
gap: 12px;
}
.pie-legend-item {
display: flex;
align-items: center;
gap: 10px;
}
.pie-legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
}
.pie-legend-label {
font-size: 14px;
color: var(--text-secondary);
}
.pie-legend-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-left: auto;
}
.tooltip {
position: absolute;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 100;
}
.tooltip.visible {
opacity: 1;
}
.actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn:hover {
border-color: var(--accent-blue);
background: rgba(88, 166, 255, 0.1);
}
.btn-primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.btn-primary:hover {
background: #4c9aed;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1><span class="icon">📊</span> Data Visualization</h1>
<p class="subtitle">Interactive charts powered by MCP Apps Extension</p>
<div class="stats-grid" id="stats-grid">
<!-- Populated dynamically -->
</div>
<div class="chart-container">
<div class="chart-header">
<span class="chart-title" id="chart-title">Monthly Revenue</span>
<div class="chart-type-toggle">
<button class="toggle-btn active" data-type="bar">Bar</button>
<button class="toggle-btn" data-type="line">Line</button>
<button class="toggle-btn" data-type="area">Area</button>
</div>
</div>
<div class="chart-area" id="main-chart">
<div class="loading"><div class="spinner"></div>Loading data...</div>
</div>
<div class="legend" id="chart-legend"></div>
</div>
<div class="chart-container">
<div class="chart-header">
<span class="chart-title">Distribution Breakdown</span>
</div>
<div class="chart-area pie-container" id="pie-chart">
<div class="loading"><div class="spinner"></div>Loading...</div>
</div>
</div>
<div class="actions">
<button class="btn" id="refresh-btn">🔄 Refresh Data</button>
<button class="btn btn-primary" id="share-btn">📤 Share Insights</button>
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<script type="module">
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";
// State
let chartData = null;
let currentChartType = 'bar';
const colors = ['#58a6ff', '#3fb950', '#a371f7', '#d29922', '#f85149', '#79c0ff'];
// Sample data to show immediately
const sampleData = {
title: "Monthly Revenue & Expenses",
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
series: [
{ name: "Revenue", data: [95000, 102000, 98000, 115000, 108000, 125000, 132000, 128000, 145000, 152000, 148000, 165000] },
{ name: "Expenses", data: [52000, 48000, 55000, 51000, 58000, 62000, 59000, 65000, 68000, 72000, 70000, 78000] }
],
stats: [
{ label: "Total Revenue", value: "$1.51M", change: 12.5 },
{ label: "Profit Margin", value: "38%", change: 5.2 },
{ label: "Customers", value: "3,247", change: 8.1 },
{ label: "Avg Order", value: "$465", change: 3.7 }
],
distribution: [
{ label: "Product Sales", value: 580000 },
{ label: "Services", value: 320000 },
{ label: "Subscriptions", value: 410000 },
{ label: "Consulting", value: 200000 }
]
};
// Initialize MCP App
const app = new App({
appInfo: { name: "Data Visualization", version: "1.0.0" },
appCapabilities: {}
});
app.ontoolinput = (input) => {
console.log("[DataViz] Tool input received:", input);
};
app.ontoolresult = (result) => {
console.log("[DataViz] Tool result received:", result);
try {
const content = result.content?.[0];
if (content?.type === 'text') {
chartData = JSON.parse(content.text);
renderAll();
}
} catch (e) {
console.error("[DataViz] Failed to parse result:", e);
}
};
// Connect to host
await app.connect(new PostMessageTransport(window.parent));
console.log("[DataViz] Connected to host");
// Render sample data immediately so users see something
chartData = sampleData;
renderAll();
// Render functions
function renderStats() {
const grid = document.getElementById('stats-grid');
if (!chartData?.stats) {
grid.innerHTML = '';
return;
}
grid.innerHTML = chartData.stats.map(stat => `
<div class="stat-card">
<div class="stat-value">${stat.value}</div>
<div class="stat-label">${stat.label}</div>
${stat.change ? `<div class="stat-change ${stat.change >= 0 ? 'positive' : 'negative'}">
${stat.change >= 0 ? '↑' : '↓'} ${Math.abs(stat.change)}%
</div>` : ''}
</div>
`).join('');
}
function renderMainChart() {
const container = document.getElementById('main-chart');
const legendContainer = document.getElementById('chart-legend');
const titleEl = document.getElementById('chart-title');
if (!chartData?.series) {
container.innerHTML = '<div class="loading">No data available</div>';
return;
}
titleEl.textContent = chartData.title || 'Chart';
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const width = container.clientWidth;
const height = container.clientHeight;
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const labels = chartData.labels || [];
const series = chartData.series || [];
const allValues = series.flatMap(s => s.data);
const maxValue = Math.max(...allValues, 0) * 1.1;
const minValue = Math.min(0, ...allValues);
const xScale = (i) => padding.left + (i + 0.5) * (chartWidth / labels.length);
const yScale = (v) => padding.top + chartHeight - ((v - minValue) / (maxValue - minValue)) * chartHeight;
let svg = `<svg viewBox="0 0 ${width} ${height}">`;
// Grid lines
const gridLines = 5;
for (let i = 0; i <= gridLines; i++) {
const y = padding.top + (chartHeight / gridLines) * i;
const value = maxValue - (maxValue - minValue) * (i / gridLines);
svg += `<line class="grid-line" x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}" />`;
svg += `<text class="axis-label" x="${padding.left - 10}" y="${y + 4}" text-anchor="end">${formatValue(value)}</text>`;
}
// X-axis labels
labels.forEach((label, i) => {
const x = xScale(i);
svg += `<text class="axis-label" x="${x}" y="${height - 10}" text-anchor="middle">${label}</text>`;
});
// Draw series
const barWidth = chartWidth / labels.length / (series.length + 1) * 0.8;
series.forEach((s, seriesIndex) => {
const color = colors[seriesIndex % colors.length];
if (currentChartType === 'bar') {
s.data.forEach((value, i) => {
const x = xScale(i) - (series.length * barWidth) / 2 + seriesIndex * barWidth;
const barHeight = ((value - minValue) / (maxValue - minValue)) * chartHeight;
const y = yScale(value);
svg += `<rect class="bar" x="${x}" y="${y}" width="${barWidth - 2}" height="${barHeight}"
fill="${color}" rx="3" data-value="${value}" data-label="${labels[i]}" data-series="${s.name}" />`;
});
} else if (currentChartType === 'line' || currentChartType === 'area') {
const points = s.data.map((v, i) => `${xScale(i)},${yScale(v)}`).join(' ');
if (currentChartType === 'area') {
const areaPoints = `${xScale(0)},${yScale(0)} ${points} ${xScale(s.data.length - 1)},${yScale(0)}`;
svg += `<polygon points="${areaPoints}" fill="${color}" opacity="0.2" />`;
}
svg += `<polyline class="line-path" points="${points}" stroke="${color}" />`;
s.data.forEach((value, i) => {
svg += `<circle class="data-point" cx="${xScale(i)}" cy="${yScale(value)}" r="4"
fill="${color}" data-value="${value}" data-label="${labels[i]}" data-series="${s.name}" />`;
});
}
});
svg += '</svg>';
container.innerHTML = svg;
// Legend
legendContainer.innerHTML = series.map((s, i) => `
<div class="legend-item">
<div class="legend-color" style="background: ${colors[i % colors.length]}"></div>
<span>${s.name}</span>
</div>
`).join('');
// Tooltips
setupTooltips(container);
}
function renderPieChart() {
const container = document.getElementById('pie-chart');
if (!chartData?.distribution) {
container.innerHTML = '<div class="loading">No distribution data</div>';
return;
}
const data = chartData.distribution;
const total = data.reduce((sum, d) => sum + d.value, 0);
const size = 200;
const center = size / 2;
const radius = 80;
const innerRadius = 50;
let svg = `<svg class="pie-chart" viewBox="0 0 ${size} ${size}">`;
let currentAngle = -90;
data.forEach((item, i) => {
const angle = (item.value / total) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
const x1 = center + radius * Math.cos(startAngle * Math.PI / 180);
const y1 = center + radius * Math.sin(startAngle * Math.PI / 180);
const x2 = center + radius * Math.cos(endAngle * Math.PI / 180);
const y2 = center + radius * Math.sin(endAngle * Math.PI / 180);
const ix1 = center + innerRadius * Math.cos(startAngle * Math.PI / 180);
const iy1 = center + innerRadius * Math.sin(startAngle * Math.PI / 180);
const ix2 = center + innerRadius * Math.cos(endAngle * Math.PI / 180);
const iy2 = center + innerRadius * Math.sin(endAngle * Math.PI / 180);
const largeArc = angle > 180 ? 1 : 0;
const color = colors[i % colors.length];
svg += `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}
L ${ix2} ${iy2} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${ix1} ${iy1} Z"
fill="${color}" stroke="${'var(--bg-primary)'}" stroke-width="2" />`;
currentAngle = endAngle;
});
svg += '</svg>';
const legend = `
<div class="pie-legend">
${data.map((item, i) => `
<div class="pie-legend-item">
<div class="pie-legend-color" style="background: ${colors[i % colors.length]}"></div>
<span class="pie-legend-label">${item.label}</span>
<span class="pie-legend-value">${formatValue(item.value)} (${((item.value / total) * 100).toFixed(1)}%)</span>
</div>
`).join('')}
</div>
`;
container.innerHTML = svg + legend;
}
function renderAll() {
renderStats();
renderMainChart();
renderPieChart();
}
function formatValue(v) {
if (v >= 1000000) return (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return (v / 1000).toFixed(1) + 'K';
return Math.round(v).toString();
}
function setupTooltips(container) {
const tooltip = document.getElementById('tooltip');
container.querySelectorAll('[data-value]').forEach(el => {
el.addEventListener('mouseenter', (e) => {
const value = e.target.dataset.value;
const label = e.target.dataset.label;
const series = e.target.dataset.series;
tooltip.textContent = `${series}: ${label} = ${formatValue(parseFloat(value))}`;
tooltip.classList.add('visible');
});
el.addEventListener('mousemove', (e) => {
tooltip.style.left = e.pageX + 10 + 'px';
tooltip.style.top = e.pageY - 30 + 'px';
});
el.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
});
});
}
// Event handlers
document.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentChartType = btn.dataset.type;
renderMainChart();
});
});
document.getElementById('refresh-btn').addEventListener('click', async () => {
const result = await app.callServerTool('get-chart-data', {});
console.log("[DataViz] Refresh result:", result);
});
document.getElementById('share-btn').addEventListener('click', () => {
if (!chartData) return;
const insights = generateInsights();
app.sendMessage('user', [
{ type: 'text', text: insights }
]);
});
function generateInsights() {
if (!chartData) return 'No data to analyze.';
let insights = '📊 **Data Insights Summary**\n\n';
if (chartData.stats) {
insights += '**Key Metrics:**\n';
chartData.stats.forEach(s => {
insights += `- ${s.label}: ${s.value}`;
if (s.change) insights += ` (${s.change >= 0 ? '+' : ''}${s.change}%)`;
insights += '\n';
});
insights += '\n';
}
if (chartData.series) {
insights += '**Trends:**\n';
chartData.series.forEach(s => {
const avg = s.data.reduce((a, b) => a + b, 0) / s.data.length;
const max = Math.max(...s.data);
const min = Math.min(...s.data);
insights += `- ${s.name}: Avg ${formatValue(avg)}, Range ${formatValue(min)}-${formatValue(max)}\n`;
});
}
return insights;
}
// Handle window resize
window.addEventListener('resize', () => {
if (chartData) renderMainChart();
});
// Notify host of size
const observer = new ResizeObserver(() => {
const height = document.body.scrollHeight;
app.sendSizeChanged(undefined, height);
});
observer.observe(document.body);
</script>
</body>
</html>