Coverage for src/ipyvizzu/animation.py: 100%

147 statements  

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

1"""A module for working with chart animations.""" 

2 

3import abc 

4from enum import Enum 

5from os import PathLike 

6import json 

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

8import jsonschema # type: ignore 

9 

10import pandas as pd # type: ignore 

11from pandas.api.types import is_numeric_dtype # type: ignore 

12 

13from ipyvizzu.json import RawJavaScript, RawJavaScriptEncoder 

14from ipyvizzu.schema import DATA_SCHEMA 

15 

16 

17class AbstractAnimation: 

18 """ 

19 An abstract class for representing animation objects 

20 that have `dump` and `build` methods. 

21 """ 

22 

23 def dump(self) -> str: 

24 """ 

25 A method for converting the built dictionary into string. 

26 

27 Returns: 

28 An str that has been json dumped with 

29 [RawJavaScriptEncoder][ipyvizzu.json.RawJavaScriptEncoder] from a dictionary. 

30 """ 

31 

32 return json.dumps(self.build(), cls=RawJavaScriptEncoder) 

33 

34 @abc.abstractmethod 

35 def build(self) -> dict: 

36 """ 

37 An abstract method for returning a dictionary with values 

38 that can be converted into json string. 

39 

40 Returns: 

41 A dictionary that stored in the animation object. 

42 """ 

43 

44 

45class PlainAnimation(dict, AbstractAnimation): 

46 """ 

47 A class for representing plain animation. 

48 It can build any dictionary. 

49 """ 

50 

51 def build(self) -> dict: 

52 """ 

53 A method for returning the plain animation dictionary. 

54 

55 Returns: 

56 A dictionary that stored in the plain animation object. 

57 """ 

58 

59 return self 

60 

61 

62class InferType(Enum): 

63 """An enum class for storing data infer types.""" 

64 

65 DIMENSION = "dimension" 

66 """An enum key-value for storing dimension infer type.""" 

67 

68 MEASURE = "measure" 

69 """An enum key-value for storing measure infer type.""" 

70 

71 

72class Data(dict, AbstractAnimation): 

73 """ 

74 A class for representing data animation. 

75 It can build data option of the chart. 

76 """ 

77 

78 @classmethod 

79 def filter(cls, filter_expr: Optional[str] = None): # -> Data: 

80 """ 

81 A class method for creating a [Data][ipyvizzu.animation.Data] 

82 class instance with a data filter. 

83 

84 Args: 

85 filter_expr: The JavaScript data filter expression. 

86 

87 Returns: 

88 (Data): A data animation instance that contains a data filter. 

89 

90 Example: 

91 Create a [Data][ipyvizzu.animation.Data] class with a data filter: 

92 

93 filter = Data.filter("record['Genres'] == 'Pop'") 

94 """ 

95 

96 data = cls() 

97 data.set_filter(filter_expr) 

98 return data 

99 

100 def set_filter(self, filter_expr: Optional[str] = None) -> None: 

101 """ 

102 A method for adding a filter to an existing 

103 [Data][ipyvizzu.animation.Data] class instance. 

104 

105 Args: 

106 filter_expr: The JavaScript data filter expression. 

107 

108 Example: 

109 Add a data filter to a [Data][ipyvizzu.animation.Data] class instance: 

110 

111 data = Data() 

112 data.set_filter("record['Genres'] == 'Pop'") 

113 """ 

114 

115 filter_expr_raw_js = ( 

116 RawJavaScript(f"record => {{ return ({' '.join(filter_expr.split())}) }}") 

117 if filter_expr is not None 

118 else filter_expr 

119 ) 

120 self.update({"filter": filter_expr_raw_js}) 

121 

122 @classmethod 

123 def from_json(cls, filename: Union[str, bytes, PathLike]): # -> Data: 

124 """ 

125 A method for returning a [Data][ipyvizzu.animation.Data] 

126 class instance which has been created from a json file. 

127 

128 Args: 

129 filename: The path of the data source json file. 

130 

131 Returns: 

132 (Data): A data animation instance that has been created from a json file. 

133 """ 

134 

