You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

229 lines
9.6 KiB

  1. import argparse
  2. sample_usage = """example:
  3. python traitograph.py --labels a b c --values 1 2 3 --max-value 4
  4. python traitograph.py -o "character.png" --labels n1 n2 n3 n4 --values 1 3 2 3 --max-value 4
  5. python traitograph.py --labels curious organized energetic friendly confident --values 5 4 3 4 2 --max-value 7 -d True
  6. """
  7. parser = argparse.ArgumentParser(description="Generate a chart showing character traits.",
  8. epilog=sample_usage,
  9. formatter_class=argparse.RawDescriptionHelpFormatter)
  10. parser.add_argument("--labels", "-l", dest="labels",
  11. required=True, nargs="+", metavar="label",
  12. help="The names of the character traits")
  13. parser.add_argument("--values", "-v", dest="values",
  14. required=True, nargs="+", type=int, metavar="value",
  15. help="The values for the character traits")
  16. parser.add_argument("--max-value", "-m", dest="max_value",
  17. required=True, type=int, metavar="max",
  18. help="The maximum value for any trait")
  19. parser.add_argument("--out-file", "-o", dest="out_file_name",
  20. metavar="out_file", default="chart.png",
  21. help="The file to which the image will be written. (Defaults to \"chart.png\")")
  22. parser.add_argument("--foreground", "-fg", dest="foreground",
  23. nargs=3, type=int, default=[0,0,0], metavar="channel",
  24. help="The foreground color of the chart. (Values from 0 to 255 -- defaults to [0,0,0])")
  25. parser.add_argument("--background", "-bg", dest="background",
  26. nargs=3, type=int, default=[255,255,255], metavar="channel",
  27. help="The background color of the chart. (Values from 0 to 255 -- defaults to [255,255,255])")
  28. parser.add_argument("--trait-color", "-tc", dest="trait_color",
  29. nargs=4, type=int, default=[0,190,190,100], metavar="channel",
  30. help="The color of the colored part of the chart. (Values from 0 to 255 -- defaults to [0,190,190,100])")
  31. parser.add_argument("--outer-padding", "-op", dest="outer_padding",
  32. type=int, default=20, metavar="pixel_count",
  33. help="The additional padding applied around the chart. (Defaults to 20)")
  34. parser.add_argument("--line-spacing", "-ls", dest="line_spacing",
  35. type=int, default=40, metavar="pixel_count",
  36. help="The distance between the lines. (Defaults to 40)")
  37. parser.add_argument("--dot-size", "-ds", dest="dot_size",
  38. type=int, default=2, metavar="dot_size",
  39. help="The size of the dots. (Defaults to 2)")
  40. parser.add_argument("--display", "-d", dest="display",
  41. type=bool, default=False, metavar="display",
  42. help="If True, shows the generated plot in a window. (Defaults to False)")
  43. args = parser.parse_args()
  44. import math
  45. import os
  46. os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
  47. import pygame
  48. from pygame import gfxdraw
  49. def generate_angles(n):
  50. return [i * math.tau / n for i in range(n)]
  51. def generate_n_gon_points(n, center, radii):
  52. if type(radii) == int:
  53. radii = [radii] * n
  54. else:
  55. assert(len(radii) == n)
  56. # to have the first vertex at the top rather than the bottom, otherwise
  57. # odd-numbered n-gons look like they are upside down:
  58. circle_offset = math.pi
  59. # go clockwise, so flip the sign in the sin and cos, functions (I know it's
  60. # unnecessary for cos, but it looks better when aligned :)
  61. return [(int(math.sin(-angle + circle_offset) * radius + center[0]), # x coordintate
  62. int(math.cos(-angle + circle_offset) * radius + center[1])) # y coordintate
  63. for angle, radius in zip(generate_angles(n), radii)]
  64. def draw_trait_polygon(surface, plot_center):
  65. transparent_overlay = pygame.Surface(surface.get_size(), pygame.SRCALPHA)
  66. radii = [value * args.line_spacing for value in args.values]
  67. trait_points = generate_n_gon_points(len(radii), plot_center, radii)
  68. trait_color_full_alpha = args.trait_color[:3]
  69. for point in trait_points:
  70. gfxdraw.filled_circle(surface, point[0], point[1], args.dot_size, trait_color_full_alpha)
  71. gfxdraw.aacircle(surface, point[0], point[1], args.dot_size, trait_color_full_alpha)
  72. pygame.draw.aalines(surface, trait_color_full_alpha, True, trait_points)
  73. # draw the transparent polygon
  74. pygame.draw.polygon(transparent_overlay, args.trait_color, trait_points)
  75. surface.blit(transparent_overlay, (0,0))
  76. def generate_labels(points, surface):
  77. label_surfaces = [font.render(label, True, args.foreground) for label in args.labels]
  78. label_offsets = []
  79. tolerance = 0.05
  80. min_x = 0
  81. min_y = 0
  82. max_x, max_y = surface.get_size()
  83. # calcualte the offsets for all the labels, depeneding on the position
  84. # around the plot
  85. for label_surface, point, alpha in zip(label_surfaces, points, generate_angles(len(points))):
  86. label_width, label_height = label_surface.get_size()
  87. # these offsets are for offsetting the actual label, so that it has soem
  88. # distance form the point it is supposed to stick to
  89. vertical_text_offset = 5
  90. horizontal_text_offset = 10
  91. if abs(alpha) < tolerance: # label is at top
  92. offset = (-label_width//2, -label_height - vertical_text_offset)
  93. elif abs(alpha-math.pi) < tolerance: # label is at bottom
  94. offset = (-label_width//2, + vertical_text_offset)
  95. elif abs(alpha-math.tau/4) < tolerance: # label is at right
  96. offset = (horizontal_text_offset, -label_height//2)
  97. elif abs(alpha-3*math.tau/4) < tolerance: # label is at left
  98. offset = (-label_width-horizontal_text_offset, -label_height//2)
  99. elif 0 < alpha < math.tau/4: # label is at top right
  100. offset = (horizontal_text_offset//2, -label_height-vertical_text_offset//2)
  101. elif math.tau/4 < alpha < math.tau/2: # label is at bottom right
  102. offset = (horizontal_text_offset//2, vertical_text_offset//2)
  103. elif math.tau/2 < alpha < 3*math.tau/4: # label is at bottom left
  104. offset = (-label_width-horizontal_text_offset//2, vertical_text_offset//2)
  105. else: # label is at top left
  106. offset = (-label_width-horizontal_text_offset//2, -label_height-vertical_text_offset//2)
  107. min_x = min(min_x, point[0] + offset[0])
  108. max_x = max(max_x, point[0] + offset[0] + label_width)
  109. min_y = min(min_y, point[1] + offset[1])
  110. max_y = max(max_y, point[1] + offset[1] + label_height)
  111. label_offsets.append(offset)
  112. # calculate new surface size
  113. padding = 10
  114. width = max_x - min_x + 2*padding
  115. height = max_y - min_y + 2*padding
  116. new_surface = pygame.Surface((width, height))
  117. new_surface.fill(args.background)
  118. # blit the original plot
  119. new_surface.blit(surface, (-min_x + padding,-min_y + padding))
  120. # blit all the labels
  121. for label_offset, label_surface, point in zip(label_offsets, label_surfaces, points):
  122. new_surface.blit(label_surface, (point[0] + label_offset[0] - min_x + padding, point[1] + label_offset[1] -min_y + padding))
  123. return new_surface
  124. def generate_plot():
  125. # figure out exactly, how big the surface needs to be, by looking at the
  126. # outer most points
  127. dimension_count = len(args.labels)
  128. radius = args.line_spacing * args.max_value
  129. points = generate_n_gon_points(dimension_count, (0, 0), radius)
  130. max_x = max((point[0] for point in points))
  131. min_x = -max_x # symmetry: fist point is always straight up
  132. min_y = -radius # fist point is always straight up
  133. max_y = max((point[1] for point in points))
  134. padding = args.dot_size//2 + 4
  135. surface_size = (max_x - min_x + 2*(padding),
  136. max_y - min_y + 2*(padding))
  137. plot_center = [surface_size[0] // 2,
  138. radius + padding]
  139. # generate the surface
  140. surface = pygame.Surface(surface_size)
  141. surface.fill(args.background)
  142. radius = args.line_spacing
  143. for i in range(args.max_value):
  144. points = generate_n_gon_points(dimension_count, plot_center, radius)
  145. pygame.draw.aalines(surface, args.foreground, True, points)
  146. for point in points:
  147. gfxdraw.filled_circle(surface, point[0], point[1], args.dot_size, args.foreground)
  148. gfxdraw.aacircle(surface, point[0], point[1], args.dot_size, args.foreground)
  149. textsurface = font.render(f"{i+1}", True, args.foreground)
  150. text_location = (points[0][0] - textsurface.get_size()[0] // 2,
  151. points[0][1] + int(args.line_spacing * 0.2))
  152. surface.blit(textsurface, text_location)
  153. radius += args.line_spacing
  154. # draw in the trait polygon
  155. draw_trait_polygon(surface, plot_center)
  156. # generate the labels
  157. return generate_labels(points, surface)
  158. if __name__ == "__main__":
  159. assert(len(args.labels) == len(args.values))
  160. assert(args.max_value > 0)
  161. pygame.init()
  162. font = pygame.font.Font(pygame.font.get_default_font(), int(args.line_spacing*0.4))
  163. surface = generate_plot()
  164. pygame.image.save(surface, args.out_file_name)
  165. if args.display:
  166. window = pygame.display.set_mode(surface.get_size())
  167. pygame.display.set_caption("Trait-o-graph")
  168. window.blit(surface, (0, 0))
  169. pygame.display.update()
  170. running = True
  171. while running:
  172. for event in pygame.event.get():
  173. if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
  174. running = False
  175. else:
  176. print("done")
  177. pygame.quit()