Coverage for src/ipyvizzustory/storylib/story.py: 100%

113 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-10 09:08 +0000

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

2 

3from typing import Optional, Union, List, Any 

4from os import PathLike 

5import json 

6import uuid 

7 

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

9 

10from ipyvizzustory.storylib.animation import DataFilter 

11from ipyvizzustory.storylib.template import ( 

12 VIZZU_STORY, 

13 DISPLAY_TEMPLATE, 

14 DISPLAY_INDENT, 

15) 

16 

17 

18class Step(dict): 

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

20 

21 def __init__( 

22 self, 

23 *animations: Union[Data, Style, Config], 

24 **anim_options: Optional[Union[str, int, float, dict]], 

25 ): 

26 """ 

27 Step constructor. 

28 

29 Args: 

30 *animations: List of [Data][ipyvizzu.Data], 

31 [Config][ipyvizzu.Config] and [Style][ipyvizzu.Style] objects. 

32 A `Step` can contain each of the above once. 

33 **anim_options: Animation options such as duration. 

34 

35 Raises: 

36 ValueError: If `animations` are not set. 

37 

38 Example: 

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

40 

41 step = Step( 

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

43 ) 

44 """ 

45 

46 super().__init__() 

47 if not animations: 

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

49 self._update(*animations) 

50 

51 if anim_options: 

52 self["animOptions"] = anim_options 

53 

54 def _update(self, *animations: Union[Data, Style, Config]) -> None: 

55 for animation in animations: 

56 if not animation or type(animation) not in [ 

57 Data, 

58 Style, 

59 Config, 

60 ]: # pylint: disable=unidiomatic-typecheck 

61 raise TypeError("Type must be Data, Style or Config.") 

62 if type(animation) == Data: # pylint: disable=unidiomatic-typecheck 

63 animation = DataFilter(animation) 

64 

65 builded_animation = animation.build() 

66 common_keys = set(builded_animation).intersection(set(self)) 

67 if common_keys: 

68 raise ValueError(f"Animation is already merged: {common_keys}") 

69 self.update(builded_animation) 

70 

71 

72class Slide(list): 

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

74 

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

76 """ 

77 Slide constructor. 

78 

79 Args: 

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

81 

82 Example: 

83 Initialize a slide without step: 

84 

85 slide = Slide() 

86 

87 Initialize a slide with a step: 

88 

89 slide = Slide( 

90 Step( 

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

92 ) 

93 ) 

94 """ 

95 

96 super().__init__() 

97 if step: 

98 self.add_step(step) 

99 

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

101 """ 

102 A method for adding a step for the slide. 

103 

104 Args: 

105 step: The next step of the slide. 

106 

107 Raises: 

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

109 [Step][ipyvizzustory.storylib.story.Step]. 

110 

111 Example: 

112 Add steps to a slide: 

113 

114 slide = Slide() 

115 slide.add_step( 

116 Step( 

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

118 ) 

119 ) 

120 slide.add_step( 

121 Step( 

122 Config({"color": "Foo", "x": "Baz", "geometry": "circle"}) 

123 ) 

124 ) 

125 """ 

126 

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

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

129 self.append(step) 

130 

131 

132class StorySize: 

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

134 

135 def __init__(self, width: Optional[str] = None, height: Optional[str] = None): 

136 """ 

137 StorySize constructor. 

138 

139 Args: 

140 width: The width of a presentation story. 

141 height: The height of a presentation story. 

142 """ 

143 self._width = width 

144 self._height = height 

145 

146 self._style = "" 

147 if any([width is not None, height is not None]): 

148 width = "" if width is None else f"width: {width};" 

149 height = "" if height is None else f"height: {height};" 

150 self._style = f"vp.style.cssText = '{width}{height}'" 

151 

152 @property 

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

154 """ 

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

156 

157 Returns: 

158 The width of a presentation story. 

159 """ 

160 

161 return self._width 

162 

163 @property 

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

165 """ 

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

167 

168 Returns: 

169 The height of a presentation story. 

170 """ 

171 

172 return self._height 

173 

174 @property 

175 def style(self) -> str: 

176 """ 

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

178 

179 Note: 

180 If `width` and `height` are not set it returns an empty string. 

181 

182 Returns: 

183 The cssText width and height of a presentation story. 

184 """ 

185 

186 return self._style 

187 

188 @staticmethod 

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

190 """ 

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

192 

193 Args: 

194 value: The value to check. 

195 

196 Returns: 

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

198 """ 

199 

200 value_is_pixel = False 

201 if isinstance(value, str): 

202 if value.endswith("px"): 

203 value_is_pixel = value[:-2].isnumeric() 

204 return value_is_pixel 

