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

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 

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

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

361 self.update(data.build()) 

362 

363 if style: 

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

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

366 self.update(style.build()) 

367 

368 self["slides"] = [] 

369 

370 @property 

371 def analytics(self) -> bool: 

372 """ 

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

374 

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. 

379 

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. 

382 

383 Please note that even when this feature is enabled, 

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

385 

386 Returns: 

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

388 """ 

389 

390 return self._analytics 

391 

392 @analytics.setter 

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

394 self._analytics = bool(analytics) 

395 

396 @property 

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

398 """ 

399 A property for changing `vizzu` url. 

400 

401 Note: 

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

403 

404 Returns: 

405 `Vizzu` url. 

406 """ 

407 

408 return self._vizzu 

409 

410 @vizzu.setter 

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

412 self._vizzu = url 

413 

414 @property 

415 def vizzu_story(self) -> str: 

416 """ 

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

418 

419 Returns: 

420 `Vizzu-story` url. 

421 """ 

422 

423 return self._vizzu_story 

424 

425 @vizzu_story.setter 

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

427 self._vizzu_story = url 

428 

429 @property 

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

431 """ 

432 A property for setting the starter slide. 

433 

434 Returns: 

435 Number of the starter slide. 

436 """ 

437 

438 return self._start_slide 

439 

440 @start_slide.setter 

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

442 self._start_slide = number 

443 

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

445 """ 

446 A method for adding a slide for the story. 

447 

448 Args: 

449 slide: The next slide of the story. 

450 

451 Raises: 

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

453 [Slide][ipyvizzustory.storylib.story.Slide]. 

454 

455 Example: 

456 Add a slide to the story: 

457 

458 story.add_slide( 

459 Slide( 

460 Step( 

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

462 ) 

463 ) 

464 ) 

465 """ 

466 

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

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

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

470 

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

472 """ 

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

474 

475 Args: 

476 name: The name of the feature. 

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

478 

479 Example: 

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

481 

482 story.set_feature("tooltip", True) 

483 """ 

484 

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

486 

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

488 """ 

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

490 

491 Args: 

492 event: The type of the event. 

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

494 

495 Example: 

496 Add an event handler to the story: 

497 

498 story.add_event("click", "alert(JSON.stringify(event.data));") 

499 """ 

500 

501 self._events.append( 

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

503 ) 

504 

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. 

513 

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. 

518 

519 Example: 

520 Change the size of the story: 

521 

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

523 """ 

524 

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

526 

527 def _repr_html_(self) -> str: 

528 return self.to_html() 

529 

530 def to_html(self) -> str: 

531 """ 

532 A method for assembling the `HTML` code. 

533 

534 Returns: 

535 The assembled `HTML` code as string. 

536 """ 

537 

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 ) 

553 

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

555 """ 

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

557 

558 Args: 

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

560 """ 

561 

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

563 file_desc.write(self.to_html())