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_framesfunction, 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!