205 

206 

207class Story(dict): 

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

209 

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

211 

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

213 """ 

214 Presentation Story constructor. 

215 

216 Args: 

217 data: Data set for the whole presentation story. 

218 After initialization `data` can not be modified, 

219 but it can be filtered. 

220 style: Style settings for the presentation story. 

221 `style` can be changed at each presentation step. 

222 

223 Raises: 

224 TypeError: If the type of the `data` is not `ipyvizzu.Data`. 

225 TypeError: If the type of the `style` is not `ipyvizzu.Style`. 

226 

227 Example: 

228 Initialize a story with data and without style: 

229 

230 data = Data() 

231 data.add_series("Foo", ["Alice", "Bob", "Ted"]) 

232 data.add_series("Bar", [15, 32, 12]) 

233 data.add_series("Baz", [5, 3, 2]) 

234 

235 story = Story(data=data) 

236 """ 

237 

238 super().__init__() 

239 

240 self._vizzu: Optional[str] = None 

241 self._vizzu_story: str = VIZZU_STORY 

242 self._start_slide: Optional[int] = None 

243 

244 self._size: StorySize = StorySize() 

245 

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

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

248 

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

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

251 self.update(data.build()) 

252 

253 if style: 

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

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

256 self.update(style.build()) 

257 

258 self["slides"] = [] 

259 

260 @property 

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

262 """ 

263 A property for changing `vizzu` url. 

264 

265 Note: 

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

267 

268 Returns: 

269 `Vizzu` url. 

270 """ 

271 

272 return self._vizzu 

273 

274 @vizzu.setter 

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

276 self._vizzu = url 

277 

278 @property 

279 def vizzu_story(self) -> str: 

280 """ 

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

282 

283 Returns: 

284 `Vizzu-story` url. 

285 """ 

286 

287 return self._vizzu_story 

288 

289 @vizzu_story.setter 

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

291 self._vizzu_story = url 

292 

293 @property 

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

295 """ 

296 A property for setting the starter slide. 

297 

298 Returns: 

299 Number of the starter slide. 

300 """ 

301 

302 return self._start_slide 

303 

304 @start_slide.setter 

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

306 self._start_slide = number 

307 

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

309 """ 

310 A method for adding a slide for the story. 

311 

312 Args: 

313 slide: The next slide of the story. 

314 

315 Raises: 

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

317 [Slide][ipyvizzustory.storylib.story.Slide]. 

318 

319 Example: 

320 Add a slide to the story: 

321 

322 story.add_slide( 

323 Slide( 

324 Step( 

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

326 ) 

327 ) 

328 ) 

329 """ 

330 

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

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

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

334 

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

336 """ 

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

338 

339 Args: 

340 name: The name of the feature. 

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

342 

343 Example: 

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

345 

346 story.set_feature("tooltip", True) 

347 """ 

348 

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

350 

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

352 """ 

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

354 

355 Args: 

356 event: The type of the event. 

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

358 

359 Example: 

360 Add an event handler to the story: 

361 

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

363 """ 

364 

365 self._events.append( 

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

367 ) 

368 

369 def set_size( 

370 self, width: Optional[str] = None, height: Optional[str] = None 

371 ) -> None: 

372 """ 

373 A method for setting width/height settings. 

374 

375 Args: 

376 width: The width of the presentation story. 

377 height: The height of the presentation story. 

378 

379 Example: 

380 Change the size of the story: 

381 

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

383 """ 

384 

385 self._size = StorySize(width=width, height=height) 

386 

387 def _repr_html_(self) -> str: 

388 return self.to_html() 

389 

390 def to_html(self) -> str: 

391 """ 

392 A method for assembling the `HTML` code. 

393 

394 Returns: 

395 The assembled `HTML` code as string. 

396 """ 

397 

398 vizzu_player_data = f"{json.dumps(self, cls=RawJavaScriptEncoder)}" 

399 return DISPLAY_TEMPLATE.format( 

400 id=uuid.uuid4().hex[:7], 

401 vizzu_attribute=f'vizzu-url="{self._vizzu}"' if self._vizzu else "", 

402 start_slide=f'start-slide="{self._start_slide}"' 

403 if self._start_slide 

404 else "", 

405 vizzu_story=self._vizzu_story, 

406 vizzu_player_data=vizzu_player_data, 

407 chart_size=self._size.style, 

408 chart_features=f"\n{DISPLAY_INDENT * 3}".join(self._features), 

409 chart_events=f"\n{DISPLAY_INDENT * 3}".join(self._events), 

410 ) 

411 

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

413 """ 

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

415 

416 Args: 

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

418 """ 

419 

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

421 file_desc.write(self.to_html())