Skip to content

Commit 94ac200

Browse files
Merge branch 'main' into build_error
2 parents a3ef0ee + 6e901fa commit 94ac200

8 files changed

+124
-18
lines changed

.pre-commit-config.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/asottile/setup-cfg-fmt
3-
rev: v2.7.0
3+
rev: v2.8.0
44
hooks:
55
- id: setup-cfg-fmt
66
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -13,7 +13,7 @@ repos:
1313
hooks:
1414
- id: mypy
1515
- repo: https://github.com/astral-sh/ruff-pre-commit
16-
rev: v0.9.9
16+
rev: v0.11.2
1717
hooks:
1818
- id: ruff
1919
args: ["--fix", "--show-fixes"]
201 Bytes
Loading
Loading
13.4 KB
Loading

labellines/core.py

+50-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import warnings
22
from typing import Optional, Union
3-
3+
from datetime import timedelta
44
import matplotlib.pyplot as plt
55
import numpy as np
66
from datetime import datetime
77
from matplotlib.container import ErrorbarContainer
8-
from matplotlib.dates import DateConverter, num2date, _SwitchableDateConverter
8+
from matplotlib.dates import (
9+
_SwitchableDateConverter,
10+
ConciseDateConverter,
11+
DateConverter,
12+
num2date,
13+
)
914
from matplotlib.lines import Line2D
1015
from more_itertools import always_iterable
1116

