Plotly animation to visualize vehicles, display problems

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:

  1. 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?

  2. 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 the frames, 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!

The entrance is build_animation function,I don’t know why, thank you for your help

Hello @Jiantao_Weng welcome to the forums. It will be pretty dificult to help you with the information you provided. Try to isolate the problems/issues one by one. Providing a minimal, reproducible example increases your chances vastly to get helped.