mlukac/xrf-explorer-dev
0
1from __future__ import annotations2 3import logging4from enum import IntEnum5from typing import TYPE_CHECKING, Optional6 7import param8 9from xrf_explorer.core.events import (10 AddDepthIntervalAnnotation,11 AddPointAnnotation,12 RemoveDepthIntervalAnnotation,13 RemovePointAnnotation,14 UpdateDepthIntervalAnnotation,15 UpdatePointAnnotation,16)17from xrf_explorer.core.types import ApplicationHandlerProtocol18from xrf_explorer.handlers.annotations import (19 DepthIntervalAnnotationHandler,20 PointAnnotationHandler,21)22from bokeh.models import ColumnDataSource23 24 25class AnnotationSelection(IntEnum):26 """Enum for annotation selection states."""27 28 NONE_SELECTED = -129 CREATE_NEW = -230 31 32class BaseAnnotationController(param.Parameterized):33 """Base class for annotation controllers providing common functionality."""34 35 # Common form inputs36 label = param.String(default="", doc="Annotation label text")37 font_size = param.Selector(38 default="14pt",39 objects=["10pt", "12pt", "14pt", "16pt", "18pt", "20pt", "24pt"],40 doc="Font size for annotation text"41 )42 angle = param.Number(default=0.0, bounds=(0, 90), doc="Text rotation angle in degrees")43 color = param.Color(default="#FF0000", doc="Annotation color")44 45 # Common action buttons46 add_annotation_button = param.Event(label="Add Annotation")47 48 # Common annotation management49 selected_annotation_index = param.Selector(50 default=AnnotationSelection.CREATE_NEW,51 objects=[],52 allow_None=False,53 label="Select Annotation",54 )55 delete_annotation_button = param.Event(label="Delete Selected")56 57 def __init__(self, app: ApplicationHandlerProtocol, handler, **kwargs) -> None:58 super().__init__(**kwargs)59 self._app = app60 self._handler = handler61 self._updating_from_selection: bool = False62 self._ui_initialized: bool = False63 64 def _update_annotation_choices(self) -> None:65 """Update the dropdown choices and handle selection persistence."""66 if not self._ui_initialized:67 return68 69 new_choices = self._get_annotation_choices()70 old_index = self.selected_annotation_index71 72 choice_objects = [choice[1] for choice in new_choices]73 choice_names = dict(new_choices)74 75 try:76 self.param.selected_annotation_index.objects = choice_names77 self.param.selected_annotation_index.names = choice_names78 except (ValueError, AttributeError) as e:79 # Log the error with full context for debugging80 logging.error(81 f"Critical error updating annotation dropdown choices in {self.__class__.__name__}: {e}. "82 f"Choice objects: {choice_objects}, Choice names: {choice_names}"83 )84 # Re-raise the exception as this indicates a serious configuration problem85 # that should not be silently ignored86 raise RuntimeError(87 f"Failed to update annotation dropdown choices: {e}"88 ) from e89 90 # Preserve selection if valid, otherwise default to CREATE_NEW91 if old_index in choice_objects:92 self.selected_annotation_index = old_index93 else:94 self.selected_annotation_index = AnnotationSelection.CREATE_NEW95 96 def initialize_ui(self) -> None:97 """Initialize the UI after all components are set up."""98 self._ui_initialized = True99 self._update_annotation_choices()100 101 # Ensure valid initial selection102 valid_choice_values = [choice[1] for choice in self._get_annotation_choices()]103 if self.selected_annotation_index not in valid_choice_values:104 self.selected_annotation_index = AnnotationSelection.CREATE_NEW105 106 def handle_annotation_data_changed(self) -> None:107 """Called after annotation data changes to update dropdown choices."""108 self._update_annotation_choices()109 110 @param.depends("delete_annotation_button", watch=True)111 def _delete_annotation_clicked(self) -> None:112 """Handle delete annotation button click."""113 if self.selected_annotation_index >= 0:114 self._app.trigger(self._get_remove_event(self.selected_annotation_index))115 self.selected_annotation_index = AnnotationSelection.CREATE_NEW116 117 @param.depends("selected_annotation_index", watch=True)118 def select_annotation(self) -> None:119 """Handle annotation selection change."""120 self._updating_from_selection = True121 122 if self.selected_annotation_index == AnnotationSelection.CREATE_NEW:123 self._reset_form()124 elif self._is_valid_annotation_index():125 self._load_annotation_data()126 else:127 # Fallback for invalid selections128 self.selected_annotation_index = AnnotationSelection.CREATE_NEW129 130 self._updating_from_selection = False131 132 def _is_valid_annotation_index(self) -> bool:133 """Check if selected annotation index is valid."""134 return (135 self.selected_annotation_index >= 0136 and self.selected_annotation_index < len(self._handler.data)137 )138 139 def _get_annotation_choices(self) -> list[tuple[str, int]]:140 """Generate dropdown choices for annotation selection."""141 raise NotImplementedError("Subclasses must implement _get_annotation_choices")142 143 def _get_remove_event(self, index: int):144 """Get the remove event for this annotation type."""145 raise NotImplementedError("Subclasses must implement _get_remove_event")146 147 def _add_annotation_clicked(self) -> None:148 """Handle add annotation button click."""149 raise NotImplementedError("Subclasses must implement _add_annotation_clicked")150 151 def _update_annotation(self) -> None:152 """Handle form field changes to update existing annotation."""153 raise NotImplementedError("Subclasses must implement _update_annotation")154 155 def _reset_form(self) -> None:156 """Reset form fields to default values."""157 raise NotImplementedError("Subclasses must implement _reset_form")158 159 def _load_annotation_data(self) -> None:160 """Load annotation data into form fields."""161 raise NotImplementedError("Subclasses must implement _load_annotation_data")162 163 164class PointAnnotationController(BaseAnnotationController):165 """Controller for managing point annotations with form inputs and selection."""166 167 # Form inputs specific to point annotations168 depth = param.Integer(default=None, bounds=(0, None))169 170 def _get_annotation_choices(self) -> list[tuple[str, int]]:171 """Generate dropdown choices for annotation selection."""172 choices = [("➕ Create New Annotation", int(AnnotationSelection.CREATE_NEW))]173 174 if len(self._handler.data) == 0:175 return choices176 177 for i, (_, row) in enumerate(self._handler.data.iterrows()):178 display_label = f"{i}: {row['label']} ({int(row['depth'])} ft)"179 choices.append((display_label, i))180 181 return choices182 183 def _get_remove_event(self, index: int):184 """Get the remove event for this annotation type."""185 return RemovePointAnnotation(index=index)186 187 @param.depends("add_annotation_button", watch=True)188 def _add_annotation_clicked(self) -> None:189 """Handle add annotation button click."""190 if self.depth is not None and self.label.strip():191 self._app.trigger(192 AddPointAnnotation(193 depth=float(self.depth), label=self.label, color=self.color,194 font_size=self.font_size, angle=self.angle195 )196 )197 # Reset form198 self._reset_form()199 200 @param.depends("depth", "label", "font_size", "angle", "color", watch=True)201 def _update_annotation(self) -> None:202 """Handle form field changes to update existing annotation."""203 if self._updating_from_selection or not self._is_valid_annotation_index():204 return205 206 if self.depth is not None:207 self._app.trigger(208 UpdatePointAnnotation(209 index=self.selected_annotation_index,210 depth=float(self.depth),211 label=self.label,212 color=self.color,213 font_size=self.font_size,214 angle=self.angle,215 )216 )217 218 def _reset_form(self) -> None:219 """Reset form fields to default values."""220 self.depth = None221 self.label = ""222 self.font_size = "14pt"223 self.angle = 0.0224 self.color = "#FF0000"225 226 def _load_annotation_data(self) -> None:227 """Load annotation data into form fields."""228 if not self._is_valid_annotation_index():229 logging.error(230 f"Invalid annotation index {self.selected_annotation_index} "231 f"for DataFrame with {len(self._handler.data)} rows. Resetting to CREATE_NEW."232 )233 self.selected_annotation_index = AnnotationSelection.CREATE_NEW234 return235 236 try:237 annotation = self._handler.data.iloc[self.selected_annotation_index]238 self.depth = int(round(annotation["depth"]))239 self.label = annotation["label"]240 self.font_size = annotation["font_size"]241 self.angle = annotation["angle"]242 self.color = annotation["color"]243 except (IndexError, KeyError, ValueError) as e:244 logging.error(245 f"Failed to load annotation data at index {self.selected_annotation_index}: {e}. "246 f"Resetting to CREATE_NEW."247 )248 self.selected_annotation_index = AnnotationSelection.CREATE_NEW249 250 251class DepthIntervalAnnotationController(BaseAnnotationController):252 """Controller for managing depth interval annotations with form inputs and selection."""253 254 # Form inputs specific to depth interval annotations255 depth_start = param.Integer(default=None, bounds=(0, None), doc="Start depth")256 depth_end = param.Integer(default=None, bounds=(0, None), doc="End depth")257 hatch_pattern = param.Selector(258 default="blank",259 objects=["blank", "dot", "ring", "horizontal_line", "vertical_line", "cross", "horizontal_dash", "vertical_dash", "spiral"],260 doc="Hatch pattern for annotation"261 )262 263 def _get_annotation_choices(self) -> list[tuple[str, int]]:264 """Generate dropdown choices for annotation selection."""265 choices = [266 ("➕ Create New Interval Annotation", int(AnnotationSelection.CREATE_NEW))267 ]268 269 if len(self._handler.data) == 0:270 return choices271 272 for i, (_, row) in enumerate(self._handler.data.iterrows()):273 start = int(row["depth_start"])274 end = int(row["depth_end"])275 display_label = f"{i}: {row['label']} ({start}-{end} ft)"276 choices.append((display_label, i))277 278 return choices279 280 def _get_remove_event(self, index: int):281 """Get the remove event for this annotation type."""282 return RemoveDepthIntervalAnnotation(index=index)283 284 @param.depends("add_annotation_button", watch=True)285 def _add_annotation_clicked(self) -> None:286 """Handle add annotation button click."""287 if (288 self.depth_start is not None289 and self.depth_end is not None290 and self.label.strip()291 and self.depth_start < self.depth_end292 ):293 self._app.trigger(294 AddDepthIntervalAnnotation(295 depth_start=float(self.depth_start),296 depth_end=float(self.depth_end),297 label=self.label,298 color=self.color,299 hatch_pattern=self.hatch_pattern,300 font_size=self.font_size,301 angle=self.angle,302 )303 )304 # Reset form305 self._reset_form()306 307 @param.depends("depth_start", "depth_end", "label", "font_size", "angle", "color", "hatch_pattern", watch=True)308 def _update_annotation(self) -> None:309 """Handle form field changes to update existing annotation."""310 if self._updating_from_selection or not self._is_valid_annotation_index():311 return312 313 if (314 self.depth_start is not None315 and self.depth_end is not None316 and self.depth_start < self.depth_end317 ):318 self._app.trigger(319 UpdateDepthIntervalAnnotation(320 index=self.selected_annotation_index,321 depth_start=float(self.depth_start),322 depth_end=float(self.depth_end),323 label=self.label,324 color=self.color,325 hatch_pattern=self.hatch_pattern,326 font_size=self.font_size,327 angle=self.angle,328 )329 )330 331 def _reset_form(self) -> None:332 """Reset form fields to default values."""333 self.depth_start = None334 self.depth_end = None335 self.label = ""336 self.font_size = "14pt"337 self.angle = 0.0338 self.color = "#FF0000"339 self.hatch_pattern = "blank"340 341 def _load_annotation_data(self) -> None:342 """Load annotation data into form fields."""343 if not self._is_valid_annotation_index():344 logging.error(345 f"Invalid annotation index {self.selected_annotation_index} "346 f"for DataFrame with {len(self._handler.data)} rows. Resetting to CREATE_NEW."347 )348 self.selected_annotation_index = AnnotationSelection.CREATE_NEW349 return350 351 try:352 annotation = self._handler.data.iloc[self.selected_annotation_index]353 self.depth_start = int(round(annotation["depth_start"]))354 self.depth_end = int(round(annotation["depth_end"]))355 self.label = annotation["label"]356 self.font_size = annotation["font_size"]357 self.angle = annotation["angle"]358 self.color = annotation["color"]359 self.hatch_pattern = annotation["hatch_pattern"]360 except (IndexError, KeyError, ValueError) as e:361 logging.error(362 f"Failed to load annotation data at index {self.selected_annotation_index}: {e}. "363 f"Resetting to CREATE_NEW."364 )365 self.selected_annotation_index = AnnotationSelection.CREATE_NEW366 