Skip to content

Commit 8044efc

Browse files
committed
performance: implement growing sparkline
1 parent cdef091 commit 8044efc

File tree

1 file changed

+117
-88
lines changed

1 file changed

+117
-88
lines changed

adafruit_display_shapes/sparkline.py

Lines changed: 117 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,25 @@ class Sparkline(displayio.Group):
5454
:param width: Width of the sparkline graph in pixels
5555
:param height: Height of the sparkline graph in pixels
5656
:param max_items: Maximum number of values housed in the sparkline
57+
:param dyn_xpitch: dynamically change xpitch (True)
5758
:param y_min: Lower range for the y-axis. Set to None for autorange.
5859
:param y_max: Upper range for the y-axis. Set to None for autorange.
5960
:param x: X-position on the screen, in pixels
6061
:param y: Y-position on the screen, in pixels
6162
:param color: Line color, the default value is 0xFFFFFF (WHITE)
63+
64+
Note: If dyn_xpitch is True (default), the sparkline will allways span
65+
the complete width. Otherwise, the sparkline will grow when you
66+
add values. Once the line has reached the full width, the sparkline
67+
will scroll to the left.
6268
"""
6369

6470
def __init__(
6571
self,
6672
width: int,
6773
height: int,
6874
max_items: int,
75+
dyn_xpitch: Optional[bool] = True, # True = dynamic pitch size
6976
y_min: Optional[int] = None, # None = autoscaling
7077
y_max: Optional[int] = None, # None = autoscaling
7178
x: int = 0,
@@ -79,6 +86,9 @@ def __init__(
7986
self.color = color #
8087
self._max_items = max_items # maximum number of items in the list
8188
self._spark_list = [] # list containing the values
89+
self.dyn_xpitch = dyn_xpitch
90+
if not dyn_xpitch:
91+
self._xpitch = (width - 1) / (self._max_items - 1)
8292
self.y_min = y_min # minimum of y-axis (None: autoscale)
8393
self.y_max = y_max # maximum of y-axis (None: autoscale)
8494
self.y_bottom = y_min
@@ -89,6 +99,7 @@ def __init__(
8999
# updated if autorange
90100
self._x = x
91101
self._y = y
102+
self._redraw = True # _redraw: redraw primitives
92103

93104
super().__init__(x=x, y=y) # self is a group of lines
94105

@@ -98,6 +109,7 @@ def clear_values(self) -> None:
98109
for _ in range(len(self)): # remove all items from the current group
99110
self.pop()
100111
self._spark_list = [] # empty the list
112+
self._redraw = True
101113

102114
def add_value(self, value: float, update: bool = True) -> None:
103115
"""Add a value to the sparkline.
@@ -114,7 +126,22 @@ def add_value(self, value: float, update: bool = True) -> None:
114126
len(self._spark_list) >= self._max_items
115127
): # if list is full, remove the first item
116128
self._spark_list.pop(0)
129+
self._redraw = True
117130
self._spark_list.append(value)
131+
132+
if self.y_min is None:
133+
self._redraw = self._redraw or value < self.y_bottom
134+
self.y_bottom = (
135+
value if not self.y_bottom else min(value, self.y_bottom)
136+
)
137+
if self.y_max is None:
138+
self._redraw = self._redraw or value > self.y_top
139+
self.y_top = value if not self.y_top else max(value, self.y_top)
140+
141+
# Guard for y_top and y_bottom being the same
142+
if self.y_top == self.y_bottom:
143+
self.y_bottom *= 0.99
144+
118145
if update:
119146
self.update()
120147

@@ -146,107 +173,109 @@ def _plotline(
146173
last_value: float,
147174
x_2: int,
148175
value: float,
149-
y_bottom: int,
150-
y_top: int,
151176
) -> None:
152177

153-
y_2 = int(self.height * (y_top - value) / (y_top - y_bottom))
154-
y_1 = int(self.height * (y_top - last_value) / (y_top - y_bottom))
178+
y_2 = int(self.height * (self.y_top - value) / (self.y_top - self.y_bottom))
179+
y_1 = int(
180+
self.height * (self.y_top - last_value) / (self.y_top - self.y_bottom)
181+
)
155182
self.append(Line(x_1, y_1, x_2, y_2, self.color)) # plot the line
183+
self._last = [x_2, value]
156184

157185
# pylint: disable= too-many-branches, too-many-nested-blocks
158186

159187
def update(self) -> None:
160188
"""Update the drawing of the sparkline."""
161189

162-
# get the y range
163-
if self.y_min is None:
164-
self.y_bottom = min(self._spark_list)
165-
else:
166-
self.y_bottom = self.y_min
190+
# bail out early if we only have a single point
191+
n_points = len(self._spark_list)
192+
if n_points < 2:
193+
self._last = [0, self._spark_list[0]]
194+
return
167195

