From 8f95afd6c8d214b5dbb99c0dda7b6884953118ba Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Feb 2022 16:41:09 +0000 Subject: [PATCH 1/5] Implement `Sparkline` renderable --- src/textual/renderables/sparkline.py | 120 +++++++++++++++++++++++++++ tests/renderables/test_sparkline.py | 41 +++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/textual/renderables/sparkline.py create mode 100644 tests/renderables/test_sparkline.py diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py new file mode 100644 index 000000000..73db9ea4c --- /dev/null +++ b/src/textual/renderables/sparkline.py @@ -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") diff --git a/tests/renderables/test_sparkline.py b/tests/renderables/test_sparkline.py new file mode 100644 index 000000000..0e426ebe9 --- /dev/null +++ b/tests/renderables/test_sparkline.py @@ -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}" From 17d1ed335a0ec4e44047336919384e62afc6c82e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 8 Feb 2022 16:49:30 +0000 Subject: [PATCH 2/5] Add docstring to buckets method --- src/textual/renderables/sparkline.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 73db9ea4c..a24630580 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -41,6 +41,13 @@ class Sparkline: @classmethod def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]: + """Partition ``data`` into ``num_buckets`` buckets. For example, the data + [1, 2, 3, 4] partitioned into 2 buckets is [[1, 2], [3, 4]]. + + Args: + data (Sequence[T]): The data to partition. + num_buckets (int): The number of buckets to partition the data into. + """ num_steps, remainder = divmod(len(data), num_buckets) for i in range(num_buckets): start = i * num_steps + min(i, remainder) From 3b674449117bb0b7bdd93715fcb6aac577fa6ac6 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 9 Feb 2022 16:32:49 +0000 Subject: [PATCH 3/5] Update Sparkline.summary_func -> summary_function --- src/textual/renderables/sparkline.py | 8 ++++---- tests/renderables/test_sparkline.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index a24630580..1de571c4d 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -19,7 +19,7 @@ class Sparkline: 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. + summary_function (Callable[list[T]]): Function that will be applied to each bucket. """ BARS = "▁▂▃▄▅▆▇█" @@ -31,13 +31,13 @@ class Sparkline: 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, + summary_function: 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 + self.summary_func = summary_function @classmethod def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]: @@ -122,6 +122,6 @@ if __name__ == "__main__": console.print(f"data = {nums}\n") for f in funcs: console.print( - f"{f.__name__}:\t", Sparkline(nums, width=12, summary_func=f), end="" + f"{f.__name__}:\t", Sparkline(nums, width=12, summary_function=f), end="" ) console.print("\n") diff --git a/tests/renderables/test_sparkline.py b/tests/renderables/test_sparkline.py index 0e426ebe9..74f1f2f6b 100644 --- a/tests/renderables/test_sparkline.py +++ b/tests/renderables/test_sparkline.py @@ -34,7 +34,7 @@ def test_sparkline_shrink_data_to_width(): 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}" + Sparkline([1, 2, 3, 4, 5], width=3, summary_function=min)) == f"{GREEN}▁{STOP}{BLENDED}▄{STOP}{RED}█{STOP}" def test_sparkline_color_blend(): From d170247da8f42975d1f9bc74ff3d036d0b7c3ab6 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 10 Feb 2022 09:45:17 +0000 Subject: [PATCH 4/5] Rename summary_func to summary_function internal references --- src/textual/renderables/sparkline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 1de571c4d..92e1c31d1 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -37,7 +37,7 @@ class Sparkline: self.width = width self.min_color = Style.from_color(min_color) self.max_color = Style.from_color(max_color) - self.summary_func = summary_function + self.summary_function = summary_function @classmethod def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]: @@ -76,11 +76,11 @@ class Sparkline: bucket_index = 0 bars_rendered = 0 step = len(buckets) / width - summary_func = self.summary_func + summary_function = self.summary_function 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) + partition_summary = summary_function(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) From c5360ce3eea7627ef8f9a6c3a67f4841631bf8a4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 10 Feb 2022 09:45:47 +0000 Subject: [PATCH 5/5] Remove redundant slice operation --- src/textual/renderables/sparkline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 92e1c31d1..22bc959f7 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -54,7 +54,7 @@ class Sparkline: end = (i + 1) * num_steps + min(i + 1, remainder) partition = data[start:end] if partition: - yield data[start:end] + yield partition def __rich_console__( self, console: Console, options: ConsoleOptions