Implement Sparkline renderable

This commit is contained in:
Darren Burns
2022-02-08 16:41:09 +00:00
parent 06417bb9b6
commit 8f95afd6c8
2 changed files with 161 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import statistics
from typing import Sequence, Iterable, Callable, TypeVar
from rich.color import Color
from rich.console import ConsoleOptions, Console, RenderResult
from rich.segment import Segment
from rich.style import Style
T = TypeVar("T", int, float)
class Sparkline:
"""A sparkline representing a series of data.
Args:
data (Sequence[T]): The sequence of data to render.
width (int, optional): The width of the sparkline/the number of buckets to partition the data into.
min_color (Color, optional): The color of values equal to the min value in data.
max_color (Color, optional): The color of values equal to the max value in data.
summary_func (Callable[list[T]]): Function that will be applied to each bucket.
"""
BARS = "▁▂▃▄▅▆▇█"
def __init__(
self,
data: Sequence[T],
*,
width: int | None,
min_color: Color = Color.from_rgb(0, 255, 0),
max_color: Color = Color.from_rgb(255, 0, 0),
summary_func: Callable[[list[T]], float] = max,
) -> None:
self.data = data
self.width = width
self.min_color = Style.from_color(min_color)
self.max_color = Style.from_color(max_color)
self.summary_func = summary_func
@classmethod
def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]:
num_steps, remainder = divmod(len(data), num_buckets)
for i in range(num_buckets):
start = i * num_steps + min(i, remainder)
end = (i + 1) * num_steps + min(i + 1, remainder)
partition = data[start:end]
if partition:
yield data[start:end]
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
width = self.width or options.max_width
len_data = len(self.data)
if len_data == 0:
yield Segment("" * width, style=self.min_color)
return
if len_data == 1:
yield Segment("" * width, style=self.max_color)
return
minimum, maximum = min(self.data), max(self.data)
extent = maximum - minimum or 1
buckets = list(self._buckets(self.data, num_buckets=self.width))
bucket_index = 0
bars_rendered = 0
step = len(buckets) / width
summary_func = self.summary_func
min_color, max_color = self.min_color.color, self.max_color.color
while bars_rendered < width:
partition = buckets[int(bucket_index)]
partition_summary = summary_func(partition)
height_ratio = (partition_summary - minimum) / extent
bar_index = int(height_ratio * (len(self.BARS) - 1))
bar_color = _blend_colors(min_color, max_color, height_ratio)
bars_rendered += 1
bucket_index += step
yield Segment(text=self.BARS[bar_index], style=Style.from_color(bar_color))
def _blend_colors(color1: Color, color2: Color, ratio: float) -> Color:
"""Given two RGB colors, return a color that sits some distance between
them in RGB color space.
Args:
color1 (Color): The first color.
color2 (Color): The second color.
ratio (float): The ratio of color1 to color2.
Returns:
Color: A Color representing the blending of the two supplied colors.
"""
r1, g1, b1 = color1.triplet
r2, g2, b2 = color2.triplet
dr = r2 - r1
dg = g2 - g1
db = b2 - b1
return Color.from_rgb(
red=r1 + dr * ratio, green=g1 + dg * ratio, blue=b1 + db * ratio
)
if __name__ == "__main__":
console = Console()
def last(l):
return l[-1]
funcs = min, max, last, statistics.median, statistics.mean
nums = [10, 2, 30, 60, 45, 20, 7, 8, 9, 10, 50, 13, 10, 6, 5, 4, 3, 7, 20]
console.print(f"data = {nums}\n")
for f in funcs:
console.print(
f"{f.__name__}:\t", Sparkline(nums, width=12, summary_func=f), end=""
)
console.print("\n")

View File

@@ -0,0 +1,41 @@
from tests.utilities.render import render
from textual.renderables.sparkline import Sparkline
GREEN = "\x1b[38;2;0;255;0m"
RED = "\x1b[38;2;255;0;0m"
BLENDED = "\x1b[38;2;127;127;0m" # Color between red and green
STOP = "\x1b[0m"
def test_sparkline_no_data():
assert render(Sparkline([], width=4)) == f"{GREEN}▁▁▁▁{STOP}"
def test_sparkline_single_datapoint():
assert render(Sparkline([2.5], width=4)) == f"{RED}████{STOP}"
def test_sparkline_two_values_min_max():
assert render(Sparkline([2, 4], width=2)) == f"{GREEN}{STOP}{RED}{STOP}"
def test_sparkline_expand_data_to_width():
assert render(Sparkline([2, 4],
width=4)) == f"{GREEN}{STOP}{GREEN}{STOP}{RED}{STOP}{RED}{STOP}"
def test_sparkline_expand_data_to_width_non_divisible():
assert render(Sparkline([2, 4], width=3)) == f"{GREEN}{STOP}{GREEN}{STOP}{RED}{STOP}"
def test_sparkline_shrink_data_to_width():
assert render(Sparkline([2, 2, 4, 4, 6, 6], width=3)) == f"{GREEN}{STOP}{BLENDED}{STOP}{RED}{STOP}"
def test_sparkline_shrink_data_to_width_non_divisible():
assert render(
Sparkline([1, 2, 3, 4, 5], width=3, summary_func=min)) == f"{GREEN}{STOP}{BLENDED}{STOP}{RED}{STOP}"
def test_sparkline_color_blend():
assert render(Sparkline([1, 2, 3], width=3)) == f"{GREEN}{STOP}{BLENDED}{STOP}{RED}{STOP}"