"""
vispy_grid_of_rectangles.py
Create a grid of rectangles of arbitrary size, and update their color using vispy.
You'll need to install the following packages:
* mypy==0.720
* numpy==1.18.2
* vispy==0.6.4
* PyQt5==5.14
4/20/2020 - Devon Bray - www.esologic.com/quickly-drawing-grids-of-rectangles-and-updating-their-colors-with-vispy
"""
import random
from typing import Optional, Union
import numpy as np
from vispy import app, gloo
from vispy.util.event import Event
from vispy.visuals.collections import PathCollection, PolygonCollection
# Both the `PolygonCollection` and `PathCollection` used in this example support 3D polygons,
# But we're only concerned with the 2D case here, so this global constant is set to avoid confusion.
Z_POSITION_DEFAULT = 0.0
def rectangle_polygon_vertices_2d(
horizontal_side_length: float, vertical_side_length: float,
) -> np.ndarray:
"""
Create a list of vertices that represent a rectangle centered around (0, 0, 0).
The `_side_length` parameters should be floats greater than zero and less than one.
These are 2D rectangles, not boxes, so changing the `z_position` will move the rectangle along
the z axis.
:param horizontal_side_length: how long the horizontal sizes should be
:param vertical_side_length: how long the vertical sides should be
:return: the vertices (x, y, z) as a numpy array.
"""
return (
np.array(
[
[1.0, 1.0, Z_POSITION_DEFAULT], # top right
[-1.0, 1.0, Z_POSITION_DEFAULT], # top left
[-1.0, -1.0, Z_POSITION_DEFAULT], # bottom left
[1.0, -1.0, Z_POSITION_DEFAULT], # bottom right
]
)
* (horizontal_side_length, vertical_side_length, 1)
/ 2
)
def offset_rectangle(position: int, side_length_float: float, buffer_length_float: float) -> float:
"""
Given the position of a rectangle plus it's side/buffer lengths, create the float offset from
the outer edge.
:param position: The column/row number of the current rectangle
:param side_length_float: the side length of the rectangle as a float
:param buffer_length_float: the buffer (distance between rectangles) as a float
:return: the offset distance for the current rectangle as a float
"""
# Since the rectangle is currently centered around (0, 0), exactly HALF of it is already above
# the origin.
middle_offset = side_length_float / 2
# These are the side lengths of the previous rectangles
length_of_rectangles = position * side_length_float
# These are the lengths of the previous buffers between rectangles
border_lengths = (position + 1) * buffer_length_float
return middle_offset + length_of_rectangles + border_lengths
class GridOfRectangles(app.Canvas):
"""
Draw a grid of rectangles using `PolygonCollection` and `PathCollection` objects.
Every 1 second, change each rectangles to a random color.
"""
def __init__(
self: "GridOfRectangles",
rows: int,
columns: int,
horizontal_side_length_pixels: int,
vertical_side_length_pixels: int,
buffer_length_pixels: int,
**kwargs: Optional[Union[bool, str]]
) -> None:
"""
Constructor, note that `self.polygons` and `self.edges_of_polygons` are created before the
parent class is initialized.
:param rows: the desired number of rows in the grid
:param columns: the desired number of columns in the grid
:param horizontal_side_length_pixels: the side horizontal length (width) of each of the
rectangles in pixels
:param vertical_side_length_pixels: the vertical side length (height) of each rectangle in
pixels
:param buffer_length_pixels: the number of pixels between each rectangle.
:param kwargs: keyword arguments to be passed to the parent canvas constructor.
:return: None
"""
self.polygons = PolygonCollection("agg", color="shared")
self.edges_of_polygons = PathCollection("agg", color="shared")
app.Canvas.__init__(
self,
size=(
(columns * horizontal_side_length_pixels) + ((columns + 1) * buffer_length_pixels),
(rows * vertical_side_length_pixels) + ((rows + 1) * buffer_length_pixels),
),
**kwargs
)
self._timer = app.Timer(interval=1, connect=self.on_timer, start=True)
horizontal_side = (horizontal_side_length_pixels / self.physical_size[0]) * 2
horizontal_buffer = (buffer_length_pixels / self.physical_size[0]) * 2
vertical_side_length = (vertical_side_length_pixels / self.physical_size[1]) * 2
vertical_buffer_length = (buffer_length_pixels / self.physical_size[1]) * 2
self.num_rectangles = columns * rows
for num_column in range(columns):
for num_row in range(rows):
xyz_offset = (
-1 + (offset_rectangle(num_column, horizontal_side, horizontal_buffer)), # X
1
- (
offset_rectangle(num_row, vertical_side_length, vertical_buffer_length)
), # Y
Z_POSITION_DEFAULT, # Z
)
polygon_points = (
rectangle_polygon_vertices_2d(horizontal_side, vertical_side_length)
+ xyz_offset
)
# The default color for each of this will be black.
# The color of the polygons will be changed with `on_timer`, but the edges will
# stay as is.
self.polygons.append(polygon_points, color=(0, 0, 0, 1))
self.edges_of_polygons.append(polygon_points, closed=True, color=(0, 0, 0, 1))
self.edges_of_polygons["linewidth"] = 1
self.edges_of_polygons["viewport"] = 0, 0, self.physical_size[0], self.physical_size[1]
def on_draw(self: "GridOfRectangles", event: Event) -> None:
"""
Called each time a frame is to be written, as often as possible
:param event: The vispy event context
:return: None
"""
gloo.clear("white")
self.polygons.draw()
self.edges_of_polygons.draw()
self.update()
def on_timer(self: "GridOfRectangles", event: Event) -> None:
"""
This function sets the color of each of the polygons to a random color.
Called by the timer, every 1 second.
:param event: The vispy event context
:return: None
"""
self.polygons["color"] = np.array(
list(
(random.uniform(0, 1), random.uniform(0, 1), random.uniform(0, 1), 1)
for _ in range(self.num_rectangles)
)
)
self.update()
if __name__ == "__main__":
canvas = GridOfRectangles(
rows=500,
columns=500,
horizontal_side_length_pixels=3,
vertical_side_length_pixels=3,
buffer_length_pixels=1,
show=True,
keys="interactive",
)
gloo.set_viewport(0, 0, canvas.size[0], canvas.size[1])
gloo.set_state("translucent", depth_test=False)
canvas.measure_fps()
app.run()