@@ -20,6 +25,8 @@ def labelLine(
2025
label: Optional[str] = None,
2126
align: Optional[bool] = None,
2227
drop_label: bool = False,
28+
xoffset: float = 0,
29+
xoffset_logspace: bool = False,
2330
yoffset: float = 0,
2431
yoffset_logspace: bool = False,
2532
outline_color: str = "auto",
@@ -44,6 +51,11 @@ def labelLine(
4451
drop_label : bool, optional
4552
If True, the label is consumed by the function so that subsequent
4653
calls to e.g. legend do not use it anymore.
54+
xoffset : double, optional
55+
Space to add to label's x position
56+
xoffset_logspace : bool, optional
57+
If True, then xoffset will be added to the label's x position in
58+
log10 space
4759
yoffset : double, optional
4860
Space to add to label's y position
4961
yoffset_logspace : bool, optional
@@ -66,6 +78,8 @@ def labelLine(
6678
x,
6779
label=label,
6880
align=align,
81+
xoffset=xoffset,
82+
xoffset_logspace=xoffset_logspace,
6983
yoffset=yoffset,
7084
yoffset_logspace=yoffset_logspace,
7185
outline_color=outline_color,
@@ -98,6 +112,7 @@ def labelLines(
98112
xvals: Optional[Union[tuple[float, float], list[float]]] = None,
99113
drop_label: bool = False,
100114
shrink_factor: float = 0.05,
115+
xoffsets: Union[float, list[float]] = 0,
101116
yoffsets: Union[float, list[float]] = 0,
102117
outline_color: str = "auto",
103118
outline_width: float = 5,
@@ -121,6 +136,9 @@ def labelLines(
121136
calls to e.g. legend do not use it anymore.
122137
shrink_factor : double, optional
123138
Relative distance from the edges to place closest labels. Defaults to 0.05.
139+
xoffsets : number or list, optional.
140+
Distance relative to the line when positioning the labels. If given a number,
141+
the same value is used for all lines.
124142
yoffsets : number or list, optional.
125143
Distance relative to the line when positioning the labels. If given a number,
126144
the same value is used for all lines.
@@ -187,18 +205,34 @@ def labelLines(
187205
if isinstance(xvals, tuple) and len(xvals) == 2:
188206
xmin, xmax = xvals
189207
xscale = ax.get_xscale()
208+
209+
# Convert datetime objects to numeric values for linspace/geomspace
210+
x_is_datetime = isinstance(xmin, datetime) or isinstance(xmax, datetime)
211+
if x_is_datetime:
212+
if not isinstance(xmin, datetime) or not isinstance(xmax, datetime):
213+
raise ValueError(
214+
f"Cannot mix datetime and numeric values in xvals: {xvals}"
215+
)
216+
xmin = plt.matplotlib.dates.date2num(xmin)
217+
xmax = plt.matplotlib.dates.date2num(xmax)
218+
190219
if xscale == "log":
191220
xvals = np.geomspace(xmin, xmax, len(all_lines) + 2)[1:-1]
192221
else:
193222
xvals = np.linspace(xmin, xmax, len(all_lines) + 2)[1:-1]
194223

224+
# Convert numeric values back to datetime objects
225+
if x_is_datetime:
226+
xvals = plt.matplotlib.dates.num2date(xvals)
227+
195228
# Build matrix line -> xvalue
196229
ok_matrix = np.zeros((len(all_lines), len(all_lines)), dtype=bool)
197230

198231
for i, line in enumerate(all_lines):
199232
xdata, _ = normalize_xydata(line)
200233
minx, maxx = min(xdata), max(xdata)
201234
for j, xv in enumerate(xvals): # type: ignore
235+
xv = line.convert_xunits(xv)
202236
ok_matrix[i, j] = minx < xv < maxx
203237

204238
# If some xvals do not fall in their corresponding line,
@@ -227,6 +261,8 @@ def labelLines(
227261
# Move xlabel if it is outside valid range
228262
xdata, _ = normalize_xydata(line)
229263
xmin, xmax = min(xdata), max(xdata)
264+
xv = line.convert_xunits(xv)
265+
230266
if not (xmin <= xv <= xmax):
231267
warnings.warn(
232268
(
@@ -246,7 +282,8 @@ def labelLines(
246282
converter = ax.xaxis.converter
247283
else:
248284
converter = ax.xaxis.get_converter()
249-
if isinstance(converter, (DateConverter, _SwitchableDateConverter)):
285+
time_classes = (_SwitchableDateConverter, DateConverter, ConciseDateConverter)
286+
if isinstance(converter, time_classes):
250287
xvals = [
251288
x # type: ignore
252289
if isinstance(x, (np.datetime64, datetime))
@@ -255,13 +292,21 @@ def labelLines(
255292
]
256293

257294
txts = []
295+
try:
296+
if isinstance(xoffsets, timedelta):
297+
xoffsets = [xoffsets] * len(all_lines) # type: ignore
298+
else:
299+
xoffsets = [float(xoffsets)] * len(all_lines) # type: ignore
300+
except TypeError:
301+
pass
258302
try:
259303
yoffsets = [float(yoffsets)] * len(all_lines) # type: ignore
260304
except TypeError:
261305
pass
262-
for line, x, yoffset, label in zip(
306+
for line, x, xoffset, yoffset, label in zip(
263307
lab_lines,
264308
xvals, # type: ignore
309+
xoffsets, # type: ignore
265310
yoffsets, # type: ignore
266311
labels,
267312
):
@@ -272,6 +317,7 @@ def labelLines(
272317
label=label,
273318
align=align,
274319
drop_label=drop_label,
320+
xoffset=xoffset,
275321
yoffset=yoffset,
276322
outline_color=outline_color,
277323
outline_width=outline_width,

labellines/line_label.py

+41-6
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from typing import TYPE_CHECKING
44

5+
import matplotlib.dates as mdates
56
import matplotlib.patheffects as patheffects
67
import numpy as np
8+
from datetime import timedelta
79
from matplotlib.text import Text
810

911
from .utils import normalize_xydata
@@ -35,6 +37,12 @@ class LineLabel(Text):
3537
_auto_align: bool
3638
"""Align text with the line (True) or parallel to x axis (False)"""
3739

40+
_xoffset: float
41+
"""An additional x offset for the label"""
42+
43+
_xoffset_logspace: bool
44+
"""Sets whether to treat _xoffset exponentially"""
45+
3846
_yoffset: float
3947
"""An additional y offset for the label"""
4048

@@ -56,6 +64,8 @@ def __init__(
5664
x: Position,
5765
label: Optional[str] = None,
5866
align: Optional[bool] = None,
67+
xoffset: float = 0,
68+
xoffset_logspace: bool = False,
5969
yoffset: float = 0,
6070
yoffset_logspace: bool = False,
6171
outline_color: Optional[Union[AutoLiteral, ColorLike]] = "auto",
@@ -76,6 +86,11 @@ def __init__(
7686
align : bool, optional
7787
If true, the label is parallel to the line, otherwise horizontal,
7888
by default True.
89+
xoffset : float, optional
90+
An additional x offset for the line label, by default 0.
91+
xoffset_logspace : bool, optional
92+
If true xoffset is applied exponentially to appear linear on a log-axis,
93+
by default False.
7994
yoffset : float, optional
8095
An additional y offset for the line label, by default 0.
8196
yoffset_logspace : bool, optional
@@ -108,6 +123,8 @@ def __init__(
108123
self._target_x = x
109124
self._ax = line.axes
110125
self._auto_align = align
126+
self._xoffset = xoffset
127+
self._xoffset_logspace = xoffset_logspace
111128
self._yoffset = yoffset
112129
self._yoffset_logspace = yoffset_logspace
113130
label = label or line.get_label()
@@ -162,19 +179,31 @@ def _update_anchors(self):
162179
x = self._line.convert_xunits(self._target_x)
163180
xdata, ydata = normalize_xydata(self._line)
164181

182+
# Convert timedelta to float if needed
183+
if isinstance(self._xoffset, timedelta):
184+
xoffset = mdates.date2num(self._xoffset + self._target_x) - x
185+
else:
186+
xoffset = self._xoffset
187+
188+
# Handle nan values
165189
mask = np.isfinite(ydata)
166190
if mask.sum() == 0:
167191
raise ValueError(f"The line {self._line} only contains nan!")
168192
xdata = xdata[mask]
169193
ydata = ydata[mask]
170194

171-
# Find the first line segment surrounding x
172-
for i, (xa, xb) in enumerate(zip(xdata[:-1], xdata[1:])):
173-
if min(xa, xb) <= x <= max(xa, xb):
174-
ya, yb = ydata[i], ydata[i + 1]
175-
break
195+
# If the valid data is a single point, then just use that point
196+
if len(xdata) == 1:
197+
xa, xb = xdata[0], xdata[0]
198+
ya, yb = ydata[0], ydata[0]
176199
else:
177-
raise ValueError("x label location is outside data range!")
200+
# Find the first line segment surrounding x
201+
for i, (xa, xb) in enumerate(zip(xdata[:-1], xdata[1:])):
202+
if min(xa, xb) <= x <= max(xa, xb):
203+
ya, yb = ydata[i], ydata[i + 1]
204+
break
205+
else:
206+
raise ValueError("x label location is outside data range!")
178207

179208
# Interpolate y position of label, (interp needs sorted data)
180209
if xa != xb:
@@ -185,6 +214,12 @@ def _update_anchors(self):
185214
else: # Vertical case
186215
y = (ya + yb) / 2
187216

217+
# Apply x offset
218+
if self._xoffset_logspace:
219+
x *= 10**xoffset
220+
else:
221+
x += xoffset
222+
188223
# Apply y offset
189224
if self._yoffset_logspace:
190225
y *= 10**self._yoffset

labellines/test.py

+30-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta
22

33
import matplotlib.pyplot as plt
44
import numpy as np
@@ -164,7 +164,20 @@ def test_dateaxis_advanced(setup_mpl):
164164
ax.xaxis.set_major_locator(DayLocator())
165165
ax.xaxis.set_major_formatter(DateFormatter("%Y-%m-%d"))
166166

167-
labelLines(ax.get_lines())
167+
labelLines(ax.get_lines(), xvals=(dates[0], dates[-1]))
168+
return plt.gcf()
169+
170+
171+
@pytest.mark.mpl_image_compare
172+
def test_dateaxis_timedelta_xoffset(setup_mpl):
173+
dates = [datetime(2018, 11, 1), datetime(2018, 11, 2), datetime(2018, 11, 3)]
174+
dt = timedelta(hours=12)
175+
176+
plt.plot(dates, [0, 1, 2], label="apples")
177+
plt.plot(dates, [3, 4, 5], label="banana")
178+
ax = plt.gca()
179+
180+
labelLines(ax.get_lines(), xoffsets=dt)
168181
return plt.gcf()
169182

170183

@@ -315,17 +328,22 @@ def test_label_datetime_plot(setup_mpl):
315328
return plt.gcf()
316329

317330

318-
def test_yoffset(setup_mpl):
331+
def test_xyoffset(setup_mpl):
319332
x = np.linspace(0, 1)
320333

321-
for yoffset in ([-0.5, 0.5], 1, 1.2): # try lists # try int # try float
334+
for offset in ([-0.5, 0.5], 1, 1.2): # try lists # try int # try float
322335
plt.clf()
323336
ax = plt.gca()
324337
ax.plot(x, np.sin(x) * 10, label=r"$\sin x$")
325338
ax.plot(x, np.cos(x) * 10, label=r"$\cos x$")
326339
lines = ax.get_lines()
327340
labelLines(
328-
lines, xvals=(0.2, 0.7), align=False, yoffsets=yoffset, bbox={"alpha": 0}
341+
lines,
342+
xvals=(0.2, 0.7),
343+
xoffsets=offset,
344+
yoffsets=offset,
345+
align=False,
346+
bbox={"alpha": 0},
329347
)
330348

331349

@@ -361,6 +379,13 @@ def test_auto_layout(setup_mpl):
361379
return plt.gcf()
362380

363381

382+
@pytest.mark.mpl_image_compare
383+
def test_single_point_line(setup_mpl):
384+
plt.plot(1, 1, label="x")
385+
labelLines(plt.gca().get_lines())
386+
return plt.gcf()
387+
388+
364389
def test_warning_out_of_range():
365390
X = [0, 1]
366391
Y = [0, 1]

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ test = [
3434
"matplotlib==3.10.1",
3535
"pytest-cov==6.0.0",
3636
"pytest-mpl==0.17.0",
37-
"pytest==8.3.4",
37+
"pytest==8.3.5",
3838
]
3939

4040
[project.urls]

0 commit comments

Comments
 (0)