Coverage for lib/lottie/utils/charts.py: 0%

221 statements  

« prev     ^ index     » next       coverage.py v7.2.2, created at 2023-03-20 16:17 +0100

1import math 

2import typing 

3import random 

4 

5from dataclasses import dataclass 

6from .. import objects 

7from .color import Color 

8from ..nvector import NVector 

9from ..objects import easing 

10from . import ellipse 

11 

12 

13class Datum: 

14 def __init__(self, value: float, color: Color): 

15 self.value = value 

16 self.color = color 

17 

18 def add_style(self, group: objects.Group): 

19 group.add_shape(objects.Fill(self.color)) 

20 

21 

22class DataSet: 

23 def __init__(self, data: typing.List[Datum]): 

24 self.data = data 

25 

26 def normalize_values(self, sum_to_one=False): 

27 """! 

28 Ensures all values are in [0, 1] 

29 

30 @param sum_to_one if \b True, the sum all values will be 1 

31 """ 

32 if sum_to_one: 

33 max_value = sum(datum.value for datum in self.data) 

34 else: 

35 max_value = max(datum.value for datum in self.data) 

36 

37 if max_value == 0: 

38 return 

39 

40 for datum in self.data: 

41 datum.value /= max_value 

42 

43 def add(self, value: float, color: Color): 

44 self.data.append(Datum(value, color)) 

45 

46 

47@dataclass 

48class AnimationSettings: 

49 #! Time at which the animation starts 

50 start: float = 0 

51 #! Duration from start until an item is fully visible 

52 fade_in: float = 0 

53 #! Duration the item is fully visible 

54 stay: float = 0 

55 #! Duration of the item disappearing 

56 fade_out: float = 0 

57 #! End time 

58 end: float = 0 

59 #! Offset between items 

60 offset: float = 0 

61 

62 @classmethod 

63 def computed(cls, item_count, duration, stay, full_stay, start=0): 

64 """! 

65 @param item_count Number of items to animate 

66 @param dutation Total duration 

67 @param stay Duration for which each item is fully visible 

68 @param full_stay Duration for all each items are fully visible 

69 @param start Start time 

70 """ 

71 

72 overlap = stay - full_stay 

73 offset = overlap / (item_count - 1) 

74 partial_stay = stay + overlap 

75 inout = duration - partial_stay 

76 

77 return AnimationSettings( 

78 start=start, 

79 fade_in=inout * 2 / 3, 

80 stay=stay, 

81 fade_out=inout / 3, 

82 offset=offset, 

83 end=start+duration, 

84 ) 

85 

86 def to_global_times(self): 

87 """! 

88 Converts durations into global times 

89 """ 

90 self.fade_in += self.start 

91 self.stay += self.fade_in 

92 self.fade_out += self.stay 

93 

94 

95class DatumAnimation: 

96 def __init__(self, datum: Datum, index_in: int, index_out: int, index_count: int, settings: AnimationSettings): 

97 self.datum = datum 

98 self.index_in = index_in 

99 self.index_out = index_out 

100 off1 = self.index_in * settings.offset 

101 off2 = self.index_out * settings.offset 

102 off_max = index_count * settings.offset 

103 self.times = AnimationSettings( 

104 start=settings.start + off1, 

105 end=settings.end - off_max + off2 

106 ) 

107 self.times.fade_in = self.times.start + settings.fade_in 

108 self.times.fade_out = self.times.end - settings.fade_out 

109 self.times.stay = self.times.fade_out 

110 

111 

112class ChartType: 

113 def animate(self, area: objects.BoundingBox, anim: DatumAnimation, index: int, total: int, sum_before: float): 

114 raise NotImplementedError() 

115 

116 

117class Histogram(ChartType): 

118 def animate(self, area: objects.BoundingBox, anim: DatumAnimation, index: int, total: int, sum_before: float): 

119 x = area.width / total * (index + .5) + area.x1 

120 width = area.width / (total + 1) 

121 height = area.height * anim.datum.value 

122 

123 group = objects.Group() 

124 rect = group.add_shape(objects.Rect()) 

125 anim.datum.add_style(group) 

126 

127 rect.position.add_keyframe(anim.times.start, NVector(x, area.y2), easing.EaseOut()) 

128 rect.position.add_keyframe(anim.times.fade_in, NVector(x, area.y2 - height / 2), easing.EaseOut()) 

