Skip to content

Commit 681aa19

Browse files
committed
Add datetime coordinate formatting for cursor and stats tools
- Cursor tools (VCursor, HCursor, XCursor) now display datetime coordinates - CurveStatsTool shows datetime-formatted X values - Refactored datetime formatting to eliminate code duplication - Added BasePlot.format_coordinate_value() for consistent formatting
1 parent dc55f6d commit 681aa19

File tree

12 files changed

+1428
-41
lines changed

12 files changed

+1428
-41
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
4343
* Configurable label rotation and spacing for optimal display
4444
* Example: `plot.set_axis_datetime("bottom", format="%H:%M:%S")` for time-only display
4545
* Example: `plot.set_axis_limits_from_datetime("bottom", dt1, dt2)` to zoom to a specific time range
46+
* Added `"datetime"` as a valid scale type (alongside `"lin"` and `"log"`) for axis configuration
47+
* Added datetime coordinate formatting support throughout PlotPy:
48+
* Cursor tools (`VCursorTool`, `HCursorTool`, `XCursorTool`) now display datetime-formatted X/Y coordinates
49+
* `CurveStatsTool` now displays datetime-formatted X coordinates for statistical computations
50+
* Marker labels automatically format coordinates as datetime when axis uses datetime scale
51+
* Coordinate display in the plot canvas now shows datetime format when appropriate
52+
* Refactored `ObjectInfo` base class to provide shared datetime formatting methods for code reuse
4653

4754
🧹 API cleanup: removed deprecated update methods (use `update_item` instead)
4855

plotpy/items/curve/base.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import guidata.io
2727

2828
from plotpy.interfaces import IItemType
29+
from plotpy.plot import BasePlot
2930
from plotpy.styles.base import ItemParameters
3031

3132

@@ -503,7 +504,16 @@ def get_coordinates_label(self, x: float, y: float) -> str:
503504
str: Coordinates label
504505
"""
505506
title = self.title().text()
506-
return f"{title}:<br>x = {x:g}<br>y = {y:g}"
507+
508+
# Use the plot's consolidated coordinate formatting method
509+
plot: BasePlot = self.plot()
510+
if plot is not None:
511+
# Format using the plot's consolidated coordinate formatting method
512+
return plot.format_coordinate_values(
513+
x, y, self.xAxis(), self.yAxis(), title
514+
)
515+
else:
516+
return f"{title}:<br>x = {x:g}<br>y = {y:g}"
507517

508518
def get_closest_x(self, xc: float) -> tuple[float, float]:
509519
"""

plotpy/items/label.py

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from __future__ import annotations
1515

16+
import re
1617
from typing import TYPE_CHECKING, Any
1718

1819
import numpy as np
@@ -26,6 +27,7 @@
2627
from plotpy.coords import canvas_to_axes
2728
from plotpy.interfaces import IBasePlotItem, ISerializableType, IShapeItemType
2829
from plotpy.items.curve.base import CurveItem
30+
from plotpy.items.shape.range import XRangeSelection
2931
from plotpy.styles.label import LabelParam
3032

3133
if TYPE_CHECKING:
@@ -38,7 +40,7 @@
3840
from qtpy.QtGui import QBrush, QPainter, QPen, QTextDocument
3941

4042
from plotpy.interfaces import IItemType
41-
from plotpy.items import ImageItem, RectangleShape, XRangeSelection, YRangeSelection
43+
from plotpy.items import ImageItem, RectangleShape, YRangeSelection
4244
from plotpy.styles.base import ItemParameters
4345

