Proposed
Data visualization is critical for analytics comprehension, but presents unique challenges in MCP/LobeHub environment:
| Challenge | Impact | Current Workaround |
|---|---|---|
| Token Cost | Base64 images consume 30% more tokens than text | No visualization |
| Library Weight | Matplotlib + dependencies = 200MB+ | Skip visualization |
| Generation Time | 2-5s for complex charts | Text-only output |
| Context Pollution | Large images reduce conversation capacity | External links |
| Quality vs Speed | High-quality plots are slow | Low-quality fallback |
From user feedback and analytics workflows:
- Fast Generation: <500ms for standard charts
- Low Token Overhead: Max 500 tokens for chart reference
- High Quality: Publication-ready output when needed
- Lightweight: Minimal Docker image size increase
- Flexible: Support common chart types (bar, line, scatter, heatmap)
Implement tiered visualization strategy using lightweight libraries with intelligent fallback and token-optimized output formats.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Visualization Architecture │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ TIER 1: ULTRALIGHT (Default) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Library: plotext (pure Python, 0 deps) │ │
│ │ Output: ASCII/Unicode charts │ │
│ │ Size: ~50KB Time: <50ms Tokens: ~100-300 │ │
│ │ Use: Quick insights, terminal-friendly, low-context │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ TIER 2: LIGHTWEIGHT (Standard) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Library: matplotlib (Agg backend only) │ │
│ │ Output: PNG base64 │ │
│ │ Size: ~15MB Time: <200ms Tokens: ~400-800 │ │
│ │ Use: Standard visualizations, reports, sharing │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ TIER 3: ADVANCED (On-Demand) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Library: plotly (static export) + kaleido │ │
│ │ Output: SVG or high-res PNG │ │
│ │ Size: ~50MB Time: <500ms Tokens: External link │ │
│ │ Use: Interactive dashboards, drill-down, publication │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Why:
- Pure Python, zero dependencies
- 50KB vs 200MB for matplotlib
- Generates ASCII/Unicode plots
- Extremely fast (<50ms)
- Zero token overhead for simple charts
Best For:
- Quick data exploration
- Terminal/CLI output
- Low-context environments
- Bar charts, line plots, histograms
Example:
import plotext as plt
plt.bar(["A", "B", "C"], [100, 150, 80])
plt.title("Sales by Category")
chart = plt.build() # Returns string
print(chart)Output:
Sales by Category
Category | Sales
----------------
A | ████████████ 100
B | ██████████████████ 150
C | █████████ 80
Token Cost: ~100 tokens (text)
Why:
- Industry standard
- Agg backend (no GUI deps)
- Small footprint with careful install
- Wide format support
Optimization Strategy:
# Minimal matplotlib installation
RUN pip install matplotlib --no-deps \
&& pip install numpy pillow kiwisolver pyparsing cycler fonttools packagingSize Reduction:
- Full install: 200MB+
- Optimized: ~15MB
- Removed: GUI backends, sample data, tests
Token Optimization:
import matplotlib.pyplot as plt
import io
import base64
def generate_chart_optimized(data, width=400, height=300, dpi=80):
"""Generate token-efficient visualization"""
plt.figure(figsize=(width/100, height/100), dpi=dpi)
# Plot data
plt.bar(data['categories'], data['values'])
plt.title(data.get('title', 'Chart'))
# Save to buffer with compression
buf = io.BytesIO()
plt.savefig(buf, format='png',
dpi=dpi,
bbox_inches='tight',
pad_inches=0.1,
optimize=True) # PNG optimization
plt.close()
# Convert to base64
buf.seek(0)
img_base64 = base64.b64encode(buf.read()).decode()
return {
"type": "image",
"format": "png",
"base64": img_base64,
"dimensions": {"width": width, "height": height}
}Token Cost: ~400-800 tokens (base64 image)
Why:
- Interactive HTML output
- SVG support (vector, scalable)
- Best quality for complex charts
- Export to file (external link)
Usage Pattern:
- Save to workspace file
- Return file path, not base64
- LobeHub renders via URL
Token Cost: ~50 tokens (file path reference)
def select_tier(query_context: dict) -> str:
"""
Automatically select visualization tier based on context
"""
available_tokens = query_context.get('available_tokens', 2000)
urgency = query_context.get('urgency', 'normal')
quality_required = query_context.get('quality', 'standard')
# Critical context pressure → ASCII
if available_tokens < 500 or urgency == 'fast':
return 'ultralight'
# Standard use case → PNG
if available_tokens < 2000 and quality_required != 'high':
return 'lightweight'
# High quality, interactive → Plotly file
return 'advanced'{
"visualization": {
"tier": "ultralight",
"type": "bar_chart",
"format": "text",
"content": "Category | Sales\nA | ████ 100\nB | ███████ 150",
"tokens_used": 85
}
}{
"visualization": {
"tier": "lightweight",
"type": "bar_chart",
"format": "base64_png",
"content": "iVBORw0KGgoAAAANSUhEUgAA...",
"dimensions": {"width": 400, "height": 300},
"tokens_used": 650,
"compression": "png_optimized"
}
}{
"visualization": {
"tier": "advanced",
"type": "interactive_chart",
"format": "file_reference",
"file_path": "/workspace/charts/sales_chart.html",
"file_size": "45KB",
"tokens_used": 45,
"interactive": true
}
}CHART_PRESETS = {
"thumbnail": {"width": 200, "height": 150, "dpi": 60},
"compact": {"width": 400, "height": 300, "dpi": 80},
"standard": {"width": 600, "height": 400, "dpi": 100},
"high_res": {"width": 1200, "height": 800, "dpi": 150}
}
def get_optimal_preset(available_tokens: int):
if available_tokens < 1000:
return CHART_PRESETS["compact"]
elif available_tokens < 2000:
return CHART_PRESETS["standard"]
return CHART_PRESETS["high_res"]# Limited palette for smaller PNGs
EFFICIENT_PALETTE = {
"primary": "#1f77b4", # Blue
"secondary": "#ff7f0e", # Orange
"tertiary": "#2ca02c", # Green
"quaternary": "#d62728", # Red
"background": "#ffffff",
"grid": "#e0e0e0"
}def strip_metadata(image_bytes: bytes) -> bytes:
"""Remove EXIF and metadata to reduce size"""
from PIL import Image
import io
img = Image.open(io.BytesIO(image_bytes))
# Strip metadata
data = list(img.getdata())
clean_img = Image.new(img.mode, img.size)
clean_img.putdata(data)
buf = io.BytesIO()
clean_img.save(buf, format='PNG', optimize=True)
return buf.getvalue()| Chart Type | Tier 1 (ASCII) | Tier 2 (PNG) | Tier 3 (Interactive) |
|---|---|---|---|
| Bar Chart | ✅ Full | ✅ Full | ✅ Full |
| Line Chart | ✅ Full | ✅ Full | ✅ Full |
| Scatter | ✅ Full | ✅ Full | |
| Histogram | ✅ Full | ✅ Full | ✅ Full |
| Pie Chart | ✅ Full | ✅ Full | ✅ Full |
| Heatmap | ✅ Full | ✅ Full | |
| Box Plot | ❌ | ✅ Full | ✅ Full |
| Violin Plot | ❌ | ✅ Full | ✅ Full |
| 3D Plots | ❌ | ✅ Limited | ✅ Full |
| Interactive | ❌ | ❌ | ✅ Full |
class VisualizationManager:
def __init__(self):
self.tiers = {
'ultralight': PlotextRenderer(),
'lightweight': MatplotlibRenderer(),
'advanced': PlotlyRenderer()
}
self.cache = VisualizationCache()
async def render(self, data, config, context_budget):
# Select tier based on budget
tier_name = select_tier(context_budget)
renderer = self.tiers[tier_name]
# Check cache
cache_key = generate_cache_key(data, config, tier_name)
cached = await self.cache.get(cache_key)
if cached:
return cached
# Generate visualization
result = await renderer.render(data, config)
# Cache result
await self.cache.set(cache_key, result)
return result{
"name": "analytics/visualize",
"description": "Generate visualization for analytical data",
"inputSchema": {
"type": "object",
"properties": {
"data_source": {
"type": "string",
"description": "Query ID or file path"
},
"chart_type": {
"type": "string",
"enum": ["bar", "line", "scatter", "histogram", "heatmap", "pie"]
},
"x_column": {"type": "string"},
"y_column": {"type": "string"},
"tier": {
"type": "string",
"enum": ["auto", "ultralight", "lightweight", "advanced"],
"default": "auto"
},
"color_column": {"type": "string"},
"title": {"type": "string"},
"width": {"type": "integer", "default": 600},
"height": {"type": "integer", "default": 400}
},
"required": ["data_source", "chart_type"]
}
}// In LobeHub agent logic
async function handleVisualizationRequest(userQuery: string, currentContext: number) {
const availableTokens = CONTEXT_WINDOW - currentContext;
// Request visualization with budget
const result = await mcp.visualize({
data_source: 'query_123',
chart_type: 'bar',
tier: 'auto', // Will select based on available_tokens
context_budget: availableTokens
});
// Result includes token count
if (result.tokens_used > availableTokens * 0.5) {
// Fall back to lighter tier
return await mcp.visualize({
...params,
tier: 'ultralight'
});
}
return result;
}class VisualizationCache:
def __init__(self):
self.l1 = {} # In-memory: chart string/base64
self.l2 = {} # File paths for external charts
def get_cache_key(self, data_hash, config_hash, tier):
return f"{data_hash}:{config_hash}:{tier}"
async def get(self, key):
# Try L1
if key in self.l1:
return self.l1[key]
# Try L2
if key in self.l2:
return {"file_path": self.l2[key]}
return None- Flexible Token Usage: 100-800 tokens vs 2000+ for standard matplotlib
- Fast Generation: <200ms for 90% of charts
- Quality Options: From quick ASCII to publication-ready
- Minimal Dependencies: Core functionality with 15MB vs 200MB
- Graceful Degradation: Falls back to text when context is limited
- Cacheable: All tiers support efficient caching
- Three Libraries to Maintain: plotext, matplotlib, plotly
- Tier Selection Complexity: Auto-selection may not always be optimal
- ASCII Limitations: Some chart types not available in Tier 1
- Image Quality Trade-offs: Lower token = lower resolution
- Learning Curve: Users need to understand tier differences
- Configuration: More options to choose from
| Component | Size | Cumulative |
|---|---|---|
| Base Python | 50MB | 50MB |
| Polars/DuckDB | 80MB | 130MB |
| plotext | 0.05MB | 130.05MB |
| matplotlib (optimized) | 15MB | 145MB |
| plotly + kaleido | 35MB | 180MB |
| Total | ~180MB |
vs Full matplotlib: ~250MB+
- Integrate plotext
- Basic chart types (bar, line, histogram)
- Auto-tier selection
- ASCII output format
- Optimize matplotlib installation
- PNG generation with compression
- Base64 encoding
- Size presets
- Plotly integration
- HTML/SVG export
- Interactive features
- File-based output
- Smart tier prediction
- User preference learning
- Automatic compression optimization
- Cross-chart caching
| Metric | Before | Target | Measurement |
|---|---|---|---|
| Chart generation time | 2-5s | <200ms | Timing logs |
| Avg tokens/chart | 2000+ | <500 | Token counting |
| Docker image size | 250MB | <200MB | Image inspection |
| Cache hit rate | 0% | >50% | Access logs |
| User satisfaction | Low | High | Feedback |
- ADR-001: Analytics Architecture
- ADR-002: Context Management Strategy
- ADR-003: Caching and Query Optimization
- MCP-SPECIFICATION: Tool specifications
- Plotext Documentation
- Matplotlib Optimization Guide
- Plotly Static Export
- LobeHub Issue #4856: Visualization context limits
| Date | Author | Decision | Rationale |
|---|---|---|---|
| 2026-03-17 | Sisyphus | Adopt 3-tier visualization strategy | Maximizes flexibility while minimizing token and resource overhead |
Status Legend:
- Proposed: Under review
- Accepted: Approved for implementation
- Deprecated: Replaced by newer ADR
- Superseded: See referenced ADR