|
13 | 13 |
|
14 | 14 | from __future__ import annotations |
15 | 15 |
|
| 16 | +import re |
16 | 17 | from typing import TYPE_CHECKING, Any |
17 | 18 |
|
18 | 19 | import numpy as np |
|
26 | 27 | from plotpy.coords import canvas_to_axes |
27 | 28 | from plotpy.interfaces import IBasePlotItem, ISerializableType, IShapeItemType |
28 | 29 | from plotpy.items.curve.base import CurveItem |
| 30 | +from plotpy.items.shape.range import XRangeSelection |
29 | 31 | from plotpy.styles.label import LabelParam |
30 | 32 |
|
31 | 33 | if TYPE_CHECKING: |
|
38 | 40 | from qtpy.QtGui import QBrush, QPainter, QPen, QTextDocument |
39 | 41 |
|
40 | 42 | from plotpy.interfaces import IItemType |
41 | | - from plotpy.items import ImageItem, RectangleShape, XRangeSelection, YRangeSelection |
| 43 | + from plotpy.items import ImageItem, RectangleShape, YRangeSelection |
42 | 44 | from plotpy.styles.base import ItemParameters |
43 | 45 |
|
44 | 46 | ANCHORS = { |
@@ -910,6 +912,85 @@ def get_text(self) -> str: |
910 | 912 | """Return the text to be displayed""" |
911 | 913 | return "" |
912 | 914 |
|
| 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 | + |
913 | 994 |
|
914 | 995 | class RangeInfo(ObjectInfo): |
915 | 996 | """ObjectInfo handling `XRangeSelection` or `YRangeSelection` |
@@ -949,7 +1030,29 @@ def get_text(self) -> str: |
949 | 1030 | v0, v1 = self.range.get_range() |
950 | 1031 | v = 0.5 * (v0 + v1) |
951 | 1032 | 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 |
953 | 1056 |
|
954 | 1057 |
|
955 | 1058 | class XRangeComputation(ObjectInfo): |
@@ -1008,7 +1111,34 @@ def get_text(self) -> str: |
1008 | 1111 | vectors.append(np.array([np.nan])) |
1009 | 1112 | else: |
1010 | 1113 | 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 |
1012 | 1142 |
|
1013 | 1143 |
|
1014 | 1144 | RangeComputation = XRangeComputation # For backward compatibility |
|
0 commit comments