Issue
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.
Update:
I've written some code to create a basic window, but I'm unable to enlarge the main window and the tooltips aren't working. Also, given the way setGeometry
is used, I don't think I could add rows infinitely dynamically. I'm still trying to figure it out. If y'all could help, please do.
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QScrollArea, QLabel, QToolTip
from PyQt5.QtGui import QPainter, QColor, QFont
from PyQt5.QtCore import Qt, QTimer
import random
import datetime
class CustomWidget(QWidget):
def __init__(self):
super().__init__()
self.initUI()
self.rows = 200
self.row_height = 25
self.scroll_offset = 0
def initUI(self):
self.setGeometry(100, 100, 800, 1200)
self.setWindowTitle('Scrollable Window')
self.setMouseTracking(True)
self.setMinimumSize(1000, 1800) # Adjust the size here
self.setFixedWidth(1500)
def paintEvent(self, event):
painter = QPainter(self)
painter.setFont(QFont('Arial', 10))
start_row = max(0, int(self.scroll_offset / self.row_height) - 1)
end_row = min(start_row + int(self.height() / self.row_height) + 2, self.rows)
for i in range(start_row, end_row):
row_y = i * self.row_height - self.scroll_offset
# Displaying datetime at the left
current_time = datetime.datetime.now() + datetime.timedelta(hours=i)
time_str = current_time.strftime("%H:%M")
painter.drawText(8, row_y + 20, time_str)
# Drawing small randomly colored circles/squares
for j in range(5):
random.seed(i * j + j)
shape = random.choice(["circle", "square"])
color = QColor(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
painter.setBrush(color)
shapeSize = 5
if shape == "circle":
painter.drawEllipse(70 + j * 30, row_y + 10, shapeSize, shapeSize)
else:
painter.drawRect(70 + j * 30, row_y + 10, shapeSize, shapeSize)
def mouseMoveEvent(self, event):
for i in range(5):
if (70 + i * 30) <= event.x() <= (90 + i * 30):
row = int((event.y() + self.scroll_offset) / self.row_height)
tooltip_text = ''.join(random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ') for _ in range(5))
QToolTip.showText(event.globalPos(), tooltip_text, self)
break
else:
QToolTip.hideText()
def wheelEvent(self, event):
scroll_amount = 20
self.scroll_offset += -event.angleDelta().y() / 8
self.scroll_offset = max(0, min(self.scroll_offset, (self.rows * self.row_height) - self.height()))
self.update()
class ScrollableWidget(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
content = CustomWidget()
scroll.setWidget(content)
# Change the size of the content widget
#content.setMinimumSize(1000, 1800) # Adjust the size here
layout.addWidget(scroll)
self.setLayout(layout)
if __name__ == '__main__':
app = QApplication(sys.argv)
ex = ScrollableWidget()
ex.show()
sys.exit(app.exec_())
Solution
One possibility, using QScrollArea, is to do something similar to what Qt item views do with models that provide the fetchMore()
capability.
What item views do is to ask the model if more items are available whenever the scroll bar reaches its maximum, which can also happen indirectly (without valueChanged
signal emission) when the view is resized.
The solution is then to create a similar behavior and eventually add items when required.
The following example shows a basic solution using a custom widget that shows "events" at random times. Note that the event positions use arbitrary values, for a more consistent solution you should use a proper centralized management that considers font metrics and style metrics.
class TimeWidget(QWidget):
types = 'square', 'circle'
cache = None
def __init__(self):
super().__init__()
self.setMinimumSize(600, 16)
self.values = []
for i in range(randint(2, 5)):
self.values.append((
randrange(1440),
choice(self.types),
QColor(randrange(256), randrange(256), randrange(256))
))
self.values.sort(key=lambda i: i[0])
def event(self, event):
if event.type() == event.ToolTip:
pos = event.pos()
for i, (rect, *args) in enumerate(self.cache):
if rect.contains(pos):
time, shape, color = self.values[i]
h, m = divmod(time, 60)
text = '{} at {:02}:{}'.format(
shape, h, m
)
QToolTip.showText(
event.globalPos(), text, self, rect)
break
return super().event(event)
def updateCache(self):
size = max(12, self.height() - 4)
margin = size // 2
r = QRect(-margin, (self.height() - size) // 2, size, size)
self.cache = []
extent = self.width() - size
for time, shape, color in self.values:
x = int(margin + time / 1440 * extent)
if shape == 'circle':
func = QPainter.drawEllipse
else:
func = QPainter.drawRect
self.cache.append((
r.translated(x, 0), color, func
))
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateCache()
def paintEvent(self, event):
if not self.values:
return
elif not self.cache:
self.updateCache()
qp = QPainter(self)
qp.setPen(Qt.NoPen)
for rect, color, func in self.cache:
qp.setBrush(color)
func(qp, rect)
class DateSlot(QWidget):
def __init__(self, date):
super().__init__()
self.date = date
self.timeLabel = QLabel(date.strftime("%Y-%m-%d"))
self.timeWidget = TimeWidget()
layout = QHBoxLayout(self)
layout.addWidget(self.timeLabel)
layout.addWidget(self.timeWidget, stretch=1)
class InfiniteScroll(QScrollArea):
fetchAmount = 5
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.items = []
self.verticalScrollBar().valueChanged.connect(self.fetchMore)
def addItem(self):
if self.items:
date = self.items[-1].date + datetime.timedelta(days=1)
else:
date = datetime.datetime.now()
item = DateSlot(date)
self.items.append(item)
self.layout.addWidget(item)
return item
def fetchMore(self):
if self.verticalScrollBar().maximum() == self.verticalScrollBar().value():
if self.items:
for i in range(self.fetchAmount):
self.addItem()
else:
baseHeight = self.addItem().sizeHint().height()
maxHeight = self.viewport().height()
count = maxHeight // (baseHeight + self.layout.spacing())
for i in range(count):
self.addItem()
def resizeEvent(self, event):
super().resizeEvent(event)
self.fetchMore()
def sizeHint(self):
return QSize(640, 480)
Another alternative could be using an actual QTableView and a custom model that actually implements canFetchMore
and fetchMore
. The benefit of this approach is that you can have headers for dates and times, and then you only need a custom item delegate along with table spanning: in that way, you have only one item for each row, and the delegate will eventually paint "events" at the correct position based on the rectangle assigned to the topleft item (which spans the whole row).
Answered By - musicamante
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.