Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1import numpy as np 

2 

3import matplotlib 

4from matplotlib import cbook, docstring, rcParams 

5from matplotlib.artist import allow_rasterization 

6import matplotlib.cbook as cbook 

7import matplotlib.transforms as mtransforms 

8import matplotlib.patches as mpatches 

9import matplotlib.path as mpath 

10 

11 

12class Spine(mpatches.Patch): 

13 """ 

14 An axis spine -- the line noting the data area boundaries 

15 

16 Spines are the lines connecting the axis tick marks and noting the 

17 boundaries of the data area. They can be placed at arbitrary 

18 positions. See function:`~matplotlib.spines.Spine.set_position` 

19 for more information. 

20 

21 The default position is ``('outward',0)``. 

22 

23 Spines are subclasses of class:`~matplotlib.patches.Patch`, and 

24 inherit much of their behavior. 

25 

26 Spines draw a line, a circle, or an arc depending if 

27 function:`~matplotlib.spines.Spine.set_patch_line`, 

28 function:`~matplotlib.spines.Spine.set_patch_circle`, or 

29 function:`~matplotlib.spines.Spine.set_patch_arc` has been called. 

30 Line-like is the default. 

31 

32 """ 

33 def __str__(self): 

34 return "Spine" 

35 

36 @docstring.dedent_interpd 

37 def __init__(self, axes, spine_type, path, **kwargs): 

38 """ 

39 Parameters 

40 ---------- 

41 axes : `~matplotlib.axes.Axes` 

42 The `~.axes.Axes` instance containing the spine. 

43 spine_type : str 

44 The spine type. 

45 path : `~matplotlib.path.Path` 

46 The `.Path` instance used to draw the spine. 

47 

48 Other Parameters 

49 ---------------- 

50 **kwargs 

51 Valid keyword arguments are: 

52 

53 %(Patch)s 

54 """ 

55 super().__init__(**kwargs) 

56 self.axes = axes 

57 self.set_figure(self.axes.figure) 

58 self.spine_type = spine_type 

59 self.set_facecolor('none') 

60 self.set_edgecolor(rcParams['axes.edgecolor']) 

61 self.set_linewidth(rcParams['axes.linewidth']) 

62 self.set_capstyle('projecting') 

63 self.axis = None 

64 

65 self.set_zorder(2.5) 

66 self.set_transform(self.axes.transData) # default transform 

67 

68 self._bounds = None # default bounds 

69 self._smart_bounds = False # deprecated in 3.2 

70 

71 # Defer initial position determination. (Not much support for 

72 # non-rectangular axes is currently implemented, and this lets 

73 # them pass through the spines machinery without errors.) 

74 self._position = None 

75 cbook._check_isinstance(matplotlib.path.Path, path=path) 

76 self._path = path 

77 

78 # To support drawing both linear and circular spines, this 

79 # class implements Patch behavior three ways. If 

80 # self._patch_type == 'line', behave like a mpatches.PathPatch 

81 # instance. If self._patch_type == 'circle', behave like a 

82 # mpatches.Ellipse instance. If self._patch_type == 'arc', behave like 

83 # a mpatches.Arc instance. 

84 self._patch_type = 'line' 

85 

86 # Behavior copied from mpatches.Ellipse: 

87 # Note: This cannot be calculated until this is added to an Axes 

88 self._patch_transform = mtransforms.IdentityTransform() 

89 

90 @cbook.deprecated("3.2") 

91 def set_smart_bounds(self, value): 

92 """Set the spine and associated axis to have smart bounds.""" 

93 self._smart_bounds = value 

94 

95 # also set the axis if possible 

96 if self.spine_type in ('left', 'right'): 

97 self.axes.yaxis.set_smart_bounds(value) 

98 elif self.spine_type in ('top', 'bottom'): 

99 self.axes.xaxis.set_smart_bounds(value) 

100 self.stale = True 

101 

102 @cbook.deprecated("3.2") 

103 def get_smart_bounds(self): 

104 """Return whether the spine has smart bounds.""" 

105 return self._smart_bounds 

106 

107 def set_patch_arc(self, center, radius, theta1, theta2): 

108 """Set the spine to be arc-like.""" 

109 self._patch_type = 'arc' 

110 self._center = center 