129 rect.position.add_keyframe(anim.times.fade_out, NVector(x, area.y2 - height / 2), easing.EaseOut()) 

130 rect.position.add_keyframe(anim.times.end, NVector(x, area.y2), easing.EaseOut()) 

131 

132 rect.size.add_keyframe(anim.times.start, NVector(width, 0), easing.EaseOut()) 

133 rect.size.add_keyframe(anim.times.fade_in, NVector(width, height), easing.EaseOut()) 

134 rect.size.add_keyframe(anim.times.fade_out, NVector(width, height), easing.EaseOut()) 

135 rect.size.add_keyframe(anim.times.end, NVector(width, 0), easing.EaseOut()) 

136 

137 return group 

138 

139 

140class PieFan(ChartType): 

141 def animate(self, area: objects.BoundingBox, anim: DatumAnimation, index: int, total: int, sum_before: float): 

142 angle_start = sum_before * math.pi * 2 

143 angle_delta = anim.datum.value * math.pi * 2 

144 

145 rad = min(area.width, area.height) / 2 

146 ellipser = ellipse.Ellipse(area.center(), NVector(rad, rad), 0) 

147 

148 group = objects.Group() 

149 shape = group.add_shape(objects.Path()) 

150 

151 cache = {} 

152 

153 self.add_transition(shape, ellipser, angle_start, 0, angle_delta, anim.times.start, anim.times.fade_in, cache) 

154 self.add_transition(shape, ellipser, angle_start, angle_delta, 0, anim.times.fade_out, anim.times.end, cache) 

155 

156 anim.datum.add_style(group) 

157 return group 

158 

159 def add_arc(self, shape, ellipser, angle_start, angle_delta, time, cache): 

160 if angle_delta not in cache: 

161 if angle_delta == 0: 

162 bez = objects.Bezier() 

163 p = ellipser.center + NVector( 

164 math.cos(angle_start) * ellipser.radii.x, 

165 math.sin(angle_start) * ellipser.radii.x 

166 ) 

167 bez.add_point(p) 

168 bez.add_point(p) 

169 bez.add_point(p) 

170 bez.add_point(p) 

171 else: 

172 bez = ellipser.to_bezier(angle_start, angle_delta, angle_delta / 4) 

173 bez.in_tangents[0] = bez.out_tangents[-1] = NVector(0, 0) 

174 

175 bez.add_point(ellipser.center) 

176 bez.add_point(ellipser.center) 

177 bez.close() 

178 

179 cache[angle_delta] = bez 

180 else: 

181 bez = cache[angle_delta] 

182 

183 shape.shape.add_keyframe(time, bez) 

184 

185 def add_transition(self, shape, ellipser, angle_start, angle_delta_from, angle_delta_to, time_from, time_to, cache): 

186 chunks = round(max(angle_delta_from, angle_delta_to) / math.pi * 100) 

187 for step in range(chunks + 1): 

188 f = step / chunks 

189 angle_delta = f * angle_delta_to + (1-f) * angle_delta_from 

190 time = f * time_to + (1-f) * time_from 

191 self.add_arc(shape, ellipser, angle_start, angle_delta, time, cache) 

192 

193 

194class PieGrow(ChartType): 

195 def animate(self, area: objects.BoundingBox, anim: DatumAnimation, index: int, total: int, sum_before: float): 

196 angle_start = sum_before * math.pi * 2 

197 angle_delta = anim.datum.value * math.pi * 2 

198 

199 group = objects.Group() 

200 shape = group.add_shape(objects.Path()) 

201 

202 rad = min(area.width, area.height) / 2 

203 bez_0 = self.arc_to_bezier(area.center(), 0, 0, 0) 

204 bez_1 = self.arc_to_bezier(area.center(), angle_start, angle_delta, rad) 

205 

206 shape.shape.add_keyframe(anim.times.start, bez_0, easing.EaseOut()) 

207 shape.shape.add_keyframe(anim.times.fade_in, bez_1, easing.EaseOut()) 

208 shape.shape.add_keyframe(anim.times.fade_out, bez_1, easing.EaseOut()) 

209 shape.shape.add_keyframe(anim.times.end, bez_0, easing.EaseOut()) 

210 

211 anim.datum.add_style(group) 

212 return group 

213 

214 def arc_to_bezier(self, center, angle_start, angle_delta, radius): 

215 if radius == 0: 

216 bez = objects.Bezier() 