135 with open(filename, "r", encoding="utf8") as file_desc: 

136 return cls(json.load(file_desc)) 

137 

138 def add_record(self, record: list) -> None: 

139 """ 

140 A method for adding a record to an existing 

141 [Data][ipyvizzu.animation.Data] class instance. 

142 

143 Args: 

144 record: A list that contains data values. 

145 

146 Example: 

147 Adding a record to a [Data][ipyvizzu.animation.Data] class instance: 

148 

149 data = Data() 

150 record = ["Pop", "Hard", 114] 

151 data.add_record(record) 

152 """ 

153 

154 self._add_value("records", record) 

155 

156 def add_records(self, records: List[list]) -> None: 

157 """ 

158 A method for adding records to an existing 

159 [Data][ipyvizzu.animation.Data] class instance. 

160 

161 Args: 

162 records: A list that contains data records. 

163 

164 Example: 

165 Adding records to a [Data][ipyvizzu.animation.Data] class instance: 

166 

167 data = Data() 

168 records = [ 

169 ["Pop", "Hard", 114], 

170 ["Rock", "Hard", 96], 

171 ["Pop", "Experimental", 127], 

172 ["Rock", "Experimental", 83], 

173 ] 

174 data.add_records(records) 

175 """ 

176 

177 list(map(self.add_record, records)) 

178 

179 def add_series(self, name: str, values: Optional[list] = None, **kwargs) -> None: 

180 """ 

181 A method for adding a series to an existing 

182 [Data][ipyvizzu.animation.Data] class instance. 

183 

184 Args: 

185 name: The name of the series. 

186 values: The data values of the series. 

187 **kwargs (Optional): 

188 Arbitrary keyword arguments. 

189 

190 For example infer type can be set with the `type` keywod argument. 

191 

192 Example: 

193 Adding a series without values to a [Data][ipyvizzu.animation.Data] class instance: 

194 

195 data = Data() 

196 data.add_series("Genres") 

197 

198 Adding a series without values and with infer type to 

199 a [Data][ipyvizzu.animation.Data] class instance: 

200 

201 data = Data() 

202 data.add_series("Kinds", type="dimension") 

203 

204 Adding a series with values to a [Data][ipyvizzu.animation.Data] class instance: 

205 

206 data = Data() 

207 data.add_series( 

208 "Popularity", [114, 96, 127, 83] 

209 ) 

210 """ 

211 

212 self._add_named_value("series", name, values, **kwargs) 

213 

214 def add_dimension(self, name: str, values: Optional[list] = None, **kwargs) -> None: 

215 """ 

216 A method for adding a dimension to an existing 

217 [Data][ipyvizzu.animation.Data] class instance. 

218 

219 Args: 

220 name: The name of the dimension. 

221 values: The data values of the dimension. 

222 **kwargs (Optional): Arbitrary keyword arguments. 

223 

224 Example: 

225 Adding a dimension with values to a [Data][ipyvizzu.animation.Data] class instance: 

226 

227 data = Data() 

228 data.add_dimension("Genres", ["Pop", "Rock"]) 

229 """ 

230 

231 self._add_named_value("dimensions", name, values, **kwargs) 

232 

233 def add_measure(self, name: str, values: Optional[list] = None, **kwargs) -> None: 

234 """ 

235 A method for adding a measure to an existing 

236 [Data][ipyvizzu.animation.Data] class instance. 

237 

238 Args: 

239 name: The name of the measure. 

240 values: The data values of the measure. 

241 **kwargs (Optional): Arbitrary keyword arguments. 

242 

243 Example: 

244 Adding a measure with values to a [Data][ipyvizzu.animation.Data] class instance: 

245 

246 data = Data() 

247 data.add_measure( 

248 "Popularity", 

249 [ 

250 [114, 96], 

251 [127, 83], 

252 ], 

253 ) 

254 """ 

255 

256 self._add_named_value("measures", name, values, **kwargs) 

257 

258 def add_data_frame( 

259 self, 

260 data_frame: Union[pd.DataFrame, pd.Series], 

261 default_measure_value: Optional[Any] = 0, 

262 default_dimension_value: Optional[Any] = "", 

263 ) -> None: 

