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
data_style DataStyle | None

Grouped style / hillshade (and, for ArrayGlyph, bands / alpha / alpha_range) options to apply at construction. These moved onto DataStyle and are therefore rejected as loose keywords; accepting the group here is what makes that redirection reachable, so an option can be set once on the glyph instead of on every plot() call. Only the fields the DataStyle actually sets are applied, and a field this glyph does not model is dropped — but a group that applies to nothing raises, rather than vanishing silently, so the four primitive glyphs (VectorGlyph, FlowGlyph, PolygonGlyph, ScatterGlyph) reject it outright. Where a field collides with a loose keyword for the same option — only alpha can, since every other DataStyle field is rejected as a loose keyword — the group is merged second and wins.

None
**kwargs

Override any key in default_options.

{}

Raises:

Type Description
TypeError

If data_style is given and is not a DataStyle.

ValueError

If data_style sets only options this glyph does not model, or if a keyword argument is not a default_options key.

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
    
  • Set the grouped style / hillshade options at construction, so they do not have to be repeated on every plot() call:
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.params import DataStyle
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None, "style": None, "hillshade": False})
    >>> g = Glyph(
    ...     default_options=opts, data_style=DataStyle(style="topography", hillshade=True)
    ... )
    >>> g.default_options["style"]
    'topography'
    >>> g.default_options["hillshade"]
    True
    
  • Only the fields the DataStyle sets are applied, so an empty group leaves every option at its default:
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.params import DataStyle
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None, "style": "topography"})
    >>> g = Glyph(default_options=opts, data_style=DataStyle())
    >>> g.default_options["style"]
    'topography'
    
  • A group that sets only options this glyph does not model is refused, rather than accepted and dropped -- the four primitive glyphs behave this way:
    >>> from cleopatra.glyphs.base.glyph import Glyph
    >>> from cleopatra.styling.params import DataStyle
    >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> Glyph(default_options=opts, data_style=DataStyle(style="topography"))
    Traceback (most recent call last):
        ...
    ValueError: Glyph has no ['style'] option(s), so data_style= does not apply to it. ...
    
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
 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
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
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).
        data_style: Grouped `style` / `hillshade` (and, for `ArrayGlyph`,
            `bands` / `alpha` / `alpha_range`) options to apply at
            construction. These moved onto `DataStyle` and are therefore
            rejected as loose keywords; accepting the group here is what
            makes that redirection reachable, so an option can be set once on
            the glyph instead of on every `plot()` call. Only the fields the
            `DataStyle` actually sets are applied, and a field this glyph
            does not model is dropped — but a group that applies to *nothing*
            raises, rather than vanishing silently, so the four primitive
            glyphs (`VectorGlyph`, `FlowGlyph`, `PolygonGlyph`,
            `ScatterGlyph`) reject it outright. Where a field collides with a
            loose keyword for the same option — only `alpha` can, since every
            other `DataStyle` field is rejected as a loose keyword — the
            group is merged second and wins.
        **kwargs: Override any key in `default_options`.

    Raises:
        TypeError: If `data_style` is given and is not a `DataStyle`.
        ValueError: If `data_style` sets only options this glyph does not
            model, or if a keyword argument is not a `default_options` key.

    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

            ```
        - Set the grouped `style` / `hillshade` options at construction, so
            they do not have to be repeated on every `plot()` call:
            ```python
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.params import DataStyle
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None, "style": None, "hillshade": False})
            >>> g = Glyph(
            ...     default_options=opts, data_style=DataStyle(style="topography", hillshade=True)
            ... )
            >>> g.default_options["style"]
            'topography'
            >>> g.default_options["hillshade"]
            True

            ```
        - Only the fields the `DataStyle` sets are applied, so an empty group
            leaves every option at its default:
            ```python
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.params import DataStyle
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None, "style": "topography"})
            >>> g = Glyph(default_options=opts, data_style=DataStyle())
            >>> g.default_options["style"]
            'topography'

            ```
        - A group that sets only options this glyph does not model is
            refused, rather than accepted and dropped -- the four primitive
            glyphs behave this way:
            ```python
            >>> from cleopatra.glyphs.base.glyph import Glyph
            >>> from cleopatra.styling.params import DataStyle
            >>> from cleopatra.styling.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> Glyph(default_options=opts, data_style=DataStyle(style="topography"))
            Traceback (most recent call last):
                ...
            ValueError: Glyph has no ['style'] option(s), so data_style= does not apply to it. ...

            ```

    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,
        data_style: DataStyle | None = None,
        **kwargs,
    ):
        self._default_options = default_options.copy()
        self._merge_kwargs(kwargs)
        #: Axis-styling options set at construction. A glyph whose `plot()`
        #: resets `default_options` (`MeshGlyph`) would otherwise discard them,
        #: so `MeshGlyph(xlabel=...)` would be accepted and never drawn.
        self._construction_axis_style = {
            key: self._default_options[key]
            for key in _AXIS_STYLE_KEYS
            if key in self._explicit_options
        }
        # Grouped options are applied after the loose ones so a construction
        # kwarg and a `DataStyle` field naming the same option resolve the
        # same way they do in `plot()`: the group wins.
        if data_style is not None:
            self._apply_construction_data_style(data_style)
        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.

        Note that this filters *option* keys only. The grouped parameter
        objects are not options and are dropped like any other unknown key,
        so `data_style=` (and `plot()`'s `color=` / `contour=` / `cells=`)
        must be passed separately rather than through this filter -- a
        `data_style` in `kwargs` would otherwise be silently discarded.

        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 _apply_construction_data_style(self, data_style: DataStyle) -> None:
        """Apply a constructor-supplied `DataStyle`, refusing one that cannot apply.

        `_merge_group_params` drops keys the glyph does not model, which is the
        right behaviour for `plot()` (one group object is shared across glyphs
        with different option sets). At construction it would be a trap: the
        four primitive glyphs model none of `DataStyle`'s options, so the whole
        group would vanish silently -- and those are exactly the glyphs whose
        loose `style=`/`hillshade=` rejection tells the caller to pass a
        `data_style=` instead. Refusing outright keeps that redirection honest.

        Args:
            data_style: The group passed to `__init__`.

        Raises:
            TypeError: If `data_style` is not a `DataStyle`.
            ValueError: If this glyph models none of the options it sets.
        """
        if not isinstance(data_style, DataStyle):
            raise TypeError(
                "data_style must be a DataStyle, got "
                f"{type(data_style).__name__}; pass "
                "data_style=DataStyle(style=..., hillshade=...)."
            )
        requested = data_style.to_options()
        applicable = {key for key in requested if key in self._default_options}
        if requested and not applicable:
            raise ValueError(
                f"{type(self).__name__} has no "
                f"{sorted(requested)} option(s), so data_style= does not apply "
                "to it. The grouped style options are honoured only by the "
                "glyphs that model them (ArrayGlyph, MeshGlyph, KDEGlyph)."
            )
        self._merge_group_params(data_style)
        # Only the keys that were actually merged count as explicitly passed;
        # recording the dropped ones would make this disagree with
        # `_merge_group_params` about what the glyph is carrying.
        self._explicit_options |= applicable

    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.

        Raises:
            TypeError: If a group is not a grouped parameter object. These
                parameters are easy to mistake for their loose predecessors --
                `color=` takes a `ColorScaling`, not a colour string -- and
                without this the caller got `'str' object has no attribute
                'to_options'`, which names neither the parameter nor what it
                wanted.
        """
        for group in groups:
            if group is None:
                continue
            for key, val in _group_option_items(group).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_option_items(group):
                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` / `lognorm` / `boundary-norm` / `midpoint` / `equalize`
        (the last is data-driven and honoured only by glyphs that expose their
        values, e.g. `ArrayGlyph`) — 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.
        caller_norm = self.default_options.get("norm")
        if caller_norm is not None:
            return self._caller_norm_and_cbar_kw(caller_norm, ticks)
        scaling = ColorScaling.from_options(self.default_options)
        # Only the equalize scale reads the data itself; skip the O(n) scan for
        # every other scale (this method is also invoked more than once/render).
        values = (
            self._scale_values()
            if self.default_options.get("color_scale") == "equalize"
            else None
        )
        return scaling.build_norm(
            ticks,
            levels=self.default_options.get("levels"),
            extend=self.default_options.get("extend"),
            values=values,
        )

    def _caller_norm_and_cbar_kw(
        self, norm: object, ticks: np.ndarray
    ) -> tuple[colors.Normalize, dict]:
        """Use a caller-supplied matplotlib `Normalize` directly.

        The escape hatch for `plot(color=my_norm)` / `plot(norm=my_norm)`: the
        caller's own norm renders as given, bypassing the `color_scale` path so
        any matplotlib norm (`FuncNorm`, `AsinhNorm`, a custom subclass) is
        reachable without a dedicated `ColorScaling` variant. A `BoundaryNorm`
        bar takes its own boundaries as ticks; a norm carrying its own
        `vmin`/`vmax` gets a ladder spanning *that* range (the incoming ticks
        come off the data range, which can differ and would place ticks off
        the bar); otherwise the incoming ladder is kept.

        Args:
            norm: The caller-supplied norm; must be a
                `matplotlib.colors.Normalize`.
            ticks: The colorbar tick ladder to fall back on.

        Returns:
            tuple[Normalize, dict]: The norm and its colorbar keyword arguments.

        Raises:
            TypeError: If `norm` is not a `matplotlib.colors.Normalize`.
        """
        if not isinstance(norm, colors.Normalize):
            raise TypeError(
                "norm= must be a matplotlib.colors.Normalize instance, got "
                f"{type(norm).__name__}."
            )
        extend = self.default_options.get("extend") or "neither"
        n_ticks = len(ticks) if ticks is not None and len(ticks) >= 2 else 8
        if isinstance(norm, colors.BoundaryNorm):
            bar_ticks = norm.boundaries
        elif norm.vmin is not None and norm.vmax is not None:
            # Place ticks evenly along the bar in the norm's own space (via its
            # inverse), so a log/asinh caller norm gets sensibly spread ticks
            # rather than a linear ladder crammed into one end.
            try:
                bar_ticks = np.asarray(
                    norm.inverse(np.linspace(0.0, 1.0, n_ticks)), dtype=float
                )
            except (ValueError, TypeError):
                bar_ticks = np.linspace(float(norm.vmin), float(norm.vmax), n_ticks)
        else:
            bar_ticks = ticks
        return norm, {"ticks": bar_ticks, "extend": extend}

    def _warn_norm_shadows_scale(self, color: Any, norm: Any) -> None:
        """Warn when one call passes both a `ColorScaling` and a raw `norm=`.

        The raw norm renders directly and the scale is dropped; surfacing the
        contradiction (like the `scheme` vs `color_scale` warning) beats
        silently ignoring the `ColorScaling`. Detected from the call's own
        arguments, so it fires exactly once and never on a sticky scale left by
        an earlier call.

        Args:
            color: The `color=` argument of this `plot`/`animate` call.
            norm: The `norm=` argument of this call (or `None`).
        """
        if isinstance(color, ColorScaling) and norm is not None:
            warnings.warn(
                f"both color=ColorScaling.{color.kind.value} and norm= were "
                "given; the norm renders directly and the color scale is ignored.",
                stacklevel=3,
            )

    def _scale_values(self) -> np.ndarray | None:
        """The data values a data-driven colour scale needs, or `None`.

        Only the `equalize` scale reads this -- it builds its quantile table
        from the data itself, not just the tick range. The base glyph exposes
        nothing (returns `None`); a glyph that carries a value array (e.g.
        `ArrayGlyph`) overrides this to return its valid, finite cells.

        Returns:
            np.ndarray or None: A 1-D array of finite data values, or `None`
                when the glyph has no value array to equalise over.
        """
        return None

    @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 _draws_own_colorbar(self, compose: bool, colorbar: Any = None) -> bool:
        """Whether this render should draw a colorbar of its own.

        `fig.colorbar()` takes its space from the axes the mappable is on, so an
        overlay that adds one re-lays-out the host -- on every overlay, since
        composing is the case where the axes already belongs to someone else.
        Composing therefore defaults the colorbar off. Only the default: a
        caller who asks for one, at construction or on the call, still gets it.

        Args:
            compose: Whether this render is composing onto an existing axes.
            colorbar: The render's `colorbar=` argument, if it has one. Anything
                but `None` counts as asking.

        Returns:
            bool: `True` when a colorbar should be drawn.
        """
        wanted = bool(self.default_options.get("add_colorbar", True))
        if not compose or not wanted:
            return wanted
        return colorbar is not None or "add_colorbar" in getattr(
            self, "_render_explicit_options", getattr(self, "_explicit_options", set())
        )

    def _restore_construction_axis_style(self, call_keys: Iterable[str]) -> None:
        """Carry construction-time axis options across a `default_options` reset.

        `MeshGlyph.plot`/`animate` rebuild `default_options` from the module
        defaults on every call, so that a per-call option cannot leak into the
        next render. That is deliberate, but it also threw away the axis options
        the constructor was given -- `MeshGlyph(xlabel=...)` rendered no label at
        all. This puts them back, without overriding a key this call passed, and
        records the combined set for `_apply_axis_style`.

        Args:
            call_keys: The option keys this render call passed; these win over
                the construction-time values.
        """
        call_keys = set(call_keys)
        for key, value in self._construction_axis_style.items():
            if key not in call_keys:
                self._default_options[key] = value
        self._render_explicit_options = set(self._construction_axis_style) | call_keys

    def _apply_axis_style(
        self,
        ax: Axes,
        *,
        apply_defaults: bool = False,
        grid_axis: str | None = "both",
    ) -> None:
        """Apply this glyph's axis-styling options to `ax`.

        Thin wrapper over `_apply_axis_options`; see it for what is applied and
        why only explicitly-passed options are honoured by default.

        A render method that accepts these options as keyword arguments sets
        `_render_explicit_options` to the constructor's keys plus its own, and
        that set wins here. It is rebuilt on every call rather than accumulated,
        so an option the caller drops on a later call stops being re-applied --
        and it is kept apart from `_explicit_options`, which `create_figure_axes`
        reads to decide whether to auto-size the figure.

        Args:
            ax: The axes to style.
            apply_defaults: Apply every option, not only the explicitly-passed
                ones.
            grid_axis: Which gridlines `grid_alpha` draws; `None` leaves the
                grid untouched.
        """
        explicit = getattr(self, "_render_explicit_options", None)
        if explicit is None:
            explicit = getattr(self, "_explicit_options", set())
        _apply_axis_options(
            ax,
            self.default_options,
            explicit,
            apply_defaults=apply_defaults,
            grid_axis=grid_axis,
        )

    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`, `loop` and `quantize_method` (GIF/WebP).

        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.

Note that this filters option keys only. The grouped parameter objects are not options and are dropped like any other unknown key, so data_style= (and plot()'s color= / contour= / cells=) must be passed separately rather than through this filter -- a data_style in kwargs would otherwise be silently discarded.

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.

    Note that this filters *option* keys only. The grouped parameter
    objects are not options and are dropped like any other unknown key,
    so `data_style=` (and `plot()`'s `color=` / `contour=` / `cells=`)
    must be passed separately rather than through this filter -- a
    `data_style` in `kwargs` would otherwise be silently discarded.

    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, loop and quantize_method (GIF/WebP).

{}

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`, `loop` and `quantize_method` (GIF/WebP).

    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)