Coverage for src/ipyvizzustory/storylib/story.py: 100%
182 statements
« prev ^ index » next coverage.py v7.5.4, created at 2024-07-10 10:22 +0000
« prev ^ index » next coverage.py v7.5.4, created at 2024-07-10 10:22 +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 ERROR_MSG_WIDTH_AND_HEIGHT = "width and height should be in pixels"
138 ERROR_MSG_WIDTH_OR_HEIGHT = "width or height should be in pixels"
139 ERROR_MSG_ASPECT_RATIO = "aspect_ratio should be a float"
141 def __init__(
142 self,
143 width: Optional[Union[int, float, str]] = None,
144 height: Optional[Union[int, float, str]] = None,
145 aspect_ratio: Optional[Union[int, float, str]] = None,
146 ):
147 """
148 StorySize constructor.
150 Args:
151 width: The width of a presentation story.
152 height: The height of a presentation story.
153 aspect_ratio: The aspect ratio of a presentation story.
155 Raises:
156 ValueError: If width, height and aspect_ratio are set together.
157 """
159 width = self._convert_to_pixel_or_return(width)
160 height = self._convert_to_pixel_or_return(height)
162 self._width = width
163 self._height = height
164 self._aspect_ratio = aspect_ratio
166 self._style = ""
167 if None not in [width, height, aspect_ratio]:
168 raise ValueError(
169 "width, height and aspect ratio cannot be set at the same time"
170 )
171 if all([height is not None, aspect_ratio is not None]):
172 width = "unset"
173 if any([width is not None, height is not None, aspect_ratio is not None]):
174 _width = "" if width is None else f"width: {width};"
175 _height = "" if height is None else f"height: {height};"
176 _aspect_ratio = (
177 ""
178 if aspect_ratio is None
179 else f"aspect-ratio: {aspect_ratio} !important;"
180 )
181 self._style = f"vp.style.cssText = '{_aspect_ratio}{_width}{_height}'"
183 @staticmethod
184 def _convert_to_pixel_or_return(value: Any) -> Optional[str]:
185 if StorySize._is_int(value) or StorySize._is_float(value):
186 return str(value) + "px"
187 return value
189 @staticmethod
190 def _is_int(value: Any) -> bool:
191 if isinstance(value, int):
192 return True
193 if isinstance(value, str):
194 if re.search(r"^[-+]?[0-9]+$", value):
195 return True
196 return False
198 @staticmethod
199 def _is_float(value: Any) -> bool:
200 if isinstance(value, float):
201 return True
202 if isinstance(value, str):
203 if re.search(r"^[+-]?[0-9]+\.[0-9]+$", value):
204 return True
205 return False
207 @property
208 def width(self) -> Optional[str]:
209 """
210 A property for storing the width of a presentation story.
212 Returns:
213 The width of a presentation story.
214 """
216 return self._width
218 @property
219 def height(self) -> Optional[str]:
220 """
221 A property for storing the height of a presentation story.
223 Returns:
224 The height of a presentation story.
225 """
227 return self._height
229 @property
230 def aspect_ratio(self) -> Optional[Union[int, float, str]]:
231 """
232 A property for storing the aspect ratio of a presentation story.
234 Returns:
235 The aspect ratio of a presentation story.
236 """
238 return self._aspect_ratio
240 @property
241 def style(self) -> str:
242 """
243 A property for storing the style of a presentation story.
245 Note:
246 If neither `width`, `height` nor `aspect_ratio` is set, it returns an empty string.
248 Returns:
249 The cssText width and height of a presentation story.
250 """
252 return self._style
254 @staticmethod
255 def is_pixel(value: Any) -> bool:
256 """
257 A static method for checking the type of the given value.
259 Args:
260 value: The value to check.
262 Returns:
263 `True` if the value is pixel, `False` otherwise.
264 """
266 if StorySize._is_int(value) or StorySize._is_float(value):
267 return True
268 if isinstance(value, str) and value.endswith("px"):
269 if StorySize._is_int(value[0:-2]) or StorySize._is_float(value[0:-2]):
270 return True
271 return False
273 def get_width_height_in_pixels(self) -> Tuple[int, int]:
274 """
275 A method for returning the width and height in pixels.
277 Raises:
278 ValueError: If width and height are not in pixels when aspect_ratio is not set.
279 ValueError: If width or height is not in pixel when aspect_ratio is set.
280 ValueError: If aspect_ratio is not a float when aspect_ratio is set.
282 Returns:
283 The width and height in pixels as int.
284 """
286 if self.aspect_ratio is None:
287 if any(
288 [
289 not StorySize.is_pixel(self.width),
290 not StorySize.is_pixel(self.height),
291 ]
292 ):
293 raise ValueError(StorySize.ERROR_MSG_WIDTH_AND_HEIGHT)
294 _width = int(float(self.width[:-2])) # type: ignore
295 _height = int(float(self.height[:-2])) # type: ignore
296 else:
297 if not any(
298 [
299 StorySize._is_int(self.aspect_ratio),
300 StorySize._is_float(self.aspect_ratio),
301 ]
302 ):
303 raise ValueError(StorySize.ERROR_MSG_ASPECT_RATIO)
304 if not any(
305 [StorySize.is_pixel(self.width), StorySize.is_pixel(self.height)]
306 ):
307 raise ValueError(StorySize.ERROR_MSG_WIDTH_OR_HEIGHT)
308 _aspect_ratio = float(self.aspect_ratio)
309 if StorySize.is_pixel(self.width):
310 _width = float(self.width[:-2]) # type: ignore
311 _height = int(_width / _aspect_ratio)
312 _width = int(_width)
313 else:
314 _height = float(self.height[:-2]) # type: ignore
315 _width = int(_height * _aspect_ratio)
316 _height = int(_height)
317 return (_width, _height)
320class Story(dict):
321 """A class for representing a presentation story."""
323 # pylint: disable=too-many-instance-attributes
325 def __init__(self, data: Data, style: Optional[Style] = None):
326 """
327 Presentation Story constructor.
329 Args:
330 data: Data set for the whole presentation story.
331 After initialization `data` can not be modified,
332 but it can be filtered.
333 style: Style settings for the presentation story.
334 `style` can be changed at each presentation step.
336 Raises:
337 TypeError: If the type of the `data` is not `ipyvizzu.Data`.
338 TypeError: If the type of the `style` is not `ipyvizzu.Style`.
340 Example:
341 Initialize a story with data and without style:
343 data = Data()
344 data.add_series("Foo", ["Alice", "Bob", "Ted"])
345 data.add_series("Bar", [15, 32, 12])
346 data.add_series("Baz", [5, 3, 2])
348 story = Story(data=data)
349 """
351 super().__init__()
353 self._analytics = True
354 self._vizzu: Optional[str] = None
355 self._vizzu_story: str = VIZZU_STORY
356 self._start_slide: Optional[int] = None
358 self._size: StorySize = StorySize()
360 self._features: List[str] = []
361 self._events: List[str] = []
362 self._plugins: List[str] = []
364 if not data or type(data) != Data: # pylint: disable=unidiomatic-typecheck
365 raise TypeError("Type must be Data.")
366 self.update(data.build())
368 if style:
369 if type(style) != Style: # pylint: disable=unidiomatic-typecheck
370 raise TypeError("Type must be Style.")
371 self.update(style.build())
373 self["slides"] = []
375 @property
376 def analytics(self) -> bool:
377 """
378 A property for enabling/disabling the usage statistics feature.
380 The usage statistics feature allows aggregate usage data collection
381 using Plausible's algorithm.
382 Enabling this feature helps us follow the progress and overall trends of our library,
383 allowing us to focus our resources effectively and better serve our users.
385 We do not track, collect, or store any personal data or personally identifiable information.
386 All data is isolated to a single day, a single site, and a single device only.
388 Please note that even when this feature is enabled,
389 publishing anything made with `ipyvizzu-story` remains GDPR compatible.
391 Returns:
392 The value of the property (default `True`).
393 """
395 return self._analytics
397 @analytics.setter
398 def analytics(self, analytics: Optional[bool]):
399 self._analytics = bool(analytics)
401 @property
402 def vizzu(self) -> Optional[str]:
403 """
404 A property for changing `vizzu` url.
406 Note:
407 If `None`, vizzu url is set by `vizzu-story`.
409 Returns:
410 `Vizzu` url.
411 """
413 return self._vizzu
415 @vizzu.setter
416 def vizzu(self, url: str) -> None:
417 self._vizzu = url
419 @property
420 def vizzu_story(self) -> str:
421 """
422 A property for changing `vizzu-story` url.
424 Returns:
425 `Vizzu-story` url.
426 """
428 return self._vizzu_story
430 @vizzu_story.setter
431 def vizzu_story(self, url: str) -> None:
432 self._vizzu_story = url
434 @property
435 def start_slide(self) -> Optional[int]:
436 """
437 A property for setting the starter slide.
439 Returns:
440 Number of the starter slide.
441 """
443 return self._start_slide
445 @start_slide.setter
446 def start_slide(self, number: int) -> None:
447 self._start_slide = number
449 def add_slide(self, slide: Slide) -> None:
450 """
451 A method for adding a slide for the story.
453 Args:
454 slide: The next slide of the story.
456 Raises:
457 TypeError: If the type of the `slide` is not
458 [Slide][ipyvizzustory.storylib.story.Slide].
460 Example:
461 Add a slide to the story:
463 story.add_slide(
464 Slide(
465 Step(
466 Config({"x": "Foo", "y": "Bar"})
467 )
468 )
469 )
470 """
472 if not slide or type(slide) != Slide: # pylint: disable=unidiomatic-typecheck
473 raise TypeError("Type must be Slide.")
474 self["slides"].append(slide)
476 def set_feature(self, name: str, enabled: bool) -> None:
477 """
478 A method for enabling or disabling a feature of the story.
480 Args:
481 name: The name of the feature.
482 enabled: `True` if enabled or `False` if disabled.
484 Example:
485 Set a feature of the story, for example enable the tooltip:
487 story.set_feature("tooltip", True)
488 """
490 self._features.append(f"chart.feature('{name}', {json.dumps(enabled)});")
492 def add_event(self, event: str, handler: str) -> None:
493 """
494 A method for creating and turning on an event handler.
496 Args:
497 event: The type of the event.
498 handler: The handler `JavaScript` expression as string.
500 Example:
501 Add an event handler to the story:
503 story.add_event("click", "alert(JSON.stringify(event.detail));")
504 """
506 self._events.append(
507 f"chart.on('{event}', event => {{{' '.join(handler.split())}}});"
508 )
510 def add_plugin(
511 self, plugin: str, options: Optional[dict] = None, name: str = "default"
512 ) -> None:
513 """
514 A method for register plugins of the chart.
516 Args:
517 plugin: The package name or the url of the plugin.
518 options: The plugin constructor options.
519 name: The name of the plugin (default `default`).
520 """
522 if options is None:
523 options = {}
525 self._plugins.append(
526 "plugins.push({"
527 + f"plugin: '{plugin}', "
528 + f"options: {json.dumps(options, cls=RawJavaScriptEncoder)}, "
529 + f"name: '{name}'"
530 + "})"
531 )
533 def set_size(
534 self,
535 width: Optional[Union[int, float, str]] = None,
536 height: Optional[Union[int, float, str]] = None,
537 aspect_ratio: Optional[Union[int, float, str]] = None,
538 ) -> None:
539 """
540 A method for setting width/height settings.
542 Args:
543 width: The width of the presentation story.
544 height: The height of the presentation story.
545 aspect_ratio: The aspect ratio of the presentation story.
547 Example:
548 Change the size of the story:
550 story.set_size("100%", "400px")
551 """
553 self._size = StorySize(width=width, height=height, aspect_ratio=aspect_ratio)
555 def _repr_html_(self) -> str:
556 return self.to_html()
558 def to_html(self) -> str:
559 """
560 A method for assembling the `HTML` code.
562 Returns:
563 The assembled `HTML` code as string.
564 """
566 vizzu_player_data = f"{json.dumps(self, cls=RawJavaScriptEncoder)}"
567 return DISPLAY_TEMPLATE.format(
568 id=uuid.uuid4().hex[:7],
569 version=__version__,
570 analytics=str(self._analytics).lower(),
571 vizzu=f'vizzu-url="{self._vizzu}"' if self._vizzu else "",
572 vizzu_story=self._vizzu_story,
573 vizzu_player_data=vizzu_player_data,
574 start_slide=f'start-slide="{self._start_slide}"'
575 if self._start_slide
576 else "",
577 chart_size=self._size.style,
578 chart_features=f"\n{DISPLAY_INDENT * 3}".join(self._features),
579 chart_events=f"\n{DISPLAY_INDENT * 3}".join(self._events),
580 chart_plugins=f"\n{DISPLAY_INDENT * 3}".join(self._plugins),
581 )
583 def export_to_html(self, filename: PathLike) -> None:
584 """
585 A method for exporting the story into `HTML` file.
587 Args:
588 filename: The path of the target `HTML` file.
589 """
591 with open(filename, "w", encoding="utf8") as file_desc:
592 file_desc.write(self.to_html())