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

1"""A module for working with presentation stories.""" 

2 

3from typing import Any, List, Optional, Tuple, Union 

4from os import PathLike 

5import json 

6import re 

7import uuid 

8 

9from ipyvizzu import RawJavaScriptEncoder, Data, Style, Config # , PlainAnimation 

10 

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__ 

18 

19 

20class Step(dict): 

21 """A class for representing a step of a slide.""" 

22 

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. 

30 

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. 

36 

37 Raises: 

38 ValueError: If `animations` are not set. 

39 

40 Example: 

41 Initialize a step with a [Config][ipyvizzu.Config] object: 

42 

43 step = Step( 

44 Config({"x": "Foo", "y": "Bar"}) 

45 ) 

46 """ 

47 

48 super().__init__() 

49 if not animations: 

50 raise ValueError("No animation was set.") 

51 self._update(*animations) 

52 

53 if anim_options: 

54 self["animOptions"] = anim_options 

55 

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) 

66 

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) 

72 

73 

74class Slide(list): 

75 """A class for representing a slide of a presentation story.""" 

76 

77 def __init__(self, step: Optional[Step] = None): 

78 """ 

79 Slide constructor. 

80 

81 Args: 

82 step: The first step can also be added to the slide in the constructor. 

83 

84 Example: 

85 Initialize a slide without step: 

86 

87 slide = Slide() 

88 

89 Initialize a slide with a step: 

90 

91 slide = Slide( 

92 Step( 

93 Config({"x": "Foo", "y": "Bar"}) 

94 ) 

95 ) 

96 """ 

97 

98 super().__init__() 

99 if step: 

100 self.add_step(step) 

101 

102 def add_step(self, step: Step) -> None: 

103 """ 

104 A method for adding a step for the slide. 

105 

106 Args: 

107 step: The next step of the slide. 

108 

109 Raises: 

110 TypeError: If the type of the `step` is not 

111 [Step][ipyvizzustory.storylib.story.Step]. 

112 

113 Example: 

114 Add steps to a slide: 

115 

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 """ 

128 

129 if not step or type(step) != Step: # pylint: disable=unidiomatic-typecheck 

130 raise TypeError("Type must be Step.") 

131 self.append(step) 

132 

133 

134class StorySize: 

135 """A class for representing the size of a presentation story.""" 

136 

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. 

145 

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. 

150 

151 Raises: 

152 ValueError: If width, height and aspect_ratio are set together. 

153 """ 

154 

155 width = self._convert_to_pixel_or_return(width) 

156 height = self._convert_to_pixel_or_return(height) 

157 

158 self._width = width 

159 self._height = height 

160 self._aspect_ratio = aspect_ratio 

161 

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}'" 

178 

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 

184 

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 

193 

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 

202 

203 @property 

204 def width(self) -> Optional[str]: 

205 """ 

206 A property for storing the width of a presentation story. 

207 

208 Returns: 

209 The width of a presentation story. 

210 """ 

211 

212 return self._width 

213 

214 @property 

215 def height(self) -> Optional[str]: 

216 """ 

217 A property for storing the height of a presentation story. 

218 

219 Returns: 

220 The height of a presentation story. 

221 """ 

222 

223 return self._height 

224 

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. 

229 

230 Returns: 

231 The aspect ratio of a presentation story. 

232 """ 

233 

234 return self._aspect_ratio 

235 

236 @property 

237 def style(self) -> str: 

238 """ 

239 A property for storing the style of a presentation story. 

240 

241 Note: 

242 If neither `width`, `height` nor `aspect_ratio` is set, it returns an empty string. 

243 

244 Returns: 

245 The cssText width and height of a presentation story. 

246 """ 

247 

248 return self._style 

249 

250 @staticmethod 

251 def is_pixel(value: Any) -> bool: 

252 """ 

253 A static method for checking the type of the given value. 

254 

255 Args: 

256 value: The value to check. 

257 

258 Returns: 

259 `True` if the value is pixel, `False` otherwise. 

260 """ 

261 

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 

268 

269 def get_width_height_in_pixels(self) -> Tuple[int, int]: 

270 """ 

271 A method for returning the width and height in pixels. 

272 

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. 

277 

278 Returns: 

279 The width and height in pixels as int. 

280 """ 

281 

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) 

314 

315 

316class Story(dict): 

317 """A class for representing a presentation story.""" 

318 

319 # pylint: disable=too-many-instance-attributes 

320 

321 def __init__(self, data: Data, style: Optional[Style] = None): 

322 """ 

323 Presentation Story constructor. 

324 

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. 

