Skip to content

Glyph Base Class#

The Glyph class is the base class for all cleopatra visualization glyphs. It provides shared infrastructure for figure/axes management, color scale normalization (including value classification), colorbar creation, tick control, point overlays, and animation saving.

ArrayGlyph, MeshGlyph, ScatterGlyph, VectorGlyph, FlowGlyph, LineGlyph, PolygonGlyph, and KDEGlyph all inherit from Glyph and share its colour-mapping / colorbar pipeline. HistogramGlyph stands alone.

Class Documentation#

cleopatra.glyphs.base.glyph.Glyph #

Base class for cleopatra visualization glyphs.

Handles figure/axes management, default options, color scale normalization, colorbar creation, tick control, point overlays, and animation saving. Subclasses implement the actual rendering.

The accepted option keys are exposed per subclass via the DEFAULT_OPTIONS class attribute, and can be inspected or filtered before constructing an instance with the option_keys and filter_kwargs classmethods (useful for safely forwarding a bag of user-supplied styling kwargs).

Parameters:

Name Type Description Default
default_options dict

Default plot options dict. Subclasses provide their own defaults merged with STYLE_DEFAULTS.

required
fig Figure | None

Pre-existing matplotlib figure to bind. Default is None. An ax fully determines its figure, so fig is optional even when ax is given; when both are passed the explicit fig is kept as the figure handle. Passing a fig that does not own the given ax emits a UserWarning (the explicit fig is still honoured, but the two handles then disagree).

None
ax Axes | None

Pre-existing matplotlib axes to bind. Default is None. Passing ax on its own is supported — its parent figure is derived automatically (the axes is no longer dropped when fig is omitted).

None
**kwargs

Override any key in default_options.

{}

Examples:

  • Create a Glyph and override the colormap:
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts["vmin"] = None
    >>> opts["vmax"] = None
    >>> g = Glyph(default_options=opts, cmap="plasma")
    >>> g.default_options["cmap"]
    'plasma'
    
  • Provide a pre-existing figure and axes:
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts["vmin"] = None
    >>> opts["vmax"] = None
    >>> fig, ax = plt.subplots()
    >>> g = Glyph(default_options=opts, fig=fig, ax=ax)
    >>> g.fig is fig
    True
    >>> g.ax is ax
    True
    
  • Provide only an axes; the figure is derived from it:
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts["vmin"] = None
    >>> opts["vmax"] = None
    >>> fig, ax = plt.subplots()
    >>> g = Glyph(default_options=opts, ax=ax)
    >>> g.ax is ax
    True
    >>> g.fig is ax.get_figure()
    True
    
See Also

cleopatra.glyphs.gridded.array_glyph.ArrayGlyph: Glyph subclass for 2D/3D arrays. cleopatra.glyphs.gridded.mesh_glyph.MeshGlyph: Glyph subclass for unstructured meshes.

