You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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像素对应1天的秒数(86400秒),缩放时调整这个值——放大时减小单位(比如1像素对应3600秒=1小时),缩小时增大单位(比如1像素对应365天的秒数),这样就能把屏幕上的每个x坐标转换成具体的时间戳。
  2. 动态刻度层级:根据当前的像素-时间比例,自动选择显示的刻度精度:
    • 当单位>31536000秒(1年):显示年刻度
    • 单位>2592000秒(30天,近似月):显示月刻度
    • 单位>86400秒(1天):显示日刻度
    • 单位>3600秒(1小时):显示小时刻度
    • 单位<=3600秒:显示分钟刻度
  3. 可见范围计算:根据屏幕左右边缘的x坐标,转换成对应的起始和结束时间,只绘制这个范围内的刻度,避免无效绘制。
  4. 细分刻度绘制:每个主刻度下,绘制下一级的细分刻度(比如年刻度下的月刻度,月刻度下的日刻度),用不同的线条样式区分主刻度和细分刻度。

修改后的完整代码

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
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 00:17:02