mlukac/xrf-explorer-dev
0
1from dataclasses import dataclass2from typing import Any, Callable3 4import param5from panel.widgets import MenuButton6 7from xrf_explorer.common.defaults import DEFAULT_FIGURES_CONFIG, DEFAULT_CURVE_GROUPS8from xrf_explorer.core.events import CombCtrlUpdated, FileLoadHandled, NormalizationUpdated, UpdateVizColumns, FigureNormalization9from xrf_explorer.core.types import ApplicationHandlerProtocol, Curve, DatasetType10from xrf_explorer.helpers.print import time_me11 12TCurveGroup = dict[DatasetType, list[str]]13TExportCurveGroup = dict[DatasetType, list[Curve]]14TLoadedCurves = dict[DatasetType, dict[str, Curve]]15 16 17class CombCurveController(param.Parameterized):18 19 set_group = param.Event(label="save as new group")20 add_fig = param.Event(label="add new figure")21 22 fig_name = param.Selector()23 editable_fig_name = param.String()24 25 normalize_xrf = param.Boolean(default=False, label="Normalize XRF")26 27 xrf_selector = param.ListSelector()28 mass_spec_selector = param.ListSelector()29 gamma_ray_selector = param.ListSelector()30 drilling_selector = param.ListSelector()31 32 def __init__(self, app: ApplicationHandlerProtocol, **kwargs) -> None:33 super().__init__(**kwargs)34 self._app = app35 self._figure_manager = _FigureManager(self)36 self._group_manager = _CurveGroupManager(self)37 self._slctr_manager = _SelectorManager(38 self,39 [40 _SelectorMeta(41 dataset_type=DatasetType.XRF,42 parameter=self.param.xrf_selector,43 value_getter=lambda: self.xrf_selector,44 ),45 _SelectorMeta(46 dataset_type=DatasetType.MASS_SPEC,47 parameter=self.param.mass_spec_selector,48 value_getter=lambda: self.mass_spec_selector,49 ),50 _SelectorMeta(51 dataset_type=DatasetType.GAMMA_RAY,52 parameter=self.param.gamma_ray_selector,53 value_getter=lambda: self.gamma_ray_selector,54 ),55 _SelectorMeta(56 dataset_type=DatasetType.DRILLING,57 parameter=self.param.drilling_selector,58 value_getter=lambda: self.drilling_selector,59 ),60 ],61 )62 63 self._group_dropdown_button = MenuButton(64 name="Set Curves To",65 items=self._group_manager.group_names,66 icon="transfer",67 icon_size="18px",68 )69 self._group_dropdown_button.on_click(self._set_group_from_dropdown)70 71 # dropdown menu to select figure to remove72 self._remove_fig_dropdown_button = MenuButton(73 name="Remove",74 items=self._figure_manager.fig_names,75 icon="square-rounded-minus",76 icon_size="18px",77 )78 self._remove_fig_dropdown_button.on_click(self._remove_figure)79 80 self._discard_figure_update: bool = False81 82 @property83 def _loaded_curves(self) -> TLoadedCurves:84 return self._app.curves.curves85 86 @property87 def group_dropdown_button(self) -> MenuButton:88 return self._group_dropdown_button89 90 @property91 def remove_fig_dropdown_button(self) -> MenuButton:92 return self._remove_fig_dropdown_button93 94 @property95 def figures(self) -> dict[str, TExportCurveGroup]:96 return self._figure_manager.exportable_figures97 98 def get_current_figure_normalization_state(self) -> bool:99 return self._figure_manager.get_normalization_state(self.fig_name)100 101 def handle_file_load(self, event: FileLoadHandled) -> None:102 self._slctr_manager.sync_selector(event.dataset_type)103 104 # Create/update figures from DEFAULT_FIGURES_CONFIG105 self._update_figures_from_config(event.dataset_type)106 107 def _update_figures_from_config(self, dataset_type: DatasetType) -> None:108 """Create or update figures based on DEFAULT_FIGURES_CONFIG for the given dataset type."""109 from xrf_explorer.common.defaults import DEFAULT_FIGURES_CONFIG110 111 available_curves = list(self._loaded_curves[dataset_type].keys())112 first_created_figure = None113 114 # Batch all figure updates in single context to minimize view rebuilds115 with _VizCurveUpdateContext(self):116 for fig_name, fig_config in DEFAULT_FIGURES_CONFIG.items():117 # Skip figures that have no curves for this dataset type118 config_curves = fig_config.get(dataset_type, [])119 if not config_curves:120 continue121 122 # Filter to curves that actually exist in the loaded file123 valid_curves = [c for c in config_curves if c in available_curves]124 if not valid_curves:125 continue126 127 # Check if figure already exists128 if fig_name in self._figure_manager.figures:129 # Figure exists - merge new curves with existing selections130 existing_curves = self._figure_manager.get_figure(fig_name)131 existing_dataset_curves = existing_curves.get(dataset_type, [])132 133 # Merge curves, avoiding duplicates134 merged_curves = list(existing_dataset_curves)135 for curve in valid_curves:136 if curve not in merged_curves:137 merged_curves.append(curve)138 139 # Update the existing figure with merged curves (no individual viz trigger)140 updated_curves = existing_curves.copy()141 updated_curves[dataset_type] = merged_curves142 self._figure_manager.set_figure(fig_name, updated_curves, auto_trigger_viz=False)143 else:144 # Figure doesn't exist - create new figure with these curves (no individual viz trigger)145 new_figure_curves = {dataset_type: valid_curves}146 self._figure_manager.add_new_figure(fig_name, new_figure_curves, auto_trigger_viz=False)147 148 if first_created_figure is None:149 first_created_figure = fig_name150 151 # Set the first created figure as active (if any were created)152 if first_created_figure is not None:153 self._figure_manager.fig_name = first_created_figure154 155 # Set normalization states for specific figures that should be normalized by default156 figures_to_normalize = ["Major Elements", "Redox Elements"]157 for fig_name in figures_to_normalize:158 if fig_name in self._figure_manager.figures:159 self._figure_manager.set_normalization_state(fig_name, True)160 161 # Sync UI checkbox for currently selected figure162 if self.fig_name and self.fig_name in figures_to_normalize:163 self._discard_figure_update = True164 self.normalize_xrf = self._figure_manager.get_normalization_state(self.fig_name)165 self._discard_figure_update = False166 167 # Compute normalized columns for all figures that have normalization enabled168 self.recompute_active_normalizations(None)169 170 @time_me()171 def _set_group_from_dropdown(self, p_event: Any) -> None:172 self._group_manager.update_group_name(str(p_event.new))173 self._set_group_from_dropdown_impl()174 175 def _set_group_from_dropdown_impl(self) -> None:176 self._discard_figure_update = True177 curve_group = self._group_manager.curve_group178 self._slctr_manager.set_curve_group(curve_group, manual_trigger=True)179 self._discard_figure_update = False180 181 self._update_figures_impl()182 self._app.trigger(CombCtrlUpdated())183 184 @time_me()185 def _remove_figure(self, p_event: Any) -> None:186 forsaken_fig_name = p_event.new187 self._figure_manager.remove_fig(forsaken_fig_name)188 self._remove_fig_dropdown_button.items = self._figure_manager.fig_names189 self._app.trigger(CombCtrlUpdated())190 191 @time_me()192 @param.depends("set_group", watch=True)193 def _set_to_group(self):194 self._group_manager.add_new_group(self._slctr_manager.get_curve_group())195 self._update_figures_impl()196 self._group_dropdown_button.items = self._group_manager.group_names197 self._app.trigger(CombCtrlUpdated())198 199 @time_me()200 @param.depends("add_fig", watch=True)201 def _add_new_figure(self):202 self._figure_manager.add_new_figure()203 self._update_fig_dropdown()204 self._app.trigger(CombCtrlUpdated())205 206 @time_me()207 @param.depends("fig_name", watch=True)208 def _change_figure_selectors(self):209 curve_group = self._figure_manager.get_figure(self.fig_name)210 211 # Suppress figure updates during UI sync212 self._discard_figure_update = True213 214 # Collect all parameter changes to batch them215 selector_params = self._slctr_manager.get_curve_group_params(curve_group)216 all_params = {217 **selector_params,218 'editable_fig_name': self.fig_name,219 'normalize_xrf': self._figure_manager.get_normalization_state(self.fig_name)220 }221 222 # Apply all parameter changes at once223 self.param.update(**all_params)224 225 self._discard_figure_update = False226 227 @time_me()228 @param.depends("editable_fig_name", watch=True)229 def _update_figure_name(self):230 if self._discard_figure_update:231 return232 if self._figure_manager.is_editable_fig_name_new():233 self._figure_manager.save_editable_name()234 self._update_fig_dropdown()235 self._app.trigger(CombCtrlUpdated())236 237 @time_me()238 @param.depends("normalize_xrf", watch=True)239 def _update_normalization(self) -> None:240 if self._discard_figure_update:241 return242 # Update the current figure's normalization state243 self._figure_manager.set_normalization_state(self.fig_name, self.normalize_xrf)244 245 # Gather normalization information for the event246 current_fig_name = self.fig_name247 fig_id = self._figure_manager.get_figure_id(current_fig_name)248 249 # Get XRF elements for the current figure250 figures = self.figures251 if figures and current_fig_name in figures:252 fig_curves = figures[current_fig_name]253 xrf_curves = fig_curves.get(DatasetType.XRF, [])254 element_names = [curve.name for curve in xrf_curves]255 else:256 element_names = []257 258 self._app.trigger(NormalizationUpdated(259 normalizations=[FigureNormalization(260 fig_id=fig_id,261 fig_name=current_fig_name,262 elements=element_names,263 normalize_enabled=self.normalize_xrf264 )]265 ))266 267 @time_me()268 @param.depends("xrf_selector", watch=True)269 def _handle_xrf_selector_change(self) -> None:270 if self._discard_figure_update:271 return272 273 # If normalization is currently enabled for this figure, disable it274 # since the XRF elements are changing275 if self._figure_manager.get_normalization_state(self.fig_name):276 print(f"[CombController] XRF elements changed - disabling normalization for figure '{self.fig_name}'")277 self._figure_manager.set_normalization_state(self.fig_name, False)278 279 # Update UI to reflect disabled normalization280 # Use the same pattern as _change_figure_selectors281 self._discard_figure_update = True282 self.normalize_xrf = False283 self._discard_figure_update = False284 285 # Continue with normal selector update logic286 self._update_selectors()287 288 @time_me()289 @param.depends(290 "mass_spec_selector",291 "gamma_ray_selector",292 "drilling_selector",293 watch=True,294 )295 def _update_selectors(self) -> None:296 self._update_figures_impl()297 298 def _update_figures_impl(self) -> None:299 if self._discard_figure_update:300 return301 302 fig_data = self._slctr_manager.get_curve_group()303 self._figure_manager.set_figure(self.fig_name, fig_data)304 self._app.trigger(CombCtrlUpdated())305 306 def _update_fig_dropdown(self) -> None:307 self._remove_fig_dropdown_button.items = self.param.fig_name.objects308 309 def _reset(self) -> None:310 self._figure_manager.set_figure(self.fig_name, {})311 empty_group = self._group_manager.get_curve_group("empty")312 self._slctr_manager.set_curve_group(empty_group)313 314 @time_me()315 def handle_normalization_viz_update(self, _event) -> None:316 """Trigger UpdateVizColumns for ALL normalized columns across all figures."""317 318 curves_to_add = []319 320 # Check all figures for normalized columns321 for fig_name, fig_curves in self.figures.items():322 # Skip if this figure doesn't have normalization enabled323 if not self._figure_manager.get_normalization_state(fig_name):324 continue325 326 xrf_curves = fig_curves.get(DatasetType.XRF, [])327 element_names = [curve.name for curve in xrf_curves] if xrf_curves else []328 329 if len(element_names) <= 1:330 continue331 332 # Get normalized column names for this figure333 fig_id = self._figure_manager.get_figure_id(fig_name)334 normalized_col_names = [f"{element}_norm_fig_{fig_id}" for element in element_names]335 336 # Add curves that exist in the registry337 for col_name in normalized_col_names:338 if col_name in self._app.curves.curves[DatasetType.XRF_STATS]:339 curves_to_add.append(self._app.curves.curves[DatasetType.XRF_STATS][col_name])340 341 # Single UpdateVizColumns event for all normalized curves342 if curves_to_add:343 self._app.trigger(UpdateVizColumns(344 ctrl_name="NormalizationHandler",345 to_add=curves_to_add,346 to_remove=[]347 ))348 349 def recompute_active_normalizations(self, _event) -> None:350 """Recompute all active normalized columns after XRF data changes (e.g., slicing)."""351 normalizations = []352 353 # Iterate through all figures and collect normalization info354 for fig_name in self.figures.keys():355 is_normalized = self._figure_manager.get_normalization_state(fig_name)356 357 if not is_normalized:358 continue359 360 # Get XRF curves for this figure361 fig_curves = self.figures[fig_name]362 xrf_curves = fig_curves.get(DatasetType.XRF, [])363 element_names = [curve.name for curve in xrf_curves] if xrf_curves else []364 365 if not element_names or len(element_names) <= 1:366 continue367 368 # Get figure ID and add to normalizations list369 fig_id = self._figure_manager.get_figure_id(fig_name)370 normalizations.append(FigureNormalization(371 fig_id=fig_id,372 fig_name=fig_name,373 elements=element_names,374 normalize_enabled=True375 ))376 377 # Trigger single vectorized NormalizationUpdated event378 if normalizations:379 self._app.trigger(NormalizationUpdated(normalizations=normalizations))380 381 382class _CurveGroupManager:383 def __init__(self, ctrl: CombCurveController) -> None:384 self._ctrl = ctrl385 self._curve_groups = DEFAULT_CURVE_GROUPS.copy()386 self._groupnames = _NameGenerator("group", seed=2)387 self._group_name: str | None = None388 389 @property390 def group_names(self) -> list[str]:391 return list(self._curve_groups.keys())392 393 @property394 def group_name(self) -> str:395 assert self._group_name is not None396 return self._group_name397 398 @property399 def curve_group(self) -> TCurveGroup:400 return self._curve_groups[self.group_name]401 402 @property403 def is_group_name_set(self) -> bool:404 return self._group_name is not None405 406 def add_new_group(self, curve_group: TCurveGroup) -> None:407 group_name = self._groupnames.next()408 self._curve_groups[group_name] = curve_group409 self._group_name = group_name410 411 def update_group_name(self, group_name: str) -> None:412 self._group_name = group_name413 414 def get_curve_group(self, group_name: str) -> TCurveGroup:415 return self._curve_groups[group_name]416 417 418class _FigureManager:419 420 def __init__(421 self,422 ctrl: CombCurveController,423 ) -> None:424 self._ctrl = ctrl425 self._fignames = _NameGenerator("Chart")426 427 # Stable figure ID tracking428 self._figure_ids: dict[str, int] = {} # fig_name -> stable_id429 self._id_to_name: dict[int, str] = {} # stable_id -> current_name430 431 # Initialize with empty dictionaries - figures will be created on first file load432 self._empty_figures: dict[str, TCurveGroup] = {}433 self._figures: dict[str, TCurveGroup] = {}434 self._exportable_figures: dict[str, TExportCurveGroup] = {}435 self._normalization_states: dict[str, bool] = {}436 437 with param.discard_events(ctrl):438 # Set initial fig_name to None - will be set when first figure is created439 self.fig_name = None440 self.editable_fig_name = ""441 self._curve_group_converter = CurveGroupConverter(ctrl)442 443 @property444 def _loaded_curves(self) -> TLoadedCurves:445 return self._ctrl._loaded_curves446 447 @property448 def figures(self) -> dict[str, TCurveGroup]:449 return self._figures450 451 @property452 def exportable_figures(self) -> dict[str, TExportCurveGroup]:453 return self._exportable_figures454 455 @property456 def fig_name(self) -> str:457 return self._ctrl.fig_name458 459 @fig_name.setter460 def fig_name(self, value: str) -> None:461 self._ctrl.fig_name = value462 463 @property464 def fig_names(self) -> list[str]:465 return self._ctrl.param.fig_name.objects466 467 @fig_names.setter468 def fig_names(self, values: list[str]) -> None:469 self._ctrl.param.fig_name.objects = values470 471 @property472 def editable_fig_name(self) -> str:473 return self._ctrl.editable_fig_name474 475 @editable_fig_name.setter476 def editable_fig_name(self, value) -> None:477 self._ctrl.editable_fig_name = value478 479 def _sync_exportable(self, fig_name: str) -> None:480 curve_group = self._figures[fig_name]481 exportable_groups = self._curve_group_converter.convert(curve_group)482 self._exportable_figures[fig_name] = exportable_groups483 484 def _sync_fig_names(self) -> None:485 new_names = list(self._figures.keys())486 self.fig_names = new_names487 488 def set_figure(self, fig_name: str, curve_group: TCurveGroup, auto_trigger_viz: bool = True) -> None:489 if auto_trigger_viz:490 with _VizCurveUpdateContext(self._ctrl):491 self._figures[fig_name] = curve_group492 self._sync_exportable(fig_name)493 else:494 self._figures[fig_name] = curve_group495 self._sync_exportable(fig_name)496 497 def get_figure(self, fig_name: str) -> TCurveGroup:498 return self._figures[fig_name]499 500 def get_normalization_state(self, fig_name: str) -> bool:501 return self._normalization_states[fig_name]502 503 def set_normalization_state(self, fig_name: str, normalize: bool) -> None:504 self._normalization_states[fig_name] = normalize505 506 def get_figure_id(self, fig_name: str) -> int:507 """Get the stable ID for a figure by name."""508 return self._figure_ids[fig_name]509 510 def get_figure_name_by_id(self, fig_id: int) -> str:511 """Get the current name for a figure by its stable ID."""512 return self._id_to_name[fig_id]513 514 def update_editable(self) -> None:515 self.editable_fig_name = self.fig_name516 517 def save_editable_name(self) -> None:518 old_name, new_name = self.fig_name, self.editable_fig_name519 520 # Update ID mappings to reflect name change (ID stays the same)521 fig_id = self._figure_ids[old_name]522 self._figure_ids[new_name] = fig_id523 self._id_to_name[fig_id] = new_name524 del self._figure_ids[old_name]525 526 with _VizCurveUpdateContext(self._ctrl):527 fig = self._figures[old_name]528 self._figures[new_name] = fig529 del self._figures[old_name]530 531 exportable_fig = self._exportable_figures[old_name]532 self._exportable_figures[new_name] = exportable_fig533 del self._exportable_figures[old_name]534 535 norm_state = self._normalization_states[old_name]536 self._normalization_states[new_name] = norm_state537 del self._normalization_states[old_name]538 539 self._sync_fig_names()540 self.fig_name = new_name541 542 def is_editable_fig_name_new(self) -> bool:543 if self.editable_fig_name == self.fig_name:544 return False545 546 if self.editable_fig_name in self.fig_names:547 return False548 549 return True550 551 def add_new_figure(self, fig_name: str = None, curves: TCurveGroup = None, auto_trigger_viz: bool = True) -> None:552 if fig_name is None:553 # Current behavior: generate name and copy current figure554 new_fig_name = self._fignames.next()555 new_curves = {556 dataset_type: data557 for dataset_type, data in self._figures[self.fig_name].items()558 }559 else:560 # New behavior: use provided name and curves, but still call next() for ID management561 self._fignames.next() # Advance the ID counter562 new_fig_name = fig_name563 new_curves = curves if curves is not None else {}564 565 new_fig_id = self._fignames.current_id()566 567 # Update ID mappings568 self._figure_ids[new_fig_name] = new_fig_id569 self._id_to_name[new_fig_id] = new_fig_name570 571 if auto_trigger_viz:572 with _VizCurveUpdateContext(self._ctrl):573 self._figures[new_fig_name] = new_curves574 self._sync_exportable(new_fig_name)575 self._normalization_states[new_fig_name] = False # Always start new figures with normalization disabled576 else:577 self._figures[new_fig_name] = new_curves578 self._sync_exportable(new_fig_name)579 self._normalization_states[new_fig_name] = False # Always start new figures with normalization disabled580 581 self._sync_fig_names()582 if fig_name is None: # Only set as active figure if this was a user-initiated add583 self.fig_name = new_fig_name584 585 def remove_fig(self, forsaken_fig_name: str) -> None:586 remaining_figs = [fig for fig in self._figures if fig != forsaken_fig_name]587 588 if len(remaining_figs) == 0:589 self._ctrl._reset()590 return591 592 if forsaken_fig_name == self.fig_name:593 self.fig_name = remaining_figs[0]594 595 # Clean up ID mappings596 forsaken_id = self._figure_ids[forsaken_fig_name]597 del self._figure_ids[forsaken_fig_name]598 del self._id_to_name[forsaken_id]599 600 with _VizCurveUpdateContext(self._ctrl):601 del self._figures[forsaken_fig_name]602 del self._exportable_figures[forsaken_fig_name]603 del self._normalization_states[forsaken_fig_name]604 605 self.fig_names = remaining_figs606 607 608class CurveGroupConverter:609 610 def __init__(self, ctrl: CombCurveController) -> None:611 self._ctrl = ctrl612 613 @property614 def _loaded_curves(self) -> TLoadedCurves:615 return self._ctrl._loaded_curves616 617 def convert(self, curve_group: TCurveGroup) -> TExportCurveGroup:618 converted = {619 dataset_type: [620 self._loaded_curves[dataset_type][curve_name]621 for curve_name in curve_names622 if dataset_type in self._loaded_curves623 and curve_name in self._loaded_curves[dataset_type]624 ]625 for dataset_type, curve_names in curve_group.items()626 }627 return converted628 629 630@dataclass631class _SelectorMeta:632 parameter: param.Parameter633 value_getter: Callable[[], list[str] | None]634 dataset_type: DatasetType635 636 637class _SelectorManager:638 639 def __init__(640 self, ctrl: CombCurveController, selectors: list[_SelectorMeta]641 ) -> None:642 self._ctrl = ctrl643 self._selectors: dict[DatasetType, Callable[[], list[str] | None]] = {644 selector.dataset_type: selector.value_getter for selector in selectors645 }646 self._selector_params: dict[DatasetType, param.Parameter] = {647 selector.dataset_type: selector.parameter for selector in selectors648 }649 650 def sync_selector(self, dataset_type: DatasetType) -> None:651 p = self.get_param(dataset_type)652 objs = list(sorted(self._ctrl._loaded_curves[dataset_type].keys()))653 p.objects = objs654 655 def get_curve_group_params(self, curve_group: TCurveGroup) -> dict[str, list[str]]:656 """Get parameter data for a curve group without applying it."""657 return {658 parameter.name: curve_group.get(dataset_type, [])659 for dataset_type, parameter in self._selector_params.items()660 }661 662 def set_curve_group(663 self, curve_group: TCurveGroup, manual_trigger: bool = False664 ) -> None:665 new_selector_data = self.get_curve_group_params(curve_group)666 self._ctrl.param.update(**new_selector_data)667 668 if not manual_trigger:669 return670 671 for parameter_name in new_selector_data:672 self._ctrl.param.trigger(parameter_name)673 674 def get_curve_group(self) -> TCurveGroup:675 return {676 dataset_type: [677 curve_name678 for curve_name in (selector() or [])679 if curve_name in self._ctrl._loaded_curves[dataset_type]680 ]681 for dataset_type, selector in self._selectors.items()682 }683 684 def get_param(self, dataset_type: DatasetType) -> param.Parameter:685 return self._selector_params[dataset_type]686 687 688class _VizCurveUpdateContext:689 """690 Manages sending viz update events after updates to the combined691 curve view.692 693 Putting it in its own Context Manager made the code inside the controller694 easier to read.695 696 When the context is entered using697 698 ```699 with _VizCurveUpdateContext(self):700 ...701 ```702 703 then the current set of visible curves is stored. When the context is exited,704 the current set of visible curves is re-computed and the set differences are705 sent as an event to the CDS managers.706 """707 708 def __init__(self, ctrl: CombCurveController):709 self._app = ctrl._app710 self._name = ctrl.name711 self._figure_manager = ctrl._figure_manager712 self._old_curves: set[Curve] = set()713 714 def __enter__(self) -> None:715 self._old_curves = self._get_all_curves()716 717 def __exit__(self, *args, **kwargs) -> None:718 new_curves = self._get_all_curves()719 self._diff_trigger(self._old_curves, new_curves)720 721 def _diff_trigger(self, old_curves: set[Curve], new_curves: set[Curve]) -> None:722 to_add = new_curves.difference(old_curves)723 to_remove = old_curves.difference(new_curves)724 725 if len(to_add) == 0 and len(to_remove) == 0:726 return727 728 event = UpdateVizColumns(729 ctrl_name=self._name,730 to_add=list(to_add),731 to_remove=list(to_remove),732 )733 self._app.trigger(event)734 735 def _get_all_curves(self) -> set[Curve]:736 # TODO: BUG WHEN CURVE NAMES ARE EQUAL ACROSS DATASETS737 figures = self._figure_manager.exportable_figures738 return {739 curve740 for chart in figures741 for dataset_type in figures[chart]742 for curve in (figures[chart][dataset_type] or [])743 }744 745 746class _NameGenerator:747 748 def __init__(self, prefix: str, seed: int = 0) -> None:749 self._prefix = prefix750 self._i: int = seed751 752 def next(self) -> str:753 self._i += 1754 return f"{self._prefix} {self._i}"755 756 def current_id(self) -> int:757 """Get the current counter value (the ID of the most recently created item)."""758 return self._i759 