264 """ 

265 A method for adding data frame to an existing 

266 [Data][ipyvizzu.animation.Data] class instance. 

267 

268 Args: 

269 data_frame: The pandas data frame object. 

270 default_measure_value: The default measure value to fill the empty values. 

271 default_dimension_value: The default dimension value to fill the empty values. 

272 

273 Raises: 

274 TypeError: If `data_frame` is not instance of [pd.DataFrame][pandas.DataFrame] 

275 or [pd.Series][pandas.Series]. 

276 

277 Example: 

278 Adding a data frame to a [Data][ipyvizzu.animation.Data] class instance: 

279 

280 data_frame = pd.DataFrame( 

281 { 

282 "Genres": ["Pop", "Rock", "Pop", "Rock"], 

283 "Kinds": ["Hard", "Hard", "Experimental", "Experimental"], 

284 "Popularity": [114, 96, 127, 83], 

285 } 

286 ) 

287 data = Data() 

288 data.add_data_frame(data_frame) 

289 """ 

290 

291 if not isinstance(data_frame, type(None)): 

292 if isinstance(data_frame, pd.Series): 

293 data_frame = pd.DataFrame(data_frame) 

294 if not isinstance(data_frame, pd.DataFrame): 

295 raise TypeError( 

296 "data_frame must be instance of pandas.DataFrame or pandas.Series" 

297 ) 

298 for name in data_frame.columns: 

299 values = [] 

300 if is_numeric_dtype(data_frame[name].dtype): 

301 infer_type = InferType.MEASURE 

302 values = ( 

303 data_frame[name] 

304 .fillna(default_measure_value) 

305 .astype(float) 

306 .values.tolist() 

307 ) 

308 else: 

309 infer_type = InferType.DIMENSION 

310 values = ( 

311 data_frame[name] 

312 .fillna(default_dimension_value) 

313 .astype(str) 

314 .values.tolist() 

315 ) 

316 self.add_series( 

317 name, 

318 values, 

319 type=infer_type.value, 

320 ) 

321 

322 def add_data_frame_index( 

323 self, 

324 data_frame: Union[pd.DataFrame, pd.Series], 

325 name: Optional[str], 

326 ) -> None: 

327 """ 

328 A method for adding data frame's index to an existing 

329 [Data][ipyvizzu.animation.Data] class instance. 

330 

331 Args: 

332 data_frame: The pandas data frame object. 

333 name: The name of the index series. 

334 

335 Raises: 

336 TypeError: If `data_frame` is not instance of [pd.DataFrame][pandas.DataFrame] 

337 or [pd.Series][pandas.Series]. 

338 

339 Example: 

340 Adding a data frame's index to a [Data][ipyvizzu.animation.Data] class instance: 

341 

342 data_frame = pd.DataFrame( 

343 {"Popularity": [114, 96]}, 

344 index=["x", "y"] 

345 ) 

346 data = Data() 

347 data.add_data_frame_index(data_frame, "DataFrameIndex") 

348 data.add_data_frame(data_frame) 

349 """ 

350 

351 if data_frame is not None: 

352 if isinstance(data_frame, pd.Series): 

353 data_frame = pd.DataFrame(data_frame) 

354 if not isinstance(data_frame, pd.DataFrame): 

355 raise TypeError( 

356 "data_frame must be instance of pandas.DataFrame or pandas.Series" 

357 ) 

358 self.add_series( 

359 str(name), 

360 [str(i) for i in data_frame.index], 

361 type=InferType.DIMENSION.value, 

362 ) 

363 

364 def _add_named_value( 

365 self, dest: str, name: str, values: Optional[list] = None, **kwargs 

366 ) -> None: 

367 value = {"name": name, **kwargs} 

368 

369 if values is not None: 

370 value["values"] = values # type: ignore 

371 

372 self._add_value(dest, value) 

373 

374 def _add_value(self, dest: str, value: Union[dict, list]) -> None: 

375 self.setdefault(dest, []).append(value) 

376 

377 def build(self) -> dict: 

