well-log-toolkit 0.1.132__py3-none-any.whl → 0.1.134__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2853,6 +2853,16 @@ class Crossplot:
2853
2853
  Regression type to apply separately for each group (well or shape). Creates
2854
2854
  separate regression lines for each well or shape category. Accepts string or dict.
2855
2855
  Default: None
2856
+ regression_by_color_and_shape : str or dict, optional
2857
+ Regression type to apply separately for each combination of color AND shape groups.
2858
+ Creates separate regression lines for each (color, shape) combination. This is useful
2859
+ for analyzing how the relationship changes across both dimensions simultaneously
2860
+ (e.g., each well in each formation, each layer in each zone). Accepts string or dict.
2861
+ Default: None
2862
+ regression_by_shape_and_color : str or dict, optional
2863
+ Alias for regression_by_color_and_shape. Provided for convenience - both parameters
2864
+ do exactly the same thing. Use whichever order feels more natural.
2865
+ Default: None
2856
2866
 
2857
2867
  Examples
2858
2868
  --------
@@ -2970,6 +2980,8 @@ class Crossplot:
2970
2980
  regression: Optional[Union[str, dict]] = None,
2971
2981
  regression_by_color: Optional[Union[str, dict]] = None,
2972
2982
  regression_by_group: Optional[Union[str, dict]] = None,
2983
+ regression_by_color_and_shape: Optional[Union[str, dict]] = None,
2984
+ regression_by_shape_and_color: Optional[Union[str, dict]] = None,
2973
2985
  ):
2974
2986
  # Store wells as list
2975
2987
  if not isinstance(wells, list):
@@ -3055,6 +3067,19 @@ class Crossplot:
3055
3067
  self.regression_by_color = regression_by_color
3056
3068
  self.regression_by_group = regression_by_group
3057
3069
 
3070
+ # Handle regression_by_shape_and_color as alias for regression_by_color_and_shape
3071
+ if regression_by_shape_and_color is not None and regression_by_color_and_shape is not None:
3072
+ warnings.warn(
3073
+ "Both regression_by_color_and_shape and regression_by_shape_and_color were specified. "
3074
+ "These are aliases for the same feature. Using regression_by_color_and_shape."
3075
+ )
3076
+ self.regression_by_color_and_shape = regression_by_color_and_shape
3077
+ elif regression_by_shape_and_color is not None:
3078
+ # Use the alias
3079
+ self.regression_by_color_and_shape = regression_by_shape_and_color
3080
+ else:
3081
+ self.regression_by_color_and_shape = regression_by_color_and_shape
3082
+
3058
3083
  # Plot objects
3059
3084
  self.fig = None
3060
3085
  self.ax = None
@@ -3076,7 +3101,11 @@ class Crossplot:
3076
3101
  # Maps property role ('shape', 'color', 'size') to labels dict {0: 'label0', 1: 'label1', ...}
3077
3102
  self._discrete_labels = {}
3078
3103
 
