Files
leo-claude-mktplace/mcp-servers/viz-platform/mcp_server/chart_tools.py
lmiranda c91f21f3d1 feat(viz-platform): implement chart tools (#173)
- Add chart_create tool: create line, bar, scatter, pie, heatmap, histogram, area charts
- Add chart_configure_interaction tool: hover templates, click data, selection, zoom
- Support theme color integration when theme is active
- Default color palette based on Mantine theme

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:47:54 -05:00

398 lines
13 KiB
Python

"""
Chart creation tools using Plotly.
Provides tools for creating data visualizations with automatic theme integration.
"""
import logging
from typing import Dict, List, Optional, Any, Union
logger = logging.getLogger(__name__)
# Default color palette based on Mantine theme
DEFAULT_COLORS = [
"#228be6", # blue
"#40c057", # green
"#fa5252", # red
"#fab005", # yellow
"#7950f2", # violet
"#fd7e14", # orange
"#20c997", # teal
"#f783ac", # pink
"#868e96", # gray
"#15aabf", # cyan
]
class ChartTools:
"""
Plotly-based chart creation tools.
Creates charts that integrate with DMC theming system.
"""
def __init__(self, theme_store=None):
"""
Initialize chart tools.
Args:
theme_store: Optional ThemeStore for theme token resolution
"""
self.theme_store = theme_store
self._active_theme = None
def set_theme(self, theme: Dict[str, Any]) -> None:
"""Set the active theme for chart styling."""
self._active_theme = theme
def _get_color_palette(self) -> List[str]:
"""Get color palette from theme or defaults."""
if self._active_theme and 'colors' in self._active_theme:
colors = self._active_theme['colors']
# Extract primary colors from theme
palette = []
for key in ['primary', 'secondary', 'success', 'warning', 'error']:
if key in colors:
palette.append(colors[key])
if palette:
return palette + DEFAULT_COLORS[len(palette):]
return DEFAULT_COLORS
def _resolve_color(self, color: Optional[str]) -> str:
"""Resolve a color token to actual color value."""
if not color:
return self._get_color_palette()[0]
# Check if it's a theme token
if self._active_theme and 'colors' in self._active_theme:
colors = self._active_theme['colors']
if color in colors:
return colors[color]
# Check if it's already a valid color
if color.startswith('#') or color.startswith('rgb'):
return color
# Map common color names to palette
color_map = {
'blue': DEFAULT_COLORS[0],
'green': DEFAULT_COLORS[1],
'red': DEFAULT_COLORS[2],
'yellow': DEFAULT_COLORS[3],
'violet': DEFAULT_COLORS[4],
'orange': DEFAULT_COLORS[5],
'teal': DEFAULT_COLORS[6],
'pink': DEFAULT_COLORS[7],
'gray': DEFAULT_COLORS[8],
'cyan': DEFAULT_COLORS[9],
}
return color_map.get(color, color)
async def chart_create(
self,
chart_type: str,
data: Dict[str, Any],
options: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create a Plotly chart.
Args:
chart_type: Type of chart (line, bar, scatter, pie, heatmap, histogram, area)
data: Data specification with x, y values or labels/values for pie
options: Optional chart options (title, color, layout settings)
Returns:
Dict with:
- figure: Plotly figure JSON
- chart_type: Type of chart created
- error: Error message if creation failed
"""
options = options or {}
# Validate chart type
valid_types = ['line', 'bar', 'scatter', 'pie', 'heatmap', 'histogram', 'area']
if chart_type not in valid_types:
return {
"error": f"Invalid chart_type '{chart_type}'. Must be one of: {valid_types}",
"chart_type": chart_type,
"figure": None
}
try:
# Build trace based on chart type
trace = self._build_trace(chart_type, data, options)
if 'error' in trace:
return trace
# Build layout
layout = self._build_layout(options)
# Create figure structure
figure = {
"data": [trace],
"layout": layout
}
return {
"figure": figure,
"chart_type": chart_type,
"trace_count": 1
}
except Exception as e:
logger.error(f"Chart creation failed: {e}")
return {
"error": str(e),
"chart_type": chart_type,
"figure": None
}
def _build_trace(
self,
chart_type: str,
data: Dict[str, Any],
options: Dict[str, Any]
) -> Dict[str, Any]:
"""Build Plotly trace for the chart type."""
color = self._resolve_color(options.get('color'))
palette = self._get_color_palette()
# Common trace properties
trace: Dict[str, Any] = {}
if chart_type == 'line':
trace = {
"type": "scatter",
"mode": "lines+markers",
"x": data.get('x', []),
"y": data.get('y', []),
"line": {"color": color},
"marker": {"color": color}
}
if 'name' in data:
trace['name'] = data['name']
elif chart_type == 'bar':
trace = {
"type": "bar",
"x": data.get('x', []),
"y": data.get('y', []),
"marker": {"color": color}
}
if options.get('horizontal'):
trace['orientation'] = 'h'
trace['x'], trace['y'] = trace['y'], trace['x']
if 'name' in data:
trace['name'] = data['name']
elif chart_type == 'scatter':
trace = {
"type": "scatter",
"mode": "markers",
"x": data.get('x', []),
"y": data.get('y', []),
"marker": {
"color": color,
"size": options.get('marker_size', 10)
}
}
if 'size' in data:
trace['marker']['size'] = data['size']
if 'name' in data:
trace['name'] = data['name']
elif chart_type == 'pie':
labels = data.get('labels', data.get('x', []))
values = data.get('values', data.get('y', []))
trace = {
"type": "pie",
"labels": labels,
"values": values,
"marker": {"colors": palette[:len(labels)]}
}
if options.get('donut'):
trace['hole'] = options.get('hole', 0.4)
elif chart_type == 'heatmap':
trace = {
"type": "heatmap",
"z": data.get('z', data.get('values', [])),
"x": data.get('x', []),
"y": data.get('y', []),
"colorscale": options.get('colorscale', 'Blues')
}
elif chart_type == 'histogram':
trace = {
"type": "histogram",
"x": data.get('x', data.get('values', [])),
"marker": {"color": color}
}
if 'nbins' in options:
trace['nbinsx'] = options['nbins']
elif chart_type == 'area':
trace = {
"type": "scatter",
"mode": "lines",
"x": data.get('x', []),
"y": data.get('y', []),
"fill": "tozeroy",
"line": {"color": color},
"fillcolor": color.replace(')', ', 0.3)').replace('rgb', 'rgba') if color.startswith('rgb') else color + '4D'
}
if 'name' in data:
trace['name'] = data['name']
else:
return {"error": f"Unsupported chart type: {chart_type}"}
return trace
def _build_layout(self, options: Dict[str, Any]) -> Dict[str, Any]:
"""Build Plotly layout from options."""
layout: Dict[str, Any] = {
"autosize": True,
"margin": {"l": 50, "r": 30, "t": 50, "b": 50}
}
# Title
if 'title' in options:
layout['title'] = {
"text": options['title'],
"x": 0.5,
"xanchor": "center"
}
# Axis labels
if 'x_label' in options:
layout['xaxis'] = layout.get('xaxis', {})
layout['xaxis']['title'] = options['x_label']
if 'y_label' in options:
layout['yaxis'] = layout.get('yaxis', {})
layout['yaxis']['title'] = options['y_label']
# Theme-based styling
if self._active_theme:
colors = self._active_theme.get('colors', {})
bg = colors.get('background', {})
if isinstance(bg, dict):
layout['paper_bgcolor'] = bg.get('base', '#ffffff')
layout['plot_bgcolor'] = bg.get('subtle', '#f8f9fa')
elif isinstance(bg, str):
layout['paper_bgcolor'] = bg
layout['plot_bgcolor'] = bg
text_color = colors.get('text', {})
if isinstance(text_color, dict):
layout['font'] = {'color': text_color.get('primary', '#212529')}
elif isinstance(text_color, str):
layout['font'] = {'color': text_color}
# Additional layout options
if 'showlegend' in options:
layout['showlegend'] = options['showlegend']
if 'height' in options:
layout['height'] = options['height']
if 'width' in options:
layout['width'] = options['width']
return layout
async def chart_configure_interaction(
self,
figure: Dict[str, Any],
interactions: Dict[str, Any]
) -> Dict[str, Any]:
"""
Configure interactions for a chart.
Args:
figure: Plotly figure JSON to modify
interactions: Interaction configuration:
- hover_template: Custom hover text template
- click_data: Enable click data capture
- selection: Enable selection (box, lasso)
- zoom: Enable/disable zoom
Returns:
Dict with:
- figure: Updated figure JSON
- interactions_added: List of interactions configured
- error: Error message if configuration failed
"""
if not figure or 'data' not in figure:
return {
"error": "Invalid figure: must contain 'data' key",
"figure": figure,
"interactions_added": []
}
try:
interactions_added = []
# Process each trace
for i, trace in enumerate(figure['data']):
# Hover template
if 'hover_template' in interactions:
trace['hovertemplate'] = interactions['hover_template']
if i == 0:
interactions_added.append('hover_template')
# Custom hover info
if 'hover_info' in interactions:
trace['hoverinfo'] = interactions['hover_info']
if i == 0:
interactions_added.append('hover_info')
# Layout-level interactions
layout = figure.get('layout', {})
# Click data (Dash callback integration)
if interactions.get('click_data', False):
layout['clickmode'] = 'event+select'
interactions_added.append('click_data')
# Selection mode
if 'selection' in interactions:
sel_mode = interactions['selection']
if sel_mode in ['box', 'lasso', 'box+lasso']:
layout['dragmode'] = 'select' if sel_mode == 'box' else sel_mode
interactions_added.append(f'selection:{sel_mode}')
# Zoom configuration
if 'zoom' in interactions:
if not interactions['zoom']:
layout['xaxis'] = layout.get('xaxis', {})
layout['yaxis'] = layout.get('yaxis', {})
layout['xaxis']['fixedrange'] = True
layout['yaxis']['fixedrange'] = True
interactions_added.append('zoom:disabled')
else:
interactions_added.append('zoom:enabled')
# Modebar configuration
if 'modebar' in interactions:
layout['modebar'] = interactions['modebar']
interactions_added.append('modebar')
figure['layout'] = layout
return {
"figure": figure,
"interactions_added": interactions_added
}
except Exception as e:
logger.error(f"Interaction configuration failed: {e}")
return {
"error": str(e),
"figure": figure,
"interactions_added": []
}