Skip to main content
Glama
data-viz.html19.8 kB
<!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>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jamesdowzard/mcp-apps-poc'

If you have feedback or need assistance with the MCP directory API, please join our Discord server