Skip to content

DotPlotter

The DotPlotter class generates all-vs-all dotplot figures from a populated SequenceIndex.

Class

DotPlotter

Generate all-vs-all dotplots for sets of DNA sequences.

Accepts a :class:~dot_explorer.SequenceIndex (single sequence collection), a :class:~dot_explorer.paf_io.CrossIndex (multi-group collection), or a :class:~dot_explorer.paf_io.PafAlignment loaded from an external aligner such as minimap2.

When a PafAlignment is passed as index, sequence lengths are read from the PAF records and alignments are rendered directly — no k-mer index is required::

from dot_explorer.paf_io import PafAlignment
from dot_explorer.dotplot import DotPlotter

aln = PafAlignment.from_file("alignments.paf")
q_order, t_order = aln.reorder_contigs()

plotter = DotPlotter(aln)
plotter.plot(
    query_names=q_order,
    target_names=t_order,
    output_path="dotplot.png",
)

When using a CrossIndex, use query_group and target_group in :meth:plot and :meth:plot_single so that sequence names are resolved automatically and pre-computed merged alignments are used for rendering::

cross = CrossIndex(k=15)
cross.load_fasta("assembly_a.fasta", group="a")
cross.load_fasta("assembly_b.fasta", group="b")
cross.compute_matches()  # pre-compute merged alignments

plotter = DotPlotter(cross)
plotter.plot(
    query_group="a",   # sequence names looked up from group 'a'
    target_group="b",  # sequence names looked up from group 'b'
    output_path="cross_plot.png",
)

To colour alignments by sequence identity, supply a :class:~dot_explorer.paf_io.PafAlignment and set color_by_identity=True::

from dot_explorer.paf_io import PafAlignment
aln = PafAlignment.from_file("alignments.paf")
plotter = DotPlotter(aln)
fig = plotter.plot(color_by_identity=True, identity_palette="viridis")
cbar = plotter.plot_identity_colorbar(palette="viridis")

Parameters:

Name Type Description Default
index SequenceIndex, CrossIndex, or PafAlignment

A populated index or alignment collection. When a :class:~dot_explorer.paf_io.PafAlignment is supplied, it is used both to resolve sequence lengths and as the source of alignment segments.

required
paf_alignment PafAlignment

Pre-loaded PAF alignments used as the data source when color_by_identity=True and index is a SequenceIndex or CrossIndex. When index is already a PafAlignment this argument is ignored. When None (default) and index is not a PafAlignment, k-mer matches from index are used for plotting.

None

Examples:

