Coverage for src/lexigram/admin/services/charts.py: 58%
73 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Chart Factory Service - DI-injectable chart generation."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from enum import StrEnum
7from typing import Any, Protocol
9from lexigram.di.decorators import inject
10from lexigram.serialization import dumps_str
13class ChartBackend(StrEnum):
14 """Supported chart rendering backends."""
16 CHARTJS = "chartjs"
17 PLOTLY = "plotly"
20class ChartType(StrEnum):
21 """Supported chart types."""
23 LINE = "line"
24 BAR = "bar"
25 PIE = "pie"
26 SCATTER = "scatter"
27 AREA = "area"
28 DONUT = "donut"
31@dataclass
32class ChartData:
33 """Chart data structure."""
35 labels: list[str] = field(default_factory=list)
36 datasets: list[dict[str, Any]] = field(default_factory=list)
37 title: str = ""
38 x_label: str = ""
39 y_label: str = ""
42class IChartRenderer(Protocol):
43 """Protocol for chart renderers."""
45 def render(self, chart_type: ChartType, data: ChartData, **options) -> str:
46 """Render chart as HTML/JS."""
47 ...
50class ChartJSRenderer:
51 """Chart.js backend renderer."""
53 def render(self, chart_type: ChartType, data: ChartData, **options) -> str:
54 """Render Chart.js chart."""
56 chart_id = options.get("id", "chart")
57 width = options.get("width", "100%")
58 height = options.get("height", "400px")
60 config = {
61 "type": chart_type.value,
62 "data": {"labels": data.labels, "datasets": data.datasets},
63 "options": {
64 "responsive": True,
65 "maintainAspectRatio": False,
66 "plugins": {
67 "title": {"display": bool(data.title), "text": data.title},
68 "legend": {"display": True},
69 },
70 "scales": {
71 "x": {
72 "title": {"display": bool(data.x_label), "text": data.x_label},
73 },
74 "y": {
75 "title": {"display": bool(data.y_label), "text": data.y_label},
76 },
77 },
78 },
79 }
81 config_json = dumps_str(config)
83 return f"""
84 <div style="width: {width}; height: {height};">
85 <canvas id="{chart_id}"></canvas>
86 </div>
87 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
88 <script>
89 (function() {{
90 const ctx = document.getElementById('{chart_id}').getContext('2d');
91 new Chart(ctx, {config_json});
92 }})();
93 </script>
94 """
97class PlotlyRenderer:
98 """Plotly backend renderer."""
100 def render(self, chart_type: ChartType, data: ChartData, **options) -> str:
101 """Render Plotly chart."""
103 chart_id = options.get("id", "chart")
104 width = options.get("width", "100%")
105 height = options.get("height", "400px")
107 # Convert to Plotly format
108 plotly_data = []
109 for dataset in data.datasets:
110 trace = {
111 "x": data.labels,
112 "y": dataset.get("data", []),
113 "name": dataset.get("label", ""),
114 "type": chart_type.value if chart_type != ChartType.DONUT else "pie",
115 }
116 if chart_type == ChartType.DONUT:
117 trace["hole"] = 0.4
118 plotly_data.append(trace)
120 layout = {
121 "title": data.title,
122 "xaxis": {"title": data.x_label},
123 "yaxis": {"title": data.y_label},
124 "autosize": True,
125 }
127 data_json = dumps_str(plotly_data)
128 layout_json = dumps_str(layout)
130 return f"""
131 <div id="{chart_id}" style="width: {width}; height: {height};"></div>
132 <script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
133 <script>
134 Plotly.newPlot('{chart_id}', {data_json}, {layout_json}, {{responsive: true}});
135 </script>
136 """
139@inject
140class ChartFactory:
141 """DI-injectable chart factory service."""
143 def __init__(self, backend: ChartBackend = ChartBackend.CHARTJS):
144 """Initialize chart factory with backend."""
145 self.backend = backend
146 self._renderers: dict[ChartBackend, IChartRenderer] = {
147 ChartBackend.CHARTJS: ChartJSRenderer(),
148 ChartBackend.PLOTLY: PlotlyRenderer(),
149 }
151 def set_backend(self, backend: ChartBackend) -> None:
152 """Change rendering backend."""
153 self.backend = backend
155 def create_chart(
156 self,
157 chart_type: ChartType,
158 data: ChartData,
159 backend: ChartBackend | None = None,
160 **options,
161 ) -> str:
162 """Create a chart with specified type and data."""
163 renderer_backend = backend or self.backend
164 renderer = self._renderers.get(renderer_backend)
166 if not renderer:
167 raise ValueError(f"Unsupported backend: {renderer_backend}")
169 return renderer.render(chart_type, data, **options)
171 def line_chart(self, data: ChartData, **options) -> str:
172 """Create a line chart."""
173 return self.create_chart(ChartType.LINE, data, **options)
175 def bar_chart(self, data: ChartData, **options) -> str:
176 """Create a bar chart."""
177 return self.create_chart(ChartType.BAR, data, **options)
179 def pie_chart(self, data: ChartData, **options) -> str:
180 """Create a pie chart."""
181 return self.create_chart(ChartType.PIE, data, **options)
183 def scatter_chart(self, data: ChartData, **options) -> str:
184 """Create a scatter chart."""
185 return self.create_chart(ChartType.SCATTER, data, **options)
187 def area_chart(self, data: ChartData, **options) -> str:
188 """Create an area chart."""
189 return self.create_chart(ChartType.AREA, data, **options)
191 def donut_chart(self, data: ChartData, **options) -> str:
192 """Create a donut chart."""
193 return self.create_chart(ChartType.DONUT, data, **options)