diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..81561687
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# Database Configuration
+# Copy this file to .env and fill in your values
+
+# Database Backend Selection
+# Options: 'postgres' or 'duckdb'
+DB_BACKEND=duckdb
+
+# PostgreSQL Configuration (used when DB_BACKEND=postgres)
+POSTGRES_DB=tracking_analytics
+POSTGRES_USER=dima
+POSTGRES_PASSWORD=
+POSTGRES_HOST=localhost
+POSTGRES_PORT=5432
+
+# DuckDB Configuration (used when DB_BACKEND=duckdb)
+DUCKDB_PATH=tracking.duckdb
+DUCKDB_READ_ONLY=false
diff --git a/.gcloudignore b/.gcloudignore
new file mode 100644
index 00000000..ee68fa40
--- /dev/null
+++ b/.gcloudignore
@@ -0,0 +1,87 @@
+# .gcloudignore - Exclude files from Cloud Build uploads
+# This works like .dockerignore but for gcloud builds submit
+
+# Data directories (IMPORTANT - exclude large data files)
+# Use / prefix to match only at root level
+/data/
+/simulated_data/
+/trained_models/
+/meshes/
+*.duckdb
+*.duckdb.wal
+
+# Git
+.git/
+.gitignore
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+*.egg-info/
+dist/
+build/
+
+# Virtual environments
+.venv/
+.venv-*/
+venv/
+env/
+ENV/
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Documentation
+docs/
+*.md
+*.rst
+
+# Tests
+tests/
+.pytest_cache/
+
+# Notebooks
+*.ipynb
+.ipynb_checkpoints/
+
+# Cache
+.cache/
+*.cache
+
+# Models and weights (if you have any)
+*.pth
+*.pt
+*.pkl
+*.h5
+*.onnx
+
+# Docker (keep only what we need)
+Dockerfile
+.dockerignore
+
+# Misc
+.DS_Store
+*.log
+*.tmp
+
+# Keep only essential files for dashboard deployment
+!requirements-db.txt
+!pyproject.toml
+!README.rst
+!collab_env/
+!schema/
+!scripts/deploy/
+
+.mypy_cache/
+submodules/
+sim-output/
+config-local/
+results/
+analysis/
+.ruff_cache/
\ No newline at end of file
diff --git a/TESTING_SAFETY_README.md b/TESTING_SAFETY_README.md
new file mode 100644
index 00000000..75f28fd0
--- /dev/null
+++ b/TESTING_SAFETY_README.md
@@ -0,0 +1,59 @@
+# 🚨 TESTING SAFETY - READ THIS FIRST! 🚨
+
+## CRITICAL WARNING
+
+**Database tests will DROP ALL TABLES and DELETE ALL DATA!**
+
+## Before Running Tests
+
+### ✅ SAFE: Using Test Database
+```bash
+export POSTGRES_DB=tracking_analytics_test # Ends with _test
+pytest tests/db/
+```
+
+### ❌ DANGEROUS: Using Production Database
+```bash
+export POSTGRES_DB=tracking_analytics # Production database!
+pytest tests/db/ # ← This will DELETE ALL YOUR DATA!
+```
+
+## Safety Checks
+
+Tests include automatic safety checks that will REFUSE to run if:
+
+1. Database name is `tracking_analytics`, `production`, `prod`, or `main`
+2. Database name doesn't end with `_test`
+
+**If tests are skipped, this is PROTECTING YOUR DATA!**
+
+## Setup Test Database
+
+```bash
+# Create test database
+createdb tracking_analytics_test
+
+# Configure environment
+export POSTGRES_DB=tracking_analytics_test
+export POSTGRES_USER=your_user
+export POSTGRES_PASSWORD=your_password
+
+# Now tests are safe to run
+pytest tests/db/ -v
+```
+
+## Full Documentation
+
+See [docs/data/db/testing_safety.md](docs/data/db/testing_safety.md) for complete testing guidelines.
+
+## Quick Reference
+
+| Database Name | Tests Will Run? | Safe? |
+|---------------|-----------------|-------|
+| `tracking_analytics_test` | ✅ Yes | ✅ Safe |
+| `mydb_test` | ✅ Yes | ✅ Safe |
+| `tracking_analytics` | ❌ BLOCKED | 🚨 Would destroy data |
+| `production` | ❌ BLOCKED | 🚨 Would destroy data |
+| `mydb` | ❌ BLOCKED | 🚨 No _test suffix |
+
+**When in doubt, tests being skipped is GOOD - it means your production data is protected!**
diff --git a/collab_env/dashboard/analysis_widgets.yaml b/collab_env/dashboard/analysis_widgets.yaml
new file mode 100644
index 00000000..ba61578b
--- /dev/null
+++ b/collab_env/dashboard/analysis_widgets.yaml
@@ -0,0 +1,58 @@
+# Analysis Widget Configuration
+#
+# This file defines which analysis widgets are enabled and their default parameters.
+# Widgets are loaded in order and appear as tabs in the spatial analysis GUI.
+
+# Default shared parameters (used across all widgets unless overridden)
+defaults:
+ spatial_bin_size: 10.0 # Spatial discretization for heatmaps
+ temporal_window_size: 10 # Time window for windowed analyses
+ min_samples: 100 # Minimum samples for statistical validity (matches QueryBackend default)
+
+# Widget registry
+# Each widget must specify:
+# - class: Full Python module path to widget class
+# - enabled: true/false to enable/disable widget
+# - order: Display order (lower numbers appear first)
+# - category: Grouping category (spatial, temporal, behavioral)
+
+widgets:
+ - class: collab_env.dashboard.widgets.basic_data_viewer_widget.BasicDataViewerWidget
+ enabled: true
+ order: 0
+ category: visualization
+ description: "Episode viewer with animated agent tracks and spatial density heatmap"
+
+ - class: collab_env.dashboard.widgets.extended_properties_viewer_widget.ExtendedPropertiesViewerWidget
+ enabled: true
+ order: 1
+ category: visualization
+ description: "Time series and histogram visualization of extended properties"
+
+ - class: collab_env.dashboard.widgets.velocity_widget.VelocityStatsWidget
+ enabled: true
+ order: 2
+ category: temporal
+ description: "Comprehensive velocity statistics: individual speed, mean velocity magnitude, relative velocity magnitude"
+
+ - class: collab_env.dashboard.widgets.distance_widget.DistanceStatsWidget
+ enabled: true
+ order: 3
+ category: spatial
+ description: "Relative locations: pairwise distances between agents"
+
+ - class: collab_env.dashboard.widgets.correlation_widget.CorrelationWidget
+ enabled: false
+ order: 4
+ category: behavioral
+ description: "Agent velocity correlations"
+
+# Example: To disable a widget, set enabled to false
+# - class: collab_env.dashboard.widgets.correlation_widget.CorrelationWidget
+# enabled: false
+
+# Example: To add a custom widget
+# - class: my_package.widgets.custom_widget.CustomWidget
+# enabled: true
+# order: 5
+# category: custom
diff --git a/collab_env/dashboard/spatial_analysis_app.py b/collab_env/dashboard/spatial_analysis_app.py
new file mode 100644
index 00000000..7932faa5
--- /dev/null
+++ b/collab_env/dashboard/spatial_analysis_app.py
@@ -0,0 +1,24 @@
+"""
+Entry point for Spatial Analysis Dashboard.
+
+Run with:
+ panel serve collab_env/dashboard/spatial_analysis_app.py --show --port 5008 --static-dirs dashboard-static=collab_env/dashboard/static
+
+Development mode with autoreload:
+ panel serve collab_env/dashboard/spatial_analysis_app.py --dev --show --port 5008 --static-dirs dashboard-static=collab_env/dashboard/static
+
+Note: The --static-dirs argument maps the dashboard's static files (JS/CSS)
+ to the /dashboard-static/ URL route. This is required for the animation viewer.
+"""
+
+import panel as pn
+import holoviews as hv
+from collab_env.dashboard.spatial_analysis_gui import create_app
+
+# Enable Panel extensions (removed "modal" - not needed for HTML-based overlay)
+pn.extension("tabulator", "plotly")
+hv.extension("plotly", "bokeh") # plotly first for Scatter3D support
+
+# Create and serve the app
+app = create_app()
+app.servable()
diff --git a/collab_env/dashboard/spatial_analysis_gui.py b/collab_env/dashboard/spatial_analysis_gui.py
new file mode 100644
index 00000000..1fed18c2
--- /dev/null
+++ b/collab_env/dashboard/spatial_analysis_gui.py
@@ -0,0 +1,586 @@
+"""
+Spatial Analysis GUI for 3D Boids Data - Refactored with Widget System.
+
+Panel/HoloViz-based web dashboard for interactive spatial analysis using
+modular widget architecture.
+"""
+
+import panel as pn
+import param
+import logging
+from pathlib import Path
+from typing import Optional
+
+from collab_env.data.db.query_backend import QueryBackend
+from collab_env.dashboard.widgets import WidgetRegistry, AnalysisContext, QueryScope
+
+import holoviews as hv
+
+
+# Set up logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Enable Panel extensions
+pn.extension("tabulator", "plotly")
+hv.extension("plotly", "bokeh") # plotly first for Scatter3D support
+
+
+class SpatialAnalysisGUI(param.Parameterized):
+ """
+ Main GUI for spatial analysis of 3D boids data with modular widgets.
+
+ Provides:
+ - Flexible data scope selection (episode, session, custom)
+ - Shared analysis parameters
+ - Plugin-based analysis widgets loaded from config
+ """
+
+ # Reactive parameters for scope selection
+ selected_category = param.String(default="", doc="Selected category ID")
+ selected_session = param.String(default="", doc="Selected session ID")
+ selected_episode = param.String(default="", doc="Selected episode ID")
+ scope_type = param.Selector(
+ default="Episode", objects=["Episode", "Session"], doc="Analysis scope type"
+ )
+
+ def __init__(self, widget_config: Optional[str] = None, **params):
+ super().__init__(**params)
+
+ # Initialize query backend
+ try:
+ self.query = QueryBackend()
+ logger.info(f"Connected to {self.query.config.backend} database")
+ except Exception as e:
+ logger.error(f"Failed to connect to database: {e}")
+ raise
+
+ # Load widgets from registry
+ if widget_config is None:
+ # Default config location
+ config_path = Path(__file__).parent / "analysis_widgets.yaml"
+ widget_config = str(config_path)
+
+ self.registry = WidgetRegistry(widget_config)
+ self.widgets = self.registry.get_enabled_widgets()
+ logger.info(f"Loaded {len(self.widgets)} widgets")
+
+ # Create UI components
+ self._create_widgets()
+
+ # Wire callbacks
+ self._wire_callbacks()
+
+ # Load initial data
+ self._load_categories()
+ self._load_sessions()
+
+ # Initialize widget contexts
+ self._update_widget_contexts()
+
+ def _create_widgets(self):
+ """Create all UI widgets."""
+
+ # === Data Scope Selection ===
+ self.scope_type_select = pn.widgets.RadioButtonGroup(
+ name="Analysis Scope",
+ options=["Episode", "Session"],
+ value="Episode",
+ button_type="success",
+ )
+
+ self.category_select = pn.widgets.Select(
+ name="Category", options=[], min_width=350, sizing_mode="stretch_width"
+ )
+
+ self.session_select = pn.widgets.Select(
+ name="Session", options=[], min_width=350, sizing_mode="stretch_width"
+ )
+
+ self.episode_select = pn.widgets.Select(
+ name="Episode", options=[], min_width=350, sizing_mode="stretch_width"
+ )
+
+ # === Agent Filtering ===
+ self.agent_type_select = pn.widgets.RadioButtonGroup(
+ name="Agent Type",
+ options=["agent", "target", "all"],
+ value="agent",
+ button_type="success",
+ )
+
+ # === Time Range Controls ===
+ self.start_time_slider = pn.widgets.IntSlider(
+ name="Start Time", value=0, start=0, end=3000, step=10, width=350
+ )
+
+ self.end_time_slider = pn.widgets.IntSlider(
+ name="End Time", value=3000, start=0, end=3000, step=10, width=350
+ )
+
+ # Quick time range buttons
+ self.btn_before_500 = pn.widgets.Button(
+ name="Before t=500", button_type="default", width=110
+ )
+
+ self.btn_after_500 = pn.widgets.Button(
+ name="After t=500", button_type="default", width=110
+ )
+
+ self.btn_full_range = pn.widgets.Button(
+ name="Full Range", button_type="default", width=110
+ )
+
+ # === Shared Analysis Parameters ===
+ defaults = self.registry.get_defaults()
+
+ self.spatial_bin_input = pn.widgets.FloatInput(
+ name="Spatial Bin Size",
+ value=defaults.get("spatial_bin_size", 10.0),
+ start=1.0,
+ end=100.0,
+ step=1.0,
+ width=350,
+ )
+
+ self.temporal_window_input = pn.widgets.IntInput(
+ name="Time Window Size",
+ value=defaults.get("temporal_window_size", 10),
+ start=2,
+ end=1000,
+ step=2,
+ width=350,
+ )
+
+ self.min_samples_input = pn.widgets.IntInput(
+ name="Min Samples",
+ value=defaults.get("min_samples", 10),
+ start=1,
+ end=1000,
+ width=350,
+ )
+
+ # === Status and Loading Indicators ===
+ self.status_pane = pn.pane.HTML(
+ "
Ready. Select a session to begin.
", sizing_mode="stretch_width"
+ )
+
+ self.loading_indicator = pn.indicators.LoadingSpinner(
+ value=False, width=50, height=50
+ )
+
+ def _wire_callbacks(self):
+ """Wire widget callbacks."""
+
+ # Category/Session/Episode selection
+ self.category_select.param.watch(self._on_category_change, "value")
+ self.session_select.param.watch(self._on_session_change, "value")
+ self.episode_select.param.watch(self._on_episode_change, "value")
+
+ # Quick time range buttons
+ self.btn_before_500.on_click(self._set_before_500)
+ self.btn_after_500.on_click(self._set_after_500)
+ self.btn_full_range.on_click(self._set_full_range)
+
+ # Update contexts when scope parameters change
+ self.scope_type_select.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+ self.agent_type_select.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+ self.start_time_slider.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+ self.end_time_slider.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+
+ # Shared parameter changes
+ self.spatial_bin_input.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+ self.temporal_window_input.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+ self.min_samples_input.param.watch(
+ lambda e: self._update_widget_contexts(), "value"
+ )
+
+ # ==================== Status Helpers ====================
+
+ def _show_loading(self, message: str = "Loading..."):
+ """Show loading indicator with message."""
+ self.loading_indicator.value = True
+ self.status_pane.object = f"{message}
"
+
+ def _hide_loading(self):
+ """Hide loading indicator."""
+ self.loading_indicator.value = False
+
+ def _show_success(self, message: str):
+ """Show success message."""
+ self._hide_loading()
+ self.status_pane.object = f"✅ {message}
"
+
+ def _show_error(self, message: str):
+ """Show error message."""
+ self._hide_loading()
+ self.status_pane.object = f"❌ Error: {message}
"
+
+ # ==================== Data Loading ====================
+
+ def _load_categories(self):
+ """Load available categories."""
+ try:
+ categories_df = self.query.get_categories()
+ if len(categories_df) == 0:
+ self.category_select.options = [""]
+ self.status_pane.object = (
+ "⚠️ No categories found in database.
"
+ )
+ else:
+ # Create display names (category_name)
+ category_options = {
+ row["category_name"]: row["category_id"]
+ for _, row in categories_df.iterrows()
+ }
+ self.category_select.options = [""] + list(category_options.keys())
+ self.category_select.param.trigger("options")
+ self._category_map = category_options
+ logger.info(f"Loaded {len(categories_df)} categories")
+ except Exception as e:
+ logger.error(f"Failed to load categories: {e}")
+ self._show_error(f"Failed to load categories: {e}")
+
+ def _load_sessions(self):
+ """Load available sessions for the selected category."""
+ # Get current category selection (None if not selected)
+ category_id = self.selected_category if self.selected_category else None
+
+ try:
+ sessions_df = self.query.get_sessions(category_id=category_id)
+ if len(sessions_df) == 0:
+ self.session_select.options = [""]
+ if category_id:
+ self.status_pane.object = f"⚠️ No sessions found for category '{category_id}'.
"
+ else:
+ self.status_pane.object = "⚠️ No sessions found. Load data first.
"
+ else:
+ # Create display names (session_name)
+ session_options = {
+ row["session_name"]: row["session_id"]
+ for _, row in sessions_df.iterrows()
+ }
+ self.session_select.options = [""] + list(session_options.keys())
+ self.session_select.param.trigger("options")
+ self._session_map = session_options
+ logger.info(f"Loaded {len(sessions_df)} sessions")
+ except Exception as e:
+ logger.error(f"Failed to load sessions: {e}")
+ self._show_error(f"Failed to load sessions: {e}")
+
+ def _on_category_change(self, event):
+ """Handle category selection."""
+ category_name = event.new
+ if not category_name or category_name == "":
+ self.session_select.options = [""]
+ self.episode_select.options = [""]
+ return
+
+ try:
+ # Get the category ID from the category map
+ category_id = self._category_map[category_name]
+ self.selected_category = category_id
+
+ # Load sessions for this category
+ self._load_sessions()
+
+ # Clear episode selection
+ self.episode_select.options = [""]
+
+ except Exception as e:
+ logger.error(f"Failed to load category: {e}")
+ self._show_error(f"Failed to load category: {e}")
+
+ def _on_session_change(self, event):
+ """Handle session selection."""
+ session_name = event.new
+ if not session_name or session_name == "":
+ self.episode_select.options = [""]
+ return
+
+ try:
+ session_id = self._session_map[session_name]
+ self.selected_session = session_id
+
+ # Load episodes
+ episodes_df = self.query.get_episodes(session_id)
+ if len(episodes_df) == 0:
+ self.episode_select.options = [""]
+ self._show_error("No episodes found for this session")
+ else:
+ # Create display names (episode_id for clarity, especially for GNN rollouts)
+ episode_options = {
+ row["episode_id"]: row["episode_id"]
+ for _, row in episodes_df.iterrows()
+ }
+ self.episode_select.options = [""] + list(episode_options.keys())
+ self.episode_select.param.trigger("options")
+ self._episode_map = episode_options
+
+ # Update time range sliders based on session's episodes
+ # Use the maximum num_frames across all episodes in the session
+ max_frames = int(episodes_df["num_frames"].max())
+ self.start_time_slider.end = max_frames
+ self.end_time_slider.end = max_frames
+ self.end_time_slider.value = max_frames
+ logger.info(f"Updated time range for session: 0 to {max_frames} frames")
+
+ # Get agent types from session
+ agent_types_df = self.query.get_agent_types_for_session(session_id)
+ if len(agent_types_df) > 0:
+ agent_types = agent_types_df["agent_type_id"].tolist()
+ # Always include 'all' option
+ agent_types_with_all = agent_types + ["all"]
+ self.agent_type_select.options = agent_types_with_all
+ # Set default to first agent type
+ self.agent_type_select.value = (
+ agent_types[0] if agent_types else "all"
+ )
+ logger.info(
+ f"Loaded {len(agent_types)} agent types for session: {agent_types}"
+ )
+
+ self._show_success(
+ f"Loaded {len(episodes_df)} episodes (max {max_frames} frames, {len(agent_types_df)} agent types)"
+ )
+ logger.info(f"Loaded {len(episodes_df)} episodes")
+
+ # Update widget contexts for session scope
+ self._update_widget_contexts()
+
+ except Exception as e:
+ logger.error(f"Failed to load episodes: {e}")
+ self._show_error(f"Failed to load episodes: {e}")
+
+ def _on_episode_change(self, event):
+ """Handle episode selection."""
+ episode_name = event.new
+ if not episode_name or episode_name == "":
+ return
+
+ try:
+ episode_id = self._episode_map[episode_name]
+ self.selected_episode = episode_id
+
+ # Get episode metadata to update time range
+ metadata = self.query.get_episode_metadata(episode_id)
+ if len(metadata) > 0:
+ num_frames = int(metadata.iloc[0]["num_frames"])
+ self.start_time_slider.end = num_frames
+ self.end_time_slider.end = num_frames
+ self.end_time_slider.value = num_frames
+
+ # Get agent types from episode
+ agent_types_df = self.query.get_agent_types(episode_id)
+ if len(agent_types_df) > 0:
+ agent_types = agent_types_df["agent_type_id"].tolist()
+ # Always include 'all' option
+ agent_types_with_all = agent_types + ["all"]
+ self.agent_type_select.options = agent_types_with_all
+ # Set default to first agent type
+ self.agent_type_select.value = (
+ agent_types[0] if agent_types else "all"
+ )
+ logger.info(f"Loaded {len(agent_types)} agent types: {agent_types}")
+
+ self._show_success(
+ f"Episode selected ({num_frames} frames, {len(agent_types_df)} agent types)"
+ )
+ logger.info(f"Selected episode {episode_id} ({num_frames} frames)")
+
+ # Update widget contexts for episode scope
+ self._update_widget_contexts()
+
+ except Exception as e:
+ logger.error(f"Failed to load episode metadata: {e}")
+ self._show_error(f"Failed to load episode metadata: {e}")
+
+ # ==================== Time Range Controls ====================
+
+ def _set_before_500(self, event):
+ """Set time range to before t=500."""
+ self.start_time_slider.value = 0
+ self.end_time_slider.value = 500
+
+ def _set_after_500(self, event):
+ """Set time range to after t=500."""
+ self.start_time_slider.value = 500
+ self.end_time_slider.value = self.end_time_slider.end
+
+ def _set_full_range(self, event):
+ """Set time range to full episode."""
+ self.start_time_slider.value = 0
+ self.end_time_slider.value = self.end_time_slider.end
+
+ # ==================== Context Management ====================
+
+ def _get_current_scope(self) -> Optional[QueryScope]:
+ """Build QueryScope from current UI state."""
+ scope_type_str = self.scope_type_select.value.lower()
+
+ if scope_type_str == "episode":
+ if not self.selected_episode:
+ return None
+
+ return QueryScope.from_episode(
+ episode_id=self.selected_episode,
+ start_time=self.start_time_slider.value,
+ end_time=self.end_time_slider.value,
+ agent_type=self.agent_type_select.value,
+ )
+
+ elif scope_type_str == "session":
+ if not self.selected_session:
+ return None
+
+ return QueryScope.from_session(
+ session_id=self.selected_session,
+ start_time=self.start_time_slider.value,
+ end_time=self.end_time_slider.value,
+ agent_type=self.agent_type_select.value,
+ )
+
+ return None
+
+ def _get_context(self) -> Optional[AnalysisContext]:
+ """Build AnalysisContext from current UI state."""
+ scope = self._get_current_scope()
+ if scope is None:
+ return None
+
+ return AnalysisContext(
+ query_backend=self.query,
+ scope=scope,
+ spatial_bin_size=self.spatial_bin_input.value,
+ temporal_window_size=self.temporal_window_input.value,
+ min_samples=self.min_samples_input.value,
+ on_loading=self._show_loading,
+ on_success=self._show_success,
+ on_error=self._show_error,
+ )
+
+ def _update_widget_contexts(self):
+ """Update context for all widgets when scope changes."""
+ context = self._get_context()
+ if context:
+ for widget in self.widgets:
+ widget.context = context
+ logger.debug(f"Updated widget contexts: {context.scope}")
+
+ @param.depends("scope_type_select.value")
+ def _get_data_selectors(self):
+ """Get data selection widgets based on current scope type."""
+ scope = self.scope_type_select.value
+
+ if scope == "Episode":
+ # Episode scope: show category, session, and episode selectors
+ return pn.Column(
+ self.category_select, self.session_select, self.episode_select
+ )
+ else: # Session scope
+ # Session scope: show only category and session selectors (no episode)
+ return pn.Column(
+ self.category_select,
+ self.session_select,
+ pn.pane.Markdown(
+ "_Episode selection not required for session-level analysis_",
+ styles={"font-size": "0.9em", "color": "#666"},
+ ),
+ )
+
+ @param.depends("scope_type_select.value")
+ def _get_time_range_controls(self):
+ """Get time range controls (available for both Episode and Session scope)."""
+ scope = self.scope_type_select.value
+
+ if scope == "Episode":
+ # Episode scope: show time range controls with quick action buttons
+ return pn.Column(
+ "## Time Range",
+ self.start_time_slider,
+ self.end_time_slider,
+ pn.Row(self.btn_before_500, self.btn_after_500, self.btn_full_range),
+ pn.layout.Divider(),
+ )
+ else: # Session scope
+ # Session scope: show time range controls (filters across all episodes)
+ return pn.Column(
+ "## Time Range (applies to all episodes in session)",
+ self.start_time_slider,
+ self.end_time_slider,
+ pn.pane.Markdown(
+ "_Filter data across all episodes in the session_",
+ styles={"font-size": "0.9em", "color": "#666"},
+ ),
+ pn.layout.Divider(),
+ )
+
+ # ==================== Layout ====================
+
+ def create_layout(self):
+ """Create the dashboard layout with dynamic widget tabs."""
+
+ # Sidebar with scope selection + shared parameters
+ sidebar = pn.Column(
+ "## Data Selection",
+ self.scope_type_select,
+ self._get_data_selectors,
+ pn.layout.Divider(),
+ "## Agent Type",
+ self.agent_type_select,
+ pn.layout.Divider(),
+ self._get_time_range_controls,
+ "## Shared Parameters",
+ self.spatial_bin_input,
+ self.temporal_window_input,
+ self.min_samples_input,
+ width=400,
+ sizing_mode="stretch_height",
+ )
+
+ # Main content: Dynamic tabs from widgets
+ tabs = pn.Tabs(
+ *[(w.widget_name, w.get_tab_content()) for w in self.widgets],
+ sizing_mode="stretch_both",
+ )
+
+ # Status bar with loading indicator
+ status_bar = pn.Row(
+ self.loading_indicator, self.status_pane, sizing_mode="stretch_width"
+ )
+
+ # Create template
+ template = pn.template.MaterialTemplate(
+ title="CIS Analysis Dashboard",
+ sidebar=[sidebar],
+ main=[status_bar, tabs],
+ header_background="#2596be",
+ sidebar_width=400,
+ )
+
+ return template
+
+
+def create_app():
+ """
+ Create and return the spatial analysis app.
+
+ Returns
+ -------
+ pn.template.MaterialTemplate
+ Panel template ready to serve
+ """
+ gui = SpatialAnalysisGUI()
+ return gui.create_layout()
diff --git a/collab_env/dashboard/static/js/viewers/episode_animation_viewer.js b/collab_env/dashboard/static/js/viewers/episode_animation_viewer.js
new file mode 100644
index 00000000..e39e4052
--- /dev/null
+++ b/collab_env/dashboard/static/js/viewers/episode_animation_viewer.js
@@ -0,0 +1,317 @@
+/**
+ * Episode Animation Viewer - client-side 2D animation for episode tracks
+ *
+ * Handles 2D visualization with smooth playback using Canvas API.
+ * Data is embedded directly from Panel - no HTTP requests needed.
+ */
+
+import { FrameManager } from '../utils/frame_manager.js';
+
+export class EpisodeAnimationViewer {
+ constructor(animationData, canvasId) {
+ console.log('🚀 Starting Episode Animation Viewer');
+
+ this.data = animationData;
+ this.canvasId = canvasId;
+
+ // Validate data
+ if (!this.data || !this.data.tracks || this.data.tracks.length === 0) {
+ console.error('No track data available');
+ return;
+ }
+
+ // Canvas and context
+ this.canvas = document.getElementById(canvasId);
+ if (!this.canvas) {
+ console.error(`Canvas element not found: ${canvasId}`);
+ return;
+ }
+ this.ctx = this.canvas.getContext('2d');
+
+ // Frame management
+ const timeRange = this.data.time_range || [0, 100];
+ this.frameManager = new FrameManager(timeRange[1] - timeRange[0]);
+ this.frameManager.setSpeed(this.data.settings.playback_speed || 1.0);
+
+ // Settings from Panel
+ this.trailLength = this.data.settings.trail_length || 50;
+ this.showIds = this.data.settings.show_agent_ids !== false;
+
+ // Bounds from data
+ this.xRange = this.data.bounds.x_range || [0, 1];
+ this.yRange = this.data.bounds.y_range || [0, 1];
+
+ // Agent colors
+ this.agentColors = this.data.agent_colors || {};
+
+ // Organize tracks by time and agent for efficient lookup
+ this.tracksByTime = this._organizeTracks();
+
+ // Trail history: {agent_id: [{x, y, time}, ...]}
+ this.trailHistory = {};
+
+ // Agent appearance
+ this.agentRadius = 8;
+
+ // Animation state
+ this.animationId = null;
+
+ // Initialize
+ this.init();
+ }
+
+ init() {
+ this.setupCanvas();
+ this.setupFrameListener();
+ this.startAnimation();
+
+ console.log('✅ Episode Animation Viewer ready');
+ console.log(` Tracks: ${this.data.tracks.length} points`);
+ console.log(` Time range: ${this.data.time_range[0]} - ${this.data.time_range[1]}`);
+ console.log(` Bounds: X[${this.xRange[0]}, ${this.xRange[1]}], Y[${this.yRange[0]}, ${this.yRange[1]}]`);
+ }
+
+ setupCanvas() {
+ // Set canvas size to match container
+ const resizeCanvas = () => {
+ const rect = this.canvas.getBoundingClientRect();
+ this.canvas.width = rect.width;
+ this.canvas.height = rect.height;
+ this.render(); // Re-render after resize
+ };
+
+ resizeCanvas();
+ window.addEventListener('resize', resizeCanvas);
+ this.resizeHandler = resizeCanvas;
+ }
+
+ _organizeTracks() {
+ /**
+ * Organize tracks by time_index for fast lookup
+ * Returns: {time_index: {agent_id: {x, y, speed, ...}, ...}, ...}
+ */
+ const tracksByTime = {};
+
+ for (const track of this.data.tracks) {
+ const time = track.time_index;
+ if (!tracksByTime[time]) {
+ tracksByTime[time] = {};
+ }
+ tracksByTime[time][track.agent_id] = track;
+ }
+
+ return tracksByTime;
+ }
+
+ setupFrameListener() {
+ // Register callback for frame changes
+ this.frameManager.onFrameChange((frame) => {
+ this.updateTrailHistory(frame);
+ });
+ }
+
+ updateTrailHistory(frame) {
+ /**
+ * Update trail history for the current frame
+ * Maintains a sliding window of trail_length frames
+ */
+ const currentTime = this.data.time_range[0] + frame;
+ const startTime = Math.max(this.data.time_range[0], currentTime - this.trailLength);
+
+ // Clear trail history
+ this.trailHistory = {};
+
+ // Build trail history from startTime to currentTime
+ for (let t = startTime; t <= currentTime; t++) {
+ const tracksAtTime = this.tracksByTime[t];
+ if (!tracksAtTime) continue;
+
+ for (const agentId in tracksAtTime) {
+ const track = tracksAtTime[agentId];
+
+ if (!this.trailHistory[agentId]) {
+ this.trailHistory[agentId] = [];
+ }
+
+ this.trailHistory[agentId].push({
+ x: track.x,
+ y: track.y,
+ time: t
+ });
+ }
+ }
+ }
+
+ startAnimation() {
+ // Start render loop
+ const animate = () => {
+ this.render();
+ this.animationId = requestAnimationFrame(animate);
+ };
+ animate();
+ }
+
+ render() {
+ if (!this.ctx) return;
+
+ // Clear canvas
+ this.ctx.fillStyle = '#1a1a1a';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // Get current frame and time
+ const frame = this.frameManager.currentFrame;
+ const currentTime = this.data.time_range[0] + frame;
+
+ // Draw grid
+ this.drawGrid();
+
+ // Draw trails
+ this.drawTrails();
+
+ // Draw current positions
+ this.drawCurrentPositions(currentTime);
+ }
+
+ drawGrid() {
+ /**
+ * Draw background grid for reference
+ */
+ this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
+ this.ctx.lineWidth = 1;
+
+ // Vertical lines
+ const numVerticalLines = 10;
+ for (let i = 0; i <= numVerticalLines; i++) {
+ const x = (i / numVerticalLines) * this.canvas.width;
+ this.ctx.beginPath();
+ this.ctx.moveTo(x, 0);
+ this.ctx.lineTo(x, this.canvas.height);
+ this.ctx.stroke();
+ }
+
+ // Horizontal lines
+ const numHorizontalLines = 10;
+ for (let i = 0; i <= numHorizontalLines; i++) {
+ const y = (i / numHorizontalLines) * this.canvas.height;
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, y);
+ this.ctx.lineTo(this.canvas.width, y);
+ this.ctx.stroke();
+ }
+ }
+
+ drawTrails() {
+ /**
+ * Draw trails for all agents
+ */
+ for (const agentId in this.trailHistory) {
+ const trail = this.trailHistory[agentId];
+ if (trail.length < 2) continue;
+
+ const color = this.agentColors[agentId] || '#888888';
+
+ this.ctx.strokeStyle = color;
+ this.ctx.lineWidth = 2;
+ this.ctx.globalAlpha = 0.6;
+
+ this.ctx.beginPath();
+ const firstPoint = trail[0];
+ const [x0, y0] = this.worldToCanvas(firstPoint.x, firstPoint.y);
+ this.ctx.moveTo(x0, y0);
+
+ for (let i = 1; i < trail.length; i++) {
+ const point = trail[i];
+ const [x, y] = this.worldToCanvas(point.x, point.y);
+ this.ctx.lineTo(x, y);
+ }
+
+ this.ctx.stroke();
+ this.ctx.globalAlpha = 1.0;
+ }
+ }
+
+ drawCurrentPositions(currentTime) {
+ /**
+ * Draw current agent positions as colored circles
+ */
+ const tracksAtTime = this.tracksByTime[currentTime];
+ if (!tracksAtTime) return;
+
+ for (const agentId in tracksAtTime) {
+ const track = tracksAtTime[agentId];
+ const color = this.agentColors[agentId] || '#888888';
+
+ const [x, y] = this.worldToCanvas(track.x, track.y);
+
+ // Draw circle
+ this.ctx.fillStyle = color;
+ this.ctx.beginPath();
+ this.ctx.arc(x, y, this.agentRadius, 0, 2 * Math.PI);
+ this.ctx.fill();
+
+ // Draw border
+ this.ctx.strokeStyle = '#ffffff';
+ this.ctx.lineWidth = 2;
+ this.ctx.stroke();
+
+ // Draw agent ID if enabled
+ if (this.showIds) {
+ this.ctx.fillStyle = '#ffffff';
+ this.ctx.font = 'bold 12px sans-serif';
+ this.ctx.textAlign = 'center';
+ this.ctx.textBaseline = 'middle';
+ this.ctx.fillText(agentId.toString(), x, y);
+ }
+ }
+ }
+
+ worldToCanvas(x, y) {
+ /**
+ * Convert world coordinates to canvas coordinates
+ * World coordinates are in the range [xRange, yRange]
+ * Canvas coordinates are in pixels
+ */
+ const xNorm = (x - this.xRange[0]) / (this.xRange[1] - this.xRange[0]);
+ const yNorm = (y - this.yRange[0]) / (this.yRange[1] - this.yRange[0]);
+
+ const canvasX = xNorm * this.canvas.width;
+ // Flip Y axis (canvas Y increases downward, world Y increases upward)
+ const canvasY = (1 - yNorm) * this.canvas.height;
+
+ return [canvasX, canvasY];
+ }
+
+ setAgentSize(radius) {
+ /**
+ * Set the agent circle radius
+ */
+ console.log(`2D setAgentSize called with radius: ${radius}`);
+ this.agentRadius = radius;
+ }
+
+ destroy() {
+ /**
+ * Clean up resources
+ */
+ console.log('🛑 Destroying Episode Animation Viewer');
+
+ // Stop animation
+ if (this.animationId) {
+ cancelAnimationFrame(this.animationId);
+ this.animationId = null;
+ }
+
+ // Stop frame manager
+ if (this.frameManager) {
+ this.frameManager.destroy();
+ }
+
+ // Remove resize handler
+ if (this.resizeHandler) {
+ window.removeEventListener('resize', this.resizeHandler);
+ }
+
+ console.log('✅ Episode Animation Viewer destroyed');
+ }
+}
+// Cache bust: 1736812800
diff --git a/collab_env/dashboard/static/js/viewers/episode_animation_viewer_3d.js b/collab_env/dashboard/static/js/viewers/episode_animation_viewer_3d.js
new file mode 100644
index 00000000..5c16297a
--- /dev/null
+++ b/collab_env/dashboard/static/js/viewers/episode_animation_viewer_3d.js
@@ -0,0 +1,517 @@
+/**
+ * Episode Animation Viewer 3D - client-side 3D animation for episode tracks
+ *
+ * Handles 3D visualization with smooth playback using Three.js WebGL.
+ * Data is embedded directly from Panel - no HTTP requests needed.
+ */
+
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
+import { FrameManager } from '../utils/frame_manager.js';
+
+export class EpisodeAnimationViewer {
+ constructor(animationData, canvasId) {
+ console.log('🚀 Starting Episode Animation Viewer 3D');
+
+ this.data = animationData;
+ this.canvasId = canvasId;
+
+ // Validate data
+ if (!this.data || !this.data.tracks || this.data.tracks.length === 0) {
+ console.error('No track data available');
+ return;
+ }
+
+ // Canvas element (or container)
+ this.canvasElement = document.getElementById(canvasId);
+ if (!this.canvasElement) {
+ console.error(`Canvas element not found: ${canvasId}`);
+ return;
+ }
+
+ // Determine if we have a canvas or container
+ this.isCanvasElement = this.canvasElement.tagName.toLowerCase() === 'canvas';
+
+ // Three.js components
+ this.scene = null;
+ this.camera = null;
+ this.renderer = null;
+ this.labelRenderer = null;
+ this.controls = null;
+
+ // Scene objects
+ this.spheres = {}; // agent_id -> sphere mesh
+ this.trails = {}; // agent_id -> line/tube mesh
+ this.labels = {}; // agent_id -> CSS2D label
+ this.trailPositions = {}; // agent_id -> [{x, y, z, time}, ...]
+
+ // Frame management
+ const timeRange = this.data.time_range || [0, 100];
+ this.frameManager = new FrameManager(timeRange[1] - timeRange[0]);
+ this.frameManager.setSpeed(this.data.settings.playback_speed || 1.0);
+
+ // Settings from Panel
+ this.trailLength = this.data.settings.trail_length || 50;
+ this.showIds = this.data.settings.show_agent_ids !== false;
+
+ // Bounds from data
+ this.xRange = this.data.bounds.x_range || [0, 1];
+ this.yRange = this.data.bounds.y_range || [0, 1];
+ this.zRange = this.data.bounds.z_range || [0, 1];
+
+ // Agent colors
+ this.agentColors = this.data.agent_colors || {};
+
+ // Organize tracks by time and agent for efficient lookup
+ this.tracksByTime = this._organizeTracks();
+
+ // Calculate scene parameters
+ this.sceneCenter = new THREE.Vector3(
+ (this.xRange[0] + this.xRange[1]) / 2,
+ (this.yRange[0] + this.yRange[1]) / 2,
+ (this.zRange[0] + this.zRange[1]) / 2
+ );
+ this.sceneSize = Math.max(
+ this.xRange[1] - this.xRange[0],
+ this.yRange[1] - this.yRange[0],
+ this.zRange[1] - this.zRange[0]
+ );
+
+ // Calculate appropriate sphere size (0.5% of scene size)
+ this.sphereSize = this.sceneSize * 0.005;
+
+ // Agent appearance scale (1.0 = default, adjustable via setAgentSize)
+ this.currentScale = 1.0;
+
+ // Animation state
+ this.animationId = null;
+
+ // Initialize
+ this.init();
+ }
+
+ init() {
+ this.setupScene();
+ this.setupCamera();
+ this.setupRenderer();
+ this.setupLights();
+ this.setupHelpers();
+ this.initializeAgents();
+ this.setupFrameListener();
+ this.startAnimation();
+
+ console.log('✅ Episode Animation Viewer 3D ready');
+ console.log(` Canvas mode: ${this.isCanvasElement ? 'Using existing