>>> from dot_explorer import SequenceIndex
>>> from dot_explorer.dotplot import DotPlotter
>>> idx = SequenceIndex(k=10)
>>> idx.add_sequence("seq1", "ACGTACGTACGT" * 10)
>>> idx.add_sequence("seq2", "TACGTACGTACG" * 10)
>>> plotter = DotPlotter(idx)
>>> fig = plotter.plot(output_path="dotplot.png")  # save to file
>>> fig = plotter.plot()  # display inline in Jupyter, no file saved
Source code in dot_explorer/dotplot.py
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
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
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
class DotPlotter:
    """Generate all-vs-all dotplots for sets of DNA sequences.

    Accepts a :class:`~dot_explorer.SequenceIndex` (single sequence collection),
    a :class:`~dot_explorer.paf_io.CrossIndex` (multi-group collection), or a
    :class:`~dot_explorer.paf_io.PafAlignment` loaded from an external aligner
    such as minimap2.

    When a ``PafAlignment`` is passed as *index*, sequence lengths are read
    from the PAF records and alignments are rendered directly — no k-mer index
    is required::

        from dot_explorer.paf_io import PafAlignment
        from dot_explorer.dotplot import DotPlotter

        aln = PafAlignment.from_file("alignments.paf")
        q_order, t_order = aln.reorder_contigs()

        plotter = DotPlotter(aln)
        plotter.plot(
            query_names=q_order,
            target_names=t_order,
            output_path="dotplot.png",
        )

    When using a ``CrossIndex``, use *query_group* and *target_group* in
    :meth:`plot` and :meth:`plot_single` so that sequence names are resolved
    automatically and pre-computed merged alignments are used for rendering::

        cross = CrossIndex(k=15)
        cross.load_fasta("assembly_a.fasta", group="a")
        cross.load_fasta("assembly_b.fasta", group="b")
        cross.compute_matches()  # pre-compute merged alignments

        plotter = DotPlotter(cross)
        plotter.plot(
            query_group="a",   # sequence names looked up from group 'a'
            target_group="b",  # sequence names looked up from group 'b'
            output_path="cross_plot.png",
        )

    To colour alignments by sequence identity, supply a
    :class:`~dot_explorer.paf_io.PafAlignment` and set
    ``color_by_identity=True``::

        from dot_explorer.paf_io import PafAlignment
        aln = PafAlignment.from_file("alignments.paf")
        plotter = DotPlotter(aln)
        fig = plotter.plot(color_by_identity=True, identity_palette="viridis")
        cbar = plotter.plot_identity_colorbar(palette="viridis")

    Parameters
    ----------
    index : SequenceIndex, CrossIndex, or PafAlignment
        A populated index or alignment collection.  When a
        :class:`~dot_explorer.paf_io.PafAlignment` is supplied, it is used both
        to resolve sequence lengths and as the source of alignment segments.
    paf_alignment : PafAlignment, optional
        Pre-loaded PAF alignments used as the data source when
        ``color_by_identity=True`` and *index* is a ``SequenceIndex`` or
        ``CrossIndex``.  When *index* is already a ``PafAlignment`` this
        argument is ignored.  When ``None`` (default) and *index* is not a
        ``PafAlignment``, k-mer matches from *index* are used for plotting.

    Examples
    --------
    >>> from dot_explorer import SequenceIndex
    >>> from dot_explorer.dotplot import DotPlotter
    >>> idx = SequenceIndex(k=10)
    >>> idx.add_sequence("seq1", "ACGTACGTACGT" * 10)
    >>> idx.add_sequence("seq2", "TACGTACGTACG" * 10)
    >>> plotter = DotPlotter(idx)
    >>> fig = plotter.plot(output_path="dotplot.png")  # save to file
    >>> fig = plotter.plot()  # display inline in Jupyter, no file saved
    """

    def __init__(
        self,
        index: Union[SequenceIndex, 'CrossIndex', 'PafAlignment'],
        paf_alignment: Optional['PafAlignment'] = None,
    ) -> None:
        """Initialise the DotPlotter.

        Parameters
        ----------
        index : SequenceIndex, CrossIndex, or PafAlignment
            A populated index or alignment collection.  When a
            :class:`~dot_explorer.paf_io.PafAlignment` is supplied, it is used
            both to resolve sequence lengths and as the source of alignment
            segments.
        paf_alignment : PafAlignment, optional
            Pre-loaded PAF alignments.  Used for identity-based colouring
            when *index* is a ``SequenceIndex`` or ``CrossIndex``.  When
            *index* is already a ``PafAlignment`` this argument is ignored.
            When ``None`` (default), k-mer matches from *index* are used.
        """
        self.index = index
        # When a PafAlignment is passed as the primary index, use it for
        # rendering alignment segments (the explicit paf_alignment kwarg is
        # then redundant and is ignored to avoid confusion).
        if isinstance(index, PafAlignment):
            self.paf_alignment: Optional[PafAlignment] = index
        else:
            self.paf_alignment = paf_alignment
        # Per-plot capture of drawn match segments for HTML report output.
        # ``None`` when inactive; a dict (panels/ncols/counter/current) while
        # :meth:`plot` is rendering to an ``.html`` destination.
        self._html_capture: Optional[dict] = None

    def _index_is_paf(self) -> bool:
        """Return ``True`` when *index* is a :class:`~dot_explorer.paf_io.PafAlignment`.

        Helper used by :meth:`_plot_panel` to decide whether to draw from PAF
        records or from the k-mer engine.
        """
        return isinstance(self.index, PafAlignment)

    def _index_is_cross(self) -> bool:
        """Return ``True`` when *index* is a :class:`~dot_explorer.paf_io.CrossIndex`."""
        from dot_explorer.paf_io import CrossIndex

        return isinstance(self.index, CrossIndex)

    @staticmethod
    def _strip_group_prefix(name: str) -> str:
        """Strip the ``'group:'`` prefix from a CrossIndex internal name.

        For a ``SequenceIndex`` or ``PafAlignment`` name that contains no
        ``':'``, the name is returned unchanged.

        Parameters
        ----------
        name : str
            Sequence name, possibly in ``'group:name'`` format.

        Returns
        -------
        str
            The un-prefixed name.
        """
        _, sep, suffix = name.partition(':')
        return suffix if sep else name

    def _get_paf_override(
        self,
        query_group: Optional[str],
        target_group: Optional[str],
    ) -> Optional['PafAlignment']:
        """Return a :class:`PafAlignment` built from pre-computed CrossIndex records.

        Parameters
        ----------
        query_group : str or None
        target_group : str or None

        Returns
        -------
        PafAlignment or None
            ``None`` when no pre-computed records exist for the pair or when
            either group is ``None``.
        """
        if query_group is None or target_group is None or not self._index_is_cross():
            return None
        cross = self.index  # type: ignore[assignment]
        pair = (query_group, target_group)
        if pair in cross.computed_group_pairs:
            records = cross.get_records_for_pair(query_group, target_group)
            paf = PafAlignment(records)
            _log.debug(
                'DotPlotter: using %d pre-computed record(s) from pair %r',
                len(records),
                pair,
            )
            return paf
        _log.debug(
            'DotPlotter: no pre-computed records for pair %r; '
            'will compute k-mer matches on demand',
            pair,
        )
        return None

    def _resolve_group_names(
        self,
        query_group: Optional[str],
        target_group: Optional[str],
        query_names: Optional[list[str]],
        target_names: Optional[list[str]],
    ) -> tuple[Optional[list[str]], Optional[list[str]], Optional['PafAlignment']]:
        """Resolve sequence name lists and an optional cached PAF alignment.

        When *query_group* / *target_group* are provided and *index* is a
        :class:`~dot_explorer.paf_io.CrossIndex`:

        * Query and target name lists are populated from the group's internal
          (``'group:name'``) identifiers, overriding any explicitly provided
          *query_names* / *target_names*.
        * If the group pair has pre-computed matches (via
          :meth:`~dot_explorer.paf_io.CrossIndex.compute_matches`), a
          :class:`~dot_explorer.paf_io.PafAlignment` is built from those records
          and returned so that :meth:`_plot_panel` can use them directly.

        Parameters
        ----------
        query_group : str or None
            Group label for query sequences.
        target_group : str or None
            Group label for target sequences.
        query_names : list[str] or None
            Caller-supplied query names (used when groups are not provided).
        target_names : list[str] or None
            Caller-supplied target names.

        Returns
        -------
        tuple of (list[str] | None, list[str] | None, PafAlignment | None)
            Resolved ``(query_names, target_names, paf_override)``.
            *paf_override* is ``None`` when no pre-computed records are found.

        Raises
        ------
        ValueError
            If *query_group* / *target_group* are provided but *index* is not
            a ``CrossIndex``.
        """
        if query_group is None and target_group is None:
            return query_names, target_names, None

        if not self._index_is_cross():
            raise ValueError(
                'query_group and target_group can only be used when index is '
                'a CrossIndex.'
            )

        cross = self.index  # type: ignore[assignment]

        if query_group is not None:
            query_names = cross.sequence_names(group=query_group)
        if target_group is not None:
            target_names = cross.sequence_names(group=target_group)

        paf_override = self._get_paf_override(query_group, target_group)
        return query_names, target_names, paf_override

    def _apply_contig_order(
        self,
        contig_order: Optional[str],
        query_group: Optional[str],
        target_group: Optional[str],
        query_names: Optional[list[str]],
        target_names: Optional[list[str]],
    ) -> tuple[Optional[list[str]], Optional[list[str]], Optional[set[str]]]:
        """Apply a plot-time contig-ordering strategy before name resolution.

        Reuses the existing reorder machinery on the underlying index:
        :meth:`~dot_explorer.paf_io.CrossIndex.reorder_by_length` /
        :meth:`~dot_explorer.paf_io.CrossIndex.reorder_for_colinearity` for a
        ``CrossIndex``, :meth:`~dot_explorer.paf_io.PafAlignment.reorder_contigs`
        for a ``PafAlignment``, and
        :meth:`~dot_explorer.SequenceIndex.optimal_contig_order` for a bare
        ``SequenceIndex``.  Explicitly supplied *query_names* /
        *target_names* take precedence: an axis whose name list was given by
        the caller is returned unchanged.

        Parameters
        ----------
        contig_order : str or None
            Ordering strategy: ``'length'`` (descending sequence length),
            ``'colinearity'`` (d-genies gravity ordering), or ``None``
            (no reordering).
        query_group : str or None
            Group label for query sequences (``CrossIndex`` only).
        target_group : str or None
            Group label for target sequences (``CrossIndex`` only).
        query_names : list[str] or None
            Caller-supplied query names, returned unchanged when not ``None``.
        target_names : list[str] or None
            Caller-supplied target names, returned unchanged when not ``None``.

        Returns
        -------
        tuple of (list[str] | None, list[str] | None, set[str] | None)
            ``(query_names, target_names, auto_reverse_set)``.  The name lists
            are ``None`` when group-based resolution (or the default
            all-sequences path) should proceed unchanged;
            *auto_reverse_set* holds the un-prefixed reverse-oriented query
            contigs detected by a ``'colinearity'`` reorder, or ``None`` when
            orientation information is unavailable.

        Raises
        ------
        ValueError
            If *contig_order* is not one of ``'length'``, ``'colinearity'``
            or ``None``, or if ``'colinearity'`` is requested on a
            ``CrossIndex`` without explicit groups when the index does not
            hold exactly two groups.
        """
        valid = ('length', 'colinearity')
        if contig_order is None:
            return query_names, target_names, None
        if contig_order not in valid:
            raise ValueError(
                f'Invalid contig_order {contig_order!r}; '
                f'valid options are {valid!r} or None.'
            )

        auto_rev: Optional[set[str]] = None

        if isinstance(self.index, CrossIndex):
            cross = self.index
            qg, tg = query_group, target_group
            if contig_order == 'length':
                if qg is None and tg is None:
                    cross.reorder_by_length()
                else:
                    for g in {qg, tg} - {None}:
                        cross.reorder_by_length(group=g)
            else:  # colinearity
                if qg is None or tg is None:
                    groups = cross.group_names
                    if len(groups) != 2:
                        raise ValueError(
                            "contig_order='colinearity' on a CrossIndex "
                            'requires query_group and target_group when the '
                            f'index does not hold exactly two groups; found '
                            f'{len(groups)} group(s): {groups!r}.'
                        )
                    if qg is None and tg is None:
                        qg, tg = groups[0], groups[1]
                    elif qg is None:
                        # Infer the query as the other of the two groups.
                        qg = next(g for g in groups if g != tg)
                    else:
                        tg = next(g for g in groups if g != qg)
                assert qg is not None and tg is not None
                if (qg, tg) not in cross.computed_group_pairs:
                    cross.compute_matches(query_group=qg, target_group=tg)
                cross.reorder_for_colinearity(qg, tg)
                auto_rev = cross.reversed_contigs(qg)
                # Groups were auto-detected: resolve names here so the new
                # order takes effect even without explicit group arguments.
                if query_group is None and query_names is None:
                    query_names = cross.sequence_names(group=qg)
                if target_group is None and target_names is None:
                    target_names = cross.sequence_names(group=tg)
            # When groups were supplied, _resolve_group_names picks up the
            # freshly reordered group membership; for 'length' without groups
            # fall back to the full (reordered) name list.
            if contig_order == 'length':
                if query_group is None and query_names is None:
                    query_names = cross.sequence_names()
                if target_group is None and target_names is None:
                    target_names = cross.sequence_names()
            return query_names, target_names, auto_rev

        all_names = self.index.sequence_names()
        if contig_order == 'length':
            by_length = sorted(
                all_names,
                key=self.index.get_sequence_length,
                reverse=True,
            )
            if query_names is None:
                query_names = list(by_length)
            if target_names is None:
                target_names = list(by_length)
            return query_names, target_names, None

        # colinearity on PafAlignment / SequenceIndex.
        q_in = query_names if query_names is not None else sorted(all_names)
        t_in = target_names if target_names is not None else sorted(all_names)
        if isinstance(self.index, PafAlignment):
            sorted_q, sorted_t = self.index.reorder_contigs(
                query_names=q_in, target_names=t_in
            )
            auto_rev = self.index.reversed_contigs
        else:
            assert isinstance(self.index, SequenceIndex)
            sorted_q, sorted_t = self.index.optimal_contig_order(q_in, t_in)
        if query_names is None:
            query_names = sorted_q
        if target_names is None:
            target_names = sorted_t
        return query_names, target_names, auto_rev

    def plot(
        self,
        query_names: Optional[list[str]] = None,
        target_names: Optional[list[str]] = None,
        query_group: Optional[str] = None,
        target_group: Optional[str] = None,
        output_path: Optional[Union[str, Path]] = None,
        figsize_per_panel: float = 4.0,
        dot_size: float = 0.5,
        cap_style: str = 'projecting',
        dot_color: str = 'blue',
        rc_color: str = 'red',
        merge: bool = True,
        title: Optional[str] = None,
        dpi: int = 150,
        scale_sequences: bool = True,
        format: Optional[str] = None,
        min_length: int = 0,
        color_by_identity: bool = False,
        identity_palette: str = 'viridis',
        annotation: Optional['GffAnnotation'] = None,
        annotation_query: Optional['GffAnnotation'] = None,
        annotation_target: Optional['GffAnnotation'] = None,
        annotation_tracks: bool = False,
        annotation_track_size: float = 0.6,
        annotation_legend: bool = True,
        chain_gap: int = 0,
        rasterized: Union[bool, str] = 'auto',
        rasterization_threshold: int = 50_000,
        reverse_contigs: Optional[set[str]] = None,
        reverse_targets: Optional[set[str]] = None,
        contig_order: Optional[str] = None,
        auto_reverse: bool = False,
        hide_internal_axes: bool = False,
        identity_colorbar: bool = False,
        highlight_regions: Optional[list[dict]] = None,
        embed_sequences: bool = False,
        tree: Optional['Tree'] = None,
        tree_width: float = 1.6,
        tree_cutoff: Optional[float] = None,
        tree_scalebar: bool = True,
        cluster_borders: Optional['ClusterResult'] = None,
        cluster_border_color: str = 'black',
        cluster_border_lw: float = 2.5,
    ) -> matplotlib.figure.Figure:
        """Plot an all-vs-all dotplot grid.

        If both ``query_names`` and ``target_names`` are provided, the plot
        will show each query sequence (rows) against each target sequence
        (columns). If only one set is provided, or neither, all pairwise
        combinations within the available sequences are plotted.

        When *index* is a :class:`~dot_explorer.paf_io.CrossIndex`, use
        *query_group* and *target_group* to specify which groups supply the
        query and target sequences.  The corresponding internal
        (``'group:name'``) identifiers are looked up automatically and used
        for sequence-length queries and k-mer comparisons.  If
        :meth:`~dot_explorer.paf_io.CrossIndex.compute_matches` has already been
        called for that pair, the pre-computed merged alignments are used for
        rendering rather than recomputing on the fly.

        The figure is always returned so it can be displayed inline in a
        Jupyter notebook.  When ``output_path`` is provided the figure is
        also saved to disk.

        Parameters
        ----------
        query_names : list[str], optional
            Sequence names for the y-axis (rows). If ``None``, uses all
            sequences in the index.  Ignored when *query_group* is provided
            and *index* is a ``CrossIndex``.
        target_names : list[str], optional
            Sequence names for the x-axis (columns). If ``None``, uses all
            sequences in the index.  Ignored when *target_group* is provided
            and *index* is a ``CrossIndex``.
        query_group : str or None, optional
            Group label whose sequences are used as query (rows).  When
            provided, the group's sequences are looked up from *index*
            (which must be a ``CrossIndex``) and *query_names* is ignored.
        target_group : str or None, optional
            Group label whose sequences are used as target (columns).  When
            provided, the group's sequences are looked up from *index*
            (which must be a ``CrossIndex``) and *target_names* is ignored.
        output_path : str or Path, optional
            Output image file path.  When ``None`` (default) the figure is
            not saved to disk.  Use a ``.svg`` extension (or set
            ``format='svg'``) to produce an SVG vector image.
        figsize_per_panel : float, optional
            Base size in inches for each subplot panel when
            ``scale_sequences=False``.  When ``scale_sequences=True`` this
            value sets the size of the *longest* sequence axis and all
            other axes are scaled proportionally.  Default is ``4.0``.
        dot_size : float, optional
            Size of each dot in the scatter plot. Default is ``0.5``.
        cap_style : {'butt', 'round', 'projecting'}, optional
            Line cap for match segments.  ``'projecting'`` (the default) and
            ``'round'`` extend the stroke past each endpoint by half the line
            width, so a match shorter than *dot_size* still reads as a mark on
            its own diagonal; with ``'butt'`` such a match is drawn wider
            across the diagonal than along it and appears rotated 90 degrees.
        dot_color : str, optional
            Colour for forward-strand (``+``) match lines. Default is ``"blue"``.
        rc_color : str, optional
            Colour for reverse-complement (``-``) strand match lines.
            Default is ``"red"``.
        merge : bool, optional
            Whether to merge sequential k-mer runs before plotting.
            Default is ``True``.
        title : str, optional
            Overall figure title. If ``None``, no title is added.
        dpi : int, optional
            Resolution of the output image. Default is ``150``.  For vector
            formats this only affects any rasterised match layer (see
            *rasterized*); axes and labels remain resolution-independent.  Raise
            it (e.g. ``dpi=300``) for a higher-resolution raster (PNG) figure.
        scale_sequences : bool, optional
            When ``True`` (default), subplot widths and heights are
            proportional to the lengths of the corresponding sequences so that
            relative sequence sizes are preserved.  When ``False``, every
            panel has the same fixed size.
        format : str, optional
            Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
            When ``None`` (default), the format is inferred from the
            ``output_path`` file extension.
        min_length : int, optional
            Minimum alignment length to display.  Matches shorter than this
            value are not drawn.  Applies to merged k-mer runs and pre-computed
            PAF alignments.  Default is ``0`` (no filtering).
        color_by_identity : bool, optional
            When ``True``, alignments are coloured by sequence identity using
            the *identity_palette* colormap.  Requires a
            :class:`~dot_explorer.paf_io.PafAlignment` to be supplied as
            ``paf_alignment`` to :meth:`__init__`; if no PAF alignment is
            available a warning is logged and the default strand colours are
            used instead.  Default is ``False``.
        identity_palette : str, optional
            Matplotlib colormap name used to map identity values (0–1) to
            colours when ``color_by_identity=True``.  Default is
            ``'viridis'``.
        annotation : GffAnnotation, optional
            Feature annotations to overlay on self-vs-self diagonal panels.
            Each feature is drawn as a transparent coloured square at its
            genomic position, behind the alignment segments (mirrored along
            whichever axes display the contig reverse-complemented).  Sequence names in
            *annotation* that are absent from the index emit a warning.
            Also used as the fallback source for side tracks when
            *annotation_query* / *annotation_target* are not given.
            Default is ``None``.
        annotation_query : GffAnnotation, optional
            Features for the query (y) axis side track.  Default ``None``.
        annotation_target : GffAnnotation, optional
            Features for the target (x) axis side track.  Default ``None``.
        highlight_regions : list[dict], optional
            Bands to shade behind the matches, each
            ``{'axis': 'x'|'y', 'seqname': str, 'start': int, 'end': int,
            'color': str}``.  ``'x'`` shades a column of the panels whose
            target is *seqname*, ``'y'`` a row of those whose query is.
            Coordinates are 0-based half-open in the sequence's own
            orientation and are mirrored here for reverse-displayed
            contigs, so a caller passes feature coordinates and gets the
            band where the feature is drawn.  Used to carry the
            interactive report's feature highlights into a saved figure.
        annotation_tracks : bool, optional
            Draw side annotation tracks (left of the y axis and below the x
            axis) with lane-packed feature shapes, strand arrows for
            gene/mRNA/exon/CDS/ORF features and connector lines joining
            multi-part groups.  Honoured only for **single-pair** (1×1)
            plots — the focused drill-down view — where the tracks have
            room to read; multi-panel grids draw diagonal squares only.
            Default is ``False``.
        annotation_track_size : float, optional
            Side-track thickness in inches.  Default is ``0.6``.
        annotation_legend : bool, optional
            Add a feature-type colour legend to the figure whenever
            annotation features are drawn.  Default is ``True``.
        chain_gap : int, optional
            When greater than ``0``, co-linear match blocks on the same diagonal
            separated by up to *chain_gap* bp are chained into single lines
            before drawing, greatly reducing the number of segments (and thus
            render time and file size) for dense plots.  Default is ``0`` (off).
        rasterized : bool or str, optional
            Whether to rasterise the match layer.  ``'auto'`` (default) keeps it
            true vector — infinitely zoomable in SVG/PDF — until a panel's
            segment count exceeds *rasterization_threshold*, above which that
            layer is rasterised at *dpi* to bound file size.  ``True`` / ``False``
            force the behaviour.  Axes, ticks and labels always stay vector.
        rasterization_threshold : int, optional
            Segment count per strand/panel above which ``rasterized='auto'``
            rasterises the layer.  Default is ``50_000``.
        reverse_contigs : set[str] or None, optional
            Un-prefixed query (row) contig names to render reverse-complemented
            so reverse-oriented contigs read along the main diagonal.  When
            ``None`` (default) the set is pulled automatically from the index:
            :meth:`~dot_explorer.paf_io.CrossIndex.reversed_contigs` for the
            *query_group* of a ``CrossIndex``, or
            :attr:`~dot_explorer.paf_io.PafAlignment.reversed_contigs` for a
            ``PafAlignment`` (both populated by a prior ``reorder`` call).  Pass
            an explicit set (including ``set()`` to disable) to override.
        reverse_targets : set[str] or None, optional
            Un-prefixed target (column) contig names to render
            reverse-complemented on the x axis: target coordinates are
            mirrored (``t → t_len - t``) and the strand flag flipped.  Never
            pulled from the index — ``None`` (default) and ``set()`` both mean
            no target mirroring.  A panel whose query *and* target are both
            mirrored (a self-comparison of a flipped contig) flips the
            strand twice, so colours are unchanged and only coordinates
            move: the contig's self-diagonal stays a forward diagonal.
        contig_order : str or None, optional
            Contig ordering applied before plotting.  ``'length'`` sorts
            contigs by descending sequence length
            (:meth:`~dot_explorer.paf_io.CrossIndex.reorder_by_length` for a
            ``CrossIndex``, otherwise a plain length sort of the resolved name
            lists).  ``'colinearity'`` applies the d-genies gravity ordering
            (:meth:`~dot_explorer.paf_io.CrossIndex.reorder_for_colinearity`,
            computing matches first if needed;
            :meth:`~dot_explorer.paf_io.PafAlignment.reorder_contigs` for a
            ``PafAlignment``;
            :meth:`~dot_explorer.SequenceIndex.optimal_contig_order` for a bare
            ``SequenceIndex``).  Explicit *query_names* / *target_names*
            arguments take precedence: an axis whose names were supplied by
            the caller keeps the caller's order.  ``'colinearity'`` computes
            CrossIndex matches on demand when needed; the cached records
            cover both strands, so subsequent rendering from the cache shows
            reverse-strand alignments too.  An invalid value raises
            :exc:`ValueError`.  Default is ``None`` (no reordering).
        auto_reverse : bool, optional
            When ``True``, reverse-oriented query contigs detected by the
            *contig_order* reorder (via
            :meth:`~dot_explorer.paf_io.CrossIndex.reversed_contigs` or
            :attr:`~dot_explorer.paf_io.PafAlignment.reversed_contigs`) are fed
            into the *reverse_contigs* rendering path so they read along the
            main diagonal.  An explicit *reverse_contigs* argument wins when
            both are given.  Only ``contig_order='colinearity'`` yields
            orientation information; otherwise this option has no effect.
            Default is ``False``.
        hide_internal_axes : bool, optional
            When ``True``, internal panel boundaries are removed so the grid
            reads as one continuous plot: inter-panel gaps collapse to zero,
            ticks and spines shared between adjacent panels are hidden, and
            only the outer frame with its tick labels remains.  Default is
            ``False``.
        identity_colorbar : bool, optional
            When ``True`` and *color_by_identity* is on, append a vertical
            identity colour key (0-100 %) at the right of the figure.
            Ignored without *color_by_identity*.  Default is ``False``.
        embed_sequences : bool, optional
            HTML output only: when ``True``, embed the matched query
            subsequences in the report so its sequence preview / copy
            buttons work in a standalone file (requires an index that
            stores sequences and at most ~2 Mb of total match residues).
            Default is ``False`` — coordinates only, keeping reports small
            even with many alignments.
        tree : Tree, optional
            A :class:`dot_explorer.Tree` (user newick or
            :meth:`~dot_explorer.Tree.from_linkage`) drawn left of the rows.
            The row order is fixed to the tree's leaf order (and the
            column order too when the plot is a self-comparison), so
            *contig_order* and *auto_reverse* cannot be combined with a
            tree.  Tip labels must match the query sequence names; row
            name labels move onto the tree tips so they never obscure it.
            Requires at least 2 rows (grid layouts only).
        tree_width : float, optional
            Width of the tree gutter in inches (floored at 18% of the
            panel-grid width so wide grids do not squash the dendrogram).
            Increase it when long sequence names crowd the tree.
            Default is ``1.6``.
        tree_cutoff : float, optional
            Draw a dashed clustering-cutoff line through the tree at this
            distance from the tips (for linkage trees, ``1 - similarity
            cutoff``).  Default is ``None`` (no line).
        tree_scalebar : bool, optional
            Show a branch-length scale bar under the tree.  Default is
            ``True``.
        cluster_borders : ClusterResult, optional
            Cluster assignments (:func:`dot_explorer.assign_clusters` or
            :func:`dot_explorer.assign_clusters_dual`); each cluster's block
            of panels gets a bold border.  Only meaningful for
            self-comparisons, where rows and columns share an order; a
            cluster that is not contiguous in the display order is
            outlined per contiguous block.
        cluster_border_color : str, optional
            Cluster border colour.  Default is ``'black'``.
        cluster_border_lw : float, optional
            Cluster border line width.  Default is ``2.5``.

        Returns
        -------
        matplotlib.figure.Figure
            The generated figure.  In a Jupyter notebook the figure is
            displayed inline automatically; call ``matplotlib.pyplot.close``
            on the returned object when it is no longer needed.

        Raises
        ------
        ValueError
            If *query_group* / *target_group* are provided but *index* is
            not a ``CrossIndex``.
        """
        if tree is not None:
            # A tree fixes the row order outright; the reordering and
            # reorientation strategies would silently fight it.
            if contig_order is not None:
                raise ValueError('tree fixes the contig order; remove contig_order')
            if auto_reverse:
                raise ValueError(
                    'auto_reverse reorients contigs independently of the '
                    'tree; remove auto_reverse when passing a tree'
                )

        # Apply the requested contig-ordering strategy (no-op when None).
        query_names, target_names, auto_reverse_set = self._apply_contig_order(
            contig_order, query_group, target_group, query_names, target_names
        )
        if auto_reverse and reverse_contigs is None and auto_reverse_set is not None:
            # Feed detected reverse-oriented contigs into the existing
            # reverse_contigs rendering path (explicit argument wins).
            reverse_contigs = auto_reverse_set

        # Resolve group names and optional pre-computed PAF records.
        query_names, target_names, paf_override = self._resolve_group_names(
            query_group, target_group, query_names, target_names
        )

        cap_style = _resolve_cap_style(cap_style)

        all_names = self.index.sequence_names()
        if not all_names:
            raise ValueError('No sequences in the index.')

        if query_names is None:
            query_names = sorted(all_names)
        if target_names is None:
            target_names = sorted(all_names)

        if tree is not None:
            # Fix the row order to the tree's leaf order.  Tip labels use
            # display names; map back to any group-prefixed internal names.
            if len(query_names) < 2:
                raise ValueError(
                    'a tree needs at least 2 query sequences (grid layouts '
                    'only, not the single-pair view)'
                )
            display_to_query = {self._strip_group_prefix(n): n for n in query_names}
            tree.validate_labels(list(display_to_query))
            query_names = [display_to_query[n] for n in tree.leaf_names()]
            display_to_target = {self._strip_group_prefix(n): n for n in target_names}
            if set(display_to_target) == set(display_to_query):
                # Self-comparison: keep the matrix symmetric by applying
                # the same order to the columns.
                target_names = [display_to_target[n] for n in tree.leaf_names()]

        # Use the per-call override if available, otherwise fall back to the
        # paf_alignment set at construction time.
        effective_paf = paf_override if paf_override is not None else self.paf_alignment

        # Resolve the set of reverse-oriented query contigs (un-prefixed names).
        # An explicit argument wins; otherwise auto-pull from the index.
        if reverse_contigs is not None:
            reverse_set = set(reverse_contigs)
        elif isinstance(self.index, CrossIndex) and query_group is not None:
            reverse_set = self.index.reversed_contigs(query_group)
        elif isinstance(self.index, PafAlignment):
            reverse_set = set(self.index.reversed_contigs)
        else:
            reverse_set = set()
        reverse_target_set: set[str] = (
            set(reverse_targets) if reverse_targets else set()
        )

        # Warn about annotation sequences missing from the index (compare by
        # display name — annotation files use raw contig names, while a
        # CrossIndex stores group-prefixed internal names).
        if annotation is not None:
            index_seqs = {self._strip_group_prefix(n) for n in all_names}
            for ann_seq in annotation.sequence_names():
                if ann_seq not in index_seqs:
                    _log.warning(
                        'Annotation contains features for sequence %r which is '
                        'not present in the index. These features will not be '
                        'plotted.',
                        ann_seq,
                    )

        nrows = len(query_names)
        ncols = len(target_names)

        # Side tracks draw from the per-axis annotations, falling back to
        # the shared *annotation*.  They are honoured only for single-pair
        # plots (the focused drill-down view) — in an N×M grid a per-row/
        # column track would be unreadable, so grids get diagonal squares.
        track_ann_q = annotation_query if annotation_query is not None else annotation
        track_ann_t = annotation_target if annotation_target is not None else annotation
        tracks_on = (
            annotation_tracks
            and nrows == 1
            and ncols == 1
            and (track_ann_q is not None or track_ann_t is not None)
        )

        flush_kw: dict[str, float] = (
            {'wspace': 0.0, 'hspace': 0.0} if hide_internal_axes else {}
        )

        # Activate per-panel segment capture when the destination is an HTML
        # report so _plot_panel / the draw helpers can gid-tag artists and
        # record the exact segments they draw.  Reset unconditionally first so
        # a previous failed plot cannot leak stale capture state.
        self._html_capture = None
        if output_path is not None and self._is_html_output(output_path, format):
            self._html_capture = {
                'panels': {},
                'ncols': ncols,
                'counter': 0,
                'current': None,
            }

        y_track_ax = None
        x_track_ax = None
        tree_ax = None
        if tracks_on:
            # Single-pair layout with side annotation tracks: a 2×2 gridspec
            # (mirroring plot_single) — y-track left of the main panel,
            # x-track below it, empty corner.  The main panel keeps the
            # sequences' aspect ratio (like the grid layout does).
            if scale_sequences:
                q_len_bp = self.index.get_sequence_length(query_names[0])
                t_len_bp = self.index.get_sequence_length(target_names[0])
                max_len = max(q_len_bp, t_len_bp, 1)
                fig_w = figsize_per_panel * (t_len_bp / max_len)
                fig_h = figsize_per_panel * (q_len_bp / max_len)
            else:
                fig_w = fig_h = figsize_per_panel
            ts = annotation_track_size
            fig = plt.figure(figsize=(fig_w + ts, fig_h + ts))
            gs = fig.add_gridspec(
                2,
                2,
                width_ratios=[ts, fig_w],
                height_ratios=[fig_h, ts],
                hspace=0.03,
                wspace=0.03,
            )
            main_ax = fig.add_subplot(gs[0, 1])
            if track_ann_q is not None:
                y_track_ax = fig.add_subplot(gs[0, 0], sharey=main_ax)
            if track_ann_t is not None:
                x_track_ax = fig.add_subplot(gs[1, 1], sharex=main_ax)
            axes = [[main_ax]]
        elif scale_sequences:
            q_lens = [self.index.get_sequence_length(n) for n in query_names]
            t_lens = [self.index.get_sequence_length(n) for n in target_names]
            max_len = max(max(q_lens), max(t_lens), 1)
            col_widths = [figsize_per_panel * (seq_len / max_len) for seq_len in t_lens]
            row_heights = [
                figsize_per_panel * (seq_len / max_len) for seq_len in q_lens
            ]
            fig_w = sum(col_widths)
            fig_h = sum(row_heights)
            if tree is not None:
                fig, axes, tree_ax = self._make_tree_grid(
                    fig_w, fig_h, col_widths, row_heights, flush_kw, tree_width
                )
            else:
                fig, axes = plt.subplots(
                    nrows,
                    ncols,
                    figsize=(fig_w, fig_h),
                    squeeze=False,
                    gridspec_kw={
                        'width_ratios': col_widths,
                        'height_ratios': row_heights,
                        **flush_kw,
                    },
                )
        else:
            fig_w = figsize_per_panel * ncols
            fig_h = figsize_per_panel * nrows
            if tree is not None:
                fig, axes, tree_ax = self._make_tree_grid(
                    fig_w,
                    fig_h,
                    [figsize_per_panel] * ncols,
                    [figsize_per_panel] * nrows,
                    flush_kw,
                    tree_width,
                )
            else:
                fig, axes = plt.subplots(
                    nrows,
                    ncols,
                    figsize=(fig_w, fig_h),
                    squeeze=False,
                    gridspec_kw=flush_kw if hide_internal_axes else None,
                )

        for row_idx, q_name in enumerate(query_names):
            for col_idx, t_name in enumerate(target_names):
                ax = axes[row_idx][col_idx]
                self._plot_panel(
                    ax,
                    q_name,
                    t_name,
                    dot_size=dot_size,
                    cap_style=cap_style,
                    dot_color=dot_color,
                    rc_color=rc_color,
                    merge=merge,
                    min_length=min_length,
                    # Sequence name labels: y-label on leftmost column only;
                    # column (x) labels are shown as titles on the top row.
                    # With a tree the names live on the tree tips instead,
                    # so panel row labels would double up and crowd it.
                    show_xlabel=False,
                    show_ylabel=(col_idx == 0 and tree is None),
                    color_by_identity=color_by_identity,
                    identity_palette=identity_palette,
                    paf_alignment_override=effective_paf,
                    chain_gap=chain_gap,
                    rasterized=rasterized,
                    rasterization_threshold=rasterization_threshold,
                    reverse_query=self._strip_group_prefix(q_name) in reverse_set,
                    reverse_target=self._strip_group_prefix(t_name)
                    in reverse_target_set,
                )

                # Row label rotation: a vertical (90 deg) contig name is as
                # tall as it is long, so on a grid of unequal contigs the
                # short rows' labels overflow their panels and smear over
                # each other.  Angling them to match the column titles cuts
                # the vertical extent and keeps them legible.  Only needed
                # when there is more than one row to collide with.
                if col_idx == 0 and nrows > 1:
                    label = ax.yaxis.label
                    label.set_rotation(_ROW_LABEL_ROTATION)
                    label.set_ha('right')
                    label.set_va('center')
                    label.set_rotation_mode('anchor')

                # Column label at top of each column (top row only), rotated.
                # Use the display name (strip group prefix for CrossIndex).
                # Focused single-pair views label the axes instead (below).
                if row_idx == 0 and not (nrows == 1 and ncols == 1):
                    ax.set_title(
                        self._strip_group_prefix(t_name),
                        fontsize=8,
                        rotation=45,
                        ha='left',
                        va='bottom',
                    )

                # Suppress redundant tick labels on internal panels.
                if row_idx < nrows - 1:
                    ax.tick_params(axis='x', labelbottom=False)
                if col_idx > 0:
                    ax.tick_params(axis='y', labelleft=False)

                # Remove internal ticks and spines so the grid reads as one
                # continuous plot, keeping the outer frame intact.
                if hide_internal_axes:
                    if row_idx < nrows - 1:
                        ax.tick_params(axis='x', bottom=False)
                        ax.spines['bottom'].set_visible(False)
                    if row_idx > 0:
                        ax.spines['top'].set_visible(False)
                    if col_idx > 0:
                        ax.tick_params(axis='y', left=False)
                        ax.spines['left'].set_visible(False)
                    if col_idx < ncols - 1:
                        ax.spines['right'].set_visible(False)

                # Feature highlight bands, behind everything else in the
                # panel so matches and annotation squares stay readable
                # through them.
                if highlight_regions:
                    self._draw_highlight_bands(
                        ax,
                        highlight_regions,
                        q_name=q_name,
                        t_name=t_name,
                        reverse_set=reverse_set,
                        reverse_target_set=reverse_target_set,
                    )

                # Annotation squares on self-vs-self (diagonal) panels,
                # drawn behind the alignments and mirrored with the axis.
                # A CrossIndex self-comparison stores the same sequence
                # under two group prefixes ('query:c1' vs 'target:c1'), so
                # also treat equal display names as self when the lengths
                # match (two *different* assemblies sharing a contig name
                # will almost never share its exact length too).
                is_self_panel = q_name == t_name or (
                    self._strip_group_prefix(q_name) == self._strip_group_prefix(t_name)
                    and self.index.get_sequence_length(q_name)
                    == self.index.get_sequence_length(t_name)
                )
                if annotation is not None and is_self_panel:
                    reverse = self._strip_group_prefix(q_name) in reverse_set
                    reverse_x = self._strip_group_prefix(t_name) in reverse_target_set
                    annot_gid = (
                        f'de-annot-{row_idx}-{col_idx}'
                        if self._html_capture is not None
                        else None
                    )
                    drawn = self._draw_annotation_squares(
                        ax,
                        q_name,
                        annotation,
                        reverse=reverse,
                        reverse_x=reverse_x,
                        gid=annot_gid,
                    )
                    if self._html_capture is not None and drawn:
                        panel = self._html_capture['panels'][
                            f'de-panel-{row_idx}-{col_idx}'
                        ]
                        # One entry per patch, in draw order — the report JS
                        # maps SVG children back by index.
                        panel['annotations'] = [
                            {
                                'type': f.feature_type,
                                'seqname': f.seqname,
                                'start': int(f.start),
                                'end': int(f.end),
                                'strand': f.strand,
                                'id': f.feature_id,
                                'parent': f.parent,
                                'name': f.name,
                                'source': f.source,
                            }
                            for f in drawn
                        ]

        # Focused single-pair views: enforce exact bp-per-inch parity on
        # both axes.  The proportional figsize only approximates it (axis
        # labels and titles skew the final axes box slightly).  Shared track
        # axes forbid box-adjustable aspect, so the tracks layout relies on
        # its proportional gridspec instead.
        if nrows == 1 and ncols == 1 and scale_sequences and not tracks_on:
            axes[0][0].set_aspect('equal', adjustable='box')

        # Side annotation tracks (single-pair layout only).
        drew_track_features = False
        if tracks_on:
            main_ax = axes[0][0]
            q_name = query_names[0]
            t_name = target_names[0]
            # Only the interactive report needs gids and a feature payload;
            # static output keeps the untagged artists it always had.
            capturing = self._html_capture is not None
            track_records: dict[str, list] = {'x': [], 'y': []}
            if y_track_ax is not None:
                lanes = draw_track(
                    y_track_ax,
                    track_ann_q,
                    self._strip_group_prefix(q_name),
                    self.index.get_sequence_length(q_name),
                    orientation='y',
                    reverse=self._strip_group_prefix(q_name) in reverse_set,
                    gid_prefix='de-ytrack' if capturing else None,
                    record_into=track_records['y'] if capturing else None,
                )
                drew_track_features = drew_track_features or lanes > 0
                # The y track owns the left edge: move the panel's tick
                # labels out of its way.
                main_ax.tick_params(axis='y', labelleft=False)
            if x_track_ax is not None:
                lanes = draw_track(
                    x_track_ax,
                    track_ann_t,
                    self._strip_group_prefix(t_name),
                    self.index.get_sequence_length(t_name),
                    orientation='x',
                    reverse=self._strip_group_prefix(t_name) in reverse_target_set,
                    gid_prefix='de-xtrack' if capturing else None,
                    record_into=track_records['x'] if capturing else None,
                )
                drew_track_features = drew_track_features or lanes > 0
                main_ax.tick_params(axis='x', labelbottom=False)
            if capturing:
                self._html_capture['tracks'] = track_records

        # Focused single-pair views: contig names become conventional axis
        # labels — left of the y axis, below the x axis — and ticks read in
        # bp/Kbp/Mbp units instead of matplotlib's scientific offset text.
        if nrows == 1 and ncols == 1:
            main_ax = axes[0][0]
            q_name = query_names[0]
            t_name = target_names[0]
            q_len = self.index.get_sequence_length(q_name)
            t_len = self.index.get_sequence_length(t_name)
            x_unit = _apply_bp_units(main_ax.xaxis, t_len)
            y_unit = _apply_bp_units(main_ax.yaxis, q_len)
            x_label = f'{self._strip_group_prefix(t_name)} ({x_unit})'
            y_label = f'{self._strip_group_prefix(q_name)} ({y_unit})'
            # When a side annotation track occupies an axis edge, its outer
            # edge carries the tick labels and the name label pads past the
            # whole band (fixed inches -> points).
            track_pad = annotation_track_size * 72.0 + _TICK_LABEL_PAD_PTS
            if x_track_ax is not None and x_track_ax.axison:
                _apply_bp_units(x_track_ax.xaxis, t_len)
                x_track_ax.tick_params(
                    axis='x', bottom=True, labelbottom=True, labelsize=6
                )
                main_ax.set_xlabel(x_label, fontsize=8, labelpad=track_pad)
            else:
                main_ax.tick_params(axis='x', labelbottom=True)
                main_ax.set_xlabel(x_label, fontsize=8)
            if y_track_ax is not None and y_track_ax.axison:
                _apply_bp_units(y_track_ax.yaxis, q_len)
                y_track_ax.tick_params(axis='y', left=True, labelleft=True, labelsize=6)
                main_ax.set_ylabel(y_label, fontsize=8, labelpad=track_pad)
            else:
                main_ax.tick_params(axis='y', labelleft=True)
                main_ax.set_ylabel(y_label, fontsize=8)
        else:
            # Multi-panel grids: raw-bp tick labels are long enough to
            # overlap along the x axis.  Use one bp/Kbp/Mbp unit across all
            # contigs (chosen from the longest, so positions stay
            # comparable between panels) and angle the x tick labels; the
            # shared unit is announced once per axis via a figure label.
            max_len = max(
                self.index.get_sequence_length(n) for n in (*query_names, *target_names)
            )
            for row in axes:
                for ax in row:
                    _apply_bp_units(ax.xaxis, max_len)
                    _apply_bp_units(ax.yaxis, max_len)
                    plt.setp(
                        ax.get_xticklabels(),
                        rotation=45,
                        ha='right',
                        rotation_mode='anchor',
                    )
            _divisor, grid_unit = _bp_unit(max_len)
            fig.supxlabel(f'Position ({grid_unit})', fontsize=8)
            fig.supylabel(f'Position ({grid_unit})', fontsize=8)

        # Feature-type colour legend whenever annotation features are shown.
        annotations_shown = [
            ann
            for ann in (annotation, annotation_query, annotation_target)
            if ann is not None
        ]
        if (
            annotation_legend
            and annotations_shown
            and (annotation is not None or drew_track_features)
        ):
            handles: dict[str, mpatches.Patch] = {}
            for ann in annotations_shown:
                for handle in annotation_legend_handles(ann):
                    handles.setdefault(handle.get_label(), handle)
            fig.legend(
                handles=list(handles.values()),
                loc='upper left',
                bbox_to_anchor=(1.005, 0.95),
                bbox_transform=fig.transFigure,
                fontsize=8,
                frameon=False,
                title='Features',
                title_fontsize=9,
            )

        if title and not (nrows == 1 and ncols == 1):
            fig.suptitle(title, fontsize=14, y=1.01)

        if nrows == 1 and ncols == 1:
            # Focused single-pair views: reserve absolute (inch) margins for
            # the title and axis labels.  The proportional figure can be a
            # very thin strip for extreme length ratios, so fractional
            # margins (and a figure-fraction suptitle y) collapse to nothing
            # — grow the canvas around the untouched panel region instead.
            # The subplot region keeps exactly its original size, so the
            # panel's bp-per-inch aspect (and the tracks gridspec ratios)
            # are preserved.
            margins = _FOCUS_MARGIN_IN
            top_in = margins['top_title'] if title else margins['top_plain']
            left_in = margins['left']
            panel_w, panel_h = fig.get_size_inches()
            # In the tracks layout the figure includes the fixed track band;
            # the panel itself is the remainder on each dimension.
            eff_w = panel_w - (annotation_track_size if tracks_on else 0.0)
            eff_h = panel_h - (annotation_track_size if tracks_on else 0.0)
            main_ax = axes[0][0]
            # A rotated y label taller than a thin panel would protrude into
            # the title band; render it horizontally instead and widen the
            # left margin to fit the text.
            y_text = main_ax.get_ylabel()
            est_label_in = len(y_text) * 8.0 * 0.62 / 72.0
            if y_text and est_label_in > eff_h:
                label = main_ax.yaxis.label
                label.set_rotation(0)
                label.set_ha('right')
                label.set_va('center')
                left_in = max(left_in, est_label_in + 0.55)
            # Thin panels cannot fit the default tick density without the
            # position labels colliding — scale the tick count to the
            # physical axis length (track axes carry the visible labels in
            # the tracks layout and have their own locators).
            for length_in, span, axis_objs in (
                (
                    eff_h,
                    self.index.get_sequence_length(query_names[0]),
                    [main_ax.yaxis]
                    + ([y_track_ax.yaxis] if y_track_ax is not None else []),
                ),
                (
                    eff_w,
                    self.index.get_sequence_length(target_names[0]),
                    [main_ax.xaxis]
                    + ([x_track_ax.xaxis] if x_track_ax is not None else []),
                ),
            ):
                if length_in < 0.4:
                    # Too thin for more than one label without overlap: a
                    # single end tick states the sequence's full length.
                    locator = mticker.FixedLocator([span])
                elif length_in < 1.5:
                    locator = mticker.MaxNLocator(nbins=max(1, int(length_in * 2.5)))
                else:
                    continue
                for axis_obj in axis_objs:
                    axis_obj.set_major_locator(locator)
            total_w = panel_w + left_in + margins['right']
            total_h = panel_h + top_in + margins['bottom']
            fig.set_size_inches(total_w, total_h)
            fig.subplots_adjust(
                left=left_in / total_w,
                right=1 - margins['right'] / total_w,
                bottom=margins['bottom'] / total_h,
                top=1 - top_in / total_h,
            )
            if title:
                # A fixed physical offset below the canvas top keeps the
                # title clear of the plot area at any figure height.
                fig.suptitle(title, fontsize=14, y=1 - 0.1 / total_h, va='top')
        elif hide_internal_axes:
            # tight_layout() would reinsert inter-panel gaps; keep the panels
            # flush and just leave margins for the outer labels and titles.
            left_frac = 0.1
            if nrows > 1 and query_names:
                # Angled row labels stick out to the left by
                # len * cos(rotation); the fixed 10% is a fraction of figure
                # width, so a wide grid has room to spare and a narrow one
                # clips.  Size it from the longest name instead, plus space
                # for the tick labels and the shared 'Position' label.
                longest = max(len(self._strip_group_prefix(n)) for n in query_names)
                text_in = longest * 8.0 * 0.62 / 72.0
                needed_in = text_in * math.cos(math.radians(_ROW_LABEL_ROTATION))
                fig_w = fig.get_size_inches()[0]
                # Cap at 40% so a pathological name can never squeeze the
                # panels out of existence.
                left_frac = min(0.4, max(left_frac, (needed_in + 0.6) / fig_w))
            fig.subplots_adjust(
                left=left_frac,
                right=0.98,
                bottom=0.06,
                top=0.92,
                wspace=0.0,
                hspace=0.0,
            )
        else:
            plt.tight_layout()
        if nrows > 1 and tree is None:
            # Panel geometry is only final once the margins are set, so the
            # row labels are fitted to their rows here rather than in the
            # drawing loop above.  (With a tree there are no row labels —
            # the names sit on the tree tips.)
            self._fit_row_labels(fig, axes, nrows)
        if color_by_identity and identity_colorbar:
            # After the layout pass: fig.colorbar steals its own space from
            # the panel axes, which tight_layout would otherwise fight.
            sm = matplotlib.cm.ScalarMappable(
                norm=mcolors.Normalize(vmin=0, vmax=1),
                cmap=plt.get_cmap(identity_palette),
            )
            cbar = fig.colorbar(sm, ax=fig.axes, fraction=0.035, pad=0.02, aspect=35)
            cbar.set_label('Identity (%)')
            cbar.set_ticks([0.0, 0.25, 0.5, 0.75, 1.0])
            cbar.set_ticklabels(['0', '25', '50', '75', '100'])
        if nrows == 1 and ncols == 1 and title and fig._suptitle is not None:
            # The colorbar (and any figure legend outside the canvas) shifts
            # the panel off figure-centre; centre the title on the panel, not
            # the figure.  Must run after the colorbar has stolen its width.
            pos = axes[0][0].get_position()
            fig._suptitle.set_x((pos.x0 + pos.x1) / 2)
        # Tree and cluster borders are drawn from the panels' final figure
        # positions, so they must come after every layout adjustment above
        # (nothing may call tight_layout past this point).
        if tree is not None and tree_ax is not None:
            self._draw_axis_tree(
                tree_ax,
                tree,
                axes,
                query_names,
                cutoff=tree_cutoff,
                scalebar=tree_scalebar,
            )
        if cluster_borders is not None:
            self._draw_cluster_borders(
                fig,
                axes,
                query_names,
                target_names,
                cluster_borders,
                color=cluster_border_color,
                lw=cluster_border_lw,
            )
        if output_path is not None:
            self._save_figure(
                fig,
                output_path,
                dpi=dpi,
                format=format,
                title=title,
                embed_sequences=embed_sequences,
            )
        return fig

    def _make_tree_grid(
        self,
        fig_w: float,
        fig_h: float,
        col_widths: list[float],
        row_heights: list[float],
        flush_kw: dict[str, float],
        tree_width: float,
    ) -> tuple[matplotlib.figure.Figure, list[list], matplotlib.axes.Axes]:
        """Build the panel grid with an extra tree-gutter column at the left.

        Mirrors the plain ``plt.subplots`` grids in :meth:`plot`, adding a
        full-height axis spanning all rows for the dendrogram.

        Parameters
        ----------
        fig_w, fig_h : float
            Panel-grid size in inches (the tree gutter is added on top).
        col_widths, row_heights : list of float
            Per-column/row size ratios (uniform or sequence-proportional).
        flush_kw : dict
            ``wspace``/``hspace`` overrides (from *hide_internal_axes*).
        tree_width : float
            Tree gutter width in inches; floored at 18% of the panel-grid
            width so wide grids do not squash the dendrogram.

        Returns
        -------
        tuple
            ``(figure, axes_rows, tree_ax)`` with *axes_rows* indexed as
            ``axes[row][col]`` like the ``plt.subplots`` output.
        """
        nrows = len(row_heights)
        ncols = len(col_widths)
        tree_width = max(tree_width, 0.18 * fig_w)
        # A slim leading margin column keeps the figure-level y label
        # ('Position (unit)', drawn by fig.supylabel at x~0.02) off the
        # dendrogram; nothing is drawn in it.
        margin = 0.35
        fig = plt.figure(figsize=(fig_w + tree_width + margin, fig_h))
        gs = fig.add_gridspec(
            nrows,
            ncols + 2,
            width_ratios=[margin, tree_width, *col_widths],
            height_ratios=row_heights,
            **flush_kw,
        )
        tree_ax = fig.add_subplot(gs[:, 1])
        # Keep the empty gutter invisible to layout passes (tight_layout
        # runs before the tree is drawn into it).
        tree_ax.set_xticks([])
        tree_ax.set_yticks([])
        for spine in tree_ax.spines.values():
            spine.set_visible(False)
        axes = [
            [fig.add_subplot(gs[row, col + 2]) for col in range(ncols)]
            for row in range(nrows)
        ]
        return fig, axes, tree_ax

    def _draw_axis_tree(
        self,
        tree_ax,
        tree: 'Tree',
        axes: list[list],
        query_names: list[str],
        *,
        cutoff: Optional[float],
        scalebar: bool,
    ) -> None:
        """Render *tree* into the gutter axis, tips aligned to panel rows.

        Leaf y positions come from each row's final figure-space centre,
        so alignment holds for any row heights and inter-panel spacing;
        callers must not adjust the layout afterwards.

        Parameters
        ----------
        tree_ax : matplotlib.axes.Axes
            The gutter axis from :meth:`_make_tree_grid`.
        tree : Tree
            Tree whose leaf order matches *query_names*.
        axes : list of list
            The panel grid, indexed ``axes[row][col]``.
        query_names : list of str
            Row sequence names (possibly group-prefixed).
        cutoff : float or None
            Distance-from-tips for the dashed cutoff line.
        scalebar : bool
            Draw the branch-length scale bar.
        """
        from dot_explorer.tree import draw_tree

        # Map the gutter's data space onto figure fractions so panel
        # centres can be used as leaf positions directly.
        pos = tree_ax.get_position()
        tree_ax.set_ylim(pos.y0, pos.y1)
        leaf_pos = {}
        for row_idx, name in enumerate(query_names):
            panel = axes[row_idx][0].get_position()
            leaf_pos[self._strip_group_prefix(name)] = (panel.y0 + panel.y1) / 2
        draw_tree(
            tree_ax,
            tree,
            leaf_pos,
            cutoff=cutoff,
            scalebar=scalebar,
            leaf_labels=True,
        )

    def _draw_cluster_borders(
        self,
        fig,
        axes: list[list],
        query_names: list[str],
        target_names: list[str],
        clusters: 'ClusterResult',
        *,
        color: str,
        lw: float,
    ) -> None:
        """Outline each cluster's block of panels with a bold border.

        Draws figure-coordinate rectangles spanning the panels whose row
        and column contigs belong to the same cluster.  Requires a
        self-comparison (identical row and column order); otherwise a
        warning is logged and nothing is drawn.  Non-contiguous clusters
        are outlined per contiguous run.

        Parameters
        ----------
        fig : matplotlib.figure.Figure
            The figure to draw into.
        axes : list of list
            Panel grid, indexed ``axes[row][col]``.
        query_names, target_names : list of str
            Row and column sequence names (possibly group-prefixed).
        clusters : ClusterResult
            Cluster assignments keyed by display name.
        color : str
            Border colour.
        lw : float
            Border line width.
        """
        from dot_explorer.heatmap import _contiguous_runs

        q_display = [self._strip_group_prefix(n) for n in query_names]
        t_display = [self._strip_group_prefix(n) for n in target_names]
        if q_display != t_display:
            _log.warning(
                'cluster borders need a self-comparison (identical row and '
                'column order); skipping'
            )
            return
        index_of = {name: i for i, name in enumerate(q_display)}
        capture: dict[str, dict[str, list[list[int]]]] = {}
        for cluster_name, members in clusters.clusters.items():
            rows = sorted(index_of[m] for m in members if m in index_of)
            if not rows:
                continue
            runs = _contiguous_runs(rows)
            if len(runs) > 1:
                _log.warning(
                    'cluster %s is not contiguous in the display order; '
                    'outlining %d separate blocks',
                    cluster_name,
                    len(runs),
                )
            for start, stop in runs:
                top = axes[start][0].get_position().y1
                bottom = axes[stop][0].get_position().y0
                left = axes[0][start].get_position().x0
                right = axes[0][stop].get_position().x1
                rect = mpatches.Rectangle(
                    (left, bottom),
                    right - left,
                    top - bottom,
                    transform=fig.transFigure,
                    fill=False,
                    edgecolor=color,
                    linewidth=lw,
                    zorder=20,
                    clip_on=False,
                )
                rect.set_gid(f'de-cluster-border-{cluster_name}')
                fig.add_artist(rect)
            capture[cluster_name] = {
                'rows': [list(run) for run in runs],
                'cols': [list(run) for run in runs],
            }
        if self._html_capture is not None and capture:
            self._html_capture['clusters'] = capture

    def _draw_highlight_bands(
        self,
        ax,
        regions: list[dict],
        q_name: str,
        t_name: str,
        reverse_set: set,
        reverse_target_set: Optional[set] = None,
    ) -> None:
        """Shade the panel columns/rows named by *regions*.

        The interactive report draws these client-side from the on-screen
        geometry; a saved figure has to reconstruct them from coordinates,
        which is why the mirroring for reverse-displayed contigs is
        repeated here rather than inherited.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            The panel being drawn.
        regions : list[dict]
            ``{'axis', 'seqname', 'start', 'end', 'color'}`` entries; see
            :meth:`plot`.
        q_name, t_name : str
            Internal (possibly group-prefixed) names for this panel.
        reverse_set : set
            Query display names shown reverse-complemented.
        reverse_target_set : set, optional
            Target display names shown reverse-complemented.
        """
        reverse_target_set = reverse_target_set or set()
        q_display = self._strip_group_prefix(q_name)
        t_display = self._strip_group_prefix(t_name)
        for region in regions:
            axis = region.get('axis')
            seqname = region.get('seqname')
            try:
                start = int(region['start'])
                end = int(region['end'])
            except (KeyError, TypeError, ValueError):
                continue
            color = region.get('color') or '#888888'
            if axis == 'x' and seqname == t_display:
                lo, hi = start, end
                if t_display in reverse_target_set:
                    seq_len = self.index.get_sequence_length(t_name)
                    lo, hi = seq_len - end, seq_len - start
                ax.axvspan(
                    lo,
                    hi,
                    facecolor=color,
                    edgecolor='none',
                    alpha=_HIGHLIGHT_ALPHA,
                    zorder=_HIGHLIGHT_ZORDER,
                )
            elif axis == 'y' and seqname == q_display:
                lo, hi = start, end
                if q_display in reverse_set:
                    seq_len = self.index.get_sequence_length(q_name)
                    lo, hi = seq_len - end, seq_len - start
                ax.axhspan(
                    lo,
                    hi,
                    facecolor=color,
                    edgecolor='none',
                    alpha=_HIGHLIGHT_ALPHA,
                    zorder=_HIGHLIGHT_ZORDER,
                )

    @staticmethod
    def _fit_row_labels(fig, axes, nrows: int) -> None:
        """Shrink or elide angled row labels so they stay inside their row.

        Rotation alone bounds the vertical extent of a contig name at
        ``length * sin(rotation)``, which is still far more than a thin row
        offers when an assembly mixes one large chromosome with small
        contigs — the short rows' labels then overrun into their
        neighbours'.  Font size is reduced first (names stay complete
        wherever possible) and only then is the text elided.

        Parameters
        ----------
        fig : matplotlib.figure.Figure
            The laid-out figure; margins must already be applied, since the
            row height is read from the axes position.
        axes : list[list[matplotlib.axes.Axes]]
            The panel grid, row-major.
        nrows : int
            Number of rows in the grid.
        """
        fig_h = fig.get_size_inches()[1]
        sin_rot = math.sin(math.radians(_ROW_LABEL_ROTATION)) or 1.0
        for row_idx in range(nrows):
            ax = axes[row_idx][0]
            text = ax.get_ylabel()
            if not text:
                continue
            row_in = ax.get_position().height * fig_h
            size = _ROW_LABEL_SIZE_PT
            # Widest the text may be along its own baseline before its
            # vertical projection exceeds the row.
            while size > _ROW_LABEL_MIN_SIZE_PT:
                if len(text) * size * 0.62 / 72.0 * sin_rot <= row_in:
                    break
                size -= 0.5
            max_chars = int(row_in / (sin_rot * size * 0.62 / 72.0))
            if _ROW_LABEL_MIN_CHARS <= max_chars < len(text):
                # Keep the tail: contig names differ in their suffix far
                # more often than in their shared prefix.
                ax.set_ylabel('…' + text[-(max_chars - 1) :])
            ax.yaxis.label.set_fontsize(size)

    def _plot_panel(
        self,
        ax: plt.Axes,
        query_name: str,
        target_name: str,
        dot_size: float = 0.5,
        cap_style: str = 'projecting',
        dot_color: str = 'blue',
        rc_color: str = 'red',
        merge: bool = True,
        min_length: int = 0,
        show_xlabel: bool = True,
        show_ylabel: bool = True,
        color_by_identity: bool = False,
        identity_palette: str = 'viridis',
        paf_alignment_override: Optional['PafAlignment'] = None,
        chain_gap: int = 0,
        rasterized: Union[bool, str] = 'auto',
        rasterization_threshold: int = 50_000,
        reverse_query: bool = False,
        reverse_target: bool = False,
    ) -> None:
        """Render a single comparison panel onto the given Axes.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            The axes to draw on.
        query_name : str
            Name of the query sequence (y-axis).  For a
            :class:`~dot_explorer.paf_io.CrossIndex` this is the internal
            (``'group:name'``) identifier; the group prefix is stripped for
            axis labels and PAF record lookup.
        target_name : str
            Name of the target sequence (x-axis).  Same note as *query_name*.
        dot_size : float, optional
            Marker size. Default is ``0.5``.
        cap_style : {'butt', 'round', 'projecting'}, optional
            Line cap for match segments. Default is ``'projecting'``.
        dot_color : str, optional
            Marker colour for forward-strand (``+``) matches. Default is ``"blue"``.
        rc_color : str, optional
            Marker colour for reverse-complement (``-``) matches. Default is ``"red"``.
        merge : bool, optional
            Whether to merge sequential runs. Default is ``True``.
        min_length : int, optional
            Minimum alignment length to display.  Matches shorter than this
            value are skipped.  Default is ``0`` (no filtering).
        show_xlabel : bool, optional
            Whether to render the target sequence name as an x-axis label.
            Default is ``True``.
        show_ylabel : bool, optional
            Whether to render the query sequence name as a y-axis label.
            Default is ``True``.
        color_by_identity : bool, optional
            When ``True``, colour alignments by sequence identity using
            *identity_palette*.  Requires a PAF alignment (either
            ``paf_alignment_override`` or ``self.paf_alignment``) to be set;
            if not, a warning is logged and strand colours are used instead.
            Default is ``False``.
        identity_palette : str, optional
            Matplotlib colormap name for identity-based colouring.
            Default is ``'viridis'``.
        paf_alignment_override : PafAlignment or None, optional
            Pre-computed PAF alignments to use for this panel.  When
            provided, this takes precedence over ``self.paf_alignment`` for
            record lookup.  Typically supplied from pre-computed
            :class:`~dot_explorer.paf_io.CrossIndex` records.
            Default is ``None``.
        chain_gap : int, optional
            When greater than ``0``, co-linear match blocks on the same diagonal
            separated by up to *chain_gap* bp (on the query axis) are chained
            into a single line before drawing, reducing the segment count.
            Ignored for identity-coloured rendering.  Default is ``0`` (off).
        rasterized : bool or str, optional
            Controls whether the match layer is rasterised.  ``'auto'`` (default)
            keeps it true vector when the segment count is at or below
            *rasterization_threshold* and rasterises it otherwise; ``True`` /
            ``False`` force the choice.  Axes, ticks and labels always stay vector.
        rasterization_threshold : int, optional
            Segment count above which ``rasterized='auto'`` switches a layer to
            rasterised.  Default is ``50_000``.
        reverse_query : bool, optional
            When ``True`` the query (row) contig is rendered reverse-complemented:
            every match's query coordinates are mirrored (``q → q_len - q``) and
            its strand colour flipped, so a reverse-oriented contig reads along
            the main diagonal.  The underlying records are not modified.
            Default is ``False``.
        reverse_target : bool, optional
            Same for the target (column) contig: target coordinates are
            mirrored (``t → t_len - t``) and the strand flipped.  With
            *reverse_query* also set the two flips cancel, so a flipped
            contig's self-panel keeps its forward diagonal and colours.
            Default is ``False``.
        """
        q_len = self.index.get_sequence_length(query_name)
        t_len = self.index.get_sequence_length(target_name)
        # Strand flips once per mirrored axis; two mirrors cancel.
        flip_strand = reverse_query != reverse_target

        # Display names: strip 'group:' prefix for CrossIndex internal names.
        display_q = self._strip_group_prefix(query_name)
        display_t = self._strip_group_prefix(target_name)

        # HTML report capture: panels are visited in row-major order by
        # plot(), so a running counter recovers (row, col) without changing
        # the grid loop.  The axes group is gid-tagged so the embedded JS can
        # find it in the SVG, and an empty panel entry is registered for the
        # draw helpers to fill in.
        capture = self._html_capture
        if capture is not None:
            row, col = divmod(capture['counter'], capture['ncols'])
            capture['counter'] += 1
            gid = f'de-panel-{row}-{col}'
            ax.set_gid(gid)
            # The report measures band overlays against this rect, so it
            # needs no bp->pixel arithmetic of its own.
            #
            # The prefix MUST differ from 'de-panel-': matplotlib wraps every
            # gid'd artist in its own <g>, so the background lands *inside*
            # the panel group.  Sharing the prefix made it match every
            # `g[id^="de-panel-"]` selector in report.js and the app's
            # double-click bridge, which broke panel dimming and drill-down.
            ax.patch.set_gid(f'de-plotbg-{row}-{col}')
            capture['current'] = gid
            capture['panels'][gid] = {
                'query': display_q,
                'target': display_t,
                'query_id': query_name,
                'qlen': int(q_len),
                'tlen': int(t_len),
                # Mirrored panels need their embedded sequences fetched from
                # the mirrored coordinates and reverse-complemented (the
                # stored sequence is always forward orientation).
                'reverse_query': reverse_query,
                'reverse_target': reverse_target,
                'segments': {'fwd': [], 'rev': [], 'identity': []},
            }

        # Effective PAF alignment: per-call override takes precedence.
        effective_paf = (
            paf_alignment_override
            if paf_alignment_override is not None
            else self.paf_alignment
        )

        # Determine rendering mode:
        # • When index is a PafAlignment (no k-mer index), always draw from
        #   PAF records using strand colours unless color_by_identity is set.
        # • When a paf_alignment_override is provided (CrossIndex pre-computed
        #   records), use it for rendering.
        # • When index has a k-mer engine but color_by_identity is requested
        #   without a PafAlignment, fall back to k-mer matches with a warning.
        use_paf = (
            color_by_identity
            or self._index_is_paf()
            or (paf_alignment_override is not None)
        )

        if use_paf and effective_paf is None:
            _log.warning(
                'color_by_identity=True requires a PafAlignment; k-mer matches '
                'are always 100% identity. Pass paf_alignment= to DotPlotter '
                'to enable identity colouring.'
            )
            use_paf = False

        if use_paf and color_by_identity:
            # Identity-coloured PAF records: one segment per record with a
            # per-segment colour.  Chaining is not applied here because each
            # record carries its own identity value.
            records = self._records_for_pair(effective_paf, display_q, display_t)
            if reverse_query or reverse_target:
                # Mirror the coordinates of every reversed axis and flip the
                # strand once per mirror so the contig(s) render
                # reverse-complemented (originals untouched).
                records = [
                    dataclasses.replace(
                        rec,
                        query_start=q_len - rec.query_end
                        if reverse_query
                        else rec.query_start,
                        query_end=q_len - rec.query_start
                        if reverse_query
                        else rec.query_end,
                        target_start=t_len - rec.target_end
                        if reverse_target
                        else rec.target_start,
                        target_end=t_len - rec.target_start
                        if reverse_target
                        else rec.target_end,
                        strand=_flip_strand(rec.strand) if flip_strand else rec.strand,
                    )
                    for rec in records
                ]
            self._draw_identity_records(
                ax,
                records,
                identity_palette=identity_palette,
                dot_size=dot_size,
                cap_style=cap_style,
                min_length=min_length,
                rasterized=rasterized,
                rasterization_threshold=rasterization_threshold,
            )
        else:
            # Strand-coloured rendering, from either pre-computed PAF records or
            # the k-mer engine.  Both produce (qs, qe, ts, te, strand) blocks.
            if use_paf:
                records = self._records_for_pair(effective_paf, display_q, display_t)
                blocks = [
                    (
                        rec.query_start,
                        rec.query_end,
                        rec.target_start,
                        rec.target_end,
                        rec.strand,
                    )
                    for rec in records
                ]
            else:
                blocks = [
                    (qs, qe, ts, te, strand)
                    for qs, qe, ts, te, strand in self.index.compare_sequences_stranded(
                        query_name, target_name, merge
                    )
                ]
            if reverse_query or reverse_target:
                # Mirror the coordinates of every reversed axis and flip the
                # strand once per mirror (a co-linear run stays co-linear on
                # the other diagonal, so chaining below is unaffected).
                blocks = [
                    (
                        q_len - qe if reverse_query else qs,
                        q_len - qs if reverse_query else qe,
                        t_len - te if reverse_target else ts,
                        t_len - ts if reverse_target else te,
                        _flip_strand(strand) if flip_strand else strand,
                    )
                    for qs, qe, ts, te, strand in blocks
                ]
            if chain_gap > 0:
                blocks = _chain_blocks(blocks, chain_gap)
            self._draw_stranded_blocks(
                ax,
                blocks,
                dot_color=dot_color,
                rc_color=rc_color,
                dot_size=dot_size,
                cap_style=cap_style,
                min_length=min_length,
                rasterized=rasterized,
                rasterization_threshold=rasterization_threshold,
            )

        ax.set_xlim(0, t_len)
        ax.set_ylim(0, q_len)
        ax.invert_yaxis()
        if show_xlabel:
            ax.set_xlabel(display_t, fontsize=8)
        if show_ylabel:
            ax.set_ylabel(display_q, fontsize=8)
        ax.tick_params(axis='both', labelsize=6)
        ax.set_aspect('auto')

    def _draw_stranded_blocks(
        self,
        ax: plt.Axes,
        blocks: list[tuple[int, int, int, int, str]],
        dot_color: str,
        rc_color: str,
        dot_size: float,
        cap_style: str,
        min_length: int,
        rasterized: Union[bool, str],
        rasterization_threshold: int,
    ) -> None:
        """Draw strand-coloured match blocks as vectorised LineCollections.

        Forward (``+``) blocks are drawn as diagonal segments and reverse (``-``)
        blocks as anti-diagonal segments, each strand batched into a single
        ``LineCollection`` built from a NumPy ``(N, 2, 2)`` array.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            Axes to draw on.
        blocks : list of tuple
            ``(query_start, query_end, target_start, target_end, strand)`` blocks.
        dot_color, rc_color : str
            Colours for forward and reverse-complement matches.
        dot_size : float
            Line width in points.
        cap_style : str
            Line cap for the segments, one of :data:`CAP_STYLES`.
        min_length : int
            Skip blocks whose query length is below this (``0`` = keep all).
        rasterized : bool or str
            Passed to :func:`_resolve_rasterized` per strand collection.
        rasterization_threshold : int
            Segment-count threshold for ``rasterized='auto'``.
        """
        if not blocks:
            return
        arr = np.asarray(
            [(qs, qe, ts, te) for (qs, qe, ts, te, _s) in blocks], dtype=float
        )
        strand = np.array([s for (_qs, _qe, _ts, _te, s) in blocks])
        lengths = arr[:, 1] - arr[:, 0]
        keep = lengths >= min_length if min_length > 0 else np.ones(len(arr), bool)

        fwd = arr[keep & (strand != '-')]
        rev = arr[keep & (strand == '-')]

        # HTML report capture: record the filtered segments in the exact
        # order they will be drawn (the SVG element <-> payload contract) and
        # remember the gid suffix for tagging the collections below.
        capture = self._html_capture
        panel = None
        gid_base = ''
        if capture is not None and capture.get('current'):
            panel = capture['panels'][capture['current']]
            # 'de-panel-<r>-<c>' -> 'de-matches-<r>-<c>'
            gid_base = 'de-matches-' + capture['current'][len('de-panel-') :]
            panel['segments']['fwd'] = fwd.astype(np.int64).tolist()
            panel['segments']['rev'] = rev.astype(np.int64).tolist()
            # Interactive reports need one SVG element per segment; a
            # rasterised layer would collapse to a single <image> and kill
            # click-to-inspect, so force vector output for HTML.
            rasterized = False

        if len(fwd):
            # Forward: (t_start, q_start) -> (t_end, q_end).
            fwd_seg = np.stack(
                [
                    np.column_stack([fwd[:, 2], fwd[:, 0]]),
                    np.column_stack([fwd[:, 3], fwd[:, 1]]),
                ],
                axis=1,
            )
            fwd_coll = LineCollection(
                fwd_seg,
                colors=dot_color,
                linewidths=dot_size,
                capstyle=cap_style,
                alpha=0.7,
                rasterized=_resolve_rasterized(
                    len(fwd_seg), rasterized, rasterization_threshold
                ),
            )
            if panel is not None:
                fwd_coll.set_gid(f'{gid_base}-fwd')
            ax.add_collection(fwd_coll)
        if len(rev):
            # Reverse complement: (t_end, q_start) -> (t_start, q_end).
            rev_seg = np.stack(
                [
                    np.column_stack([rev[:, 3], rev[:, 0]]),
                    np.column_stack([rev[:, 2], rev[:, 1]]),
                ],
                axis=1,
            )
            rev_coll = LineCollection(
                rev_seg,
                colors=rc_color,
                linewidths=dot_size,
                capstyle=cap_style,
                alpha=0.7,
                rasterized=_resolve_rasterized(
                    len(rev_seg), rasterized, rasterization_threshold
                ),
            )
            if panel is not None:
                rev_coll.set_gid(f'{gid_base}-rev')
            ax.add_collection(rev_coll)

    def _draw_identity_records(
        self,
        ax: plt.Axes,
        records: list,
        identity_palette: str,
        dot_size: float,
        cap_style: str,
        min_length: int,
        rasterized: Union[bool, str],
        rasterization_threshold: int,
    ) -> None:
        """Draw PAF records coloured by per-record identity.

        Each record becomes one segment; colours come from the record identity
        (``residue_matches / alignment_block_len``) mapped through
        *identity_palette*.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            Axes to draw on.
        records : list of PafRecord
            Records for this panel's sequence pair.
        identity_palette : str
            Matplotlib colormap name for identity colouring.
        dot_size : float
            Line width in points.
        cap_style : str
            Line cap for the segments, one of :data:`CAP_STYLES`.
        min_length : int
            Skip records whose query aligned length is below this.
        rasterized : bool or str
            Passed to :func:`_resolve_rasterized`.
        rasterization_threshold : int
            Segment-count threshold for ``rasterized='auto'``.
        """
        cmap = plt.get_cmap(identity_palette)
        norm = mcolors.Normalize(vmin=0, vmax=1)
        segments: list[list[tuple[float, float]]] = []
        colors: list = []
        # HTML report capture: serialise records in drawing order so the nth
        # SVG element of the identity collection maps to the nth payload row.
        capture = self._html_capture
        panel = None
        gid_base = ''
        if capture is not None and capture.get('current'):
            panel = capture['panels'][capture['current']]
            gid_base = 'de-matches-' + capture['current'][len('de-panel-') :]
            # Force vector output for HTML: rasterising would collapse the
            # layer to one <image> and break per-segment click mapping.
            rasterized = False
        for rec in records:
            if min_length > 0 and rec.query_aligned_len < min_length:
                continue
            identity = rec.identity
            color = cmap(norm(identity))
            if rec.strand == '-':
                xs = (rec.target_end, rec.target_start)
            else:
                xs = (rec.target_start, rec.target_end)
            segments.append([(xs[0], rec.query_start), (xs[1], rec.query_end)])
            colors.append(color)
            if panel is not None:
                panel['segments']['identity'].append(
                    [
                        int(rec.query_start),
                        int(rec.query_end),
                        int(rec.target_start),
                        int(rec.target_end),
                        round(float(identity), 4),
                        rec.strand,
                    ]
                )
        if segments:
            id_coll = LineCollection(
                segments,
                colors=colors,
                linewidths=dot_size,
                capstyle=cap_style,
                alpha=0.7,
                rasterized=_resolve_rasterized(
                    len(segments), rasterized, rasterization_threshold
                ),
            )
            if panel is not None:
                id_coll.set_gid(f'{gid_base}-identity')
            ax.add_collection(id_coll)

    def _records_for_pair(
        self,
        effective_paf: 'PafAlignment',
        display_q: str,
        display_t: str,
    ) -> list:
        """Return the PAF records for one ``(query, target)`` pair.

        A ``(query_name, target_name) -> list[PafRecord]`` index is built once
        per unique ``PafAlignment`` object and cached on the plotter, so panel
        rendering does not rescan every record for each of the ``N × N`` panels.

        Parameters
        ----------
        effective_paf : PafAlignment
            The alignment whose records should be indexed.
        display_q : str
            Query sequence display name (group prefix already stripped).
        display_t : str
            Target sequence display name (group prefix already stripped).

        Returns
        -------
        list of PafRecord
            Records matching the requested pair (empty list if none).
        """
        cache = getattr(self, '_paf_record_index_cache', None)
        # Rebuild the index only when the PafAlignment object changes identity
        # (accessing ``.records`` can itself be an O(records) rebuild).
        if cache is None or cache[0] is not effective_paf:
            index: dict[tuple[str, str], list] = {}
            for r in effective_paf.records:
                index.setdefault((r.query_name, r.target_name), []).append(r)
            cache = (effective_paf, index)
            self._paf_record_index_cache = cache
        return cache[1].get((display_q, display_t), [])

    def _draw_annotation_squares(
        self,
        ax: plt.Axes,
        seq_name: str,
        annotation: 'GffAnnotation',
        reverse: bool = False,
        reverse_x: bool = False,
        gid: Optional[str] = None,
    ) -> list['GffFeature']:
        """Overlay annotation feature squares on a self-vs-self panel.

        Each feature ``[start, end)`` is drawn as a filled square at position
        ``(start, start)`` to ``(end, end)`` in the dotplot coordinate system,
        **behind** the alignment segments (``zorder=0.5`` — line collections
        default to zorder 2) so features never obscure matches.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            The axes of the self-vs-self panel.
        seq_name : str
            Sequence name whose features should be drawn (group prefixes are
            stripped before the annotation lookup).
        annotation : GffAnnotation
            The annotation object providing features and colours.
        reverse : bool, optional
            Mirror feature coordinates on the query (y) axis when the contig
            is displayed reverse-complemented, matching the alignment
            mirroring.  Default ``False``.
        reverse_x : bool, optional
            Likewise for the target (x) axis.  Default ``False``.
        gid : str, optional
            SVG group id assigned to the patch collection (used by the HTML
            report to make features clickable).  Default ``None``.

        Returns
        -------
        list[GffFeature]
            The drawn features, in patch order (empty when none) — the HTML
            serializer relies on this order matching the SVG children.
        """
        display_name = self._strip_group_prefix(seq_name)
        features = annotation.get_features_for_sequence(display_name)
        seq_len = self.index.get_sequence_length(seq_name)
        # Batch all feature squares into a single PatchCollection rather than
        # adding one artist per feature.
        rects = []
        facecolors = []
        for feat in features:
            width = feat.end - feat.start
            # Each axis mirrors independently: a self panel of a contig
            # flipped on both axes keeps its squares on the diagonal.
            x = (seq_len - feat.end) if reverse_x else feat.start
            y = (seq_len - feat.end) if reverse else feat.start
            rects.append(mpatches.Rectangle((x, y), width, width))
            facecolors.append(feat.color or annotation.get_color(feat.feature_type))
        if rects:
            collection = PatchCollection(
                rects,
                facecolors=facecolors,
                edgecolors='none',
                alpha=0.35,
                match_original=False,
                zorder=0.5,
            )
            if gid is not None:
                collection.set_gid(gid)
                # Keep each feature as its own SVG child so the report can
                # map clicks back by index.
                collection.set_rasterized(False)
            ax.add_collection(collection)
        return features

    def plot_annotation_legend(
        self,
        annotation: 'GffAnnotation',
        output_path: Optional[Union[str, Path]] = None,
        figsize: tuple[float, float] = (3.0, 4.0),
        dpi: int = 150,
        format: Optional[str] = None,
    ) -> matplotlib.figure.Figure:
        """Render the annotation feature-type legend as a standalone figure.

        Produces a figure containing only a colour legend that maps each
        feature type to its assigned colour.  This is intended to be
        displayed alongside dotplots produced with an *annotation* argument.

        Parameters
        ----------
        annotation : GffAnnotation
            The annotation object whose feature-type colours are displayed.
        output_path : str or Path, optional
            Output image file path.  When ``None`` (default) the figure is
            not saved to disk.
        figsize : tuple[float, float], optional
            Figure size as ``(width, height)`` in inches.
            Default is ``(3.0, 4.0)``.
        dpi : int, optional
            Output image resolution. Default is ``150``.
        format : str, optional
            Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
            When ``None`` (default), the format is inferred from the
            ``output_path`` file extension.

        Returns
        -------
        matplotlib.figure.Figure
            A figure containing only the legend.
        """
        # Legend figures never support HTML capture; clear any stale capture
        # left by an aborted plot() so _save_figure raises cleanly for HTML.
        self._html_capture = None
        handles = [
            mpatches.Patch(
                facecolor=annotation.get_color(ft),
                edgecolor='none',
                label=ft,
            )
            for ft in annotation.feature_types()
        ]
        fig, ax = plt.subplots(figsize=figsize)
        ax.set_visible(False)
        fig.legend(handles=handles, loc='center', fontsize=10, frameon=True)
        plt.tight_layout()
        if output_path is not None:
            self._save_figure(fig, output_path, dpi=dpi, format=format)
        return fig

    def plot_single(
        self,
        query_name: str,
        target_name: str,
        query_group: Optional[str] = None,
        target_group: Optional[str] = None,
        output_path: Optional[Union[str, Path]] = None,
        figsize: tuple[float, float] = (6.0, 6.0),
        dot_size: float = 0.5,
        cap_style: str = 'projecting',
        dot_color: str = 'blue',
        rc_color: str = 'red',
        merge: bool = True,
        title: Optional[str] = None,
        dpi: int = 150,
        format: Optional[str] = None,
        min_length: int = 0,
        color_by_identity: bool = False,
        identity_palette: str = 'viridis',
        annotation: Optional['GffAnnotation'] = None,
        annotation_track_size: float = 0.4,
        chain_gap: int = 0,
        rasterized: Union[bool, str] = 'auto',
        rasterization_threshold: int = 50_000,
    ) -> matplotlib.figure.Figure:
        """Plot a single pairwise dotplot.

        When *annotation* is provided, a linear annotation track is drawn
        below the x-axis (target sequence features) and to the left of the
        y-axis (query sequence features).

        When *index* is a :class:`~dot_explorer.paf_io.CrossIndex`, supply
        *query_group* and *target_group* to have the sequence names resolved
        to internal (``'group:name'``) identifiers automatically, and to
        render from pre-computed records when available.

        Parameters
        ----------
        query_name : str
            Name of the query sequence (y-axis).  When *query_group* is
            provided and *index* is a ``CrossIndex``, this is treated as an
            un-prefixed name and the internal identifier is looked up.
        target_name : str
            Name of the target sequence (x-axis).  Same note as *query_name*.
        query_group : str or None, optional
            Group label for the query sequence.  When provided and *index* is
            a ``CrossIndex``, the internal name is resolved as
            ``'{query_group}:{query_name}'``.
        target_group : str or None, optional
            Group label for the target sequence.  When provided and *index*
            is a ``CrossIndex``, the internal name is resolved as
            ``'{target_group}:{target_name}'``.
        output_path : str or Path, optional
            Output image file path.  When ``None`` (default) the figure is
            not saved to disk.  Use a ``.svg`` extension (or set
            ``format='svg'``) to produce an SVG vector image.
        figsize : tuple[float, float], optional
            Figure size as (width, height) in inches for the main dotplot
            panel.  When annotation tracks are added the overall figure will
            be slightly larger.  Default is ``(6, 6)``.
        dot_size : float, optional
            Marker/line size for each match. Default is ``0.5``.
        cap_style : {'butt', 'round', 'projecting'}, optional
            Line cap for match segments.  ``'projecting'`` (the default) and
            ``'round'`` extend the stroke past each endpoint by half the line
            width, so a match shorter than *dot_size* still reads as a mark on
            its own diagonal; with ``'butt'`` such a match is drawn wider
            across the diagonal than along it and appears rotated 90 degrees.
        dot_color : str, optional
            Colour for forward-strand (``+``) matches. Default is ``"blue"``.
        rc_color : str, optional
            Colour for reverse-complement (``-``) matches. Default is ``"red"``.
        merge : bool, optional
            Whether to merge sequential k-mer runs. Default is ``True``.
        title : str, optional
            Plot title. If ``None``, a default title is used.
        dpi : int, optional
            Output image resolution. Default is ``150``.
        format : str, optional
            Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
            When ``None`` (default), the format is inferred from the
            ``output_path`` file extension.
        min_length : int, optional
            Minimum alignment length to display.  Matches shorter than this
            value are not drawn.  Applies to merged k-mer runs and pre-computed
            PAF alignments.  Default is ``0`` (no filtering).
        color_by_identity : bool, optional
            When ``True``, alignments are coloured by sequence identity using
            the *identity_palette* colormap.  Requires a
            :class:`~dot_explorer.paf_io.PafAlignment` to be supplied as
            ``paf_alignment`` to :meth:`__init__`; if no PAF alignment is
            available a warning is logged and the default strand colours are
            used instead.  Default is ``False``.
        identity_palette : str, optional
            Matplotlib colormap name used to map identity values (0–1) to
            colours when ``color_by_identity=True``.  Default is
            ``'viridis'``.
        annotation : GffAnnotation, optional
            Feature annotations to display as linear tracks flanking the
            dotplot.  Target features are drawn below the x-axis; query
            features are drawn to the left of the y-axis.  Sequence names
            in *annotation* absent from the index emit a warning.
            Default is ``None``.
        annotation_track_size : float, optional
            Height/width in inches of each annotation track.
            Default is ``0.4``.
        chain_gap : int, optional
            When greater than ``0``, chain co-linear match blocks on the same
            diagonal separated by up to *chain_gap* bp into single lines before
            drawing.  Default is ``0`` (off).  See :meth:`plot`.
        rasterized : bool or str, optional
            Whether to rasterise the match layer; ``'auto'`` (default) keeps it
            true vector until the segment count exceeds
            *rasterization_threshold*.  See :meth:`plot`.
        rasterization_threshold : int, optional
            Segment count above which ``rasterized='auto'`` rasterises the
            layer.  Default is ``50_000``.

        Returns
        -------
        matplotlib.figure.Figure
            The generated figure.  In a Jupyter notebook the figure is
            displayed inline automatically; call ``matplotlib.pyplot.close``
            on the returned object when it is no longer needed.

        Raises
        ------
        ValueError
            If *query_group* / *target_group* are provided but *index* is
            not a ``CrossIndex``.
        """
        cap_style = _resolve_cap_style(cap_style)

        # Single-panel figures do not support HTML capture (v1 covers the
        # grid plot() path only); clear any stale capture defensively.
        self._html_capture = None

        # Resolve group-prefixed names for CrossIndex.
        if query_group is not None or target_group is not None:
            if not self._index_is_cross():
                raise ValueError(
                    'query_group and target_group can only be used when index '
                    'is a CrossIndex.'
                )
            cross = self.index  # type: ignore[assignment]
            if query_group is not None:
                query_name = cross.make_internal_name(query_group, query_name)
            if target_group is not None:
                target_name = cross.make_internal_name(target_group, target_name)

        # Use pre-computed records when available (via shared helper).
        paf_override = self._get_paf_override(query_group, target_group)
        effective_paf = paf_override if paf_override is not None else self.paf_alignment

        import matplotlib.gridspec as gridspec

        if annotation is not None:
            # Warn about annotation sequences not in the index.
            index_seqs = set(self.index.sequence_names())
            for ann_seq in annotation.sequence_names():
                if ann_seq not in index_seqs:
                    _log.warning(
                        'Annotation contains features for sequence %r which is '
                        'not present in the index. These features will not be '
                        'plotted.',
                        ann_seq,
                    )
            has_tracks = True
        else:
            has_tracks = False

        if has_tracks:
            fw, fh = figsize
            ts = annotation_track_size
            # GridSpec layout:
            #   rows: [main (fh), x-track (ts)]
            #   cols: [y-track (ts), main (fw)]
            total_w = fw + ts
            total_h = fh + ts
            fig = plt.figure(figsize=(total_w, total_h))
            gs = gridspec.GridSpec(
                2,
                2,
                width_ratios=[ts, fw],
                height_ratios=[fh, ts],
                hspace=0.02,
                wspace=0.02,
            )
            main_ax = fig.add_subplot(gs[0, 1])
            y_track_ax = fig.add_subplot(gs[0, 0], sharey=main_ax)
            x_track_ax = fig.add_subplot(gs[1, 1], sharex=main_ax)
            corner_ax = fig.add_subplot(gs[1, 0])
            corner_ax.set_visible(False)
        else:
            fig, main_ax = plt.subplots(figsize=figsize)

        self._plot_panel(
            main_ax,
            query_name,
            target_name,
            dot_size=dot_size,
            cap_style=cap_style,
            dot_color=dot_color,
            rc_color=rc_color,
            merge=merge,
            min_length=min_length,
            color_by_identity=color_by_identity,
            identity_palette=identity_palette,
            paf_alignment_override=effective_paf,
            chain_gap=chain_gap,
            rasterized=rasterized,
            rasterization_threshold=rasterization_threshold,
        )

        # Contig names as conventional axis labels with bp/Kbp/Mbp ticks —
        # matching plot()'s focused single-pair view.
        display_t = self._strip_group_prefix(target_name)
        display_q = self._strip_group_prefix(query_name)
        q_len = self.index.get_sequence_length(query_name)
        t_len = self.index.get_sequence_length(target_name)
        x_unit = _apply_bp_units(main_ax.xaxis, t_len)
        y_unit = _apply_bp_units(main_ax.yaxis, q_len)
        x_label = f'{display_t} ({x_unit})'
        y_label = f'{display_q} ({y_unit})'
        if has_tracks:
            # The tracks own the near axis edges: their outer edges carry
            # the tick labels and the name labels pad past the whole band.
            main_ax.tick_params(axis='x', labelbottom=False)
            main_ax.tick_params(axis='y', labelleft=False)

            # Shared lane-packed track rendering (strand arrows, rounded
            # rectangles, multi-part connectors) — identical to plot()'s
            # focused single-pair view.
            draw_track(
                x_track_ax,
                annotation,  # type: ignore[arg-type]
                display_t,
                t_len,
                orientation='x',
            )
            draw_track(
                y_track_ax,
                annotation,  # type: ignore[arg-type]
                display_q,
                q_len,
                orientation='y',
            )
            track_pad = annotation_track_size * 72.0 + _TICK_LABEL_PAD_PTS
            if x_track_ax.axison:
                _apply_bp_units(x_track_ax.xaxis, t_len)
                x_track_ax.tick_params(
                    axis='x', bottom=True, labelbottom=True, labelsize=6
                )
                main_ax.set_xlabel(x_label, fontsize=8, labelpad=track_pad)
            else:
                main_ax.tick_params(axis='x', labelbottom=True)
                main_ax.set_xlabel(x_label, fontsize=8)
            if y_track_ax.axison:
                _apply_bp_units(y_track_ax.yaxis, q_len)
                y_track_ax.tick_params(axis='y', left=True, labelleft=True, labelsize=6)
                main_ax.set_ylabel(y_label, fontsize=8, labelpad=track_pad)
            else:
                main_ax.tick_params(axis='y', labelleft=True)
                main_ax.set_ylabel(y_label, fontsize=8)
        else:
            main_ax.set_xlabel(x_label, fontsize=8)
            main_ax.set_ylabel(y_label, fontsize=8)

        # Title: use display names (strip group prefix for CrossIndex).
        if title is None:
            dq = self._strip_group_prefix(query_name)
            dt = self._strip_group_prefix(target_name)
            title = f'{dq} vs {dt}'
        main_ax.set_title(title, fontsize=10)

        if has_tracks:
            fig.subplots_adjust(hspace=0.02, wspace=0.02)
        else:
            plt.tight_layout()
        if output_path is not None:
            self._save_figure(fig, output_path, dpi=dpi, format=format)
        return fig

    def plot_identity_colorbar(
        self,
        palette: str = 'viridis',
        figsize: tuple[float, float] = (1.5, 4.0),
        output_path: Optional[Union[str, Path]] = None,
        dpi: int = 150,
        format: Optional[str] = None,
    ) -> matplotlib.figure.Figure:
        """Render the identity colour scale as a standalone figure.

        Produces a figure containing only a vertical colorbar that maps
        identity values (0–100 %) to colours from *palette*.  This is
        intended to be displayed alongside a dotplot produced with
        ``color_by_identity=True``.

        Parameters
        ----------
        palette : str, optional
            Matplotlib colormap name.  Should match the *identity_palette*
            used when calling :meth:`plot` or :meth:`plot_single`.
            Default is ``'viridis'``.
        figsize : tuple[float, float], optional
            Figure size as ``(width, height)`` in inches.
            Default is ``(1.5, 4.0)``.
        output_path : str or Path, optional
            Output image file path.  When ``None`` (default) the figure is
            not saved to disk.
        dpi : int, optional
            Output image resolution. Default is ``150``.
        format : str, optional
            Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
            When ``None`` (default), the format is inferred from the
            ``output_path`` file extension.

        Returns
        -------
        matplotlib.figure.Figure
            A figure containing only the colorbar.
        """
        # Colorbar figures never support HTML capture; clear any stale
        # capture left by an aborted plot() so _save_figure raises cleanly.
        self._html_capture = None
        norm = mcolors.Normalize(vmin=0, vmax=1)
        sm = plt.cm.ScalarMappable(cmap=plt.get_cmap(palette), norm=norm)
        sm.set_array([])
        fig, ax = plt.subplots(figsize=figsize)
        cb = fig.colorbar(sm, ax=ax, orientation='vertical')
        cb.set_label('Identity', fontsize=10)
        cb.set_ticks([0, 0.25, 0.5, 0.75, 1.0])
        cb.set_ticklabels(['0%', '25%', '50%', '75%', '100%'])
        ax.set_visible(False)
        plt.tight_layout()
        if output_path is not None:
            self._save_figure(fig, output_path, dpi=dpi, format=format)
        return fig

    @staticmethod
    def _is_html_output(
        output_path: Union[str, Path],
        format: Optional[str],
    ) -> bool:
        """Return ``True`` when the requested output is an HTML report.

        Parameters
        ----------
        output_path : str or Path
            Destination file path.
        format : str or None
            Explicit output format, if any.  An explicit non-HTML format
            (e.g. ``format='svg'`` with a ``.html`` path) wins over the file
            extension, matching matplotlib's own precedence.

        Returns
        -------
        bool
            ``True`` when ``format='html'`` or, with no explicit format, the
            path ends in ``.html`` / ``.htm``.
        """
        if format is not None:
            return format.lower() == 'html'
        return Path(output_path).suffix.lower() in ('.html', '.htm')

    def _save_figure(
        self,
        fig: matplotlib.figure.Figure,
        output_path: Union[str, Path],
        dpi: int,
        format: Optional[str] = None,
        title: Optional[str] = None,
        embed_sequences: bool = False,
    ) -> None:
        """Save a figure, dispatching on the requested output format.

        Routes to the interactive HTML report renderer when the destination
        is HTML (``.html``/``.htm`` suffix or ``format='html'``); otherwise
        performs the standard matplotlib save.

        Parameters
        ----------
        fig : matplotlib.figure.Figure
            The figure to save.
        output_path : str or Path
            Destination file path.
        dpi : int
            Raster resolution passed to matplotlib for non-HTML output.
        format : str, optional
            Explicit output format; ``None`` (default) infers from the file
            extension.
        title : str, optional
            Report title for HTML output (unused otherwise).  ``None``
            (default) uses a generic title.
        embed_sequences : bool, optional
            HTML output only: embed matched query subsequences in the
            report payload.  Default is ``False``.

        Raises
        ------
        ValueError
            If HTML output is requested but no panel capture is active,
            i.e. the figure was not produced by :meth:`plot` (v1 supports
            HTML reports for the grid ``plot()`` path only).
        """
        if self._is_html_output(output_path, format):
            capture = self._html_capture
            try:
                if capture is None or not capture['panels']:
                    raise ValueError(
                        'HTML output is only supported for figures produced '
                        'by DotPlotter.plot() / DotPlotter.to_html(); save '
                        'this figure as PNG/SVG/PDF instead.'
                    )
                # Local import keeps matplotlib-only workflows free of any
                # HTML machinery import cost.
                from dot_explorer._html import build_panel_payload, render_html_report

                # SequenceIndex and CrossIndex expose get_sequence();
                # PafAlignment does not, so its reports omit sequences.
                # Embedding is opt-in: coordinates-only payloads keep
                # reports small even with many alignments.
                get_seq = (
                    getattr(self.index, 'get_sequence', None)
                    if embed_sequences
                    else None
                )
                payload = build_panel_payload(capture, get_sequence=get_seq)
                render_html_report(
                    fig,
                    payload,
                    Path(output_path),
                    title=title if title is not None else 'dot-explorer report',
                )
            finally:
                # Always drop the capture so state never leaks between plots.
                self._html_capture = None
        else:
            fig.savefig(str(output_path), dpi=dpi, bbox_inches='tight', format=format)

    def to_html(
        self,
        output_path: Union[str, Path],
        **plot_kwargs: object,
    ) -> matplotlib.figure.Figure:
        """Render the all-vs-all dotplot grid as an interactive HTML report.

        Convenience wrapper around :meth:`plot` that always produces a
        single self-contained HTML file: the figure is embedded as inline
        SVG together with the match coordinates (and, when
        ``embed_sequences=True`` and the index stores sequences, the
        matched query subsequences).  In a browser, panels can be zoomed
        by clicking or scrolling and individual match lines can be clicked
        to inspect their coordinates.

        Parameters
        ----------
        output_path : str or Path
            Destination ``.html`` file path.  HTML output is produced even
            if the suffix differs.
        **plot_kwargs : object
            Additional keyword arguments forwarded to :meth:`plot`
            (e.g. ``query_names``, ``title``, ``color_by_identity``,
            ``embed_sequences``).

        Returns
        -------
        matplotlib.figure.Figure
            The rendered figure, as returned by :meth:`plot`.

        Examples
        --------
        >>> plotter = DotPlotter(idx)  # doctest: +SKIP
        >>> plotter.to_html('report.html', title='asm1 vs asm2')  # doctest: +SKIP
        """
        path = Path(output_path)
        if path.suffix.lower() not in ('.html', '.htm'):
            # Force HTML dispatch even for non-.html suffixes.
            plot_kwargs.setdefault('format', 'html')
        return self.plot(output_path=path, **plot_kwargs)  # type: ignore[arg-type]

