@@ -753,6 +753,151 @@ def text(self, x, y, s, fontdict=None, **kwargs):
753
753
self ._add_text (t )
754
754
return t
755
755
756
+ def label_lines (self , * args , ** kwargs ):
757
+ handles , labels , extra_args , kwargs = mlegend ._parse_legend_args (
758
+ [self ],
759
+ * args ,
760
+ ** kwargs )
761
+ if len (extra_args ):
762
+ raise TypeError (
763
+ 'label_lines only accepts two nonkeyword arguments' )
764
+
765
+ self ._line_labels = []
766
+
767
+ # TODO(omarchehab98): Should we avoid using a Line2D method from inside
768
+ # the Axes class?
769
+ # If it's okay, we should check if handle is an
770
+ # instance of Line2D.
771
+ def get_last_data (handle ):
772
+ last_x = handle .get_xdata ()[- 1 ]
773
+ last_y = handle .get_ydata ()[- 1 ]
774
+ return last_x , last_y
775
+ # Get last data point for each handle
776
+ xys = list (map (get_last_data , handles ))
777
+
778
+ # Find the largest last x value
779
+ data_maxx = max (map (lambda point : point [0 ], xys ))
780
+ # TODO(omarchehab98): We do eventually sort `xys` by `y`.
781
+ # If we reorder things we can do these two
782
+ # computations in constant time.
783
+ # Rather than linear time.
784
+ # Find the smallest last y value
785
+ data_miny = min (map (lambda point : point [1 ], xys ))
786
+ # Find the largest last y value
787
+ data_maxy = max (map (lambda point : point [1 ], xys ))
788
+
789
+ # Get the figure width and height in pixels
790
+ fig_dpi_transform = self .figure .dpi_scale_trans .inverted ()
791
+ fig_bbox = self .get_window_extent ().transformed (fig_dpi_transform )
792
+ fig_width_px = fig_bbox .width * self .figure .dpi
793
+ fig_height_px = fig_bbox .height * self .figure .dpi
794
+
795
+ # Get the figure width and height in points
796
+ fig_minx , fig_maxx = self .get_xbound ()
797
+ fig_miny , fig_maxy = self .get_ybound ()
798
+ fig_width_pt = abs (fig_maxx - fig_minx )
799
+ fig_height_pt = abs (fig_maxy - fig_miny )
800
+
801
+ # Space to the left of the text converted from pixels
802
+ margin_left = 8 * (fig_width_pt / fig_width_px )
803
+ # Space to the top and bottom of the text converted from pixels
804
+ margin_vertical = 2 * (fig_height_pt / fig_height_px )
805
+
806
+ text_fontsize = 10
807
+ # Height of the text converted from pixels
808
+ text_height = text_fontsize * (fig_height_pt / fig_height_px )
809
+
810
+ # Height of each bucket
811
+ bucket_height = text_height + 2 * margin_vertical
812
+ # Total number of buckets
813
+ buckets_total = 2 * int ((fig_maxy - fig_miny - text_height ) /
814
+ bucket_height )
815
+ # Bit array to track which buckets are used
816
+ buckets_map = 0
817
+
818
+ def get_bucket_index (y ):
819
+ return int ((y - fig_miny ) / bucket_height )
820
+
821
+ # How many text SHOULD be in this bucket
822
+ bucket_densities = [0 ] * buckets_total
823
+ for xy in xys :
824
+ data_x , data_y = xy
825
+ bucket = get_bucket_index (data_y )
826
+ bucket_densities [bucket ] += 1
827
+
828
+ def by_y (args ):
829
+ xy = args [2 ]
830
+ x , y = xy
831
+ return y
832
+
833
+ bucket_offset = None
834
+ prev_ideal_bucket = - 1
835
+ # Iterate over all the handles, labels, and xy data sorted by y asc
836
+ for handle , label , xy in sorted (zip (handles , labels , xys ), key = by_y ):
837
+ data_x , data_y = xy
838
+
839
+ ideal_bucket = get_bucket_index (data_y )
840
+ if ideal_bucket != prev_ideal_bucket :
841
+ # If a bucket is dense, then we want to center the text around
842
+ # the bucket. SSo, find out how many empty buckets there are
843
+ # below the current bucket
844
+ bucket_density = bucket_densities [ideal_bucket ]
845
+ ideal_offset = bucket_density // 2
846
+ empty_buckets_below = 0
847
+ for i in range (ideal_bucket + 1 ,
848
+ ideal_bucket - ideal_offset + 1 ,
849
+ - 1 ):
850
+ # If reached bottom or bucket is not empty
851
+ if i == 0 or buckets_map & (1 << i ) == 1 :
852
+ break
853
+ empty_buckets_below += 1
854
+ bucket_offset = - min (ideal_offset , empty_buckets_below )
855
+ prev_ideal_bucket = ideal_bucket
856
+
857
+ bucket = ideal_bucket + bucket_offset
858
+ text_x , text_y = None , None
859
+
860
+ # Find the nearest empty bucket
861
+ for index in range (buckets_total ):
862
+ # 0 <= bucket_index <= buckets_total
863
+ bucket_index = max (0 , min (bucket + index , buckets_total ))
864
+ bucket_mask = 1 << bucket_index
865
+ # If this bucket is empty
866
+ if buckets_map & bucket_mask == 0 :
867
+ # Mark this bucket as used
868
+ buckets_map |= bucket_mask
869
+ text_x = data_maxx + margin_left
870
+ text_y = bucket_index * bucket_height + fig_miny
871
+ break
872
+
873
+ # If a bucket was found for the text
874
+ if text_x is not None and text_y is not None :
875
+ text_color = handle .get_color ()
876
+ line_label = self .annotate (label , (data_maxx , data_y ),
877
+ xytext = (text_x , text_y ),
878
+ fontsize = text_fontsize ,
879
+ color = text_color ,
880
+ annotation_clip = True )
881
+ self ._line_labels .append (line_label )
882
+
883
+ # A bucket is not necessarily aligned with the data point
884
+ # If space allows, center the text around the data point
885
+ for line_label in self ._line_labels :
886
+ datax , datay = line_label .xy
887
+ targety = datay - text_height / 2
888
+ textx , texty = line_label .get_position ()
889
+ direction = 1 if targety > texty else - 1
890
+ bucket_mask = 1 << (get_bucket_index (targety ) + direction )
891
+ if (get_bucket_index (targety ) == get_bucket_index (texty ) and
892
+ buckets_map & bucket_mask == 0 ):
893
+ line_label .set_position ((textx , targety ))
894
+
895
+ def reset_label_lines (self , * args , ** kwargs ):
896
+ for label in self ._line_labels :
897
+ label .remove ()
898
+ self ._line_labels = []
899
+ self .label_lines (* args , ** kwargs )
900
+
756
901
@cbook ._rename_parameter ("3.3" , "s" , "text" )
757
902
@docstring .dedent_interpd
758
903
def annotate (self , text , xy , * args , ** kwargs ):
0 commit comments