378 """ 

379 A method for validating and returning the data animation dictionary. 

380 

381 Returns: 

382 A dictionary that stored in the data animation object. 

383 It contains a `data` key whose value is the stored animation. 

384 """ 

385 

386 jsonschema.validate(self, DATA_SCHEMA) 

387 return {"data": self} 

388 

389 

390class ConfigAttr(type): 

391 """ 

392 A metaclass class for the [Config][ipyvizzu.animation.Config] class. 

393 Returns a [Config][ipyvizzu.animation.Config] class with a chart preset 

394 if the `__getattr__` method called. 

395 

396 For information on all available chart presets see the 

397 [Vizzu Code reference](https://lib.vizzuhq.com/latest/reference/modules/presets/#interfaces). 

398 """ 

399 

400 @classmethod 

401 def __getattr__(mcs, name): 

402 config_attr = mcs("ConfigAttr", (object,), {"name": name}) 

403 return config_attr._get_preset # pylint: disable=no-member 

404 

405 def _get_preset(cls, preset): 

406 config = Config(RawJavaScript(f"lib.presets.{cls.name}({preset})")) 

407 return config 

408 

409 

410class Config(AbstractAnimation, metaclass=ConfigAttr): 

411 """ 

412 A class for representing config animation. 

413 It can build config option of the chart. 

414 """ 

415 

416 def __init__(self, data: Optional[dict]): 

417 """ 

418 Config constructor. 

419 

420 Args: 

421 data: 

422 A config animation dictionary. 

423 For information on all available config parameters see the 

424 [Vizzu Code reference](https://lib.vizzuhq.com/latest/reference/interfaces/vizzu.Config.Chart/#properties). 

425 """ # pylint: disable=line-too-long 

426 

427 self._data = data 

428 

429 def build(self) -> dict: 

430 """ 

431 A method for returning the config animation dictionary. 

432 

433 Returns: 

434 A dictionary that stored in the config animation object. 

435 It contains a `config` key whose value is the stored animation. 

436 """ 

437 

438 return {"config": self._data} 

439 

440 

441class Style(AbstractAnimation): 

442 """ 

443 A class for representing style animation. 

444 It can build style option of the chart. 

445 """ 

446 

447 def __init__(self, data: Optional[dict]): 

448 """ 

449 Style constructor. 

450 

451 Args: 

452 data: 

453 A style animation dictionary. 

454 For information on all available style parameters see the [Style][styling-properties] 

455 chapter or the 

456 [Vizzu Code reference](https://lib.vizzuhq.com/latest/reference/interfaces/vizzu.Styles.Chart/#properties). 

457 """ # pylint: disable=line-too-long 

458 

459 self._data = data 

460 

461 def build(self) -> dict: 

462 """ 

463 A method for returning the style animation dictionary. 

464 

465 Returns: 

466 A dictionary that stored in the style animation object. 

467 It contains a `style` key whose value is the stored animation. 

468 """ 

469 

470 return {"style": self._data} 

471 

472 

473class Keyframe(AbstractAnimation): 

474 """ 

475 A class for representing keyframe animation. 

476 It can build keyframe of the chart. 

477 """ 

478 

479 def __init__( 

480 self, 

481 *animations: AbstractAnimation, 

482 **options: Optional[Union[str, int, float, dict]], 

483 ): 

484 """ 

485 Keyframe constructor. 

486 

487 Args: 

488 *animations: 

489 List of AbstractAnimation inherited objects such as [Data][ipyvizzu.animation.Data], 

490 [Config][ipyvizzu.animation.Config] and [Style][ipyvizzu.animation.Style]. 

491 **options: Dictionary of animation options for example `duration=1`. 

492 For information on all available animation options see the 

493 [Vizzu Code reference](https://lib.vizzuhq.com/latest/reference/interfaces/vizzu.Anim.Options/#properties). 

494 

495 Raises: 

496 ValueError: If `animations` is not set. 

497 ValueError: If initialized with a `Keyframe`. 

498 """ # pylint: disable=line-too-long 

499 

500 if not animations: 

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

502 if [animation for animation in animations if isinstance(animation, Keyframe)]: 