3079
- def add_layer(self, x: str, y: str, label: str):
3104
+ # Legend placement tracking
3105
+ # Maps segment numbers (1-9) to legend type placed there
3106
+ self._occupied_segments = {}
3107
+
3108
+ def add_layer(self, x: str, y: str, label: str) -> 'Crossplot':
3080
3109
  """
3081
3110
  Add a new data layer to the crossplot.
3082
3111
 
@@ -3419,6 +3448,121 @@ class Crossplot:
3419
3448
 
3420
3449
  return best_pos, second_best_pos
3421
3450
 
3451
+ def _find_optimal_legend_segment(
3452
+ self,
3453
+ data: pd.DataFrame,
3454
+ legend_type: str,
3455
+ is_large: bool = False
3456
+ ) -> tuple[int, str]:
3457
+ """Find optimal segment for legend placement using priority-based algorithm.
3458
+
3459
+ Segments are numbered 1-9:
3460
+ 1 2 3 (upper left, upper center, upper right)
3461
+ 4 5 6 (center left, center, center right)
3462
+ 7 8 9 (lower left, lower center, lower right)
3463
+
3464
+ Checks segments in priority order: 1,9,4,6,3,7,2,8,5
3465
+ A segment is eligible if:
3466
+ - It has <10% of datapoints
3467
+ - It doesn't have a previous legend, EXCEPT:
3468
+ - Shape and color legends can share a segment if neither is very large
3469
+
3470
+ Args:
3471
+ data: DataFrame with 'x' and 'y' columns
3472
+ legend_type: Type of legend ('shape', 'color', 'size', 'regression', etc.)
3473
+ is_large: Whether this legend is considered large (many items)
3474
+
3475
+ Returns:
3476
+ Tuple of (segment_number, matplotlib_location_string)
3477
+ """
3478
+ # Get x and y bounds
3479
+ x_vals = data['x'].values
3480
+ y_vals = data['y'].values
3481
+
3482
+ # Handle log scales for binning
3483
+ if self.x_log:
3484
+ x_vals = np.log10(x_vals[x_vals > 0])
3485
+ if self.y_log:
3486
+ y_vals = np.log10(y_vals[y_vals > 0])
3487
+
3488
+ x_min, x_max = np.nanmin(x_vals), np.nanmax(x_vals)
3489
+ y_min, y_max = np.nanmin(y_vals), np.nanmax(y_vals)
3490
+
3491
+ # Create 3x3 grid and count points in each square
3492
+ x_bins = np.linspace(x_min, x_max, 4)
3493
+ y_bins = np.linspace(y_min, y_max, 4)
3494
+
3495
+ # Map segments 1-9 to grid positions (i, j)
3496
+ # Segment numbering:
3497
+ # 1 2 3
3498
+ # 4 5 6
3499
+ # 7 8 9
3500
+ segment_to_grid = {
3501
+ 1: (0, 2), # upper left
3502
+ 2: (1, 2), # upper center
3503
+ 3: (2, 2), # upper right
3504
+ 4: (0, 1), # center left
3505
+ 5: (1, 1), # center
3506
+ 6: (2, 1), # center right
3507
+ 7: (0, 0), # lower left
3508
+ 8: (1, 0), # lower center
3509
+ 9: (2, 0), # lower right
3510
+ }
3511
+
3512
+ # Map segments to matplotlib location strings
3513
+ segment_to_location = {
3514
+ 1: 'upper left',
3515
+ 2: 'upper center',
3516
+ 3: 'upper right',
3517
+ 4: 'center left',
3518
+ 5: 'center',
3519
+ 6: 'center right',
3520
+ 7: 'lower left',
3521
+ 8: 'lower center',
3522
+ 9: 'lower right',
3523
+ }
3524
+
3525
+ # Count points in each segment
3526
+ total_points = len(x_vals)
3527
+ segment_counts = {}
3528
+ for segment, (i, j) in segment_to_grid.items():
3529
+ x_mask = (x_vals >= x_bins[i]) & (x_vals < x_bins[i+1])
3530
+ y_mask = (y_vals >= y_bins[j]) & (y_vals < y_bins[j+1])
3531
+ count = np.sum(x_mask & y_mask)
3532
+ segment_counts[segment] = count
3533
+
3534
+ # Priority order: 1,9,4,6,3,7,2,8,5
3535
+ priority_order = [1, 9, 4, 6, 3, 7, 2, 8, 5]
3536
+
3537
+ # Check each segment in priority order
3538
+ for segment in priority_order:
3539
+ # Check datapoint percentage
3540
+ if total_points > 0:
3541
+ percentage = segment_counts[segment] / total_points
3542
+ if percentage >= 0.10: # 10% threshold
3543
+ continue
3544
+
3545
+ # Check if segment is occupied
3546
+ if segment in self._occupied_segments:
3547
+ existing_type = self._occupied_segments[segment]
3548
+
3549
+ # Allow shape and color to share if neither is very large
3550
+ shareable = {'shape', 'color'}
3551
+ if (legend_type in shareable and existing_type in shareable
3552
+ and not is_large
3553
+ and not self._occupied_segments.get(f'{segment}_large', False)):
3554
+ # Can share this segment
3555
+ pass
3556
+ else:
3557
+ # Segment occupied, try next
3558
+ continue
3559
+
3560
+ # Found eligible segment
3561
+ return segment, segment_to_location[segment]
3562
+
3563
+ # If no eligible segment found, use fallback (segment 1)
3564
+ return 1, segment_to_location[1]
3565
+
3422
3566
  def _store_discrete_labels(self, prop, role: str) -> None:
3423
3567
  """
