"""

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()