Methods:

__init__(index, paf_alignment=None)

Initialise the DotPlotter.

Parameters:

Name Type Description Default
index SequenceIndex, CrossIndex, or PafAlignment

A populated index or alignment collection. When a :class:~dot_explorer.paf_io.PafAlignment is supplied, it is used both to resolve sequence lengths and as the source of alignment segments.

required
paf_alignment PafAlignment

Pre-loaded PAF alignments. Used for identity-based colouring when index is a SequenceIndex or CrossIndex. When index is already a PafAlignment this argument is ignored. When None (default), k-mer matches from index are used.

None
Source code in dot_explorer/dotplot.py
def __init__(
    self,
    index: Union[SequenceIndex, 'CrossIndex', 'PafAlignment'],
    paf_alignment: Optional['PafAlignment'] = None,
) -> None:
    """Initialise the DotPlotter.

    Parameters
    ----------
    index : SequenceIndex, CrossIndex, or PafAlignment
        A populated index or alignment collection.  When a
        :class:`~dot_explorer.paf_io.PafAlignment` is supplied, it is used
        both to resolve sequence lengths and as the source of alignment
        segments.
    paf_alignment : PafAlignment, optional
        Pre-loaded PAF alignments.  Used for identity-based colouring
        when *index* is a ``SequenceIndex`` or ``CrossIndex``.  When
        *index* is already a ``PafAlignment`` this argument is ignored.
        When ``None`` (default), k-mer matches from *index* are used.
    """
    self.index = index
    # When a PafAlignment is passed as the primary index, use it for
    # rendering alignment segments (the explicit paf_alignment kwarg is
    # then redundant and is ignored to avoid confusion).
    if isinstance(index, PafAlignment):
        self.paf_alignment: Optional[PafAlignment] = index
    else:
        self.paf_alignment = paf_alignment
    # Per-plot capture of drawn match segments for HTML report output.
    # ``None`` when inactive; a dict (panels/ncols/counter/current) while
    # :meth:`plot` is rendering to an ``.html`` destination.
    self._html_capture: Optional[dict] = None

