├── README.md ├── example_screens.rpy ├── icons ├── Cards-48.png ├── Dashboard-48.png ├── Knight Shield-48.png ├── Sword-48.png └── square.png ├── license.md ├── radar_chart ├── aa_line_ren.py ├── base_radar_chart_ren.py ├── point_2d_ren.py ├── polygon_ren.py └── radarchart.rpy ├── ruff.toml ├── screenshot0001.png ├── script.rpy └── tox.ini /README.md: -------------------------------------------------------------------------------- 1 | ![Screenshot](/screenshot0001.png?raw=true "Screenshot") 2 | 3 | # Radar Chart for Ren'Py 4 | Ren'Py Displayable for plotting data onto a radar chart 5 | 6 | - radarchart.rpy: Contains the RadarChart classes. Drop this file into a Ren'Py project directory. 7 | - script.rpy: Usage examples. 8 | - example_screens.rpy: Example screens for the examples. 9 | 10 | ### Running the Demo 11 | Just drop script.rpy, example_screens.rpy, and radarchart.rpy into a new Ren'Py project's 'game' directory and try it out. 12 | 13 | ### Getting Started 14 | This guide assumes you have basic familiarity with Ren'Py labels and screens. 15 | 16 | ##### Necessary Files: 17 | Place radarchart.rpy into your project's 'game' directory 18 | 19 | ##### Creating a Radar Chart: 20 | To create a chart, first you'll need some data to plot, such as: 21 | 22 | COOL_VALUE = 200 23 | BEAUTY_VALUE = 110 24 | CUTE_VALUE = 90 25 | SMART_VALUE = 67 26 | TOUGH_VALUE = 100 27 | 28 | The data should be created inside a label. 29 | 30 | A chart's data should then be gathered in a tuple: 31 | 32 | plot_values = (COOL_VALUE, BEAUTY_VALUE, CUTE_VALUE, SMART_VALUE, TOUGH_VALUE) 33 | 34 | The number of items in the tuple will automatically determine how many points the RadarChart has. 35 | 36 | Next, create a RadarChart instance: 37 | 38 | rc = RadarChart( 39 | size=200, 40 | values=plot_values, 41 | max_value=255, 42 | labels=[Text("Coolness"), Text("Beauty"), Text("Cuteness"), Text("Intelligence"), Text("Strength")], 43 | lines={"webs": [2, 4, 6, 8]}, 44 | data_colour=(100, 200, 100, 125), 45 | line_colour=(153, 153, 153, 255), 46 | background_colour=(255, 255, 255, 255) 47 | ) 48 | 49 | The RadarChart should be created inside a python block, inside a label. 50 | 51 | ##### Updating a Radar Chart 52 | To update or change the values in a RadarChart object, simply assign RadarChart.values a new tuple. 53 | 54 | For example, if COOL_VALUE has increased: 55 | 56 | COOL_VALUE = 210 57 | rc.values = (COOL_VALUE, BEAUTY_VALUE, CUTE_VALUE, SMART_VALUE, TOUGH_VALUE) 58 | 59 | ### Documentation 60 | - class RadarChart 61 | - Arguments: 62 | - size (int): Width & height of the chart 63 | - values (list[int]): All the values to chart on the plot 64 | - max_value (int): The largest number a value should have 65 | - labels(list[displayable]): All the labels for each value 66 | - lines(dict): Properties for which lines to draw: 67 | { 68 | "chart":True, 69 | "data":True 70 | "spokes":True, 71 | "webs":[int], # 1-9 allowed. represents 10%, 20%, etc 72 | } 73 | - break_limit(bool): If any value can exceed max_value or not 74 | - data_colour: RGBA tuple or HEX string 75 | - line_colour: RGBA tuple or HEX string 76 | - background_colour: RGBA tuple or HEX string 77 | - point (displayable): Displayable at each value's tip 78 | - visible (dict): Properties for which pieces of the RadarChart 79 | should be visible: 80 | { 81 | "base": True, 82 | "data": True, 83 | "lines": True, 84 | "points": True, 85 | "labels": True 86 | } 87 | 88 | - Attributes: 89 | - chart_base: Displayable for the chart's base 90 | - chart_data: Displayable for the chart's data 91 | - chart_lines: Displayable for the chart's spokes and webs 92 | - chart_points: Displayable for the chart's points 93 | - chart_labels: Displayable for the chart's labels 94 | 95 | - chart_labels.l_padding (int): Padding between the labels and chart for left-aligned labels 96 | - chart_labels.r_padding (int): Padding between the labels and chart for right-aligned labels 97 | - chart_labels.c_padding (int): Padding between the labels and chart for center-aligned labels 98 | 99 | ### License & Usage 100 | The pretty icons used in the examples are from https://icons8.com/ 101 | 102 | All the code is under the MIT license, just like Ren'Py. Do whatever you want with it. 103 | 104 | If you use this in a game, I'd appreciate getting credit for it, or at least a link back to this repo somewhere. 105 | 106 | If you need any sort of new feature and/or enhancement, create a new issue on Github and I'll see what I can do. -------------------------------------------------------------------------------- /example_screens.rpy: -------------------------------------------------------------------------------- 1 | # Example Screen 1: Lines & Borders 2 | screen radarChart_lines: 3 | 4 | text "Default" xpos 20 ypos 20 5 | add default: 6 | xpos 50 7 | ypos 50 8 | 9 | text "No Data Border" xpos 270 ypos 20 10 | add no_data_outline: 11 | xpos 300 12 | ypos 50 13 | 14 | text "No Borders, No Spokes" xpos 530 ypos 20 15 | add no_outline: 16 | xpos 560 17 | ypos 50 18 | 19 | text "No Spokes" xpos 100 ypos 270 20 | add no_spokes: 21 | xpos 100 22 | ypos 300 23 | 24 | text "Borders, Spokes, & Webs" xpos 400 ypos 270 25 | add spider_web_partial: 26 | xpos 400 27 | ypos 300 28 | 29 | textbutton "Return" action Return() xalign .99 yalign .99 30 | 31 | # Example Screen 2: Labels 32 | screen radarChart_labels: 33 | 34 | text "Text Labels" xpos 20 ypos 20 35 | add label_chart: 36 | xpos 120 37 | ypos 100 38 | 39 | text "Image Labels" xpos 420 ypos 20 40 | add label_chart_images: 41 | xpos 500 42 | ypos 100 43 | 44 | textbutton "Return" action Return() xalign .99 yalign .99 45 | 46 | # Example Screen 2b: Points 47 | screen radarChart_points: 48 | text "Points" xpos 20 ypos 20 49 | add points_chart: 50 | xpos 100 51 | ypos 100 52 | 53 | textbutton "Return" action Return() xalign .99 yalign .99 54 | 55 | 56 | # Example Screen 3: ATL Transforms 57 | screen radarChart_transforms: 58 | 59 | # When splitting up the pieces of a RadarChart, 60 | # it helps to put them inside a frame. 61 | text "Animated Points" xpos 20 ypos 20 62 | frame: 63 | background None 64 | xysize (200, 200) 65 | xpadding 0 66 | ypadding 0 67 | xpos 50 68 | ypos 50 69 | 70 | # The RadarChart 71 | add animated_points 72 | 73 | # The RadarChart's points, using a Transform 74 | add animated_points.chart_points at moving_points 75 | 76 | # RadarChart where all the labels are split 77 | text "Multiple Transforms" xpos 370 ypos 20 78 | frame: 79 | background None 80 | xysize (200, 200) 81 | xpadding 0 82 | ypadding 0 83 | xpos 400 84 | ypos 150 85 | add animated_spider_web.chart_base at spinner_base 86 | add animated_spider_web.chart_data at spinner_data 87 | add animated_spider_web.chart_lines at spinner_base 88 | add animated_spider_web.chart_labels at spinner_labels 89 | 90 | textbutton "Return" action Return() xalign .99 yalign .99 91 | -------------------------------------------------------------------------------- /icons/Cards-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/renpy-radarchart/7c641a40c35a910174c8d125e79b4d91d840ab47/icons/Cards-48.png -------------------------------------------------------------------------------- /icons/Dashboard-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/renpy-radarchart/7c641a40c35a910174c8d125e79b4d91d840ab47/icons/Dashboard-48.png -------------------------------------------------------------------------------- /icons/Knight Shield-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/renpy-radarchart/7c641a40c35a910174c8d125e79b4d91d840ab47/icons/Knight Shield-48.png -------------------------------------------------------------------------------- /icons/Sword-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/renpy-radarchart/7c641a40c35a910174c8d125e79b4d91d840ab47/icons/Sword-48.png -------------------------------------------------------------------------------- /icons/square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/renpy-radarchart/7c641a40c35a910174c8d125e79b4d91d840ab47/icons/square.png -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Joshua Fehler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /radar_chart/aa_line_ren.py: -------------------------------------------------------------------------------- 1 | """renpy 2 | init -999 python: 3 | """ 4 | class AALineDisplayable(renpy.Displayable): 5 | """Displayable to draw an aaline.""" 6 | def __init__( 7 | self, 8 | radar_chart, 9 | colour: tuple[int, int, int, int], 10 | lines, 11 | **kwargs, 12 | ): 13 | super().__init__(**kwargs) 14 | 15 | self.radar_chart = radar_chart 16 | self.size = radar_chart.size 17 | self.colour = colour 18 | self._lines = lines 19 | 20 | @property 21 | def lines(self): 22 | """Coordinates for the lines.""" 23 | return self._lines 24 | 25 | def render(self, width, height, st, at): # NOQA: D102 26 | render = renpy.Render(self.size, self.size) 27 | 28 | shape = render.canvas() 29 | for line in self.lines: 30 | shape.aaline( 31 | self.colour, 32 | (line['a'].x, line['a'].y), 33 | (line['b'].x, line['b'].y), 34 | ) 35 | 36 | return render 37 | 38 | def per_interact(self): # NOQA: D102 39 | renpy.redraw(self, 0) 40 | -------------------------------------------------------------------------------- /radar_chart/base_radar_chart_ren.py: -------------------------------------------------------------------------------- 1 | from .point_2d_ren import Point2D 2 | 3 | """renpy 4 | init -999 python: 5 | """ 6 | import math # NOQA E402 7 | from typing import Union # NOQA E402 8 | 9 | 10 | class BaseRadarChart: 11 | """Contains all the logical calculations for a RadarChart. 12 | 13 | This doesn't handle the actual drawing, 14 | just calculations for where to draw. 15 | 16 | Args: 17 | size (int): Width & height of the chart 18 | values (list[int]): All the values to chart on the plot 19 | max_value (int): The largest number a value should have 20 | labels(list[displayable]): All the labels for each value 21 | lines(dict): Properties for which lines to draw: 22 | { 23 | "chart":True, 24 | "data":True 25 | "spokes":True, 26 | "webs":[int], # 1-9 allowed. represents 10%, 20%, etc 27 | } 28 | break_limit(bool): If any value can exceed max_value or not 29 | 30 | Raises: 31 | ValueError: If labels is an empty list. 32 | """ 33 | def __init__( 34 | self, 35 | size: int, 36 | values: list[int], 37 | max_value: int = 0, 38 | labels: Union[list, None] = None, 39 | lines: Union[dict[str, Union[bool, list[int]]], None] = None, 40 | break_limit: bool = True, 41 | ): 42 | 43 | super(BaseRadarChart, self).__init__() 44 | 45 | self.size = size 46 | self._values = values 47 | self.max_value = max_value 48 | 49 | self.labels = labels 50 | if labels is not None: 51 | if len(labels) <= 0: 52 | raise ValueError("Empty Label List provided.") 53 | 54 | self.break_limit = break_limit 55 | 56 | if not break_limit: 57 | self._values = self.__validate_values() 58 | 59 | # Point2D: Represents the center point of the chart. 60 | self.origin = size * 0.5 61 | self.origin_point = Point2D(self.origin, self.origin) 62 | 63 | self.number_of_points = len(values) 64 | 65 | # Dict: Default choices for drawing lines. 66 | lines_defaults = { 67 | "chart": True, 68 | "data": True, 69 | "spokes": True, 70 | "webs": [], 71 | } 72 | 73 | # Update defaults with args. 74 | if lines: 75 | lines_defaults.update(lines) 76 | self.lines = lines_defaults 77 | 78 | # Path for the chart's background outline and polygon. 79 | self._endpoints = self.__get_chart_endpoints(self.origin) 80 | self.max_coordinates = self.__physical_coordinates(self._endpoints) 81 | self.chart_polygon = self._build_path(self.max_coordinates) 82 | 83 | # Path for the spokes going from the origin to each max_coordinate. 84 | self.spokes = self._build_spokes() 85 | 86 | # Only build path for the spider-web if required. 87 | if self.lines.get("webs"): 88 | self.web_points = self.__build_web_points() 89 | 90 | # Generate the chart data from the values. 91 | self._generate_chart_data() 92 | 93 | def __build_web_points(self): 94 | """Create an inner outline paths at each 10th of the chart. 95 | 96 | Returns: 97 | list: List of paths for the inner web lines 98 | 99 | Raises: 100 | ValueError: If webs contain anything but integers 1-9, 101 | or if it has any more than once. 102 | """ 103 | webs = self.lines["webs"] 104 | 105 | min_allowed = 1 106 | max_allowed = 9 107 | 108 | for item in webs: 109 | if item < min_allowed: 110 | raise ValueError("Lowest position possible is 1.") 111 | if item > max_allowed: 112 | raise ValueError("Can't use webs past the 9th position.") 113 | 114 | if len(webs) != len(set(webs)): 115 | raise ValueError("Can't use duplicate webs.") 116 | 117 | rv = [] 118 | for item in webs: 119 | radius = self.origin * (float(item) * 0.1) 120 | endpoints = self.__get_chart_endpoints(radius) 121 | phys_endpoints = self.__physical_coordinates(endpoints) 122 | line = self._build_path(phys_endpoints) 123 | rv.append(line) 124 | 125 | return rv 126 | 127 | def __validate_values(self) -> list[int]: 128 | """Check self._values for any value being above self.max_value. 129 | 130 | Replace values above self.max_value with self.max_value 131 | 132 | Returns: 133 | list 134 | """ 135 | for index, value in enumerate(self._values): 136 | if value > self.max_value: 137 | self._values[index] = self.max_value 138 | return self._values 139 | 140 | @property 141 | def values(self): 142 | """All the values to chart on the plot.""" 143 | return self._values 144 | 145 | @values.setter 146 | def values(self, val: int): 147 | """Whenever new values are set, regenerate the chart data.""" 148 | self._values = val 149 | if not self.break_limit: 150 | self._values = self.__validate_values() 151 | self.number_of_points = len(self._values) 152 | self._generate_chart_data() 153 | 154 | def __get_chart_endpoints(self, radius: int) -> list: 155 | """Take a circle and slice it based on the number of data points. 156 | 157 | Each slice is turned into a Point2D. 158 | 159 | Args: 160 | radius (int): Circle's radius 161 | 162 | Returns: 163 | list: Every Point2D created. 164 | """ 165 | chart_slice = (2 * math.pi) / self.number_of_points 166 | 167 | rv = [] 168 | for i in range(self.number_of_points): 169 | angle = chart_slice * i 170 | nx = round(radius * math.sin(angle)) 171 | ny = round(radius * math.cos(angle)) 172 | p2d = Point2D(nx, ny) 173 | 174 | # Correction for upside down chart display. 175 | p2d = p2d.rotate(180) 176 | 177 | rv.append(p2d) 178 | 179 | return rv 180 | 181 | def _values_to_percentage(self): 182 | """Convert values from integer to percentage. 183 | 184 | Builds new list from self.values, turning them into percentages 185 | based on the max_value. 186 | 187 | Returns: 188 | list: Percentage versions of the values. 189 | """ 190 | max_value = float(self.max_value) 191 | 192 | return [(float(value) / max_value) for value in self._values] 193 | 194 | def __physical_coordinates(self, coords): 195 | """Get physical coordinates. 196 | 197 | Returns: 198 | list: Point2D coordinates relative to the origin point. 199 | """ 200 | return [coord + self.origin_point for coord in coords] 201 | 202 | def _build_spokes(self): 203 | """Builds the path for the spokes inside the chart. 204 | 205 | A list of dict are created, one dict for each spoke's 206 | start and end positions: 207 | eg: 208 | { 209 | "a": Point2D(x, y) 210 | "b": Point2D(x1, y1) 211 | } 212 | 213 | Returns: 214 | list[dict]: Path for the spokes. 215 | """ 216 | return [ 217 | {"a":self.origin_point, "b":item} for item in self.max_coordinates 218 | ] 219 | 220 | def _build_path(self, points): 221 | """Creates the logical path for a set of points. 222 | 223 | A list of dict are created, containing the 224 | (x,y) and (x1, y1) Point2D for each line. 225 | 226 | Returns: 227 | list[dict]: Path for a polygon. 228 | """ 229 | # Create 2nd list for (x1, y1). 230 | points_b = points[1:] 231 | points_b += [points[0]] 232 | 233 | return [ 234 | {"a": a, "b": b} for a, b in zip(points, points_b) 235 | ] 236 | 237 | def _generate_chart_data(self): 238 | """Perform all the steps necessary to create the chart data.""" 239 | # Convert values to percentage. 240 | c_values = self._values_to_percentage() 241 | 242 | # Data physical location. 243 | # endpoint * value percentage + origin = data location. 244 | o = self.origin 245 | values_length = [ 246 | Point2D(a.x * b + o, a.y * b + o) for a, b in zip(self._endpoints, c_values) 247 | ] 248 | 249 | # Path for the data plotted. 250 | self.data_polygon = self._build_path(values_length) 251 | 252 | # Path for the origin. 253 | # Used to create animation effect. 254 | self.start_points = [ 255 | {"a": self.origin_point} for point in self.data_polygon 256 | ] 257 | -------------------------------------------------------------------------------- /radar_chart/point_2d_ren.py: -------------------------------------------------------------------------------- 1 | """renpy 2 | init -999 python: 3 | """ 4 | import math 5 | 6 | class Point2D: 7 | """Stores x and y coordinates. 8 | 9 | Args: 10 | x (int): x-axis coordinate 11 | y (int): y-axis coordinate 12 | 13 | """ 14 | def __init__(self, x: int = 0, y: int = 0): 15 | self.x = x 16 | self.y = y 17 | 18 | def __str__(self): 19 | return f"x:{self.x}, y:{self.y}" 20 | 21 | def __add__(self, other): 22 | return Point2D(self.x + other.x, self.y + other.y) 23 | 24 | def __sub__(self, other): 25 | return Point2D(self.x - other.x, self.y - other.y) 26 | 27 | def rotate(self, angle: int): 28 | """Rotate around the x and y axis by the given angle in degrees. 29 | 30 | Args: 31 | angle (int): Degrees to rotate the point 32 | 33 | Returns: 34 | Point2D 35 | """ 36 | rad = angle * math.pi / 180 37 | cos_angle = math.cos(rad) 38 | sin_angle = math.sin(rad) 39 | 40 | x = self.y * sin_angle + self.x * cos_angle 41 | y = self.y * cos_angle - self.x * sin_angle 42 | 43 | return Point2D(x, y) 44 | -------------------------------------------------------------------------------- /radar_chart/polygon_ren.py: -------------------------------------------------------------------------------- 1 | """renpy 2 | init -999 python: 3 | """ 4 | from typing import Union 5 | 6 | 7 | class PolygonDisplayable(renpy.Displayable): 8 | """Displayable to draw a polygon for the RadarChart. 9 | 10 | Args: 11 | radar_chart(displayable): The RadarChart using this displayable. 12 | border(displayable): The border associated with this displayable. 13 | """ 14 | def __init__( 15 | self, 16 | radar_chart, 17 | colour: tuple[int, int, int, int], 18 | border=None, 19 | points: Union[list[tuple[int, int]], None] = None, 20 | **kwargs, 21 | ): 22 | super().__init__(**kwargs) 23 | 24 | self.radar_chart = radar_chart 25 | 26 | self.size = radar_chart.size 27 | self.colour = colour 28 | 29 | self.border = border 30 | 31 | self._points = points 32 | 33 | @property 34 | def points(self): 35 | """Coordinates for the polygon.""" 36 | rv = self._points 37 | return rv 38 | 39 | def render(self, width, height, st, at): # NOQA: D102 40 | render = renpy.Render(self.size, self.size) 41 | 42 | shape = render.canvas() 43 | shape.polygon(self.colour, self.points) 44 | 45 | if self.border is not None: 46 | render.place(self.border, x=0, y=0) 47 | 48 | return render 49 | 50 | def per_interact(self): # NOQA: D102 51 | renpy.redraw(self, 0) 52 | 53 | def visit(self): # NOQA: D102 54 | return [self.border] 55 | -------------------------------------------------------------------------------- /radar_chart/radarchart.rpy: -------------------------------------------------------------------------------- 1 | init -998 python: 2 | import math 3 | 4 | 5 | class _RadarChartData(PolygonDisplayable): 6 | """Draws the polygon that represents the chart data.""" 7 | @property 8 | def points(self): 9 | """Rebuild the points list. 10 | """ 11 | return [ 12 | (point['a'].x, point['a'].y) for point in self.radar_chart.data_polygon 13 | ] 14 | 15 | 16 | class _RadarChartDataBorder(AALineDisplayable): 17 | @property 18 | def lines(self): 19 | return self.radar_chart.data_polygon 20 | 21 | 22 | class _RadarChartLines(renpy.Displayable): 23 | """Collection of AALineDisplayable. 24 | 25 | All the lines that can be drawn inside a chart. 26 | """ 27 | def __init__(self, children, **kwargs): 28 | super(_RadarChartLines, self).__init__(**kwargs) 29 | 30 | self.children = children 31 | self.size = children[0].size 32 | 33 | def render(self, width, height, st, at): 34 | render = renpy.Render(self.size, self.size) 35 | 36 | for child in self.children: 37 | render.place(child, x=0, y=0) 38 | 39 | return render 40 | 41 | def visit(self): 42 | return self.children 43 | 44 | 45 | class _RadarChartLabels(renpy.Displayable): 46 | """Collection of labels for each data point. 47 | 48 | For every data point, 49 | take any displayable and place it as a label for the data. 50 | 51 | Args: 52 | radar_chart(displayable): The RadarChart using this displayable. 53 | 54 | Attributes: 55 | l_padding (int): Padding between the labels and chart for left-aligned labels 56 | r_padding (int): Padding between the labels and chart for right-aligned labels 57 | c_padding (int): Padding between the labels and chart for center-aligned labels 58 | 59 | Raises: 60 | ValueError: If the number of labels and data points are not the same. 61 | """ 62 | def __init__(self, radar_chart, **kwargs): 63 | super(_RadarChartLabels, self).__init__(**kwargs) 64 | 65 | self.radar_chart = radar_chart 66 | self.size = radar_chart.size 67 | self.labels = radar_chart.labels 68 | 69 | self.l_padding = 20 70 | self.r_padding = 20 71 | self.c_padding = 20 72 | 73 | if len(self.radar_chart.chart_polygon) != len(self.labels): 74 | raise ValueError("Amount of labels given does not match amount of data points.") 75 | 76 | def render(self, width, height, st, at): 77 | render = renpy.Render(self.size, self.size) 78 | 79 | origin = round(self.radar_chart.origin) 80 | 81 | for x, item in enumerate(self.labels): 82 | chart = self.radar_chart.chart_polygon[x] 83 | 84 | # Only Text Displayables have a size attribute 85 | try: 86 | w, h = item.size() 87 | 88 | except AttributeError: 89 | child_render = renpy.render(item, width, height, st, at) 90 | w, h = child_render.get_size() 91 | 92 | xa = round(chart["a"].x) 93 | ya = round(chart["a"].y) 94 | 95 | x_center = xa - (w * 0.5) 96 | y_center = ya - (h * 0.5) 97 | 98 | # Left. 99 | if xa < origin: 100 | px = xa - w - self.l_padding 101 | py = y_center 102 | 103 | # Center Top. 104 | elif xa == origin and ya == 0: 105 | px = x_center 106 | py = ya - h - self.c_padding 107 | 108 | # Center Bottom. 109 | elif xa == origin and ya == round(self.size): 110 | px = x_center 111 | py = ya + self.c_padding 112 | 113 | # Right. 114 | else: 115 | px = xa + self.r_padding 116 | py = y_center 117 | 118 | render.place(item, x=px, y=py) 119 | 120 | return render 121 | 122 | def visit(self): 123 | return self.labels 124 | 125 | 126 | class _RadarChartPoints(renpy.Displayable): 127 | """Handles display for all point displayables in a RadarChart. 128 | 129 | Args: 130 | radar_chart(displayable): The RadarChart using this displayable. 131 | """ 132 | def __init__(self, radar_chart, **kwargs): 133 | super(_RadarChartPoints, self).__init__(**kwargs) 134 | 135 | self.radar_chart = radar_chart 136 | self.size = radar_chart.size 137 | 138 | # Displayable at each point. 139 | self.points = [] 140 | for item in radar_chart.data_polygon: 141 | self.points.append(radar_chart.point) 142 | 143 | def render(self, width, height, st, at): 144 | render = renpy.Render(self.size, self.size) 145 | 146 | # Get width and height of point. 147 | child_render = renpy.render(self.points[0], width, height, st, at) 148 | w, h = child_render.get_size() 149 | 150 | data = self.radar_chart.data_polygon 151 | for n in range(len(self.points)): 152 | render.place(self.points[n], x=data[n]["a"].x - w / 2, y=data[n]["a"].y - h / 2) 153 | 154 | return render 155 | 156 | def visit(self): 157 | return self.points 158 | 159 | 160 | class RadarChart(BaseRadarChart, renpy.Displayable): 161 | """ 162 | Displayable that uses BaseRadarChart for the path calculations. 163 | Collects all the displayables that make up the pieces of the RadarChart. 164 | 165 | Args: 166 | data_colour: RGBA tuple or HEX string 167 | line_colour: RGBA tuple or HEX string 168 | background_colour: RGBA tuple or HEX string 169 | point (displayable): Displayable at each value's tip 170 | visible (dict): Properties for which pieces of the RadarChart 171 | should be visible: 172 | { 173 | "base": True, 174 | "data": True, 175 | "lines": True, 176 | "points": True, 177 | "labels": True 178 | } 179 | """ 180 | def __init__(self, 181 | data_colour=(100, 200, 100, 125), 182 | line_colour=(153, 153, 153, 255), 183 | background_colour=(255, 255, 255, 255), 184 | point=None, visible = {}, **kwargs): 185 | 186 | super(RadarChart, self).__init__(**kwargs) 187 | 188 | # Colours. 189 | self.data_colour = color(data_colour) 190 | self.line_colour = color(line_colour) 191 | self.background_colour = color(background_colour) 192 | 193 | visible_defaults = { 194 | "base": True, 195 | "data": True, 196 | "lines": True, 197 | "points": True, 198 | "labels": True 199 | } 200 | 201 | visible_defaults.update(visible) 202 | self.visible = visible_defaults 203 | 204 | # Displayable for the points. 205 | self.point = point 206 | 207 | # Group of point displayables, one for each point on the chart. 208 | if point is not None: 209 | self.chart_points = _RadarChartPoints(self) 210 | else: 211 | self.chart_points = None 212 | 213 | # Lines 214 | chart_lines = [] 215 | chart_line = None 216 | 217 | if self.lines["chart"]: 218 | chart_line = AALineDisplayable(self, line_colour, self.chart_polygon) 219 | 220 | _points = [ 221 | (point['a'].x, point['a'].y) for point in self.chart_polygon 222 | ] 223 | 224 | self.chart_base = PolygonDisplayable( 225 | self, 226 | self.background_colour, 227 | border=chart_line, 228 | points=_points, 229 | ) 230 | 231 | if self.lines["spokes"]: 232 | line = AALineDisplayable(self, line_colour, self.spokes) 233 | chart_lines.append(line) 234 | 235 | self.data_line = None 236 | if self.lines["data"]: 237 | self.data_line = _RadarChartDataBorder(self, line_colour, self.data_polygon) 238 | 239 | if self.lines["webs"]: 240 | for item in self.web_points: 241 | line = AALineDisplayable(self, line_colour, item) 242 | chart_lines.append(line) 243 | 244 | self.chart_lines = None 245 | if len(chart_lines) > 0: 246 | self.chart_lines = _RadarChartLines(chart_lines) 247 | 248 | self.chart_labels = None 249 | if self.labels: 250 | self.chart_labels = _RadarChartLabels(self) 251 | 252 | @property 253 | def chart_data(self): 254 | """Chart Data must be rebuilt every time it's called. 255 | """ 256 | return _RadarChartData(self, self.data_colour, border=self.data_line) 257 | 258 | def render(self, width, height, st, at): 259 | render = renpy.Render(self.size, self.size) 260 | 261 | if self.visible["base"]: 262 | render.place(self.chart_base, x=0, y=0) 263 | 264 | if self.visible["data"]: 265 | render.place(self.chart_data, x=0, y=0) 266 | 267 | if self.chart_lines is not None and self.visible["lines"]: 268 | render.place(self.chart_lines, x=0, y=0) 269 | 270 | if self.point is not None and self.visible["points"]: 271 | render.place(self.chart_points, x=0, y=0) 272 | 273 | if self.labels is not None and self.visible["labels"]: 274 | render.place(self.chart_labels, x=0, y=0) 275 | 276 | return render 277 | 278 | def per_interact(self): 279 | renpy.redraw(self, 0) 280 | 281 | def visit(self): 282 | return [ 283 | self.chart_base, 284 | self.chart_data, 285 | self.chart_lines, 286 | self.chart_points, 287 | self.chart_labels 288 | ] 289 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 99 2 | 3 | [lint.mccabe] 4 | max-complexity = 8 5 | 6 | 7 | [lint.pydocstyle] 8 | convention = "google" 9 | 10 | [lint] 11 | select = [ 12 | "A", 13 | "B", 14 | "BLE", 15 | "C4", 16 | "COM", 17 | "C90", 18 | "D", 19 | "E", 20 | "F", 21 | "N", 22 | "PL", 23 | "PTH", 24 | "RUF", 25 | "S", 26 | "SLOT", 27 | "T20", 28 | "TRY", 29 | "W", 30 | ] 31 | 32 | ignore = [ 33 | "B026", 34 | "D100", 35 | "D105", 36 | "D107", 37 | "D204", 38 | "D205", 39 | "D413", 40 | "D415", 41 | "F405", 42 | "F821", 43 | "PLR0913", 44 | "RUF010", 45 | "TRY003", 46 | ] 47 | -------------------------------------------------------------------------------- /screenshot0001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/renpy-radarchart/7c641a40c35a910174c8d125e79b4d91d840ab47/screenshot0001.png -------------------------------------------------------------------------------- /script.rpy: -------------------------------------------------------------------------------- 1 | # Example Selection Menu 2 | screen example_select: 3 | window: 4 | xalign 0.5 5 | yalign 0.0 6 | vbox: 7 | text "Example Select" 8 | textbutton "Lines & Borders" xfill True action Jump("example_one") 9 | textbutton "Labels & Points" xfill True action Jump("example_two") 10 | textbutton "ATL Transforms" xfill True action Jump("example_three") 11 | textbutton "Updating Values" xfill True action Jump("example_four") 12 | 13 | 14 | label example_select: 15 | call screen example_select 16 | 17 | 18 | label start: 19 | 20 | # Data Setup 21 | python: 22 | # Create some dummy data to use in the Radar Charts 23 | GRAPE_VALUE = 200 24 | APPLE_VALUE = 110 25 | ORANGE_VALUE = 90 26 | BANANA_VALUE = 67 27 | LEMON_VALUE = 100 28 | LIME_VALUE = 82 29 | POTATO_VALUE = 333 30 | 31 | plot_values_2 = [ 32 | GRAPE_VALUE, 33 | APPLE_VALUE, 34 | ORANGE_VALUE, 35 | BANANA_VALUE, 36 | LEMON_VALUE, 37 | LIME_VALUE, 38 | POTATO_VALUE 39 | ] 40 | 41 | LION_VALUE = 200 42 | TIGER_VALUE = 110 43 | BEAR_VALUE = 190 44 | PANDA_VALUE = 67 45 | HORSE_VALUE = 100 46 | CHEETAH_VALUE = 22 47 | EAGLE_VALUE = 333 48 | FALCON_VALUE = 111 49 | 50 | plot_values_3 = [ 51 | LION_VALUE, 52 | TIGER_VALUE, 53 | BEAR_VALUE, 54 | PANDA_VALUE, 55 | HORSE_VALUE, 56 | CHEETAH_VALUE, 57 | EAGLE_VALUE, 58 | FALCON_VALUE 59 | ] 60 | 61 | # Create RadarChart objects 62 | 63 | # Example Screen 1 Charts: 64 | 65 | # Default 66 | default = RadarChart( 67 | size=200, 68 | values=plot_values_2, 69 | max_value=350, 70 | data_colour=(255, 238, 136, 255), 71 | line_colour=(136, 0, 68, 255), 72 | background_colour=(221, 17, 85, 255) 73 | ) 74 | 75 | # Outline, spokes, and no data outline 76 | no_data_outline = RadarChart( 77 | size=200, 78 | values=plot_values_2, 79 | max_value=350, 80 | data_colour=(81, 214, 85, 255), 81 | line_colour=(82, 43, 41, 255), 82 | background_colour=(160, 107, 154, 255), 83 | lines={"data": False}, 84 | ) 85 | 86 | # Outline, no spokes 87 | no_spokes = RadarChart( 88 | size=200, 89 | values=plot_values_2, 90 | max_value=350, 91 | data_colour=(198, 192, 19, 255), 92 | line_colour=(66, 62, 55, 255), 93 | background_colour=(110, 103, 95, 255), 94 | lines={"spokes": False}, 95 | ) 96 | 97 | # No outline 98 | no_outline = RadarChart( 99 | size=200, 100 | values=plot_values_2, 101 | max_value=350, 102 | data_colour=(108, 207, 246, 255), 103 | background_colour=(0, 16, 17, 255), 104 | lines={"chart":False, "data":False, "spokes":False}, 105 | ) 106 | 107 | # Spider-web 108 | spider_web_partial = RadarChart( 109 | size=200, 110 | values=plot_values_2, 111 | max_value=350, 112 | data_colour=(254, 220, 151, 255), 113 | line_colour=(3, 63, 99, 255), 114 | background_colour=(40, 102, 110, 255), 115 | lines={"webs": [2, 4, 6, 8]}, 116 | ) 117 | 118 | # Example Screen 2 Charts 119 | 120 | example_labels_text = [ 121 | Text('Attack'), 122 | Text('Defense'), 123 | Text('Evasion'), 124 | Text('Magic'), 125 | Text('M. Defense'), 126 | Text('M.Evade'), 127 | Text('Speed'), 128 | Text('Stamina') 129 | ] 130 | 131 | example_labels_images = [ 132 | Image('icons/Sword-48.png'), 133 | Image('icons/Knight Shield-48.png'), 134 | Image('icons/Dashboard-48.png'), 135 | Image('icons/Cards-48.png'), 136 | ] 137 | 138 | label_chart = RadarChart( 139 | size=200, 140 | values=plot_values_3, 141 | max_value=350, 142 | data_colour=(213, 71, 130, 255), 143 | line_colour=(0, 0, 0, 255), 144 | background_colour=(20, 21, 17, 170), 145 | labels = example_labels_text 146 | ) 147 | 148 | label_chart_images = RadarChart( 149 | size=200, 150 | values=[300, 246, 765, 444], 151 | max_value=1000, 152 | data_colour=(254, 185, 95, 255), 153 | line_colour=(9, 4, 70, 255), 154 | background_colour=(120, 111, 82, 255), 155 | labels = example_labels_images 156 | ) 157 | 158 | basic_point = Image("icons/square.png") 159 | 160 | points_chart = RadarChart( 161 | size=200, 162 | values=[300, 246, 765, 444, 676], 163 | max_value=1000, 164 | data_colour=(237, 155, 64, 255), 165 | line_colour=(170, 43, 102, 255), 166 | background_colour=(186, 59, 70, 255), 167 | point=basic_point, 168 | lines={'data':False} 169 | ) 170 | 171 | # Example Screen 3 Charts 172 | animated_points = RadarChart( 173 | size=200, 174 | values=[100, 134, 222, 122, 77, 99, 101], 175 | max_value=350, 176 | data_colour=(229, 99, 153, 255), 177 | line_colour=(80, 81, 79, 255), 178 | background_colour=(135, 145, 158, 255), 179 | point=basic_point, 180 | visible={ 181 | "base": True, 182 | "data": True, 183 | "lines": True, 184 | "points": False, 185 | "labels": True 186 | } 187 | ) 188 | 189 | animated_spider_web = RadarChart( 190 | size=200, 191 | values=[50, 50, 30, 50, 50, 50], 192 | max_value=50, 193 | labels = [ 194 | Text("Strength"), 195 | Text("Speed"), 196 | Text("Range"), 197 | Text("Durability"), 198 | Text("Precision"), 199 | Text("Potential") 200 | ], 201 | data_colour=(165, 190, 0, 255), 202 | line_colour=(103, 148, 54, 255), 203 | background_colour=(88, 82, 74, 255), 204 | lines={"webs": [1, 2, 3, 4, 5, 6, 7, 8, 9]}, 205 | visible={"base":False} 206 | ) 207 | 208 | transform moving_points: 209 | zoom 0.0 210 | align (0.5, 0.5) 211 | alpha 0.0 212 | parallel: 213 | linear 0.5 zoom 1.0 214 | linear 0.5 zoom 0.9 215 | repeat 216 | parallel: 217 | linear 1.0 alpha 1.0 218 | 219 | transform spinner_base: 220 | zoom 0.0 221 | align (0.5, 0.5) 222 | rotate 0.0 223 | alpha 0.0 224 | parallel: 225 | linear 1.0 rotate 360 226 | parallel: 227 | linear 1.0 zoom 1.0 228 | parallel: 229 | linear 1.0 alpha 1.0 230 | 231 | transform spinner_data: 232 | zoom 0.0 233 | align (0.5, 0.5) 234 | rotate 0.0 235 | alpha 0.0 236 | parallel: 237 | linear 1.0 rotate 360 238 | parallel: 239 | pause 1.0 240 | linear 0.5 zoom 1.0 241 | parallel: 242 | linear 2.0 alpha 1.0 243 | 244 | transform spinner_labels: 245 | zoom 0.0 246 | align (0.5, 0.5) 247 | alpha 0.0 248 | parallel: 249 | linear 1.0 zoom 1.0 250 | parallel: 251 | linear 1.0 alpha 1.0 252 | 253 | jump example_select 254 | 255 | # Example One: Lines & Borders 256 | label example_one: 257 | call screen radarChart_lines 258 | jump example_select 259 | 260 | # Example Two: Labels & Points 261 | label example_two: 262 | "Example Two: Labels & Points" 263 | "Each value in a RadarChart can have a label assigned to it." 264 | "Each label must be a Displayable. Text or pictures, it doesn't matter." 265 | call screen radarChart_labels 266 | "Look at label example_two in the script to see how they're created." 267 | "You can also assign a Displayable to be shown at each value point." 268 | 269 | call screen radarChart_points 270 | "Like the labels, this can be any Displayable, but unlike the labels, one Displayable is used for every value." 271 | 272 | jump example_select 273 | 274 | label example_three: 275 | 276 | "Example Three: ATL Transforms" 277 | "The RadarChart is a Displayable. As such it can be used with Ren'Py Transforms and ATL." 278 | "However, you may only want to Transform one part of the RadarChart." 279 | "Each RadarChart can be split into 5 pieces: Base, Lines, Data, Labels, and Points." 280 | "These are all Displayables as well, and can all be called separately after creating the RadarChart." 281 | 282 | call screen radarChart_transforms 283 | "By switching off the default visibility of each piece, they won't appear until you explicitly call them." 284 | "This allows you to apply Transforms to each piece." 285 | "Look at label example_three in the script to see how this is done." 286 | 287 | jump example_select 288 | 289 | # Example Four: Updating Values 290 | label example_four: 291 | python: 292 | # Dummy data 293 | COOL_VALUE = 200 294 | BEAUTY_VALUE = 110 295 | CUTE_VALUE = 90 296 | SMART_VALUE = 67 297 | TOUGH_VALUE = 100 298 | 299 | # Stick them into a tuple. 300 | plot_values = ( 301 | COOL_VALUE, 302 | BEAUTY_VALUE, 303 | CUTE_VALUE, 304 | SMART_VALUE, 305 | TOUGH_VALUE 306 | ) 307 | 308 | # Create a basic RadarChart 309 | example_four_chart = RadarChart( 310 | size=200, 311 | values=plot_values, 312 | max_value=255, 313 | data_colour=(100, 200, 100, 255), 314 | line_colour=(153, 153, 153, 255), 315 | background_colour=(255, 255, 255, 255), 316 | ) 317 | 318 | show image example_four_chart at topright 319 | "The values for a RadarChart can be updated by setting RadarChart.values." 320 | "The chart on display's current values are [example_four_chart.values]." 321 | "Take a peek at label example_four in script.rpy." 322 | "We're going to update the values for the chart currently on display." 323 | 324 | python: 325 | # Update the variables. 326 | COOL_VALUE = 255 327 | BEAUTY_VALUE = 143 328 | CUTE_VALUE = 110 329 | SMART_VALUE = 90 330 | TOUGH_VALUE = 245 331 | 332 | # Stick them into a tuple. 333 | new_plot_values = ( 334 | COOL_VALUE, 335 | BEAUTY_VALUE, 336 | CUTE_VALUE, 337 | SMART_VALUE, 338 | TOUGH_VALUE 339 | ) 340 | 341 | # Update values. 342 | example_four_chart.values = new_plot_values 343 | 344 | "Now the values are [example_four_chart.values]." 345 | 346 | "Of course, this can also be done with a chart that uses ATL." 347 | hide image example_four_chart 348 | 349 | python: 350 | # Create RadarChart, but hide the data and lines. 351 | # We'll show them separately. 352 | example_four_animated_chart = RadarChart( 353 | size=200, 354 | values=plot_values, 355 | max_value=255, 356 | data_colour=(100, 200, 100, 255), 357 | line_colour=(153, 153, 153, 255), 358 | background_colour=(255, 255, 255, 255), 359 | visible={ 360 | "data": False, 361 | "lines": False 362 | } 363 | ) 364 | 365 | # Create transform for the data 366 | transform atl_data: 367 | alpha 0.0 368 | align (1.0, 0.0) 369 | linear 1.0 alpha 1.0 370 | 371 | show image example_four_animated_chart at topright 372 | show image example_four_animated_chart.chart_data at atl_data 373 | show image example_four_animated_chart.chart_lines at topright 374 | 375 | "This RadarChart starts off with the values [example_four_animated_chart.values]." 376 | 377 | python: 378 | example_four_animated_chart.values = new_plot_values 379 | 380 | show image example_four_animated_chart.chart_data at atl_data 381 | "Now, let's update them." 382 | "Like the previous chart, the values are updated to [example_four_animated_chart.values]." 383 | 384 | hide image example_four_animated_chart 385 | hide image example_four_animated_chart.chart_data 386 | hide image example_four_animated_chart.chart_lines 387 | 388 | jump example_select 389 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | 4 | [testenv:lint] 5 | basepython=py39 6 | skip_install = true 7 | deps = ruff 8 | changedir = . 9 | commands = ruff check radar_chart 10 | --------------------------------------------------------------------------------