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
« prev ^ index » next coverage.py v7.2.2, created at 2023-03-20 16:17 +0100
1import math
2import typing
3import random
5from dataclasses import dataclass
6from .. import objects
7from .color import Color
8from ..nvector import NVector
9from ..objects import easing
10from . import ellipse
13class Datum:
14 def __init__(self, value: float, color: Color):
15 self.value = value
16 self.color = color
18 def add_style(self, group: objects.Group):
19 group.add_shape(objects.Fill(self.color))
22class DataSet:
23 def __init__(self, data: typing.List[Datum]):
24 self.data = data
26 def normalize_values(self, sum_to_one=False):
27 """!
28 Ensures all values are in [0, 1]
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)
37 if max_value == 0:
38 return
40 for datum in self.data:
41 datum.value /= max_value
43 def add(self, value: float, color: Color):
44 self.data.append(Datum(value, color))
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
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 """
72 overlap = stay - full_stay
73 offset = overlap / (item_count - 1)
74 partial_stay = stay + overlap
75 inout = duration - partial_stay
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 )
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
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
112class ChartType:
113 def animate(self, area: objects.BoundingBox, anim: DatumAnimation, index: int, total: int, sum_before: float):
114 raise NotImplementedError()
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
123 group = objects.Group()
124 rect = group.add_shape(objects.Rect())
125 anim.datum.add_style(group)
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())
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())
137 return group
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
145 rad = min(area.width, area.height) / 2
146 ellipser = ellipse.Ellipse(area.center(), NVector(rad, rad), 0)
148 group = objects.Group()
149 shape = group.add_shape(objects.Path())
151 cache = {}
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)
156 anim.datum.add_style(group)
157 return group
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)
175 bez.add_point(ellipser.center)
176 bez.add_point(ellipser.center)
177 bez.close()
179 cache[angle_delta] = bez
180 else:
181 bez = cache[angle_delta]
183 shape.shape.add_keyframe(time, bez)
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)
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
199 group = objects.Group()
200 shape = group.add_shape(objects.Path())
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)
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())
211 anim.datum.add_style(group)
212 return group
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)
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()
239 return bez
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
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
253 def __call__(self, data: DataSet):
254 return self.indices(data)
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
266class SequentialOrder(AnimationOrder):
267 def indices(self, data: DataSet):
268 indices = list(range(len(data.data)))
269 return indices, indices
272class SortValueOrder(AnimationOrder):
273 def __init__(self, largest_first=True):
274 self.largest_first = largest_first
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)
281 ranking = [it[0] for it in info]
282 indices = list(map(ranking.index, range(len(data.data))))
283 return indices, indices
286class SimultaneousOrder(AnimationOrder):
287 def indices(self, data: DataSet):
288 n = len(data.data)
289 return [0] * n, [n-1] * n
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
297 def indices(self, data: DataSet):
298 return self.indices_in, self.indices_out
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
308 if isinstance(data, DataSet):
309 self.data = data
310 else:
311 self.data = DataSet(data or [])
313 def compute_animation(self, *a, **kw):
314 self.timing = AnimationSettings.computed(len(self.data.data), *a, **kw)
316 def shapes(self):
317 total = len(self.data.data)
318 indices_in, indices_out = self.order(self.data)
319 shapes = []
321 value_sum = 0
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
328 return shapes
330 def layer(self):
331 layer = objects.ShapeLayer()
332 layer.shapes = self.shapes()
333 return layer
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