Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 5a6c99b

Browse files
ENH: Adding zoom and pan to colorbar
The zoom and pan funcitons change the vmin/vmax of the norm attached to the colorbar. The colorbar is rendered as an inset axis, but the event handler is implemented on the parent axis.
1 parent 464dcf6 commit 5a6c99b

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Colorbars now have pan and zoom functionality
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
Interactive plots with colorbars can now be zoomed and panned on
5+
the colorbar axis. This adjusts the *vmin* and *vmax* of the
6+
``ScalarMappable`` associated with the colorbar.

‎lib/matplotlib/colorbar.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import copy
3232
import logging
3333
import textwrap
34+
import types
3435

3536
import numpy as np
3637

@@ -1199,6 +1200,11 @@ def __init__(self, ax, mappable, **kwargs):
11991200
mappable.colorbar_cid = mappable.callbacksSM.connect(
12001201
'changed', self.update_normal)
12011202

1203+
# Handle interactiveness on the outer axis
1204+
for x in ["_get_view", "_set_view", "_set_view_from_bbox",
1205+
"start_pan", "end_pan", "drag_pan"]:
1206+
setattr(self.ax.outer_ax, x, getattr(self, x))
1207+
12021208
@_api.deprecated("3.3", alternative="update_normal")
12031209
def on_mappable_changed(self, mappable):
12041210
"""
@@ -1326,6 +1332,242 @@ def remove(self):
13261332
# use_gridspec was True
13271333
ax.set_subplotspec(subplotspec)
13281334

1335+
def _get_view(self):
1336+
"""
1337+
Save information required to reproduce the current view.
1338+
1339+
The colorbar is controlled by the norm's vmin and vmax
1340+
"""
1341+
return self.norm.vmin, self.norm.vmax
1342+
1343+
def _set_view(self, view):
1344+
"""
1345+
Apply a previously saved view.
1346+
1347+
This sets the vmin and vmax of the norm associated with
1348+
the scalar mappable. We then update the normal to redraw
1349+
the content that was updated.
1350+
"""
1351+
self.mappable.norm.vmin, self.mappable.norm.vmax = view
1352+
self.update_normal(self.mappable)
1353+
1354+
def _set_view_from_bbox(self, bbox, direction='in',
1355+
mode=None, twinx=False, twiny=False):
1356+
"""
1357+
Update view from a selection bbox.
1358+
1359+
The view of a colorbar is controlled by the vmin and vmax of the
1360+
norm. Updating the view from a bbox will change the vmin and vmax
1361+
to the extent of the selection when zooming in and scale the values
1362+
away from the selection when zooming out.
1363+
1364+
Parameters
1365+
----------
1366+
bbox : 4-tuple or 3 tuple
1367+
* If bbox is a 4 tuple, it is the selected bounding box limits,
1368+
in *display* coordinates.
1369+
* If bbox is a 3 tuple, it is an (xp, yp, scl) triple, where
1370+
(xp, yp) is the center of zooming and scl the scale factor to
1371+
zoom by.
1372+
direction : str
1373+
The direction to apply the bounding box.
1374+
* `'in'` - The bounding box describes the view directly, i.e.,
1375+
it zooms in.
1376+
* `'out'` - The bounding box describes the size to make the
1377+
existing view, i.e., it zooms out.
1378+
mode : str or None
1379+
The selection mode, whether to apply the bounding box in only the
1380+
`'x'` direction, `'y'` direction or both (`None`).
1381+
twinx : bool
1382+
Whether this axis is twinned in the *x*-direction.
1383+
twiny : bool
1384+
Whether this axis is twinned in the *y*-direction.
1385+
"""
1386+
ax = self.ax
1387+
if len(bbox) == 3:
1388+
Xmin, Xmax = ax.get_xlim()
1389+
Ymin, Ymax = ax.get_ylim()
1390+
1391+
xp, yp, scl = bbox # Zooming code
1392+
1393+
if scl == 0: # Should not happen
1394+
scl = 1.
1395+
1396+
if scl > 1:
1397+
direction = 'in'
1398+
else:
1399+
direction = 'out'
1400+
scl = 1/scl
1401+
1402+
# get the limits of the axes
1403+
tranD2C = ax.transData.transform
1404+
xmin, ymin = tranD2C((Xmin, Ymin))
1405+
xmax, ymax = tranD2C((Xmax, Ymax))
1406+
1407+
# set the range
1408+
xwidth = xmax - xmin
1409+
ywidth = ymax - ymin
1410+
xcen = (xmax + xmin)*.5
1411+
ycen = (ymax + ymin)*.5
1412+
xzc = (xp*(scl - 1) + xcen)/scl
1413+
yzc = (yp*(scl - 1) + ycen)/scl
1414+
1415+
bbox = [xzc - xwidth/2./scl, yzc - ywidth/2./scl,
1416+
xzc + xwidth/2./scl, yzc + ywidth/2./scl]
1417+
elif len(bbox) != 4:
1418+
# should be len 3 or 4 but nothing else
1419+
_api.warn_external(
1420+
"Warning in _set_view_from_bbox: bounding box is not a tuple "
1421+
"of length 3 or 4. Ignoring the view change.")
1422+
return
1423+
1424+
# Original limits.
1425+
xmin0, xmax0 = ax.get_xbound()
1426+
ymin0, ymax0 = ax.get_ybound()
1427+
# The zoom box in screen coords.
1428+
startx, starty, stopx, stopy = bbox
1429+
1430+
# Convert to data coords.
1431+
(startx, starty), (stopx, stopy) = ax.transData.inverted().transform(
1432+
[(startx, starty), (stopx, stopy)])
1433+
# Clip to axes limits.
1434+
xmin, xmax = np.clip(sorted([startx, stopx]), xmin0, xmax0)
1435+
ymin, ymax = np.clip(sorted([starty, stopy]), ymin0, ymax0)
1436+
# Don't double-zoom twinned axes or if zooming only the other axis.
1437+
if twinx or mode == "y":
1438+
xmin, xmax = xmin0, xmax0
1439+
if twiny or mode == "x":
1440+
ymin, ymax = ymin0, ymax0
1441+
1442+
if direction == "in":
1443+
new_xbound = xmin, xmax
1444+
new_ybound = ymin, ymax
1445+
1446+
elif direction == "out":
1447+
x_trf = ax.xaxis.get_transform()
1448+
sxmin0, sxmax0, sxmin, sxmax = x_trf.transform(
1449+
[xmin0, xmax0, xmin, xmax]) # To screen space.
1450+
factor = (sxmax0 - sxmin0) / (sxmax - sxmin) # Unzoom factor.
1451+
# Move original bounds away by
1452+
# (factor) x (distance between unzoom box and axes bbox).
1453+
sxmin1 = sxmin0 - factor * (sxmin - sxmin0)
1454+
sxmax1 = sxmax0 + factor * (sxmax0 - sxmax)
1455+
# And back to data space.
1456+
new_xbound = x_trf.inverted().transform([sxmin1, sxmax1])
1457+
1458+
y_trf = ax.yaxis.get_transform()
1459+
symin0, symax0, symin, symax = y_trf.transform(
1460+
[ymin0, ymax0, ymin, ymax])
1461+
factor = (symax0 - symin0) / (symax - symin)
1462+
symin1 = symin0 - factor * (symin - symin0)
1463+
symax1 = symax0 + factor * (symax0 - symax)
1464+
new_ybound = y_trf.inverted().transform([symin1, symax1])
1465+
1466+
norm = self.mappable.norm
1467+
if self.orientation == 'horizontal' and not twinx and mode != "y":
1468+
norm.vmin, norm.vmax = new_xbound
1469+
if self.orientation == 'vertical' and not twiny and mode != "x":
1470+
norm.vmin, norm.vmax = new_ybound
1471+
self.update_normal(self.mappable)
1472+
1473+
def start_pan(self, x, y, button):
1474+
"""
1475+
Called when a pan operation has started.
1476+
1477+
Parameters
1478+
----------
1479+
x, y : float
1480+
The mouse coordinates in display coords.
1481+
button : `.MouseButton`
1482+
The pressed mouse button.
1483+
"""
1484+
self._pan_start = types.SimpleNamespace(
1485+
lim=self.ax.viewLim.frozen(),
1486+
trans=self.ax.transData.frozen(),
1487+
trans_inverse=self.ax.transData.inverted().frozen(),
1488+
bbox=self.ax.bbox.frozen(),
1489+
x=x,
1490+
y=y)
1491+
1492+
def end_pan(self):
1493+
"""
1494+
Called when a pan operation completes (when the mouse button is up.)
1495+
"""
1496+
del self._pan_start
1497+
1498+
def drag_pan(self, button, key, x, y):
1499+
"""
1500+
Called when the mouse moves during a pan operation.
1501+
1502+
Parameters
1503+
----------
1504+
button : `.MouseButton`
1505+
The pressed mouse button.
1506+
key : str or None
1507+
The pressed key, if any.
1508+
x, y : float
1509+
The mouse coordinates in display coords.
1510+
"""
1511+
def format_deltas(key, dx, dy):
1512+
if key == 'control':
1513+
if abs(dx) > abs(dy):
1514+
dy = dx
1515+
else:
1516+
dx = dy
1517+
elif key == 'x':
1518+
dy = 0
1519+
elif key == 'y':
1520+
dx = 0
1521+
elif key == 'shift':
1522+
if 2 * abs(dx) < abs(dy):
1523+
dx = 0
1524+
elif 2 * abs(dy) < abs(dx):
1525+
dy = 0
1526+
elif abs(dx) > abs(dy):
1527+
dy = dy / abs(dy) * abs(dx)
1528+
else:
1529+
dx = dx / abs(dx) * abs(dy)
1530+
return dx, dy
1531+
1532+
p = self._pan_start
1533+
dx = x - p.x
1534+
dy = y - p.y
1535+
if dx == dy == 0:
1536+
return
1537+
if button == 1:
1538+
dx, dy = format_deltas(key, dx, dy)
1539+
result = p.bbox.translated(-dx, -dy).transformed(p.trans_inverse)
1540+
elif button == 3:
1541+
try:
1542+
dx = -dx / self.ax.bbox.width
1543+
dy = -dy / self.ax.bbox.height
1544+
dx, dy = format_deltas(key, dx, dy)
1545+
if self.ax.get_aspect() != 'auto':
1546+
dx = dy = 0.5 * (dx + dy)
1547+
alpha = np.power(10.0, (dx, dy))
1548+
start = np.array([p.x, p.y])
1549+
oldpoints = p.lim.transformed(p.trans)
1550+
newpoints = start + alpha * (oldpoints - start)
1551+
result = (mtransforms.Bbox(newpoints)
1552+
.transformed(p.trans_inverse))
1553+
except OverflowError:
1554+
_api._warn_external('Overflow while panning')
1555+
return
1556+
else:
1557+
return
1558+
1559+
valid = np.isfinite(result.transformed(p.trans))
1560+
points = result.get_points().astype(object)
1561+
# Just ignore invalid limits (typically, underflow in log-scale).
1562+
points[~valid] = None
1563+
1564+
norm = self.mappable.norm
1565+
if self.orientation == 'horizontal':
1566+
norm.vmin, norm.vmax = points[:, 0]
1567+
if self.orientation == 'vertical':
1568+
norm.vmin, norm.vmax = points[:, 1]
1569+
self.update_normal(self.mappable)
1570+
13291571

13301572
def _normalize_location_orientation(location, orientation):
13311573
if location is None:

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /