- 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>
398 lines
13 KiB
Python
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": []
|
|
}
|