503 raise ValueError("A Keyframe cannot contain a Keyframe.") 

504 

505 self._keyframe = {} 

506 self._keyframe["target"] = AnimationMerger.merge_animations(animations).build() 

507 if options: 

508 self._keyframe["options"] = options 

509 

510 def build(self) -> dict: 

511 """ 

512 A method for returning the keyframe animation dictionary. 

513 

514 Returns: 

515 A dictionary that stored in the keyframe animation object. 

516 It contains a `target` key whose value is the stored animation 

517 and an optional `options` key whose value is the stored animation options. 

518 """ 

519 

520 return self._keyframe 

521 

522 

523class Snapshot(AbstractAnimation): 

524 """ 

525 A class for representing a stored chart state. 

526 It can build the snapshot id of the chart. 

527 """ 

528 

529 def __init__(self, snapshot_id: str): 

530 """ 

531 Snapshot constructor. 

532 

533 Args: 

534 snapshot_id: A snapshot id. 

535 """ 

536 

537 self._snapshot_id = snapshot_id 

538 

539 def build(self) -> str: # type: ignore 

540 """ 

541 A method for returning the snapshot id str. 

542 

543 Returns: 

544 An str snapshot id that stored in the snapshot animation object. 

545 """ 

546 

547 return self._snapshot_id 

548 

549 

550class Animation(Snapshot): 

551 """ 

552 A class for representing a stored animation. 

553 It can build the snapshot id of the animation. 

554 """ 

555 

556 

557class AnimationMerger(AbstractAnimation): 

558 """A class for merging different types of animations.""" 

559 

560 def __init__(self): 

561 """AnimationMerger constructor.""" 

562 

563 self._dict = {} 

564 self._list = [] 

565 

566 @classmethod 

567 def merge_animations( 

568 cls, animations: Tuple[AbstractAnimation, ...] 

569 ) -> AbstractAnimation: 

570 """ 

571 A class method for merging animations. 

572 

573 Args: 

574 animations: List of `AbstractAnimation` inherited objects. 

575 

576 Returns: 

577 An `AnimationMerger` class with the merged animations. 

578 """ 

579 

580 if len(animations) == 1 and not isinstance(animations[0], Keyframe): 

581 return animations[0] 

582 

583 merger = cls() 

584 for animation in animations: 

585 merger.merge(animation) 

586 

587 return merger 

588 

589 def merge(self, animation: AbstractAnimation) -> None: 

590 """ 

591 A method for merging an animation with the previously merged animations. 

592 

593 Args: 

594 animation: An animation to be merged with with previously merged animations. 

595 

596 Raises: 

597 ValueError: If the type of an animation is already merged. 

598 ValueError: If `Keyframe` is merged with different type of animation. 

599 """ 

600 

601 if isinstance(animation, Keyframe): 

602 if self._dict: 

603 raise ValueError("Keyframe cannot be merged with other animations.") 

604 data = animation.build() 

605 self._list.append(data) 

606 else: 

607 if self._list: 

608 raise ValueError("Keyframe cannot be merged with other animations.") 

609 data = self._validate(animation) 

610 self._dict.update(data) 

611 

612 def _validate(self, animation: AbstractAnimation) -> dict: 

613 if isinstance(animation, Snapshot): 

614 raise ValueError("Snapshot cannot be merged with other animations.") 

615 data = animation.build() 

616 common_keys = set(data).intersection(self._dict) 

617 

618 if common_keys: 

619 raise ValueError(f"{common_keys} is already merged.") 

620 

621 return data 

622 

623 def build(self) -> Union[dict, list]: # type: ignore 

624 """ 

625 A method for returning a merged list of `Keyframes` 

626 or a merged dictionary from different types of animations. 

627 

628 Returns: 

629 A merged list of [Keyframes][ipyvizzu.animation.Keyframe] or 

630 a merged dictionary from 

631 [Data][ipyvizzu.animation.Data], 

632 [Config][ipyvizzu.animation.Config] and 

633 [Style][ipyvizzu.animation.Style] animations. 

634 """ 

635 

636 if self._dict: 

637 return self._dict 

638 return self._list