plot(query_names=None, target_names=None, query_group=None, target_group=None, output_path=None, figsize_per_panel=4.0, dot_size=0.5, cap_style='projecting', dot_color='blue', rc_color='red', merge=True, title=None, dpi=150, scale_sequences=True, format=None, min_length=0, color_by_identity=False, identity_palette='viridis', annotation=None, annotation_query=None, annotation_target=None, annotation_tracks=False, annotation_track_size=0.6, annotation_legend=True, chain_gap=0, rasterized='auto', rasterization_threshold=50000, reverse_contigs=None, reverse_targets=None, contig_order=None, auto_reverse=False, hide_internal_axes=False, identity_colorbar=False, highlight_regions=None, embed_sequences=False, tree=None, tree_width=1.6, tree_cutoff=None, tree_scalebar=True, cluster_borders=None, cluster_border_color='black', cluster_border_lw=2.5)

Plot an all-vs-all dotplot grid.

If both query_names and target_names are provided, the plot will show each query sequence (rows) against each target sequence (columns). If only one set is provided, or neither, all pairwise combinations within the available sequences are plotted.

When index is a :class:~dot_explorer.paf_io.CrossIndex, use query_group and target_group to specify which groups supply the query and target sequences. The corresponding internal ('group:name') identifiers are looked up automatically and used for sequence-length queries and k-mer comparisons. If :meth:~dot_explorer.paf_io.CrossIndex.compute_matches has already been called for that pair, the pre-computed merged alignments are used for rendering rather than recomputing on the fly.