168-
if self.y_max is None:
169-
self.y_top = max(self._spark_list)
196+
if self.dyn_xpitch:
197+
# this is a float, only make int when plotting the line
198+
xpitch = (self.width - 1) / (n_points - 1)
199+
self._redraw = True
170200
else:
171-
self.y_top = self.y_max
172-
173-
# Guard for y_top and y_bottom being the same
174-
if self.y_top == self.y_bottom:
175-
self.y_bottom -= 10
176-
self.y_top += 10
177-
178-
if len(self._spark_list) > 2:
179-
xpitch = (self.width - 1) / (
180-
len(self._spark_list) - 1
181-
) # this is a float, only make int when plotting the line
182-
183-
for _ in range(len(self)): # remove all items from the current group
184-
self.pop()
185-
186-
for count, value in enumerate(self._spark_list):
187-
if count == 0:
188-
pass # don't draw anything for a first point
189-
else:
190-
x_2 = int(xpitch * count)
191-
x_1 = int(xpitch * (count - 1))
192-
193-
if (self.y_bottom <= last_value <= self.y_top) and (
194-
self.y_bottom <= value <= self.y_top
195-
): # both points are in range, plot the line
196-
self._plotline(
197-
x_1, last_value, x_2, value, self.y_bottom, self.y_top
198-
)
199-
200-
else: # at least one point is out of range, clip one or both ends the line
201-
if ((last_value > self.y_top) and (value > self.y_top)) or (
202-
(last_value < self.y_bottom) and (value < self.y_bottom)
203-
):
204-
# both points are on the same side out of range: don't draw anything
201+
xpitch = self._xpitch
202+
203+
# only add new segment if redrawing is not necessary
204+
if not self._redraw:
205+
# end of last line (last point, read as "x(-1)")
206+
x_m1 = self._last[0]
207+
y_m1 = self._last[1]
208+
# end of new line (new point, read as "x(0)")
209+
x_0 = int(x_m1 + xpitch)
210+
y_0 = self._spark_list[-1]
211+
self._plotline(x_m1, y_m1, x_0, y_0)
212+
return
213+
214+
self._redraw = False # reset, since we now redraw everything
215+
for _ in range(len(self)): # remove all items from the current group
216+
self.pop()
217+
218+
for count, value in enumerate(self._spark_list):
219+
if count == 0:
220+
pass # don't draw anything for a first point
221+
else:
222+
x_2 = int(xpitch * count)
223+
x_1 = int(xpitch * (count - 1))
224+
225+
if (self.y_bottom <= last_value <= self.y_top) and (
226+
self.y_bottom <= value <= self.y_top
227+
): # both points are in range, plot the line
228+
self._plotline(x_1, last_value, x_2, value)
229+
230+
else: # at least one point is out of range, clip one or both ends the line
231+
if ((last_value > self.y_top) and (value > self.y_top)) or (
232+
(last_value < self.y_bottom) and (value < self.y_bottom)
233+
):
234+
# both points are on the same side out of range: don't draw anything
235+
pass
236+
else:
237+
xint_bottom = self._xintercept(
238+
x_1, last_value, x_2, value, self.y_bottom
239+
) # get possible new x intercept points
240+
xint_top = self._xintercept(
241+
x_1, last_value, x_2, value, self.y_top
242+
) # on the top and bottom of range
243+
if (xint_bottom is None) or (
244+
xint_top is None
245+
): # out of range doublecheck
205246
pass
206247
else:
207-
xint_bottom = self._xintercept(
208-
x_1, last_value, x_2, value, self.y_bottom
209-
) # get possible new x intercept points
210-
xint_top = self._xintercept(
211-
x_1, last_value, x_2, value, self.y_top
212-
) # on the top and bottom of range
213-
214-
if (xint_bottom is None) or (
215-
xint_top is None
216-
): # out of range doublecheck
217-
pass
218-
else:
219-
# Initialize the adjusted values as the baseline
220-
adj_x_1 = x_1
221-
adj_last_value = last_value
222-
adj_x_2 = x_2
223-
adj_value = value
224-
225-
if value > last_value: # slope is positive
226-
if xint_bottom >= x_1: # bottom is clipped
227-
adj_x_1 = xint_bottom
228-
adj_last_value = self.y_bottom # y_1
229-
if xint_top <= x_2: # top is clipped
230-
adj_x_2 = xint_top
231-
adj_value = self.y_top # y_2
232-
else: # slope is negative
233-
if xint_top >= x_1: # top is clipped
234-
adj_x_1 = xint_top
235-
adj_last_value = self.y_top # y_1
236-
if xint_bottom <= x_2: # bottom is clipped
237-
adj_x_2 = xint_bottom
238-
adj_value = self.y_bottom # y_2
239-
240-
self._plotline(
241-
adj_x_1,
242-
adj_last_value,
243-
adj_x_2,
244-
adj_value,
245-
self.y_bottom,
246-
self.y_top,
247-
)
248-
249-
last_value = value # store value for the next iteration
248+
# Initialize the adjusted values as the baseline
249+
adj_x_1 = x_1
250+
adj_last_value = last_value
251+
adj_x_2 = x_2
252+
adj_value = value
253+
254+
if value > last_value: # slope is positive
255+
if xint_bottom >= x_1: # bottom is clipped
256+
adj_x_1 = xint_bottom
257+
adj_last_value = self.y_bottom # y_1
258+
if xint_top <= x_2: # top is clipped
259+
adj_x_2 = xint_top
260+
adj_value = self.y_top # y_2
261+
else: # slope is negative
262+
if xint_top >= x_1: # top is clipped
263+
adj_x_1 = xint_top
264+
adj_last_value = self.y_top # y_1
265+
if xint_bottom <= x_2: # bottom is clipped
266+
adj_x_2 = xint_bottom
267+
adj_value = self.y_bottom # y_2
268+
269+
self._plotline(
270+
adj_x_1,
271+
adj_last_value,
272+
adj_x_2,
273+
adj_value,
274+
self.y_bottom,
275+
self.y_top,
276+
)
277+
278+
last_value = value # store value for the next iteration
250279

251280
def values(self) -> List[float]:
252281
"""Returns the values displayed on the sparkline."""

0 commit comments

Comments
 (0)