Coverage for src/ipyvizzustory/storylib/story.py: 100%
174 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-26 18:42 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-26 18:42 +0000
1"""A module for working with presentation stories."""
3from typing import Any, List, Optional, Tuple, Union
4from os import PathLike
5import json
6import re
7import uuid
9from ipyvizzu import RawJavaScriptEncoder, Data, Style, Config # , PlainAnimation
11from ipyvizzustory.storylib.animation import DataFilter
12from ipyvizzustory.storylib.template import (
13 VIZZU_STORY,
14 DISPLAY_TEMPLATE,
15 DISPLAY_INDENT,
16)
17from ipyvizzustory.__version__ import __version__
20class Step(dict):
21 """A class for representing a step of a slide."""
23 def __init__(
24 self,
25 *animations: Union[Data, Style, Config],
26 **anim_options: Optional[Union[str, int, float, dict]],
27 ):
28 """
29 Step constructor.
31 Args:
32 *animations: List of [Data][ipyvizzu.Data],
33 [Config][ipyvizzu.Config] and [Style][ipyvizzu.Style] objects.
34 A `Step` can contain each of the above once.
35 **anim_options: Animation options such as duration.
37 Raises:
38 ValueError: If `animations` are not set.
40 Example:
41 Initialize a step with a [Config][ipyvizzu.Config] object:
43 step = Step(
44 Config({"x": "Foo", "y": "Bar"})
45 )
46 """
48 super().__init__()
49 if not animations:
50 raise ValueError("No animation was set.")
51 self._update(*animations)
53 if anim_options:
54 self["animOptions"] = anim_options
56 def _update(self, *animations: Union[Data, Style, Config]) -> None:
57 for animation in animations:
58 if not animation or type(animation) not in [
59 Data,
60 Style,
61 Config,
62 ]: # pylint: disable=unidiomatic-typecheck
63 raise TypeError("Type must be Data, Style or Config.")
64 if type(animation) == Data: # pylint: disable=unidiomatic-typecheck
65 animation = DataFilter(animation)
67 builded_animation = animation.build()
68 common_keys = set(builded_animation).intersection(set(self))
69 if common_keys:
70 raise ValueError(f"Animation is already merged: {common_keys}")
71 self.update(builded_animation)
74class Slide(list):
75 """A class for representing a slide of a presentation story."""
77 def __init__(self, step: Optional[Step] = None):
78 """
79 Slide constructor.
81 Args:
82 step: The first step can also be added to the slide in the constructor.
84 Example:
85 Initialize a slide without step:
87 slide = Slide()
89 Initialize a slide with a step:
91 slide = Slide(
92 Step(
93 Config({"x": "Foo", "y": "Bar"})
94 )
95 )
96 """
98 super().__init__()
99 if step:
100 self.add_step(step)
102 def add_step(self, step: Step) -> None:
103 """
104 A method for adding a step for the slide.
106 Args:
107 step: The next step of the slide.
109 Raises:
110 TypeError: If the type of the `step` is not
111 [Step][ipyvizzustory.storylib.story.Step].
113 Example:
114 Add steps to a slide:
116 slide = Slide()
117 slide.add_step(
118 Step(
119 Config({"x": "Foo", "y": "Bar"})
120 )
121 )
122 slide.add_step(
123 Step(
124 Config({"color": "Foo", "x": "Baz", "geometry": "circle"})
125 )
126 )
127 """
129 if not step or type(step) != Step: # pylint: disable=unidiomatic-typecheck
130 raise TypeError("Type must be Step.")
131 self.append(step)
134class StorySize:
135 """A class for representing the size of a presentation story."""
137 def __init__(
138 self,
139 width: Optional[Union[int, float, str]] = None,
140 height: Optional[Union[int, float, str]] = None,
141 aspect_ratio: Optional[Union[int, float, str]] = None,
142 ):
143 """
144 StorySize constructor.
146 Args:
147 width: The width of a presentation story.
148 height: The height of a presentation story.
149 aspect_ratio: The aspect ratio of a presentation story.
151 Raises:
152 ValueError: If width, height and aspect_ratio are set together.
153 """
155 width = self._convert_to_pixel_or_return(width)
156 height = self._convert_to_pixel_or_return(height)
158 self._width = width
159 self._height = height
160 self._aspect_ratio = aspect_ratio
162 self._style = ""
163 if None not in [width, height, aspect_ratio]:
164 raise ValueError(
165 "width, height and aspect ratio cannot be set at the same time"
166 )
167 if all([height is not None, aspect_ratio is not None]):
168 width = "unset"
169 if any([width is not None, height is not None, aspect_ratio is not None]):
170 _width = "" if width is None else f"width: {width};"
171 _height = "" if height is None else f"height: {height};"
172 _aspect_ratio = (
173 ""
174 if aspect_ratio is None
175 else f"aspect-ratio: {aspect_ratio} !important;"
176 )
177 self._style = f"vp.style.cssText = '{_aspect_ratio}{_width}{_height}'"
179 @staticmethod
180 def _convert_to_pixel_or_return(value: Any) -> Optional[str]:
181 if StorySize._is_int(value) or StorySize._is_float(value):
182 return str(value) + "px"
183 return value
185 @staticmethod
186 def _is_int(value: Any) -> bool:
187 if isinstance(value, int):
188 return True
189 if isinstance(value, str):
190 if re.search(r"^[-+]?[0-9]+$", value):
191 return True
192 return False
194 @staticmethod
195 def _is_float(value: Any) -> bool:
196 if isinstance(value, float):
197 return True
198 if isinstance(value, str):
199 if re.search(r"^[+-]?[0-9]+\.[0-9]+$", value):
200 return True
201 return False
203 @property
204 def width(self) -> Optional[str]:
205 """
206 A property for storing the width of a presentation story.
208 Returns:
209 The width of a presentation story.
210 """
212 return self._width
214 @property
215 def height(self) -> Optional[str]:
216 """
217 A property for storing the height of a presentation story.
219 Returns:
220 The height of a presentation story.
221 """
223 return self._height
225 @property
226 def aspect_ratio(self) -> Optional[Union[int, float, str]]:
227 """
228 A property for storing the aspect ratio of a presentation story.
230 Returns:
231 The aspect ratio of a presentation story.
232 """
234 return self._aspect_ratio
236 @property
237 def style(self) -> str:
238 """
239 A property for storing the style of a presentation story.
241 Note:
242 If neither `width`, `height` nor `aspect_ratio` is set, it returns an empty string.
244 Returns:
245 The cssText width and height of a presentation story.
246 """
248 return self._style
250 @staticmethod
251 def is_pixel(value: Any) -> bool:
252 """
253 A static method for checking the type of the given value.
255 Args:
256 value: The value to check.
258 Returns:
259 `True` if the value is pixel, `False` otherwise.
260 """
262 if StorySize._is_int(value) or StorySize._is_float(value):
263 return True
264 if isinstance(value, str) and value.endswith("px"):
265 if StorySize._is_int(value[0:-2]) or StorySize._is_float(value[0:-2]):
266 return True
267 return False
269 def get_width_height_in_pixels(self) -> Tuple[int, int]:
270 """
271 A method for returning the width and height in pixels.
273 Raises:
274 ValueError: If width and height are not in pixels when aspect_ratio is not set.
275 ValueError: If width or height is not in pixel when aspect_ratio is set.
276 ValueError: If aspect_ratio is not a float when aspect_ratio is set.
278 Returns:
279 The width and height in pixels as int.
280 """
282 if self.aspect_ratio is None:
283 if any(
284 [
285 not StorySize.is_pixel(self.width),
286 not StorySize.is_pixel(self.height),
287 ]
288 ):
289 raise ValueError("width and height should be in pixels")
290 _width = int(float(self.width[:-2])) # type: ignore
291 _height = int(float(self.height[:-2])) # type: ignore
292 else:
293 if not any(
294 [
295 StorySize._is_int(self.aspect_ratio),
296 StorySize._is_float(self.aspect_ratio),
297 ]
298 ):
299 raise ValueError("aspect_ratio should be a float")
300 if not any(
301 [StorySize.is_pixel(self.width), StorySize.is_pixel(self.height)]
302 ):
303 raise ValueError("width or height should be in pixels")
304 _aspect_ratio = float(self.aspect_ratio)
305 if StorySize.is_pixel(self.width):
306 _width = float(self.width[:-2]) # type: ignore
307 _height = int(_width / _aspect_ratio)
308 _width = int(_width)
309 else:
310 _height = float(self.height[:-2]) # type: ignore
311 _width = int(_height * _aspect_ratio)
312 _height = int(_height)
313 return (_width, _height)
316class Story(dict):
317 """A class for representing a presentation story."""
319 # pylint: disable=too-many-instance-attributes
321 def __init__(self, data: Data, style: Optional[Style] = None):
322 """
323 Presentation Story constructor.
325 Args:
326 data: Data set for the whole presentation story.
327 After initialization `data` can not be modified,
328 but it can be filtered.
329 style: Style settings for the presentation story.
330 `style` can be changed at each presentation step.
332 Raises:
333 TypeError: If the type of the `data` is not `ipyvizzu.Data`.
334 TypeError: If the type of the `style` is not `ipyvizzu.Style`.
336 Example:
337 Initialize a story with data and without style:
339 data = Data()
340 data.add_series("Foo", ["Alice", "Bob", "Ted"])
341 data.add_series("Bar", [15, 32, 12])
342 data.add_series("Baz", [5, 3, 2])
344 story = Story(data=data)
345 """
347 super().__init__()
349 self._analytics = True
350 self._vizzu: Optional[str] = None
351 self._vizzu_story: str = VIZZU_STORY
352 self._start_slide: Optional[int] = None
354 self._size: StorySize = StorySize()
356 self._features: List[str] = []
357 self._events: List[str] = []
359 if not data or type(data) != Data: # pylint: disable=unidiomatic-typecheck
360 raise TypeError("Type must be Data.")
361 self.update(data.build())
363 if style:
364 if type(style) != Style: # pylint: disable=unidiomatic-typecheck
365 raise TypeError("Type must be Style.")
366 self.update(style.build())
368 self["slides"] = []
370 @property
371 def analytics(self) -> bool:
372 """
373 A property for enabling/disabling the usage statistics feature.
375 The usage statistics feature allows aggregate usage data collection
376 using Plausible's algorithm.
377 Enabling this feature helps us follow the progress and overall trends of our library,
378 allowing us to focus our resources effectively and better serve our users.
380 We do not track, collect, or store any personal data or personally identifiable information.
381 All data is isolated to a single day, a single site, and a single device only.
383 Please note that even when this feature is enabled,
384 publishing anything made with `ipyvizzu-story` remains GDPR compatible.
386 Returns:
387 The value of the property (default `True`).
388 """
390 return self._analytics
392 @analytics.setter
393 def analytics(self, analytics: Optional[bool]):
394 self._analytics = bool(analytics)
396 @property
397 def vizzu(self) -> Optional[str]:
398 """
399 A property for changing `vizzu` url.
401 Note:
402 If `None`, vizzu url is set by `vizzu-story`.
404 Returns:
405 `Vizzu` url.
406 """
408 return self._vizzu
410 @vizzu.setter
411 def vizzu(self, url: str) -> None:
412 self._vizzu = url
414 @property
415 def vizzu_story(self) -> str:
416 """
417 A property for changing `vizzu-story` url.
419 Returns:
420 `Vizzu-story` url.
421 """
423 return self._vizzu_story
425 @vizzu_story.setter
426 def vizzu_story(self, url: str) -> None:
427 self._vizzu_story = url
429 @property
430 def start_slide(self) -> Optional[int]:
431 """
432 A property for setting the starter slide.
434 Returns:
435 Number of the starter slide.
436 """
438 return self._start_slide
440 @start_slide.setter
441 def start_slide(self, number: int) -> None:
442 self._start_slide = number
444 def add_slide(self, slide: Slide) -> None:
445 """
446 A method for adding a slide for the story.
448 Args:
449 slide: The next slide of the story.
451 Raises:
452 TypeError: If the type of the `slide` is not
453 [Slide][ipyvizzustory.storylib.story.Slide].
455 Example:
456 Add a slide to the story:
458 story.add_slide(
459 Slide(
460 Step(
461 Config({"x": "Foo", "y": "Bar"})
462 )
463 )
464 )
465 """
467 if not slide or type(slide) != Slide: # pylint: disable=unidiomatic-typecheck
468 raise TypeError("Type must be Slide.")
469 self["slides"].append(slide)
471 def set_feature(self, name: str, enabled: bool) -> None:
472 """
473 A method for enabling or disabling a feature of the story.
475 Args:
476 name: The name of the feature.
477 enabled: `True` if enabled or `False` if disabled.
479 Example:
480 Set a feature of the story, for example enable the tooltip:
482 story.set_feature("tooltip", True)
483 """
485 self._features.append(f"chart.feature('{name}', {json.dumps(enabled)});")
487 def add_event(self, event: str, handler: str) -> None:
488 """
489 A method for creating and turning on an event handler.
491 Args:
492 event: The type of the event.
493 handler: The handler `JavaScript` expression as string.
495 Example:
496 Add an event handler to the story:
498 story.add_event("click", "alert(JSON.stringify(event.data));")
499 """
501 self._events.append(
502 f"chart.on('{event}', event => {{{' '.join(handler.split())}}});"
503 )
505 def set_size(
506 self,
507 width: Optional[Union[int, float, str]] = None,
508 height: Optional[Union[int, float, str]] = None,
509 aspect_ratio: Optional[Union[int, float, str]] = None,
510 ) -> None:
511 """
512 A method for setting width/height settings.
514 Args:
515 width: The width of the presentation story.
516 height: The height of the presentation story.
517 aspect_ratio: The aspect ratio of the presentation story.
519 Example:
520 Change the size of the story:
522 story.set_size("100%", "400px")
523 """
525 self._size = StorySize(width=width, height=height, aspect_ratio=aspect_ratio)
527 def _repr_html_(self) -> str:
528 return self.to_html()
530 def to_html(self) -> str:
531 """
532 A method for assembling the `HTML` code.
534 Returns:
535 The assembled `HTML` code as string.
536 """
538 vizzu_player_data = f"{json.dumps(self, cls=RawJavaScriptEncoder)}"
539 return DISPLAY_TEMPLATE.format(
540 id=uuid.uuid4().hex[:7],
541 version=__version__,
542 analytics=str(self._analytics).lower(),
543 vizzu=f'vizzu-url="{self._vizzu}"' if self._vizzu else "",
544 vizzu_story=self._vizzu_story,
545 vizzu_player_data=vizzu_player_data,
546 start_slide=f'start-slide="{self._start_slide}"'
547 if self._start_slide
548 else "",
549 chart_size=self._size.style,
550 chart_features=f"\n{DISPLAY_INDENT * 3}".join(self._features),
551 chart_events=f"\n{DISPLAY_INDENT * 3}".join(self._events),
552 )
554 def export_to_html(self, filename: PathLike) -> None:
555 """
556 A method for exporting the story into `HTML` file.
558 Args:
559 filename: The path of the target `HTML` file.
560 """
562 with open(filename, "w", encoding="utf8") as file_desc:
563 file_desc.write(self.to_html())