Coverage for piemap/bitmap.py: 56.73%
76 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-04 21:26:31 +00:00
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-04 21:26:31 +00:00
1from typing import no_type_check
3from PIL import Image, ImageDraw, ImageFont # type: ignore
5import piemap.geometry as geom
6from piemap import (
7 ANGLE_MAX,
8 ANGLE_OFF,
9 CIRCLE_CENTER,
10 GRAY,
11 GREEN,
12 HEIGHT,
13 HEIGHT_OFF,
14 LABEL_SIZE,
15 LINE_WIDTH,
16 PIE_BOX,
17 RADIUS,
18 RED,
19 SUBTITLE_SIZE,
20 TITLE_SIZE,
21 TOP_LEFT_X,
22 TOP_LEFT_Y,
23 WHITE,
24 WIDTH,
25 WIDTH_HALF,
26 YELLOW,
27)
29FONT_PATH = '../FreeMono.ttf'
30OUT_PNG_PATH = '../bitmap.png'
31FAKE_EPS_DEGREES = 0.05
33Coord = tuple[float, float]
34BBox = tuple[Coord, Coord]
37@no_type_check
38def start_of(n: int, n_max: int) -> float:
39 """Start angle in values [0, MAX] first ref is 12 o'clock (OFF) with the of sector n of [1, N]."""
40 fractional_angle = ANGLE_MAX / n_max
41 return (n * fractional_angle + ANGLE_OFF - fractional_angle / 2) % ANGLE_MAX
44@no_type_check
45def middle_of(n: int, n_max: int) -> float:
46 """Middle angle (axis) in values [0, MAX] first ref is 12 o'clock (OFF) with the of sector n of [1, N]."""
47 return (n * ANGLE_MAX / n_max + ANGLE_OFF) % ANGLE_MAX
50@no_type_check
51def end_of(n: int, n_max: int) -> float:
52 """End angle in values [0, MAX] first ref is 12 o'clock (OFF) with the of sector n of [1, N]."""
53 return (start_of(n, n_max) + ANGLE_MAX / n_max) % ANGLE_MAX
56@no_type_check
57def bbox_from_radii(ref_r: int, r: float) -> BBox:
58 """Return bbox as left upper and right lower x,y tuples from radius within reference radius"""
59 shift = ref_r - r
60 return (TOP_LEFT_X + shift, TOP_LEFT_Y + shift), (TOP_LEFT_X + ref_r + r, TOP_LEFT_Y + ref_r + r)
63@no_type_check
64def extrude_bbox_by(bbox: BBox, extrusion: int) -> BBox:
65 """Extrude bounding box symmetrically by extrusion amount."""
66 return (bbox[0][0] - extrusion, bbox[0][1] - extrusion), (bbox[1][0] + extrusion, bbox[1][1] + extrusion)
69@no_type_check
70def fake_full_circle(angle: float) -> Coord:
71 """Fake a full circle to reduce the number of functions used."""
72 return angle, angle - FAKE_EPS_DEGREES
75@no_type_check
76def fake_line(angle: float) -> Coord:
77 """Fake a line to reduce the number of functions used."""
78 return angle, angle + FAKE_EPS_DEGREES
81@no_type_check
82def draw_titles(engine, title: str, subtitle: str) -> None:
83 """Draw the title and subtitle centered on top of the pie and adjust placement for text length."""
84 t_fnt = ImageFont.truetype(FONT_PATH, TITLE_SIZE)
85 st_fnt = ImageFont.truetype(FONT_PATH, SUBTITLE_SIZE)
86 t_len = engine.textlength(title, font=t_fnt)
87 st_len = engine.textlength(subtitle, font=st_fnt)
88 engine.multiline_text((WIDTH_HALF - int(t_len / 2), HEIGHT_OFF), title, font=t_fnt, fill=(0, 0, 0), align='center')
89 engine.multiline_text(
90 (WIDTH_HALF - int(st_len / 2), HEIGHT_OFF + TITLE_SIZE), subtitle, font=st_fnt, fill=(0, 0, 0), align='center'
91 )
94@no_type_check
95def draw_label_at(engine, label: str, at: Coord) -> None:
96 """Draw the label starting at position."""
97 fnt = ImageFont.truetype(FONT_PATH, LABEL_SIZE)
98 l_len = engine.textlength(label, font=fnt)
99 engine.multiline_text((at[0] - int(l_len / 2), at[1]), label, font=fnt, fill=(0, 0, 0), align='center')
102@no_type_check
103def to_plot(xy: Coord) -> Coord:
104 """Map [0, 1] x [0, 1] onto the plot area that goes east, south."""
105 return NotImplemented
108@no_type_check
109def render(values: tuple[float | None, ...]) -> None:
110 """Make importable to support tests."""
111 n_dim = len(values)
113 # create an image for prototyping
114 im = Image.new(mode='RGBA', size=(WIDTH, HEIGHT), color=WHITE)
116 # create prototyping draw context
117 draw = ImageDraw.Draw(im)
119 # All good if above disc at 80% all sectors below receive proportional red coloring adding to the yellow
120 draw.pieslice(bbox_from_radii(RADIUS, RADIUS * 0.80), *fake_full_circle(ANGLE_OFF), fill=RED)
122 # Inner disc to hide singularity noise at center
123 draw.pieslice(bbox_from_radii(RADIUS, RADIUS * 0.03), *fake_full_circle(ANGLE_OFF), fill=GRAY)
125 # The sectors
126 for n, val in enumerate(values):
127 if val is None:
128 draw.pieslice(bbox_from_radii(RADIUS, RADIUS), start_of(n, n_dim), end_of(n, n_dim), fill=WHITE)
129 continue
131 color = YELLOW if val < RADIUS * 0.80 else GREEN
132 draw.pieslice(bbox_from_radii(RADIUS, val), start_of(n, n_dim), end_of(n, n_dim), fill=color)
134 draw_label_at(draw, '^', CIRCLE_CENTER) # protonly
135 draw_label_at(draw, 'L', (CIRCLE_CENTER[0] - RADIUS, CIRCLE_CENTER[1] + RADIUS)) # protonly
136 # The axes and dim=value labels
137 for n, val in enumerate(values):
138 draw.pieslice(
139 extrude_bbox_by(PIE_BOX, 15), *fake_line(middle_of(n, n_dim)), fill=GRAY, outline=GRAY, width=LINE_WIDTH
140 )
141 coords = geom.xy_point_from_radius_angle(RADIUS, middle_of(n, n_dim), *CIRCLE_CENTER)
142 if val is None:
143 val_disp = 'n/a'
144 else:
145 val_disp = f'{round(val, 1)}|{round(val/RADIUS, 1)}' # |{[round(c, 1) for c in coords]}'
146 # ang_quest = (middle_of(n, n_dim) - ANGLE_OFF) % 360
147 # val_disp = f'{round(ang_quest, 1)}|{[round(c, 1) for c in coords]}'
148 # draw_label_at(draw, f'd[{n}]={val_disp}', (PIE_BOX[0][0], PIE_BOX[0][1] + n * LABEL_SIZE))
149 draw_label_at(draw, f'd[{n}]={val_disp}', coords)
151 # outer circle (axis marker joining all dimensions)
152 draw.pieslice(
153 bbox_from_radii(RADIUS, RADIUS * 1.00), *fake_full_circle(ANGLE_OFF), fill=None, outline=GRAY, width=LINE_WIDTH
154 )
156 # All good if above disc at 80% circle only as marker
157 draw.pieslice(
158 bbox_from_radii(RADIUS, RADIUS * 0.80), *fake_full_circle(ANGLE_OFF), fill=None, outline=GRAY, width=1
159 )
161 # title and sub title
162 draw_titles(draw, title='Hällo', subtitle='Wörldß')
163 del draw
165 # im.resize((640, 640), resample=Image.Resampling.LANCZOS)
166 im.save(OUT_PNG_PATH, 'PNG')
168 im.show()
171if __name__ == '__main__': # pragma: no cover
172 R = RADIUS
173 r = (R, R * 0.90, R * 0.80, R * 0.70, R * 0.65, R * 0.60, R * 0.55, 0, None)
174 render(r)