Source code in src/cleopatra/glyphs/base/glyph.py
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
class Glyph:
    """Base class for cleopatra visualization glyphs.

    Handles figure/axes management, default options, color scale
    normalization, colorbar creation, tick control, point overlays,
    and animation saving. Subclasses implement the actual rendering.

    The accepted option keys are exposed per subclass via the
    `DEFAULT_OPTIONS` class attribute, and can be inspected or filtered
    *before* constructing an instance with the `option_keys` and
    `filter_kwargs` classmethods (useful for safely forwarding a bag of
    user-supplied styling kwargs).

    Args:
        default_options: Default plot options dict. Subclasses provide
            their own defaults merged with `STYLE_DEFAULTS`.
        fig: Pre-existing matplotlib figure to bind. Default is None.
            An `ax` fully determines its figure, so `fig` is optional even
            when `ax` is given; when both are passed the explicit `fig`
            is kept as the figure handle. Passing a `fig` that does not own
            the given `ax` emits a `UserWarning` (the explicit `fig` is
            still honoured, but the two handles then disagree).
        ax: Pre-existing matplotlib axes to bind. Default is None. Passing
            `ax` on its own is supported — its parent figure is derived
            automatically (the axes is no longer dropped when `fig` is
            omitted).
        **kwargs: Override any key in `default_options`.

    Examples:
        - Create a Glyph and override the colormap:
            ```python
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts["vmin"] = None
            >>> opts["vmax"] = None
            >>> g = Glyph(default_options=opts, cmap="plasma")
            >>> g.default_options["cmap"]
            'plasma'

            ```
        - Provide a pre-existing figure and axes:
            ```python
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts["vmin"] = None
            >>> opts["vmax"] = None
            >>> fig, ax = plt.subplots()
            >>> g = Glyph(default_options=opts, fig=fig, ax=ax)
            >>> g.fig is fig
            True
            >>> g.ax is ax
            True

            ```
        - Provide only an axes; the figure is derived from it:
            ```python
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts["vmin"] = None
            >>> opts["vmax"] = None
            >>> fig, ax = plt.subplots()
            >>> g = Glyph(default_options=opts, ax=ax)
            >>> g.ax is ax
            True
            >>> g.fig is ax.get_figure()
            True

            ```

    See Also:
        cleopatra.glyphs.gridded.array_glyph.ArrayGlyph: Glyph subclass for
            2D/3D arrays.
        cleopatra.glyphs.gridded.mesh_glyph.MeshGlyph: Glyph subclass for
            unstructured meshes.
    """

    #: The option keys this glyph accepts, as a class attribute so they can
    #: be introspected/filtered *before* an instance exists (see
    #: `option_keys`/`filter_kwargs`). Each subclass overrides this with its
    #: own option dict (built as `STYLE_DEFAULTS | <glyph-specific>`); the
    #: base value is the shared style defaults.
    DEFAULT_OPTIONS: dict = STYLE_DEFAULTS

    #: Whether this glyph's `plot()` reads back the categorical side-channel
    #: (`self._categorical`) instead of feeding raw `values` straight into
    #: the mappable. Only true for glyphs whose per-element value is a
    #: nominal class label rather than a continuous magnitude (e.g.
    #: `PolygonGlyph`, `ScatterGlyph`) — `scheme="categorical"` is rejected
    #: for any other glyph rather than silently mis-colouring it.
    _SUPPORTS_CATEGORICAL_SCHEME = False

    def __init__(
        self,
        default_options: dict,
        fig: Figure | None = None,
        ax: Axes | None = None,
        **kwargs,
    ):
        self._default_options = default_options.copy()
        self._merge_kwargs(kwargs)
        self._vmin: float | None = None
        self._vmax: float | None = None
        self.ticks_spacing: float | None = None
        #: Set by `_prepare_categorical_mapping` when `scheme="categorical"`
        #: — `{"codes", "cmap", "colors", "labels"}` — else `None`.
        self._categorical: dict | None = None
        #: Set by a subclass's `animate()`; exposed read-only via `anim`.
        self._anim: FuncAnimation | None = None
        if ax is not None:
            self.ax: Axes | None = ax
            if fig is not None:
                if fig is not _immediate_figure(ax) and fig is not _root_figure(ax):
                    warnings.warn(
                        "The given `fig` is not the figure that owns `ax`; "
                        "the axes' own figure is what will be drawn on. Pass "
                        "only `ax` (its figure is derived automatically).",
                        stacklevel=2,
                    )
                self.fig: Figure | None = fig
            else:
                self.fig = _root_figure(ax)
        elif fig is not None:
            self.fig = fig
            self.ax = None
        else:
            self.fig = None
            self.ax = None

    @property
    def vmin(self) -> float | None:
        """Minimum value for color scaling."""
        return self._vmin

    @property
    def vmax(self) -> float | None:
        """Maximum value for color scaling."""
        return self._vmax

    @property
    def default_options(self) -> dict:
        """Default plot options."""
        return self._default_options

    @classmethod
    def option_keys(cls) -> set[str]:
        """Return the keyword-argument keys this glyph accepts.

        Resolves from the class-level `DEFAULT_OPTIONS`, so the accepted
        keys can be inspected **without constructing an instance** (and
        therefore without tripping the strict unknown-kwarg check in
        `_merge_kwargs`). The keys differ per glyph subclass.

        This reports the class's *default* option set. For every concrete
        glyph subclass that equals the instance's accepted keys (each
        subclass passes the same dict to `__init__`). The base `Glyph`
        reports the shared `STYLE_DEFAULTS`; an instance built with a
        custom injected `default_options` is the one case where the two
        can differ, so base `Glyph` is not part of the introspection
        contract.

        Returns:
            set[str]: The accepted option keys for this glyph class.

        Examples:
            - Inspect the keys a glyph accepts before building one:
                ```python
                >>> from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph
                >>> keys = ScatterGlyph.option_keys()
                >>> "cmap" in keys
                True
                >>> "totally_unknown" in keys
                False

                ```
            - Different glyphs expose different keys:
                ```python
                >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
                >>> "edgecolor" in PolygonGlyph.option_keys()
                True

                ```

        See Also:
            filter_kwargs: Drop the keys a glyph does not accept from a dict.
        """
        return set(cls.DEFAULT_OPTIONS)

    @classmethod
    def filter_kwargs(cls, kwargs: dict) -> dict:
        """Return only the subset of `kwargs` whose keys this glyph accepts.

        A convenience for callers that forward a bag of user-supplied
        styling kwargs into a glyph: pre-filtering with this method lets
        the construction succeed instead of raising on an unknown key.
        Order and values are preserved; rejected keys are simply dropped.

        Args:
            kwargs: A mapping of candidate option keys to values.

        Returns:
            dict: The entries of `kwargs` whose keys are in `option_keys()`.

        Examples:
            - Keep only the accepted keys, then construct safely:
                ```python
                >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
                >>> raw = {"cmap": "viridis", "edgecolor": "black", "bogus": 1}
                >>> safe = PolygonGlyph.filter_kwargs(raw)
                >>> sorted(safe)
                ['cmap', 'edgecolor']
                >>> safe["cmap"]
                'viridis'

                ```
            - An empty mapping yields an empty mapping:
                ```python
                >>> from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph
                >>> ScatterGlyph.filter_kwargs({})
                {}

                ```

        See Also:
            option_keys: The set of keys this glyph accepts.
        """
        keys = cls.option_keys()
        return {key: val for key, val in kwargs.items() if key in keys}

    @property
    def anim(self) -> FuncAnimation:
        """Animation object created by `animate()`."""
        if self._anim is not None:
            return self._anim
        raise ValueError(
            "Please first use the animate method to create the animation object"
        )

    def _merge_kwargs(self, kwargs: dict) -> None:
        """Validate and merge keyword arguments into default_options."""
        #: Option keys the caller passed explicitly, so a subclass can tell an
        #: overridden option from one left at its default (e.g. `ArrayGlyph` only
        #: auto-sizes the figure when `figsize` was not passed).
        self._explicit_options: set[str] = set(kwargs)
        _reject_grouped_kwargs(kwargs)
        for key, val in kwargs.items():
            if key not in self._default_options:
                raise ValueError(
                    f"The given keyword argument:{key} is not correct, "
                    f"possible parameters are, {list(self._default_options.keys())}"
                )
            else:
                self._default_options[key] = val

    def _merge_group_params(self, *groups: Any) -> None:
        """Flatten grouped parameter objects into `default_options`.

        Each glyph's `plot`/`animate` accepts grouped parameter objects
        (e.g. `color=ColorScaling(...)`) in place of the loose keyword
        arguments they replaced. Every such object exposes `to_options()`,
        returning the flat `default_options` keys the rendering engine
        reads; this helper merges each non-`None` object's keys in, so the
        internal storage stays a single flat dict.

        Only keys the glyph actually supports (already present in its
        `default_options`) are applied, so a single group object can be
        passed to glyphs that support different subsets of it -- e.g. a
        `Contour` carrying `levels` + `labels` applies both on `ArrayGlyph`
        (which draws isoline labels) but only `levels` on `ScatterGlyph`
        (which has no labels). A group's `to_options()` emits only the
        fields the caller explicitly set, so unset fields never clobber a
        glyph's own defaults.

        Args:
            *groups: Grouped parameter objects (or `None` for an omitted
                group). Anything `None` is skipped; each other object must
                expose a `to_options()` returning a dict.
        """
        for group in groups:
            if group is None:
                continue
            for key, val in group.to_options().items():
                if key in self.default_options:
                    self.default_options[key] = val

    def _snapshot_group_options(self, *groups: Any) -> dict:
        """Snapshot the current value of every option key `groups` will touch.

        Records the pre-merge value of each `default_options` key any of the
        given group objects will write (via `to_options()`), so a failed merge
        (e.g. an invalid `style` validated afterwards) can restore the WHOLE
        set -- not just the key that failed -- keeping a co-passed
        `color=`/`contour=`/`cells=` from leaking into a later plain `plot()`
        on a sticky-options glyph.

        Args:
            *groups: The grouped parameter objects about to be merged (each a
                `to_options()`-bearing object, or `None` to skip).

        Returns:
            dict: `{key: current value}` for every key the groups will touch
                that exists in `default_options`.
        """
        snapshot: dict = {}
        for group in groups:
            if group is None:
                continue
            for key in group.to_options():
                if key in self.default_options and key not in snapshot:
                    snapshot[key] = self.default_options[key]
        return snapshot

    @contextmanager
    def _rollback_options_on_error(self) -> Iterator[None]:
        """Restore `default_options` if the wrapped render body raises.

        A glyph's `plot` merges grouped parameter objects (`color=`, `contour=`,
        `classify=`, ...) into the persistent `default_options` at the top of the
        call, then renders. Most glyphs keep those options across plots (they are
        sticky by design), so a render that raises *after* the merge -- an
        unsupported `scheme`, a degenerate colour scale -- would leave the
        half-applied options behind and poison later plain `plot()` calls on the
        same instance: the stale option re-triggers the same error, or silently
        renders with a colour scale that was never successfully applied.

        Wrap the render body (the merge included) in this context manager: it
        snapshots `default_options` on entry and, if the body raises, restores it
        exactly, so a failed styled render leaves the glyph's option state
        untouched. On success the merged options stay. Unlike a wrapping
        decorator, a `with` block adds no stack frame, so warnings emitted inside
        the render keep their caller-attributed `stacklevel`.

        Yields:
            None: control returns to the `with` body with the snapshot taken.
        """
        snapshot = dict(self._default_options)
        try:
            yield
        except BaseException:
            self._default_options.clear()
            self._default_options.update(snapshot)
            raise

    def create_figure_axes(self) -> tuple[Figure, Axes]:
        """Create a new figure and axes from default_options.

        Uses the `figsize` key from `default_options` to set the
        figure dimensions.

        Returns:
            tuple[Figure, Axes]: The created figure and axes.

        Examples:
            - Create a figure with custom size:
                ```python
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts, figsize=(12, 4))
                >>> fig, ax = g.create_figure_axes()
                >>> fig.get_size_inches()
                array([12.,  4.])

                ```
        """
        fig, ax = plt.subplots(figsize=self.default_options["figsize"])
        return fig, ax

    def _reset_axes_for_restyle(self) -> None:
        """Prepare `self.ax` for an in-place restyle (used by `apply_style`).

        When the glyph has a **live** axes (already plotted and its figure is
        still open), the previous render is cleared from it -- the glyph's
        colorbar, any legend / swatch inset axes, and all artists -- so the
        restyle replaces the content in place. `apply_style` therefore takes
        full ownership of this axes and must not be used on an axes shared with
        unrelated caller content. When the glyph was never plotted, its figure
        was closed, or it was built with a figure but no axes, a fresh axes is
        created instead (on the existing figure when one is still open).
        """
        ax = self.ax
        fig = self.fig
        root = _root_figure(ax) if ax is not None else fig
        ax_live = ax is not None and _figure_is_open(root)
        if ax_live:
            assert ax is not None
            for attr in ("cbar", "_cbar"):
                cbar = getattr(self, attr, None)
                if cbar is not None:
                    cbar.remove()
                    setattr(self, attr, None)
            for inset in list(ax.child_axes):
                inset.remove()
            ax.clear()
        elif fig is not None and _figure_is_open(fig):
            self.ax = fig.axes[0] if fig.axes else fig.add_subplot(111)
        else:
            self.fig, self.ax = self.create_figure_axes()

    def get_ticks(self) -> np.ndarray:
        """Compute colorbar tick locations from default_options.

        Uses `vmin`, `vmax`, and `ticks_spacing` from
        `default_options` to generate evenly-spaced tick positions.

        Returns:
            np.ndarray: Array of tick positions.

        Examples:
            - Compute ticks for a 0-10 range with spacing of 2:
                ```python
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": 10.0, "ticks_spacing": 2.0})
                >>> g = Glyph(default_options=opts)
                >>> g.get_ticks()
                array([ 0.,  2.,  4.,  6.,  8., 10.])

                ```
        """
        ticks_spacing = self.default_options["ticks_spacing"]
        vmax = self.default_options["vmax"]
        vmin = self.default_options["vmin"]
        if not ticks_spacing or vmax <= vmin:
            result = np.array([vmin])
        else:
            ticks = np.arange(vmin, vmax + ticks_spacing, ticks_spacing)
            ticks = ticks[ticks <= vmax + 1e-9]
            if ticks.size == 0:
                result = np.array([vmin, vmax])
            else:
                if (vmax - ticks[-1]) > 0.04 * (vmax - vmin):
                    ticks = np.append(ticks, vmax)
                else:
                    ticks[-1] = vmax
                result = ticks
        return result

    def _create_norm_and_cbar_kw(
        self, ticks: np.ndarray
    ) -> tuple[colors.Normalize | None, dict]:
        """Create a matplotlib Normalize and colorbar kwargs.

        Honours the `color_scale` option — a `cleopatra.styling.styles.ColorScale`
        member or its string value (case-insensitive): `linear` / `power` /
        `sym-lognorm` / `boundary-norm` / `midpoint` — and the
        xarray-aligned `levels` and `extend` options when present in
        `default_options`. An unrecognised `color_scale` (including a
        non-string such as an int) raises `ValueError`.

        Behaviour for `levels`:

        * `levels` is `None` (default) — continuous norm based on
          `color_scale`.
        * `levels` is an `int` and `color_scale` is the default
          `"linear"` — switch to a `BoundaryNorm` with `levels`
          linearly-spaced edges between `vmin` and `vmax`.
        * `levels` is a sequence and `color_scale` is `"linear"` —
          use the sequence as explicit bin edges in a `BoundaryNorm`.
        * `levels` is set and `color_scale` is `"boundary-norm"`
          with no explicit `bounds` — treat `levels` as the bounds.
        * Otherwise (`color_scale` is some other enum value) — the
          user's choice wins; `levels` is left for the caller to
          forward to `contour` / `contourf`.

        Behaviour for `extend`: when present and non-None, the value
        is forwarded to the colorbar via `cbar_kw["extend"]`. The
        auto-resolution (`"both"` when `levels` is set, else
        `"neither"`) happens here only when `extend` is `None`.

        Args:
            ticks: Tick positions for the colorbar.

        Returns:
            tuple[Normalize or None, dict]: The norm (None for linear)
                and colorbar keyword arguments.

        Raises:
            ValueError: If `default_options["color_scale"]` is not a
                recognised `cleopatra.styling.styles.ColorScale` value.

        Examples:
            - Linear colour scale with no levels gives `norm=None`
                and ticks forwarded straight through:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": 10.0})
                >>> g = Glyph(default_options=opts)
                >>> norm, cbar_kw = g._create_norm_and_cbar_kw(np.array([0.0, 5.0, 10.0]))
                >>> norm is None
                True
                >>> cbar_kw["extend"]
                'neither'
                >>> [float(t) for t in cbar_kw["ticks"]]
                [0.0, 5.0, 10.0]

                ```
            - With `levels` set and the default linear scale, a
                `BoundaryNorm` is built and `extend` defaults to
                `"both"`:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": 10.0, "levels": 5})
                >>> g = Glyph(default_options=opts)
                >>> norm, cbar_kw = g._create_norm_and_cbar_kw(np.array([0.0, 5.0, 10.0]))
                >>> norm is None
                False
                >>> cbar_kw["extend"]
                'both'
                >>> [float(t) for t in cbar_kw["ticks"]]
                [0.0, 2.5, 5.0, 7.5, 10.0]

                ```
        """
        # The colour-scale logic lives on `ColorScaling` (see
        # `cleopatra.styling.scaling`); this method is the thin bridge from
        # the flat `default_options` storage to that object. `levels` and
        # `extend` are cross-group inputs (contour discretisation / colorbar
        # arrow extension), passed in rather than owned by the scale.
        scaling = ColorScaling.from_options(self.default_options)
        return scaling.build_norm(
            ticks,
            levels=self.default_options.get("levels"),
            extend=self.default_options.get("extend"),
        )

    @staticmethod
    def _levels_to_bounds(
        levels: int | list[float] | np.ndarray | None,
        vmin: float,
        vmax: float,
    ) -> np.ndarray | None:
        """Convert the `levels` option to an array of bin edges.

        Returns `None` when no levels are configured, signalling that
        the caller should fall back to the continuous norm path.

        Args:
            levels: Number of levels (`int`), explicit edges
                (`list` / `ndarray`), or `None` for no
                discretisation.
            vmin: Lower colour limit. Used when `levels` is an int to
                build the linspace.
            vmax: Upper colour limit. Used when `levels` is an int to
                build the linspace.

        Returns:
            np.ndarray or None: Sorted ascending array of bin edges, or
                `None` when `levels` is `None`.

        Raises:
            ValueError: If `levels` is an integer outside the range
                `[2, MAX_DISCRETE_LEVELS]` (a single edge cannot form a
                `BoundaryNorm`, and an enormous count would OOM
                `np.linspace`).

        Examples:
            - Integer `levels` becomes a `linspace` between
                `vmin` and `vmax`:
                ```python
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> bounds = Glyph._levels_to_bounds(5, 0.0, 10.0)
                >>> [float(b) for b in bounds]
                [0.0, 2.5, 5.0, 7.5, 10.0]

                ```
            - A sequence is sorted ascending and returned as a float
                `ndarray`; `None` short-circuits to `None`:
                ```python
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> bounds = Glyph._levels_to_bounds([10.0, 0.0, 5.0], 0.0, 10.0)
                >>> [float(b) for b in bounds]
                [0.0, 5.0, 10.0]
                >>> Glyph._levels_to_bounds(None, 0.0, 10.0) is None
                True

                ```
        """
        # Behaviour lives on `cleopatra.styling.scaling.levels_to_bounds`;
        # kept here as a thin delegator for the existing callers/doctests.
        return levels_to_bounds(levels, vmin, vmax)

    def _resolve_limits(self, values: np.ndarray) -> tuple[float, float]:
        """Resolve `(vmin, vmax)` from options, falling back to the data range.

        Reads `vmin` / `vmax` from `default_options`; whichever is `None`
        (or absent) is filled from the nan-aware min/max of `values`. This
        mirrors the simple branch of `ArrayGlyph._resolve_color_limits`
        (the `robust` / `center` / `percentile` machinery stays an
        `ArrayGlyph` concern). All-NaN input is detected and rejected here
        rather than surfacing later as an opaque failure inside
        `get_ticks()` or matplotlib.

        Args:
            values: The scalar array that will be colour-mapped. Used to
                supply data-driven limits when `vmin` / `vmax` are unset.

        Returns:
            tuple[float, float]: The resolved `(vmin, vmax)` as floats.

        Raises:
            ValueError: If a limit cannot be resolved to a finite number
                (e.g. `values` is empty or all-NaN and the corresponding
                limit was not pinned explicitly).

        Examples:
            - Auto-resolve both limits from the data:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts)
                >>> g._resolve_limits(np.array([1.0, 5.0, 9.0]))
                (1.0, 9.0)

                ```
            - An explicit limit is preserved; only the missing one is
                taken from the data:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": None})
                >>> g = Glyph(default_options=opts)
                >>> g._resolve_limits(np.array([1.0, 5.0, 9.0]))
                (0.0, 9.0)

                ```
            - An all-NaN array with unpinned limits raises `ValueError`:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts)
                >>> g._resolve_limits(np.array([np.nan, np.nan]))
                Traceback (most recent call last):
                    ...
                ValueError: Cannot determine vmin/vmax: no finite values...

                ```
        """
        vmin = self.default_options.get("vmin")
        vmax = self.default_options.get("vmax")
        if vmin is None or vmax is None:
            with warnings.catch_warnings():
                warnings.simplefilter("ignore", RuntimeWarning)
                data_min = np.nanmin(values)
                data_max = np.nanmax(values)
            vmin = data_min if vmin is None else vmin
            vmax = data_max if vmax is None else vmax
        if not (np.isfinite(vmin) and np.isfinite(vmax)):
            raise ValueError(
                "Cannot determine vmin/vmax: no finite values. Pass "
                "explicit vmin/vmax, or filter the array first."
            )
        return float(vmin), float(vmax)

    def _prepare_scalar_mapping(
        self, values: np.ndarray
    ) -> tuple[colors.Normalize | None, dict, np.ndarray]:
        """Build the `(norm, cbar_kw, ticks)` triple shared by coloured glyphs.

        This is the single home for the scalar-mapping contract that every
        colour-by-value glyph needs. It:

        1. resolves `(vmin, vmax)` from `default_options`, falling back to
           the data range via `_resolve_limits`;
        2. derives a sensible `ticks_spacing` of `(vmax - vmin) / 10` when
           the caller left it unset (`None`), guarding flat data so the
           spacing is never zero;
        3. writes `vmin`, `vmax`, and `ticks_spacing` back into
           `default_options` so the existing `get_ticks()` — which reads
           from `default_options` — can see them; and
        4. computes the ticks and forwards them to
           `_create_norm_and_cbar_kw`, honouring `levels` / `color_scale`.

        Subclasses call this instead of re-deriving the contract (which is
        easy to get subtly wrong: `get_ticks()` does not read `self._vmin`,
        and `np.arange(None, None)` raises).

        When the `scheme` option is set, the continuous steps above are
        bypassed: control is handed to `_prepare_classified_mapping`, which
        bins the data into discrete colour classes (a `BoundaryNorm`).
        `scheme="categorical"` bypasses them even earlier — before
        `_resolve_limits`, since a `vmin`/`vmax` range is meaningless for
        nominal values (and would raise for non-numeric ones) — and hands
        off to `_prepare_categorical_mapping` instead. With `scheme` unset
        (the default) the behaviour is unchanged.

        Args:
            values: The scalar array to be colour-mapped (e.g. point
                values, vector magnitudes, per-polygon values).

        Returns:
            tuple[Normalize or None, dict, np.ndarray]: the matplotlib norm
                (`None` for a plain linear scale), the colorbar keyword
                arguments from `_create_norm_and_cbar_kw`, and the computed
                tick positions.

        Raises:
            ValueError: Propagated from `_resolve_limits` when no finite
                limits can be determined.

        Examples:
            - Auto limits resolve from the data and produce a non-`None`
                `ticks_spacing` plus continuous-scale ticks:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None, "ticks_spacing": None})
                >>> g = Glyph(default_options=opts)
                >>> norm, cbar_kw, ticks = g._prepare_scalar_mapping(
                ...     np.array([0.0, 5.0, 10.0])
                ... )
                >>> norm is None
                True
                >>> g.default_options["ticks_spacing"]
                1.0
                >>> [float(t) for t in ticks]
                [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]

                ```
            - Flat data does not produce a zero spacing:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None, "ticks_spacing": None})
                >>> g = Glyph(default_options=opts)
                >>> _ = g._prepare_scalar_mapping(np.array([3.0, 3.0, 3.0]))
                >>> g.default_options["ticks_spacing"]
                1.0

                ```
        """
        self._categorical = None
        if self.default_options.get("scheme") == "categorical":
            result = self._prepare_categorical_mapping(values)
        else:
            self._vmin, self._vmax = self._resolve_limits(np.asarray(values))
            if self.default_options.get("ticks_spacing") is None:
                self.ticks_spacing = (self._vmax - self._vmin) / 10 or 1.0
                self.default_options["ticks_spacing"] = self.ticks_spacing
            self.default_options["vmin"] = self._vmin
            self.default_options["vmax"] = self._vmax
            scheme = self.default_options.get("scheme")
            if scheme is not None:
                result = self._prepare_classified_mapping(values, scheme)
            else:
                ticks = self.get_ticks()
                norm, cbar_kw = self._create_norm_and_cbar_kw(ticks)
                result = (norm, cbar_kw, ticks)
        return result

    def _warn_scheme_overrides_continuous_options(self) -> None:
        """Warn when a `scheme` is set alongside continuous-only options.

        Shared by `_prepare_classified_mapping` and
        `_prepare_categorical_mapping`: either scheme owns the norm
        entirely, so a `color_scale` other than `"linear"` or an explicit
        `levels` the caller also set is silently ignored rather than
        applied -- this warns so that conflicting configuration is visible
        instead of quietly doing nothing.
        """
        if self.default_options.get("color_scale", "linear") != "linear":
            warnings.warn(
                "`scheme` is set, so `color_scale="
                f"{self.default_options['color_scale']!r}` is ignored "
                "(classification builds its own discrete norm).",
                stacklevel=5,
            )
        if self.default_options.get("levels") is not None:
            warnings.warn(
                "`scheme` is set, so `levels` is ignored (the classification "
                "scheme determines the bins).",
                stacklevel=5,
            )

    def _prepare_classified_mapping(
        self, values: np.ndarray, scheme: str | list | np.ndarray
    ) -> tuple[colors.BoundaryNorm, dict, np.ndarray]:
        """Build the `(norm, cbar_kw, ticks)` triple for classified colouring.

        The discrete sibling of the continuous branch in
        `_prepare_scalar_mapping`. When the `scheme` option is set, the
        data is binned into classes by `cleopatra.styling.styles.classify` (using
        the `k` option for the count/width schemes), and the resulting bin
        edges drive a `matplotlib.colors.BoundaryNorm` plus a colorbar
        whose ticks sit on the class boundaries — so `create_color_bar`
        renders a stepped colorbar. The `color_scale` / `levels` options
        are intentionally bypassed here; classification owns the norm.

        Args:
            values: The scalar array to classify and colour-map.
            scheme: A scheme name accepted by `classify` (e.g.
                `"quantiles"`, `"equal_interval"`) or an explicit sequence
                of bin edges.

        Returns:
            tuple[BoundaryNorm, dict, np.ndarray]: the discrete norm, the
                colorbar keyword arguments (boundary `ticks` plus
                `extend`), and the bin edges (returned in the `ticks`
                slot of the shared contract).

        Raises:
            ValueError: Propagated from `classify` (unknown scheme,
                degenerate data, or `k < 1`).

        Examples:
            - A quantile scheme yields a `BoundaryNorm` and boundary ticks:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update(
                ...     {"vmin": None, "vmax": None, "scheme": "quantiles", "k": 4}
                ... )
                >>> g = Glyph(default_options=opts)
                >>> norm, cbar_kw, edges = g._prepare_classified_mapping(
                ...     np.arange(100.0), "quantiles"
                ... )
                >>> [float(b) for b in norm.boundaries]
                [0.0, 24.75, 49.5, 74.25, 99.0]
                >>> [float(t) for t in cbar_kw["ticks"]]
                [0.0, 24.75, 49.5, 74.25, 99.0]
                >>> cbar_kw["extend"]
                'neither'

                ```
        """
        self._warn_scheme_overrides_continuous_options()
        k = self.default_options.get("k", 5)
        bin_edges, norm = classify(values, scheme, k)
        extend = self.default_options.get("extend")
        cbar_kw = {
            "ticks": bin_edges,
            "extend": "neither" if extend is None else extend,
        }
        return norm, cbar_kw, bin_edges

    def _prepare_categorical_mapping(
        self, values: np.ndarray
    ) -> tuple[colors.BoundaryNorm, dict, np.ndarray]:
        """Build the `(norm, cbar_kw, edges)` triple for `scheme="categorical"`.

        The nominal sibling of `_prepare_classified_mapping`: instead of
        binning a continuous range, `cleopatra.styling.styles.categorize` assigns
        one colour per distinct value in `values` (sorted when sortable),
        and this builds a `ListedColormap` + `BoundaryNorm` over the
        resulting integer class codes — the same construction
        `colors.apply_data_style` uses for a preset's `categories`, but with
        the category table auto-derived from the data instead of
        hand-authored. The mapping (per-element codes, the `ListedColormap`,
        and the colour/label pairs) is stashed on `self._categorical` for
        the calling glyph to read back, since — unlike the continuous and
        classified paths — the array fed to the mappable is these integer
        codes, not `values` itself (which may not even be numeric).

        Only glyphs with `_SUPPORTS_CATEGORICAL_SCHEME = True` may use this
        scheme: for any other glyph, `values` are a continuous magnitude
        (e.g. vector length), where "one colour per distinct float" is
        almost never what the caller wants, and the glyph's `plot()` does
        not know to read `self._categorical` back in the first place — it
        would keep feeding the raw (mismatched) values to the mappable.

        The glyph's `cmap` option drives `categorize`'s palette, with one
        override: if `cmap` is still at the shared continuous/diverging
        default (`"coolwarm_r"`, matched by resolved name so a `Colormap`
        instance equivalent to the default is caught too, not just the bare
        string) — i.e. the caller never overrode it — it is substituted with
        `CATEGORICAL_DEFAULT_CMAP` (`"tab10"`) instead, since sampling a
        diverging gradient at N points would defeat the point of "one
        distinct colour per class". Any other `cmap`, qualitative or not,
        is always honoured as given.

        Args:
            values: The per-element nominal values to categorize.

        Returns:
            tuple[BoundaryNorm, dict, np.ndarray]: the discrete norm over
                the integer class codes, an empty colorbar-kwargs dict (a
                categorical scheme draws a `disjoint_legend`, never a
                colorbar — see `create_categorical_legend`), and the code
                boundary edges (`-0.5 .. n_categories - 0.5`).

        Raises:
            ValueError: If this glyph does not support `scheme="categorical"`,
                or (propagated from `categorize`) if `values` has no
                non-null entries.

        Examples:
            - Three distinct values map to three integer codes and colours:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
                >>> polys = [np.zeros((3, 2))] * 3
                >>> g = PolygonGlyph(polys, values=np.array(["a", "b", "a"]))
                >>> norm, cbar_kw, edges = g._prepare_categorical_mapping(
                ...     np.array(["a", "b", "a"])
                ... )
                >>> [float(b) for b in edges]
                [-0.5, 0.5, 1.5]
                >>> [float(c) for c in g._categorical["codes"]]
                [0.0, 1.0, 0.0]

                ```
        """
        if not self._SUPPORTS_CATEGORICAL_SCHEME:
            raise ValueError(
                f"{type(self).__name__} does not support scheme='categorical' "
                "(its values are a continuous magnitude, not nominal class "
                "labels)."
            )
        self._warn_scheme_overrides_continuous_options()
        cmap = resolve_colormap(self.default_options["cmap"])
        cmap_name = cmap if isinstance(cmap, str) else getattr(cmap, "name", None)
        if cmap_name == STYLE_DEFAULTS["cmap"]:
            cmap = CATEGORICAL_DEFAULT_CMAP
        raw = np.asarray(values, dtype=object).ravel().tolist()
        categories, palette = categorize(raw, cmap=cmap)
        lookup = {category: i for i, category in enumerate(categories.tolist())}
        codes = np.array([lookup.get(v, np.nan) for v in raw], dtype=float)
        listed_cmap = colors.ListedColormap(palette)
        edges = np.arange(len(categories) + 1) - 0.5
        norm = colors.BoundaryNorm(edges, len(palette))
        self._categorical = {
            "codes": codes,
            "cmap": listed_cmap,
            "colors": palette,
            "labels": [str(c) for c in categories.tolist()],
        }
        return norm, {}, edges

    def create_categorical_legend(self, ax: Axes) -> Legend:
        """Attach the disjoint legend for a `scheme="categorical"` mapping.

        Reads the category colours/labels `_prepare_categorical_mapping`
        stashed on `self._categorical` and draws them via
        `cleopatra.styling.styles.disjoint_legend` — the discrete counterpart to
        `create_color_bar`, used instead of it whenever `scheme` is
        `"categorical"` (a colorbar would imply a false ordering over
        nominal classes). The legend's title defaults to the `cbar_label`
        option (the same label a continuous plot would put on its
        colorbar); the `category_legend_kwargs` option is merged over that
        default and forwarded to `disjoint_legend` (e.g. `loc`, `ncol`,
        `bbox_to_anchor`, or an explicit `title` override) — the categorical
        counterpart to `size_legend_kwargs`.

        Args:
            ax: The axes to attach the legend to.

        Returns:
            Legend: The created legend artist, already added to `ax`.

        Raises:
            ValueError: If `self._categorical` has not been populated yet
                (i.e. `_prepare_categorical_mapping` has not run for this
                glyph instance).

        Examples:
            - Prepare a categorical mapping, then draw and inspect the legend:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
                >>> polys = [np.zeros((3, 2))] * 3
                >>> g = PolygonGlyph(polys, values=np.array(["a", "b", "a"]))
                >>> _ = g._prepare_categorical_mapping(np.array(["a", "b", "a"]))
                >>> fig, ax = plt.subplots()
                >>> legend = g.create_categorical_legend(ax)
                >>> [t.get_text() for t in legend.get_texts()]
                ['a', 'b']
                >>> plt.close(fig)

                ```
            - Calling it before a categorical mapping exists raises `ValueError`:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
                >>> g = PolygonGlyph([np.zeros((3, 2))] * 2, values=np.array(["a", "b"]))
                >>> fig, ax = plt.subplots()
                >>> g.create_categorical_legend(ax)
                Traceback (most recent call last):
                    ...
                ValueError: create_categorical_legend() called before a scheme='categorical' mapping was prepared -- call _prepare_scalar_mapping (or plot()) first.
                >>> plt.close(fig)

                ```
            - `category_legend_kwargs` overrides the default title and adds
                a `loc`:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
                >>> polys = [np.zeros((3, 2))] * 2
                >>> g = PolygonGlyph(polys, values=np.array(["a", "b"]))
                >>> g.default_options["category_legend_kwargs"] = {
                ...     "title": "Class", "loc": "upper left"
                ... }
                >>> _ = g._prepare_categorical_mapping(np.array(["a", "b"]))
                >>> fig, ax = plt.subplots()
                >>> legend = g.create_categorical_legend(ax)
                >>> legend.get_title().get_text()
                'Class'
                >>> plt.close(fig)

                ```
        """
        categorical = self._categorical
        if categorical is None:
            raise ValueError(
                "create_categorical_legend() called before a "
                "scheme='categorical' mapping was prepared -- call "
                "_prepare_scalar_mapping (or plot()) first."
            )
        legend_kwargs = {
            "title": self.default_options.get("cbar_label"),
            **(self.default_options.get("category_legend_kwargs") or {}),
        }
        return disjoint_legend(
            ax,
            categorical["colors"],
            categorical["labels"],
            **legend_kwargs,
        )

    def create_color_bar(self, ax: Axes, im: Any, cbar_kw: dict) -> Colorbar:
        """Create a colorbar with full customization from default_options.

        Reads `cbar_length`, `cbar_orientation`, `cbar_label`,
        `cbar_label_size`, and `cbar_label_location` from
        `default_options` to configure the colorbar. When the optional
        `cbar_kwargs` entry is present in `default_options` (an
        xarray-aligned dict-of-overrides), its keys are merged over the
        defaults so the user wins on any collision (e.g. `label`,
        `shrink`, `orientation`, `ticks`, `extend`).

        `cbar_kwargs` is read from `self.default_options["cbar_kwargs"]`.
        Set it via the constructor or `plot` kwargs of the calling
        glyph subclass. Keys recognised by `matplotlib.pyplot.colorbar`
        — `label`, `shrink`, `aspect`, `orientation`, `pad`,
        `ticks`, `extend` — are forwarded; `label` is special-cased
        so that label-size and label-location styling from
        `default_options` are still applied.

        Placement is controlled by `cbar_location`
        (`'left'`/`'right'`/`'top'`/`'bottom'`, which also fixes the
        orientation) and `cbar_inside`: when `True`, the colorbar is inset
        *inside* `ax` at that edge (a child of `ax`, so it tracks
        `full_bleed` instead of floating), with its tick labels facing into
        the frame and an optional `cbar_box` backing panel drawn behind it so
        the data does not show through. When `cbar_location` is `None` and
        `cbar_inside` is `False`, placement is matplotlib's default.

        Args:
            ax: Matplotlib axes.
            im: The mappable (image or contour) to attach the
                colorbar to.
            cbar_kw: Colorbar keyword arguments (ticks, format,
                extend, etc.) computed by
                `_create_norm_and_cbar_kw`.

        Returns:
            Colorbar: The created colorbar.

        Raises:
            TypeError: If `default_options["cbar_kwargs"]` is set
                but is not a `dict`.

        Examples:
            - Create a colorbar with a custom label:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts, cbar_label="Depth [m]")
                >>> fig, ax = plt.subplots()
                >>> im = ax.imshow(np.arange(9).reshape(3, 3))
                >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
                >>> cbar.orientation
                'vertical'

                ```
            - User-supplied `cbar_kwargs` win on collision and
                `label` is applied via `set_label`:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({
                ...     "vmin": None,
                ...     "vmax": None,
                ...     "cbar_kwargs": {"label": "User Label", "orientation": "horizontal"},
                ... })
                >>> g = Glyph(default_options=opts, cbar_label="Default Label")
                >>> fig, ax = plt.subplots()
                >>> im = ax.imshow(np.arange(9).reshape(3, 3))
                >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
                >>> cbar.orientation
                'horizontal'
                >>> cbar.ax.get_xlabel() or cbar.ax.get_ylabel()
                'User Label'

                ```
            - Non-dict `cbar_kwargs` raises `TypeError`:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None, "cbar_kwargs": "oops"})
                >>> g = Glyph(default_options=opts)
                >>> fig, ax = plt.subplots()
                >>> im = ax.imshow(np.arange(9).reshape(3, 3))
                >>> g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
                Traceback (most recent call last):
                    ...
                TypeError: cbar_kwargs must be a dict of colorbar keyword arguments, got str.

                ```
        """
        location = self.default_options.get("cbar_location")
        if location is not None and location not in ("left", "right", "top", "bottom"):
            raise ValueError(
                "cbar_location must be one of 'left', 'right', 'top', "
                f"'bottom', or None, got {location!r}."
            )
        orientation_opt = self.default_options.get("cbar_orientation")
        if orientation_opt is not None and orientation_opt not in ("vertical", "horizontal"):
            raise ValueError(
                "cbar_orientation must be 'vertical' or 'horizontal', got "
                f"{orientation_opt!r}."
            )
        inside = bool(self.default_options.get("cbar_inside", False))
        orientation = self._resolve_cbar_orientation(location)
        user_kwargs, user_label = self._cbar_user_kwargs()

        box_info = None
        if inside:
            inset_location = location or (
                "bottom" if orientation == "horizontal" else "right"
            )
            cbar, box_info = self._inside_colorbar_axes(
                ax, im, cbar_kw, inset_location, orientation, user_kwargs
            )
        else:
            cbar = self._outside_colorbar(
                ax, im, cbar_kw, location, orientation, user_kwargs
            )

        self._apply_cbar_styling(cbar, user_label)
        box = self.default_options.get("cbar_box")
        if inside and box and box_info is not None:
            self._draw_cbar_box(ax, box_info, box)
        return cbar

    def _resolve_cbar_orientation(self, location: str | None) -> str:
        """Orientation implied by `cbar_location` (else `cbar_orientation`)."""
        if location in ("left", "right"):
            orientation = "vertical"
        elif location in ("top", "bottom"):
            orientation = "horizontal"
        else:
            orientation = self.default_options["cbar_orientation"]
        return orientation

    def _cbar_user_kwargs(self) -> tuple[dict, Any]:
        """A validated copy of `cbar_kwargs` with `label` split out for set_label."""
        user_kwargs = self.default_options.get("cbar_kwargs") or {}
        if not isinstance(user_kwargs, dict):
            raise TypeError(
                "cbar_kwargs must be a dict of colorbar keyword "
                f"arguments, got {type(user_kwargs).__name__}."
            )
        user_kwargs = dict(user_kwargs)
        return user_kwargs, user_kwargs.pop("label", None)

    def _outside_colorbar(
        self,
        ax: Axes,
        im: Any,
        cbar_kw: dict,
        location: str | None,
        orientation: str,
        user_kwargs: dict,
    ) -> Colorbar:
        """Draw a normal (outside-gutter) colorbar via `fig.colorbar`."""
        fig = ax.figure
        merged_kw = {
            "shrink": self.default_options["cbar_length"],
            "pad": 0.02,
            "use_gridspec": len(fig.axes) <= 1,
        }
        if location is not None:
            # matplotlib places the bar on that side and sets orientation.
            merged_kw["location"] = location
        else:
            merged_kw["orientation"] = orientation
        merged_kw.update(cbar_kw)
        merged_kw.update(user_kwargs)
        if "location" in merged_kw:
            merged_kw.pop("orientation", None)
        return fig.colorbar(im, ax=ax, **merged_kw)

    def _apply_cbar_styling(self, cbar: Colorbar, user_label: Any) -> None:
        """Apply tick/label colours, size, location, and text to `cbar`."""
        tick_color = self.default_options.get("cbar_tick_color")
        label_color = self.default_options.get("cbar_label_color")
        cbar.ax.tick_params(
            labelsize=10, **({"colors": tick_color} if tick_color else {})
        )
        label_text = (
            user_label if user_label is not None else self.default_options["cbar_label"]
        )
        label_rotation = self.default_options.get("cbar_label_rotation")
        label_location = self.default_options["cbar_label_location"]
        if label_location is not None:
            valid = (
                ("bottom", "center", "top")
                if cbar.orientation == "vertical"
                else ("left", "center", "right")
            )
            if label_location not in valid:
                raise ValueError(
                    f"cbar_label_location={label_location!r} is not valid for a "
                    f"{cbar.orientation} colorbar; use one of {list(valid)}."
                )
        cbar.set_label(
            label_text,
            fontsize=self.default_options["cbar_label_size"],
            loc=label_location,
            **({"color": label_color} if label_color else {}),
            **({"rotation": label_rotation} if label_rotation is not None else {}),
        )

    def _inside_colorbar_axes(
        self,
        ax: Axes,
        im: Any,
        cbar_kw: dict,
        location: str,
        orientation: str,
        user_kwargs: dict,
    ) -> tuple[Colorbar, tuple]:
        """Draw the colorbar as an inset *inside* `ax` at `location`.

        The colorbar is placed in an inset axes (a child of `ax`), so it
        tracks the data axes through `full_bleed` instead of floating. Its
        tick labels are turned to face into the frame, so a backing box can
        enclose them.

        Args:
            ax: The data axes to inset the colorbar into.
            im: The mappable to attach the colorbar to.
            cbar_kw: Colorbar keyword arguments from `_create_norm_and_cbar_kw`.
            location: Edge to sit on -- `'left'`, `'right'`, `'top'`, `'bottom'`.
            orientation: `'vertical'` or `'horizontal'`.
            user_kwargs: Extra `fig.colorbar` kwargs (user `cbar_kwargs`).

        Returns:
            tuple: `(cbar, box_info)` where `box_info` is
                `(cax, inset_bounds, label_side)` for `_draw_cbar_box`.
        """
        fig = ax.figure
        default_length = STYLE_DEFAULTS["cbar_length"]
        length = self.default_options.get("cbar_length") or default_length
        long_frac = 0.72 * (length / default_length)
        long_start = 0.5 - long_frac / 2
        bounds, label_side = {
            "right": ((0.905, long_start, 0.022, long_frac), "left"),
            "left": ((0.073, long_start, 0.022, long_frac), "right"),
            "top": ((long_start, 0.905, long_frac, 0.022), "bottom"),
            "bottom": ((long_start, 0.073, long_frac, 0.022), "top"),
        }[location]
        cax = ax.inset_axes(bounds)
        cax.set_zorder(6)
        merged_kw = {"orientation": orientation, "ticklocation": label_side}
        merged_kw.update(cbar_kw)
        merged_kw.update(user_kwargs)
        cbar = fig.colorbar(im, cax=cax, **merged_kw)
        return cbar, (cax, bounds, label_side)

    def _draw_cbar_box(self, ax: Axes, box_info: tuple, box: bool | str | dict) -> None:
        """Draw a backing panel behind an inset colorbar (its bar + tick labels).

        Sized to the colorbar's tight bounding box (labels included) with an
        analytic fallback on the label side, and drawn above the data but
        below the bar so the animating field can't show through the labels.

        Args:
            ax: The data axes the colorbar is inset into.
            box_info: `(cax, inset_bounds, label_side)` from
                `_inside_colorbar_axes`.
            box: `True` for a translucent white panel, a colour string for a
                panel of that colour, or a dict of `Rectangle` kwargs.
        """
        cax, bounds, label_side = box_info
        kw: dict = {
            "facecolor": "white",
            "edgecolor": "0.6",
            "linewidth": 0.6,
        }
        if isinstance(box, str):
            kw["facecolor"] = box
        elif isinstance(box, dict):
            kw = {**kw, **box}
        fig = ax.figure
        try:
            fig.canvas.draw()
            bb = cax.get_tightbbox(fig.canvas.get_renderer())
            inv = ax.transAxes.inverted()
            x0, y0 = inv.transform((bb.x0, bb.y0))
            x1, y1 = inv.transform((bb.x1, bb.y1))
        except Exception:  # pragma: no cover - renderer unavailable on some backends
            # Fallback: the inset bounds, grown on the side the labels face.
            bx0, by0, bw, bh = bounds
            x0, y0, x1, y1 = bx0, by0, bx0 + bw, by0 + bh
            allow = 0.11 if bw < bh else 0.06
            if label_side == "left":
                x0 -= allow
            elif label_side == "right":
                x1 += allow
            elif label_side == "bottom":
                y0 -= allow
            else:
                y1 += allow
        pad = 0.014
        rect = Rectangle(
            (x0 - pad, y0 - pad),
            (x1 - x0) + 2 * pad,
            (y1 - y0) + 2 * pad,
            transform=ax.transAxes,
            zorder=5,
            clip_on=False,
            **kw,
        )
        ax.add_patch(rect)

    def adjust_ticks(
        self,
        axis: str,
        multiply_value: float | int = 1,
        add_value: float | int = 0,
        fmt: str = "{0:g}",
        visible: bool = True,
    ) -> None:
        """Adjust the axis tick labels with a linear transformation.

        Applies `tick_value * multiply_value + add_value` to each
        tick, formatted with `fmt`. Useful for converting pixel
        coordinates to real-world units.

        Args:
            axis: `"x"` or `"y"`.
            multiply_value: Multiplier for tick values. Default is 1.
            add_value: Offset added to tick values. Default is 0.
            fmt: Format string for tick labels.
                Default is `"{0:g}"`.
            visible: Whether the axis is visible. Default is True.

        Examples:
            - Scale x-axis ticks by 100 and offset by 5:
                ```python
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyphs.base.glyph import Glyph
                >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts)
                >>> fig, ax = plt.subplots()
                >>> _ = ax.plot([0, 1, 2], [0, 1, 2])
                >>> g.fig, g.ax = fig, ax
                >>> g.adjust_ticks(axis="x", multiply_value=100, add_value=5)

                ```
        """
        assert self.ax is not None
        if axis == "x":
            ticks_fn = ticker.FuncFormatter(
                lambda x, pos: fmt.format(x * multiply_value + add_value)
            )
            self.ax.xaxis.set_major_formatter(ticks_fn)
        else:
            ticks_fn = ticker.FuncFormatter(
                lambda y, pos: fmt.format(y * multiply_value + add_value)
            )
            self.ax.yaxis.set_major_formatter(ticks_fn)

        if not visible:
            if axis == "x":
                self.ax.get_xaxis().set_visible(visible)
            else:
                self.ax.get_yaxis().set_visible(visible)

    def save_animation(self, path: str | os.PathLike, fps: int = 2, **kwargs) -> None:
        """Save this glyph's animation (`self.anim`) to a file.

        Thin wrapper around `cleopatra.glyphs.base.animation.save_animation`; the output
        format is determined by the file extension. GIF and WebP use an
        optimising Pillow writer; mov/avi/mp4 use FFmpeg (a system binary if
        present, otherwise the one bundled with imageio-ffmpeg).

        Args:
            path: Output file path, as a `str` or `os.PathLike` (e.g. a
                `pathlib.Path`). Extension determines format.
                Supported: gif, mov, avi, mp4, webp.
            fps: Frames per second. Default is 2.
            **kwargs: Additional keyword arguments forwarded to
                `cleopatra.glyphs.base.animation.save_animation`, e.g. `crf`, `bitrate`,
                `codec`, `preset`, `pix_fmt`, `dpi` (ffmpeg formats) or
                `optimize` and `loop` (GIF).

        Raises:
            ValueError: If `animate()` has not been called yet, if the file
                format is not supported, or if both `crf` and `bitrate`
                are given.
            FileNotFoundError: If a video format is requested but neither a
                system FFmpeg nor imageio-ffmpeg's bundled binary is found.

        Examples:
            - Check the supported video formats:
                ```python
                >>> from cleopatra.glyphs.base.glyph import SUPPORTED_VIDEO_FORMAT
                >>> sorted(SUPPORTED_VIDEO_FORMAT)
                ['avi', 'gif', 'mov', 'mp4', 'webp']

                ```
        """
        _save_animation(self.anim, path, fps=fps, **kwargs)

anim property #

Animation object created by animate().

default_options property #

Default plot options.

vmax property #

Maximum value for color scaling.

vmin property #

Minimum value for color scaling.

adjust_ticks(axis, multiply_value=1, add_value=0, fmt='{0:g}', visible=True) #

Adjust the axis tick labels with a linear transformation.

Applies tick_value * multiply_value + add_value to each tick, formatted with fmt. Useful for converting pixel coordinates to real-world units.

Parameters:

Name Type Description Default
axis str

"x" or "y".

required
multiply_value float | int

Multiplier for tick values. Default is 1.

1
add_value float | int

Offset added to tick values. Default is 0.

0
fmt str

Format string for tick labels. Default is "{0:g}".

'{0:g}'
visible bool

Whether the axis is visible. Default is True.

True

Examples:

  • Scale x-axis ticks by 100 and offset by 5:
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> g = Glyph(default_options=opts)
    >>> fig, ax = plt.subplots()
    >>> _ = ax.plot([0, 1, 2], [0, 1, 2])
    >>> g.fig, g.ax = fig, ax
    >>> g.adjust_ticks(axis="x", multiply_value=100, add_value=5)
    
Source code in src/cleopatra/glyphs/base/glyph.py
def adjust_ticks(
    self,
    axis: str,
    multiply_value: float | int = 1,
    add_value: float | int = 0,
    fmt: str = "{0:g}",
    visible: bool = True,
) -> None:
    """Adjust the axis tick labels with a linear transformation.

    Applies `tick_value * multiply_value + add_value` to each
    tick, formatted with `fmt`. Useful for converting pixel
    coordinates to real-world units.

    Args:
        axis: `"x"` or `"y"`.
        multiply_value: Multiplier for tick values. Default is 1.
        add_value: Offset added to tick values. Default is 0.
        fmt: Format string for tick labels.
            Default is `"{0:g}"`.
        visible: Whether the axis is visible. Default is True.

    Examples:
        - Scale x-axis ticks by 100 and offset by 5:
            ```python
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> g = Glyph(default_options=opts)
            >>> fig, ax = plt.subplots()
            >>> _ = ax.plot([0, 1, 2], [0, 1, 2])
            >>> g.fig, g.ax = fig, ax
            >>> g.adjust_ticks(axis="x", multiply_value=100, add_value=5)

            ```
    """
    assert self.ax is not None
    if axis == "x":
        ticks_fn = ticker.FuncFormatter(
            lambda x, pos: fmt.format(x * multiply_value + add_value)
        )
        self.ax.xaxis.set_major_formatter(ticks_fn)
    else:
        ticks_fn = ticker.FuncFormatter(
            lambda y, pos: fmt.format(y * multiply_value + add_value)
        )
        self.ax.yaxis.set_major_formatter(ticks_fn)

    if not visible:
        if axis == "x":
            self.ax.get_xaxis().set_visible(visible)
        else:
            self.ax.get_yaxis().set_visible(visible)

