Is it possible to tie the linewidth of a matplotlib path to the figure zoom/scale level?
I am drawing a map where the matplotlib path (with bezier curves) draws the road on the map. Upon zooming in I would like the width of the path to zoom in.
In attached script, the polygonal approximation can properly zoom, but the path (red line) cannot zoom (in width).
Is it possible to tie the linewidth to some scale transformation and redraw via callback ?
import matplotlib.pyplot as plt
from matplotlib.path import Path
import matplotlib.patches as patches
import numpy as np
def main():
ax = plt.subplot(111)
verts = np.array([ (0., 0.), (0.5, .5), (1., 0.8), (0.8, 0.)])
codes = np.array([Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.LINETO ])
# Can this curve have zoomable width
path = Path(verts, codes)
patch = patches.PathPatch(path, fc='none', color='r', lw=4, zorder=3)
ax.add_patch(patch)
ax.plot(verts[:,0], verts[:,1], 'o--', lw=2, color='k', zorder=2)
# these will be polygonal approx that will have proper zoom
v=np.array([]).reshape((-1,2))
c=[]
for i in range(len(verts)-1):
vtmp, ctmp = line2poly(verts[[i,i+1],:],0.03)
v = np.vstack( (v,vtmp) )
c = np.concatenate( (c,ctmp) )
path_zoom = Path(v,c)
patch_zoom = patches.PathPatch(path_zoom, fc='r', ec='k', zorder=1, alpha=0.4)
ax.add_patch(patch_zoom)
ax.set_xlim(-0.1, 1.1)
ax.set_ylim(-0.1, 1.1)
plt.show()
def line2poly(line, width):
dx,dy = np.hstack(np.diff(line,axis=0)).tolist()
theta = np.arctan2(dy,dx)
print(np.hstack(np.diff(line,axis=0)).tolist())
print(np.degrees(theta))
s = width/2 * np.sin(theta)
c = width/2 * np.cos(theta)
trans = np.array([(-s,c),(s,-c),(s,-c),(-s,c)])
verts = line[[0,0,1,1],:]+trans
verts = np.vstack((verts, verts[0,:]))
codes = np.array([Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])
return verts,codes
if __name__=='__main__':
main()
To the best of my knowledge, there's no way to do this in matplotlib, as the stroke width of a line cannot be directly tied to data coordinates. (As you mentioned, you could connect a callback to the draw event and accomplish this. It would incur a large performance penalty, though.)
However, a quick workaround would be to use shapely to generate polygons by buffering your street paths.
As a quick example:
import shapely.geometry
import descartes
import matplotlib.pyplot as plt
lines = ([(0, 0), (1, 0), (0, 1)],
[(0, 0), (1, 1)],
[(0.5, 0.5), (1, 0.5)],
)
lines = shapely.geometry.MultiLineString(lines)
# "0.05" is the _radius_ in data coords, so the width will be 0.1 units.
poly = lines.buffer(0.05)
fig, ax = plt.subplots()
patch = descartes.PolygonPatch(poly, fc='gray', ec='black')
ax.add_artist(patch)
# Rescale things to leave a bit of room around the edges...
ax.margins(0.1)
plt.show()
If you did want to take the callback route, you might do something like this:
import matplotlib.pyplot as plt
def main():
lines = ([(0, 0), (1, 0), (0, 1)],
[(0, 0), (1, 1)],
[(0.5, 0.5), (1, 0.5)],
)
fig, ax = plt.subplots()
artists = []
for verts in lines:
x, y = zip(*verts)
line, = ax.plot(x, y)
artists.append(line)
scalar = StrokeScalar(artists, 0.1)
ax.callbacks.connect('xlim_changed', scalar)
ax.callbacks.connect('ylim_changed', scalar)
# Rescale things to leave a bit of room around the edges...
ax.margins(0.05)
plt.show()
class StrokeScalar(object):
def __init__(self, artists, width):
self.width = width
self.artists = artists
# Assume there's only one axes and one figure, for the moment...
self.ax = artists[0].axes
self.fig = self.ax.figure
def __call__(self, event):
"""Intended to be connected to a draw event callback."""
for artist in self.artists:
artist.set_linewidth(self.stroke_width)
#property
def stroke_width(self):
positions = [[0, 0], [self.width, self.width]]
to_inches = self.fig.dpi_scale_trans.inverted().transform
pixels = self.ax.transData.transform(positions)
points = to_inches(pixels) * 72
return points.ptp(axis=0).mean() # Not quite correct...
main()
Related
I am generating a plot with a collection of wedges using matplotlib.patches.Wedge(). The wedges are different colors and with different angles subtended. I would like to include Wedge artists in a legend, but the following MWE is resulting in Line artists in the legend instead of Wedge artists.
legend() docs show examples for Line2D and Patch artists. How can I include Wedge artists in my legend?
import matplotlib.pylab as plt
from matplotlib.patches import Wedge
from matplotlib import rc
plt.rcParams['axes.facecolor']='#EEEEEE'
plt.rcParams['savefig.facecolor']='#EEEEEE'
colors = ['#e41a1c','#377eb8','#4daf4a','#984ea3']
fig1 = plt.figure(figsize=(4, 3))
ax = fig1.gca().axes
radius = 0.5
legend_elements = [Wedge((0, 1), radius, 0, 90, width=radius, color=colors[0], label='category1'),
Wedge((0, 1), radius, 0, 180, width=radius, color=colors[1], label='category2'),
Wedge((0, 1), radius, 0, 270, width=radius, color=colors[2], label='category3'),
Wedge((0, 1), radius, 0, 360, width=radius, color=colors[3], label='category4')]
ax.legend(handles = legend_elements, loc='lower center',
bbox_to_anchor= (0.5, 1.01), ncol=4,
borderaxespad=0, frameon=False)
fig1.show()
You'll need to create your own Handler that tells matplotlib how to take a specific Artist and transform it to appear in a legend. Often times things need to be resized or transformed in some manner to appear better on a legend.
To create a handler, all you need to do is create any Python object that defines a legend_artist method and has the same call signature as below see docs for more info.
From there we simply create a dummy Wedge object whose radius is smaller than the box it's being drawn in (hence min(width, height)). We update the aesthetic properties on this new dummy wedge based on the inputted Artists.
Finally we add the newly created/updated dummy Wedge to the handlebox to be drawn later.
With the Handler out of the way, we now supply a handler_map to the call of ax.legend. This is essentially a dictionary that maps an Artist (in this case Wedge) to the Handler we just created, essentially tying the artist to the instructions on how to represent it in a legend.
import matplotlib.pylab as plt
from matplotlib.patches import Wedge
from matplotlib import rc
plt.rcParams['axes.facecolor']='#EEEEEE'
plt.rcParams['savefig.facecolor']='#EEEEEE'
class WedgeHandler:
def legend_artist(self, legend, orig_handle, fontsize, handlebox):
x0, y0 = handlebox.xdescent, handlebox.ydescent
width, height = handlebox.width, handlebox.height
handle = Wedge((0, 0), min(width, height), orig_handle.theta1, orig_handle.theta2)
handle.update_from(orig_handle)
handlebox.add_artist(handle)
return handle
colors = ['#e41a1c','#377eb8','#4daf4a','#984ea3']
fig1 = plt.figure(figsize=(4, 3))
ax = fig1.gca().axes
radius = 0.5
legend_elements = [Wedge((0, 1), radius, 0, 90, width=radius, color=colors[0], label='category1'),
Wedge((0, 1), radius, 0, 180, width=radius, color=colors[1], label='category2'),
Wedge((0, 1), radius, 0, 270, width=radius, color=colors[2], label='category3'),
Wedge((0, 1), radius, 0, 360, width=radius, color=colors[3], label='category4')]
ax.legend(handles = legend_elements, loc='lower center',
bbox_to_anchor= (0.5, 1.05), ncol=4,
borderaxespad=0, frameon=False,
handler_map={Wedge: WedgeHandler()}
)
plt.show()
update, you may run into some issues if you use Artist.update_from as it copies over a few too many properties. Instead you can manually specify which properties you want copied using this as an example:
import matplotlib.pylab as plt
from matplotlib.patches import Wedge
from matplotlib import rc
class WedgeHandler:
def legend_artist(self, legend, orig_handle, fontsize, handlebox):
x0, y0 = handlebox.xdescent, handlebox.ydescent
width, height = handlebox.width, handlebox.height
r = min(width, height)
handle = Wedge(
center=(x0 + width / 2, y0 + height / 2), # centers handle in handlebox
r=r, # ensures radius fits in handlebox
width=r * (orig_handle.width / orig_handle.r), # preserves original radius/width ratio
theta1=orig_handle.theta1, # copies the following parameters
theta2=orig_handle.theta2,
color=orig_handle.get_facecolor(),
transform=handlebox.get_transform(), # use handlebox coordinate system
)
handlebox.add_artist(handle)
return handle
plt.rcParams['axes.facecolor']='#EEEEEE'
plt.rcParams['savefig.facecolor']='#EEEEEE'
colors = ['#e41a1c','#377eb8','#4daf4a','#984ea3']
theta2 = [90, 180, 270, 360]
fig, ax = plt.subplots()
for i, (color, t2) in enumerate(zip(colors, theta2), start=1):
wedge = Wedge((i, .5), 0.25, theta1=0, theta2=t2, width=0.25, color=color, label=f'category{i}')
ax.add_artist(wedge)
ax.set_xlim(1-.25, i + .25)
legend = ax.legend(title='Default Handler', loc='upper left', bbox_to_anchor=(1.01, 1))
ax.add_artist(legend)
ax.legend(title='Custom Handler', loc='upper left', bbox_to_anchor=(1.01, 0.5), handler_map={Wedge: WedgeHandler()})
plt.show()
I'm trying to create an inset figure that has a different projection from the parent. The only issue I have at this point is the inset figures's tick labels are not legible because they are black and blend in with the plot behind it. I could change the color of the ticks and labels to white, but that does not help when the data in ax0 yields lighter colors. Here is the MWE:
import calipsoFunctions as cf
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import numpy as np
import pylab as pl
from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
from mpl_toolkits.axes_grid1.inset_locator import inset_axes, mark_inset, InsetPosition
x, y = np.arange(100), np.arange(200)
X, Y = np.meshgrid(x, y)
C = np.random.randint(0, 100, (200, 100))
fig = pl.figure(figsize=(6.5, 5.25))
gs0 = pl.GridSpec(3, 1)
gs0.update(left=0.08, right=0.925,
top=0.95, bottom=0.33,
hspace=0.10, wspace=0.0)
gs1 = pl.GridSpec(1, 2)
gs1.update(left=0.08, right=0.925,
top=0.225, bottom=0.05,
hspace=0.0, wspace=0.025)
# create primary axes
ax0 = pl.subplot(gs0[0])
ax1 = pl.subplot(gs0[1])
ax0.pcolormesh(X, Y, C, vmin=0, vmax=75)
ax1.pcolormesh(X, Y, C, vmin=0, vmax=75)
# add map plot (inset axis)
loc_box = [0.8, 0.55, 0.20, 0.45]
ax0_inset = fig.add_axes(loc_box,
projection=ccrs.PlateCarree(),
aspect="auto",
facecolor="w",
frameon=True)
lat_array = np.arange(-20, 20)
lon_array = np.arange(-10, 10, 0.5)
ax0_inset.plot(lat_array, lon_array, "k-", lw=1)
ip = InsetPosition(ax0, loc_box)
ax0_inset.set_axes_locator(ip)
ax0_inset.coastlines(resolution="10m", linewidth=0.25, color="k")
ax0_inset.add_feature(cfeature.LAND)
llat, ulat = lat_array.min(), lat_array.max()
llon, ulon = lon_array.min(), lon_array.max()
llat = np.round(llat / 10) * 10
ulat = np.round(ulat / 10) * 10
llon = np.round(llon / 5) * 5
ulon = np.round(ulon / 5) * 5
ax0_inset.set_yticks(np.arange(llat, ulat, 20), minor=False)
ax0_inset.set_yticks(np.arange(llat, ulat, 10), minor=True)
ax0_inset.set_yticklabels(np.arange(llat, ulat, 20),
fontsize=8)
ax0_inset.yaxis.set_major_formatter(LatitudeFormatter())
ax0_inset.set_xticks(np.arange(llon, ulon, 5), minor=False)
ax0_inset.set_xticks(np.arange(llon, ulon, 1), minor=True)
ax0_inset.set_xticklabels(np.arange(llon, ulon, 5),
fontsize=8,
rotation=45)
ax0_inset.xaxis.set_major_formatter(LongitudeFormatter())
ax0_inset.grid()
ax0_inset.tick_params(which="both",
axis="both",
direction="in",
labelsize=8)
fig.show()
Is there a way to change the background color of ax0_inset so that these tick labels are legible? I tried changing the face_color to "w", but that did not work. Ideally, I want the same behavior as ax0.figure.set_facecolor("w"), but for the ax0_inset axis. Is this doable?
Following #Mr. T's comment suggestion, a work-around solution could be:
# insert transparent (or opaque) rectangle around inset_axes plot
# to make axes labels more visible
# make buffer variable to control amount of buffer around inset_axes
buffer = 0.1 # fractional axes coordinates
# use ax inset tuple coords in loc_box to add rectangle patch
# [left, bottom, width, height] (fractional axes coordinates)
fig.add_patch(plt.Rectangle((
loc_box[0]-buffer, loc_box[1]-buffer),
loc_box[2]+buffer, loc_box[3]+buffer,
linestyle="-", edgecolor="k", facecolor="w",
linewidth=1, alpha=0.75, zorder=5,
transform=ax0.transAxes))
Similar to this post, I would like to transform my data coordinates to figure coordinates. Unfortunately, the transformation tutorial doesn't seem to talk about it. So I came up with something analogous to the answer by wilywampa, but for some reason, there is something wrong and I can't figure it out:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import ConnectionPatch
t = [
0, 6.297, 39.988, 46.288, 79.989, 86.298, 120.005, 126.314, 159.994,
166.295, 200.012, 206.314, 240.005, 246.301, 280.05, 286.35, 320.032,
326.336, 360.045, 366.345, 480.971, 493.146, 1080.117, 1093.154, 1681.019,
1692.266, 2281.008, 2293.146, 2881.014, 2893.178, 3480.988, 3493.149,
4080.077, 4092.298, 4681.007, 4693.275, 5281.003, 5293.183, 5881.023,
5893.188, 6481.002, 6492.31
]
y = np.zeros(len(t))
fig, (axA, axB) = plt.subplots(2, 1)
fig.tight_layout()
for ax in (axA, axB):
ax.set_frame_on(False)
ax.axes.get_yaxis().set_visible(False)
axA.plot(t[:22], y[:22], c='black')
axA.plot(t[:22], y[:22], 'o', c='#ff4500')
axA.set_ylim((-0.05, 1))
axB.plot(t, y, c='black')
axB.plot(t, y, 'o', c='#ff4500')
axB.set_ylim((-0.05, 1))
pos1 = axB.get_position()
pos2 = [pos1.x0, pos1.y0 + 0.3, pos1.width, pos1.height]
axB.set_position(pos2)
trans = [
# (ax.transAxes + ax.transData.inverted()).inverted().transform for ax in
(fig.transFigure + ax.transData.inverted()).inverted().transform for ax in
(axA, axB)
]
con1 = ConnectionPatch(
xyA=trans[0]((0, 0)), xyB=(0, 0.1), coordsA="figure fraction",
coordsB="data", axesA=axA, axesB=axB, color="black"
)
con2 = ConnectionPatch(
xyA=(500, 0), xyB=(500, 0.1), coordsA="data", coordsB="data",
axesA=axA, axesB=axB, color="black"
)
print(trans[0]((0, 0)))
axB.add_artist(con1)
axB.add_artist(con2)
plt.show()
The line on the left is supposed to go to (0, 0) of the upper axis, but it doesn't. The same happens btw if I try to convert to axes coordinates, so there seems be to something fundamentally wrong.
The reason why I want to use figure coords is because I don't actually want the line to end at (0, 0), but slightly below the '0' tick label. I cannot do that in data coords so I tried to swap to figure coods.
Adapting the second example from this tutorial code, it seems no special combinations of transforms is needed. You can use coordsA=axA.get_xaxis_transform(), if x is in data coordinates and y in figure coordinates. Or coordsA=axA.transData if x and y are both in data coordinates. Note that when using data coordinates you are allowed to give coordinates outside the view window; by default a ConnectionPatch isn't clipped.
The following code uses z-order to put the connection lines behind the rest and adds a semi-transparent background to the tick labels of axA (avoiding that the text gets crossed out by the connection line):
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import ConnectionPatch
t = [0, 6.297, 39.988, 46.288, 79.989, 86.298, 120.005, 126.314, 159.994, 166.295, 200.012, 206.314, 240.005, 246.301, 280.05, 286.35, 320.032, 326.336, 360.045, 366.345, 480.971, 493.146, 1080.117, 1093.154, 1681.019, 1692.266, 2281.008, 2293.146, 2881.014, 2893.178, 3480.988, 3493.149, 4080.077, 4092.298, 4681.007, 4693.275, 5281.003, 5293.183, 5881.023, 5893.188, 6481.002, 6492.31]
y = np.zeros(len(t))
fig, (axA, axB) = plt.subplots(2, 1)
fig.tight_layout()
for ax in (axA, axB):
ax.set_frame_on(False)
ax.axes.get_yaxis().set_visible(False)
axA.plot(t[:22], y[:22], c='black')
axA.plot(t[:22], y[:22], 'o', c='#ff4500')
axA.set_ylim((-0.05, 1))
axB.plot(t, y, c='black')
axB.plot(t, y, 'o', c='#ff4500')
axB.set_ylim((-0.05, 1))
pos1 = axB.get_position()
pos2 = [pos1.x0, pos1.y0 + 0.3, pos1.width, pos1.height]
axB.set_position(pos2)
con1 = ConnectionPatch(xyA=(0, 0.02), coordsA=axA.get_xaxis_transform(),
xyB=(0, 0.05), coordsB=axB.get_xaxis_transform(),
# linestyle='--', color='black', zorder=-1)
linestyle='--', color='darkgrey', zorder=-1)
con2 = ConnectionPatch(xyA=(500, 0.02), coordsA=axA.get_xaxis_transform(),
xyB=(500, 0.05), coordsB=axB.get_xaxis_transform(),
linestyle='--', color='darkgrey', zorder=-1)
fig.add_artist(con1)
fig.add_artist(con2)
for lbl in axA.get_xticklabels():
lbl.set_backgroundcolor((1, 1, 1, 0.8))
plt.show()
Possible answer to your last comment:
As you're dealing with figure coords, these can change depending on your screen resolution. So if your other machine has a different res then this could be why its changing. You'll have to look into using Axes coords instead if you don't want these random changes.
I need to draw a polygon that has 4 sides with the right side being an arc. Similar to this:
I have tried to use the code provided by matplotlib for bezier curves but with no success. Any help would be appreciated : )
from matplotlib.path import Path
import matplotlib.patches as patches
verts = [
(0., 0.), # P0
(0, 1.), # P1
(1., 1), # P2
(1, 0.), # P3
]
codes = [
Path.MOVETO,
Path.CURVE4,
Path.CURVE4,
Path.CURVE4,
]
path = Path(verts, codes)
fig, ax = plt.subplots()
patch = patches.PathPatch(path, facecolor='none', lw=2)
ax.add_patch(patch)
xs, ys = zip(*verts)
ax.plot(xs, ys, lw=2, color='black', ms=10)
ax.set_xlim(-0.1, 1.1)
ax.set_ylim(-0.1, 1.1)
plt.show()
I have no idea if that is the correct approach to draw path objects - you better wait for people who have experience with this method. But until then, you can do this:
from matplotlib.path import Path
import matplotlib.patches as patches
verts = [
(0, 0), # P0
(0, 1), # P1
(1, 1), # P2
(0.4, 1), #these are the vertices for the
(0.2, 0), #Bezier curve
(0.5, 0), # P3
(0, 0) #and back to P0
]
codes = [
Path.MOVETO, # P0
Path.LINETO, #line to P1
Path.LINETO, #line to P2
Path.CURVE4, #control point one for the Bezier curve
Path.CURVE4, #control point two
Path.CURVE4, #end point for the Bezier curve
Path.LINETO #and back to P0
]
path = Path(verts, codes)
fig, ax = plt.subplots()
patch = patches.PathPatch(path, facecolor='none', lw=2)
ax.add_patch(patch)
#you can add the hull figure for the Bezier curve
#xs, ys = zip(*verts)
#ax.plot(xs, ys, "x--", lw=2, color='black', ms=10)
ax.set_xlim(-0.1, 1.1)
ax.set_ylim(-0.1, 1.1)
plt.show()
Sample output:
The matplotlib documentation says that for a Bezier curve, you need (in addition to your current position) two control points and one end point. So, your four-point approach might not be sufficient.
I use matplotlib to generate an image in the following way:
fig = plt.figure()
ax = fig.add_subplot(111)
ax.fill(border[0],border[1], color='g', linewidth=1, fill=True, alpha = 0.5)
patches = []
for x1,y1,r in zip(x, y, radii):
circle = Circle((x1,y1), r)
patches.append(circle)
p = PatchCollection(patches, cmap='cool', alpha=1.0)
p.set_array(c)
ax.add_collection(p)
plt.colorbar(p)
plt.savefig(fig_name)
What I want to have is a polygon (given by its border) and colored circles on the top of this polygon. However, I get the polygon on the top of the circles.
This is strange because I plot the polygon first and then I add circles to the plot.
Does anybody know why it happens and how this problem can be resolved?
ADDED
As requested, here is fully working example:
import pandas
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.collections import PatchCollection
from matplotlib.patches import Circle, Polygon
import numpy as np
def plot_xyc(df, x_col, y_col, c_col, radius, fig_name, title, zrange):
resolution = 50
x = df[x_col]
y = df[y_col]
c = df[c_col]
x0 = (max(x) + min(x))/2.0
y0 = (max(y) + min(y))/2.0
dx = (max(x) - min(x))
dy = (max(y) - min(y))
delta = max(dx, dy)
radii = [delta*radius for i in range(len(x))]
fig = plt.figure()
plt.title(title)
ax = fig.add_subplot(111)
border = ([-3, 3, 3, -3], [-3, -3, 3, 3])
ax.fill(border[0],border[1], color='g', linewidth=1, fill=True, alpha = 1.0)
patches = []
for x1,y1,r in zip(x, y, radii):
circle = Circle((x1,y1), r)
patches.append(circle)
patches.append(Circle((-100,-100), r))
patches.append(Circle((-100,-100), r))
p = PatchCollection(patches, cmap='cool', alpha=1.0)
p.set_array(c)
max_ind = max(c.index)
c.set_value(max_ind + 1, min(zrange))
c.set_value(max_ind + 2, max(zrange))
plt.xlim([x0 - delta/2.0 - 0.05*delta, x0 + delta/2.0 + 0.05*delta])
plt.ylim([y0 - delta/2.0 - 0.05*delta, y0 + delta/2.0 + 0.05*delta])
ax.add_collection(p)
plt.colorbar(p)
plt.savefig(fig_name)
if __name__ == '__main__':
df = pandas.DataFrame({'x':[1,2,3,4], 'y':[4,3,2,1], 'z':[1,1,2,2]})
plot_xyc(df, 'x', 'y', 'z', 0.1, 'test2.png', 'My Titlle', (0.0, 3.0))
You're looking for zorder.
In matplotlib, all additional arguments are just passed up the class heirarchy. zorder is a kwarg of the Artist class, so you just need to make sure that at some point it gets zorder.
You can do it two ways in your example;
either add it in here:
ax.fill(border[0],border[1], color='g', linewidth=1, fill=True, alpha = 1.0, zorder=1)
or here:
p = PatchCollection(patches, cmap='cool', alpha=1.0, zorder=2)
or if you want, both. Objects with a higher zorder sit on top of those with lower values.