|
31 | 31 | import copy
|
32 | 32 | import logging
|
33 | 33 | import textwrap
|
| 34 | +import types |
34 | 35 |
|
35 | 36 | import numpy as np
|
36 | 37 |
|
@@ -1199,6 +1200,11 @@ def __init__(self, ax, mappable, **kwargs):
|
1199 | 1200 | mappable.colorbar_cid = mappable.callbacksSM.connect(
|
1200 | 1201 | 'changed', self.update_normal)
|
1201 | 1202 |
|
| 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 | + |
1202 | 1208 | @_api.deprecated("3.3", alternative="update_normal")
|
1203 | 1209 | def on_mappable_changed(self, mappable):
|
1204 | 1210 | """
|
@@ -1326,6 +1332,242 @@ def remove(self):
|
1326 | 1332 | # use_gridspec was True
|
1327 | 1333 | ax.set_subplotspec(subplotspec)
|
1328 | 1334 |
|
| 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 | + |
1329 | 1571 |
|
1330 | 1572 | def _normalize_location_orientation(location, orientation):
|
1331 | 1573 | if location is None:
|
|
0 commit comments