plotlive 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotlive/colors.py ADDED
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import numpy as np
4
+
5
+ # matplotlib single-char shortcuts
6
+ _SINGLE_CHAR: dict[str, tuple[int, int, int]] = {
7
+ 'b': (31, 119, 180),
8
+ 'r': (214, 39, 40),
9
+ 'g': (44, 160, 44),
10
+ 'y': (188, 189, 34),
11
+ 'k': (0, 0, 0),
12
+ 'w': (255, 255, 255),
13
+ 'm': (148, 103, 189),
14
+ 'c': (23, 190, 207),
15
+ }
16
+
17
+ # Default color cycle (matplotlib tab10)
18
+ DEFAULT_CYCLE: list[tuple[int, int, int]] = [
19
+ (31, 119, 180),
20
+ (255, 127, 14),
21
+ (44, 160, 44),
22
+ (214, 39, 40),
23
+ (148, 103, 189),
24
+ (140, 86, 75),
25
+ (227, 119, 194),
26
+ (127, 127, 127),
27
+ (188, 189, 34),
28
+ (23, 190, 207),
29
+ ]
30
+
31
+ # CSS named colors (subset)
32
+ _NAMED: dict[str, tuple[int, int, int]] = {
33
+ 'aliceblue': (240, 248, 255), 'antiquewhite': (250, 235, 215),
34
+ 'aqua': (0, 255, 255), 'aquamarine': (127, 255, 212),
35
+ 'azure': (240, 255, 255), 'beige': (245, 245, 220),
36
+ 'bisque': (255, 228, 196), 'black': (0, 0, 0),
37
+ 'blanchedalmond': (255, 235, 205), 'blue': (0, 0, 255),
38
+ 'blueviolet': (138, 43, 226), 'brown': (165, 42, 42),
39
+ 'burlywood': (222, 184, 135), 'cadetblue': (95, 158, 160),
40
+ 'chartreuse': (127, 255, 0), 'chocolate': (210, 105, 30),
41
+ 'coral': (255, 127, 80), 'cornflowerblue': (100, 149, 237),
42
+ 'cornsilk': (255, 248, 220), 'crimson': (220, 20, 60),
43
+ 'cyan': (0, 255, 255), 'darkblue': (0, 0, 139),
44
+ 'darkcyan': (0, 139, 139), 'darkgoldenrod': (184, 134, 11),
45
+ 'darkgray': (169, 169, 169), 'darkgreen': (0, 100, 0),
46
+ 'darkgrey': (169, 169, 169), 'darkkhaki': (189, 183, 107),
47
+ 'darkmagenta': (139, 0, 139), 'darkolivegreen': (85, 107, 47),
48
+ 'darkorange': (255, 140, 0), 'darkorchid': (153, 50, 204),
49
+ 'darkred': (139, 0, 0), 'darksalmon': (233, 150, 122),
50
+ 'darkseagreen': (143, 188, 143), 'darkslateblue': (72, 61, 139),
51
+ 'darkslategray': (47, 79, 79), 'darkturquoise': (0, 206, 209),
52
+ 'darkviolet': (148, 0, 211), 'deeppink': (255, 20, 147),
53
+ 'deepskyblue': (0, 191, 255), 'dimgray': (105, 105, 105),
54
+ 'dodgerblue': (30, 144, 255), 'firebrick': (178, 34, 34),
55
+ 'floralwhite': (255, 250, 240), 'forestgreen': (34, 139, 34),
56
+ 'fuchsia': (255, 0, 255), 'gainsboro': (220, 220, 220),
57
+ 'ghostwhite': (248, 248, 255), 'gold': (255, 215, 0),
58
+ 'goldenrod': (218, 165, 32), 'gray': (128, 128, 128),
59
+ 'green': (0, 128, 0), 'greenyellow': (173, 255, 47),
60
+ 'grey': (128, 128, 128), 'honeydew': (240, 255, 240),
61
+ 'hotpink': (255, 105, 180), 'indianred': (205, 92, 92),
62
+ 'indigo': (75, 0, 130), 'ivory': (255, 255, 240),
63
+ 'khaki': (240, 230, 140), 'lavender': (230, 230, 250),
64
+ 'lawngreen': (124, 252, 0), 'lemonchiffon': (255, 250, 205),
65
+ 'lightblue': (173, 216, 230), 'lightcoral': (240, 128, 128),
66
+ 'lightcyan': (224, 255, 255), 'lightgray': (211, 211, 211),
67
+ 'lightgreen': (144, 238, 144), 'lightgrey': (211, 211, 211),
68
+ 'lightpink': (255, 182, 193), 'lightsalmon': (255, 160, 122),
69
+ 'lightseagreen': (32, 178, 170), 'lightskyblue': (135, 206, 250),
70
+ 'lightslategray': (119, 136, 153), 'lightsteelblue': (176, 196, 222),
71
+ 'lightyellow': (255, 255, 224), 'lime': (0, 255, 0),
72
+ 'limegreen': (50, 205, 50), 'linen': (250, 240, 230),
73
+ 'magenta': (255, 0, 255), 'maroon': (128, 0, 0),
74
+ 'mediumaquamarine': (102, 205, 170), 'mediumblue': (0, 0, 205),
75
+ 'mediumorchid': (186, 85, 211), 'mediumpurple': (147, 112, 219),
76
+ 'mediumseagreen': (60, 179, 113), 'mediumslateblue': (123, 104, 238),
77
+ 'mediumspringgreen': (0, 250, 154), 'mediumturquoise': (72, 209, 204),
78
+ 'mediumvioletred': (199, 21, 133), 'midnightblue': (25, 25, 112),
79
+ 'mintcream': (245, 255, 250), 'mistyrose': (255, 228, 225),
80
+ 'moccasin': (255, 228, 181), 'navajowhite': (255, 222, 173),
81
+ 'navy': (0, 0, 128), 'oldlace': (253, 245, 230),
82
+ 'olive': (128, 128, 0), 'olivedrab': (107, 142, 35),
83
+ 'orange': (255, 165, 0), 'orangered': (255, 69, 0),
84
+ 'orchid': (218, 112, 214), 'palegoldenrod': (238, 232, 170),
85
+ 'palegreen': (152, 251, 152), 'paleturquoise': (175, 238, 238),
86
+ 'palevioletred': (219, 112, 147), 'papayawhip': (255, 239, 213),
87
+ 'peachpuff': (255, 218, 185), 'peru': (205, 133, 63),
88
+ 'pink': (255, 192, 203), 'plum': (221, 160, 221),
89
+ 'powderblue': (176, 224, 230), 'purple': (128, 0, 128),
90
+ 'rebeccapurple': (102, 51, 153), 'red': (255, 0, 0),
91
+ 'rosybrown': (188, 143, 143), 'royalblue': (65, 105, 225),
92
+ 'saddlebrown': (139, 69, 19), 'salmon': (250, 128, 114),
93
+ 'sandybrown': (244, 164, 96), 'seagreen': (46, 139, 87),
94
+ 'seashell': (255, 245, 238), 'sienna': (160, 82, 45),
95
+ 'silver': (192, 192, 192), 'skyblue': (135, 206, 235),
96
+ 'slateblue': (106, 90, 205), 'slategray': (112, 128, 144),
97
+ 'slategrey': (112, 128, 144), 'snow': (255, 250, 250),
98
+ 'springgreen': (0, 255, 127), 'steelblue': (70, 130, 180),
99
+ 'tan': (210, 180, 140), 'teal': (0, 128, 128),
100
+ 'thistle': (216, 191, 216), 'tomato': (255, 99, 71),
101
+ 'turquoise': (64, 224, 208), 'violet': (238, 130, 238),
102
+ 'wheat': (245, 222, 179), 'white': (255, 255, 255),
103
+ 'whitesmoke': (245, 245, 245), 'yellow': (255, 255, 0),
104
+ 'yellowgreen': (154, 205, 50),
105
+ }
106
+
107
+
108
+ def to_rgba(color, alpha: float = 1.0) -> tuple[int, int, int, int]:
109
+ """Parse any matplotlib color spec into (R, G, B, A) uint8 tuple."""
110
+ if color is None:
111
+ return (0, 0, 0, 0)
112
+
113
+ a = int(round(alpha * 255))
114
+
115
+ # Already a tuple/list
116
+ if isinstance(color, (tuple, list)):
117
+ vals = list(color)
118
+ if len(vals) == 4:
119
+ # Has embedded alpha — use it
120
+ if all(isinstance(v, float) and v <= 1.0 for v in vals):
121
+ return (int(round(vals[0]*255)), int(round(vals[1]*255)),
122
+ int(round(vals[2]*255)), int(round(vals[3]*255)))
123
+ return (int(vals[0]), int(vals[1]), int(vals[2]), int(vals[3]))
124
+ if len(vals) == 3:
125
+ if all(isinstance(v, float) for v in vals) and all(0.0 <= v <= 1.0 for v in vals):
126
+ return (int(round(vals[0]*255)), int(round(vals[1]*255)),
127
+ int(round(vals[2]*255)), a)
128
+ return (int(vals[0]), int(vals[1]), int(vals[2]), a)
129
+ raise ValueError(f"Color tuple must have 3 or 4 elements, got {color}")
130
+
131
+ # numpy array
132
+ if isinstance(color, np.ndarray):
133
+ return to_rgba(color.tolist(), alpha)
134
+
135
+ if not isinstance(color, str):
136
+ raise ValueError(f"Unrecognized color: {color!r}")
137
+
138
+ s = color.strip()
139
+
140
+ # 'none' / 'None'
141
+ if s.lower() == 'none':
142
+ return (0, 0, 0, 0)
143
+
144
+ # Single char
145
+ if s in _SINGLE_CHAR:
146
+ r, g, b = _SINGLE_CHAR[s]
147
+ return (r, g, b, a)
148
+
149
+ # Cycle alias C0-C9
150
+ if len(s) == 2 and s[0] == 'C' and s[1].isdigit():
151
+ r, g, b = DEFAULT_CYCLE[int(s[1])]
152
+ return (r, g, b, a)
153
+
154
+ # Hex
155
+ if s.startswith('#'):
156
+ h = s[1:]
157
+ if len(h) == 3:
158
+ h = h[0]*2 + h[1]*2 + h[2]*2
159
+ if len(h) == 6:
160
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), a)
161
+ if len(h) == 8:
162
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16))
163
+ raise ValueError(f"Bad hex color: {color!r}")
164
+
165
+ # Grayscale string '0.5'
166
+ try:
167
+ v = float(s)
168
+ g_val = int(round(v * 255))
169
+ return (g_val, g_val, g_val, a)
170
+ except ValueError:
171
+ pass
172
+
173
+ # Named color
174
+ low = s.lower()
175
+ if low in _NAMED:
176
+ r, g, b = _NAMED[low]
177
+ return (r, g, b, a)
178
+
179
+ raise ValueError(f"Unrecognized color: {color!r}")
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Colormaps
184
+ # ---------------------------------------------------------------------------
185
+
186
+ class Colormap:
187
+ def __init__(self, name: str, stops: list[tuple[float, float, float, float]]):
188
+ self.name = name
189
+ self._stops = sorted(stops, key=lambda s: s[0])
190
+
191
+ def __call__(self, t: float | np.ndarray) -> np.ndarray:
192
+ t_arr = np.asarray(t, dtype=float)
193
+ scalar = t_arr.ndim == 0
194
+ t_arr = np.atleast_1d(np.clip(t_arr, 0.0, 1.0))
195
+ result = np.zeros((*t_arr.shape, 4), dtype=np.uint8)
196
+ stops = self._stops
197
+ for i, ti in np.ndenumerate(t_arr):
198
+ # find surrounding stops
199
+ if ti <= stops[0][0]:
200
+ _, r, g, b = stops[0]
201
+ result[i] = (int(r*255), int(g*255), int(b*255), 255)
202
+ continue
203
+ if ti >= stops[-1][0]:
204
+ _, r, g, b = stops[-1]
205
+ result[i] = (int(r*255), int(g*255), int(b*255), 255)
206
+ continue
207
+ for j in range(len(stops) - 1):
208
+ t0, r0, g0, b0 = stops[j]
209
+ t1, r1, g1, b1 = stops[j+1]
210
+ if t0 <= ti <= t1:
211
+ f = (ti - t0) / (t1 - t0)
212
+ result[i] = (
213
+ int((r0 + f*(r1-r0))*255),
214
+ int((g0 + f*(g1-g0))*255),
215
+ int((b0 + f*(b1-b0))*255),
216
+ 255,
217
+ )
218
+ break
219
+ if scalar:
220
+ return result[0]
221
+ return result
222
+
223
+ def to_lut(self, n: int = 256) -> np.ndarray:
224
+ t = np.linspace(0.0, 1.0, n)
225
+ return self(t)
226
+
227
+
228
+ def _make_colormaps() -> dict[str, Colormap]:
229
+ cmaps = {}
230
+
231
+ # viridis
232
+ cmaps['viridis'] = Colormap('viridis', [
233
+ (0.0, 0.267, 0.005, 0.329),
234
+ (0.25, 0.230, 0.322, 0.546),
235
+ (0.5, 0.128, 0.566, 0.551),
236
+ (0.75, 0.370, 0.788, 0.384),
237
+ (1.0, 0.993, 0.906, 0.144),
238
+ ])
239
+
240
+ # plasma
241
+ cmaps['plasma'] = Colormap('plasma', [
242
+ (0.0, 0.051, 0.031, 0.529),
243
+ (0.25, 0.459, 0.063, 0.608),
244
+ (0.5, 0.798, 0.165, 0.412),
245
+ (0.75, 0.973, 0.463, 0.129),
246
+ (1.0, 0.940, 0.975, 0.131),
247
+ ])
248
+
249
+ # inferno
250
+ cmaps['inferno'] = Colormap('inferno', [
251
+ (0.0, 0.0, 0.0, 0.016),
252
+ (0.25, 0.220, 0.016, 0.388),
253
+ (0.5, 0.576, 0.149, 0.404),
254
+ (0.75, 0.902, 0.435, 0.196),
255
+ (1.0, 0.988, 1.0, 0.643),
256
+ ])
257
+
258
+ # magma
259
+ cmaps['magma'] = Colormap('magma', [
260
+ (0.0, 0.0, 0.0, 0.016),
261
+ (0.25, 0.208, 0.012, 0.392),
262
+ (0.5, 0.588, 0.114, 0.475),
263
+ (0.75, 0.929, 0.518, 0.608),
264
+ (1.0, 0.988, 0.992, 0.749),
265
+ ])
266
+
267
+ # coolwarm (diverging, blue->white->red)
268
+ cmaps['coolwarm'] = Colormap('coolwarm', [
269
+ (0.0, 0.227, 0.341, 0.749),
270
+ (0.5, 0.867, 0.867, 0.867),
271
+ (1.0, 0.706, 0.016, 0.149),
272
+ ])
273
+
274
+ # RdBu (diverging)
275
+ cmaps['RdBu'] = Colormap('RdBu', [
276
+ (0.0, 0.404, 0.004, 0.122),
277
+ (0.25, 0.839, 0.376, 0.302),
278
+ (0.5, 0.969, 0.969, 0.969),
279
+ (0.75, 0.404, 0.663, 0.812),
280
+ (1.0, 0.020, 0.188, 0.380),
281
+ ])
282
+
283
+ # Blues
284
+ cmaps['Blues'] = Colormap('Blues', [
285
+ (0.0, 0.969, 0.984, 1.0),
286
+ (0.5, 0.420, 0.682, 0.839),
287
+ (1.0, 0.031, 0.188, 0.420),
288
+ ])
289
+
290
+ # Reds
291
+ cmaps['Reds'] = Colormap('Reds', [
292
+ (0.0, 1.0, 0.961, 0.941),
293
+ (0.5, 0.988, 0.553, 0.349),
294
+ (1.0, 0.404, 0.0, 0.051),
295
+ ])
296
+
297
+ # jet (legacy)
298
+ cmaps['jet'] = Colormap('jet', [
299
+ (0.0, 0.0, 0.0, 0.5),
300
+ (0.11, 0.0, 0.0, 1.0),
301
+ (0.35, 0.0, 1.0, 1.0),
302
+ (0.5, 0.0, 0.5, 0.0),
303
+ (0.66, 1.0, 1.0, 0.0),
304
+ (0.89, 1.0, 0.0, 0.0),
305
+ (1.0, 0.5, 0.0, 0.0),
306
+ ])
307
+
308
+ # gray
309
+ cmaps['gray'] = Colormap('gray', [
310
+ (0.0, 0.0, 0.0, 0.0),
311
+ (1.0, 1.0, 1.0, 1.0),
312
+ ])
313
+ cmaps['grey'] = cmaps['gray']
314
+
315
+ return cmaps
316
+
317
+
318
+ _COLORMAPS = _make_colormaps()
319
+
320
+
321
+ def get_cmap(name: str) -> Colormap:
322
+ if name not in _COLORMAPS:
323
+ raise KeyError(f"Unknown colormap {name!r}. Available: {list(_COLORMAPS)}")
324
+ return _COLORMAPS[name]
@@ -0,0 +1 @@
1
+ 404: Not Found
@@ -0,0 +1 @@
1
+ 404: Not Found
Binary file
plotlive/drawing.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import numpy as np
4
+
5
+ _LINESTYLE_PATTERNS: dict[str, list[int] | None] = {
6
+ '-': None, 'solid': None,
7
+ '--': [8, 4], 'dashed': [8, 4],
8
+ '-.': [8, 4, 2, 4], 'dashdot': [8, 4, 2, 4],
9
+ ':': [2, 3], 'dotted': [2, 3],
10
+ 'None': None, 'none': None, '': None,
11
+ }
12
+
13
+
14
+ def draw_dashed_polyline(
15
+ surface,
16
+ color: tuple,
17
+ points: list[tuple[float, float]],
18
+ linewidth: int = 1,
19
+ linestyle: str = '-',
20
+ ) -> None:
21
+ """Draw a polyline with optional dash pattern on a pygame Surface."""
22
+ import pygame
23
+
24
+ # Filter out NaN-split segments
25
+ segments = _split_nan(points)
26
+ pattern = _LINESTYLE_PATTERNS.get(linestyle, None)
27
+
28
+ for seg in segments:
29
+ if len(seg) < 2:
30
+ continue
31
+ ipts = [(int(round(p[0])), int(round(p[1]))) for p in seg]
32
+
33
+ if pattern is None:
34
+ if linestyle in ('None', 'none', ''):
35
+ continue
36
+ # Solid anti-aliased
37
+ if linewidth <= 1:
38
+ pygame.draw.aalines(surface, color[:3], False, ipts)
39
+ else:
40
+ pygame.draw.lines(surface, color[:3], False, ipts, linewidth)
41
+ else:
42
+ _draw_dashed(surface, color[:3], seg, pattern, linewidth)
43
+
44
+
45
+ def _draw_dashed(surface, color, points, pattern, linewidth):
46
+ import pygame
47
+ pat_idx = 0
48
+ draw = True
49
+ remaining = pattern[0]
50
+
51
+ for i in range(len(points) - 1):
52
+ x0, y0 = points[i]
53
+ x1, y1 = points[i + 1]
54
+ seg_len = math.hypot(x1 - x0, y1 - y0)
55
+ if seg_len == 0:
56
+ continue
57
+ dx, dy = (x1 - x0) / seg_len, (y1 - y0) / seg_len
58
+ dist = 0.0
59
+
60
+ while dist < seg_len:
61
+ chunk = min(remaining, seg_len - dist)
62
+ ex = x0 + dx * (dist + chunk)
63
+ ey = y0 + dy * (dist + chunk)
64
+ if draw:
65
+ sx, sy = x0 + dx * dist, y0 + dy * dist
66
+ pygame.draw.line(surface, color,
67
+ (int(round(sx)), int(round(sy))),
68
+ (int(round(ex)), int(round(ey))),
69
+ max(1, linewidth))
70
+ dist += chunk
71
+ remaining -= chunk
72
+ if remaining <= 0:
73
+ pat_idx = (pat_idx + 1) % len(pattern)
74
+ draw = not draw
75
+ remaining = pattern[pat_idx]
76
+
77
+
78
+ def _split_nan(points):
79
+ segments = []
80
+ current = []
81
+ for p in points:
82
+ if math.isnan(p[0]) or math.isnan(p[1]):
83
+ if current:
84
+ segments.append(current)
85
+ current = []
86
+ else:
87
+ current.append(p)
88
+ if current:
89
+ segments.append(current)
90
+ return segments
91
+
92
+
93
+ def draw_marker(surface, color: tuple, pos: tuple[float, float],
94
+ marker: str, size: float) -> None:
95
+ """Draw a single marker at pos."""
96
+ import pygame
97
+ x, y = int(round(pos[0])), int(round(pos[1]))
98
+ r = max(2, round(math.sqrt(max(size, 1)) / 1.5))
99
+ c = color[:3]
100
+
101
+ if marker == 'o':
102
+ pygame.draw.circle(surface, c, (x, y), r)
103
+ elif marker == 's':
104
+ pygame.draw.rect(surface, c, (x - r, y - r, 2*r, 2*r))
105
+ elif marker == '^':
106
+ pts = [(x, y - r), (x - r, y + r), (x + r, y + r)]
107
+ pygame.draw.polygon(surface, c, pts)
108
+ elif marker == 'v':
109
+ pts = [(x, y + r), (x - r, y - r), (x + r, y - r)]
110
+ pygame.draw.polygon(surface, c, pts)
111
+ elif marker == '<':
112
+ pts = [(x - r, y), (x + r, y - r), (x + r, y + r)]
113
+ pygame.draw.polygon(surface, c, pts)
114
+ elif marker == '>':
115
+ pts = [(x + r, y), (x - r, y - r), (x - r, y + r)]
116
+ pygame.draw.polygon(surface, c, pts)
117
+ elif marker == 'D':
118
+ pts = [(x, y - r), (x + r, y), (x, y + r), (x - r, y)]
119
+ pygame.draw.polygon(surface, c, pts)
120
+ elif marker == '+':
121
+ pygame.draw.line(surface, c, (x - r, y), (x + r, y), 1)
122
+ pygame.draw.line(surface, c, (x, y - r), (x, y + r), 1)
123
+ elif marker == 'x':
124
+ pygame.draw.line(surface, c, (x - r, y - r), (x + r, y + r), 1)
125
+ pygame.draw.line(surface, c, (x + r, y - r), (x - r, y + r), 1)
126
+ elif marker == '.':
127
+ pygame.draw.circle(surface, c, (x, y), max(1, r // 2))
128
+ elif marker == '*':
129
+ # 6-point star approximated as two overlapping triangles
130
+ pts1 = [(x, y - r), (x - r, y + r//2), (x + r, y + r//2)]
131
+ pts2 = [(x, y + r), (x - r, y - r//2), (x + r, y - r//2)]
132
+ pygame.draw.polygon(surface, c, pts1)
133
+ pygame.draw.polygon(surface, c, pts2)
134
+ else:
135
+ # Default: circle
136
+ pygame.draw.circle(surface, c, (x, y), r)
137
+
138
+
139
+ def draw_colorbar_strip(surface, rect, cmap, vmin: float, vmax: float,
140
+ n_ticks: int = 5) -> None:
141
+ """Draw a vertical colorbar strip in rect with tick labels to the right."""
142
+ import pygame
143
+ from .ticks import auto_ticks, format_ticks
144
+ from .fonts import get_font
145
+
146
+ l, t, w, h = rect.left, rect.top, rect.width, rect.height
147
+ bar_w = max(12, w // 3)
148
+
149
+ # Draw the color gradient strip
150
+ lut = cmap.to_lut(h)
151
+ for i in range(h):
152
+ # top of strip = vmax, bottom = vmin (reverse for screen coords)
153
+ color = tuple(int(v) for v in lut[h - 1 - i, :3])
154
+ pygame.draw.line(surface, color, (l, t + i), (l + bar_w, t + i))
155
+
156
+ # Border
157
+ pygame.draw.rect(surface, (0, 0, 0), (l, t, bar_w, h), 1)
158
+
159
+ # Ticks and labels
160
+ ticks = auto_ticks(vmin, vmax, max_ticks=n_ticks)
161
+ labels = format_ticks(ticks)
162
+ font = get_font('tick')
163
+ for tick, label in zip(ticks, labels):
164
+ frac = (tick - vmin) / (vmax - vmin) if vmax != vmin else 0.5
165
+ sy = t + h - int(frac * h)
166
+ pygame.draw.line(surface, (0, 0, 0), (l + bar_w, sy), (l + bar_w + 3, sy), 1)
167
+ txt_surf = font.render(label, True, (0, 0, 0))
168
+ surface.blit(txt_surf, (l + bar_w + 5, sy - txt_surf.get_height() // 2))