diff --git a/README.md b/README.md index aa7f00b..aaf95b1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,12 @@ Make animated bar and line chart races in Python with matplotlib or plotly. + +## Bar Chart Race with Images For Bars +This is what this feature should accomplish. It is a little rough right now and not very generalizable. +![img](gif_for_github.gif) + + ![img](https://github.com/dexplo/bar_chart_race/raw/gh-pages/images/covid19_horiz.gif) ## Official Documentation diff --git a/bar_chart_race/_bar_chart_race.py b/bar_chart_race/_bar_chart_race.py index 8b31d34..6b2f7d1 100644 --- a/bar_chart_race/_bar_chart_race.py +++ b/bar_chart_race/_bar_chart_race.py @@ -7,9 +7,44 @@ from ._func_animation import FuncAnimation from matplotlib.colors import Colormap +from matplotlib.offsetbox import OffsetImage,AnnotationBbox,TextArea +from PIL import Image #for opening images +#import matplotlib.image as mpimg +import os + from ._common_chart import CommonChart from ._utils import prepare_wide_data + + +def get_image_label(root_folder,name): + #path = "data/flags/Flags/flags/flags/24/{}.png".format(name.title()) + path = os.path.join(root_folder , name) + #im = plt.imread(path) + img = Image.open(path) + img.thumbnail((200,200),Image.ANTIALIAS) + return img + +def get_image_name(col_name): + ''' + Needs to account for more cases. This is incomplete but it will do for now. It assumes there + is only one `.` in the filename. If it finds a dot, it will assume the name already has an extension. + If not, it will append `.png` to the name. + col_name: str + Takes a column or bar name and attaches an image extension (only .png for now) + + Returns + ---------- + str + ''' + split_name = col_name.split('.') + if len(split_name) > 1: + img_name = split_name + else: + img_name = col_name + '.png' + return img_name + + class _BarChartRace(CommonChart): def __init__(self, df, filename, orientation, sort, n_bars, fixed_order, fixed_max, @@ -17,7 +52,8 @@ def __init__(self, df, filename, orientation, sort, n_bars, fixed_order, fixed_m period_label, period_template, period_summary_func, perpendicular_bar_func, colors, title, bar_size, bar_textposition, bar_texttemplate, bar_label_font, tick_label_font, tick_template, shared_fontdict, scale, fig, writer, - bar_kwargs, fig_kwargs, filter_column_colors): + bar_kwargs, fig_kwargs, filter_column_colors, + img_label_folder,tick_label_mode,tick_image_mode): self.filename = filename self.extension = self.get_extension() self.orientation = orientation @@ -58,6 +94,12 @@ def __init__(self, df, filename, orientation, sort, n_bars, fixed_order, fixed_m self.subplots_adjust = self.get_subplots_adjust() self.fig = self.get_fig(fig) + self.img_label_folder = img_label_folder #root folder where image labels are stored + self.tick_label_mode = tick_label_mode + self.tick_image_mode = tick_image_mode + self.img_label_artist = [] #stores image artists + + def validate_params(self): if isinstance(self.filename, str): if '.' not in self.filename: @@ -142,6 +184,107 @@ def get_font(self, font, ticks=False): font = {**default_font_dict, **font} return font + def offset_image(self,location,lenght,name,ax): + """ + Creates AnnotationBbox objects to display image labels on the graph. There is a + new AnnotationBbox object created at each frame. Maybe a better approach is to create + the original AnnotationBbox objects and then simply update them as the program runs. + + Parameters + ---------- + location: scalar or array + Coordinate of the bar (in the axis that is moving) + name: str + Filename of image file stored in `self.img_label_folder` + + """ + #load image as an OffsetImage object + img_name = get_image_name(name) + img = get_image_label(self.img_label_folder,img_name) + im = OffsetImage(img,zoom=.08) + im.image.axes = ax + + + if self.orientation=='h': + ab = AnnotationBbox(im,(lenght,location,),xybox=(0,0.),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + if self.tick_label_mode=='mixed': + #load text as TextArea object + label_text = TextArea(name) + text_ab = AnnotationBbox(label_text,(0,location,),xybox=(-30,-5),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + self.img_label_artist.append(text_ab) + ax.add_artist(text_ab) + + elif self.orientation=='v': + ab = AnnotationBbox(im,(location,lenght,),xybox=(0.,0),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + if self.tick_label_mode=='mixed': + #load text as TextArea object + label_text = TextArea(name) + text_ab = AnnotationBbox(label_text,(location,0,),xybox=(-5,30),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + self.img_label_artist.append(text_ab) + ax.add_artist(text_ab) + + self.img_label_artist.append(ab) + ax.add_artist(ab) + + def _add_tick_label_offset_image(self,location,length,name,ax): + """ + Creates AnnotationBbox objects to display image labels on the graph. There is a + new AnnotationBbox object created at each frame. Maybe a better approach is to create + the original AnnotationBbox objects and then simply update them as the program runs. + + Parameters + ---------- + location: scalar or array + Coordinate of the bar (in the axis that is moving) + name: str + Filename of image file stored in `self.img_label_folder` + + """ + #load image as an OffsetImage object + img_name = get_image_name(name) + img = get_image_label(self.img_label_folder,img_name) + im = OffsetImage(img,zoom=.08) + im.image.axes = ax + + #renderer = self.fig.canvas.renderer + #_,_,img_width,img_height = im.get_window_extent(renderer=None) + #print(im.get_data()) + img_width = 30 + img_height = 30 + + if self.tick_image_mode=='trailing': #images move along with the bar + if self.orientation=='h': + #len_bar = (img_width/2) + 2 if length < img_width else length - (img_width/2) - 2 + len_bar = length + xybox_val = (15,0) if length < 30 else (-5,0) + ab = AnnotationBbox(im,(len_bar,location,),xybox=xybox_val,frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + else: + #len_bar = (img_height/2) + 2 if length < img_height else length - (img_height/2) - 2 + len_bar = length + xybox_val = (0,15) if length < 30 else (0,-5) + ab = AnnotationBbox(im,(location,len_bar,),xybox=(0.,-10.),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + + elif self.tick_image_mode=='fixed': #images stay fixed at the beginning of the bar + if self.orientation=='h': + #len_bar = (img_width/2) + 2 + len_bar = 10 + ab = AnnotationBbox(im,(len_bar,location,),xybox=(10,0.),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + else: + #len_bar = (img_height/2) + 2 + len_bar = 10 + ab = AnnotationBbox(im,(location,len_bar,),xybox=(0.,10.),frameon=False,xycoords='data', + boxcoords='offset points',pad=0) + + self.img_label_artist.append(ab) + ax.add_artist(ab) + def prepare_data(self, df): if self.fixed_order is True: last_values = df.iloc[-1].sort_values(ascending=False) @@ -350,7 +493,9 @@ def plot_bars(self, ax, i): if self.orientation == 'h': ax.barh(bar_location, bar_length, tick_label=cols, color=colors, **self.bar_kwargs) - ax.set_yticklabels(ax.get_yticklabels(), **self.tick_label_font) + ax.set_yticklabels(ax.get_yticklabels(), **self.tick_label_font,wrap=True)#,visible=False) + #ax.set_yticklabels([]) + #ax.tick_params(top=False, bottom=False, left=False, right=False, labelleft=True, labelbottom=True) if not self.fixed_max and self.bar_textposition == 'outside': max_bar = bar_length.max() new_max_pixels = ax.transData.transform((max_bar, 0))[0] + self.extra_pixels @@ -366,6 +511,12 @@ def plot_bars(self, ax, i): new_ymax = ax.transData.inverted().transform((0, new_max_pixels))[1] ax.set_ylim(ax.get_ylim()[0], new_ymax) + if self.img_label_folder: #here I am handling the addition of images as the bar tick labels + zipped = zip(bar_location,bar_length,cols) + for bar_loc,bar_len,col_name in zipped: + #self.offset_image(bar_loc,bar_len,col_name,ax) + self._add_tick_label_offset_image(bar_loc,bar_len,col_name,ax) + self.set_major_formatter(ax) self.add_period_label(ax, i) self.add_period_summary(ax, i) @@ -425,7 +576,6 @@ def add_bar_labels(self, ax, bar_location, bar_length): text = self.bar_texttemplate.format(x=val) xtext, ytext = ax.transLimits.inverted().transform((xtext, ytext)) - text_obj = ax.text(xtext, ytext, text, clip_on=True, **self.bar_label_font) text_objs.append(text_obj) return text_objs @@ -452,6 +602,7 @@ def add_perpendicular_bar(self, ax, bar_length, i): line.set_ydata([val] * 2) def anim_func(self, i): + if i is None: return ax = self.fig.axes[0] @@ -460,12 +611,20 @@ def anim_func(self, i): start = int(bool(self.period_label)) for text in ax.texts[start:]: text.remove() + + if self.img_label_folder: + for artist in self.img_label_artist: + artist.remove() + self.img_label_artist = [] #clears the list of artists for the next cycle. self.plot_bars(ax, i) + self.fig.tight_layout() + def make_animation(self): def init_func(): ax = self.fig.axes[0] self.plot_bars(ax, 0) + self.fig.tight_layout() interval = self.period_length / self.steps_per_period pause = int(self.end_period_pause // interval) @@ -516,8 +675,9 @@ def bar_chart_race(df, filename=None, orientation='h', sort='desc', n_bars=None, perpendicular_bar_func=None, colors=None, title=None, bar_size=.95, bar_textposition='outside', bar_texttemplate='{x:,.0f}', bar_label_font=None, tick_label_font=None, tick_template='{x:,.0f}', - shared_fontdict=None, scale='linear', fig=None, writer=None, - bar_kwargs=None, fig_kwargs=None, filter_column_colors=False): + shared_fontdict=None, scale='linear', fig=None, writer=None, bar_kwargs=None, + fig_kwargs=None, filter_column_colors=False, + img_label_folder=None,tick_label_mode='image',tick_image_mode='trailing'): ''' Create an animated bar chart race using matplotlib. Data must be in 'wide' format where each row represents a single time period and each @@ -815,7 +975,31 @@ def func(val): EXPERIMENTAL This parameter is experimental and may be changed/removed in a later version. + + img_label_folder : `None` or str, default `None` + Folder that contains images to be used as labels in the chart. + The folder should contain one image per bar in the chart and + the filenames should match name of the corresponding column in the dataframe. + + tick_label_mode : str, default `image` + Dictates what kind of tick label will be used for the bars. Depending on the + mode selected, only the image might show up, or both image and text. + For only text, simply use the default value of `None` for `img_label_folder` above. + + Possible keys are: + `image`, `mixed` + + DO NOT USE. I have not polished it and it does not look good. Just let it use the + default value `image` and everything will be fine. + tick_image_mode : str, default `trailing` + Tells how to update image tick labels. `trailing` will make it so that the image is + always moving with the bar as it grows. `fixed` will keep the image at a fixed + location near the start of the bar. I have not decided on the best way to automate + the location. + + Possible keys are: + `trailing`, `fixed` Returns ------- When `filename` is left as `None`, an HTML5 video is returned as a string. @@ -872,5 +1056,6 @@ def func(val): period_label, period_template, period_summary_func, perpendicular_bar_func, colors, title, bar_size, bar_textposition, bar_texttemplate, bar_label_font, tick_label_font, tick_template, shared_fontdict, scale, - fig, writer, bar_kwargs, fig_kwargs, filter_column_colors) + fig, writer, bar_kwargs, fig_kwargs, filter_column_colors, + img_label_folder,tick_label_mode,tick_image_mode) return bcr.make_animation() diff --git a/docs/tutorial.md b/docs/tutorial.md index 40dddb1..1d5d9ba 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -34,6 +34,26 @@ df.bcr.bar_chart_race()