Files
leo-claude-mktplace/mcp-servers/viz-platform/mcp_server/chart_tools.py
lmiranda 9698e8724d feat(plugins): implement Sprint 4 commands (#241-#258)
Sprint 4 - Plugin Commands implementation adding 18 new user-facing
commands across 8 plugins as part of V5.2.0 Plugin Enhancements.

**projman:**
- #241: /sprint-diagram - Mermaid visualization of sprint issues

**pr-review:**
- #242: Confidence threshold config (PR_REVIEW_CONFIDENCE_THRESHOLD)
- #243: /pr-diff - Formatted diff with inline review comments

**data-platform:**
- #244: /data-quality - DataFrame quality checks (nulls, duplicates, outliers)
- #245: /lineage-viz - dbt lineage as Mermaid diagrams
- #246: /dbt-test - Formatted dbt test runner

**viz-platform:**
- #247: /chart-export - Export charts to PNG/SVG/PDF via kaleido
- #248: /accessibility-check - Color blind validation (WCAG contrast)
- #249: /breakpoints - Responsive layout configuration

**contract-validator:**
- #250: /dependency-graph - Plugin dependency visualization

**doc-guardian:**
- #251: /changelog-gen - Generate changelog from conventional commits
- #252: /doc-coverage - Documentation coverage metrics
- #253: /stale-docs - Flag outdated documentation

**claude-config-maintainer:**
- #254: /config-diff - Track CLAUDE.md changes over time
- #255: /config-lint - 31 lint rules for CLAUDE.md best practices

**cmdb-assistant:**
- #256: /cmdb-topology - Infrastructure topology diagrams
- #257: /change-audit - NetBox audit trail queries
- #258: /ip-conflicts - Detect IP conflicts and overlaps

Closes #241, #242, #243, #244, #245, #246, #247, #248, #249,
#250, #251, #252, #253, #254, #255, #256, #257, #258

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:02:26 -05:00

534 lines
18 KiB
Python

"""
Chart creation tools using Plotly.
Provides tools for creating data visualizations with automatic theme integration.
"""
import base64
import logging
import os
from typing import Dict, List, Optional, Any, Union
logger = logging.getLogger(__name__)
# Check for kaleido availability
KALEIDO_AVAILABLE = False
try:
import kaleido
KALEIDO_AVAILABLE = True
except ImportError:
logger.debug("kaleido not installed - chart export will be unavailable")
# 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": []
}
async def chart_export(
self,
figure: Dict[str, Any],
format: str = "png",
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 2.0,
output_path: Optional[str] = None
) -> Dict[str, Any]:
"""
Export a Plotly chart to a static image format.
Args:
figure: Plotly figure JSON to export
format: Output format - png, svg, or pdf
width: Image width in pixels (default: from figure or 1200)
height: Image height in pixels (default: from figure or 800)
scale: Resolution scale factor (default: 2 for retina)
output_path: Optional file path to save the image
Returns:
Dict with:
- image_data: Base64-encoded image (if no output_path)
- file_path: Path to saved file (if output_path provided)
- format: Export format used
- dimensions: {width, height, scale}
- error: Error message if export failed
"""
# Validate format
valid_formats = ['png', 'svg', 'pdf']
format = format.lower()
if format not in valid_formats:
return {
"error": f"Invalid format '{format}'. Must be one of: {valid_formats}",
"format": format,
"image_data": None
}
# Check kaleido availability
if not KALEIDO_AVAILABLE:
return {
"error": "kaleido package not installed. Install with: pip install kaleido",
"format": format,
"image_data": None,
"install_hint": "pip install kaleido"
}
# Validate figure
if not figure or 'data' not in figure:
return {
"error": "Invalid figure: must contain 'data' key",
"format": format,
"image_data": None
}
try:
import plotly.graph_objects as go
import plotly.io as pio
# Create Plotly figure object
fig = go.Figure(figure)
# Determine dimensions
layout = figure.get('layout', {})
export_width = width or layout.get('width') or 1200
export_height = height or layout.get('height') or 800
# Export to bytes
image_bytes = pio.to_image(
fig,
format=format,
width=export_width,
height=export_height,
scale=scale
)
result = {
"format": format,
"dimensions": {
"width": export_width,
"height": export_height,
"scale": scale,
"effective_width": int(export_width * scale),
"effective_height": int(export_height * scale)
}
}
# Save to file or return base64
if output_path:
# Ensure directory exists
output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# Add extension if missing
if not output_path.endswith(f'.{format}'):
output_path = f"{output_path}.{format}"
with open(output_path, 'wb') as f:
f.write(image_bytes)
result["file_path"] = output_path
result["file_size_bytes"] = len(image_bytes)
else:
# Return as base64
result["image_data"] = base64.b64encode(image_bytes).decode('utf-8')
result["data_uri"] = f"data:image/{format};base64,{result['image_data']}"
return result
except ImportError as e:
logger.error(f"Chart export failed - missing dependency: {e}")
return {
"error": f"Missing dependency for export: {e}",
"format": format,
"image_data": None,
"install_hint": "pip install plotly kaleido"
}
except Exception as e:
logger.error(f"Chart export failed: {e}")
return {
"error": str(e),
"format": format,
"image_data": None
}