111 self._width = radius * 2 

112 self._height = radius * 2 

113 self._theta1 = theta1 

114 self._theta2 = theta2 

115 self._path = mpath.Path.arc(theta1, theta2) 

116 # arc drawn on axes transform 

117 self.set_transform(self.axes.transAxes) 

118 self.stale = True 

119 

120 def set_patch_circle(self, center, radius): 

121 """Set the spine to be circular.""" 

122 self._patch_type = 'circle' 

123 self._center = center 

124 self._width = radius * 2 

125 self._height = radius * 2 

126 # circle drawn on axes transform 

127 self.set_transform(self.axes.transAxes) 

128 self.stale = True 

129 

130 def set_patch_line(self): 

131 """Set the spine to be linear.""" 

132 self._patch_type = 'line' 

133 self.stale = True 

134 

135 # Behavior copied from mpatches.Ellipse: 

136 def _recompute_transform(self): 

137 """ 

138 Notes 

139 ----- 

140 This cannot be called until after this has been added to an Axes, 

141 otherwise unit conversion will fail. This makes it very important to 

142 call the accessor method and not directly access the transformation 

143 member variable. 

144 """ 

145 assert self._patch_type in ('arc', 'circle') 

146 center = (self.convert_xunits(self._center[0]), 

147 self.convert_yunits(self._center[1])) 

148 width = self.convert_xunits(self._width) 

149 height = self.convert_yunits(self._height) 

150 self._patch_transform = mtransforms.Affine2D() \ 

151 .scale(width * 0.5, height * 0.5) \ 

152 .translate(*center) 

153 

154 def get_patch_transform(self): 

155 if self._patch_type in ('arc', 'circle'): 

156 self._recompute_transform() 

157 return self._patch_transform 

158 else: 

159 return super().get_patch_transform() 

160 

161 def get_window_extent(self, renderer=None): 

162 """ 

163 Return the window extent of the spines in display space, including 

164 padding for ticks (but not their labels) 

165 

166 See Also 

167 -------- 

168 matplotlib.axes.Axes.get_tightbbox 

169 matplotlib.axes.Axes.get_window_extent 

170 """ 

171 # make sure the location is updated so that transforms etc are correct: 

172 self._adjust_location() 

173 bb = super().get_window_extent(renderer=renderer) 

174 if self.axis is None: 

175 return bb 

176 bboxes = [bb] 

177 tickstocheck = [self.axis.majorTicks[0]] 

178 if len(self.axis.minorTicks) > 1: 

179 # only pad for minor ticks if there are more than one 

180 # of them. There is always one... 

181 tickstocheck.append(self.axis.minorTicks[1]) 

182 for tick in tickstocheck: 

183 bb0 = bb.frozen() 

184 tickl = tick._size 

185 tickdir = tick._tickdir 

186 if tickdir == 'out': 

187 padout = 1 

188 padin = 0 

189 elif tickdir == 'in': 

190 padout = 0 

191 padin = 1 

192 else: 

193 padout = 0.5 

194 padin = 0.5 

195 padout = padout * tickl / 72 * self.figure.dpi 

196 padin = padin * tickl / 72 * self.figure.dpi 

197 

198 if tick.tick1line.get_visible(): 

199 if self.spine_type == 'left': 

200 bb0.x0 = bb0.x0 - padout 

201 bb0.x1 = bb0.x1 + padin 

202 elif self.spine_type == 'bottom': 

203 bb0.y0 = bb0.y0 - padout 

204 bb0.y1 = bb0.y1 + padin 

205 

206 if tick.tick2line.get_visible(): 

207 if self.spine_type == 'right': 

208 bb0.x1 = bb0.x1 + padout 

209 bb0.x0 = bb0.x0 - padin 

210 elif self.spine_type == 'top': 

211 bb0.y1 = bb0.y1 + padout 

212 bb0.y0 = bb0.y0 - padout 

213 bboxes.append(bb0) 

214 

215 return mtransforms.Bbox.union(bboxes) 

216 

217 def get_path(self): 

218 return self._path 

219 

220 def _ensure_position_is_set(self): 

221 if self._position is None: 

222 # default position 

223 self._position = ('outward', 0.0) # in points 

224 self.set_position(self._position) 

225 

226 def register_axis(self, axis): 