3424
3568
  Store labels from a discrete property for later use in legends.
@@ -3523,6 +3667,7 @@ class Crossplot:
3523
3667
  bbox_transform=self.fig.transFigure
3524
3668
  )
3525
3669
  shape_legend.get_title().set_fontweight('bold')
3670
+ shape_legend.set_clip_on(False) # Prevent clipping outside axes
3526
3671
  self.ax.add_artist(shape_legend)
3527
3672
 
3528
3673
  # Calculate offset for color legend below shape legend
@@ -3548,6 +3693,7 @@ class Crossplot:
3548
3693
  bbox_transform=self.fig.transFigure
3549
3694
  )
3550
3695
  color_legend.get_title().set_fontweight('bold')
3696
+ color_legend.set_clip_on(False) # Prevent clipping outside axes
3551
3697
  else:
3552
3698
  # Place side by side for non-edge locations (top, bottom, center)
3553
3699
  # Estimate width of each legend
@@ -3569,6 +3715,7 @@ class Crossplot:
3569
3715
  bbox_transform=self.fig.transFigure
3570
3716
  )
3571
3717
  shape_legend.get_title().set_fontweight('bold')
3718
+ shape_legend.set_clip_on(False) # Prevent clipping outside axes
3572
3719
  self.ax.add_artist(shape_legend)
3573
3720
 
3574
3721
  color_legend = self.ax.legend(
@@ -3582,6 +3729,7 @@ class Crossplot:
3582
3729
  bbox_transform=self.fig.transFigure
3583
3730
  )
3584
3731
  color_legend.get_title().set_fontweight('bold')
3732
+ color_legend.set_clip_on(False) # Prevent clipping outside axes
3585
3733
  else:
3586
3734
  # For other positions, fall back to stacking
3587
3735
  shape_legend = self.ax.legend(
@@ -3593,6 +3741,7 @@ class Crossplot:
3593
3741
  edgecolor='black'
3594
3742
  )
3595
3743
  shape_legend.get_title().set_fontweight('bold')
3744
+ shape_legend.set_clip_on(False) # Prevent clipping outside axes
3596
3745
  self.ax.add_artist(shape_legend)
3597
3746
 
3598
3747
  # Estimate offset
@@ -3613,6 +3762,7 @@ class Crossplot:
3613
3762
  bbox_transform=self.fig.transFigure
3614
3763
  )
3615
3764
  color_legend.get_title().set_fontweight('bold')
3765
+ color_legend.set_clip_on(False) # Prevent clipping outside axes
3616
3766
 
3617
3767
  def _format_regression_label(self, name: str, reg, include_equation: bool = None, include_r2: bool = None) -> str:
3618
3768
  """Format a modern, compact regression label.
@@ -3642,11 +3792,8 @@ class Crossplot:
3642
3792
 
3643
3793
  # Add R² on second line if requested (will be styled grey later)
3644
3794
  if include_r2:
3645
- # Format R² with appropriate note
3646
- if self.y_log:
3647
- r2_label = f"R² = {reg.r_squared:.3f} (log)"
3648
- else:
3649
- r2_label = f"R² = {reg.r_squared:.3f}"
3795
+ # Format R² (no suffix needed)
3796
+ r2_label = f"R² = {reg.r_squared:.3f}"
3650
3797
  return f"{first_line}\n{r2_label}"
3651
3798
  else:
3652
3799
  return first_line
@@ -3673,13 +3820,32 @@ class Crossplot:
3673
3820
  regression_labels.append(line.get_label())
3674
3821
 
3675
3822
  if regression_handles:
3676
- # Get smart placement based on data density
3823
+ # Get smart placement based on data density using optimized segment algorithm
3677
3824
  if self._data is not None:
3678
- _, secondary_loc = self._find_best_legend_locations(self._data)
3825
+ # Determine if regression legend is large
3826
+ regression_is_large = len(regression_handles) > 5
3827
+ segment, secondary_loc = self._find_optimal_legend_segment(
3828
+ self._data,
3829
+ legend_type='regression',
3830
+ is_large=regression_is_large
3831
+ )
3832
+ # Mark segment as occupied
3833
+ self._occupied_segments[segment] = 'regression'
3834
+ self._occupied_segments[f'{segment}_large'] = regression_is_large
3679
3835
  else:
3680
3836
  # Fallback if data not available
3681
3837
  secondary_loc = 'lower right'
3682
3838
 
3839
+ # Determine descriptive title based on regression type
3840
+ if self.regression_by_color_and_shape:
3841
+ regression_title = 'Regressions by color and shape'
3842
+ elif self.regression_by_color:
3843
+ regression_title = 'Regressions by color'
3844
+ elif self.regression_by_group:
3845
+ regression_title = 'Regressions by group'
3846
+ else:
3847
+ regression_title = 'Regressions'
3848
+
3683
3849
  # Import legend from matplotlib
3684
3850
  from matplotlib.legend import Legend
3685
3851
 
@@ -3695,7 +3861,7 @@ class Crossplot:
3695
3861
  fancybox=False,
3696
3862
  shadow=False,
3697
3863
  fontsize=9,
3698
- title='Regressions',
3864
+ title=regression_title,
3699
3865
  title_fontsize=10
3700
3866
  )
3701
3867
 
@@ -3712,7 +3878,7 @@ class Crossplot:
3712
3878
 
3713
3879
  def _add_automatic_regressions(self, data: pd.DataFrame) -> None:
3714
3880
  """Add automatic regressions based on initialization parameters."""
3715
- if not any([self.regression, self.regression_by_color, self.regression_by_group]):
3881
+ if not any([self.regression, self.regression_by_color, self.regression_by_group, self.regression_by_color_and_shape]):
3716
3882
  return
3717
3883
 
3718
3884
  total_points = len(data)
@@ -3749,20 +3915,16 @@ class Crossplot:
3749
3915
 
3750
3916
  # Determine grouping column based on what's being used for colors in the plot
3751
3917
  group_column = None
3752
- group_label = None
3753
3918
 
3754
3919
  if self.color and 'color_val' in data.columns:
3755
3920
  # User specified explicit color mapping
3756
3921
  group_column = 'color_val'
3757
- group_label = self.color
3758
3922
  elif self.shape == "well" and 'well' in data.columns:
3759
3923
  # When shape="well", each well gets a different color in the plot
3760
3924
  group_column = 'well'
3761
- group_label = 'well'
3762
3925
  elif self.shape and self.shape != "well" and 'shape_val' in data.columns:
3763
3926
  # When shape is a property, each shape group gets a different color
3764
3927
  group_column = 'shape_val'
3765
- group_label = self.shape
3766
3928
 
3767
3929
  if group_column is None:
3768
3930
  warnings.warn(
@@ -3771,12 +3933,57 @@ class Crossplot:
3771
3933
  )
3772
3934
  else:
3773
3935
  # Check if color is categorical (not continuous like depth)
3774
- if group_column == 'color_val' and (self.color == 'depth' or pd.api.types.is_numeric_dtype(data[group_column])):
3775
- # For continuous values, we can't create separate regressions
3776
- warnings.warn(
3777
- f"regression_by_color requires categorical color mapping, "
3778
- f"but '{self.color}' is continuous. Use regression_by_group instead."
3779
- )
3936
+ # Use _is_categorical_color() to properly handle discrete properties
3937
+ if group_column == 'color_val':
3938
+ is_categorical = self._is_categorical_color(data[group_column].values)
3939
+ if not is_categorical:
3940
+ # For continuous values, we can't create separate regressions
3941
+ warnings.warn(
3942
+ f"regression_by_color requires categorical color mapping, "
3943
+ f"but '{self.color}' is continuous. Use regression_by_group instead."
3944
+ )
3945
+ # Skip this section
3946
+ else:
3947
+ # Categorical values - group and create regressions
3948
+ color_groups = data.groupby(group_column)
3949
+ n_groups = len(color_groups)
3950
+
3951
+ # Validate regression count
3952
+ if regression_count + n_groups > total_points / 2:
3953
+ raise ValueError(
3954
+ f"Too many regression lines requested: {regression_count + n_groups} lines "
3955
+ f"for {total_points} data points (average < 2 points per line). "
3956
+ f"Reduce the number of groups or use a different regression strategy."
3957
+ )
3958
+
3959
+ # Get the actual colors used for each group in the plot
3960
+ group_colors_map = self._get_group_colors(data, group_column)
3961
+
3962
+ for idx, (group_name, group_data) in enumerate(color_groups):
3963
+ x_vals = group_data['x'].values
3964
+ y_vals = group_data['y'].values
3965
+ mask = np.isfinite(x_vals) & np.isfinite(y_vals)
3966
+ if np.sum(mask) >= 2:
3967
+ # Copy config and use the same color as the data group
3968
+ group_config = config.copy()
3969
+ if 'line_color' not in group_config:
3970
+ # Use the same color as the data points for this group
3971
+ group_config['line_color'] = group_colors_map.get(group_name, regression_colors[color_idx % len(regression_colors)])
3972
+
3973
+ # Get display label for group name (converts codes to formation names)
3974
+ group_display = self._get_display_label(group_name, 'color')
3975
+
3976
+ # Skip legend update for all but last regression
3977
+ is_last = (idx == n_groups - 1)
3978
+ self._add_group_regression(
3979
+ x_vals[mask], y_vals[mask],
3980
+ reg_type,
3981
+ name=group_display,
3982
+ config=group_config,
3983
+ update_legend=is_last
3984
+ )
3985
+ regression_count += 1
3986
+ color_idx += 1
3780
3987
  else:
3781
3988
  # Categorical values - group and create regressions
3782
3989
  color_groups = data.groupby(group_column)
@@ -3804,12 +4011,15 @@ class Crossplot:
3804
4011
  # Use the same color as the data points for this group
3805
4012
  group_config['line_color'] = group_colors_map.get(group_name, regression_colors[color_idx % len(regression_colors)])
3806
4013
 
4014
+ # Get display label for group name (converts codes to formation names)
4015
+ group_display = self._get_display_label(group_name, 'color')
4016
+
3807
4017
  # Skip legend update for all but last regression
3808
4018
  is_last = (idx == n_groups - 1)
3809
4019
  self._add_group_regression(
3810
4020
  x_vals[mask], y_vals[mask],
3811
4021
  reg_type,
3812
- name=f"{group_label}={group_name}",
4022
+ name=group_display,
3813
4023
  config=group_config,
3814
4024
  update_legend=is_last
3815
4025
  )
@@ -3866,6 +4076,95 @@ class Crossplot:
3866
4076
  "Use shape='well' or set shape to a property name."
3867
4077
  )
3868
4078
 
4079
+ # Add regression by color AND shape combinations
4080
+ if self.regression_by_color_and_shape:
4081
+ config = self._parse_regression_config(self.regression_by_color_and_shape)
4082
+ reg_type = config['type']
4083
+
4084
+ # Determine color and shape columns
4085
+ color_col = None
4086
+ shape_col = None
4087
+ color_label = None
4088
+ shape_label = None
4089
+
4090
+ # Identify color column
4091
+ if self.color and 'color_val' in data.columns:
4092
+ # Check if categorical
4093
+ if self._is_categorical_color(data['color_val'].values):
4094
+ color_col = 'color_val'
4095
+ color_label = self.color
4096
+ elif self.shape == "well" and 'well' in data.columns:
4097
+ # When shape="well", wells provide colors
4098
+ color_col = 'well'
4099
+ color_label = 'well'
4100
+
4101
+ # Identify shape column
4102
+ if self.shape == "well" and 'well' in data.columns:
4103
+ shape_col = 'well'
4104
+ shape_label = 'well'
4105
+ elif self.shape and self.shape != "well" and 'shape_val' in data.columns:
4106
+ shape_col = 'shape_val'
4107
+ shape_label = self.shape
4108
+
4109
+ # Need both color and shape columns for this to work
4110
+ if color_col is None or shape_col is None:
4111
+ warnings.warn(
4112
+ "regression_by_color_and_shape requires both categorical color mapping AND shape/well grouping. "
4113
+ "Set both color and shape parameters, or use regression_by_color or regression_by_group instead."
4114
+ )
4115
+ elif color_col == shape_col:
4116
+ warnings.warn(
4117
+ "regression_by_color_and_shape requires DIFFERENT color and shape mappings. "
4118
+ "Currently both are mapped to the same property. Use regression_by_color or regression_by_group instead."
4119
+ )
4120
+ else:
4121
+ # Group by both color and shape
4122
+ combined_groups = data.groupby([color_col, shape_col])
4123
+ n_groups = len(combined_groups)
4124
+
4125
+ # Validate regression count
4126
+ if regression_count + n_groups > total_points / 2:
4127
+ raise ValueError(
4128
+ f"Too many regression lines requested: {regression_count + n_groups} lines "
4129
+ f"for {total_points} data points (average < 2 points per line). "
4130
+ f"Reduce the number of groups or use a simpler regression strategy."
4131
+ )
4132
+
4133
+ # Get color maps for both dimensions
4134
+ color_colors_map = self._get_group_colors(data, color_col)
4135
+ shape_colors_map = self._get_group_colors(data, shape_col)
4136
+
4137
+ for idx, ((color_val, shape_val), group_data) in enumerate(combined_groups):
4138
+ x_vals = group_data['x'].values
4139
+ y_vals = group_data['y'].values
4140
+ mask = np.isfinite(x_vals) & np.isfinite(y_vals)
4141
+ if np.sum(mask) >= 2:
4142
+ # Copy config and use appropriate color
4143
+ group_config = config.copy()
4144
+ if 'line_color' not in group_config:
4145
+ # Prefer color from color dimension, fallback to shape dimension
4146
+ group_config['line_color'] = color_colors_map.get(
4147
+ color_val,
4148
+ shape_colors_map.get(shape_val, regression_colors[color_idx % len(regression_colors)])
4149
+ )
4150
+
4151
+ # Create descriptive name with both dimensions
4152
+ color_display = self._get_display_label(color_val, 'color')
4153
+ shape_display = self._get_display_label(shape_val, 'shape')
4154
+ name = f"{color_label}={color_display}, {shape_label}={shape_display}"
4155
+
4156
+ # Skip legend update for all but last regression
4157
+ is_last = (idx == n_groups - 1)
4158
+ self._add_group_regression(
4159
+ x_vals[mask], y_vals[mask],
4160
+ reg_type,
4161
+ name=name,
4162
+ config=group_config,
4163
+ update_legend=is_last
4164
+ )
4165
+ regression_count += 1
4166
+ color_idx += 1
4167
+
3869
4168
  def _add_group_regression(
3870
4169
  self,
3871
4170
  x_vals: np.ndarray,
@@ -3961,6 +4260,9 @@ class Crossplot:
3961
4260
 
3962
4261
  def plot(self) -> 'Crossplot':
3963
4262
  """Generate the crossplot figure."""
4263
+ # Reset legend placement tracking for new plot
4264
+ self._occupied_segments = {}
4265
+
3964
4266
  # Prepare data
3965
4267
  data = self._prepare_data()
3966
4268
 
@@ -3982,6 +4284,14 @@ class Crossplot:
3982
4284
  if self.y_log:
3983
4285
  self.ax.set_yscale('log')
3984
4286
 
4287
+ # Disable scientific notation on axes
4288
+ from matplotlib.ticker import ScalarFormatter
4289
+ formatter = ScalarFormatter(useOffset=False)
4290
+ formatter.set_scientific(False)
4291
+ self.ax.yaxis.set_major_formatter(formatter)
4292
+ if not self.x_log:
4293
+ self.ax.xaxis.set_major_formatter(formatter)
4294
+
3985
4295
  # Labels and title
3986
4296
  self.ax.set_xlabel(self.xlabel, fontsize=12, fontweight='bold')
3987
4297
  self.ax.set_ylabel(self.ylabel, fontsize=12, fontweight='bold')
@@ -4138,14 +4448,31 @@ class Crossplot:
4138
4448
  label=self._get_display_label(cat, 'color'))
4139
4449
  for cat in unique_categories]
4140
4450
 
4451
+ # Determine if legend is large
4452
+ color_is_large = len(legend_elements) > 5
4453
+
4454
+ # Find optimal segment for color legend
4455
+ if self._data is not None:
4456
+ segment, location = self._find_optimal_legend_segment(
4457
+ self._data,
4458
+ legend_type='color',
4459
+ is_large=color_is_large
4460
+ )
4461
+ # Mark segment as occupied
4462
+ self._occupied_segments[segment] = 'color'
4463
+ self._occupied_segments[f'{segment}_large'] = color_is_large
4464
+ else:
4465
+ location = 'best'
4466
+
4141
4467
  colorbar_label = self.color if self.color != "depth" and self.color != "label" else "Category"
4142
4468
  legend = self.ax.legend(handles=legend_elements,
4143
4469
  title=colorbar_label,
4144
- loc='best',
4470
+ loc=location,
4145
4471
  frameon=True,
4146
4472
  framealpha=0.9,
4147
4473
  edgecolor='black')
4148
4474
  legend.get_title().set_fontweight('bold')
4475
+ legend.set_clip_on(False) # Prevent clipping outside axes
4149
4476
  self.ax.add_artist(legend)
4150
4477
  elif not is_categorical and self.show_colorbar:
4151
4478
  # Add colorbar for continuous colors
@@ -4256,13 +4583,7 @@ class Crossplot:
4256
4583
  need_color_legend = self.color and is_categorical and self.show_legend
4257
4584
 
4258
4585
  if need_shape_legend and need_color_legend:
4259
- # Create grouped legends in the same region
4260
- # Get best location based on data density
4261
- if self._data is not None:
4262
- primary_loc, _ = self._find_best_legend_locations(self._data)
4263
- else:
4264
- primary_loc = 'best'
4265
-
4586
+ # Create grouped legends in the same region using optimized placement
4266
4587
  # Prepare shape legend handles (from scatter plots)
4267
4588
  shape_handles, _ = self.ax.get_legend_handles_labels()
4268
4589
 
@@ -4274,6 +4595,23 @@ class Crossplot:
4274
4595
  label=self._get_display_label(cat, 'color'))
4275
4596
  for cat in unique_categories]
4276
4597
 
4598
+ # Determine if legends are large (more than 5 items)
4599
+ shape_is_large = len(shape_handles) > 5
4600
+ color_is_large = len(color_handles) > 5
4601
+
4602
+ # Find optimal segment for the grouped legends
4603
+ if self._data is not None:
4604
+ segment, location = self._find_optimal_legend_segment(
4605
+ self._data,
4606
+ legend_type='shape',
4607
+ is_large=shape_is_large or color_is_large
4608
+ )
4609
+ # Mark segment as occupied by both shape and color
4610
+ self._occupied_segments[segment] = 'shape'
4611
+ self._occupied_segments[f'{segment}_large'] = shape_is_large or color_is_large
4612
+ else:
4613
+ location = 'best'
4614
+
4277
4615
  colorbar_label = self.color if self.color != "depth" and self.color != "label" else "Category"
4278
4616
 
4279
4617
  # Create grouped legends
@@ -4282,19 +4620,30 @@ class Crossplot:
4282
4620
  shape_title=group_label,
4283
4621
  color_handles=color_handles,
4284
4622
  color_title=colorbar_label,
4285
- location=primary_loc
4623
+ location=location
4286
4624
  )
4287
4625
 
4288
4626
  elif need_shape_legend:
4289
4627
  # Only shape legend needed
4628
+ shape_handles, _ = self.ax.get_legend_handles_labels()
4629
+ shape_is_large = len(shape_handles) > 5
4630
+
4631
+ # Find optimal segment
4290
4632
  if self._data is not None:
4291
- primary_loc, _ = self._find_best_legend_locations(self._data)
4633
+ segment, location = self._find_optimal_legend_segment(
4634
+ self._data,
4635
+ legend_type='shape',
4636
+ is_large=shape_is_large
4637
+ )
4638
+ # Mark segment as occupied
4639
+ self._occupied_segments[segment] = 'shape'
4640
+ self._occupied_segments[f'{segment}_large'] = shape_is_large
4292
4641
  else:
4293
- primary_loc = 'best'
4642
+ location = 'best'
4294
4643
 
4295
4644
  legend = self.ax.legend(
4296
4645
  title=group_label,
4297
- loc=primary_loc,
4646
+ loc=location,
4298
4647
  frameon=True,
4299
4648
  framealpha=0.9,
4300
4649
  edgecolor='black'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.132
3
+ Version: 0.1.134
4
4
  Summary: Fast LAS file processing with lazy loading and filtering for well log analysis
5
5
  Author-email: Kristian dF Kollsgård <kkollsg@gmail.com>
6
6
  License: MIT
@@ -7,9 +7,9 @@ well_log_toolkit/property.py,sha256=B-3mXNJmvIqjjMdsu1kgVSwMgEwbJ36wn_n_oppdJFw,
7
7
  well_log_toolkit/regression.py,sha256=7D3oI-1XVlFb-mOoHTxTTtUHERFyvQSBAzJzAGVoZnk,25192
8
8
  well_log_toolkit/statistics.py,sha256=_huPMbv2H3o9ezunjEM94mJknX5wPK8V4nDv2lIZZRw,16814
9
9
  well_log_toolkit/utils.py,sha256=O2KPq4htIoUlL74V2zKftdqqTjRfezU9M-568zPLme0,6866
10
- well_log_toolkit/visualization.py,sha256=204l_LFgcSfnpHti1Xc1iGF-KUVEyxkJNS88gkUDeXA,179959
10
+ well_log_toolkit/visualization.py,sha256=xpui0fOv6JRaQTIK8o7QC8FNaJbjzdNCwnJ2KyOi4lk,196670
11
11
  well_log_toolkit/well.py,sha256=Aav5Y-rui8YsJdvk7BFndNPUu1O9mcjwDApAGyqV9kw,104535
12
- well_log_toolkit-0.1.132.dist-info/METADATA,sha256=5IVBXhf2t9MQW4Gswct9F1IHl5JJnKOWDZxK4g2vUq4,59810
13
- well_log_toolkit-0.1.132.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- well_log_toolkit-0.1.132.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
- well_log_toolkit-0.1.132.dist-info/RECORD,,
12
+ well_log_toolkit-0.1.134.dist-info/METADATA,sha256=afUQvTYy5a4E2vvkdKjjrrWsvkC491apUHSUNQNw1jE,59810
13
+ well_log_toolkit-0.1.134.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ well_log_toolkit-0.1.134.dist-info/top_level.txt,sha256=BMOo7OKLcZEnjo0wOLMclwzwTbYKYh31I8RGDOGSBdE,17
15
+ well_log_toolkit-0.1.134.dist-info/RECORD,,