11from __future__ import annotations
22
33import html
4+ import math
45from collections .abc import Callable , Mapping , Sequence
56from dataclasses import dataclass , field
67from enum import StrEnum
@@ -708,8 +709,66 @@ def _facet_counts(self, col: str, dataframes: list[pl.DataFrame]) -> dict[str, i
708709 return counts
709710
710711
712+ class DisplayPoints :
713+ def __init__ (self ) -> None :
714+ self ._use_utm : bool = False
715+ self ._df : pl .DataFrame = pl .DataFrame (schema = self .schema ())
716+
717+ @staticmethod
718+ def schema () -> dict [str , Any ]:
719+ return {
720+ "well_connection_cell" : pl .Array (pl .Int64 , 3 ),
721+ "east" : pl .Float32 ,
722+ "north" : pl .Float32 ,
723+ "tvd" : pl .Float32 ,
724+ "status" : pl .String ,
725+ }
726+
727+ def update_display_points (
728+ self , display_points : pl .DataFrame , use_utm : bool
729+ ) -> None :
730+ self ._use_utm = use_utm
731+ self ._df = display_points .select (self .schema ().keys ())
732+
733+ def get_point (self , index : int | None ) -> dict [str , Any ] | None :
734+ if index is not None and 0 <= index < len (self ._df ):
735+ return self ._df .row (index , named = True )
736+ return None
737+
738+ def get_point_coordinates (
739+ self , index : int | None
740+ ) -> tuple [float , float , float ] | None :
741+ point = self .get_point (index )
742+ if point is None :
743+ return None
744+ if self ._use_utm :
745+ return (
746+ point ["east" ],
747+ point ["north" ],
748+ point ["tvd" ],
749+ )
750+ return tuple (point ["well_connection_cell" ])
751+
752+ def get_points_to_plot (self ) -> tuple [list [float ], list [float ], list [float ]]:
753+ if self ._use_utm :
754+ return (
755+ self ._df ["east" ].to_list (),
756+ self ._df ["north" ].to_list (),
757+ self ._df ["tvd" ].to_list (),
758+ )
759+ return (
760+ self ._df ["well_connection_cell" ].arr .get (0 ).to_list (),
761+ self ._df ["well_connection_cell" ].arr .get (1 ).to_list (),
762+ self ._df ["well_connection_cell" ].arr .get (2 ).to_list (),
763+ )
764+
765+
711766class RftPlot :
712- def __init__ (self , show_details : Callable [[dict [str , Any ]], None ]) -> None :
767+ def __init__ (
768+ self ,
769+ show_details : Callable [[dict [str , Any ]], None ],
770+ clear_details : Callable [[], None ],
771+ ) -> None :
713772 figure = Figure ()
714773 self ._canvas : FigureCanvas = FigureCanvas (figure )
715774 self ._canvas .setSizePolicy (
@@ -723,16 +782,14 @@ def __init__(self, show_details: Callable[[dict[str, Any]], None]) -> None:
723782 self ._autoscaled_limits : tuple [Any , Any , Any ] | None = None
724783
725784 self ._point_artist : PathCollection | None = None
726- self ._point_coords : list [tuple [float , float , float ]] = []
727- self ._display_points : pl .DataFrame = pl .DataFrame (
728- schema = self ._display_point_schema ()
729- )
785+ self ._display_points : DisplayPoints = DisplayPoints ()
730786 self ._selection_artist : Any = None
731787 self ._hover_artist : Any = None
732788 self ._selected_index : int | None = None
733789 self ._hover_index : int | None = None
734790
735791 self ._show_details = show_details
792+ self ._clear_details = clear_details
736793
737794 @property
738795 def canvas (self ) -> FigureCanvas :
@@ -759,20 +816,21 @@ def redraw(
759816 self ._ax .get_ylim (),
760817 self ._ax .get_zlim (),
761818 )
819+ previous_selected_point = self ._display_points .get_point (self ._selected_index )
762820 self ._ax .cla ()
763821 self ._ax .invert_zaxis ()
764822 self ._point_artist = None
765823 self ._selection_artist = None
766824 self ._hover_artist = None
767- self ._point_coords = []
768825 self ._selected_index = None
826+ self ._clear_details ()
769827 self ._hover_index = None
770828
771829 if obs_df .is_empty () and response_df .is_empty () and file_rft_df .is_empty ():
772830 self ._canvas .draw ()
773831 return
774832
775- point_columns = self ._display_point_schema ().keys ()
833+ point_columns = self ._display_points . schema ().keys ()
776834
777835 points = pl .concat (
778836 [
@@ -876,11 +934,6 @@ def _get_observation_cell_center_overlay(
876934 )
877935 self ._ax .add_collection3d (lc )
878936
879- xs , ys , zs = (
880- points ["east" ].to_list (),
881- points ["north" ].to_list (),
882- points ["tvd" ].to_list (),
883- )
884937 else :
885938 self ._ax .set_xlabel ("i" , labelpad = 6 )
886939 self ._ax .set_ylabel ("j" , labelpad = 6 )
@@ -897,11 +950,8 @@ def _get_observation_cell_center_overlay(
897950 _point_style (statuses ), statuses , coords
898951 )
899952
900- xs , ys , zs = (
901- points ["well_connection_cell" ].arr .get (0 ).to_list (),
902- points ["well_connection_cell" ].arr .get (1 ).to_list (),
903- points ["well_connection_cell" ].arr .get (2 ).to_list (),
904- )
953+ self ._display_points .update_display_points (points , use_utm )
954+ xs , ys , zs = self ._display_points .get_points_to_plot ()
905955 self ._point_artist = self ._ax .scatter (
906956 xs ,
907957 ys ,
@@ -910,10 +960,8 @@ def _get_observation_cell_center_overlay(
910960 picker = 5 ,
911961 depthshade = False ,
912962 )
913- self ._point_coords = list (zip (xs , ys , zs , strict = True ))
914- self ._display_points = points
915963
916- displayed_statuses = self . _display_points ["status" ].unique ().to_list ()
964+ displayed_statuses = points ["status" ].unique ().to_list ()
917965 for status , style in _POINT_STYLE .items ():
918966 if status in displayed_statuses :
919967 self ._ax .scatter (
@@ -926,6 +974,39 @@ def _get_observation_cell_center_overlay(
926974
927975 self ._ax .legend (loc = "upper left" , fontsize = "x-small" )
928976 self ._create_overlay_artists ()
977+
978+ if previous_selected_point is not None :
979+ # Try to restore the previously selected point
980+
981+ fallback_index = None
982+ fallback_point = None
983+ for i , point in enumerate (points .rows (named = True )):
984+ if (
985+ point ["well_connection_cell" ]
986+ == previous_selected_point ["well_connection_cell" ]
987+ ):
988+ fallback_index = i
989+ fallback_point = point
990+ if (
991+ math .isclose (
992+ point ["east" ], previous_selected_point ["east" ], rel_tol = 1e-5
993+ )
994+ and math .isclose (
995+ point ["north" ],
996+ previous_selected_point ["north" ],
997+ rel_tol = 1e-5 ,
998+ )
999+ and math .isclose (
1000+ point ["tvd" ], previous_selected_point ["tvd" ], rel_tol = 1e-5
1001+ )
1002+ ):
1003+ self ._update_selected_point (i , point )
1004+ break
1005+ # If we didn't find an exact match, but did find a point with the same cell
1006+ # center, use that as a fallback.
1007+ if fallback_index is not None and fallback_point is not None :
1008+ self ._update_selected_point (fallback_index , fallback_point )
1009+
9291010 self ._canvas .draw ()
9301011
9311012 # Store the autoscale limits after the redraw, so we can restore them later if
@@ -943,16 +1024,6 @@ def _get_observation_cell_center_overlay(
9431024 self ._ax .set_zlim (* zlim )
9441025 self ._canvas .draw_idle ()
9451026
946- @staticmethod
947- def _display_point_schema () -> dict [str , Any ]:
948- return {
949- "well_connection_cell" : pl .Array (pl .Int64 , 3 ),
950- "east" : pl .Float32 ,
951- "north" : pl .Float32 ,
952- "tvd" : pl .Float32 ,
953- "status" : pl .String ,
954- }
955-
9561027 def _fit_view_to_displayed_points (self ) -> None :
9571028 if self ._autoscaled_limits is None :
9581029 return
@@ -963,11 +1034,12 @@ def _fit_view_to_displayed_points(self) -> None:
9631034 self ._canvas .draw_idle ()
9641035
9651036 def _center_on_selected (self ) -> None :
966- if self ._selected_index is None or not (
967- 0 <= self ._selected_index < len (self ._point_coords )
968- ):
1037+ if self ._selected_index is None :
1038+ return
1039+ coordinates = self ._display_points .get_point_coordinates (self ._selected_index )
1040+ if coordinates is None :
9691041 return
970- cx , cy , cz = self . _point_coords [ self . _selected_index ]
1042+ cx , cy , cz = coordinates
9711043 for getter , setter , center in (
9721044 (self ._ax .get_xlim , self ._ax .set_xlim , cx ),
9731045 (self ._ax .get_ylim , self ._ax .set_ylim , cy ),
@@ -1011,9 +1083,10 @@ def _refresh_overlays(self) -> None:
10111083 def _coords_for (
10121084 idx : int | None ,
10131085 ) -> tuple [list [float ], list [float ], list [float ]]:
1014- if idx is None or not (0 <= idx < len (self ._point_coords )):
1086+ coordinates = self ._display_points .get_point_coordinates (idx )
1087+ if coordinates is None :
10151088 return ([], [], [])
1016- x , y , z = self . _point_coords [ idx ]
1089+ x , y , z = coordinates
10171090 return ([x ], [y ], [z ])
10181091
10191092 hover_idx = (
@@ -1033,11 +1106,15 @@ def _on_pick(self, event: PickEvent) -> None:
10331106 if not hasattr (event , "ind" ) or len (event .ind ) == 0 :
10341107 return
10351108 idx = int (event .ind [0 ])
1036- if not (0 <= idx < len (self ._point_coords )):
1037- return
1038- self ._selected_index = idx
1039- if 0 <= idx < len (self ._display_points ):
1040- self ._show_details (self ._display_points .row (idx , named = True ))
1109+ selected_point = self ._display_points .get_point (idx )
1110+ if selected_point is not None :
1111+ self ._update_selected_point (idx , selected_point )
1112+
1113+ def _update_selected_point (
1114+ self , index : int , selected_point : dict [str , Any ]
1115+ ) -> None :
1116+ self ._selected_index = index
1117+ self ._show_details (selected_point )
10411118 self ._refresh_overlays ()
10421119
10431120 def _on_hover (self , event : MouseEvent ) -> None :
@@ -1077,12 +1154,9 @@ def _on_scroll(self, event: MouseEvent) -> None:
10771154 if event .inaxes is not self ._ax :
10781155 return
10791156 scale = 0.8 if event .button == "up" else 1.25
1080- if self ._selected_index is not None and 0 <= self ._selected_index < len (
1081- self ._point_coords
1082- ):
1083- cx , cy , cz = self ._point_coords [self ._selected_index ]
1084- else :
1085- cx , cy , cz = None , None , None
1157+ coordinates = self ._display_points .get_point_coordinates (self ._selected_index )
1158+ (cx , cy , cz ) = (None , None , None ) if coordinates is None else coordinates
1159+
10861160 # Assert to reassure mypy that self._ax is indeed an Axes3D instance:
10871161 assert isinstance (self ._ax , Axes3D )
10881162 for getter , setter , center in (
@@ -1141,7 +1215,7 @@ def __init__(self, ert_config: ErtConfig | None = None) -> None:
11411215 self ._load_rft_file : bool = False
11421216
11431217 self ._use_utm = False
1144- self ._plot = RftPlot (self ._show_details )
1218+ self ._plot = RftPlot (self ._show_details , self . _clear_details )
11451219 self ._filter_panel = FilterPanel (
11461220 self ._apply_filter_and_redraw ,
11471221 self ._plot ._fit_view_to_displayed_points ,
@@ -1697,3 +1771,6 @@ def _obs_coords(row: dict[str, Any]) -> str | None:
16971771 { file_point_details_html }
16981772 </table>
16991773 """ )
1774+
1775+ def _clear_details (self ) -> None :
1776+ self ._details .clear ()
0 commit comments