Source code for pysepal.sepalwidgets.vue_app

"""Custom Map app layout for SEPAL ui Map interfaces."""

import json
import logging
from pathlib import Path
from typing import Optional

import ipyvuetify as v
import pandas as pd
from ipywidgets import DOMWidget, jsdlink, link
from ipywidgets.widgets.widget import widget_serialization
from traitlets import Bool, Dict, HasTraits, Instance, Int, List, Unicode, observe

from pysepal.scripts import utils as su
from pysepal.solara.theme import ThemeState, get_current_theme_state
from pysepal.translator import Translator

logger = logging.getLogger("sepalui.vue_app")


[docs] class MapApp(v.VuetifyTemplate): template_file = Unicode(str(Path(__file__).parents[1] / "sepalwidgets/vue/MapApp.vue")).tag( sync=True ) app_title = Unicode("Map Application").tag(sync=True) app_icon = Unicode("mdi-earth").tag(sync=True) repo_url = Unicode("").tag(sync=True) docs_url = Unicode("").tag(sync=True) dialog_width = Int(800).tag(sync=True) dialog_fullscreen = Bool(False).tag(sync=True) main_map = List(Instance(DOMWidget)).tag(sync=True, **widget_serialization) theme_toggle = List(Instance(DOMWidget)).tag(sync=True, **widget_serialization) language_selector = List(Instance(DOMWidget)).tag(sync=True, **widget_serialization) right_panel = List(Instance(DOMWidget)).tag(sync=True, **widget_serialization) right_panel_open = Bool(False).tag(sync=True) right_panel_width = Int(300).tag(sync=True) is_pinned = Bool(True).tag(sync=True) drawer_width = Int(320).tag(sync=True) "Current drawer pixel width (collapsed or expanded). Pushed from Vue." window_width = Int(0).tag(sync=True) window_height = Int(0).tag(sync=True) "Browser window size, pushed from Vue on mount and resize." right_panel_config = Dict( default_value={ "title": "Extra Content", "icon": "mdi-widgets", "width": 300, "description": "", "toggle_icon": "mdi-chevron-left", } ).tag(sync=True) right_panel_content = List( Dict( { "title": Unicode(), "icon": Unicode(), "content": List(Instance(DOMWidget)), "divider": Bool(), "description": Unicode(), } ), default_value=[], ).tag(sync=True, **widget_serialization) steps_data = List( Dict( { "id": Int(), "name": Unicode(), "icon": Unicode(), "display": Unicode(), "right_panel_action": Unicode(), "content": List(Instance(DOMWidget)), "content_enabled": Bool(), "actions": List(), "width": Int(), "height": Int(), } ) ).tag(sync=True, **widget_serialization) initial_step = Int(allow_none=True).tag(sync=True) current_step = Int(allow_none=True).tag(sync=True) step_open = Bool(False).tag(sync=True)
[docs] def __init__( self, theme_toggle: "ThemeToggle" = None, theme_state: Optional[ThemeState] = None, initial_step: Optional[int] = None, model: Optional[HasTraits] = None, **kwargs, ): """Instantiate the MapApp class. Parameters ---------- theme_toggle : ThemeToggle, optional Theme toggle widget initial_step : int, optional Initial step to display model : HasTraits, optional Model to bind with. If provided, will automatically link matching traitlets **kwargs Additional parameters """ self._theme_state = theme_state or get_current_theme_state() self._model = model self._model_links = [] # Store links for cleanup kwargs["theme_toggle"] = self._coerce_theme_toggle(theme_toggle, self._theme_state) # Create right panel from parameters if content or config is provided right_panel = None if kwargs.get("right_panel_content") or kwargs.get("right_panel_config"): config = kwargs.get("right_panel_config", {}) content_data = kwargs.get("right_panel_content", []) right_panel = RightPanel(config=config, content_data=content_data) # Check if right_panel_open was specified and apply it if "right_panel_open" in kwargs: right_panel.is_open = kwargs["right_panel_open"] kwargs["right_panel"] = [right_panel] if right_panel else [] # Set up right panel state tracking if right_panel: kwargs["right_panel_open"] = right_panel.is_open kwargs["right_panel_width"] = right_panel.config.get("width", 300) kwargs["language_selector"] = kwargs.get("language_selector", [LocaleSelect()]) # Handle initial step configuration if initial_step is not None: kwargs["initial_step"] = initial_step kwargs["current_step"] = initial_step super().__init__(**kwargs) # Set up right panel state observation after initialization if right_panel: right_panel.observe(self._on_right_panel_change, "is_open") right_panel.observe(self._on_right_panel_config_change, "config") # Set up automatic model binding if model is provided if self._model is not None: self._setup_model_binding() # Push overlay insets to the embedded map so fit_bounds targets the visible region. self.observe( self._sync_map_insets, [ "drawer_width", "right_panel_open", "right_panel_width", "window_width", "window_height", "main_map", ], ) self.observe(self._sync_map_theme_state, ["theme_toggle", "main_map"]) self._sync_map_insets() self._sync_map_theme_state()
def _coerce_theme_toggle( self, theme_toggle, theme_state: Optional[ThemeState] ) -> list["ThemeToggle"]: """Normalize theme toggle input and bind it to the session theme state.""" if isinstance(theme_toggle, list): widgets = list(theme_toggle) elif isinstance(theme_toggle, tuple): widgets = list(theme_toggle) elif theme_toggle is None: widgets = [] else: widgets = [theme_toggle] if widgets: widget = widgets[0] if hasattr(widget, "bind_theme_state"): widget.bind_theme_state(theme_state) return widgets return [ThemeToggle(theme_state=theme_state)] # Mirror of MapApp.vue: viewports below this width dock the right # panel as a bottom sheet sized at NARROW_PANEL_HEIGHT_VH of the # window height. Both values must stay in sync with the .vue file. _NARROW_BREAKPOINT_PX = 960 _NARROW_PANEL_HEIGHT_VH = 0.45 def _sync_map_insets(self, *args): """Push drawer / right-panel pixel widths + window size onto the map.""" if not self.main_map: return map_widget = self.main_map[0] if not hasattr(map_widget, "viewport_inset_left"): return is_narrow = 0 < self.window_width < self._NARROW_BREAKPOINT_PX has_right_panel = bool(self.right_panel) map_widget.viewport_inset_left = int(self.drawer_width) # In narrow mode the right panel is docked at the bottom, so it # consumes height (mirrored below) rather than width. map_widget.viewport_inset_right = ( int(self.right_panel_width) if self.right_panel_open and not is_narrow else 0 ) if hasattr(map_widget, "viewport_inset_bottom"): bottom = ( int(self.window_height * self._NARROW_PANEL_HEIGHT_VH) if is_narrow and has_right_panel else 0 ) map_widget.viewport_inset_bottom = bottom if self.window_width > 0 and hasattr(map_widget, "canvas_width_px"): map_widget.canvas_width_px = int(self.window_width) if self.window_height > 0 and hasattr(map_widget, "canvas_height_px"): map_widget.canvas_height_px = int(self.window_height) def _sync_map_theme_state(self, *args): """Push the current theme state into the embedded map.""" if not self.main_map: return map_widget = self.main_map[0] if hasattr(map_widget, "bind_theme_state"): map_widget.bind_theme_state(self._resolve_theme_state()) def _resolve_theme_state(self) -> ThemeState: """Resolve the theme state from the mounted toggle when available.""" if self.theme_toggle and hasattr(self.theme_toggle[0], "get_theme_state"): theme_state = self.theme_toggle[0].get_theme_state() if theme_state is not None: return theme_state return self._theme_state
[docs] def vue_set_drawer_width(self, width): """Receive the real drawer pixel width from Vue on mount/mini-toggle.""" try: self.drawer_width = int(width) except (TypeError, ValueError): pass
[docs] def vue_set_window_size(self, size): """Receive the real browser window size from Vue on mount/resize.""" try: self.window_width = int(size.get("w", 0)) self.window_height = int(size.get("h", 0)) except (TypeError, ValueError, AttributeError): pass
def _setup_model_binding(self): """Set up automatic two-way binding with the provided model. This method automatically links matching traitlets between MapApp and the model. """ logger.info("Setting up model binding...") if self._model is None: return # Get all traitlet names from both objects app_traits = set(self.trait_names()) model_traits = set(self._model.trait_names()) common_traits = app_traits.intersection(model_traits) # Set up bidirectional links for common traits for trait_name in common_traits: if trait_name.startswith("_") or trait_name in [ "template_file", "main_map", "theme_toggle", "language_selector", "right_panel", "steps_data", "right_panel_content", ]: continue try: model_link = link((self, trait_name), (self._model, trait_name)) self._model_links.append(model_link) except Exception as e: logger.warning(f"⚠ Could not link {trait_name}: {e}")
[docs] def set_model(self, model: HasTraits): """Set or change the bound model. Parameters ---------- model : HasTraits New model to bind with """ # Cleanup existing links self.unlink_model() # Set new model and create links self._model = model if model is not None: self._setup_model_binding()
[docs] def vue_handle_right_panel_action(self, action): """Handle right panel actions from step activation.""" if self.right_panel and len(self.right_panel) > 0: panel = self.right_panel[0] if action == "open": panel.is_open = True elif action == "close": panel.is_open = False elif action == "toggle": panel.is_open = not panel.is_open
[docs] def vue_handle_step_change(self, step_id, is_open): """Handle step activation/deactivation from Vue component. Parameters ---------- step_id : int The ID of the step being changed is_open : bool Whether the step is being opened or closed """ self.current_step = step_id if is_open else None self.step_open = is_open
[docs] def vue_handle_step_activation(self, step_id): """Handle step activation from Vue component. Parameters ---------- step_id : int The ID of the step being activated """ self.current_step = step_id self.step_open = True
[docs] def vue_handle_step_deactivation(self, *args): """Handle step deactivation from Vue component.""" self.current_step = None self.step_open = False
def _on_right_panel_change(self, change): """Update the right panel state when it changes.""" self.right_panel_open = change["new"] def _on_right_panel_config_change(self, change): """Update the right panel width when config changes.""" new_config = change["new"] if "width" in new_config: self.right_panel_width = new_config["width"]
[docs] class ThemeToggle(v.VuetifyTemplate): template_file = Unicode(str(Path(__file__).parents[1] / "sepalwidgets/vue/Theming.vue")).tag( sync=True ) dark = Bool(None, allow_none=True).tag(sync=True) resolved_dark = Bool(False).tag(sync=True) enable_auto = Bool(True).tag(sync=True) on_icon = Unicode("mdi-weather-night").tag(sync=True) off_icon = Unicode("mdi-white-balance-sunny").tag(sync=True) auto_icon = Unicode("mdi-auto-fix").tag(sync=True)
[docs] def __init__(self, theme_state: Optional[ThemeState] = None, **kwargs): """Initialize the toggle, optionally bound to a shared ThemeState.""" self._theme_state = None super().__init__(**kwargs) if theme_state is not None: self.bind_theme_state(theme_state)
[docs] def bind_theme_state(self, theme_state: Optional[ThemeState]) -> None: """Bind the toggle widget to a shared theme state.""" if theme_state is self._theme_state: return if self._theme_state is not None: self._theme_state.unobserve(self._on_theme_state_mode_change, "mode") self._theme_state.unobserve(self._on_theme_state_dark_change, "dark") self._theme_state = theme_state if self._theme_state is None: return self.dark = ThemeState.mode_to_widget_dark(self._theme_state.mode) self.resolved_dark = bool(self._theme_state.dark) self._theme_state.observe(self._on_theme_state_mode_change, "mode") self._theme_state.observe(self._on_theme_state_dark_change, "dark")
[docs] def get_theme_state(self) -> Optional[ThemeState]: """Return the currently bound theme state.""" return self._theme_state
@observe("dark") def _on_dark_change(self, change): """Mirror widget mode changes into the shared theme state.""" if self._theme_state is not None: self._theme_state.set_mode(ThemeState.widget_dark_to_mode(change["new"])) @observe("resolved_dark") def _on_resolved_dark_change(self, change): """Mirror frontend-resolved dark/light into the shared theme state.""" if self._theme_state is not None: self._theme_state.set_dark(change["new"]) def _on_theme_state_mode_change(self, change): """Mirror external theme mode changes back into the widget.""" self.dark = ThemeState.mode_to_widget_dark(change["new"]) def _on_theme_state_dark_change(self, change): """Mirror external resolved dark/light changes back into the widget.""" self.resolved_dark = bool(change["new"])
[docs] class RightPanel(v.VuetifyTemplate): template_file = Unicode(str(Path(__file__).parents[1] / "sepalwidgets/vue/RightPanel.vue")).tag( sync=True ) is_open = Bool(False).tag(sync=True) disabled = Bool(False).tag(sync=True) config = Dict( default_value={ "title": "Extra Content", "icon": "mdi-widgets", "width": 300, "description": "", "toggle_icon": "mdi-chevron-left", } ).tag(sync=True) content_data = List( Dict( { "title": Unicode(), "icon": Unicode(), "content": List(Instance(DOMWidget)), "divider": Bool(), "description": Unicode(), } ), default_value=[], ).tag(sync=True, **widget_serialization)
[docs] def __init__(self, **kwargs): """Initialize RightPanel with event handlers.""" super().__init__(**kwargs)
[docs] def vue_panel_state_changed(self, state): """Handle panel state changes from Vue component.""" self.is_open = state
[docs] class LocaleSelect(v.VuetifyTemplate): template_file = Unicode( str(Path(__file__).parents[1] / "sepalwidgets/vue/LocaleSelect.vue") ).tag(sync=True) COUNTRIES: pd.DataFrame = pd.read_parquet(Path(__file__).parents[1] / "data" / "locale.parquet") available_locales = List([{"code": "en", "name": "English", "flag": "gb"}]).tag(sync=True) selected_locale = Unicode("en").tag(sync=True) value = Unicode().tag(sync=True)
[docs] def __init__(self, translator: Optional[Translator] = None, **kwargs): """Instantiate the LocaleSelect class.""" super().__init__(**kwargs) available_locales = ["en"] if translator is None else translator.available_locales() available_locales = self.COUNTRIES[self.COUNTRIES.code.isin(available_locales)] self.available_locales = json.loads(available_locales.to_json(orient="records")) # TODO: consider removing this, I'm not sure if an app is using the value jsdlink((self, "selected_locale"), (self, "value")) self.observe(self._on_locale_select, "selected_locale")
def _on_locale_select(self, change: dict) -> None: """adapt the application to the newly selected language. Display the new flag and country code on the widget btn change the value in the config file """ if not change["new"]: return # get the line in the locale dataframe loc = self.COUNTRIES[self.COUNTRIES.code == change["new"]].squeeze() # change the parameter file su.set_config("locale", loc.code) return