Coverage for src / lexigram / ui / charts / static.py: 88%
180 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui.charts.config import bg_class, hex_color, text_class
6from lexigram.ui.charts.types import ChartConfig, ChartDataPoint
7from lexigram.ui.core.base import Component, el
10class BarChart(Component):
11 def __init__(
12 self,
13 data: list[ChartDataPoint],
14 config: ChartConfig | None = None,
15 ) -> None:
16 super().__init__()
17 self.data = data
18 self.config = config or ChartConfig()
20 def _parse_height(self) -> int:
21 h = self.config.height
22 if h.endswith("px"):
23 return int(h[:-2])
24 return 100
26 def _empty_state(self) -> Any:
27 height = self._parse_height()
28 return el(
29 "svg",
30 el(
31 "rect",
32 x="2",
33 y="2",
34 width="396",
35 height=str(height - 4),
36 rx="4",
37 ry="4",
38 fill="#f9fafb",
39 **{
40 "stroke": "#d1d5db",
41 "stroke-width": "1.5",
42 "stroke-dasharray": "6,4",
43 },
44 ),
45 el(
46 "text",
47 "No data yet",
48 x="200",
49 y=str(height // 2),
50 **{
51 "fill": "#9ca3af",
52 "font-size": "14",
53 "text-anchor": "middle",
54 "dominant-baseline": "central",
55 },
56 ),
57 viewBox=f"0 0 400 {height}",
58 class_="w-full",
59 style=f"height:{self.config.height};",
60 xmlns="http://www.w3.org/2000/svg",
61 )
63 def render(self) -> Any:
64 if not self.data:
65 return self._empty_state()
67 max_value = max(d.value for d in self.data) or 1
68 animate_class = (
69 "transition-all duration-700 ease-out" if self.config.animate else ""
70 )
72 bars = []
73 for point in self.data:
74 pct = (point.value / max_value) * 100
75 bar_color = bg_class(point.color)
77 bars.append(
78 el(
79 "div",
80 el(
81 "span",
82 point.label,
83 class_="text-xs text-muted-foreground w-20 flex-shrink-0 truncate",
84 ),
85 el(
86 "div",
87 el(
88 "div",
89 class_=f"h-4 rounded {bar_color} {animate_class}",
90 style=f"width:{pct:.1f}%;",
91 ),
92 class_="flex-1 bg-muted rounded h-4 overflow-hidden",
93 ),
94 el(
95 "span",
96 f"{point.value:g}",
97 class_="text-xs text-muted-foreground w-12 text-right flex-shrink-0 tabular-nums",
98 ),
99 el(
100 "div",
101 f"{point.label}: {point.value:g}",
102 class_="absolute -top-8 left-1/2 -translate-x-1/2 px-2 py-1 rounded bg-accent text-accent-foreground text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg",
103 ),
104 class_="flex items-center gap-2 group relative",
105 )
106 )
108 return el("div", *bars, class_="space-y-2")
111class LineChart(Component):
112 def __init__(
113 self,
114 data: list[ChartDataPoint],
115 config: ChartConfig | None = None,
116 *,
117 line_color: str = "blue",
118 fill_area: bool = False,
119 ) -> None:
120 super().__init__()
121 self.data = data
122 self.config = config or ChartConfig()
123 self.line_color = line_color
124 self.fill_area = fill_area
126 def _parse_height(self) -> int:
127 h = self.config.height
128 if h.endswith("px"):
129 return int(h[:-2])
130 return 200
132 def _empty_state(self) -> Any:
133 height = self._parse_height()
134 return el(
135 "svg",
136 el(
137 "rect",
138 x="30",
139 y="30",
140 width="340",
141 height=str(height - 60),
142 rx="4",
143 ry="4",
144 fill="#f9fafb",
145 **{
146 "stroke": "#d1d5db",
147 "stroke-width": "1.5",
148 "stroke-dasharray": "6,4",
149 },
150 ),
151 el(
152 "text",
153 "No data yet",
154 x="200",
155 y=str(height // 2),
156 **{
157 "fill": "#9ca3af",
158 "font-size": "14",
159 "text-anchor": "middle",
160 "dominant-baseline": "central",
161 },
162 ),
163 viewBox=f"0 0 400 {height}",
164 class_="w-full h-full",
165 style=f"height:{self.config.height};",
166 xmlns="http://www.w3.org/2000/svg",
167 )
169 def render(self) -> Any:
170 if not self.data:
171 return self._empty_state()
173 height = self._parse_height()
174 width = 400
175 padding = 30
176 chart_w = width - padding * 2
177 chart_h = height - padding * 2
179 max_val = max(d.value for d in self.data) or 1
180 min_val = 0
181 value_range = max_val - min_val or 1
183 n = len(self.data)
184 n = max(n, 2)
186 hex_col = hex_color(self.line_color)
188 points_list: list[str] = []
189 for i, point in enumerate(self.data):
190 x = padding + (i / (n - 1)) * chart_w
191 y = padding + chart_h - ((point.value - min_val) / value_range) * chart_h
192 points_list.append(f"{x:.1f},{y:.1f}")
193 points_str = " ".join(points_list)
195 fill_points = (
196 f"{padding},{padding + chart_h} "
197 f"{points_str} "
198 f"{padding + chart_w},{padding + chart_h}"
199 )
201 children: list[Any] = []
203 if self.config.show_grid:
204 grid_lines = 4
205 for g in range(grid_lines + 1):
206 y = padding + chart_h - (g / grid_lines) * chart_h
207 val = min_val + (g / grid_lines) * value_range
208 children.append(
209 el(
210 "line",
211 x1=str(padding),
212 y1=f"{y:.1f}",
213 x2=str(padding + chart_w),
214 y2=f"{y:.1f}",
215 **{
216 "stroke": "#e5e7eb",
217 "stroke-width": "1",
218 "stroke-dasharray": "4,4",
219 },
220 )
221 )
222 if self.config.show_labels:
223 children.append(
224 el(
225 "text",
226 f"{val:g}",
227 x=str(padding - 4),
228 y=f"{y + 3:.1f}",
229 **{
230 "fill": "#9ca3af",
231 "font-size": "10",
232 "text-anchor": "end",
233 },
234 )
235 )
237 if self.fill_area:
238 children.append(
239 el(
240 "polygon",
241 points=fill_points,
242 fill=hex_col,
243 opacity="0.1",
244 )
245 )
247 children.append(
248 el(
249 "polyline",
250 points=points_str,
251 fill="none",
252 **{
253 "stroke": hex_col,
254 "stroke-width": "2",
255 "stroke-linejoin": "round",
256 "stroke-linecap": "round",
257 },
258 )
259 )
261 for i, point in enumerate(self.data):
262 x = padding + (i / (n - 1)) * chart_w
263 y = padding + chart_h - ((point.value - min_val) / value_range) * chart_h
264 children.append(
265 el(
266 "g",
267 el(
268 "circle",
269 cx=f"{x:.1f}",
270 cy=f"{y:.1f}",
271 r="5", fill="transparent", stroke="transparent",
272 ),
273 el("title", f"{point.label}: {point.value:g}"),
274 )
275 )
277 if self.config.show_labels:
278 for i, point in enumerate(self.data):
279 x = padding + (i / (n - 1)) * chart_w
280 if n <= 12 or i % max(1, n // 8) == 0:
281 children.append(
282 el(
283 "text",
284 point.label,
285 x=f"{x:.1f}",
286 y=str(height - 4),
287 **{
288 "fill": "#9ca3af",
289 "font-size": "10",
290 "text-anchor": "middle",
291 },
292 )
293 )
295 return el(
296 "svg",
297 *children,
298 viewBox=f"0 0 {width} {height}",
299 class_="w-full h-full",
300 style=f"height:{self.config.height};",
301 xmlns="http://www.w3.org/2000/svg",
302 )
305class AreaChart(LineChart):
306 def __init__(
307 self,
308 data: list[ChartDataPoint],
309 config: ChartConfig | None = None,
310 *,
311 line_color: str = "blue",
312 ) -> None:
313 super().__init__(data, config, line_color=line_color, fill_area=True)
316class PieChart(Component):
317 def __init__(
318 self,
319 data: list[ChartDataPoint],
320 config: ChartConfig | None = None,
321 *,
322 size: int = 160,
323 ) -> None:
324 super().__init__()
325 self.data = data
326 self.config = config or ChartConfig()
327 self.size = size
329 def _empty_state(self) -> Any:
330 size = self.size
331 cx = cy = size / 2
332 r = size / 2 - 10
333 return el(
334 "svg",
335 el(
336 "circle",
337 cx=str(cx),
338 cy=str(cy),
339 r=str(r),
340 fill="#f9fafb",
341 **{
342 "stroke": "#d1d5db",
343 "stroke-width": "1.5",
344 "stroke-dasharray": "6,4",
345 },
346 ),
347 el(
348 "text",
349 "No data yet",
350 x=str(cx),
351 y=str(cy),
352 **{
353 "fill": "#9ca3af",
354 "font-size": "12",
355 "text-anchor": "middle",
356 "dominant-baseline": "central",
357 },
358 ),
359 viewBox=f"0 0 {size} {size}",
360 class_="w-full flex justify-center",
361 style=f"height:{size}px;",
362 xmlns="http://www.w3.org/2000/svg",
363 )
365 def render(self) -> Any:
366 if not self.data:
367 return self._empty_state()
369 total = sum(d.value for d in self.data) or 1
371 gradient_parts: list[str] = []
372 cumulative = 0.0
373 for point in self.data:
374 pct = (point.value / total) * 100
375 hex_col = hex_color(point.color)
376 start_pct = cumulative
377 end_pct = cumulative + pct
378 gradient_parts.append(f"{hex_col} {start_pct:.1f}% {end_pct:.1f}%")
379 cumulative = end_pct
381 conic_gradient = f"conic-gradient({', '.join(gradient_parts)})"
383 pie = el(
384 "div",
385 class_="rounded-full",
386 style=f"width:{self.size}px;height:{self.size}px;background:{conic_gradient};",
387 )
389 legend_items = []
390 for point in self.data:
391 pct = (point.value / total) * 100
392 dot_class = bg_class(point.color)
393 txt_class = text_class(point.color)
394 legend_items.append(
395 el(
396 "div",
397 el(
398 "span", class_=f"w-3 h-3 rounded-full {dot_class} flex-shrink-0"
399 ),
400 el(
401 "span",
402 point.label,
403 class_="text-xs text-foreground",
404 ),
405 el(
406 "span",
407 f"{pct:.1f}%",
408 class_=f"text-xs font-medium {txt_class} ml-auto tabular-nums",
409 ),
410 el(
411 "div",
412 f"{point.label}: {point.value:g} ({pct:.1f}%)",
413 class_="absolute -top-6 left-1/2 -translate-x-1/2 px-2 py-1 rounded bg-accent text-accent-foreground text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg",
414 ),
415 class_="flex items-center gap-2 group relative",
416 )
417 )
419 legend = el("div", *legend_items, class_="space-y-1.5")
421 return el(
422 "div",
423 el("div", pie, class_="flex justify-center"),
424 legend,
425 class_="space-y-4",
426 )
429class Sparkline(Component):
430 def __init__(
431 self,
432 data: list[ChartDataPoint],
433 *,
434 line_color: str = "blue",
435 height: int = 32,
436 width: int = 80,
437 show_area: bool = False,
438 ) -> None:
439 super().__init__()
440 self.data = data
441 self.line_color = line_color
442 self.height = height
443 self.width = width
444 self.show_area = show_area
446 def render(self) -> Any:
447 if not self.data:
448 return el("div", class_=f"h-[{self.height}px] w-[{self.width}px]")
450 values = [d.value for d in self.data]
451 max_val = max(values) or 1
452 min_val = min(values) or 0
453 rng = max_val - min_val or 1
454 n = len(values)
455 n = max(n, 2)
457 hex_col = hex_color(self.line_color)
458 padding = 2
459 chart_w = self.width - padding * 2
460 chart_h = self.height - padding * 2
462 pts: list[str] = []
463 for i, v in enumerate(values):
464 x = padding + (i / (n - 1)) * chart_w
465 y = padding + chart_h - ((v - min_val) / rng) * chart_h
466 pts.append(f"{x:.1f},{y:.1f}")
467 pts_str = " ".join(pts)
469 children: list[Any] = []
471 if self.show_area:
472 first_x, _ = pts[0].split(",") if pts else ("0", "0")
473 last_x, _ = pts[-1].split(",") if pts else (str(self.width), "0")
474 area_pts = (
475 f"{first_x},{padding + chart_h} {pts_str} {last_x},{padding + chart_h}"
476 )
477 children.append(
478 el("polygon", points=area_pts, fill=hex_col, opacity="0.15")
479 )
481 children.append(
482 el(
483 "polyline",
484 points=pts_str,
485 fill="none",
486 **{
487 "stroke": hex_col,
488 "stroke-width": "1.5",
489 "stroke-linecap": "round",
490 "stroke-linejoin": "round",
491 },
492 )
493 )
495 for i, v in enumerate(values):
496 x = padding + (i / (n - 1)) * chart_w
497 y = padding + chart_h - ((v - min_val) / rng) * chart_h
498 children.append(
499 el(
500 "g",
501 el(
502 "circle",
503 cx=f"{x:.1f}",
504 cy=f"{y:.1f}",
505 r="4", fill="transparent", stroke="transparent",
506 ),
507 el("title", f"{self.data[i].label}: {v:g}"),
508 )
509 )
511 return el(
512 "svg",
513 *children,
514 viewBox=f"0 0 {self.width} {self.height}",
515 class_="inline-block",
516 style=f"width:{self.width}px;height:{self.height}px;",
517 xmlns="http://www.w3.org/2000/svg",
518 )
521class MiniBar(Component):
522 def __init__(
523 self,
524 value: float,
525 max_value: float = 100,
526 *,
527 color: str = "blue",
528 height: int = 8,
529 width: int = 60,
530 show_value: bool = False,
531 ) -> None:
532 super().__init__()
533 self.value = value
534 self.max_value = max_value or 1
535 self.color = color
536 self.height = height
537 self.width = width
538 self.show_value = show_value
540 def render(self) -> Any:
541 pct = max((self.value / self.max_value) * 100, 2)
542 bar_color = bg_class(self.color) if self.value > 0 else "bg-muted"
544 children = [
545 el(
546 "div",
547 el(
548 "div",
549 class_=f"h-full rounded {bar_color} transition-all duration-500",
550 style=f"width:{pct:.1f}%;",
551 ),
552 class_="w-full bg-muted rounded overflow-hidden",
553 style=f"height:{self.height}px;",
554 ),
555 ]
557 if self.show_value:
558 children.append(
559 el(
560 "span",
561 f"{self.value:g}",
562 class_="text-xs text-muted-foreground ml-1 tabular-nums",
563 )
564 )
566 children.append(
567 el("div", f"{self.value:g} / {self.max_value:g}",
568 class_="absolute -top-6 left-1/2 -translate-x-1/2 px-2 py-1 rounded bg-accent text-accent-foreground text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg"),
569 )
571 return el(
572 "div",
573 *children,
574 class_="flex items-center gap-1 group relative",
575 style=f"width:{self.width}px;" if not self.show_value else "",
576 )