7

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.

Mattia Surricchio
  • 1,362
  • 2
  • 21
  • 49

1 Answers1

2

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.

Z-Y.L
  • 1,740
  • 1
  • 11
  • 15
  • This is amazing! You should open a PR to Squarify and add this functionality! – Mattia Surricchio Mar 21 '22 at 09:39
  • 1
    @MattiaSurricchio Now yout can check the package [matplotlib-extra](https://github.com/chenyulue/matplotlib-extra), which has a `AutoFitText` class and a `treemap`. It's a new implementation. – Z-Y.L Apr 09 '22 at 13:02
  • Hey, how would you implement this in squarify treemap? Thanks – Adorable Jul 04 '23 at 15:48