create_categorical_legend(ax) #

Attach the disjoint legend for a scheme="categorical" mapping.

Reads the category colours/labels _prepare_categorical_mapping stashed on self._categorical and draws them via cleopatra.styling.styles.disjoint_legend — the discrete counterpart to create_color_bar, used instead of it whenever scheme is "categorical" (a colorbar would imply a false ordering over nominal classes). The legend's title defaults to the cbar_label option (the same label a continuous plot would put on its colorbar); the category_legend_kwargs option is merged over that default and forwarded to disjoint_legend (e.g. loc, ncol, bbox_to_anchor, or an explicit title override) — the categorical counterpart to size_legend_kwargs.

Parameters:

Name Type Description Default
ax Axes

The axes to attach the legend to.

required

Returns:

Name Type Description
Legend Legend

The created legend artist, already added to ax.

Raises:

Type Description
ValueError

If self._categorical has not been populated yet (i.e. _prepare_categorical_mapping has not run for this glyph instance).

Examples:

  • Prepare a categorical mapping, then draw and inspect the legend:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
    >>> polys = [np.zeros((3, 2))] * 3
    >>> g = PolygonGlyph(polys, values=np.array(["a", "b", "a"]))
    >>> _ = g._prepare_categorical_mapping(np.array(["a", "b", "a"]))
    >>> fig, ax = plt.subplots()
    >>> legend = g.create_categorical_legend(ax)
    >>> [t.get_text() for t in legend.get_texts()]
    ['a', 'b']
    >>> plt.close(fig)
    
  • Calling it before a categorical mapping exists raises ValueError:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
    >>> g = PolygonGlyph([np.zeros((3, 2))] * 2, values=np.array(["a", "b"]))
    >>> fig, ax = plt.subplots()
    >>> g.create_categorical_legend(ax)
    Traceback (most recent call last):
        ...
    ValueError: create_categorical_legend() called before a scheme='categorical' mapping was prepared -- call _prepare_scalar_mapping (or plot()) first.
    >>> plt.close(fig)
    
  • category_legend_kwargs overrides the default title and adds a loc:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
    >>> polys = [np.zeros((3, 2))] * 2
    >>> g = PolygonGlyph(polys, values=np.array(["a", "b"]))
    >>> g.default_options["category_legend_kwargs"] = {
    ...     "title": "Class", "loc": "upper left"
    ... }
    >>> _ = g._prepare_categorical_mapping(np.array(["a", "b"]))
    >>> fig, ax = plt.subplots()
    >>> legend = g.create_categorical_legend(ax)
    >>> legend.get_title().get_text()
    'Class'
    >>> plt.close(fig)
    
Source code in src/cleopatra/glyphs/base/glyph.py
def create_categorical_legend(self, ax: Axes) -> Legend:
    """Attach the disjoint legend for a `scheme="categorical"` mapping.

    Reads the category colours/labels `_prepare_categorical_mapping`
    stashed on `self._categorical` and draws them via
    `cleopatra.styling.styles.disjoint_legend` — the discrete counterpart to
    `create_color_bar`, used instead of it whenever `scheme` is
    `"categorical"` (a colorbar would imply a false ordering over
    nominal classes). The legend's title defaults to the `cbar_label`
    option (the same label a continuous plot would put on its
    colorbar); the `category_legend_kwargs` option is merged over that
    default and forwarded to `disjoint_legend` (e.g. `loc`, `ncol`,
    `bbox_to_anchor`, or an explicit `title` override) — the categorical
    counterpart to `size_legend_kwargs`.

    Args:
        ax: The axes to attach the legend to.

    Returns:
        Legend: The created legend artist, already added to `ax`.

    Raises:
        ValueError: If `self._categorical` has not been populated yet
            (i.e. `_prepare_categorical_mapping` has not run for this
            glyph instance).

    Examples:
        - Prepare a categorical mapping, then draw and inspect the legend:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
            >>> polys = [np.zeros((3, 2))] * 3
            >>> g = PolygonGlyph(polys, values=np.array(["a", "b", "a"]))
            >>> _ = g._prepare_categorical_mapping(np.array(["a", "b", "a"]))
            >>> fig, ax = plt.subplots()
            >>> legend = g.create_categorical_legend(ax)
            >>> [t.get_text() for t in legend.get_texts()]
            ['a', 'b']
            >>> plt.close(fig)

            ```
        - Calling it before a categorical mapping exists raises `ValueError`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
            >>> g = PolygonGlyph([np.zeros((3, 2))] * 2, values=np.array(["a", "b"]))
            >>> fig, ax = plt.subplots()
            >>> g.create_categorical_legend(ax)
            Traceback (most recent call last):
                ...
            ValueError: create_categorical_legend() called before a scheme='categorical' mapping was prepared -- call _prepare_scalar_mapping (or plot()) first.
            >>> plt.close(fig)

            ```
        - `category_legend_kwargs` overrides the default title and adds
            a `loc`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
            >>> polys = [np.zeros((3, 2))] * 2
            >>> g = PolygonGlyph(polys, values=np.array(["a", "b"]))
            >>> g.default_options["category_legend_kwargs"] = {
            ...     "title": "Class", "loc": "upper left"
            ... }
            >>> _ = g._prepare_categorical_mapping(np.array(["a", "b"]))
            >>> fig, ax = plt.subplots()
            >>> legend = g.create_categorical_legend(ax)
            >>> legend.get_title().get_text()
            'Class'
            >>> plt.close(fig)

            ```
    """
    categorical = self._categorical
    if categorical is None:
        raise ValueError(
            "create_categorical_legend() called before a "
            "scheme='categorical' mapping was prepared -- call "
            "_prepare_scalar_mapping (or plot()) first."
        )
    legend_kwargs = {
        "title": self.default_options.get("cbar_label"),
        **(self.default_options.get("category_legend_kwargs") or {}),
    }
    return disjoint_legend(
        ax,
        categorical["colors"],
        categorical["labels"],
        **legend_kwargs,
    )

create_color_bar(ax, im, cbar_kw) #

Create a colorbar with full customization from default_options.

Reads cbar_length, cbar_orientation, cbar_label, cbar_label_size, and cbar_label_location from default_options to configure the colorbar. When the optional cbar_kwargs entry is present in default_options (an xarray-aligned dict-of-overrides), its keys are merged over the defaults so the user wins on any collision (e.g. label, shrink, orientation, ticks, extend).

cbar_kwargs is read from self.default_options["cbar_kwargs"]. Set it via the constructor or plot kwargs of the calling glyph subclass. Keys recognised by matplotlib.pyplot.colorbarlabel, shrink, aspect, orientation, pad, ticks, extend — are forwarded; label is special-cased so that label-size and label-location styling from default_options are still applied.

Placement is controlled by cbar_location ('left'/'right'/'top'/'bottom', which also fixes the orientation) and cbar_inside: when True, the colorbar is inset inside ax at that edge (a child of ax, so it tracks full_bleed instead of floating), with its tick labels facing into the frame and an optional cbar_box backing panel drawn behind it so the data does not show through. When cbar_location is None and cbar_inside is False, placement is matplotlib's default.

Parameters:

Name Type Description Default
ax Axes

Matplotlib axes.

required
im Any

The mappable (image or contour) to attach the colorbar to.

required
cbar_kw dict

Colorbar keyword arguments (ticks, format, extend, etc.) computed by _create_norm_and_cbar_kw.

required

Returns:

Name Type Description
Colorbar Colorbar

The created colorbar.

Raises:

Type Description
TypeError

If default_options["cbar_kwargs"] is set but is not a dict.

Examples:

  • Create a colorbar with a custom label:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> g = Glyph(default_options=opts, cbar_label="Depth [m]")
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.arange(9).reshape(3, 3))
    >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
    >>> cbar.orientation
    'vertical'
    
  • User-supplied cbar_kwargs win on collision and label is applied via set_label:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({
    ...     "vmin": None,
    ...     "vmax": None,
    ...     "cbar_kwargs": {"label": "User Label", "orientation": "horizontal"},
    ... })
    >>> g = Glyph(default_options=opts, cbar_label="Default Label")
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.arange(9).reshape(3, 3))
    >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
    >>> cbar.orientation
    'horizontal'
    >>> cbar.ax.get_xlabel() or cbar.ax.get_ylabel()
    'User Label'
    
  • Non-dict cbar_kwargs raises TypeError:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None, "cbar_kwargs": "oops"})
    >>> g = Glyph(default_options=opts)
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.arange(9).reshape(3, 3))
    >>> g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
    Traceback (most recent call last):
        ...
    TypeError: cbar_kwargs must be a dict of colorbar keyword arguments, got str.
    
Source code in src/cleopatra/glyphs/base/glyph.py
def create_color_bar(self, ax: Axes, im: Any, cbar_kw: dict) -> Colorbar:
    """Create a colorbar with full customization from default_options.

    Reads `cbar_length`, `cbar_orientation`, `cbar_label`,
    `cbar_label_size`, and `cbar_label_location` from
    `default_options` to configure the colorbar. When the optional
    `cbar_kwargs` entry is present in `default_options` (an
    xarray-aligned dict-of-overrides), its keys are merged over the
    defaults so the user wins on any collision (e.g. `label`,
    `shrink`, `orientation`, `ticks`, `extend`).

    `cbar_kwargs` is read from `self.default_options["cbar_kwargs"]`.
    Set it via the constructor or `plot` kwargs of the calling
    glyph subclass. Keys recognised by `matplotlib.pyplot.colorbar`
    — `label`, `shrink`, `aspect`, `orientation`, `pad`,
    `ticks`, `extend` — are forwarded; `label` is special-cased
    so that label-size and label-location styling from
    `default_options` are still applied.

    Placement is controlled by `cbar_location`
    (`'left'`/`'right'`/`'top'`/`'bottom'`, which also fixes the
    orientation) and `cbar_inside`: when `True`, the colorbar is inset
    *inside* `ax` at that edge (a child of `ax`, so it tracks
    `full_bleed` instead of floating), with its tick labels facing into
    the frame and an optional `cbar_box` backing panel drawn behind it so
    the data does not show through. When `cbar_location` is `None` and
    `cbar_inside` is `False`, placement is matplotlib's default.

    Args:
        ax: Matplotlib axes.
        im: The mappable (image or contour) to attach the
            colorbar to.
        cbar_kw: Colorbar keyword arguments (ticks, format,
            extend, etc.) computed by
            `_create_norm_and_cbar_kw`.

    Returns:
        Colorbar: The created colorbar.

    Raises:
        TypeError: If `default_options["cbar_kwargs"]` is set
            but is not a `dict`.

    Examples:
        - Create a colorbar with a custom label:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> g = Glyph(default_options=opts, cbar_label="Depth [m]")
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.arange(9).reshape(3, 3))
            >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
            >>> cbar.orientation
            'vertical'

            ```
        - User-supplied `cbar_kwargs` win on collision and
            `label` is applied via `set_label`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({
            ...     "vmin": None,
            ...     "vmax": None,
            ...     "cbar_kwargs": {"label": "User Label", "orientation": "horizontal"},
            ... })
            >>> g = Glyph(default_options=opts, cbar_label="Default Label")
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.arange(9).reshape(3, 3))
            >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
            >>> cbar.orientation
            'horizontal'
            >>> cbar.ax.get_xlabel() or cbar.ax.get_ylabel()
            'User Label'

            ```
        - Non-dict `cbar_kwargs` raises `TypeError`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None, "cbar_kwargs": "oops"})
            >>> g = Glyph(default_options=opts)
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.arange(9).reshape(3, 3))
            >>> g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
            Traceback (most recent call last):
                ...
            TypeError: cbar_kwargs must be a dict of colorbar keyword arguments, got str.

            ```
    """
    location = self.default_options.get("cbar_location")
    if location is not None and location not in ("left", "right", "top", "bottom"):
        raise ValueError(
            "cbar_location must be one of 'left', 'right', 'top', "
            f"'bottom', or None, got {location!r}."
        )
    orientation_opt = self.default_options.get("cbar_orientation")
    if orientation_opt is not None and orientation_opt not in ("vertical", "horizontal"):
        raise ValueError(
            "cbar_orientation must be 'vertical' or 'horizontal', got "
            f"{orientation_opt!r}."
        )
    inside = bool(self.default_options.get("cbar_inside", False))
    orientation = self._resolve_cbar_orientation(location)
    user_kwargs, user_label = self._cbar_user_kwargs()

    box_info = None
    if inside:
        inset_location = location or (
            "bottom" if orientation == "horizontal" else "right"
        )
        cbar, box_info = self._inside_colorbar_axes(
            ax, im, cbar_kw, inset_location, orientation, user_kwargs
        )
    else:
        cbar = self._outside_colorbar(
            ax, im, cbar_kw, location, orientation, user_kwargs
        )

    self._apply_cbar_styling(cbar, user_label)
    box = self.default_options.get("cbar_box")
    if inside and box and box_info is not None:
        self._draw_cbar_box(ax, box_info, box)
    return cbar

create_figure_axes() #

Create a new figure and axes from default_options.

Uses the figsize key from default_options to set the figure dimensions.

Returns:

Type Description
tuple[Figure, Axes]

tuple[Figure, Axes]: The created figure and axes.

Examples:

  • Create a figure with custom size:
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> g = Glyph(default_options=opts, figsize=(12, 4))
    >>> fig, ax = g.create_figure_axes()
    >>> fig.get_size_inches()
    array([12.,  4.])
    
