I found a way to do it, although crudely. It involves drawing the annotation in the normal position first, redrawing the canvas, and then if it's outside of the axis bounds, deleting it and drawing a new annotation with an updated position.
I couldn't figure out how to update the position of the original annotation. That would require the same answer as this question.
The canvas redraw fig.canvas.draw()
is required to force the annotation to update it's bbox, so it can be compared to the axis bounds. I couldn't find a way yet to update the bbox without a full canvas redraw. (This requires more digging in the code)
The canvas redraw and recreating the annotation in the new position causes a visible blink on an interactive figure, but should work correctly when saving to file, where only the final position would be visible. (I have only tested it with an interactive figure)
Example PSEUDOCODE: (key pieces copied from working code)
pos_dc = (X_DC, Y_DC) # position where the arrow is pointing, in data coordinates
xytext = [20, 20] # normal x and y position of the annotation textbox, in points coordines
xytext_flip = [-80, -80] # flipped x and y positions, when it's too close to edge, in points coordines
TEXT = ("Text box contents" + "\n" +
"with multiple" + "\n" +
"lines"
)
# create original annotation to measure its position
annot = ax.annotate ("", xy=(0,0), xytext=(xytext[0],xytext[1]),
textcoords="offset points", bbox=dict(boxstyle="round",
fc="w"), arrowprops=dict(arrowstyle="->", color='black')
)
annot.xy = pos_dc
annot.set_text (TEXT)
# draw the canvas, forcing the annotation position to be updated
# fig.canvas.draw_idle() didn't work here
fig.canvas.draw()
# measure the annotation's position
p = annot.get_bbox_patch()
ex = p.get_extents() # annotation textbox, in pixel coordinates
T = ax.transData.inverted()
ex_datac = T.transform (ex) # annotation textbox, in data coordinates
# ex_datac structure in data coordinates:
# [[xleft ybottom] [xright ytop]]
ax_xb = ax.get_xbound()
ax_yb = ax.get_ybound() # axis bounds (the visible area), in data coordinates
# structure in data coordinates:
# ax_xb = (xleft, xright) , ax_yb = (ybottom, ytop)
# in data coordinates, (y) top is a GREATER value than bottom, use '>' to compare it
dc_top_of_display = ax_yb[1]
dc_bottom_of_display = ax_yb[0]
dc_left_of_display = ax_xb[0]
dc_right_of_display = ax_xb[1]
# only testing the right and top edges of the annotation textbox, which was sufficient in this case
dc_box_right = ex_datac[1][0]
dc_box_top = ex_datac[1][1]
# test whether the annotation textbox is outside of the axis bounds
flip_x = dc_box_right > dc_right_of_display
flip_y = dc_box_top > dc_top_of_display # top is GREATER than bottom, use '>'
# if the text box right or top edges are outside of the axis bounds, update the annotation's position
if flip_x or flip_y:
xytext2 = [0, 0]
xytext2[0] = xytext[0] if not flip_x else xytext_flip[0]
xytext2[1] = xytext[1] if not flip_y else xytext_flip[1]
# remove the original annotation
annot.remove ()
# create new annotation with updated position
annot = ax.annotate ("", xy=(0,0), xytext=(xytext2[0],xytext2[1]),
textcoords="offset points", bbox=dict(boxstyle="round",
fc="w"), arrowprops=dict(arrowstyle="->", color='black')
)
annot.xy = pos_dc
annot.set_text (TEXT)
# redraw the canvas
fig.canvas.draw_idle()