4446
ANCHORS = {
@@ -910,6 +912,85 @@ def get_text(self) -> str:
910912
"""Return the text to be displayed"""
911913
return ""
912914

915+
@staticmethod
916+
def _replace_format_specifiers(label: str, datetime_positions: list[int]) -> str:
917+
"""Replace numeric format specifiers with %s for datetime positions
918+
919+
Args:
920+
label: The label format string
921+
datetime_positions: List of positions that should use %s
922+
923+
Returns:
924+
Modified label string
925+
"""
926+
# Find all format specifiers
927+
pattern = r"%[+-]?(?:\d+)?(?:\.\d+)?[hlL]?[diouxXeEfFgGcrs%]"
928+
matches = list(re.finditer(pattern, label))
929+
930+
# Replace numeric formats with %s for datetime positions
931+
result = label
932+
offset = 0
933+
spec_index = 0
934+
935+
for match in matches:
936+
if match.group() == "%%": # Skip escaped %
937+
continue
938+
939+
if spec_index in datetime_positions:
940+
# Replace this format specifier with %s
941+
old_spec = match.group()
942+
new_spec = "%s"
943+
start = match.start() + offset
944+
end = match.end() + offset
945+
result = result[:start] + new_spec + result[end:]
946+
offset += len(new_spec) - len(old_spec)
947+
948+
spec_index += 1
949+
950+
return result
951+
952+
@staticmethod
953+
def _format_values_for_datetime_axis(
954+
plot, axis_id: int, label: str, result: tuple | list, value_checker: Callable
955+
) -> tuple[str, tuple]:
956+
"""Format values as datetime strings if they match the value_checker criterion
957+
958+
Args:
959+
plot: The plot object
960+
axis_id: The axis ID to check for datetime scale
961+
label: The label format string
962+
result: The result tuple/list from the function
963+
value_checker: Function that takes (value, index) and returns True if the
964+
value should be formatted as datetime
965+
966+
Returns:
967+
Tuple of (adjusted_label, formatted_result)
968+
"""
969+
# Check if axis is datetime
970+
if plot.get_axis_scale(axis_id) != "datetime":
971+
return label, result
972+
973+
# Convert result to list for modification
974+
if not isinstance(result, (list, tuple)):
975+
result = [result]
976+
else:
977+
result = list(result)
978+
979+
# Find positions that need datetime formatting
980+
datetime_positions = []
981+
for i, val in enumerate(result):
982+
if isinstance(val, (int, float, np.number)) and not np.isnan(val):
983+
if value_checker(val, i):
984+
datetime_positions.append(i)
985+
# Format as datetime string
986+
result[i] = plot.format_coordinate_value(val, axis_id)
987+
988+
# Replace format specifiers for datetime positions in the label
989+
if datetime_positions:
990+
label = ObjectInfo._replace_format_specifiers(label, datetime_positions)
991+
992+
return label, tuple(result)
993+
913994

914995
class RangeInfo(ObjectInfo):
915996
"""ObjectInfo handling `XRangeSelection` or `YRangeSelection`
@@ -949,7 +1030,29 @@ def get_text(self) -> str:
9491030
v0, v1 = self.range.get_range()
9501031
v = 0.5 * (v0 + v1)
9511032
dv = 0.5 * (v1 - v0)
952-
return self.label % self.func(v, dv)
1033+
1034+
# Get result from function
1035+
result = self.func(v, dv)
1036+
1037+
# Check if we need to format as datetime
1038+
plot = self.range.plot()
1039+
if plot is not None:
1040+
# Determine which axis this range is on
1041+
if isinstance(self.range, XRangeSelection):
1042+
axis_id = self.range.xAxis()
1043+
else:
1044+
axis_id = self.range.yAxis()
1045+
1046+
# Value checker: returns True if value matches v or dv (likely datetime)
1047+
def is_range_value(val, _idx):
1048+
return abs(val - v) < 1e-10 or abs(abs(val) - abs(dv)) < 1e-10
1049+
1050+
label, result = self._format_values_for_datetime_axis(
1051+
plot, axis_id, self.label, result, is_range_value
1052+
)
1053+
return label % result
1054+
1055+
return self.label % result
9531056

9541057

9551058
class XRangeComputation(ObjectInfo):
@@ -1008,7 +1111,34 @@ def get_text(self) -> str:
10081111
vectors.append(np.array([np.nan]))
10091112
else:
10101113
vectors.append(vector[i0:i1])
1011-
return self.label % self.func(*vectors)
1114+
1115+
# Get result from function
1116+
result = self.func(*vectors)
1117+
1118+
# Format values considering datetime axis
1119+
plot = self.curve.plot()
1120+
if plot is not None:
1121+
# Check if x-axis is datetime
1122+
x_axis = self.curve.xAxis()
1123+
1124+
# Compute x-statistics to identify x-coordinates in result
1125+
x_vector = vectors[0]
1126+
x_stats = set()
1127+
if x_vector is not None and len(x_vector) > 0:
1128+
x_stats = {x_vector.min(), x_vector.max(), x_vector.mean()}
1129+
1130+
# Value checker: returns True if value matches an x-statistic
1131+
def is_x_coordinate(val, _idx):
1132+
return any(
1133+
abs(val - stat) < 1e-10 for stat in x_stats if not np.isnan(stat)
1134+
)
1135+
1136+
label, result = self._format_values_for_datetime_axis(
1137+
plot, x_axis, self.label, result, is_x_coordinate
1138+
)
1139+
return label % result
1140+
1141+
return self.label % result
10121142

10131143

10141144
RangeComputation = XRangeComputation # For backward compatibility

plotpy/items/shape/marker.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from qtpy.QtGui import QPainter
2727

2828
from plotpy.interfaces import IItemType
29+
from plotpy.plot import BasePlot
2930
from plotpy.styles.base import ItemParameters
3031

3132

@@ -510,16 +511,41 @@ def invalidate_plot(self) -> None:
510511
def update_label(self) -> None:
511512
"""Update label"""
512513
x, y = self.xValue(), self.yValue()
514+
plot: BasePlot = self.plot()
513515
if self.label_cb:
514516
label = self.label_cb(x, y)
515517
if label is None:
516518
return
517519
elif self.is_vertical():
518-
label = f"x = {x:g}"
520+
# Format x-coordinate considering datetime axis
521+
if plot is not None and plot.get_axis_scale(self.xAxis()) == "datetime":
522+
x_formatted = plot.format_coordinate_value(x, self.xAxis())
523+
label = f"x = {x_formatted}"
524+
else:
525+
label = f"x = {x:g}"
519526
elif self.is_horizontal():
520-
label = f"y = {y:g}"
527+
# Format y-coordinate considering datetime axis
528+
if plot is not None and plot.get_axis_scale(self.yAxis()) == "datetime":
529+
y_formatted = plot.format_coordinate_value(y, self.yAxis())
530+
label = f"y = {y_formatted}"
531+
else:
532+
label = f"y = {y:g}"
521533
else:
522-
label = f"x = {x:g}<br>y = {y:g}"
534+
# Format both coordinates considering datetime axes
535+
if plot is not None:
536+
x_formatted = x
537+
y_formatted = y
538+
if plot.get_axis_scale(self.xAxis()) == "datetime":
539+
x_formatted = plot.format_coordinate_value(x, self.xAxis())
540+
else:
541+
x_formatted = f"{x:g}"
542+
if plot.get_axis_scale(self.yAxis()) == "datetime":
543+
y_formatted = plot.format_coordinate_value(y, self.yAxis())
544+
else:
545+
y_formatted = f"{y:g}"
546+
label = f"x = {x_formatted}<br>y = {y_formatted}"
547+
else:
548+
label = f"x = {x:g}<br>y = {y:g}"
523549
text = self.label()
524550
text.setText(label)
525551
self.setLabel(text)

plotpy/locale/fr/LC_MESSAGES/plotpy.po

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ msgid ""
66
msgstr ""
77
"Project-Id-Version: plotpy 2.7.4\n"
88
"Report-Msgid-Bugs-To: p.raybaut@codra.fr\n"
9-
"POT-Creation-Date: 2025-07-27 15:03+0200\n"
9+
"POT-Creation-Date: 2025-10-08 11:19+0200\n"
1010
"PO-Revision-Date: 2025-06-02 11:14+0200\n"
1111
"Last-Translator: Christophe Debonnel <c.debonnel@codra.fr>\n"
1212
"Language: fr\n"
@@ -356,15 +356,18 @@ msgstr "Police du titre"
356356
msgid "Values font"
357357
msgstr "Police des valeurs"
358358

359+
msgid "Scale"
360+
msgstr "Échelle"
361+
359362
msgid "linear"
360363
msgstr "linéaire"
361364

365+
msgid "date/time"
366+
msgstr "date/heure"
367+
362368
msgid "logarithmic"
363369
msgstr "logarithmique"
364370

365-
msgid "Scale"
366-
msgstr "Échelle"
367-
368371
msgid "Lower axis limit"
369372
msgstr "Borne inférieure de l'axe"
370373

0 commit comments

Comments
 (0)