Search code examples
pythonmatplotlibpyqt

How to display graphical information infinitely downward in Python?


Example:
enter image description here

I need to display more rows like this (in the direction of the red arrow), with more of those squares. Along with the squares I could also use other shapes of various colours, and the objects need to be placed in very specific coordinates. When I hover over any of those objects, it has to show a tooltip relevant to that object. The text shown, would be rendered as a graphical object, and not as text that can be selected with a mouse pointer. Basically, everything is fully graphically rendered.
As more data is generated, more rows will be added and it goes on infinitely. Obviously a scrollbar is needed.
So far, from libraries like Matplotlib, I've known only options of creating a graphical screen of fixed size. I considered PyQt widgets, but it doesn't seem to have the desired functionality.
I considered HTML (since adding new rows infinitely is easy) and JavaScript, but it's too cumbersome to export the data from Python and load and parse it in JavaScript.

Is there any way to do such a display in Python? Demonstrating one way of achieving this objective effectively would suffice.
Purpose: I'm creating this to visualize how certain foods and sleep loss lead to health issues. Displaying it like this allows me to see patterns across weeks or months. It's not just about plotting points and text, it's also about being able to dynamically update their color and size on clicking any of the graphical elements.


Solution

  • I came up with a basic example, based on musicamante's suggestion.

    import sys
    import random
    import string
    import datetime
    from PyQt5.QtCore import Qt, QTimer
    from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel
    from PyQt5.QtGui import QPainter, QColor
    from PyQt5.QtCore import Qt, QTimer
    
    class Constants:
        MAIN_WINDOW_WIDTH = 900
        MAIN_WINDOW_HEIGHT = 600
        TOOLTIP_DURATION_MSEC = 3000
        SHAPE_RECTANGLE = 'Rectangle'
        SHAPE_ELLIPSE = 'Ellipse'
    
    class Markers(QWidget):
        colors = {}  # Class-level variable to store colors for each instance
        shapes = {}
        def __init__(self):
            super().__init__()
            self.init_color()
            self.color = Markers.colors[self]
            self.shapes = Markers.shapes[self]
    
        def init_color(self):
            if self not in Markers.colors:
                Markers.colors[self] = self.generate_random_color()        
                Markers.shapes[self] = self.generate_random_shape()    
    
        def paintEvent(self, event):
            painter = QPainter(self)        
            self.draw_random_shapes(painter)
    
        def draw_random_shapes(self, painter):
            colors = [QColor(255, 0, 0), QColor(0, 255, 0), QColor(0, 0, 255)]
    
            for _ in range(random.randint(1, 5)):
                painter.setBrush(self.color)
                x = 0; y = 0;
                if self.shapes == Constants.SHAPE_RECTANGLE:
                    #width = random.randint(10, 40); height = random.randint(10, 40)
                    width = 5; height = width
                    painter.drawRect(x, y, width, height)
                elif self.shapes == Constants.SHAPE_ELLIPSE:
                    radiusWidth = 5; radiusHeight = radiusWidth
                    painter.drawEllipse(x, y, radiusWidth, radiusHeight)
            
        def generate_random_color(self):
            return QColor(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
        
        def generate_random_shape(self):
            return random.choice([Constants.SHAPE_ELLIPSE, Constants.SHAPE_RECTANGLE])
    
    class OneRowData(QWidget):
        def __init__(self, date, parent=None):
            super().__init__(parent)
            self.date = date
            self.initUI()
    
        def initUI(self):
            layout = QHBoxLayout(self)
            dateLabel = QLabel(self.date.strftime("%Y-%m-%d %H:%M:%S"))        
            layout.addWidget(dateLabel, Qt.AlignLeft)
            dots = Markers()
            toolstring = ''.join(random.choices(string.ascii_lowercase, k=10))
            dots.setToolTip(toolstring) #hovering over any of the squares or circles for a while will make the tooltip show
            dots.setToolTipDuration(Constants.TOOLTIP_DURATION_MSEC)
            layout.addWidget(dots, Qt.AlignLeft)
            #https://doc.qt.io/qtforpython-5/PySide2/QtGui/QPainter.html
    
        def generateRandomColor(self):
            return '#{0:06x}'.format(random.randint(0, 0xFFFFFF))  # Random color in hexadecimal
    
    class Header(QWidget):
        def __init__(self, date, parent=None):
            super().__init__(parent)
            self.date = date
            self.initUI()            
    
        def initUI(self):
            layout = QHBoxLayout(self) 
            TIME_HEADER_OFFSET = "                                               "
            headerString = TIME_HEADER_OFFSET + self.getTimeHeaderString()       
            dateLabel = QLabel(headerString)                  
            layout.addWidget(dateLabel, Qt.AlignLeft)  
            dots = Markers()
    
        def getTimeHeaderString(self):
            TIME_HEADER_GAPS = "  "
            timeHeader = ["00:00", "01:00", "02:00", "03:00", "04:00", "05:00", "06:00", "07:00", "08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00", "017:00", "18:00", "19:00", "20:00", "21:00", "22:00", "23:00", "00:00"]
            return "".join(x + TIME_HEADER_GAPS for x in timeHeader) #concatenates all elements in the list
    
    class InfiniteScroll(QScrollArea):
        def __init__(self):
            super().__init__()
            self.setWidgetResizable(True)
            self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
    
            self.content = QWidget()
            self.layout = QVBoxLayout(self.content)
            self.layout.setAlignment(Qt.AlignTop)
            self.layout.setSpacing(1)        
            self.setWidget(self.content)
    
            self.timer = QTimer(self)
            self.timer.timeout.connect(self.addItem)
            self.timer.start(500)  # Add an item at intervals
            
            date = datetime.datetime.now()
            item2 = Header(date)    
            self.layout.addWidget(item2)
    
        def addItem(self):
            date = datetime.datetime.now()
            item = OneRowData(date)            
            self.layout.addWidget(item)
    
    
    def main():
        app = QApplication(sys.argv)
        scroll = InfiniteScroll()
        scroll.resize(Constants.MAIN_WINDOW_WIDTH, Constants.MAIN_WINDOW_HEIGHT) #set initial size of the main window
        scroll.show()
        sys.exit(app.exec_())
    
    
    if __name__ == '__main__':
        main()