Coverage for src/lexigram/admin/services/charts.py: 0%

73 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +0800

1"""Chart Factory Service - DI-injectable chart generation.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from enum import StrEnum 

7from typing import Any, Protocol 

8 

9from lexigram.di.decorators import inject 

10from lexigram.serialization import dumps_str 

11 

12 

13class ChartBackend(StrEnum): 

14 """Supported chart rendering backends.""" 

15 

16 CHARTJS = "chartjs" 

17 PLOTLY = "plotly" 

18 

19 

20class ChartType(StrEnum): 

21 """Supported chart types.""" 

22 

23 LINE = "line" 

24 BAR = "bar" 

25 PIE = "pie" 

26 SCATTER = "scatter" 

27 AREA = "area" 

28 DONUT = "donut" 

29 

30 

31@dataclass 

32class ChartData: 

33 """Chart data structure.""" 

34 

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 = "" 

40 

41 

42class IChartRenderer(Protocol): 

43 """Protocol for chart renderers.""" 

44 

45 def render(self, chart_type: ChartType, data: ChartData, **options) -> str: 

46 """Render chart as HTML/JS.""" 

47 ... 

48 

49 

50class ChartJSRenderer: 

51 """Chart.js backend renderer.""" 

52 

53 def render(self, chart_type: ChartType, data: ChartData, **options) -> str: 

54 """Render Chart.js chart.""" 

55 

56 chart_id = options.get("id", "chart") 

57 width = options.get("width", "100%") 

58 height = options.get("height", "400px") 

59 

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 } 

80 

81 config_json = dumps_str(config) 

82 

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 """ 

95 

96 

97class PlotlyRenderer: 

98 """Plotly backend renderer.""" 

99 

100 def render(self, chart_type: ChartType, data: ChartData, **options) -> str: 

101 """Render Plotly chart.""" 

102 

103 chart_id = options.get("id", "chart") 

104 width = options.get("width", "100%") 

105 height = options.get("height", "400px") 

106 

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) 

119 

120 layout = { 

121 "title": data.title, 

122 "xaxis": {"title": data.x_label}, 

123 "yaxis": {"title": data.y_label}, 

124 "autosize": True, 

125 } 

126 

127 data_json = dumps_str(plotly_data) 

128 layout_json = dumps_str(layout) 

129 

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 """ 

137 

138 

139@inject 

140class ChartFactory: 

141 """DI-injectable chart factory service.""" 

142 

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 } 

150 

151 def set_backend(self, backend: ChartBackend) -> None: 

152 """Change rendering backend.""" 

153 self.backend = backend 

154 

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) 

165 

166 if not renderer: 

167 raise ValueError(f"Unsupported backend: {renderer_backend}") 

168 

169 return renderer.render(chart_type, data, **options) 

170 

171 def line_chart(self, data: ChartData, **options) -> str: 

172 """Create a line chart.""" 

173 return self.create_chart(ChartType.LINE, data, **options) 

174 

175 def bar_chart(self, data: ChartData, **options) -> str: 

176 """Create a bar chart.""" 

177 return self.create_chart(ChartType.BAR, data, **options) 

178 

179 def pie_chart(self, data: ChartData, **options) -> str: 

180 """Create a pie chart.""" 

181 return self.create_chart(ChartType.PIE, data, **options) 

182 

183 def scatter_chart(self, data: ChartData, **options) -> str: 

184 """Create a scatter chart.""" 

185 return self.create_chart(ChartType.SCATTER, data, **options) 

186 

187 def area_chart(self, data: ChartData, **options) -> str: 

188 """Create an area chart.""" 

189 return self.create_chart(ChartType.AREA, data, **options) 

190 

191 def donut_chart(self, data: ChartData, **options) -> str: 

192 """Create a donut chart.""" 

193 return self.create_chart(ChartType.DONUT, data, **options)