The figure is always returned so it can be displayed inline in a Jupyter notebook. When output_path is provided the figure is also saved to disk.

Parameters:

Name Type Description Default
query_names list[str]

Sequence names for the y-axis (rows). If None, uses all sequences in the index. Ignored when query_group is provided and index is a CrossIndex.

None
target_names list[str]

Sequence names for the x-axis (columns). If None, uses all sequences in the index. Ignored when target_group is provided and index is a CrossIndex.

None
query_group str or None

Group label whose sequences are used as query (rows). When provided, the group's sequences are looked up from index (which must be a CrossIndex) and query_names is ignored.

None
target_group str or None

Group label whose sequences are used as target (columns). When provided, the group's sequences are looked up from index (which must be a CrossIndex) and target_names is ignored.

None
output_path str or Path

Output image file path. When None (default) the figure is not saved to disk. Use a .svg extension (or set format='svg') to produce an SVG vector image.

None
figsize_per_panel float

Base size in inches for each subplot panel when scale_sequences=False. When scale_sequences=True this value sets the size of the longest sequence axis and all other axes are scaled proportionally. Default is 4.0.

4.0
dot_size float

Size of each dot in the scatter plot. Default is 0.5.

0.5
cap_style ('butt', 'round', 'projecting')

Line cap for match segments. 'projecting' (the default) and 'round' extend the stroke past each endpoint by half the line width, so a match shorter than dot_size still reads as a mark on its own diagonal; with 'butt' such a match is drawn wider across the diagonal than along it and appears rotated 90 degrees.

'butt'
dot_color str

Colour for forward-strand (+) match lines. Default is "blue".

'blue'
rc_color str

Colour for reverse-complement (-) strand match lines. Default is "red".

'red'
merge bool

Whether to merge sequential k-mer runs before plotting. Default is True.

True
title str

Overall figure title. If None, no title is added.

None
dpi int

Resolution of the output image. Default is 150. For vector formats this only affects any rasterised match layer (see rasterized); axes and labels remain resolution-independent. Raise it (e.g. dpi=300) for a higher-resolution raster (PNG) figure.

150
scale_sequences bool

When True (default), subplot widths and heights are proportional to the lengths of the corresponding sequences so that relative sequence sizes are preserved. When False, every panel has the same fixed size.

True
format str

Output image format (e.g. 'png', 'svg', 'pdf'). When None (default), the format is inferred from the output_path file extension.

None
min_length int

Minimum alignment length to display. Matches shorter than this value are not drawn. Applies to merged k-mer runs and pre-computed PAF alignments. Default is 0 (no filtering).

0
color_by_identity bool

When True, alignments are coloured by sequence identity using the identity_palette colormap. Requires a :class:~dot_explorer.paf_io.PafAlignment to be supplied as paf_alignment to :meth:__init__; if no PAF alignment is available a warning is logged and the default strand colours are used instead. Default is False.

False
identity_palette str

Matplotlib colormap name used to map identity values (0–1) to colours when color_by_identity=True. Default is 'viridis'.

'viridis'
annotation GffAnnotation

Feature annotations to overlay on self-vs-self diagonal panels. Each feature is drawn as a transparent coloured square at its genomic position, behind the alignment segments (mirrored along whichever axes display the contig reverse-complemented). Sequence names in annotation that are absent from the index emit a warning. Also used as the fallback source for side tracks when annotation_query / annotation_target are not given. Default is None.

None
annotation_query GffAnnotation

Features for the query (y) axis side track. Default None.

None
annotation_target GffAnnotation

Features for the target (x) axis side track. Default None.

None
highlight_regions list[dict]

Bands to shade behind the matches, each {'axis': 'x'|'y', 'seqname': str, 'start': int, 'end': int, 'color': str}. 'x' shades a column of the panels whose target is seqname, 'y' a row of those whose query is. Coordinates are 0-based half-open in the sequence's own orientation and are mirrored here for reverse-displayed contigs, so a caller passes feature coordinates and gets the band where the feature is drawn. Used to carry the interactive report's feature highlights into a saved figure.

None
annotation_tracks bool

Draw side annotation tracks (left of the y axis and below the x axis) with lane-packed feature shapes, strand arrows for gene/mRNA/exon/CDS/ORF features and connector lines joining multi-part groups. Honoured only for single-pair (1×1) plots — the focused drill-down view — where the tracks have room to read; multi-panel grids draw diagonal squares only. Default is False.

False
annotation_track_size float

Side-track thickness in inches. Default is 0.6.

0.6
annotation_legend bool

Add a feature-type colour legend to the figure whenever annotation features are drawn. Default is True.

True
chain_gap int

When greater than 0, co-linear match blocks on the same diagonal separated by up to chain_gap bp are chained into single lines before drawing, greatly reducing the number of segments (and thus render time and file size) for dense plots. Default is 0 (off).

0
rasterized bool or str

Whether to rasterise the match layer. 'auto' (default) keeps it true vector — infinitely zoomable in SVG/PDF — until a panel's segment count exceeds rasterization_threshold, above which that layer is rasterised at dpi to bound file size. True / False force the behaviour. Axes, ticks and labels always stay vector.

'auto'
rasterization_threshold int

Segment count per strand/panel above which rasterized='auto' rasterises the layer. Default is 50_000.

50000
reverse_contigs set[str] or None

Un-prefixed query (row) contig names to render reverse-complemented so reverse-oriented contigs read along the main diagonal. When None (default) the set is pulled automatically from the index: :meth:~dot_explorer.paf_io.CrossIndex.reversed_contigs for the query_group of a CrossIndex, or :attr:~dot_explorer.paf_io.PafAlignment.reversed_contigs for a PafAlignment (both populated by a prior reorder call). Pass an explicit set (including set() to disable) to override.

None
reverse_targets set[str] or None

Un-prefixed target (column) contig names to render reverse-complemented on the x axis: target coordinates are mirrored (t → t_len - t) and the strand flag flipped. Never pulled from the index — None (default) and set() both mean no target mirroring. A panel whose query and target are both mirrored (a self-comparison of a flipped contig) flips the strand twice, so colours are unchanged and only coordinates move: the contig's self-diagonal stays a forward diagonal.

None
contig_order str or None

Contig ordering applied before plotting. 'length' sorts contigs by descending sequence length (:meth:~dot_explorer.paf_io.CrossIndex.reorder_by_length for a CrossIndex, otherwise a plain length sort of the resolved name lists). 'colinearity' applies the d-genies gravity ordering (:meth:~dot_explorer.paf_io.CrossIndex.reorder_for_colinearity, computing matches first if needed; :meth:~dot_explorer.paf_io.PafAlignment.reorder_contigs for a PafAlignment; :meth:~dot_explorer.SequenceIndex.optimal_contig_order for a bare SequenceIndex). Explicit query_names / target_names arguments take precedence: an axis whose names were supplied by the caller keeps the caller's order. 'colinearity' computes CrossIndex matches on demand when needed; the cached records cover both strands, so subsequent rendering from the cache shows reverse-strand alignments too. An invalid value raises :exc:ValueError. Default is None (no reordering).

None
auto_reverse bool

When True, reverse-oriented query contigs detected by the contig_order reorder (via :meth:~dot_explorer.paf_io.CrossIndex.reversed_contigs or :attr:~dot_explorer.paf_io.PafAlignment.reversed_contigs) are fed into the reverse_contigs rendering path so they read along the main diagonal. An explicit reverse_contigs argument wins when both are given. Only contig_order='colinearity' yields orientation information; otherwise this option has no effect. Default is False.

False
hide_internal_axes bool

When True, internal panel boundaries are removed so the grid reads as one continuous plot: inter-panel gaps collapse to zero, ticks and spines shared between adjacent panels are hidden, and only the outer frame with its tick labels remains. Default is False.

False
identity_colorbar bool

When True and color_by_identity is on, append a vertical identity colour key (0-100 %) at the right of the figure. Ignored without color_by_identity. Default is False.

False
embed_sequences bool

HTML output only: when True, embed the matched query subsequences in the report so its sequence preview / copy buttons work in a standalone file (requires an index that stores sequences and at most ~2 Mb of total match residues). Default is False — coordinates only, keeping reports small even with many alignments.

False
tree Tree

A :class:dot_explorer.Tree (user newick or :meth:~dot_explorer.Tree.from_linkage) drawn left of the rows. The row order is fixed to the tree's leaf order (and the column order too when the plot is a self-comparison), so contig_order and auto_reverse cannot be combined with a tree. Tip labels must match the query sequence names; row name labels move onto the tree tips so they never obscure it. Requires at least 2 rows (grid layouts only).

None
tree_width float

Width of the tree gutter in inches (floored at 18% of the panel-grid width so wide grids do not squash the dendrogram). Increase it when long sequence names crowd the tree. Default is 1.6.

1.6
tree_cutoff float

Draw a dashed clustering-cutoff line through the tree at this distance from the tips (for linkage trees, 1 - similarity cutoff). Default is None (no line).

None
tree_scalebar bool

Show a branch-length scale bar under the tree. Default is True.

True
cluster_borders ClusterResult

Cluster assignments (:func:dot_explorer.assign_clusters or :func:dot_explorer.assign_clusters_dual); each cluster's block of panels gets a bold border. Only meaningful for self-comparisons, where rows and columns share an order; a cluster that is not contiguous in the display order is outlined per contiguous block.

None
cluster_border_color str

Cluster border colour. Default is 'black'.

'black'
cluster_border_lw float

Cluster border line width. Default is 2.5.

2.5

Returns:

Type Description
Figure

The generated figure. In a Jupyter notebook the figure is displayed inline automatically; call matplotlib.pyplot.close on the returned object when it is no longer needed.

Raises:

Type Description
ValueError

If query_group / target_group are provided but index is not a CrossIndex.