227 """Register an axis. 

228 

229 An axis should be registered with its corresponding spine from 

230 the Axes instance. This allows the spine to clear any axis 

231 properties when needed. 

232 """ 

233 self.axis = axis 

234 if self.axis is not None: 

235 self.axis.cla() 

236 self.stale = True 

237 

238 def cla(self): 

239 """Clear the current spine.""" 

240 self._position = None # clear position 

241 if self.axis is not None: 

242 self.axis.cla() 

243 

244 @cbook.deprecated("3.1") 

245 def is_frame_like(self): 

246 """Return True if directly on axes frame. 

247 

248 This is useful for determining if a spine is the edge of an 

249 old style MPL plot. If so, this function will return True. 

250 """ 

251 self._ensure_position_is_set() 

252 position = self._position 

253 if isinstance(position, str): 

254 if position == 'center': 

255 position = ('axes', 0.5) 

256 elif position == 'zero': 

257 position = ('data', 0) 

258 if len(position) != 2: 

259 raise ValueError("position should be 2-tuple") 

260 position_type, amount = position 

261 if position_type == 'outward' and amount == 0: 

262 return True 

263 else: 

264 return False 

265 

266 def _adjust_location(self): 

267 """Automatically set spine bounds to the view interval.""" 

268 

269 if self.spine_type == 'circle': 

270 return 

271 

272 if self._bounds is None: 

273 if self.spine_type in ('left', 'right'): 

274 low, high = self.axes.viewLim.intervaly 

275 elif self.spine_type in ('top', 'bottom'): 

276 low, high = self.axes.viewLim.intervalx 

277 else: 

278 raise ValueError('unknown spine spine_type: %s' % 

279 self.spine_type) 

280 

281 if self._smart_bounds: # deprecated in 3.2 

282 # attempt to set bounds in sophisticated way 

283 

284 # handle inverted limits 

285 viewlim_low, viewlim_high = sorted([low, high]) 

286 

287 if self.spine_type in ('left', 'right'): 

288 datalim_low, datalim_high = self.axes.dataLim.intervaly 

289 ticks = self.axes.get_yticks() 

290 elif self.spine_type in ('top', 'bottom'): 

291 datalim_low, datalim_high = self.axes.dataLim.intervalx 

292 ticks = self.axes.get_xticks() 

293 # handle inverted limits 

294 ticks = np.sort(ticks) 

295 datalim_low, datalim_high = sorted([datalim_low, datalim_high]) 

296 

297 if datalim_low < viewlim_low: 

298 # Data extends past view. Clip line to view. 

299 low = viewlim_low 

300 else: 

301 # Data ends before view ends. 

302 cond = (ticks <= datalim_low) & (ticks >= viewlim_low) 

303 tickvals = ticks[cond] 

304 if len(tickvals): 

305 # A tick is less than or equal to lowest data point. 

306 low = tickvals[-1] 

307 else: 

308 # No tick is available 

309 low = datalim_low 

310 low = max(low, viewlim_low) 

311 

312 if datalim_high > viewlim_high: 

313 # Data extends past view. Clip line to view. 

314 high = viewlim_high 

315 else: 

316 # Data ends before view ends. 

317 cond = (ticks >= datalim_high) & (ticks <= viewlim_high) 

318 tickvals = ticks[cond] 

319 if len(tickvals): 

320 # A tick is greater than or equal to highest data 

321 # point. 

322 high = tickvals[0] 

323 else: 

324 # No tick is available 

325 high = datalim_high 

326 high = min(high, viewlim_high) 

327 

328 else: 

329 low, high = self._bounds 

330 

331 if self._patch_type == 'arc': 

332 if self.spine_type in ('bottom', 'top'): 

333 try: 

334 direction = self.axes.get_theta_direction() 

335 except AttributeError: 

336 direction = 1 

337 try: 

338 offset = self.axes.get_theta_offset() 

339 except AttributeError: 

340 offset = 0 

341 low = low * direction + offset 

342 high = high * direction + offset 

343 if low > high: 

344 low, high = high, low 

345 

346 self._path = mpath.Path.arc(np.rad2deg(low), np.rad2deg(high)) 

347 

348 if self.spine_type == 'bottom': 

349 rmin, rmax = self.axes.viewLim.intervaly 