217 p = center 

218 bez.add_point(p) 

219 bez.add_point(p) 

220 bez.add_point(p) 

221 bez.add_point(p) 

222 elif angle_delta == 0: 

223 bez = objects.Bezier() 

224 p = center + NVector(math.cos(angle_start) * radius, math.sin(angle_start) * radius) 

225 bez.add_point(p) 

226 bez.add_point(p) 

227 bez.add_point(p) 

228 bez.add_point(p) 

229 else: 

230 ellipser = ellipse.Ellipse(center, NVector(radius, radius), 0) 

231 bez = ellipser.to_bezier(angle_start, angle_delta, angle_delta / 4) 

232 bez.in_tangents[0] = bez.out_tangents[-1] = NVector(0, 0) 

233 

234 # Dunno why we need to add it twice but it doesn't work with just one o_O 

235 bez.add_point(center) 

236 bez.add_point(center) 

237 bez.close() 

238 

239 return bez 

240 

241 

242class AnimationOrder: 

243 def indices(self, data: DataSet): 

244 """! 

245 Returns two list of indices indicating the order of animation 

246 for fade in and out of the elements 

247 

248 The lists are laid out so that the nth element is the index at 

249 which that element animates at 

250 """ 

251 raise NotImplementedError 

252 

253 def __call__(self, data: DataSet): 

254 return self.indices(data) 

255 

256 

257class RandomOrder(AnimationOrder): 

258 def indices(self, data: DataSet): 

259 indices_in = list(range(len(data.data))) 

260 indices_out = list(indices_in) 

261 random.shuffle(indices_in) 

262 random.shuffle(indices_out) 

263 return indices_in, indices_out 

264 

265 

266class SequentialOrder(AnimationOrder): 

267 def indices(self, data: DataSet): 

268 indices = list(range(len(data.data))) 

269 return indices, indices 

270 

271 

272class SortValueOrder(AnimationOrder): 

273 def __init__(self, largest_first=True): 

274 self.largest_first = largest_first 

275 

276 def indices(self, data: DataSet): 

277 info = sorted(enumerate(data.data), key=lambda it: it[1].value) 

278 if self.largest_first: 

279 info = reversed(info) 

280 

281 ranking = [it[0] for it in info] 

282 indices = list(map(ranking.index, range(len(data.data)))) 

283 return indices, indices 

284 

285 

286class SimultaneousOrder(AnimationOrder): 

287 def indices(self, data: DataSet): 

288 n = len(data.data) 

289 return [0] * n, [n-1] * n 

290 

291 

292class FixedOrder(AnimationOrder): 

293 def __init__(self, indices_in, indices_out=None): 

294 self.indices_in = indices_in 

295 self.indices_out = indices_out or indices_in 

296 

297 def indices(self, data: DataSet): 

298 return self.indices_in, self.indices_out 

299 

300 

301class Chart: 

302 def __init__(self, type=Histogram(), area=None, data=None, animation=None, order=SimultaneousOrder()): 

303 self.type = type 

304 self.area = area or objects.BoundingBox(0, 0, 512, 512) 

305 self.timing = animation or AnimationSettings() 

306 self.order = order 

307 

308 if isinstance(data, DataSet): 

309 self.data = data 

310 else: 

311 self.data = DataSet(data or []) 

312 

313 def compute_animation(self, *a, **kw): 

314 self.timing = AnimationSettings.computed(len(self.data.data), *a, **kw) 

315 

316 def shapes(self): 

317 total = len(self.data.data) 

318 indices_in, indices_out = self.order(self.data) 

319 shapes = [] 

320 

321 value_sum = 0 

322 

323 for i, (datum, ii, io) in enumerate(zip(self.data.data, indices_in, indices_out)): 

324 anim = DatumAnimation(datum, ii, io, total, self.timing) 

325 shapes.append(self.type.animate(self.area, anim, i, total, value_sum)) 

326 value_sum += datum.value 

327 

328 return shapes 

329 

330 def layer(self): 

331 layer = objects.ShapeLayer() 

332 layer.shapes = self.shapes() 

333 return layer 

334 

335 def animation(self): 

336 animation = objects.Animation() 

337 animation.width = self.area.x2 

338 animation.height = self.area.y2 

339 animation.in_point = self.timing.start 

340 animation.out_point = self.timing.end 

341 animation.add_layer(self.layer()) 

342 return animation