From c91f21f3d1662ee05c8f8d4ef51d7bd8076a6cd5 Mon Sep 17 00:00:00 2001 From: lmiranda Date: Mon, 26 Jan 2026 11:47:54 -0500 Subject: [PATCH] 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 --- .../viz-platform/mcp_server/chart_tools.py | 397 ++++++++++++++++++ mcp-servers/viz-platform/mcp_server/server.py | 96 ++++- 2 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 mcp-servers/viz-platform/mcp_server/chart_tools.py diff --git a/mcp-servers/viz-platform/mcp_server/chart_tools.py b/mcp-servers/viz-platform/mcp_server/chart_tools.py new file mode 100644 index 0000000..4ba81d6 --- /dev/null +++ b/mcp-servers/viz-platform/mcp_server/chart_tools.py @@ -0,0 +1,397 @@ +""" +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": [] + } diff --git a/mcp-servers/viz-platform/mcp_server/server.py b/mcp-servers/viz-platform/mcp_server/server.py index 59f6a92..007d88d 100644 --- a/mcp-servers/viz-platform/mcp_server/server.py +++ b/mcp-servers/viz-platform/mcp_server/server.py @@ -13,6 +13,7 @@ from mcp.types import Tool, TextContent from .config import VizPlatformConfig from .dmc_tools import DMCTools +from .chart_tools import ChartTools # Suppress noisy MCP validation warnings on stderr logging.basicConfig(level=logging.INFO) @@ -28,8 +29,8 @@ class VizPlatformMCPServer: self.server = Server("viz-platform-mcp") self.config = None self.dmc_tools = DMCTools() + self.chart_tools = ChartTools() # Tool handlers will be added in subsequent issues - # self.chart_tools = None # self.layout_tools = None # self.theme_tools = None # self.page_tools = None @@ -134,8 +135,66 @@ class VizPlatformMCPServer: )) # Chart tools (Issue #173) - # - chart_create - # - chart_configure_interaction + tools.append(Tool( + name="chart_create", + description=( + "Create a Plotly chart for data visualization. " + "Supports line, bar, scatter, pie, heatmap, histogram, and area charts. " + "Automatically applies theme colors when a theme is active." + ), + inputSchema={ + "type": "object", + "properties": { + "chart_type": { + "type": "string", + "enum": ["line", "bar", "scatter", "pie", "heatmap", "histogram", "area"], + "description": "Type of chart to create" + }, + "data": { + "type": "object", + "description": ( + "Data for the chart. For most charts: {x: [], y: []}. " + "For pie: {labels: [], values: []}. " + "For heatmap: {x: [], y: [], z: [[]]}" + ) + }, + "options": { + "type": "object", + "description": ( + "Optional settings: title, x_label, y_label, color, " + "showlegend, height, width, horizontal (for bar)" + ) + } + }, + "required": ["chart_type", "data"] + } + )) + + tools.append(Tool( + name="chart_configure_interaction", + description=( + "Configure interactions on an existing chart. " + "Add hover templates, enable click data capture, selection modes, " + "and zoom behavior for Dash callback integration." + ), + inputSchema={ + "type": "object", + "properties": { + "figure": { + "type": "object", + "description": "Plotly figure JSON to modify" + }, + "interactions": { + "type": "object", + "description": ( + "Interaction config: hover_template (string), " + "click_data (bool), selection ('box'|'lasso'), zoom (bool)" + ) + } + }, + "required": ["figure", "interactions"] + } + )) # Layout tools (Issue #174) # - layout_create @@ -196,7 +255,36 @@ class VizPlatformMCPServer: text=json.dumps(result, indent=2) )] - # Chart tools (Issue #173) + # Chart tools + elif name == "chart_create": + chart_type = arguments.get('chart_type') + data = arguments.get('data', {}) + options = arguments.get('options', {}) + if not chart_type: + return [TextContent( + type="text", + text=json.dumps({"error": "chart_type is required"}, indent=2) + )] + result = await self.chart_tools.chart_create(chart_type, data, options) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + + elif name == "chart_configure_interaction": + figure = arguments.get('figure') + interactions = arguments.get('interactions', {}) + if not figure: + return [TextContent( + type="text", + text=json.dumps({"error": "figure is required"}, indent=2) + )] + result = await self.chart_tools.chart_configure_interaction(figure, interactions) + return [TextContent( + type="text", + text=json.dumps(result, indent=2) + )] + # Layout tools (Issue #174) # Theme tools (Issue #175) # Page tools (Issue #176)