基于PyQt5实现可缩放带细分刻度的无限时间线技术求助
工时报告录入程序的时间刻度实现问题
我要开发一款类似time.graphics编辑器的工时报告录入程序,已经搞定了缩放、拖拽和伪无限区域功能,但两个核心需求还没实现:
- 随缩放动态添加年份间的细分刻度线
- 为每条刻度线添加精确到分钟的日期标注
之前试了只在屏幕边缘画刻度线,用屏幕宽度除以刻度间距的整数结果确定刻度数量,刻度间距跟着缩放比例变,但没法实现细分刻度和像time.graphics那样的无限缩放效果。现有代码如下:
import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QLabel from PyQt5.QtGui import QPainter, QPen from PyQt5.QtCore import Qt, QPoint class DrawingApp(QMainWindow): def __init__(self): super().__init__() #settings self.drawing_widget = self.DrawingWidget(self) self.setCentralWidget(self.drawing_widget) self.status_label = QLabel("", self) self.status_label.setGeometry(10, 10, 200, 20) self.setGeometry(100, 100, 800, 600) self.drawing_widget.installEventFilter(self) self.drawing_widget.setMouseTracking(True) self.dragging = False self.drag_start_position = QPoint() self.was_dragged = False def eventFilter(self, obj, event): '''all mouse events''' if obj is self.drawing_widget: if event.type() == event.MouseButtonPress and event.button() == Qt.LeftButton: self.dragging = True self.drag_start_position = event.pos() self.was_dragged = False return True if event.type() == event.MouseMove and self.dragging: delta = event.pos() - self.drag_start_position if delta.manhattanLength() > 0: self.drawing_widget.update_offset(delta) self.drag_start_position = event.pos() self.was_dragged = True return True if event.type() == event.MouseButtonRelease and event.button() == Qt.LeftButton: self.dragging = False if not self.was_dragged: self.status_label.setText(f"Click at: ({event.pos().x()}, {event.pos().y()})") return True if event.type() == event.Wheel: zoom_scale = 1.1 if event.angleDelta().y() > 0: self.drawing_widget.change_scale(zoom_scale, event.pos()) else: self.drawing_widget.change_scale(1 / zoom_scale, event.pos()) return True return super().eventFilter(obj, event) class DrawingWidget(QWidget): '''all drawing events''' def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(800, 600) self.offset = QPoint(0, 0) self.scale = 1.0 def update_offset(self, delta): self.offset += delta self.update() def change_scale(self, scale_factor, mouse_pos): '''change scale with align to cursors coordinate''' new_scale = self.scale * scale_factor if new_scale < 0.1: new_scale = 0.1 if new_scale > 100: new_scale = 100 cursor_offset = mouse_pos - self.rect().center() self.offset = QPoint( int(self.offset.x() * scale_factor - cursor_offset.x() * (scale_factor - 1)), #int(self.offset.y() * scale_factor - cursor_offset.y() * (scale_factor - 1)) ) self.scale = new_scale self.update() def paintEvent(self, event): painter = QPainter(self) offset_x, offset_y = self.offset.x(), self.offset.y() widget_width = self.width() widget_height = self.height() center = self.rect().center() scaled_size = 100 * self.scale sector_count = round(widget_width / scaled_size) offset_x %= scaled_size for i in range(sector_count + 2): # x_start = round(center.x() + offset_x) - scaled_size * i # x_end = round(center.x() + offset_x) + scaled_size * i painter.setPen(QPen(Qt.green, 2, Qt.SolidLine)) x = round(round(center.x() + offset_x) + scaled_size * i) painter.drawLine(x, 20, x, widget_height - 20) x = round(round(center.x() + offset_x) - scaled_size * i) painter.drawLine(x, 20, x, widget_height - 20) #sublines # if scaled_size > 200: # subgrid_interval = scaled_size / 4 # for j in range(1, 4): # sub_x1 = round(x_start + j * subgrid_interval) # sub_x2 = round(x_end - j * subgrid_interval) # painter.setPen(QPen(Qt.gray, 1, Qt.DashLine)) # painter.drawLine(sub_x1, 20, sub_x1, widget_height - 20) # painter.drawLine(sub_x2, 20, sub_x2, widget_height - 20) #dates # painter.setPen(Qt.black) # painter.drawText(x_start - 10, widget_height // 2 + 5, "date") # painter.drawText(x_end - 10, widget_height // 2 + 5, "date") if __name__ == '__main__': app = QApplication(sys.argv) main_window = DrawingApp() main_window.show() sys.exit(app.exec_())
解决方案
核心思路
- 像素-时间映射:定义基础单位,比如初始状态下1像素对应1天的秒数(86400秒),缩放时调整这个值——放大时减小单位(比如1像素对应3600秒=1小时),缩小时增大单位(比如1像素对应365天的秒数),这样就能把屏幕上的每个x坐标转换成具体的时间戳。
- 动态刻度层级:根据当前的像素-时间比例,自动选择显示的刻度精度:
- 当单位>31536000秒(1年):显示年刻度
- 单位>2592000秒(30天,近似月):显示月刻度
- 单位>86400秒(1天):显示日刻度
- 单位>3600秒(1小时):显示小时刻度
- 单位<=3600秒:显示分钟刻度
- 可见范围计算:根据屏幕左右边缘的x坐标,转换成对应的起始和结束时间,只绘制这个范围内的刻度,避免无效绘制。
- 细分刻度绘制:每个主刻度下,绘制下一级的细分刻度(比如年刻度下的月刻度,月刻度下的日刻度),用不同的线条样式区分主刻度和细分刻度。
修改后的完整代码
import sys from datetime import datetime, timedelta from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget, QLabel from PyQt5.QtGui import QPainter, QPen, QFont from PyQt5.QtCore import Qt, QPoint class DrawingApp(QMainWindow): def __init__(self): super().__init__() self.drawing_widget = self.DrawingWidget(self) self.setCentralWidget(self.drawing_widget) self.status_label = QLabel("", self) self.status_label.setGeometry(10, 10, 400, 20) self.setGeometry(100, 100, 800, 600) self.drawing_widget.installEventFilter(self) self.drawing_widget.setMouseTracking(True) self.dragging = False self.drag_start_position = QPoint() self.was_dragged = False def eventFilter(self, obj, event): if obj is self.drawing_widget: if event.type() == event.MouseButtonPress and event.button() == Qt.LeftButton: self.dragging = True self.drag_start_position = event.pos() self.was_dragged = False return True if event.type() == event.MouseMove and self.dragging: delta = event.pos() - self.drag_start_position if delta.manhattanLength() > 0: self.drawing_widget.update_offset(delta) self.drag_start_position = event.pos() self.was_dragged = True return True if event.type() == event.MouseButtonRelease and event.button() == Qt.LeftButton: self.dragging = False if not self.was_dragged: # 显示点击位置对应的日期 timestamp = self.drawing_widget.pixel_to_timestamp(event.pos().x()) date_str = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M") self.status_label.setText(f"Clicked at: {date_str}") return True if event.type() == event.Wheel: zoom_scale = 1.1 if event.angleDelta().y() > 0: self.drawing_widget.change_scale(zoom_scale, event.pos()) else: self.drawing_widget.change_scale(1 / zoom_scale, event.pos()) return True return super().eventFilter(obj, event) class DrawingWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(800, 600) self.offset = QPoint(0, 0) # 初始状态:1像素对应1天的秒数(86400秒) self.seconds_per_pixel = 86400.0 # 基准时间:2020-01-01 00:00:00 self.base_timestamp = datetime(2020, 1, 1).timestamp() def update_offset(self, delta): self.offset += delta self.update() def change_scale(self, scale_factor, mouse_pos): # 缩放时保持鼠标位置对应的时间不变 mouse_timestamp = self.pixel_to_timestamp(mouse_pos.x()) self.seconds_per_pixel /= scale_factor # 限制缩放范围,避免过细或过粗 min_seconds = 60 # 最小1像素对应1分钟 max_seconds = 31536000 * 10 # 最大1像素对应10年 self.seconds_per_pixel = max(min(self.seconds_per_pixel, max_seconds), min_seconds) # 调整偏移量,确保鼠标位置的时间不变 new_mouse_x = self.timestamp_to_pixel(mouse_timestamp) delta_x = mouse_pos.x() - new_mouse_x self.offset.setX(self.offset.x() + delta_x) self.update() def pixel_to_timestamp(self, x): # 将屏幕x坐标转换为时间戳 center_x = self.rect().center().x() relative_x = x - center_x - self.offset.x() return self.base_timestamp + relative_x * self.seconds_per_pixel def timestamp_to_pixel(self, timestamp): # 将时间戳转换为屏幕x坐标 center_x = self.rect().center().x() relative_x = (timestamp - self.base_timestamp) / self.seconds_per_pixel return int(center_x + self.offset.x() + relative_x) def get_tick_level(self): # 根据当前缩放比例确定刻度层级 if self.seconds_per_pixel > 31536000: # 1年 return "year" elif self.seconds_per_pixel > 2592000: # 30天(近似月) return "month" elif self.seconds_per_pixel > 86400: # 1天 return "day" elif self.seconds_per_pixel > 3600: # 1小时 return "hour" else: # 1分钟及以下 return "minute" def paintEvent(self, event): painter = QPainter(self) widget_width = self.width() widget_height = self.height() center_y = widget_height // 2 # 获取可见区域的时间范围 left_timestamp = self.pixel_to_timestamp(0) right_timestamp = self.pixel_to_timestamp(widget_width) # 确定当前刻度层级 tick_level = self.get_tick_level() # 主刻度样式 main_pen = QPen(Qt.green, 2, Qt.SolidLine) # 细分刻度样式 sub_pen = QPen(Qt.gray, 1, Qt.DashLine) # 文本样式 text_pen = QPen(Qt.black) font = QFont() font.setPointSize(8) painter.setFont(font) if tick_level == "year": # 主刻度:年 start_year = datetime.fromtimestamp(left_timestamp).year - 1 end_year = datetime.fromtimestamp(right_timestamp).year + 1 for year in range(start_year, end_year + 1): timestamp = datetime(year, 1, 1).timestamp() x = self.timestamp_to_pixel(timestamp) if 0 <= x <= widget_width: # 绘制主刻度线 painter.setPen(main_pen) painter.drawLine(x, 20, x, widget_height - 20) # 绘制年份文本 painter.setPen(text_pen) painter.drawText(x - 20, center_y + 15, str(year)) # 细分刻度:月(每年12个) for month in range(1, 12): sub_timestamp = datetime(year, month + 1, 1).timestamp() - 86400 sub_x = self.timestamp_to_pixel(sub_timestamp) if 0 <= sub_x <= widget_width: painter.setPen(sub_pen) painter.drawLine(sub_x, 50, sub_x, widget_height - 50) elif tick_level == "month": # 主刻度:月 start_date = datetime.fromtimestamp(left_timestamp).replace(day=1) - timedelta(days=30) end_date = datetime.fromtimestamp(right_timestamp).replace(day=1) + timedelta(days=60) current_date = start_date while current_date <= end_date: timestamp = current_date.timestamp() x = self.timestamp_to_pixel(timestamp) if 0 <= x <= widget_width: painter.setPen(main_pen) painter.drawLine(x, 20, x, widget_height - 20) painter.setPen(text_pen) painter.drawText(x - 20, center_y + 15, current_date.strftime("%Y-%m")) # 细分刻度:日(每月近似30个) for day in range(1, 30): sub_date = current_date + timedelta(days=day) sub_timestamp = sub_date.timestamp() sub_x = self.timestamp_to_pixel(sub_timestamp) if 0 <= sub_x <= widget_width: painter.setPen(sub_pen) painter.drawLine(sub_x, 50, sub_x, widget_height - 50) current_date += timedelta(days=30) elif tick_level == "day": # 主刻度:日 start_date = datetime.fromtimestamp(left_timestamp).replace(hour=0, minute=0, second=0) - timedelta(days=1) end_date = datetime.fromtimestamp(right_timestamp).replace(hour=0, minute=0, second=0) + timedelta(days=2) current_date = start_date while current_date <= end_date: timestamp = current_date.timestamp() x = self.timestamp_to_pixel(timestamp) if 0 <= x <= widget_width: painter.setPen(main_pen) painter.drawLine(x, 20, x, widget_height - 20) painter.setPen(text_pen) painter.drawText(x - 25, center_y + 15, current_date.strftime("%m-%d")) # 细分刻度:小时(每天24个) for hour in range(1, 24): sub_date = current_date + timedelta(hours=hour) sub_timestamp = sub_date.timestamp() sub_x = self.timestamp_to_pixel(sub_timestamp) if 0 <= sub_x <= widget_width: painter.setPen(sub_pen) painter.drawLine(sub_x, 50, sub_x, widget_height - 50) current_date += timedelta(days=1) elif tick_level == "hour": # 主刻度:小时 start_date = datetime.fromtimestamp(left_timestamp).replace(minute=0, second=0) - timedelta
相关产品推荐
相关产品推荐