331 

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`. 

335 

336 Example: 

337 Initialize a story with data and without style: 

338 

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]) 

343 

344 story = Story(data=data) 

345 """ 

346 

347 super().__init__() 

348 

349 self._analytics = True 

350 self._vizzu: Optional[str] = None 

351 self._vizzu_story: str = VIZZU_STORY 

352 self._start_slide: Optional[int] = None 

353 

354 self._size: StorySize = StorySize() 

355 

356 self._features: List[str] = [] 

357 self._events: List[str] = [] 

358 self._plugins: List[str] = [] 

359 

360 if not data or type(data) != Data: # pylint: disable=unidiomatic-typecheck 

361 raise TypeError("Type must be Data.") 

362 self.update(data.build()) 

363 

364 if style: 

365 if type(style) != Style: # pylint: disable=unidiomatic-typecheck 

366 raise TypeError("Type must be Style.") 

367 self.update(style.build()) 

368 

369 self["slides"] = [] 

370 

371 @property 

372 def analytics(self) -> bool: 

373 """ 

374 A property for enabling/disabling the usage statistics feature. 

375 

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. 

380 

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. 

383 

384 Please note that even when this feature is enabled, 

385 publishing anything made with `ipyvizzu-story` remains GDPR compatible. 

386 

387 Returns: 

388 The value of the property (default `True`). 

389 """ 

390 

391 return self._analytics 

392 

393 @analytics.setter 

394 def analytics(self, analytics: Optional[bool]): 

395 self._analytics = bool(analytics) 

396 

397 @property 

398 def vizzu(self) -> Optional[str]: 

399 """ 

400 A property for changing `vizzu` url. 

401 

402 Note: 

403 If `None`, vizzu url is set by `vizzu-story`. 

404 

405 Returns: 

406 `Vizzu` url. 

407 """ 

408 

409 return self._vizzu 

410 

411 @vizzu.setter 

412 def vizzu(self, url: str) -> None: 

413 self._vizzu = url 

414 

415 @property 

416 def vizzu_story(self) -> str: 

417 """ 

418 A property for changing `vizzu-story` url. 

419 

420 Returns: 

421 `Vizzu-story` url. 

422 """ 

423 

424 return self._vizzu_story 

425 

426 @vizzu_story.setter 

427 def vizzu_story(self, url: str) -> None: 

428 self._vizzu_story = url 

429 

430 @property 

431 def start_slide(self) -> Optional[int]: 

432 """ 

433 A property for setting the starter slide. 

434 

435 Returns: 

436 Number of the starter slide. 

437 """ 

438 

439 return self._start_slide 

440 

441 @start_slide.setter 

442 def start_slide(self, number: int) -> None: 

443 self._start_slide = number 

444 

445 def add_slide(self, slide: Slide) -> None: 

446 """ 

447 A method for adding a slide for the story. 

448 

449 Args: 

450 slide: The next slide of the story. 

451 

452 Raises: 

453 TypeError: If the type of the `slide` is not 

454 [Slide][ipyvizzustory.storylib.story.Slide]. 

455 

456 Example: 

457 Add a slide to the story: 

458 

459 story.add_slide( 

460 Slide( 

461 Step( 

462 Config({"x": "Foo", "y": "Bar"}) 

463 ) 

464 ) 

465 ) 

466 """ 

467 

468 if not slide or type(slide) != Slide: # pylint: disable=unidiomatic-typecheck 

469 raise TypeError("Type must be Slide.") 

470 self["slides"].append(slide) 

471 

472 def set_feature(self, name: str, enabled: bool) -> None: 

473 """ 

474 A method for enabling or disabling a feature of the story. 

475 

476 Args: 

477 name: The name of the feature. 

478 enabled: `True` if enabled or `False` if disabled. 

479 

480 Example: 

481 Set a feature of the story, for example enable the tooltip: 

482 

483 story.set_feature("tooltip", True) 

484 """ 

485 

486 self._features.append(f"chart.feature('{name}', {json.dumps(enabled)});") 

487 

488 def add_event(self, event: str, handler: str) -> None: 

489 """ 

490 A method for creating and turning on an event handler. 

491 

492 Args: 

493 event: The type of the event. 

494 handler: The handler `JavaScript` expression as string. 

495 

496 Example: 

497 Add an event handler to the story: 

498 

499 story.add_event("click", "alert(JSON.stringify(event.detail));") 

500 """ 

501 

502 self._events.append( 

503 f"chart.on('{event}', event => {{{' '.join(handler.split())}}});" 

504 ) 

505 

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. 

511 

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 """ 

517 

518 if options is None: 

519 options = {} 

520 

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 ) 

528 

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. 

537 

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. 

542 

543 Example: 

544 Change the size of the story: 

545 

546 story.set_size("100%", "400px") 

547 """ 

548 

549 self._size = StorySize(width=width, height=height, aspect_ratio=aspect_ratio) 

550 

551 def _repr_html_(self) -> str: 

552 return self.to_html() 

553 

554 def to_html(self) -> str: 

555 """ 

556 A method for assembling the `HTML` code. 

557 

558 Returns: 

559 The assembled `HTML` code as string. 

560 """ 

561 

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 ) 

578 

579 def export_to_html(self, filename: PathLike) -> None: 

580 """ 

581 A method for exporting the story into `HTML` file. 

582 

583 Args: 

584 filename: The path of the target `HTML` file. 

585 """ 

586 

587 with open(filename, "w", encoding="utf8") as file_desc: 

588 file_desc.write(self.to_html())