Source code in src/cleopatra/glyphs/base/glyph.py
def create_figure_axes(self) -> tuple[Figure, Axes]:
    """Create a new figure and axes from default_options.

    Uses the `figsize` key from `default_options` to set the
    figure dimensions.

    Returns:
        tuple[Figure, Axes]: The created figure and axes.

    Examples:
        - Create a figure with custom size:
            ```python
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> g = Glyph(default_options=opts, figsize=(12, 4))
            >>> fig, ax = g.create_figure_axes()
            >>> fig.get_size_inches()
            array([12.,  4.])

            ```
    """
    fig, ax = plt.subplots(figsize=self.default_options["figsize"])
    return fig, ax

filter_kwargs(kwargs) classmethod #

Return only the subset of kwargs whose keys this glyph accepts.

A convenience for callers that forward a bag of user-supplied styling kwargs into a glyph: pre-filtering with this method lets the construction succeed instead of raising on an unknown key. Order and values are preserved; rejected keys are simply dropped.

Parameters:

Name Type Description Default
kwargs dict

A mapping of candidate option keys to values.

required

Returns:

Name Type Description
dict dict

The entries of kwargs whose keys are in option_keys().

Examples:

  • Keep only the accepted keys, then construct safely:
    >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
    >>> raw = {"cmap": "viridis", "edgecolor": "black", "bogus": 1}
    >>> safe = PolygonGlyph.filter_kwargs(raw)
    >>> sorted(safe)
    ['cmap', 'edgecolor']
    >>> safe["cmap"]
    'viridis'
    
  • An empty mapping yields an empty mapping:
    >>> from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph
    >>> ScatterGlyph.filter_kwargs({})
    {}
    
See Also

option_keys: The set of keys this glyph accepts.

Source code in src/cleopatra/glyphs/base/glyph.py
@classmethod
def filter_kwargs(cls, kwargs: dict) -> dict:
    """Return only the subset of `kwargs` whose keys this glyph accepts.

    A convenience for callers that forward a bag of user-supplied
    styling kwargs into a glyph: pre-filtering with this method lets
    the construction succeed instead of raising on an unknown key.
    Order and values are preserved; rejected keys are simply dropped.

    Args:
        kwargs: A mapping of candidate option keys to values.

    Returns:
        dict: The entries of `kwargs` whose keys are in `option_keys()`.

    Examples:
        - Keep only the accepted keys, then construct safely:
            ```python
            >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
            >>> raw = {"cmap": "viridis", "edgecolor": "black", "bogus": 1}
            >>> safe = PolygonGlyph.filter_kwargs(raw)
            >>> sorted(safe)
            ['cmap', 'edgecolor']
            >>> safe["cmap"]
            'viridis'

            ```
        - An empty mapping yields an empty mapping:
            ```python
            >>> from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph
            >>> ScatterGlyph.filter_kwargs({})
            {}

            ```

    See Also:
        option_keys: The set of keys this glyph accepts.
    """
    keys = cls.option_keys()
    return {key: val for key, val in kwargs.items() if key in keys}

get_ticks() #

Compute colorbar tick locations from default_options.

Uses vmin, vmax, and ticks_spacing from default_options to generate evenly-spaced tick positions.

Returns:

Type Description
ndarray

np.ndarray: Array of tick positions.

Examples:

  • Compute ticks for a 0-10 range with spacing of 2:
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": 0.0, "vmax": 10.0, "ticks_spacing": 2.0})
    >>> g = Glyph(default_options=opts)
    >>> g.get_ticks()
    array([ 0.,  2.,  4.,  6.,  8., 10.])
    
Source code in src/cleopatra/glyphs/base/glyph.py
def get_ticks(self) -> np.ndarray:
    """Compute colorbar tick locations from default_options.

    Uses `vmin`, `vmax`, and `ticks_spacing` from
    `default_options` to generate evenly-spaced tick positions.

    Returns:
        np.ndarray: Array of tick positions.

    Examples:
        - Compute ticks for a 0-10 range with spacing of 2:
            ```python
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": 0.0, "vmax": 10.0, "ticks_spacing": 2.0})
            >>> g = Glyph(default_options=opts)
            >>> g.get_ticks()
            array([ 0.,  2.,  4.,  6.,  8., 10.])

            ```
    """
    ticks_spacing = self.default_options["ticks_spacing"]
    vmax = self.default_options["vmax"]
    vmin = self.default_options["vmin"]
    if not ticks_spacing or vmax <= vmin:
        result = np.array([vmin])
    else:
        ticks = np.arange(vmin, vmax + ticks_spacing, ticks_spacing)
        ticks = ticks[ticks <= vmax + 1e-9]
        if ticks.size == 0:
            result = np.array([vmin, vmax])
        else:
            if (vmax - ticks[-1]) > 0.04 * (vmax - vmin):
                ticks = np.append(ticks, vmax)
            else:
                ticks[-1] = vmax
            result = ticks
    return result

option_keys() classmethod #

Return the keyword-argument keys this glyph accepts.

Resolves from the class-level DEFAULT_OPTIONS, so the accepted keys can be inspected without constructing an instance (and therefore without tripping the strict unknown-kwarg check in _merge_kwargs). The keys differ per glyph subclass.

This reports the class's default option set. For every concrete glyph subclass that equals the instance's accepted keys (each subclass passes the same dict to __init__). The base Glyph reports the shared STYLE_DEFAULTS; an instance built with a custom injected default_options is the one case where the two can differ, so base Glyph is not part of the introspection contract.

Returns:

Type Description
set[str]

set[str]: The accepted option keys for this glyph class.

Examples:

  • Inspect the keys a glyph accepts before building one:
    >>> from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph
    >>> keys = ScatterGlyph.option_keys()
    >>> "cmap" in keys
    True
    >>> "totally_unknown" in keys
    False
    
  • Different glyphs expose different keys:
    >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
    >>> "edgecolor" in PolygonGlyph.option_keys()
    True
    
See Also

filter_kwargs: Drop the keys a glyph does not accept from a dict.

Source code in src/cleopatra/glyphs/base/glyph.py
@classmethod
def option_keys(cls) -> set[str]:
    """Return the keyword-argument keys this glyph accepts.

    Resolves from the class-level `DEFAULT_OPTIONS`, so the accepted
    keys can be inspected **without constructing an instance** (and
    therefore without tripping the strict unknown-kwarg check in
    `_merge_kwargs`). The keys differ per glyph subclass.

    This reports the class's *default* option set. For every concrete
    glyph subclass that equals the instance's accepted keys (each
    subclass passes the same dict to `__init__`). The base `Glyph`
    reports the shared `STYLE_DEFAULTS`; an instance built with a
    custom injected `default_options` is the one case where the two
    can differ, so base `Glyph` is not part of the introspection
    contract.

    Returns:
        set[str]: The accepted option keys for this glyph class.

    Examples:
        - Inspect the keys a glyph accepts before building one:
            ```python
            >>> from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph
            >>> keys = ScatterGlyph.option_keys()
            >>> "cmap" in keys
            True
            >>> "totally_unknown" in keys
            False

            ```
        - Different glyphs expose different keys:
            ```python
            >>> from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph
            >>> "edgecolor" in PolygonGlyph.option_keys()
            True

            ```

    See Also:
        filter_kwargs: Drop the keys a glyph does not accept from a dict.
    """
    return set(cls.DEFAULT_OPTIONS)

save_animation(path, fps=2, **kwargs) #

Save this glyph's animation (self.anim) to a file.

Thin wrapper around cleopatra.glyphs.base.animation.save_animation; the output format is determined by the file extension. GIF and WebP use an optimising Pillow writer; mov/avi/mp4 use FFmpeg (a system binary if present, otherwise the one bundled with imageio-ffmpeg).

Parameters:

Name Type Description Default
path str | PathLike

Output file path, as a str or os.PathLike (e.g. a pathlib.Path). Extension determines format. Supported: gif, mov, avi, mp4, webp.

required
fps int

Frames per second. Default is 2.

2
**kwargs

Additional keyword arguments forwarded to cleopatra.glyphs.base.animation.save_animation, e.g. crf, bitrate, codec, preset, pix_fmt, dpi (ffmpeg formats) or optimize and loop (GIF).

{}

Raises:

Type Description
ValueError

If animate() has not been called yet, if the file format is not supported, or if both crf and bitrate are given.

FileNotFoundError

If a video format is requested but neither a system FFmpeg nor imageio-ffmpeg's bundled binary is found.

Examples:

  • Check the supported video formats:
    >>> from cleopatra.glyphs.base.glyph import SUPPORTED_VIDEO_FORMAT
    >>> sorted(SUPPORTED_VIDEO_FORMAT)
    ['avi', 'gif', 'mov', 'mp4', 'webp']
    
Source code in src/cleopatra/glyphs/base/glyph.py
def save_animation(self, path: str | os.PathLike, fps: int = 2, **kwargs) -> None:
    """Save this glyph's animation (`self.anim`) to a file.

    Thin wrapper around `cleopatra.glyphs.base.animation.save_animation`; the output
    format is determined by the file extension. GIF and WebP use an
    optimising Pillow writer; mov/avi/mp4 use FFmpeg (a system binary if
    present, otherwise the one bundled with imageio-ffmpeg).

    Args:
        path: Output file path, as a `str` or `os.PathLike` (e.g. a
            `pathlib.Path`). Extension determines format.
            Supported: gif, mov, avi, mp4, webp.
        fps: Frames per second. Default is 2.
        **kwargs: Additional keyword arguments forwarded to
            `cleopatra.glyphs.base.animation.save_animation`, e.g. `crf`, `bitrate`,
            `codec`, `preset`, `pix_fmt`, `dpi` (ffmpeg formats) or
            `optimize` and `loop` (GIF).

    Raises:
        ValueError: If `animate()` has not been called yet, if the file
            format is not supported, or if both `crf` and `bitrate`
            are given.
        FileNotFoundError: If a video format is requested but neither a
            system FFmpeg nor imageio-ffmpeg's bundled binary is found.

    Examples:
        - Check the supported video formats:
            ```python
            >>> from cleopatra.glyphs.base.glyph import SUPPORTED_VIDEO_FORMAT
            >>> sorted(SUPPORTED_VIDEO_FORMAT)
            ['avi', 'gif', 'mov', 'mp4', 'webp']

            ```
    """
    _save_animation(self.anim, path, fps=fps, **kwargs)