import os
import base64
import json
import math
import numpy as np
import plotly.graph_objects as go
from typing import Dict, Tuple, Optional
from ..structure.node import Node
from ..structure.vehicle import Vehicle
class AnimationBuilder:
"""Efficient class for building MCETP animations"""
def __init__(self,
nodes_dict: Dict[int, Node],
vehicles_dict: Dict[int, Vehicle],
preset_frames: int = 800,
frame_duration: int = 50,
icons: Optional[dict] = None):
"""Initialize animation builder
Args:
nodes_dict: Dictionary of all nodes
vehicles_dict: Dictionary of all vehicles
preset_frames: Total animation frames (default 800)
frame_duration: Duration per frame in ms (minimum 50)
icons: Optional dictionary of custom icons
"""
self.nodes = nodes_dict
self.vehicles = vehicles_dict
self.preset_frames = preset_frames
self.frame_duration = max(50, frame_duration)
self.icons = icons or {}
self.vehicle_icons = {}
self.fig = go.Figure()
self._setup_colors()
self._setup_icon_sizes()
# Calculate coordinate ranges for icon size normalization
self.all_x = [node.coordinate[0] for node in self.nodes.values()]
self.all_y = [node.coordinate[1] for node in self.nodes.values()]
self.x_min, self.x_max = min(self.all_x), max(self.all_x)
self.y_min, self.y_max = min(self.all_y), max(self.all_y)
self.x_range = self.x_max - self.x_min if (self.x_max - self.x_min) > 0 else 1
self.y_range = self.y_max - self.y_min if (self.y_max - self.y_min) > 0 else 1
self.base_size = min(self.x_range, self.y_range) * 0.1
def _encode_icons(self, image_url):
"""Convert local icons to base64 encoded strings
Args:
image_url: Path to local image or URL
Returns:
Base64 encoded string if local file, original URL otherwise
"""
if not os.path.exists(image_url):
return image_url # Return as-is if URL
with open(image_url, "rb") as f:
encoded_image = base64.b64encode(f.read()).decode()
postfix = os.path.splitext(os.path.basename(image_url))[1].lstrip('.')
return f"data:image/{postfix};base64,{encoded_image}"
def _setup_colors(self):
"""Configure color scheme for visual elements"""
self.colors = {
'depot': '#FF6B6B',
'customer': '#4ECDC4',
'vehicle': ['#FFD166', '#06D6A0', '#118AB2', '#EF476F', '#073B4C'],
'active_path': '#EF476F',
'completed_path': '#6A6A6A',
'text': '#073B4C',
'trail': 'rgba(100, 100, 100, 0.2)',
'background': 'rgba(250,250,250,0.9)'
}
def _setup_icon_sizes(self):
"""Configure icon size parameters"""
self.icon_params = {
'depot': {
'size': 0.8, # Relative size for depot icons
'opacity': 0.9
},
'vehicle': {
'size': 0.8, # Relative size for vehicle icons
'opacity': 0.85,
'arrow_size': 25 # Direction arrow size
}
}
# Pre-encode vehicle icons
for icon_type in ['vehicle', 'flight']:
if self.icons.get(icon_type):
self.vehicle_icons[icon_type] = self._encode_icons(
self.icons[icon_type])
def _add_static_nodes(self):
"""Add all static nodes (depots and customers) to base frame"""
depot_x, depot_y = [], []
customer_x, customer_y = [], []
depot_text, customer_text = [], []
# Process depot nodes
for idx, node in self.nodes.items():
coord = node.coordinate
info = json.dumps(node.to_dict(), indent=2)
if node.is_depot:
depot_x.append(coord[0])
depot_y.append(coord[1])
text = f"<b>🏭 Depot {idx}</b><br>{info}"
depot_text.append(text)
# Add depot icon (base layout)
if self.icons.get('depot'):
icon_size = self.base_size * self.icon_params['depot']['size']
self.fig.add_layout_image(
dict(source=self._encode_icons(self.icons['depot']),
xref="x",
yref="y",
x=coord[0],
y=coord[1],
sizex=icon_size,
sizey=icon_size,
xanchor="center",
yanchor="middle",
opacity=self.icon_params['depot']['opacity'],
layer="above"))
# Add depot hover points
self.fig.add_trace(
go.Scatter(x=depot_x,
y=depot_y,
mode='markers',
marker=dict(size=20, color='rgba(0,0,0,0)', opacity=0),
text=depot_text,
hoverinfo='text',
name='Depots',
showlegend=False))
# Add customer points
for idx, node in self.nodes.items():
if not node.is_depot:
coord = node.coordinate
info = json.dumps(node.to_dict(), indent=2)
customer_x.append(coord[0])
customer_y.append(coord[1])
customer_text.append(f"<b>📦 Customer {idx}</b><br>{info}")
self.fig.add_trace(
go.Scatter(x=customer_x,
y=customer_y,
mode='markers+text',
marker=dict(size=12,
color=self.colors['customer'],
opacity=0.8,
line=dict(width=1, color='white')),
text=[f"Node {i}" for i in self.nodes if not self.nodes[i].is_depot],
textposition='top center',
hoverinfo='text',
hovertext=customer_text,
name='Customers',
showlegend=True))
def _generate_vehicle_paths(self) -> Tuple[Dict, Dict]:
"""Pre-compute path sequences and directions for all vehicles
Returns:
Tuple containing:
- paths: Dictionary of vehicle path data
- directions: Dictionary of vehicle directions
- max_frames: Maximum frames needed for animation
"""
paths = {}
directions = {}
max_travel_time = max(
sum(segment[3] for segment in vehicle.path)
for vehicle in self.vehicles.values()) or 1 # Avoid division by zero
for vid, vehicle in self.vehicles.items():
path_seq = []
dir_seq = []
total_frames = 0
for segment in vehicle.path:
u, v, _, travel_time = segment
start = self.nodes[u].coordinate
end = self.nodes[v].coordinate
# Calculate movement direction and angle
dx = end[0] - start[0]
dy = end[1] - start[1]
angle = math.degrees(math.atan2(dy, dx))
# Calculate required frames (based on travel time proportion)
frames = max(1, int(self.preset_frames * travel_time / max_travel_time))
total_frames += frames
# Generate interpolation sequence
x_vals = np.linspace(start[0], end[0], frames + 1)[1:] # Exclude start point
y_vals = np.linspace(start[1], end[1], frames + 1)[1:]
path_seq.extend(zip(x_vals, y_vals))
dir_seq.extend([angle] * frames)
paths[vid] = {
'coords': path_seq,
'total_frames': total_frames,
'color': self.colors['vehicle'][vid % len(self.colors['vehicle'])],
'type': vehicle.type
}
directions[vid] = dir_seq
# Calculate maximum frames
max_frames = max(path['total_frames'] for path in paths.values()) if paths else 1
return paths, directions, max_frames
def _create_animation_frames(self, paths: Dict, directions: Dict, max_frames: int):
"""Create animation frame data
Args:
paths: Pre-computed vehicle paths
directions: Pre-computed vehicle directions
max_frames: Maximum number of frames needed
Returns:
List of animation frames
"""
frames = []
# Progress text trace (added separately for easy updates)
progress_trace = go.Scatter(x=[0.02],
y=[0.05],
mode='text',
textfont=dict(size=16, color=self.colors['text']),
showlegend=False,
xaxis='x',
yaxis='y',
hoverinfo='none')
# Add initial frame (frame 0)
frame_data = [progress_trace]
frames.append(go.Frame(data=frame_data, name="frame_0"))
# Create animation frames
for frame_idx in range(1, max_frames + 1):
frame_data = []
# 1. Add path traces
for vid, path_data in paths.items():
current_frame = min(frame_idx, path_data['total_frames'])
if current_frame > 0:
# Main path
seg_x, seg_y = zip(*path_data['coords'][:current_frame])
frame_data.append(
go.Scatter(
x=seg_x,
y=seg_y,
mode='lines',
line=dict(
color=path_data['color'],
width=4,
dash='dot' if frame_idx < path_data['total_frames'] else 'solid'),
showlegend=False,
hoverinfo='none'))
# Path trail
if current_frame > 5:
trail_length = min(10, current_frame)
trail_x, trail_y = zip(
*path_data['coords'][current_frame - trail_length:current_frame])
frame_data.append(
go.Scatter(x=trail_x,
y=trail_y,
mode='lines',
line=dict(color=self.colors['trail'],
width=6),
showlegend=False,
hoverinfo='none'))
# 2. Add vehicle markers
vehicle_texts = []
for vid, path_data in paths.items():
current_frame = min(frame_idx, path_data['total_frames'])
if current_frame > 0:
x, y = path_data['coords'][current_frame - 1]
angle = directions[vid][current_frame - 1]
else:
depot_id = self.vehicles[vid].belonging
x, y = self.nodes[depot_id].coordinate
angle = 0
# Vehicle direction arrow
frame_data.append(
go.Scatter(
x=[x],
y=[y],
mode='markers',
marker=dict(
size=self.icon_params['vehicle']['arrow_size'],
symbol='arrow',
angle=angle,
color=path_data['color'],
opacity=0.9,
line=dict(width=2, color='white')),
hoverinfo='none',
showlegend=False))
# Vehicle label (text)
vehicle_texts.append(
go.Scatter(x=[x],
y=[y + 0.03 * self.base_size],
mode='text',
text=[f"V{vid}"],
textfont=dict(size=10, color='white'),
showlegend=False,
textposition='top center',
hoverinfo='none'))
# 3. Add progress text
progress = min(1.0, frame_idx / max_frames)
progress_text = go.Scatter(
x=[0.02],
y=[0.05],
mode='text',
text=[f"⏱️ Progress: {progress*100:.1f}%"],
textfont=dict(size=16, color=self.colors['text']),
showlegend=False,
hoverinfo='none')
# Combine all traces
frame_data.extend(vehicle_texts)
frame_data.append(progress_text)
frames.append(go.Frame(data=frame_data, name=f"frame_{frame_idx}"))
return frames
def _add_animation_controls(self, max_frames: int):
"""Add animation control buttons and layout settings
Args:
max_frames: Maximum number of frames in animation
"""
self.fig.update_layout(
height=800,
autosize=True,
updatemenus=[{
"type": "buttons",
"showactive": False,
"buttons": [{
"label": "▶️ Play",
"method": "animate",
"args": [
None, {
"frame": {"duration": self.frame_duration, "redraw": True},
"fromcurrent": True,
"transition": {"duration": 0},
"mode": "immediate"
}
]
}, {
"label": "⏸️ Pause",
"method": "animate",
"args": [[None], {
"mode": "immediate",
"frame": {"duration": 0, "redraw": False}
}]
}],
"x": 0.1,
"y": -0.12,
"xanchor": "left",
"yanchor": "bottom"
}],
sliders=[{
"steps": [{
"method": "animate",
"args": [[f'frame_{k}'], {
"frame": {"duration": 0},
"mode": "immediate"
}],
"label": f'{k/max_frames*100:.0f}%'
} for k in range(0, max_frames + 1, max(1, max_frames // 10))],
"active": 0,
"transition": {"duration": 0},
"x": 0.1,
"y": -0.15,
"len": 0.8,
"xanchor": "left",
"yanchor": "top",
"pad": {"t": 30, "b": 10}
}],
xaxis=dict(showgrid=False,
zeroline=False,
visible=False,
scaleanchor="y",
range=[self.x_min - 0.1 * self.x_range, self.x_max + 0.1 * self.x_range]),
yaxis=dict(showgrid=False,
zeroline=False,
visible=False,
range=[self.y_min - 0.1 * self.y_range, self.y_max + 0.1 * self.y_range]),
hovermode='closest',
plot_bgcolor=self.colors['background'],
margin=dict(l=50, r=50, t=80, b=150),
legend=dict(orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1),
annotations=[
dict(text="MCETP Vehicle Routing Animation",
showarrow=False,
xref="paper",
yref="paper",
x=0.5,
y=1.1,
font=dict(size=24, color=self.colors['text']))
])
def build(self) -> go.Figure:
"""Build and return the animated figure
Returns:
plotly.graph_objects.Figure: The complete animation figure
"""
# 1. Add static elements to base frame
self._add_static_nodes()
# 2. Pre-compute vehicle paths
paths, directions, max_frames = self._generate_vehicle_paths()
# 3. Create animation frames
frames = self._create_animation_frames(paths, directions, max_frames)
self.fig.frames = frames
# 4. Add animation controls
self._add_animation_controls(max_frames)
return self.fig
def build_animation(nodes_dict: Dict[int, Node],
vehicles_dict: Dict[int, Vehicle],
preset_frames: int = 800,
frame_duration: int = 50,
icons: dict = None) -> go.Figure:
"""Entry function for building MCETP animations
Args:
nodes_dict: Dictionary of all nodes
vehicles_dict: Dictionary of all vehicles
preset_frames: Total animation frames (default 800)
frame_duration: Duration per frame in ms (default 50)
icons: Optional dictionary of custom icons supporting:
- 'depot': Path/URL to depot icon
- 'vehicle': Path/URL to vehicle icon
- 'flight': Path/URL to aircraft icon
Returns:
plotly.graph_objects.Figure: The complete animation figure
"""
builder = AnimationBuilder(nodes_dict, vehicles_dict, preset_frames,
frame_duration, icons)
return builder.build()
Hi Plotly community,
I’m working on a Plotly animation to visualize vehicles moving along predefined paths (routes). However, I’ve encountered a few issues:
-
Static Nodes Not Displayed:
It seems that if I don’t explicitly add the static nodes (e.g., depots or warehouses) inside the_create_animation_frames
function, they do not appear in the animation. Is this expected behavior? Is there a better way to render static elements that persist across all frames? -
Incomplete Path Trails:
I have several vehicles in the data, but only two of them are showing visible path trails in the animation. Others are either missing or their paths are not displayed correctly. I suspect the issue might be due to how I’m generating theframes
, but I’m not sure what the best practice is for plotting multiple moving vehicles, each with its own full route and trail.
Has anyone experienced similar issues or found a clean way to ensure:
-
static nodes are always visible, and
-
all vehicles’ path trails are drawn correctly during the animation?
Any advice, references, or best practices would be greatly appreciated.
Thanks in advance!