Source code in dot_explorer/dotplot.py
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
def plot(
    self,
    query_names: Optional[list[str]] = None,
    target_names: Optional[list[str]] = None,
    query_group: Optional[str] = None,
    target_group: Optional[str] = None,
    output_path: Optional[Union[str, Path]] = None,
    figsize_per_panel: float = 4.0,
    dot_size: float = 0.5,
    cap_style: str = 'projecting',
    dot_color: str = 'blue',
    rc_color: str = 'red',
    merge: bool = True,
    title: Optional[str] = None,
    dpi: int = 150,
    scale_sequences: bool = True,
    format: Optional[str] = None,
    min_length: int = 0,
    color_by_identity: bool = False,
    identity_palette: str = 'viridis',
    annotation: Optional['GffAnnotation'] = None,
    annotation_query: Optional['GffAnnotation'] = None,
    annotation_target: Optional['GffAnnotation'] = None,
    annotation_tracks: bool = False,
    annotation_track_size: float = 0.6,
    annotation_legend: bool = True,
    chain_gap: int = 0,
    rasterized: Union[bool, str] = 'auto',
    rasterization_threshold: int = 50_000,
    reverse_contigs: Optional[set[str]] = None,
    reverse_targets: Optional[set[str]] = None,
    contig_order: Optional[str] = None,
    auto_reverse: bool = False,
    hide_internal_axes: bool = False,
    identity_colorbar: bool = False,
    highlight_regions: Optional[list[dict]] = None,
    embed_sequences: bool = False,
    tree: Optional['Tree'] = None,
    tree_width: float = 1.6,
    tree_cutoff: Optional[float] = None,
    tree_scalebar: bool = True,
    cluster_borders: Optional['ClusterResult'] = None,
    cluster_border_color: str = 'black',
    cluster_border_lw: float = 2.5,
) -> matplotlib.figure.Figure:
    """Plot an all-vs-all dotplot grid.

    If both ``query_names`` and ``target_names`` are provided, the plot
    will show each query sequence (rows) against each target sequence
    (columns). If only one set is provided, or neither, all pairwise
    combinations within the available sequences are plotted.

    When *index* is a :class:`~dot_explorer.paf_io.CrossIndex`, use
    *query_group* and *target_group* to specify which groups supply the
    query and target sequences.  The corresponding internal
    (``'group:name'``) identifiers are looked up automatically and used
    for sequence-length queries and k-mer comparisons.  If
    :meth:`~dot_explorer.paf_io.CrossIndex.compute_matches` has already been
    called for that pair, the pre-computed merged alignments are used for
    rendering rather than recomputing on the fly.

    The figure is always returned so it can be displayed inline in a
    Jupyter notebook.  When ``output_path`` is provided the figure is
    also saved to disk.

    Parameters
    ----------
    query_names : list[str], optional
        Sequence names for the y-axis (rows). If ``None``, uses all
        sequences in the index.  Ignored when *query_group* is provided
        and *index* is a ``CrossIndex``.
    target_names : list[str], optional
        Sequence names for the x-axis (columns). If ``None``, uses all
        sequences in the index.  Ignored when *target_group* is provided
        and *index* is a ``CrossIndex``.
    query_group : str or None, optional
        Group label whose sequences are used as query (rows).  When
        provided, the group's sequences are looked up from *index*
        (which must be a ``CrossIndex``) and *query_names* is ignored.
    target_group : str or None, optional
        Group label whose sequences are used as target (columns).  When
        provided, the group's sequences are looked up from *index*
        (which must be a ``CrossIndex``) and *target_names* is ignored.
    output_path : str or Path, optional
        Output image file path.  When ``None`` (default) the figure is
        not saved to disk.  Use a ``.svg`` extension (or set
        ``format='svg'``) to produce an SVG vector image.
    figsize_per_panel : float, optional
        Base size in inches for each subplot panel when
        ``scale_sequences=False``.  When ``scale_sequences=True`` this
        value sets the size of the *longest* sequence axis and all
        other axes are scaled proportionally.  Default is ``4.0``.
    dot_size : float, optional
        Size of each dot in the scatter plot. Default is ``0.5``.
    cap_style : {'butt', 'round', 'projecting'}, optional
        Line cap for match segments.  ``'projecting'`` (the default) and
        ``'round'`` extend the stroke past each endpoint by half the line
        width, so a match shorter than *dot_size* still reads as a mark on
        its own diagonal; with ``'butt'`` such a match is drawn wider
        across the diagonal than along it and appears rotated 90 degrees.
    dot_color : str, optional
        Colour for forward-strand (``+``) match lines. Default is ``"blue"``.
    rc_color : str, optional
        Colour for reverse-complement (``-``) strand match lines.
        Default is ``"red"``.
    merge : bool, optional
        Whether to merge sequential k-mer runs before plotting.
        Default is ``True``.
    title : str, optional
        Overall figure title. If ``None``, no title is added.
    dpi : int, optional
        Resolution of the output image. Default is ``150``.  For vector
        formats this only affects any rasterised match layer (see
        *rasterized*); axes and labels remain resolution-independent.  Raise
        it (e.g. ``dpi=300``) for a higher-resolution raster (PNG) figure.
    scale_sequences : bool, optional
        When ``True`` (default), subplot widths and heights are
        proportional to the lengths of the corresponding sequences so that
        relative sequence sizes are preserved.  When ``False``, every
        panel has the same fixed size.
    format : str, optional
        Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
        When ``None`` (default), the format is inferred from the
        ``output_path`` file extension.
    min_length : int, optional
        Minimum alignment length to display.  Matches shorter than this
        value are not drawn.  Applies to merged k-mer runs and pre-computed
        PAF alignments.  Default is ``0`` (no filtering).
    color_by_identity : bool, optional
        When ``True``, alignments are coloured by sequence identity using
        the *identity_palette* colormap.  Requires a
        :class:`~dot_explorer.paf_io.PafAlignment` to be supplied as
        ``paf_alignment`` to :meth:`__init__`; if no PAF alignment is
        available a warning is logged and the default strand colours are
        used instead.  Default is ``False``.
    identity_palette : str, optional
        Matplotlib colormap name used to map identity values (0–1) to
        colours when ``color_by_identity=True``.  Default is
        ``'viridis'``.
    annotation : GffAnnotation, optional
        Feature annotations to overlay on self-vs-self diagonal panels.
        Each feature is drawn as a transparent coloured square at its
        genomic position, behind the alignment segments (mirrored along
        whichever axes display the contig reverse-complemented).  Sequence names in
        *annotation* that are absent from the index emit a warning.
        Also used as the fallback source for side tracks when
        *annotation_query* / *annotation_target* are not given.
        Default is ``None``.
    annotation_query : GffAnnotation, optional
        Features for the query (y) axis side track.  Default ``None``.
    annotation_target : GffAnnotation, optional
        Features for the target (x) axis side track.  Default ``None``.
    highlight_regions : list[dict], optional
        Bands to shade behind the matches, each
        ``{'axis': 'x'|'y', 'seqname': str, 'start': int, 'end': int,
        'color': str}``.  ``'x'`` shades a column of the panels whose
        target is *seqname*, ``'y'`` a row of those whose query is.
        Coordinates are 0-based half-open in the sequence's own
        orientation and are mirrored here for reverse-displayed
        contigs, so a caller passes feature coordinates and gets the
        band where the feature is drawn.  Used to carry the
        interactive report's feature highlights into a saved figure.
    annotation_tracks : bool, optional
        Draw side annotation tracks (left of the y axis and below the x
        axis) with lane-packed feature shapes, strand arrows for
        gene/mRNA/exon/CDS/ORF features and connector lines joining
        multi-part groups.  Honoured only for **single-pair** (1×1)
        plots — the focused drill-down view — where the tracks have
        room to read; multi-panel grids draw diagonal squares only.
        Default is ``False``.
    annotation_track_size : float, optional
        Side-track thickness in inches.  Default is ``0.6``.
    annotation_legend : bool, optional
        Add a feature-type colour legend to the figure whenever
        annotation features are drawn.  Default is ``True``.
    chain_gap : int, optional
        When greater than ``0``, co-linear match blocks on the same diagonal
        separated by up to *chain_gap* bp are chained into single lines
        before drawing, greatly reducing the number of segments (and thus
        render time and file size) for dense plots.  Default is ``0`` (off).
    rasterized : bool or str, optional
        Whether to rasterise the match layer.  ``'auto'`` (default) keeps it
        true vector — infinitely zoomable in SVG/PDF — until a panel's
        segment count exceeds *rasterization_threshold*, above which that
        layer is rasterised at *dpi* to bound file size.  ``True`` / ``False``
        force the behaviour.  Axes, ticks and labels always stay vector.
    rasterization_threshold : int, optional
        Segment count per strand/panel above which ``rasterized='auto'``
        rasterises the layer.  Default is ``50_000``.
    reverse_contigs : set[str] or None, optional
        Un-prefixed query (row) contig names to render reverse-complemented
        so reverse-oriented contigs read along the main diagonal.  When
        ``None`` (default) the set is pulled automatically from the index:
        :meth:`~dot_explorer.paf_io.CrossIndex.reversed_contigs` for the
        *query_group* of a ``CrossIndex``, or
        :attr:`~dot_explorer.paf_io.PafAlignment.reversed_contigs` for a
        ``PafAlignment`` (both populated by a prior ``reorder`` call).  Pass
        an explicit set (including ``set()`` to disable) to override.
    reverse_targets : set[str] or None, optional
        Un-prefixed target (column) contig names to render
        reverse-complemented on the x axis: target coordinates are
        mirrored (``t → t_len - t``) and the strand flag flipped.  Never
        pulled from the index — ``None`` (default) and ``set()`` both mean
        no target mirroring.  A panel whose query *and* target are both
        mirrored (a self-comparison of a flipped contig) flips the
        strand twice, so colours are unchanged and only coordinates
        move: the contig's self-diagonal stays a forward diagonal.
    contig_order : str or None, optional
        Contig ordering applied before plotting.  ``'length'`` sorts
        contigs by descending sequence length
        (:meth:`~dot_explorer.paf_io.CrossIndex.reorder_by_length` for a
        ``CrossIndex``, otherwise a plain length sort of the resolved name
        lists).  ``'colinearity'`` applies the d-genies gravity ordering
        (:meth:`~dot_explorer.paf_io.CrossIndex.reorder_for_colinearity`,
        computing matches first if needed;
        :meth:`~dot_explorer.paf_io.PafAlignment.reorder_contigs` for a
        ``PafAlignment``;
        :meth:`~dot_explorer.SequenceIndex.optimal_contig_order` for a bare
        ``SequenceIndex``).  Explicit *query_names* / *target_names*
        arguments take precedence: an axis whose names were supplied by
        the caller keeps the caller's order.  ``'colinearity'`` computes
        CrossIndex matches on demand when needed; the cached records
        cover both strands, so subsequent rendering from the cache shows
        reverse-strand alignments too.  An invalid value raises
        :exc:`ValueError`.  Default is ``None`` (no reordering).
    auto_reverse : bool, optional
        When ``True``, reverse-oriented query contigs detected by the
        *contig_order* reorder (via
        :meth:`~dot_explorer.paf_io.CrossIndex.reversed_contigs` or
        :attr:`~dot_explorer.paf_io.PafAlignment.reversed_contigs`) are fed
        into the *reverse_contigs* rendering path so they read along the
        main diagonal.  An explicit *reverse_contigs* argument wins when
        both are given.  Only ``contig_order='colinearity'`` yields
        orientation information; otherwise this option has no effect.
        Default is ``False``.
    hide_internal_axes : bool, optional
        When ``True``, internal panel boundaries are removed so the grid
        reads as one continuous plot: inter-panel gaps collapse to zero,
        ticks and spines shared between adjacent panels are hidden, and
        only the outer frame with its tick labels remains.  Default is
        ``False``.
    identity_colorbar : bool, optional
        When ``True`` and *color_by_identity* is on, append a vertical
        identity colour key (0-100 %) at the right of the figure.
        Ignored without *color_by_identity*.  Default is ``False``.
    embed_sequences : bool, optional
        HTML output only: when ``True``, embed the matched query
        subsequences in the report so its sequence preview / copy
        buttons work in a standalone file (requires an index that
        stores sequences and at most ~2 Mb of total match residues).
        Default is ``False`` — coordinates only, keeping reports small
        even with many alignments.
    tree : Tree, optional
        A :class:`dot_explorer.Tree` (user newick or
        :meth:`~dot_explorer.Tree.from_linkage`) drawn left of the rows.
        The row order is fixed to the tree's leaf order (and the
        column order too when the plot is a self-comparison), so
        *contig_order* and *auto_reverse* cannot be combined with a
        tree.  Tip labels must match the query sequence names; row
        name labels move onto the tree tips so they never obscure it.
        Requires at least 2 rows (grid layouts only).
    tree_width : float, optional
        Width of the tree gutter in inches (floored at 18% of the
        panel-grid width so wide grids do not squash the dendrogram).
        Increase it when long sequence names crowd the tree.
        Default is ``1.6``.
    tree_cutoff : float, optional
        Draw a dashed clustering-cutoff line through the tree at this
        distance from the tips (for linkage trees, ``1 - similarity
        cutoff``).  Default is ``None`` (no line).
    tree_scalebar : bool, optional
        Show a branch-length scale bar under the tree.  Default is
        ``True``.
    cluster_borders : ClusterResult, optional
        Cluster assignments (:func:`dot_explorer.assign_clusters` or
        :func:`dot_explorer.assign_clusters_dual`); each cluster's block
        of panels gets a bold border.  Only meaningful for
        self-comparisons, where rows and columns share an order; a
        cluster that is not contiguous in the display order is
        outlined per contiguous block.
    cluster_border_color : str, optional
        Cluster border colour.  Default is ``'black'``.
    cluster_border_lw : float, optional
        Cluster border line width.  Default is ``2.5``.

    Returns
    -------
    matplotlib.figure.Figure
        The generated figure.  In a Jupyter notebook the figure is
        displayed inline automatically; call ``matplotlib.pyplot.close``
        on the returned object when it is no longer needed.

    Raises
    ------
    ValueError
        If *query_group* / *target_group* are provided but *index* is
        not a ``CrossIndex``.
    """
    if tree is not None:
        # A tree fixes the row order outright; the reordering and
        # reorientation strategies would silently fight it.
        if contig_order is not None:
            raise ValueError('tree fixes the contig order; remove contig_order')
        if auto_reverse:
            raise ValueError(
                'auto_reverse reorients contigs independently of the '
                'tree; remove auto_reverse when passing a tree'
            )

    # Apply the requested contig-ordering strategy (no-op when None).
    query_names, target_names, auto_reverse_set = self._apply_contig_order(
        contig_order, query_group, target_group, query_names, target_names
    )
    if auto_reverse and reverse_contigs is None and auto_reverse_set is not None:
        # Feed detected reverse-oriented contigs into the existing
        # reverse_contigs rendering path (explicit argument wins).
        reverse_contigs = auto_reverse_set

    # Resolve group names and optional pre-computed PAF records.
    query_names, target_names, paf_override = self._resolve_group_names(
        query_group, target_group, query_names, target_names
    )

    cap_style = _resolve_cap_style(cap_style)

    all_names = self.index.sequence_names()
    if not all_names:
        raise ValueError('No sequences in the index.')

    if query_names is None:
        query_names = sorted(all_names)
    if target_names is None:
        target_names = sorted(all_names)

    if tree is not None:
        # Fix the row order to the tree's leaf order.  Tip labels use
        # display names; map back to any group-prefixed internal names.
        if len(query_names) < 2:
            raise ValueError(
                'a tree needs at least 2 query sequences (grid layouts '
                'only, not the single-pair view)'
            )
        display_to_query = {self._strip_group_prefix(n): n for n in query_names}
        tree.validate_labels(list(display_to_query))
        query_names = [display_to_query[n] for n in tree.leaf_names()]
        display_to_target = {self._strip_group_prefix(n): n for n in target_names}
        if set(display_to_target) == set(display_to_query):
            # Self-comparison: keep the matrix symmetric by applying
            # the same order to the columns.
            target_names = [display_to_target[n] for n in tree.leaf_names()]

    # Use the per-call override if available, otherwise fall back to the
    # paf_alignment set at construction time.
    effective_paf = paf_override if paf_override is not None else self.paf_alignment

    # Resolve the set of reverse-oriented query contigs (un-prefixed names).
    # An explicit argument wins; otherwise auto-pull from the index.
    if reverse_contigs is not None:
        reverse_set = set(reverse_contigs)
    elif isinstance(self.index, CrossIndex) and query_group is not None:
        reverse_set = self.index.reversed_contigs(query_group)
    elif isinstance(self.index, PafAlignment):
        reverse_set = set(self.index.reversed_contigs)
    else:
        reverse_set = set()
    reverse_target_set: set[str] = (
        set(reverse_targets) if reverse_targets else set()
    )

    # Warn about annotation sequences missing from the index (compare by
    # display name — annotation files use raw contig names, while a
    # CrossIndex stores group-prefixed internal names).
    if annotation is not None:
        index_seqs = {self._strip_group_prefix(n) for n in all_names}
        for ann_seq in annotation.sequence_names():
            if ann_seq not in index_seqs:
                _log.warning(
                    'Annotation contains features for sequence %r which is '
                    'not present in the index. These features will not be '
                    'plotted.',
                    ann_seq,
                )

    nrows = len(query_names)
    ncols = len(target_names)

    # Side tracks draw from the per-axis annotations, falling back to
    # the shared *annotation*.  They are honoured only for single-pair
    # plots (the focused drill-down view) — in an N×M grid a per-row/
    # column track would be unreadable, so grids get diagonal squares.
    track_ann_q = annotation_query if annotation_query is not None else annotation
    track_ann_t = annotation_target if annotation_target is not None else annotation
    tracks_on = (
        annotation_tracks
        and nrows == 1
        and ncols == 1
        and (track_ann_q is not None or track_ann_t is not None)
    )

    flush_kw: dict[str, float] = (
        {'wspace': 0.0, 'hspace': 0.0} if hide_internal_axes else {}
    )

    # Activate per-panel segment capture when the destination is an HTML
    # report so _plot_panel / the draw helpers can gid-tag artists and
    # record the exact segments they draw.  Reset unconditionally first so
    # a previous failed plot cannot leak stale capture state.
    self._html_capture = None
    if output_path is not None and self._is_html_output(output_path, format):
        self._html_capture = {
            'panels': {},
            'ncols': ncols,
            'counter': 0,
            'current': None,
        }

    y_track_ax = None
    x_track_ax = None
    tree_ax = None
    if tracks_on:
        # Single-pair layout with side annotation tracks: a 2×2 gridspec
        # (mirroring plot_single) — y-track left of the main panel,
        # x-track below it, empty corner.  The main panel keeps the
        # sequences' aspect ratio (like the grid layout does).
        if scale_sequences:
            q_len_bp = self.index.get_sequence_length(query_names[0])
            t_len_bp = self.index.get_sequence_length(target_names[0])
            max_len = max(q_len_bp, t_len_bp, 1)
            fig_w = figsize_per_panel * (t_len_bp / max_len)
            fig_h = figsize_per_panel * (q_len_bp / max_len)
        else:
            fig_w = fig_h = figsize_per_panel
        ts = annotation_track_size
        fig = plt.figure(figsize=(fig_w + ts, fig_h + ts))
        gs = fig.add_gridspec(
            2,
            2,
            width_ratios=[ts, fig_w],
            height_ratios=[fig_h, ts],
            hspace=0.03,
            wspace=0.03,
        )
        main_ax = fig.add_subplot(gs[0, 1])
        if track_ann_q is not None:
            y_track_ax = fig.add_subplot(gs[0, 0], sharey=main_ax)
        if track_ann_t is not None:
            x_track_ax = fig.add_subplot(gs[1, 1], sharex=main_ax)
        axes = [[main_ax]]
    elif scale_sequences:
        q_lens = [self.index.get_sequence_length(n) for n in query_names]
        t_lens = [self.index.get_sequence_length(n) for n in target_names]
        max_len = max(max(q_lens), max(t_lens), 1)
        col_widths = [figsize_per_panel * (seq_len / max_len) for seq_len in t_lens]
        row_heights = [
            figsize_per_panel * (seq_len / max_len) for seq_len in q_lens
        ]
        fig_w = sum(col_widths)
        fig_h = sum(row_heights)
        if tree is not None:
            fig, axes, tree_ax = self._make_tree_grid(
                fig_w, fig_h, col_widths, row_heights, flush_kw, tree_width
            )
        else:
            fig, axes = plt.subplots(
                nrows,
                ncols,
                figsize=(fig_w, fig_h),
                squeeze=False,
                gridspec_kw={
                    'width_ratios': col_widths,
                    'height_ratios': row_heights,
                    **flush_kw,
                },
            )
    else:
        fig_w = figsize_per_panel * ncols
        fig_h = figsize_per_panel * nrows
        if tree is not None:
            fig, axes, tree_ax = self._make_tree_grid(
                fig_w,
                fig_h,
                [figsize_per_panel] * ncols,
                [figsize_per_panel] * nrows,
                flush_kw,
                tree_width,
            )
        else:
            fig, axes = plt.subplots(
                nrows,
                ncols,
                figsize=(fig_w, fig_h),
                squeeze=False,
                gridspec_kw=flush_kw if hide_internal_axes else None,
            )

    for row_idx, q_name in enumerate(query_names):
        for col_idx, t_name in enumerate(target_names):
            ax = axes[row_idx][col_idx]
            self._plot_panel(
                ax,
                q_name,
                t_name,
                dot_size=dot_size,
                cap_style=cap_style,
                dot_color=dot_color,
                rc_color=rc_color,
                merge=merge,
                min_length=min_length,
                # Sequence name labels: y-label on leftmost column only;
                # column (x) labels are shown as titles on the top row.
                # With a tree the names live on the tree tips instead,
                # so panel row labels would double up and crowd it.
                show_xlabel=False,
                show_ylabel=(col_idx == 0 and tree is None),
                color_by_identity=color_by_identity,
                identity_palette=identity_palette,
                paf_alignment_override=effective_paf,
                chain_gap=chain_gap,
                rasterized=rasterized,
                rasterization_threshold=rasterization_threshold,
                reverse_query=self._strip_group_prefix(q_name) in reverse_set,
                reverse_target=self._strip_group_prefix(t_name)
                in reverse_target_set,
            )

            # Row label rotation: a vertical (90 deg) contig name is as
            # tall as it is long, so on a grid of unequal contigs the
            # short rows' labels overflow their panels and smear over
            # each other.  Angling them to match the column titles cuts
            # the vertical extent and keeps them legible.  Only needed
            # when there is more than one row to collide with.
            if col_idx == 0 and nrows > 1:
                label = ax.yaxis.label
                label.set_rotation(_ROW_LABEL_ROTATION)
                label.set_ha('right')
                label.set_va('center')
                label.set_rotation_mode('anchor')

            # Column label at top of each column (top row only), rotated.
            # Use the display name (strip group prefix for CrossIndex).
            # Focused single-pair views label the axes instead (below).
            if row_idx == 0 and not (nrows == 1 and ncols == 1):
                ax.set_title(
                    self._strip_group_prefix(t_name),
                    fontsize=8,
                    rotation=45,
                    ha='left',
                    va='bottom',
                )

            # Suppress redundant tick labels on internal panels.
            if row_idx < nrows - 1:
                ax.tick_params(axis='x', labelbottom=False)
            if col_idx > 0:
                ax.tick_params(axis='y', labelleft=False)

            # Remove internal ticks and spines so the grid reads as one
            # continuous plot, keeping the outer frame intact.
            if hide_internal_axes:
                if row_idx < nrows - 1:
                    ax.tick_params(axis='x', bottom=False)
                    ax.spines['bottom'].set_visible(False)
                if row_idx > 0:
                    ax.spines['top'].set_visible(False)
                if col_idx > 0:
                    ax.tick_params(axis='y', left=False)
                    ax.spines['left'].set_visible(False)
                if col_idx < ncols - 1:
                    ax.spines['right'].set_visible(False)

            # Feature highlight bands, behind everything else in the
            # panel so matches and annotation squares stay readable
            # through them.
            if highlight_regions:
                self._draw_highlight_bands(
                    ax,
                    highlight_regions,
                    q_name=q_name,
                    t_name=t_name,
                    reverse_set=reverse_set,
                    reverse_target_set=reverse_target_set,
                )

            # Annotation squares on self-vs-self (diagonal) panels,
            # drawn behind the alignments and mirrored with the axis.
            # A CrossIndex self-comparison stores the same sequence
            # under two group prefixes ('query:c1' vs 'target:c1'), so
            # also treat equal display names as self when the lengths
            # match (two *different* assemblies sharing a contig name
            # will almost never share its exact length too).
            is_self_panel = q_name == t_name or (
                self._strip_group_prefix(q_name) == self._strip_group_prefix(t_name)
                and self.index.get_sequence_length(q_name)
                == self.index.get_sequence_length(t_name)
            )
            if annotation is not None and is_self_panel:
                reverse = self._strip_group_prefix(q_name) in reverse_set
                reverse_x = self._strip_group_prefix(t_name) in reverse_target_set
                annot_gid = (
                    f'de-annot-{row_idx}-{col_idx}'
                    if self._html_capture is not None
                    else None
                )
                drawn = self._draw_annotation_squares(
                    ax,
                    q_name,
                    annotation,
                    reverse=reverse,
                    reverse_x=reverse_x,
                    gid=annot_gid,
                )
                if self._html_capture is not None and drawn:
                    panel = self._html_capture['panels'][
                        f'de-panel-{row_idx}-{col_idx}'
                    ]
                    # One entry per patch, in draw order — the report JS
                    # maps SVG children back by index.
                    panel['annotations'] = [
                        {
                            'type': f.feature_type,
                            'seqname': f.seqname,
                            'start': int(f.start),
                            'end': int(f.end),
                            'strand': f.strand,
                            'id': f.feature_id,
                            'parent': f.parent,
                            'name': f.name,
                            'source': f.source,
                        }
                        for f in drawn
                    ]

    # Focused single-pair views: enforce exact bp-per-inch parity on
    # both axes.  The proportional figsize only approximates it (axis
    # labels and titles skew the final axes box slightly).  Shared track
    # axes forbid box-adjustable aspect, so the tracks layout relies on
    # its proportional gridspec instead.
    if nrows == 1 and ncols == 1 and scale_sequences and not tracks_on:
        axes[0][0].set_aspect('equal', adjustable='box')

    # Side annotation tracks (single-pair layout only).
    drew_track_features = False
    if tracks_on:
        main_ax = axes[0][0]
        q_name = query_names[0]
        t_name = target_names[0]
        # Only the interactive report needs gids and a feature payload;
        # static output keeps the untagged artists it always had.
        capturing = self._html_capture is not None
        track_records: dict[str, list] = {'x': [], 'y': []}
        if y_track_ax is not None:
            lanes = draw_track(
                y_track_ax,
                track_ann_q,
                self._strip_group_prefix(q_name),
                self.index.get_sequence_length(q_name),
                orientation='y',
                reverse=self._strip_group_prefix(q_name) in reverse_set,
                gid_prefix='de-ytrack' if capturing else None,
                record_into=track_records['y'] if capturing else None,
            )
            drew_track_features = drew_track_features or lanes > 0
            # The y track owns the left edge: move the panel's tick
            # labels out of its way.
            main_ax.tick_params(axis='y', labelleft=False)
        if x_track_ax is not None:
            lanes = draw_track(
                x_track_ax,
                track_ann_t,
                self._strip_group_prefix(t_name),
                self.index.get_sequence_length(t_name),
                orientation='x',
                reverse=self._strip_group_prefix(t_name) in reverse_target_set,
                gid_prefix='de-xtrack' if capturing else None,
                record_into=track_records['x'] if capturing else None,
            )
            drew_track_features = drew_track_features or lanes > 0
            main_ax.tick_params(axis='x', labelbottom=False)
        if capturing:
            self._html_capture['tracks'] = track_records

    # Focused single-pair views: contig names become conventional axis
    # labels — left of the y axis, below the x axis — and ticks read in
    # bp/Kbp/Mbp units instead of matplotlib's scientific offset text.
    if nrows == 1 and ncols == 1:
        main_ax = axes[0][0]
        q_name = query_names[0]
        t_name = target_names[0]
        q_len = self.index.get_sequence_length(q_name)
        t_len = self.index.get_sequence_length(t_name)
        x_unit = _apply_bp_units(main_ax.xaxis, t_len)
        y_unit = _apply_bp_units(main_ax.yaxis, q_len)
        x_label = f'{self._strip_group_prefix(t_name)} ({x_unit})'
        y_label = f'{self._strip_group_prefix(q_name)} ({y_unit})'
        # When a side annotation track occupies an axis edge, its outer
        # edge carries the tick labels and the name label pads past the
        # whole band (fixed inches -> points).
        track_pad = annotation_track_size * 72.0 + _TICK_LABEL_PAD_PTS
        if x_track_ax is not None and x_track_ax.axison:
            _apply_bp_units(x_track_ax.xaxis, t_len)
            x_track_ax.tick_params(
                axis='x', bottom=True, labelbottom=True, labelsize=6
            )
            main_ax.set_xlabel(x_label, fontsize=8, labelpad=track_pad)
        else:
            main_ax.tick_params(axis='x', labelbottom=True)
            main_ax.set_xlabel(x_label, fontsize=8)
        if y_track_ax is not None and y_track_ax.axison:
            _apply_bp_units(y_track_ax.yaxis, q_len)
            y_track_ax.tick_params(axis='y', left=True, labelleft=True, labelsize=6)
            main_ax.set_ylabel(y_label, fontsize=8, labelpad=track_pad)
        else:
            main_ax.tick_params(axis='y', labelleft=True)
            main_ax.set_ylabel(y_label, fontsize=8)
    else:
        # Multi-panel grids: raw-bp tick labels are long enough to
        # overlap along the x axis.  Use one bp/Kbp/Mbp unit across all
        # contigs (chosen from the longest, so positions stay
        # comparable between panels) and angle the x tick labels; the
        # shared unit is announced once per axis via a figure label.
        max_len = max(
            self.index.get_sequence_length(n) for n in (*query_names, *target_names)
        )
        for row in axes:
            for ax in row:
                _apply_bp_units(ax.xaxis, max_len)
                _apply_bp_units(ax.yaxis, max_len)
                plt.setp(
                    ax.get_xticklabels(),
                    rotation=45,
                    ha='right',
                    rotation_mode='anchor',
                )
        _divisor, grid_unit = _bp_unit(max_len)
        fig.supxlabel(f'Position ({grid_unit})', fontsize=8)
        fig.supylabel(f'Position ({grid_unit})', fontsize=8)

    # Feature-type colour legend whenever annotation features are shown.
    annotations_shown = [
        ann
        for ann in (annotation, annotation_query, annotation_target)
        if ann is not None
    ]
    if (
        annotation_legend
        and annotations_shown
        and (annotation is not None or drew_track_features)
    ):
        handles: dict[str, mpatches.Patch] = {}
        for ann in annotations_shown:
            for handle in annotation_legend_handles(ann):
                handles.setdefault(handle.get_label(), handle)
        fig.legend(
            handles=list(handles.values()),
            loc='upper left',
            bbox_to_anchor=(1.005, 0.95),
            bbox_transform=fig.transFigure,
            fontsize=8,
            frameon=False,
            title='Features',
            title_fontsize=9,
        )

    if title and not (nrows == 1 and ncols == 1):
        fig.suptitle(title, fontsize=14, y=1.01)

    if nrows == 1 and ncols == 1:
        # Focused single-pair views: reserve absolute (inch) margins for
        # the title and axis labels.  The proportional figure can be a
        # very thin strip for extreme length ratios, so fractional
        # margins (and a figure-fraction suptitle y) collapse to nothing
        # — grow the canvas around the untouched panel region instead.
        # The subplot region keeps exactly its original size, so the
        # panel's bp-per-inch aspect (and the tracks gridspec ratios)
        # are preserved.
        margins = _FOCUS_MARGIN_IN
        top_in = margins['top_title'] if title else margins['top_plain']
        left_in = margins['left']
        panel_w, panel_h = fig.get_size_inches()
        # In the tracks layout the figure includes the fixed track band;
        # the panel itself is the remainder on each dimension.
        eff_w = panel_w - (annotation_track_size if tracks_on else 0.0)
        eff_h = panel_h - (annotation_track_size if tracks_on else 0.0)
        main_ax = axes[0][0]
        # A rotated y label taller than a thin panel would protrude into
        # the title band; render it horizontally instead and widen the
        # left margin to fit the text.
        y_text = main_ax.get_ylabel()
        est_label_in = len(y_text) * 8.0 * 0.62 / 72.0
        if y_text and est_label_in > eff_h:
            label = main_ax.yaxis.label
            label.set_rotation(0)
            label.set_ha('right')
            label.set_va('center')
            left_in = max(left_in, est_label_in + 0.55)
        # Thin panels cannot fit the default tick density without the
        # position labels colliding — scale the tick count to the
        # physical axis length (track axes carry the visible labels in
        # the tracks layout and have their own locators).
        for length_in, span, axis_objs in (
            (
                eff_h,
                self.index.get_sequence_length(query_names[0]),
                [main_ax.yaxis]
                + ([y_track_ax.yaxis] if y_track_ax is not None else []),
            ),
            (
                eff_w,
                self.index.get_sequence_length(target_names[0]),
                [main_ax.xaxis]
                + ([x_track_ax.xaxis] if x_track_ax is not None else []),
            ),
        ):
            if length_in < 0.4:
                # Too thin for more than one label without overlap: a
                # single end tick states the sequence's full length.
                locator = mticker.FixedLocator([span])
            elif length_in < 1.5:
                locator = mticker.MaxNLocator(nbins=max(1, int(length_in * 2.5)))
            else:
                continue
            for axis_obj in axis_objs:
                axis_obj.set_major_locator(locator)
        total_w = panel_w + left_in + margins['right']
        total_h = panel_h + top_in + margins['bottom']
        fig.set_size_inches(total_w, total_h)
        fig.subplots_adjust(
            left=left_in / total_w,
            right=1 - margins['right'] / total_w,
            bottom=margins['bottom'] / total_h,
            top=1 - top_in / total_h,
        )
        if title:
            # A fixed physical offset below the canvas top keeps the
            # title clear of the plot area at any figure height.
            fig.suptitle(title, fontsize=14, y=1 - 0.1 / total_h, va='top')
    elif hide_internal_axes:
        # tight_layout() would reinsert inter-panel gaps; keep the panels
        # flush and just leave margins for the outer labels and titles.
        left_frac = 0.1
        if nrows > 1 and query_names:
            # Angled row labels stick out to the left by
            # len * cos(rotation); the fixed 10% is a fraction of figure
            # width, so a wide grid has room to spare and a narrow one
            # clips.  Size it from the longest name instead, plus space
            # for the tick labels and the shared 'Position' label.
            longest = max(len(self._strip_group_prefix(n)) for n in query_names)
            text_in = longest * 8.0 * 0.62 / 72.0
            needed_in = text_in * math.cos(math.radians(_ROW_LABEL_ROTATION))
            fig_w = fig.get_size_inches()[0]
            # Cap at 40% so a pathological name can never squeeze the
            # panels out of existence.
            left_frac = min(0.4, max(left_frac, (needed_in + 0.6) / fig_w))
        fig.subplots_adjust(
            left=left_frac,
            right=0.98,
            bottom=0.06,
            top=0.92,
            wspace=0.0,
            hspace=0.0,
        )
    else:
        plt.tight_layout()
    if nrows > 1 and tree is None:
        # Panel geometry is only final once the margins are set, so the
        # row labels are fitted to their rows here rather than in the
        # drawing loop above.  (With a tree there are no row labels —
        # the names sit on the tree tips.)
        self._fit_row_labels(fig, axes, nrows)
    if color_by_identity and identity_colorbar:
        # After the layout pass: fig.colorbar steals its own space from
        # the panel axes, which tight_layout would otherwise fight.
        sm = matplotlib.cm.ScalarMappable(
            norm=mcolors.Normalize(vmin=0, vmax=1),
            cmap=plt.get_cmap(identity_palette),
        )
        cbar = fig.colorbar(sm, ax=fig.axes, fraction=0.035, pad=0.02, aspect=35)
        cbar.set_label('Identity (%)')
        cbar.set_ticks([0.0, 0.25, 0.5, 0.75, 1.0])
        cbar.set_ticklabels(['0', '25', '50', '75', '100'])
    if nrows == 1 and ncols == 1 and title and fig._suptitle is not None:
        # The colorbar (and any figure legend outside the canvas) shifts
        # the panel off figure-centre; centre the title on the panel, not
        # the figure.  Must run after the colorbar has stolen its width.
        pos = axes[0][0].get_position()
        fig._suptitle.set_x((pos.x0 + pos.x1) / 2)
    # Tree and cluster borders are drawn from the panels' final figure
    # positions, so they must come after every layout adjustment above
    # (nothing may call tight_layout past this point).
    if tree is not None and tree_ax is not None:
        self._draw_axis_tree(
            tree_ax,
            tree,
            axes,
            query_names,
            cutoff=tree_cutoff,
            scalebar=tree_scalebar,
        )
    if cluster_borders is not None:
        self._draw_cluster_borders(
            fig,
            axes,
            query_names,
            target_names,
            cluster_borders,
            color=cluster_border_color,
            lw=cluster_border_lw,
        )
    if output_path is not None:
        self._save_figure(
            fig,
            output_path,
            dpi=dpi,
            format=format,
            title=title,
            embed_sequences=embed_sequences,
        )
    return fig

plot_annotation_legend(annotation, output_path=None, figsize=(3.0, 4.0), dpi=150, format=None)

Render the annotation feature-type legend as a standalone figure.

Produces a figure containing only a colour legend that maps each feature type to its assigned colour. This is intended to be displayed alongside dotplots produced with an annotation argument.

Parameters:

Name Type Description Default
annotation GffAnnotation

The annotation object whose feature-type colours are displayed.

required
output_path str or Path

Output image file path. When None (default) the figure is not saved to disk.

None
figsize tuple[float, float]

Figure size as (width, height) in inches. Default is (3.0, 4.0).

(3.0, 4.0)
dpi int

Output image resolution. Default is 150.

150
format str

Output image format (e.g. 'png', 'svg', 'pdf'). When None (default), the format is inferred from the output_path file extension.

None

Returns:

Type Description
Figure

A figure containing only the legend.

