@@ -753,6 +753,164 @@ 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 = 1 + 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
+ if bucket >= 0 and bucket < buckets_total :
827
+ bucket_densities [bucket ] += 1
828
+
829
+ def in_viewport (args ):
830
+ xy = args [2 ]
831
+ x , y = xy
832
+ return fig_minx < x < fig_maxx and fig_miny < y < fig_maxy
833
+
834
+ def by_y (args ):
835
+ xy = args [2 ]
836
+ x , y = xy
837
+ return y
838
+
839
+ bucket_offset = None
840
+ prev_ideal_bucket = - 1
841
+ # Iterate over all the handles, labels, and xy data sorted by y asc
842
+ for handle , label , xy in sorted (filter (in_viewport ,
843
+ zip (handles , labels , xys )),
844
+ key = by_y ):
845
+ data_x , data_y = xy
846
+
847
+ ideal_bucket = get_bucket_index (data_y )
848
+ if ideal_bucket != prev_ideal_bucket :
849
+ # If a bucket is dense, then we want to center the text around
850
+ # the bucket. SSo, find out how many empty buckets there are
851
+ # below the current bucket
852
+ bucket_density = bucket_densities [ideal_bucket ]
853
+ ideal_offset = bucket_density // 2
854
+ empty_buckets_below = 0
855
+ for i in range (ideal_bucket + 1 ,
856
+ ideal_bucket - ideal_offset + 1 ,
857
+ - 1 ):
858
+ # If reached bottom or bucket is not empty
859
+ if i == 0 or buckets_map & (1 << i ) == 1 :
860
+ break
861
+ empty_buckets_below += 1
862
+ bucket_offset = - min (ideal_offset , empty_buckets_below )
863
+ prev_ideal_bucket = ideal_bucket
864
+
865
+ bucket = ideal_bucket + bucket_offset
866
+ text_x , text_y = None , None
867
+
868
+ # Find the nearest empty bucket
869
+ for index in range (buckets_total ):
870
+ # 0 <= bucket_index <= buckets_total
871
+ bucket_index = max (0 , min (bucket + index , buckets_total ))
872
+ bucket_mask = 1 << bucket_index
873
+ # If this bucket is empty
874
+ if buckets_map & bucket_mask == 0 :
875
+ # Mark this bucket as used
876
+ buckets_map |= bucket_mask
877
+ text_x = data_maxx + margin_left
878
+ text_y = bucket_index * bucket_height + fig_miny
879
+ break
880
+
881
+ # If a bucket was found for the text
882
+ if text_x is not None and text_y is not None :
883
+ text_color = handle .get_color ()
884
+ line_label = self .annotate (label , (data_maxx , data_y ),
885
+ xytext = (text_x , text_y ),
886
+ fontsize = text_fontsize ,
887
+ color = text_color ,
888
+ annotation_clip = True )
889
+ self ._line_labels .append (line_label )
890
+
891
+ # A bucket is not necessarily aligned with the data point
892
+ # If space allows, center the text around the data point
893
+ # TODO(omarchehab98): group fuzzing
894
+ # for line_label in self._line_labels:
895
+ # datax, datay = line_label.xy
896
+ # targety = datay - text_height / 2
897
+ # textx, texty = line_label.get_position()
898
+ # direction = 1 if targety > texty else -1
899
+ # target_bucket = get_bucket_index(targety) + direction
900
+ # if (get_bucket_index(targety) == get_bucket_index(texty) and
901
+ # (target_bucket == -1 or
902
+ # buckets_map & (1 << target_bucket) == 0)):
903
+ # line_label.set_position((textx, targety))
904
+
905
+ def has_label_lines (self ):
906
+ return self ._line_labels is not None
907
+
908
+ def refresh_label_lines (self , * args , ** kwargs ):
909
+ for label in self ._line_labels :
910
+ label .remove ()
911
+ self ._line_labels = None
912
+ self .label_lines (* args , ** kwargs )
913
+
756
914
@cbook ._rename_parameter ("3.3" , "s" , "text" )
757
915
@docstring .dedent_interpd
758
916
def annotate (self , text , xy , * args , ** kwargs ):
0 commit comments