|
| 1 | +#!/usr/bin/env python |
| 2 | +# coding: utf-8 |
| 3 | + |
| 4 | +import matplotlib |
| 5 | +from matplotlib import transforms, patches, pyplot as plt |
| 6 | +from mpl_toolkits.axes_grid1.inset_locator import inset_axes |
| 7 | +import numpy as np |
| 8 | +import seaborn as sns |
| 9 | + |
| 10 | + |
| 11 | +# inline matplotlib plots |
| 12 | +get_ipython().run_line_magic('matplotlib', 'inline') |
| 13 | + |
| 14 | + |
| 15 | +# define colors |
| 16 | +GRAY7, GRAY9 = '#929497', '#BFBEBE' |
| 17 | +BLUE_GREEN = '#31859D' |
| 18 | +BLUE_GREEN_LIGHT ='#92CDDD' |
| 19 | +BLUE_DARK = '#1E497D' |
| 20 | +BLUE_LIGHT = '#95B3D7' |
| 21 | +ORANGE_LIGHT = '#FBC08F' |
| 22 | +RED3 = '#DE3A2F' |
| 23 | + |
| 24 | + |
| 25 | +def hex_to_rgb(hex_value): |
| 26 | + h = hex_value.lstrip('#') |
| 27 | + return tuple(int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) |
| 28 | + |
| 29 | +def print_colors(colors): |
| 30 | + rgb_colors = list(map(hex_to_rgb, colors)) |
| 31 | + sns.palplot(rgb_colors) |
| 32 | + |
| 33 | + |
| 34 | +print_colors([ |
| 35 | + GRAY7, |
| 36 | + GRAY9, |
| 37 | + BLUE_GREEN, |
| 38 | + BLUE_GREEN_LIGHT, |
| 39 | + BLUE_DARK, |
| 40 | + BLUE_LIGHT, |
| 41 | + ORANGE_LIGHT, |
| 42 | + RED3 |
| 43 | + ]) |
| 44 | + |
| 45 | + |
| 46 | +# configure plot font family to Arial |
| 47 | +plt.rcParams['font.family'] = 'Arial' |
| 48 | +# configure mathtext bold and italic font family to Arial |
| 49 | +matplotlib.rcParams['mathtext.fontset'] = 'custom' |
| 50 | +matplotlib.rcParams['mathtext.bf'] = 'Arial:bold' |
| 51 | +matplotlib.rcParams['mathtext.it'] = 'Arial:italic' |
| 52 | + |
| 53 | + |
| 54 | +# Returns the x coordinates of a text element on a given axis of a given |
| 55 | +# figure. |
| 56 | +# Used to position elements on the canvas |
| 57 | +# Returns object with attributes: |
| 58 | +# x0 coordinate of the text element |
| 59 | +# x1 coordinate of the text element |
| 60 | +# y0 coordinate of the text element |
| 61 | +# y1 coordinate of the text element |
| 62 | +def get_text_coordinates(text_element, ax, fig): |
| 63 | + x0 = text_element.get_window_extent(fig.canvas.get_renderer()).x0 |
| 64 | + x1 = text_element.get_window_extent(fig.canvas.get_renderer()).x1 |
| 65 | + y0 = text_element.get_window_extent(fig.canvas.get_renderer()).y0 |
| 66 | + y1 = text_element.get_window_extent(fig.canvas.get_renderer()).y1 |
| 67 | + return { |
| 68 | + 'x0': round(ax.transData.inverted().transform_point((x0, 0))[0], 2), |
| 69 | + 'x1': round(ax.transData.inverted().transform_point((x1, 0))[0], 2), |
| 70 | + 'y0': round(ax.transData.inverted().transform_point((0, y0))[1], 2), |
| 71 | + 'y1': round(ax.transData.inverted().transform_point((0, y1))[1], 2) |
| 72 | + } |
| 73 | + |
| 74 | + |
| 75 | +# A to O |
| 76 | +features = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K' ,'L', 'M', 'N', 'O'] |
| 77 | +feature_labels = [f'Feature {x}' for x in features] |
| 78 | + |
| 79 | + |
| 80 | +X_values = [ |
| 81 | + [0, 1, 1, 11, 40, 47], # A |
| 82 | + [0, 2, 2, 13, 36, 47], # B |
| 83 | + [2, 2, 5, 24, 34, 33], # C |
| 84 | + [8, 1, 4, 21, 37, 29], # D |
| 85 | + [6, 1, 6, 23, 36, 28], # E |
| 86 | + [14, 1, 5, 20, 35, 25], # F |
| 87 | + [19, 2, 5, 15, 26, 33], # G |
| 88 | + [13, 1, 6, 23, 32, 25], # H |
| 89 | + [22, 2, 5, 17, 27, 27], # I |
| 90 | + [2, 8, 14, 24, 27, 25], # J |
| 91 | + [29, 1, 4, 17, 28, 21], # K |
| 92 | + [29, 1, 4, 23, 27, 16], # L |
| 93 | + [33, 3, 8, 25, 18, 13], # M |
| 94 | + [26, 9, 14, 24, 17, 10], # N |
| 95 | + [51, 1, 6, 15, 16, 11] # O |
| 96 | + ] |
| 97 | + |
| 98 | + |
| 99 | +colors = [ |
| 100 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_DARK]*2), # A |
| 101 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_DARK]*2), # B |
| 102 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # C |
| 103 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # D |
| 104 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # E |
| 105 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # F |
| 106 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # G |
| 107 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # H |
| 108 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # I |
| 109 | + ([BLUE_GREEN_LIGHT] + [RED3]*2 + [GRAY9] + [BLUE_LIGHT]*2), # J |
| 110 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # K |
| 111 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # L |
| 112 | + ([BLUE_GREEN_LIGHT] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2), # M |
| 113 | + ([BLUE_GREEN_LIGHT] + [RED3]*2 + [GRAY9] + [BLUE_LIGHT]*2), # N |
| 114 | + ([BLUE_GREEN] + [ORANGE_LIGHT]*2 + [GRAY9] + [BLUE_LIGHT]*2) # 0 |
| 115 | + ] |
| 116 | + |
| 117 | +colors |
| 118 | + |
| 119 | + |
| 120 | +feature_labels |
| 121 | + |
| 122 | + |
| 123 | +X_values_reversed_transposed = np.array(X_values[::-1]).T.tolist() |
| 124 | +colors_reversed_transposed = np.array(colors[::-1]).T.tolist() |
| 125 | +feature_labels_reversed = feature_labels[::-1] |
| 126 | + |
| 127 | + |
| 128 | +# create a horizontal stacked bar chart |
| 129 | +fig, ax = plt.subplots(figsize=(25, 7), # width, height in inches |
| 130 | + dpi=110) # resolution of the figure |
| 131 | + |
| 132 | +# tune the subplot layout by setting sides of the figure |
| 133 | +fig.subplots_adjust(left=0.28, right=0.53, top=0.61, bottom=0.107) |
| 134 | + |
| 135 | +widths = np.cumsum(X_values, axis=1)[::-1].T.tolist() |
| 136 | + |
| 137 | +for i, (colname, color) in enumerate(zip(X_values_reversed_transposed, colors_reversed_transposed)): |
| 138 | + bars = ax.barh(feature_labels_reversed, |
| 139 | + colname, |
| 140 | + color=color, |
| 141 | + edgecolor='white', |
| 142 | + height=0.65, |
| 143 | + left=np.sum(X_values_reversed_transposed[:i], axis=0)) |
| 144 | + |
| 145 | + |
| 146 | + for j, b in enumerate(bars): |
| 147 | + # 3% is too small to display the label |
| 148 | + if b.get_width() <= 3: |
| 149 | + continue |
| 150 | + |
| 151 | + # We could annotate all values, but only need to draw attention |
| 152 | + # to these specific ones |
| 153 | + if color[j] not in [BLUE_DARK, RED3, BLUE_GREEN]: |
| 154 | + continue |
| 155 | + |
| 156 | + width = widths[i][j] |
| 157 | + |
| 158 | + plt.text(width - 0.7, b.get_y() + b.get_height() / 2, |
| 159 | + f'{b.get_width():.0f}%', |
| 160 | + ha='right', |
| 161 | + va='center', |
| 162 | + fontsize=6, |
| 163 | + color='white') |
| 164 | + |
| 165 | +# set y ticks and labels |
| 166 | +ax.set_yticks(range(len(feature_labels_reversed))) |
| 167 | +ax.set_yticklabels(feature_labels_reversed, fontsize=10, fontweight='bold') |
| 168 | + |
| 169 | +# set x axis limits |
| 170 | +ax.set_xlim(0, 100) |
| 171 | + |
| 172 | +# set x ticks and labels |
| 173 | +ax.set_xticks([0, 25, 50, 75, 100]) |
| 174 | + |
| 175 | +ax.tick_params(top=False, bottom=False, left=False, labelbottom=False, labeltop=False) |
| 176 | +ax.tick_params(color=GRAY7) |
| 177 | +ax.spines['top'].set_visible(False) |
| 178 | +ax.spines['left'].set_visible(False) |
| 179 | +ax.spines['right'].set_visible(False) |
| 180 | +ax.spines['bottom'].set_visible(False) |
| 181 | + |
| 182 | +# Legend |
| 183 | +legends_end_x1 = 100 |
| 184 | + |
| 185 | +legend_texts = [ |
| 186 | + '- Have not used', |
| 187 | + '- Not satisfied at all', |
| 188 | + '- Not very satisfied', |
| 189 | + '- Somewhat satisfied', |
| 190 | + '- Very satisfied', |
| 191 | + '- Completely satisfied', |
| 192 | +] |
| 193 | + |
| 194 | +legend_fontsize = 8 |
| 195 | + |
| 196 | +# Drawing the elements to get their size with respect to coordinates to |
| 197 | +# calculate the starting position given the end position and the desired spacing |
| 198 | +legends_length = 0 |
| 199 | +for text in legend_texts: |
| 200 | + element = ax.text(0, 0, text, fontsize=legend_fontsize, fontweight='bold') |
| 201 | + legends_length += get_text_coordinates(element, ax=ax, fig=fig)['x1'] |
| 202 | + element.set_visible(False) |
| 203 | + |
| 204 | +spacing = 3 |
| 205 | + |
| 206 | +legends_start_x0 = legends_end_x1 - legends_length - spacing * (len(legend_texts) - 1) |
| 207 | + |
| 208 | +legends_y = len(feature_labels_reversed) + 0.05 |
| 209 | + |
| 210 | +legend_colors = [BLUE_GREEN] + [RED3] * 2 + [GRAY9] + [BLUE_DARK] * 2 |
| 211 | + |
| 212 | +x = legends_start_x0 |
| 213 | + |
| 214 | +for text, color in zip(legend_texts, legend_colors): |
| 215 | + previous_element = ax.text(x, legends_y, text, fontsize=legend_fontsize, color=color, fontweight='bold') |
| 216 | + x = get_text_coordinates(previous_element, ax=ax, fig=fig)['x1'] + spacing |
| 217 | + |
| 218 | +title_x0 = legends_start_x0 - 5 |
| 219 | + |
| 220 | +# Calculate spacing dynamically |
| 221 | +side_box_text_1 = 'Features A and B\ncontinue to top user\nsatisfaction' |
| 222 | +side_box_text_2 = 'Users are least\nsatisfied with\nFeatures J and N;\nwhat improvements\n' 'can we make here\nfor a better user\nexperience?' |
| 223 | + |
| 224 | +# In the following text, I added two spaces to match the size of the above boxes. |
| 225 | +# Should be fixed by using a monospace font |
| 226 | +side_box_text_3 = 'Feature O is least\nused. What steps\n' 'can we proactively \n' 'take with existing\n' 'users to increase\n' 'utilization?' |
| 227 | + |
| 228 | +linespacing = 1.3 |
| 229 | +fontsize = 10 |
| 230 | + |
| 231 | + |
| 232 | +side_boxes_height = 0 |
| 233 | +__el = ax.text(0, 0, side_box_text_1, fontsize=fontsize, linespacing=linespacing) |
| 234 | +side_boxes_height += get_text_coordinates(__el, ax=ax, fig=fig)['y1'] |
| 235 | +__el.set_visible(False) |
| 236 | + |
| 237 | +__el = ax.text(0, 0, side_box_text_2, fontsize=fontsize, linespacing=linespacing) |
| 238 | +side_boxes_height += get_text_coordinates(__el, ax=ax, fig=fig)['y1'] |
| 239 | +__el.set_visible(False) |
| 240 | + |
| 241 | +__el = ax.text(0, 0, side_box_text_3, fontsize=fontsize, linespacing=linespacing) |
| 242 | +side_boxes_height += get_text_coordinates(__el, ax=ax, fig=fig)['y1'] |
| 243 | +__el.set_visible(False) |
| 244 | + |
| 245 | + |
| 246 | +side_boxes_start_y0 = 0.07 # to handle the padding from the background color |
| 247 | +side_boxes_end_y1 = len(feature_labels_reversed) - 1 |
| 248 | + |
| 249 | +spacing = (side_boxes_end_y1 - side_boxes_start_y0 - side_boxes_height) / (3 - 1) # n_elements - 1 |
| 250 | + |
| 251 | +side_box_x = 102.5 |
| 252 | + |
| 253 | +y1 = get_text_coordinates(previous_element, ax=ax, fig=fig)['y1'] + spacing |
| 254 | + |
| 255 | +previous_element = ax.text(side_box_x, side_boxes_start_y0, side_box_text_3, |
| 256 | + fontsize=fontsize, linespacing=linespacing, backgroundcolor=BLUE_GREEN, |
| 257 | + color='white') |
| 258 | + |
| 259 | +y1 = get_text_coordinates(previous_element, ax=ax, fig=fig)['y1'] + spacing |
| 260 | + |
| 261 | +previous_element = ax.text(side_box_x, y1, side_box_text_2, |
| 262 | + fontsize=fontsize, linespacing=linespacing, backgroundcolor=RED3, |
| 263 | + color='white') |
| 264 | + |
| 265 | +y1 = get_text_coordinates(previous_element, ax=ax, fig=fig)['y1'] + spacing |
| 266 | + |
| 267 | +ax.text(side_box_x, y1, side_box_text_1, |
| 268 | + fontsize=fontsize, linespacing=linespacing, backgroundcolor=BLUE_DARK, |
| 269 | + color='white') |
| 270 | + |
| 271 | + |
| 272 | +rightmost_x = get_text_coordinates(previous_element, ax=ax, fig=fig)['x1'] |
| 273 | + |
| 274 | + |
| 275 | +title = ax.text(title_x0, len(feature_labels_reversed) + 3.15, |
| 276 | + 'User satisfaction varies greatly by feature', |
| 277 | + fontsize=15, backgroundcolor='none', color='white', |
| 278 | + ha='left') |
| 279 | + |
| 280 | +title_coordinates = get_text_coordinates(title, ax=ax, fig=fig) |
| 281 | + |
| 282 | +# I cannot use background color with the title, because it does not extend up to |
| 283 | +# the rightmost x coordinate. |
| 284 | +# I could not do this with a bbox config. |
| 285 | +# I could not use a rectangle directly because it is not being drawn out of the |
| 286 | +# ax x/y limits. |
| 287 | +# I create another axis, ax2, and I create a rectangle there. |
| 288 | +title_x1 = title_coordinates['x1'] |
| 289 | +title_y0 = title_coordinates['y0'] |
| 290 | +title_y1 = title_coordinates['y1'] |
| 291 | + |
| 292 | +height = title_y1 - title_y0 |
| 293 | +width = rightmost_x - title_x0 |
| 294 | + |
| 295 | +padding = 0.65 # space around the text |
| 296 | + |
| 297 | +padding_coord = (height / 2 * padding) |
| 298 | + |
| 299 | +ax2_x_min = title_x0 - padding_coord |
| 300 | +ax2_y_min = title_y0 - padding_coord |
| 301 | +ax2_y_max = title_y1 + padding_coord |
| 302 | + |
| 303 | +ax2_height = ax2_y_max - ax2_y_min |
| 304 | + |
| 305 | +tweak_coords = 1.45 # extend the box a bit to align with background color of side texts |
| 306 | + |
| 307 | +ax2_width = width + 2 * padding_coord + tweak_coords |
| 308 | +ax2 = ax.inset_axes([ax2_x_min, ax2_y_min, ax2_width, ax2_height], transform=ax.transData, zorder=1) |
| 309 | +ax2.xaxis.set_visible(False) |
| 310 | +ax2.yaxis.set_visible(False) |
| 311 | + |
| 312 | +ax2_x_max = ax2.get_xlim()[1] |
| 313 | + |
| 314 | +ax2.spines['top'].set_visible(False) |
| 315 | +ax2.spines['left'].set_visible(False) |
| 316 | +ax2.spines['right'].set_visible(False) |
| 317 | +ax2.spines['bottom'].set_visible(False) |
| 318 | + |
| 319 | +rect = patches.Rectangle((0, 0), ax2_x_max, ax2_y_max, linewidth=1, edgecolor=GRAY7, facecolor=GRAY7) |
| 320 | +ax2.add_patch(rect) |
| 321 | + |
| 322 | +# subtitle |
| 323 | +ax.text(title_x0, len(feature_labels_reversed) + 1.50, |
| 324 | + 'Product X User Satisfaction: ' |
| 325 | + '$\\bf{Features}$', |
| 326 | + fontsize=10, |
| 327 | + color='#4B4B4B') |
| 328 | + |
| 329 | +# footnote |
| 330 | +ax.text(title_x0, -2, |
| 331 | + 'Reponses based on survey question "How satisfied have you been with each' |
| 332 | + ' of these features?"\n' |
| 333 | + 'Need more details here to help put this data into context: How many' |
| 334 | + ' people completed survey? What proportion of users does this represent?\n' |
| 335 | + 'Do those who completed survey look like the overall population' |
| 336 | + ' demographic wise? When was the survey conducted?', |
| 337 | + fontsize=6, linespacing=1.3) |
| 338 | + |
| 339 | +plt.show() |
| 340 | + |
| 341 | + |
| 342 | + |
| 343 | + |
0 commit comments