Source code in dot_explorer/dotplot.py
def plot_annotation_legend(
    self,
    annotation: 'GffAnnotation',
    output_path: Optional[Union[str, Path]] = None,
    figsize: tuple[float, float] = (3.0, 4.0),
    dpi: int = 150,
    format: Optional[str] = None,
) -> matplotlib.figure.Figure:
    """Render the annotation feature-type legend as a standalone figure.

    Produces a figure containing only a colour legend that maps each
    feature type to its assigned colour.  This is intended to be
    displayed alongside dotplots produced with an *annotation* argument.

    Parameters
    ----------
    annotation : GffAnnotation
        The annotation object whose feature-type colours are displayed.
    output_path : str or Path, optional
        Output image file path.  When ``None`` (default) the figure is
        not saved to disk.
    figsize : tuple[float, float], optional
        Figure size as ``(width, height)`` in inches.
        Default is ``(3.0, 4.0)``.
    dpi : int, optional
        Output image resolution. Default is ``150``.
    format : str, optional
        Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
        When ``None`` (default), the format is inferred from the
        ``output_path`` file extension.

    Returns
    -------
    matplotlib.figure.Figure
        A figure containing only the legend.
    """
    # Legend figures never support HTML capture; clear any stale capture
    # left by an aborted plot() so _save_figure raises cleanly for HTML.
    self._html_capture = None
    handles = [
        mpatches.Patch(
            facecolor=annotation.get_color(ft),
            edgecolor='none',
            label=ft,
        )
        for ft in annotation.feature_types()
    ]
    fig, ax = plt.subplots(figsize=figsize)
    ax.set_visible(False)
    fig.legend(handles=handles, loc='center', fontsize=10, frameon=True)
    plt.tight_layout()
    if output_path is not None:
        self._save_figure(fig, output_path, dpi=dpi, format=format)
    return fig

plot_single(query_name, target_name, query_group=None, target_group=None, output_path=None, figsize=(6.0, 6.0), dot_size=0.5, cap_style='projecting', dot_color='blue', rc_color='red', merge=True, title=None, dpi=150, format=None, min_length=0, color_by_identity=False, identity_palette='viridis', annotation=None, annotation_track_size=0.4, chain_gap=0, rasterized='auto', rasterization_threshold=50000)

Plot a single pairwise dotplot.

When annotation is provided, a linear annotation track is drawn below the x-axis (target sequence features) and to the left of the y-axis (query sequence features).

When index is a :class:~dot_explorer.paf_io.CrossIndex, supply query_group and target_group to have the sequence names resolved to internal ('group:name') identifiers automatically, and to render from pre-computed records when available.

Parameters:

Name Type Description Default
query_name str

Name of the query sequence (y-axis). When query_group is provided and index is a CrossIndex, this is treated as an un-prefixed name and the internal identifier is looked up.

required
target_name str

Name of the target sequence (x-axis). Same note as query_name.

required
query_group str or None

Group label for the query sequence. When provided and index is a CrossIndex, the internal name is resolved as '{query_group}:{query_name}'.

None
target_group str or None

Group label for the target sequence. When provided and index is a CrossIndex, the internal name is resolved as '{target_group}:{target_name}'.

None
output_path str or Path

Output image file path. When None (default) the figure is not saved to disk. Use a .svg extension (or set format='svg') to produce an SVG vector image.

None
figsize tuple[float, float]

Figure size as (width, height) in inches for the main dotplot panel. When annotation tracks are added the overall figure will be slightly larger. Default is (6, 6).

(6.0, 6.0)
dot_size float

Marker/line size for each match. Default is 0.5.

0.5
cap_style ('butt', 'round', 'projecting')

Line cap for match segments. 'projecting' (the default) and 'round' extend the stroke past each endpoint by half the line width, so a match shorter than dot_size still reads as a mark on its own diagonal; with 'butt' such a match is drawn wider across the diagonal than along it and appears rotated 90 degrees.

'butt'
dot_color str

Colour for forward-strand (+) matches. Default is "blue".

'blue'
rc_color str

Colour for reverse-complement (-) matches. Default is "red".

'red'
merge bool

Whether to merge sequential k-mer runs. Default is True.

True
title str

Plot title. If None, a default title is used.

None
dpi int

Output image resolution. Default is 150.

150
format str

Output image format (e.g. 'png', 'svg', 'pdf'). When None (default), the format is inferred from the output_path file extension.

None
min_length int

Minimum alignment length to display. Matches shorter than this value are not drawn. Applies to merged k-mer runs and pre-computed PAF alignments. Default is 0 (no filtering).

0
color_by_identity bool

When True, alignments are coloured by sequence identity using the identity_palette colormap. Requires a :class:~dot_explorer.paf_io.PafAlignment to be supplied as paf_alignment to :meth:__init__; if no PAF alignment is available a warning is logged and the default strand colours are used instead. Default is False.

False
identity_palette str

Matplotlib colormap name used to map identity values (0–1) to colours when color_by_identity=True. Default is 'viridis'.

'viridis'
annotation GffAnnotation

Feature annotations to display as linear tracks flanking the dotplot. Target features are drawn below the x-axis; query features are drawn to the left of the y-axis. Sequence names in annotation absent from the index emit a warning. Default is None.

None
annotation_track_size float

Height/width in inches of each annotation track. Default is 0.4.

0.4
chain_gap int

When greater than 0, chain co-linear match blocks on the same diagonal separated by up to chain_gap bp into single lines before drawing. Default is 0 (off). See :meth:plot.

0
rasterized bool or str

Whether to rasterise the match layer; 'auto' (default) keeps it true vector until the segment count exceeds rasterization_threshold. See :meth:plot.

'auto'
rasterization_threshold int

Segment count above which rasterized='auto' rasterises the layer. Default is 50_000.

50000

Returns:

Type Description
Figure

The generated figure. In a Jupyter notebook the figure is displayed inline automatically; call matplotlib.pyplot.close on the returned object when it is no longer needed.

Raises:

Type Description
ValueError

If query_group / target_group are provided but index is not a CrossIndex.

Source code in dot_explorer/dotplot.py
def plot_single(
    self,
    query_name: str,
    target_name: str,
    query_group: Optional[str] = None,
    target_group: Optional[str] = None,
    output_path: Optional[Union[str, Path]] = None,
    figsize: tuple[float, float] = (6.0, 6.0),
    dot_size: float = 0.5,
    cap_style: str = 'projecting',
    dot_color: str = 'blue',
    rc_color: str = 'red',
    merge: bool = True,
    title: Optional[str] = None,
    dpi: int = 150,
    format: Optional[str] = None,
    min_length: int = 0,
    color_by_identity: bool = False,
    identity_palette: str = 'viridis',
    annotation: Optional['GffAnnotation'] = None,
    annotation_track_size: float = 0.4,
    chain_gap: int = 0,
    rasterized: Union[bool, str] = 'auto',
    rasterization_threshold: int = 50_000,
) -> matplotlib.figure.Figure:
    """Plot a single pairwise dotplot.

    When *annotation* is provided, a linear annotation track is drawn
    below the x-axis (target sequence features) and to the left of the
    y-axis (query sequence features).

    When *index* is a :class:`~dot_explorer.paf_io.CrossIndex`, supply
    *query_group* and *target_group* to have the sequence names resolved
    to internal (``'group:name'``) identifiers automatically, and to
    render from pre-computed records when available.

    Parameters
    ----------
    query_name : str
        Name of the query sequence (y-axis).  When *query_group* is
        provided and *index* is a ``CrossIndex``, this is treated as an
        un-prefixed name and the internal identifier is looked up.
    target_name : str
        Name of the target sequence (x-axis).  Same note as *query_name*.
    query_group : str or None, optional
        Group label for the query sequence.  When provided and *index* is
        a ``CrossIndex``, the internal name is resolved as
        ``'{query_group}:{query_name}'``.
    target_group : str or None, optional
        Group label for the target sequence.  When provided and *index*
        is a ``CrossIndex``, the internal name is resolved as
        ``'{target_group}:{target_name}'``.
    output_path : str or Path, optional
        Output image file path.  When ``None`` (default) the figure is
        not saved to disk.  Use a ``.svg`` extension (or set
        ``format='svg'``) to produce an SVG vector image.
    figsize : tuple[float, float], optional
        Figure size as (width, height) in inches for the main dotplot
        panel.  When annotation tracks are added the overall figure will
        be slightly larger.  Default is ``(6, 6)``.
    dot_size : float, optional
        Marker/line size for each match. Default is ``0.5``.
    cap_style : {'butt', 'round', 'projecting'}, optional
        Line cap for match segments.  ``'projecting'`` (the default) and
        ``'round'`` extend the stroke past each endpoint by half the line
        width, so a match shorter than *dot_size* still reads as a mark on
        its own diagonal; with ``'butt'`` such a match is drawn wider
        across the diagonal than along it and appears rotated 90 degrees.
    dot_color : str, optional
        Colour for forward-strand (``+``) matches. Default is ``"blue"``.
    rc_color : str, optional
        Colour for reverse-complement (``-``) matches. Default is ``"red"``.
    merge : bool, optional
        Whether to merge sequential k-mer runs. Default is ``True``.
    title : str, optional
        Plot title. If ``None``, a default title is used.
    dpi : int, optional
        Output image resolution. Default is ``150``.
    format : str, optional
        Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
        When ``None`` (default), the format is inferred from the
        ``output_path`` file extension.
    min_length : int, optional
        Minimum alignment length to display.  Matches shorter than this
        value are not drawn.  Applies to merged k-mer runs and pre-computed
        PAF alignments.  Default is ``0`` (no filtering).
    color_by_identity : bool, optional
        When ``True``, alignments are coloured by sequence identity using
        the *identity_palette* colormap.  Requires a
        :class:`~dot_explorer.paf_io.PafAlignment` to be supplied as
        ``paf_alignment`` to :meth:`__init__`; if no PAF alignment is
        available a warning is logged and the default strand colours are
        used instead.  Default is ``False``.
    identity_palette : str, optional
        Matplotlib colormap name used to map identity values (0–1) to
        colours when ``color_by_identity=True``.  Default is
        ``'viridis'``.
    annotation : GffAnnotation, optional
        Feature annotations to display as linear tracks flanking the
        dotplot.  Target features are drawn below the x-axis; query
        features are drawn to the left of the y-axis.  Sequence names
        in *annotation* absent from the index emit a warning.
        Default is ``None``.
    annotation_track_size : float, optional
        Height/width in inches of each annotation track.
        Default is ``0.4``.
    chain_gap : int, optional
        When greater than ``0``, chain co-linear match blocks on the same
        diagonal separated by up to *chain_gap* bp into single lines before
        drawing.  Default is ``0`` (off).  See :meth:`plot`.
    rasterized : bool or str, optional
        Whether to rasterise the match layer; ``'auto'`` (default) keeps it
        true vector until the segment count exceeds
        *rasterization_threshold*.  See :meth:`plot`.
    rasterization_threshold : int, optional
        Segment count above which ``rasterized='auto'`` rasterises the
        layer.  Default is ``50_000``.

    Returns
    -------
    matplotlib.figure.Figure
        The generated figure.  In a Jupyter notebook the figure is
        displayed inline automatically; call ``matplotlib.pyplot.close``
        on the returned object when it is no longer needed.

    Raises
    ------
    ValueError
        If *query_group* / *target_group* are provided but *index* is
        not a ``CrossIndex``.
    """
    cap_style = _resolve_cap_style(cap_style)

    # Single-panel figures do not support HTML capture (v1 covers the
    # grid plot() path only); clear any stale capture defensively.
    self._html_capture = None

    # Resolve group-prefixed names for CrossIndex.
    if query_group is not None or target_group is not None:
        if not self._index_is_cross():
            raise ValueError(
                'query_group and target_group can only be used when index '
                'is a CrossIndex.'
            )
        cross = self.index  # type: ignore[assignment]
        if query_group is not None:
            query_name = cross.make_internal_name(query_group, query_name)
        if target_group is not None:
            target_name = cross.make_internal_name(target_group, target_name)

    # Use pre-computed records when available (via shared helper).
    paf_override = self._get_paf_override(query_group, target_group)
    effective_paf = paf_override if paf_override is not None else self.paf_alignment

    import matplotlib.gridspec as gridspec

    if annotation is not None:
        # Warn about annotation sequences not in the index.
        index_seqs = set(self.index.sequence_names())
        for ann_seq in annotation.sequence_names():
            if ann_seq not in index_seqs:
                _log.warning(
                    'Annotation contains features for sequence %r which is '
                    'not present in the index. These features will not be '
                    'plotted.',
                    ann_seq,
                )
        has_tracks = True
    else:
        has_tracks = False

    if has_tracks:
        fw, fh = figsize
        ts = annotation_track_size
        # GridSpec layout:
        #   rows: [main (fh), x-track (ts)]
        #   cols: [y-track (ts), main (fw)]
        total_w = fw + ts
        total_h = fh + ts
        fig = plt.figure(figsize=(total_w, total_h))
        gs = gridspec.GridSpec(
            2,
            2,
            width_ratios=[ts, fw],
            height_ratios=[fh, ts],
            hspace=0.02,
            wspace=0.02,
        )
        main_ax = fig.add_subplot(gs[0, 1])
        y_track_ax = fig.add_subplot(gs[0, 0], sharey=main_ax)
        x_track_ax = fig.add_subplot(gs[1, 1], sharex=main_ax)
        corner_ax = fig.add_subplot(gs[1, 0])
        corner_ax.set_visible(False)
    else:
        fig, main_ax = plt.subplots(figsize=figsize)

    self._plot_panel(
        main_ax,
        query_name,
        target_name,
        dot_size=dot_size,
        cap_style=cap_style,
        dot_color=dot_color,
        rc_color=rc_color,
        merge=merge,
        min_length=min_length,
        color_by_identity=color_by_identity,
        identity_palette=identity_palette,
        paf_alignment_override=effective_paf,
        chain_gap=chain_gap,
        rasterized=rasterized,
        rasterization_threshold=rasterization_threshold,
    )

    # Contig names as conventional axis labels with bp/Kbp/Mbp ticks —
    # matching plot()'s focused single-pair view.
    display_t = self._strip_group_prefix(target_name)
    display_q = self._strip_group_prefix(query_name)
    q_len = self.index.get_sequence_length(query_name)
    t_len = self.index.get_sequence_length(target_name)
    x_unit = _apply_bp_units(main_ax.xaxis, t_len)
    y_unit = _apply_bp_units(main_ax.yaxis, q_len)
    x_label = f'{display_t} ({x_unit})'
    y_label = f'{display_q} ({y_unit})'
    if has_tracks:
        # The tracks own the near axis edges: their outer edges carry
        # the tick labels and the name labels pad past the whole band.
        main_ax.tick_params(axis='x', labelbottom=False)
        main_ax.tick_params(axis='y', labelleft=False)

        # Shared lane-packed track rendering (strand arrows, rounded
        # rectangles, multi-part connectors) — identical to plot()'s
        # focused single-pair view.
        draw_track(
            x_track_ax,
            annotation,  # type: ignore[arg-type]
            display_t,
            t_len,
            orientation='x',
        )
        draw_track(
            y_track_ax,
            annotation,  # type: ignore[arg-type]
            display_q,
            q_len,
            orientation='y',
        )
        track_pad = annotation_track_size * 72.0 + _TICK_LABEL_PAD_PTS
        if x_track_ax.axison:
            _apply_bp_units(x_track_ax.xaxis, t_len)
            x_track_ax.tick_params(
                axis='x', bottom=True, labelbottom=True, labelsize=6
            )
            main_ax.set_xlabel(x_label, fontsize=8, labelpad=track_pad)
        else:
            main_ax.tick_params(axis='x', labelbottom=True)
            main_ax.set_xlabel(x_label, fontsize=8)
        if y_track_ax.axison:
            _apply_bp_units(y_track_ax.yaxis, q_len)
            y_track_ax.tick_params(axis='y', left=True, labelleft=True, labelsize=6)
            main_ax.set_ylabel(y_label, fontsize=8, labelpad=track_pad)
        else:
            main_ax.tick_params(axis='y', labelleft=True)
            main_ax.set_ylabel(y_label, fontsize=8)
    else:
        main_ax.set_xlabel(x_label, fontsize=8)
        main_ax.set_ylabel(y_label, fontsize=8)

    # Title: use display names (strip group prefix for CrossIndex).
    if title is None:
        dq = self._strip_group_prefix(query_name)
        dt = self._strip_group_prefix(target_name)
        title = f'{dq} vs {dt}'
    main_ax.set_title(title, fontsize=10)

    if has_tracks:
        fig.subplots_adjust(hspace=0.02, wspace=0.02)
    else:
        plt.tight_layout()
    if output_path is not None:
        self._save_figure(fig, output_path, dpi=dpi, format=format)
    return fig

plot_identity_colorbar(palette='viridis', figsize=(1.5, 4.0), output_path=None, dpi=150, format=None)

Render the identity colour scale as a standalone figure.

Produces a figure containing only a vertical colorbar that maps identity values (0–100 %) to colours from palette. This is intended to be displayed alongside a dotplot produced with color_by_identity=True.

Parameters:

Name Type Description Default
palette str

Matplotlib colormap name. Should match the identity_palette used when calling :meth:plot or :meth:plot_single. Default is 'viridis'.

'viridis'
figsize tuple[float, float]

Figure size as (width, height) in inches. Default is (1.5, 4.0).

(1.5, 4.0)
output_path str or Path

Output image file path. When None (default) the figure is not saved to disk.

None
dpi int

Output image resolution. Default is 150.

150
format str

Output image format (e.g. 'png', 'svg', 'pdf'). When None (default), the format is inferred from the output_path file extension.

None

Returns:

Type Description
Figure

A figure containing only the colorbar.

Source code in dot_explorer/dotplot.py
def plot_identity_colorbar(
    self,
    palette: str = 'viridis',
    figsize: tuple[float, float] = (1.5, 4.0),
    output_path: Optional[Union[str, Path]] = None,
    dpi: int = 150,
    format: Optional[str] = None,
) -> matplotlib.figure.Figure:
    """Render the identity colour scale as a standalone figure.

    Produces a figure containing only a vertical colorbar that maps
    identity values (0–100 %) to colours from *palette*.  This is
    intended to be displayed alongside a dotplot produced with
    ``color_by_identity=True``.

    Parameters
    ----------
    palette : str, optional
        Matplotlib colormap name.  Should match the *identity_palette*
        used when calling :meth:`plot` or :meth:`plot_single`.
        Default is ``'viridis'``.
    figsize : tuple[float, float], optional
        Figure size as ``(width, height)`` in inches.
        Default is ``(1.5, 4.0)``.
    output_path : str or Path, optional
        Output image file path.  When ``None`` (default) the figure is
        not saved to disk.
    dpi : int, optional
        Output image resolution. Default is ``150``.
    format : str, optional
        Output image format (e.g. ``'png'``, ``'svg'``, ``'pdf'``).
        When ``None`` (default), the format is inferred from the
        ``output_path`` file extension.

    Returns
    -------
    matplotlib.figure.Figure
        A figure containing only the colorbar.
    """
    # Colorbar figures never support HTML capture; clear any stale
    # capture left by an aborted plot() so _save_figure raises cleanly.
    self._html_capture = None
    norm = mcolors.Normalize(vmin=0, vmax=1)
    sm = plt.cm.ScalarMappable(cmap=plt.get_cmap(palette), norm=norm)
    sm.set_array([])
    fig, ax = plt.subplots(figsize=figsize)
    cb = fig.colorbar(sm, ax=ax, orientation='vertical')
    cb.set_label('Identity', fontsize=10)
    cb.set_ticks([0, 0.25, 0.5, 0.75, 1.0])
    cb.set_ticklabels(['0%', '25%', '50%', '75%', '100%'])
    ax.set_visible(False)
    plt.tight_layout()
    if output_path is not None:
        self._save_figure(fig, output_path, dpi=dpi, format=format)
    return fig

to_html(output_path, **plot_kwargs)

Render the all-vs-all dotplot grid as an interactive HTML report.

Convenience wrapper around :meth:plot that always produces a single self-contained HTML file: the figure is embedded as inline SVG together with the match coordinates (and, when embed_sequences=True and the index stores sequences, the matched query subsequences). In a browser, panels can be zoomed by clicking or scrolling and individual match lines can be clicked to inspect their coordinates.

Parameters:

Name Type Description Default
output_path str or Path

Destination .html file path. HTML output is produced even if the suffix differs.

required
**plot_kwargs object

Additional keyword arguments forwarded to :meth:plot (e.g. query_names, title, color_by_identity, embed_sequences).

{}

Returns:

Type Description
Figure

The rendered figure, as returned by :meth:plot.

Examples:

>>> plotter = DotPlotter(idx)
>>> plotter.to_html('report.html', title='asm1 vs asm2')
Source code in dot_explorer/dotplot.py
def to_html(
    self,
    output_path: Union[str, Path],
    **plot_kwargs: object,
) -> matplotlib.figure.Figure:
    """Render the all-vs-all dotplot grid as an interactive HTML report.

    Convenience wrapper around :meth:`plot` that always produces a
    single self-contained HTML file: the figure is embedded as inline
    SVG together with the match coordinates (and, when
    ``embed_sequences=True`` and the index stores sequences, the
    matched query subsequences).  In a browser, panels can be zoomed
    by clicking or scrolling and individual match lines can be clicked
    to inspect their coordinates.

    Parameters
    ----------
    output_path : str or Path
        Destination ``.html`` file path.  HTML output is produced even
        if the suffix differs.
    **plot_kwargs : object
        Additional keyword arguments forwarded to :meth:`plot`
        (e.g. ``query_names``, ``title``, ``color_by_identity``,
        ``embed_sequences``).

    Returns
    -------
    matplotlib.figure.Figure
        The rendered figure, as returned by :meth:`plot`.

    Examples
    --------
    >>> plotter = DotPlotter(idx)  # doctest: +SKIP
    >>> plotter.to_html('report.html', title='asm1 vs asm2')  # doctest: +SKIP
    """
    path = Path(output_path)
    if path.suffix.lower() not in ('.html', '.htm'):
        # Force HTML dispatch even for non-.html suffixes.
        plot_kwargs.setdefault('format', 'html')
    return self.plot(output_path=path, **plot_kwargs)  # type: ignore[arg-type]