pythonmatplotlibtreemapsquarify

Squarify - Auto resize label in treemap


I'm implementing a simple treemap in Python using Squarify.

I'm plotting the artist name with it's percentage of streams in the considered song chart, the bigger/darker the square, the higher is the value.

My code is the following:

dataGoals = sort_by_streams[sort_by_streams["Streams"]>1]

#Utilise matplotlib to scale our stream number between the min and max, then assign this scale to our values.
norm = matplotlib.colors.Normalize(vmin=min(dataGoals.Streams), vmax=max(dataGoals.Streams))
colors = [matplotlib.cm.Blues(norm(value)) for value in dataGoals.Streams]

#Create our plot and resize it.
fig1 = plt.figure()
ax = fig1.add_subplot()
fig1.set_size_inches(16, 4.5)

#Use squarify to plot our data, label it and add colours. We add an alpha layer to ensure black labels show through
labels = ["%s\n%.2f" % (label) for label in zip(dataGoals.Artist, dataGoals.Streams)]

squarify.plot(label=labels,sizes=dataGoals.Streams, color = colors, alpha=.7, bar_kwargs=dict(linewidth=0.5, edgecolor="#222222"),text_kwargs={'fontsize':15})
plt.title("Streams Percentage",fontsize=23,fontweight="bold")

#Remove our axes and display the plot
plt.axis('off')
plt.show()

And this is the result:

Treemap

As you might notice, the labels of the smaller squares overlaps and go out of the borders. Is there a way to automatically resize the label in order to fit the square?

EDIT: I tried to implement the autowrap function of matplotlib with the following code: squarify.plot(label=labels,sizes=dataGoals.Streams, color = colors, alpha=.7, bar_kwargs=dict(linewidth=0.5, edgecolor="#222222"),text_kwargs={'fontsize':20, 'wrap':True}) but this doesn't solve my problem, my text labels still go out of bounds.


Solution

  • I have the same problem when trying to draw a treemap with squarify. After some search, I come up with a solution, which seems to work as expected.

    import matplotlib.patches as mpatches
    import matplotlib.text as mtext
    
    # Refrence https://stackoverflow.com/questions/48079364/wrapping-text-not-working-in-matplotlib
    # and https://stackoverflow.com/questions/50742503/how-do-i-get-the-height-of-a-wrapped-text-in-matplotlib
    class WrapText(mtext.Text):
        def __init__(self,
                     x=0, y=0, text='',
                     width=0,
                     **kwargs):
            mtext.Text.__init__(self,
                     x=x, y=y, text=text,
                     wrap=True,
                     **kwargs)
            self.width = width  # in screen pixels. You could do scaling first
    
        def _get_wrap_line_width(self):
            return self.width
        
        def get_lines_num(self):
            return len(self._get_wrapped_text().split('\n'))
        
    
    class WrapAnnotation(mtext.Annotation):
        def __init__(self,
                     text, xy,
                     width, **kwargs):
            mtext.Annotation.__init__(self, 
                                      text=text,
                                      xy=xy,
                                      wrap=True,
                                      **kwargs)
            self.width = width
            
        def _get_wrap_line_width(self):
            return self.width
        
        def get_lines_num(self):
            return len(self._get_wrapped_text().split('\n'))
    
    
    def text_with_autofit(self, txt, xy, width, height, *, 
                          transform=None, 
                          ha='center', va='center',
                          wrap=False, show_rect=False,
                          min_size=1, adjust=0,
                          **kwargs):
        if transform is None:
            if isinstance(self, Axes):
                transform = self.transData
            if isinstance(self, Figure):
                transform = self.transFigure
            
            
        x_data = {'center': (xy[0] - width/2, xy[0] + width/2), 
                'left': (xy[0], xy[0] + width),
                'right': (xy[0] - width, xy[0])}
        y_data = {'center': (xy[1] - height/2, xy[1] + height/2),
                'bottom': (xy[1], xy[1] + height),
                'top': (xy[1] - height, xy[1])}
        
        (x0, y0) = transform.transform((x_data[ha][0], y_data[va][0]))
        (x1, y1) = transform.transform((x_data[ha][1], y_data[va][1]))
        # rectange region size to constrain the text
        rect_width = x1 - x0
        rect_height = y1- y0
        
        fig = self.get_figure() if isinstance(self, Axes) else self
        dpi = fig.dpi
        rect_height_inch = rect_height / dpi
        fontsize = rect_height_inch * 72
    
        if isinstance(self, Figure):
            if not wrap:
                text = self.text(*xy, txt, ha=ha, va=va, transform=transform, 
                                 fontsize=min_size, 
                                 **kwargs)
            else:
                fontsize /= 2
                text = WrapText(*xy, txt, width=rect_width, ha=ha, va=va,
                                transform=transform, fontsize=fontsize,
                                **kwargs)
                self.add_artist(text)
                
        if isinstance(self, Axes):
            if not wrap:
                text = self.annotate(txt, xy, ha=ha, va=va, xycoords=transform,
                                     fontsize=min_size, 
                                     **kwargs)
            else:
                fontsize /= 2
                text = WrapAnnotation(txt, xy, ha=ha, va=va, xycoords=transform,
                                      fontsize=fontsize, width=rect_width,
                                      **kwargs)
                self.add_artist(text)
        
        while fontsize > min_size:
            text.set_fontsize(fontsize)
            bbox = text.get_window_extent(fig.canvas.get_renderer())
            bbox_width = bbox.width / text.get_lines_num() if wrap else bbox.width
            if bbox_width <= rect_width:
                while bbox_width <= rect_width:
                    fontsize += 1
                    text.set_fontsize(fontsize)
                    bbox = text.get_window_extent(fig.canvas.get_renderer())
                    bbox_width = bbox.width / text.get_lines_num() if wrap else bbox.width
                else:
                    fontsize = fontsize - 1
                    text.set_fontsize(fontsize)
                    break;
            
            fontsize /= 2      
        
        if fig.get_constrained_layout():
            c_fontsize = fontsize + adjust + 0.5
            text.set_fontsize(c_fontsize if c_fontsize > min_size else min_size)
        if fig.get_tight_layout():
            c_fontsize = fontsize + adjust
            text.set_fontsize(c_fontsize if c_fontsize > min_size else min_size)
        
        if show_rect and isinstance(self, Axes):   
            rect = mpatches.Rectangle((x_data[ha][0], y_data[va][0]), 
                                      width, height, fill=False, ls='--')
            self.add_patch(rect)
            
        return text
    

    This function supports auto-fitting text into a box. If wrap is True, then the text will be auto-wrapped according to the size of the box.

    The following is the figure with auto-fitting (grow=True) and auto-wraping (wrap=True)

    The data is G20 from treemapify, which is an excellent R's package to plot a treemap.

    Figure with auto-fitting: enter image description here

    Figure with auto-fitting and auto-wraping: enter image description here

    The basic process of auto-fitting is setting the font size according to the height of the box, comparing text width with the box's width and decreasing the font size until the text width is less than box's width.

    As for auto-wrapping, the underlying process depends on the built-in auto-wrap in matplotlib by setting wrap=True. The process of auto-adjusting the fontsize is same.

    However, the process of auto-fitting is a little slow. I hope some one can figure out some more efficient algorithm of auto-fitting.

    Hope this function can help you.