350 try: 

351 rorigin = self.axes.get_rorigin() 

352 except AttributeError: 

353 rorigin = rmin 

354 scaled_diameter = (rmin - rorigin) / (rmax - rorigin) 

355 self._height = scaled_diameter 

356 self._width = scaled_diameter 

357 

358 else: 

359 raise ValueError('unable to set bounds for spine "%s"' % 

360 self.spine_type) 

361 else: 

362 v1 = self._path.vertices 

363 assert v1.shape == (2, 2), 'unexpected vertices shape' 

364 if self.spine_type in ['left', 'right']: 

365 v1[0, 1] = low 

366 v1[1, 1] = high 

367 elif self.spine_type in ['bottom', 'top']: 

368 v1[0, 0] = low 

369 v1[1, 0] = high 

370 else: 

371 raise ValueError('unable to set bounds for spine "%s"' % 

372 self.spine_type) 

373 

374 @allow_rasterization 

375 def draw(self, renderer): 

376 self._adjust_location() 

377 ret = super().draw(renderer) 

378 self.stale = False 

379 return ret 

380 

381 def set_position(self, position): 

382 """Set the position of the spine. 

383 

384 Spine position is specified by a 2 tuple of (position type, 

385 amount). The position types are: 

386 

387 * 'outward' : place the spine out from the data area by the 

388 specified number of points. (Negative values specify placing the 

389 spine inward.) 

390 

391 * 'axes' : place the spine at the specified Axes coordinate (from 

392 0.0-1.0). 

393 

394 * 'data' : place the spine at the specified data coordinate. 

395 

396 Additionally, shorthand notations define a special positions: 

397 

398 * 'center' -> ('axes',0.5) 

399 * 'zero' -> ('data', 0.0) 

400 

401 """ 

402 if position in ('center', 'zero'): 

403 # special positions 

404 pass 

405 else: 

406 if len(position) != 2: 

407 raise ValueError("position should be 'center' or 2-tuple") 

408 if position[0] not in ['outward', 'axes', 'data']: 

409 raise ValueError("position[0] should be one of 'outward', " 

410 "'axes', or 'data' ") 

411 self._position = position 

412 

413 self.set_transform(self.get_spine_transform()) 

414 

415 if self.axis is not None: 

416 self.axis.reset_ticks() 

417 self.stale = True 

418 

419 def get_position(self): 

420 """Return the spine position.""" 

421 self._ensure_position_is_set() 

422 return self._position 

423 

424 def get_spine_transform(self): 

425 """Return the spine transform.""" 

426 self._ensure_position_is_set() 

427 

428 position = self._position 

429 if isinstance(position, str): 

430 if position == 'center': 

431 position = ('axes', 0.5) 

432 elif position == 'zero': 

433 position = ('data', 0) 

434 assert len(position) == 2, 'position should be 2-tuple' 

435 position_type, amount = position 

436 cbook._check_in_list(['axes', 'outward', 'data'], 

437 position_type=position_type) 

438 if self.spine_type in ['left', 'right']: 

439 base_transform = self.axes.get_yaxis_transform(which='grid') 

440 elif self.spine_type in ['top', 'bottom']: 

441 base_transform = self.axes.get_xaxis_transform(which='grid') 

442 else: 

443 raise ValueError(f'unknown spine spine_type: {self.spine_type!r}') 

444 

445 if position_type == 'outward': 

446 if amount == 0: # short circuit commonest case 

447 return base_transform 

448 else: 

449 offset_vec = {'left': (-1, 0), 'right': (1, 0), 

450 'bottom': (0, -1), 'top': (0, 1), 

451 }[self.spine_type] 

452 # calculate x and y offset in dots 

453 offset_dots = amount * np.array(offset_vec) / 72 

454 return (base_transform 

455 + mtransforms.ScaledTranslation( 

456 *offset_dots, self.figure.dpi_scale_trans)) 

457 elif position_type == 'axes': 

458 if self.spine_type in ['left', 'right']: 

459 # keep y unchanged, fix x at amount 

460 return (mtransforms.Affine2D.from_values(0, 0, 0, 1, amount, 0) 

461 + base_transform) 

462 elif self.spine_type in ['bottom', 'top']: 

