Skip to content

Commit 85f139b

Browse files
committed
Prepare merge with feature/datetime branch
1 parent 5924fc8 commit 85f139b

File tree

3 files changed

+74
-204
lines changed

3 files changed

+74
-204
lines changed

CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
## Version 0.14.7
44

5-
- Fixed label positioning for rotated scale labels - labels are now properly positioned to account for their rotated bounding box, preventing overlap with scale backbone and ticks
6-
- Improved text rendering quality for rotated scale labels by enabling enhanced antialiasing and smooth rendering hints
7-
- Implemented hybrid rendering approach for rotated text: uses crisp direct rendering for 90-degree multiples (0°, 90°, 180°, 270°) and pixmap-based rendering for arbitrary angles to balance text quality and character alignment
5+
- Added support for `QwtDateTimeScaleDraw` and `QwtDateTimeScaleEngine` for datetime axis support (see `QwtDateTimeScaleDraw` and `QwtDateTimeScaleEngine` classes in the `qwt` module)
6+
- Improved font rendering for rotated text in `QwtPlainTextEngine.draw` method: disabled font hinting to avoid character misalignment in rotated text
87

98
## Version 0.14.6
109

qwt/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@
4848
QwtSeriesStore,
4949
)
5050
from qwt.scale_div import QwtScaleDiv # noqa: F401
51-
from qwt.scale_draw import QwtAbstractScaleDraw, QwtScaleDraw # noqa: F401
51+
from qwt.scale_draw import ( # noqa: F401
52+
QwtAbstractScaleDraw,
53+
QwtDateTimeScaleDraw,
54+
QwtScaleDraw,
55+
)
5256
from qwt.scale_engine import ( # noqa: F401
5357
QwtDateTimeScaleEngine,
5458
QwtLinearScaleEngine,

qwt/scale_draw.py

Lines changed: 67 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"""
2121

2222
import math
23+
from datetime import datetime
2324

2425
from qtpy.QtCore import (
2526
QLineF,
@@ -28,11 +29,10 @@
2829
QPointF,
2930
QRect,
3031
QRectF,
31-
QSize,
3232
Qt,
3333
qFuzzyCompare,
3434
)
35-
from qtpy.QtGui import QFont, QFontMetrics, QPainter, QPalette, QPixmap, QTransform
35+
from qtpy.QtGui import QFontMetrics, QPalette, QTransform
3636

3737
from qwt._math import qwtRadians
3838
from qwt.scale_div import QwtScaleDiv
@@ -761,57 +761,13 @@ def labelPosition(self, value):
761761
:param float value: Value
762762
:return: Position, where to paint a label
763763
"""
764-
# For backward compatibility, use a default font metrics approach
765-
# when rotation is involved
766-
if abs(self.labelRotation()) > 1e-6:
767-
# We need font information for proper rotation-aware positioning
768-
# Use QFontMetrics with a default font as fallback
769-
default_font = QFont()
770-
return self._labelPositionWithFont(default_font, value)
771-
772-
# Original implementation for non-rotated labels
773-
tval = self.scaleMap().transform(value)
774-
dist = self.spacing()
775-
if self.hasComponent(QwtAbstractScaleDraw.Backbone):
776-
dist += max([1, self.penWidth()])
777-
if self.hasComponent(QwtAbstractScaleDraw.Ticks):
778-
dist += self.tickLength(QwtScaleDiv.MajorTick)
779-
780-
px = 0
781-
py = 0
782-
if self.alignment() == self.RightScale:
783-
px = self.__data.pos.x() + dist
784-
py = tval
785-
elif self.alignment() == self.LeftScale:
786-
px = self.__data.pos.x() - dist
787-
py = tval
788-
elif self.alignment() == self.BottomScale:
789-
px = tval
790-
py = self.__data.pos.y() + dist
791-
elif self.alignment() == self.TopScale:
792-
px = tval
793-
py = self.__data.pos.y() - dist
794-
795-
return QPointF(px, py)
796-
797-
def _labelPositionWithFont(self, font, value):
798-
"""
799-
Find the position where to paint a label, taking rotation into account.
800-
801-
:param QFont font: Font used for the label
802-
:param float value: Value
803-
:return: Position where to paint a label
804-
"""
805764
tval = self.scaleMap().transform(value)
806765
dist = self.spacing()
807766
if self.hasComponent(QwtAbstractScaleDraw.Backbone):
808767
dist += max([1, self.penWidth()])
809768
if self.hasComponent(QwtAbstractScaleDraw.Ticks):
810769
dist += self.tickLength(QwtScaleDiv.MajorTick)
811770

812-
# Add rotation-aware offset
813-
dist += self._rotatedLabelOffset(font, value)
814-
815771
px = 0
816772
py = 0
817773
if self.alignment() == self.RightScale:
@@ -829,45 +785,6 @@ def _labelPositionWithFont(self, font, value):
829785

830786
return QPointF(px, py)
831787

832-
def _rotatedLabelOffset(self, font, value):
833-
"""
834-
Calculate the additional offset needed for a rotated label
835-
to avoid overlap with the scale backbone and ticks.
836-
837-
:param QFont font: Font used for the label
838-
:param float value: Value for which to calculate the offset
839-
:return: Additional offset distance
840-
"""
841-
rotation = self.labelRotation()
842-
if abs(rotation) < 1e-6: # No rotation, no additional offset needed
843-
return 0.0
844-
845-
lbl, labelSize = self.tickLabel(font, value)
846-
if lbl.isEmpty():
847-
return 0.0
848-
849-
# Convert rotation to radians
850-
angle = qwtRadians(rotation)
851-
cos_a = abs(math.cos(angle))
852-
sin_a = abs(math.sin(angle))
853-
854-
# Calculate the rotated bounding box dimensions
855-
width = labelSize.width()
856-
height = labelSize.height()
857-
rotated_width = width * cos_a + height * sin_a
858-
rotated_height = width * sin_a + height * cos_a
859-
860-
# Calculate additional offset based on scale alignment
861-
additional_offset = 0.0
862-
if self.alignment() in (self.LeftScale, self.RightScale):
863-
# For vertical scales, consider the horizontal extent of rotated label
864-
additional_offset = max(0, (rotated_width - width) * 0.5)
865-
else: # TopScale, BottomScale
866-
# For horizontal scales, consider the vertical extent of rotated label
867-
additional_offset = max(0, (rotated_height - height) * 0.5)
868-
869-
return additional_offset
870-
871788
def drawTick(self, painter, value, len_):
872789
"""
873790
Draw a tick
@@ -1041,120 +958,11 @@ def drawLabel(self, painter, value):
1041958
lbl, labelSize = self.tickLabel(painter.font(), value)
1042959
if lbl is None or lbl.isEmpty():
1043960
return
1044-
pos = self._labelPositionWithFont(painter.font(), value)
1045-
1046-
# For rotated text, choose rendering method based on rotation angle
1047-
rotation = self.labelRotation()
1048-
if abs(rotation) > 1e-6:
1049-
# Check if rotation is a multiple of 90 degrees (within tolerance)
1050-
normalized_rotation = rotation % 360
1051-
is_90_degree_multiple = (
1052-
abs(normalized_rotation) < 1e-6
1053-
or abs(normalized_rotation - 90) < 1e-6
1054-
or abs(normalized_rotation - 180) < 1e-6
1055-
or abs(normalized_rotation - 270) < 1e-6
1056-
or abs(normalized_rotation - 360) < 1e-6
1057-
)
1058-
1059-
if is_90_degree_multiple:
1060-
# Use direct rendering for 90-degree multiples (crisp)
1061-
transform = self.labelTransformation(pos, labelSize)
1062-
painter.save()
1063-
painter.setRenderHint(QPainter.TextAntialiasing, True)
1064-
painter.setWorldTransform(transform, True)
1065-
lbl.draw(painter, QRect(QPoint(0, 0), labelSize.toSize()))
1066-
painter.restore()
1067-
else:
1068-
# Use pixmap-based rendering for arbitrary angles (aligned but slightly blurry)
1069-
self._drawRotatedTextWithAlignment(
1070-
painter, lbl, pos, labelSize, rotation
1071-
)
1072-
else:
1073-
# Use standard approach for non-rotated text
1074-
transform = self.labelTransformation(pos, labelSize)
1075-
painter.save()
1076-
painter.setRenderHint(QPainter.TextAntialiasing, True)
1077-
painter.setWorldTransform(transform, True)
1078-
lbl.draw(painter, QRect(QPoint(0, 0), labelSize.toSize()))
1079-
painter.restore()
1080-
1081-
def _drawRotatedTextWithAlignment(self, painter, lbl, pos, labelSize, rotation):
1082-
"""
1083-
Draw rotated text with improved character alignment by rendering to an
1084-
intermediate pixmap and then rotating the pixmap instead of applying
1085-
transformation to text.
1086-
:param QPainter painter: Painter
1087-
:param QwtText lbl: Label text object
1088-
:param QPointF pos: Position where to paint the label
1089-
:param QSizeF labelSize: Size of the label
1090-
:param float rotation: Rotation angle in degrees
1091-
"""
1092-
# Create a pixmap to render the text without rotation first
1093-
text_size = labelSize.toSize()
1094-
if text_size.width() <= 0 or text_size.height() <= 0:
1095-
return
1096-
1097-
# Add some padding to prevent edge clipping
1098-
padding = 2
1099-
pixmap_size = text_size + QSize(padding * 2, padding * 2)
1100-
pixmap = QPixmap(pixmap_size)
1101-
pixmap.fill(Qt.transparent)
1102-
1103-
# Render the text to the pixmap without any rotation
1104-
pixmap_painter = QPainter(pixmap)
1105-
pixmap_painter.setRenderHint(QPainter.TextAntialiasing, True)
1106-
1107-
# Set font and color from QwtText
1108-
if lbl.testPaintAttribute(QwtText.PaintUsingTextFont):
1109-
pixmap_painter.setFont(lbl.font())
1110-
else:
1111-
pixmap_painter.setFont(painter.font())
1112-
1113-
if (
1114-
lbl.testPaintAttribute(QwtText.PaintUsingTextColor)
1115-
and lbl.color().isValid()
1116-
):
1117-
pixmap_painter.setPen(lbl.color())
1118-
else:
1119-
pixmap_painter.setPen(painter.pen())
1120-
1121-
# Draw text on pixmap without rotation for perfect character alignment
1122-
text_rect = QRect(padding, padding, text_size.width(), text_size.height())
1123-
lbl.draw(pixmap_painter, text_rect)
1124-
pixmap_painter.end()
1125-
1126-
# Now draw the pixmap with rotation
961+
pos = self.labelPosition(value)
962+
transform = self.labelTransformation(pos, labelSize)
1127963
painter.save()
1128-
1129-
# Get alignment flags for positioning
1130-
flags = self.labelAlignment()
1131-
if flags == 0:
1132-
flags = self.Flags[self.alignment()]
1133-
1134-
# Calculate alignment offsets
1135-
if flags & Qt.AlignLeft:
1136-
x_offset = -labelSize.width()
1137-
elif flags & Qt.AlignRight:
1138-
x_offset = 0.0
1139-
else:
1140-
x_offset = -(0.5 * labelSize.width())
1141-
1142-
if flags & Qt.AlignTop:
1143-
y_offset = -labelSize.height()
1144-
elif flags & Qt.AlignBottom:
1145-
y_offset = 0
1146-
else:
1147-
y_offset = -(0.5 * labelSize.height())
1148-
1149-
# Apply transformation and draw the pre-rendered pixmap
1150-
painter.translate(pos.x(), pos.y())
1151-
painter.rotate(rotation)
1152-
painter.translate(x_offset - padding, y_offset - padding)
1153-
1154-
# Use smooth pixmap transform for better quality
1155-
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
1156-
painter.drawPixmap(0, 0, pixmap)
1157-
964+
painter.setWorldTransform(transform, True)
965+
lbl.draw(painter, QRect(QPoint(0, 0), labelSize.toSize()))
1158966
painter.restore()
1159967

1160968
def boundingLabelRect(self, font, value):
@@ -1175,7 +983,7 @@ def boundingLabelRect(self, font, value):
1175983
lbl, labelSize = self.tickLabel(font, value)
1176984
if lbl.isEmpty():
1177985
return QRect()
1178-
pos = self._labelPositionWithFont(font, value)
986+
pos = self.labelPosition(value)
1179987
transform = self.labelTransformation(pos, labelSize)
1180988
return transform.mapRect(QRect(QPoint(0, 0), labelSize.toSize()))
1181989

@@ -1231,7 +1039,7 @@ def labelRect(self, font, value):
12311039
lbl, labelSize = self.tickLabel(font, value)
12321040
if not lbl or lbl.isEmpty():
12331041
return QRectF(0.0, 0.0, 0.0, 0.0)
1234-
pos = self._labelPositionWithFont(font, value)
1042+
pos = self.labelPosition(value)
12351043
transform = self.labelTransformation(pos, labelSize)
12361044
br = transform.mapRect(QRectF(QPointF(0, 0), labelSize))
12371045
br.translate(-pos.x(), -pos.y())
@@ -1411,3 +1219,62 @@ def updateMap(self):
14111219
sm.setPaintInterval(pos.y() + len_, pos.y())
14121220
else:
14131221
sm.setPaintInterval(pos.x(), pos.x() + len_)
1222+
1223+
1224+
class QwtDateTimeScaleDraw(QwtScaleDraw):
1225+
"""Scale draw for datetime axis
1226+
1227+
This class formats axis labels as date/time strings from Unix timestamps.
1228+
1229+
Args:
1230+
format: Format string for datetime display (default: "%Y-%m-%d %H:%M:%S").
1231+
Uses Python datetime.strftime() format codes.
1232+
spacing: Spacing between labels (default: 4)
1233+
1234+
Examples:
1235+
>>> # Create a datetime scale with default format
1236+
>>> scale = QwtDateTimeScaleDraw()
1237+
1238+
>>> # Create a datetime scale with custom format (time only)
1239+
>>> scale = QwtDateTimeScaleDraw(format="%H:%M:%S")
1240+
1241+
>>> # Create a datetime scale with date only
1242+
>>> scale = QwtDateTimeScaleDraw(format="%Y-%m-%d", spacing=4)
1243+
"""
1244+
1245+
def __init__(self, format: str = "%Y-%m-%d %H:%M:%S", spacing: int = 4) -> None:
1246+
super().__init__()
1247+
self._format = format
1248+
self.setSpacing(spacing)
1249+
1250+
def get_format(self) -> str:
1251+
"""Get the current datetime format string
1252+
1253+
Returns:
1254+
str: Format string
1255+
"""
1256+
return self._format
1257+
1258+
def set_format(self, format: str) -> None:
1259+
"""Set the datetime format string
1260+
1261+
Args:
1262+
format: Format string for datetime display
1263+
"""
1264+
self._format = format
1265+
1266+
def label(self, value: float) -> QwtText:
1267+
"""Convert a timestamp value to a formatted date/time label
1268+
1269+
Args:
1270+
value: Unix timestamp (seconds since epoch)
1271+
1272+
Returns:
1273+
QwtText: Formatted label
1274+
"""
1275+
try:
1276+
dt = datetime.fromtimestamp(value)
1277+
return QwtText(dt.strftime(self._format))
1278+
except (ValueError, OSError):
1279+
# Handle invalid timestamps
1280+
return QwtText("")

0 commit comments

Comments
 (0)