Coverage for src/ipyvizzustory/storylib/story.py: 100%
179 statements
« prev ^ index » next coverage.py v7.3.4, created at 2023-12-21 18:17 +0000
« prev ^ index » next coverage.py v7.3.4, created at 2023-12-21 18:17 +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] = []
358 self._plugins: List[str] = []
360 if not data or type(data) != Data: # pylint: disable=unidiomatic-typecheck
361 raise TypeError("Type must be Data.")
362 self.update(data.build())
364 if style:
365 if type(style) != Style: # pylint: disable=unidiomatic-typecheck
366 raise TypeError("Type must be Style.")
367 self.update(style.build())
369 self["slides"] = []
371 @property
372 def analytics(self) -> bool:
373 """
374 A property for enabling/disabling the usage statistics feature.
376 The usage statistics feature allows aggregate usage data collection
377 using Plausible's algorithm.
378 Enabling this feature helps us follow the progress and overall trends of our library,
379 allowing us to focus our resources effectively and better serve our users.
381 We do not track, collect, or store any personal data or personally identifiable information.
382 All data is isolated to a single day, a single site, and a single device only.
384 Please note that even when this feature is enabled,
385 publishing anything made with `ipyvizzu-story` remains GDPR compatible.
387 Returns:
388 The value of the property (default `True`).
389 """
391 return self._analytics
393 @analytics.setter
394 def analytics(self, analytics: Optional[bool]):
395 self._analytics = bool(analytics)
397 @property
398 def vizzu(self) -> Optional[str]:
399 """
400 A property for changing `vizzu` url.
402 Note:
403 If `None`, vizzu url is set by `vizzu-story`.
405 Returns:
406 `Vizzu` url.
407 """
409 return self._vizzu
411 @vizzu.setter
412 def vizzu(self, url: str) -> None:
413 self._vizzu = url
415 @property
416 def vizzu_story(self) -> str:
417 """
418 A property for changing `vizzu-story` url.
420 Returns:
421 `Vizzu-story` url.
422 """
424 return self._vizzu_story
426 @vizzu_story.setter
427 def vizzu_story(self, url: str) -> None:
428 self._vizzu_story = url
430 @property
431 def start_slide(self) -> Optional[int]:
432 """
433 A property for setting the starter slide.
435 Returns:
436 Number of the starter slide.
437 """
439 return self._start_slide
441 @start_slide.setter
442 def start_slide(self, number: int) -> None:
443 self._start_slide = number
445 def add_slide(self, slide: Slide) -> None:
446 """
447 A method for adding a slide for the story.
449 Args:
450 slide: The next slide of the story.
452 Raises:
453 TypeError: If the type of the `slide` is not
454 [Slide][ipyvizzustory.storylib.story.Slide].
456 Example:
457 Add a slide to the story:
459 story.add_slide(
460 Slide(
461 Step(
462 Config({"x": "Foo", "y": "Bar"})
463 )
464 )
465 )
466 """
468 if not slide or type(slide) != Slide: # pylint: disable=unidiomatic-typecheck
469 raise TypeError("Type must be Slide.")
470 self["slides"].append(slide)
472 def set_feature(self, name: str, enabled: bool) -> None:
473 """
474 A method for enabling or disabling a feature of the story.
476 Args:
477 name: The name of the feature.
478 enabled: `True` if enabled or `False` if disabled.
480 Example:
481 Set a feature of the story, for example enable the tooltip:
483 story.set_feature("tooltip", True)
484 """
486 self._features.append(f"chart.feature('{name}', {json.dumps(enabled)});")
488 def add_event(self, event: str, handler: str) -> None:
489 """
490 A method for creating and turning on an event handler.
492 Args:
493 event: The type of the event.
494 handler: The handler `JavaScript` expression as string.
496 Example:
497 Add an event handler to the story:
499 story.add_event("click", "alert(JSON.stringify(event.detail));")
500 """
502 self._events.append(
503 f"chart.on('{event}', event => {{{' '.join(handler.split())}}});"
504 )
506 def add_plugin(
507 self, plugin: str, options: Optional[dict] = None, name: str = "default"
508 ) -> None:
509 """
510 A method for register plugins of the chart.
512 Args:
513 plugin: The package name or the url of the plugin.
514 options: The plugin constructor options.
515 name: The name of the plugin (default `default`).
516 """
518 if options is None:
519 options = {}
521 self._plugins.append(
522 "plugins.push({"
523 + f"plugin: '{plugin}', "
524 + f"options: {json.dumps(options, cls=RawJavaScriptEncoder)}, "
525 + f"name: '{name}'"
526 + "})"
527 )
529 def set_size(
530 self,
531 width: Optional[Union[int, float, str]] = None,
532 height: Optional[Union[int, float, str]] = None,
533 aspect_ratio: Optional[Union[int, float, str]] = None,
534 ) -> None:
535 """
536 A method for setting width/height settings.
538 Args:
539 width: The width of the presentation story.
540 height: The height of the presentation story.
541 aspect_ratio: The aspect ratio of the presentation story.
543 Example:
544 Change the size of the story:
546 story.set_size("100%", "400px")
547 """
549 self._size = StorySize(width=width, height=height, aspect_ratio=aspect_ratio)
551 def _repr_html_(self) -> str:
552 return self.to_html()
554 def to_html(self) -> str:
555 """
556 A method for assembling the `HTML` code.
558 Returns:
559 The assembled `HTML` code as string.
560 """
562 vizzu_player_data = f"{json.dumps(self, cls=RawJavaScriptEncoder)}"
563 return DISPLAY_TEMPLATE.format(
564 id=uuid.uuid4().hex[:7],
565 version=__version__,
566 analytics=str(self._analytics).lower(),
567 vizzu=f'vizzu-url="{self._vizzu}"' if self._vizzu else "",
568 vizzu_story=self._vizzu_story,
569 vizzu_player_data=vizzu_player_data,
570 start_slide=f'start-slide="{self._start_slide}"'
571 if self._start_slide
572 else "",
573 chart_size=self._size.style,
574 chart_features=f"\n{DISPLAY_INDENT * 3}".join(self._features),
575 chart_events=f"\n{DISPLAY_INDENT * 3}".join(self._events),
576 chart_plugins=f"\n{DISPLAY_INDENT * 3}".join(self._plugins),
577 )
579 def export_to_html(self, filename: PathLike) -> None:
580 """
581 A method for exporting the story into `HTML` file.
583 Args:
584 filename: The path of the target `HTML` file.
585 """
587 with open(filename, "w", encoding="utf8") as file_desc:
588 file_desc.write(self.to_html())