463 # keep x unchanged, fix y at amount 

464 return (mtransforms.Affine2D.from_values(1, 0, 0, 0, 0, amount) 

465 + base_transform) 

466 elif position_type == 'data': 

467 if self.spine_type in ('right', 'top'): 

468 # The right and top spines have a default position of 1 in 

469 # axes coordinates. When specifying the position in data 

470 # coordinates, we need to calculate the position relative to 0. 

471 amount -= 1 

472 if self.spine_type in ('left', 'right'): 

473 return mtransforms.blended_transform_factory( 

474 mtransforms.Affine2D().translate(amount, 0) 

475 + self.axes.transData, 

476 self.axes.transData) 

477 elif self.spine_type in ('bottom', 'top'): 

478 return mtransforms.blended_transform_factory( 

479 self.axes.transData, 

480 mtransforms.Affine2D().translate(0, amount) 

481 + self.axes.transData) 

482 

483 def set_bounds(self, low=None, high=None): 

484 """ 

485 Set the spine bounds. 

486 

487 Parameters 

488 ---------- 

489 low : float or None, optional 

490 The lower spine bound. Passing *None* leaves the limit unchanged. 

491 

492 The bounds may also be passed as the tuple (*low*, *high*) as the 

493 first positional argument. 

494 

495 .. ACCEPTS: (low: float, high: float) 

496 

497 high : float or None, optional 

498 The higher spine bound. Passing *None* leaves the limit unchanged. 

499 """ 

500 if self.spine_type == 'circle': 

501 raise ValueError( 

502 'set_bounds() method incompatible with circular spines') 

503 if high is None and np.iterable(low): 

504 low, high = low 

505 old_low, old_high = self.get_bounds() or (None, None) 

506 if low is None: 

507 low = old_low 

508 if high is None: 

509 high = old_high 

510 self._bounds = (low, high) 

511 self.stale = True 

512 

513 def get_bounds(self): 

514 """Get the bounds of the spine.""" 

515 return self._bounds 

516 

517 @classmethod 

518 def linear_spine(cls, axes, spine_type, **kwargs): 

519 """ 

520 Returns a linear `Spine`. 

521 """ 

522 # all values of 0.999 get replaced upon call to set_bounds() 

523 if spine_type == 'left': 

524 path = mpath.Path([(0.0, 0.999), (0.0, 0.999)]) 

525 elif spine_type == 'right': 

526 path = mpath.Path([(1.0, 0.999), (1.0, 0.999)]) 

527 elif spine_type == 'bottom': 

528 path = mpath.Path([(0.999, 0.0), (0.999, 0.0)]) 

529 elif spine_type == 'top': 

530 path = mpath.Path([(0.999, 1.0), (0.999, 1.0)]) 

531 else: 

532 raise ValueError('unable to make path for spine "%s"' % spine_type) 

533 result = cls(axes, spine_type, path, **kwargs) 

534 result.set_visible(rcParams['axes.spines.{0}'.format(spine_type)]) 

535 

536 return result 

537 

538 @classmethod 

539 def arc_spine(cls, axes, spine_type, center, radius, theta1, theta2, 

540 **kwargs): 

541 """ 

542 Returns an arc `Spine`. 

543 """ 

544 path = mpath.Path.arc(theta1, theta2) 

545 result = cls(axes, spine_type, path, **kwargs) 

546 result.set_patch_arc(center, radius, theta1, theta2) 

547 return result 

548 

549 @classmethod 

550 def circular_spine(cls, axes, center, radius, **kwargs): 

551 """ 

552 Returns a circular `Spine`. 

553 """ 

554 path = mpath.Path.unit_circle() 

555 spine_type = 'circle' 

556 result = cls(axes, spine_type, path, **kwargs) 

557 result.set_patch_circle(center, radius) 

558 return result 

559 

560 def set_color(self, c): 

561 """ 

562 Set the edgecolor. 

563 

564 Parameters 

565 ---------- 

566 c : color 

567 

568 Notes 

569 ----- 

570 This method does not modify the facecolor (which defaults to "none"), 

571 unlike the `Patch.set_color` method defined in the parent class. Use 

572 `Patch.set_facecolor` to set the facecolor. 

573 """ 

574 self.set_edgecolor(c) 

575 self.stale = True