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

1from __future__ import annotations 

2 

3from typing import Any 

4 

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 

8 

9 

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() 

19 

20 def _parse_height(self) -> int: 

21 h = self.config.height 

22 if h.endswith("px"): 

23 return int(h[:-2]) 

24 return 100 

25 

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 ) 

62 

63 def render(self) -> Any: 

64 if not self.data: 

65 return self._empty_state() 

66 

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 ) 

71 

72 bars = [] 

73 for point in self.data: 

74 pct = (point.value / max_value) * 100 

75 bar_color = bg_class(point.color) 

76 

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 ) 

107 

108 return el("div", *bars, class_="space-y-2") 

109 

110 

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 

125 

126 def _parse_height(self) -> int: 

127 h = self.config.height 

128 if h.endswith("px"): 

129 return int(h[:-2]) 

130 return 200 

131 

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 ) 

168 

169 def render(self) -> Any: 

170 if not self.data: 

171 return self._empty_state() 

172 

173 height = self._parse_height() 

174 width = 400 

175 padding = 30 

176 chart_w = width - padding * 2 

177 chart_h = height - padding * 2 

178 

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 

182 

183 n = len(self.data) 

184 n = max(n, 2) 

185 

186 hex_col = hex_color(self.line_color) 

187 

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) 

194 

195 fill_points = ( 

196 f"{padding},{padding + chart_h} " 

197 f"{points_str} " 

198 f"{padding + chart_w},{padding + chart_h}" 

199 ) 

200 

201 children: list[Any] = [] 

202 

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 ) 

236 

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 ) 

246 

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 ) 

260 

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 ) 

276 

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 ) 

294 

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 ) 

303 

304 

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) 

314 

315 

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 

328 

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 ) 

364 

365 def render(self) -> Any: 

366 if not self.data: 

367 return self._empty_state() 

368 

369 total = sum(d.value for d in self.data) or 1 

370 

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 

380 

381 conic_gradient = f"conic-gradient({', '.join(gradient_parts)})" 

382 

383 pie = el( 

384 "div", 

385 class_="rounded-full", 

386 style=f"width:{self.size}px;height:{self.size}px;background:{conic_gradient};", 

387 ) 

388 

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 ) 

418 

419 legend = el("div", *legend_items, class_="space-y-1.5") 

420 

421 return el( 

422 "div", 

423 el("div", pie, class_="flex justify-center"), 

424 legend, 

425 class_="space-y-4", 

426 ) 

427 

428 

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 

445 

446 def render(self) -> Any: 

447 if not self.data: 

448 return el("div", class_=f"h-[{self.height}px] w-[{self.width}px]") 

449 

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) 

456 

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 

461 

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) 

468 

469 children: list[Any] = [] 

470 

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 ) 

480 

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 ) 

494 

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 ) 

510 

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 ) 

519 

520 

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 

539 

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" 

543 

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 ] 

556 

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 ) 

565 

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 ) 

570 

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 )