Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 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 | 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 546x 546x 546x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 121x 121x 121x 121x 121x 121x 121x 121x 120x 1x 1x 1x 1x 1x 1x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 2x 87x 87x 87x 2x 2x 2x 2x 121x 121x 121x 121x 121x 121x 121x 2x 489x 489x 489x 489x 489x 2x 489x 489x 489x 489x 174x 174x 489x 489x 489x 489x 489x 489x 489x 489x 63x 63x 63x 63x 489x 2x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 99x 99x 99x 99x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1049x 47x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 80x 80x 80x 80x 1096x 5x 1016x 2x 1011x 163x 163x 1009x 846x 846x 846x 846x 1096x 1096x 1096x 1096x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 986x 986x 986x 986x 986x 986x 986x 986x 986x 30x 986x 986x 31x 986x 986x 63x 63x 63x 63x 63x 16x 47x 63x 63x 63x 48x 15x 63x 101x 6x 101x 63x 63x 63x 986x 986x 986x 2x 489x 489x 58x 260x 37x 21x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 2x 2x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 489x 1x 1x 1x 489x 489x 489x 489x 2x 37x 37x 37x 74x 74x 37x 37x 37x 37x 37x 37x 2x 1049x 1049x 1049x 1049x 1049x 1049x 525x 524x 1049x 1049x 1049x 1049x 1049x 1049x 1049x 1049x 1049x 1049x 1049x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1049x 1049x 1049x 1049x 1049x 1049x 1049x 2x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 2x 552x 552x 552x 552x 2x 2x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 2x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 2x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 2x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 1096x 2x 182x 182x 182x 182x 1x 1x 1x 182x 182x 182x 182x 182x 170x 170x 170x 182x 170x 170x 182x 182x 182x 182x 182x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 182x 182x 12x 12x 12x 12x 12x 12x 12x 182x 61x 61x 60x 60x 61x 61x 61x 61x 182x 121x 121x 182x 2x 121x 121x 121x 121x 121x 121x 2x 170x 170x 170x 170x 170x 168x 168x 168x 168x 168x 2x 170x 170x 170x 2x 168x 168x 2x 2x 168x 168x 2x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 168x 2x 226x 226x 607x 607x 226x 226x 2x 7x 7x 2x 12x 12x 2x 168x 168x 168x 168x 168x 168x 168x 2x 63x 63x 63x 63x 2x 853x 7x 2x 2x 2x 2x 2x 2x 2x 4x 853x 2x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 7x 1x 1x 1x 1x 1x 1x 1x 853x 1x 1x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 121x 2x 2x 108x 108x 108x 108x 108x 108x 108x 108x 108x 120x 120x 120x 108x 108x 108x 108x | /**
* The **Tracks** card (spec §8.1): every file-driven row in one list, the lanes
* drawn beside the map and the overlays drawn on it.
*
* One card for both, because the two differ in their per-row controls and in
* where their contents get drawn, and in nothing else — whether a file is a
* lane or an overlay is a fact about its format, which the bytes decide. Two
* cards would be two copies of the loading, the error reporting, the overlap
* warning, the colour picker and the remove button, kept in step by hand.
*
* The rows are rebuilt from the store on every change rather than patched. The
* list is a handful of items and the alternative — reconciling a DOM against a
* list — is the one part of this app that would genuinely want a framework. The
* single concession to that is {@link holdsFocus}: a rebuild is *deferred* while
* a control in the list has the caret, because the change that asked for it
* almost always came from that control.
*
* The row head carries the three things a reader changes without thinking — the
* colour, the name and the order — as direct manipulation rather than as fields
* behind a disclosure. Clicking the swatch opens the colour picker, clicking the
* name edits it in place, and the grip drags the row up the stack. What is left
* behind the chevron is what genuinely wants a form.
*/
import {
TRACK_OPS,
TRACK_OP_LABELS,
derivedTrackLabel,
isTrackOp,
} from '../annotations/derived'
import type { AnnotationLoader } from '../annotations/loader'
import type { AnnotationStore } from '../annotations/store'
import { hasTrackControls, isIntervalSet, readsAlignments, readsOf, searchesByName } from '../annotations/types'
import type { AnnotationEntry, ColourBy, ContigOverlap } from '../annotations/types'
import { OVERLAY_DRAG_TYPE, TRACK_DRAG_TYPE, button, dragHasFiles, el, fmt } from './dom'
import { tabAmong } from './tabWalk'
import { dragHasFileRow } from './fileDrag'
import { chooseFiles, filesFrom } from './chooseFiles'
import { allowHandles, hasHandle } from '../state/handles'
import { pairIndexes } from '../annotations/loader'
import { cardTitle, icon, iconButton } from './icons'
import { batchFields } from './batchSettings'
import { mapsOrLead, overlayMapsField, placementField } from './lanePlacement'
import { subscribeReveal } from './revealTrack'
import { calledSetFields } from './calledSurfaces'
import {
framingOf,
isSynthetic,
pairExports,
recomputeNote,
syntheticExports,
syntheticMark,
syntheticNote,
type SyntheticFormat,
} from './synthetic'
import { stepperField } from './trackControls'
import {
arrowsToggle,
barScaleToggle,
featureDisplayField,
colourByField,
colourLegend,
featureColourField,
featureLegend,
fillFeatureLegend,
fillLegend,
flipButton,
framesField,
scaleField,
heightField,
layoutField,
limitsField,
mapqField,
modField,
modFractionField,
modeField,
radiusField,
styleFields,
opacityField,
duplicatesToggle,
sashimiField,
splitsToggle,
} from './laneSettings'
/**
* The `<option>` value that means "not one of these" in the group picker.
*
* A sentinel rather than an empty string, because the empty string is already
* taken: it is what "no group" is stored as. A leading space cannot occur in a
* name, which is trimmed before it is stored.
*/
const NEW_GROUP = ' new'
export interface AnnotationCardProps {
store: AnnotationStore
/**
* Every map a track could be drawn on, in the order they are on screen.
*
* Absent, or one entry, is a session with a single map, and the location
* control keeps the shape it has always had. The labels are the deck's, since
* naming a panel is the deck's business and not this card's.
*/
maps?(): Array<{ id: string; label: string }>
loader: AnnotationLoader
/**
* A one-click offer, when the open map has annotations published beside it.
*
* Only the Linear card is given one, even though the set it loads spans both:
* the offer is about *this map*, not about this card, and two buttons that do
* the same thing would raise the question of how they differ.
*/
demo?: { label: string; hint: string; run(): void }
/**
* Which kind of session this card is in.
*
* The controls are the same either way — a colour is a colour and a group is a
* group — but three of the *sentences* are not: beside a map an overlay is
* drawn on the diagonal and a pair is a circle off it, and in a 1D browser
* there is no diagonal, a pair is an arc, and "bottom or right edge" is not a
* choice anybody has. Saying so wrongly is worse than saying nothing, so the
* card is told where it is.
*/
context?: 'map' | 'browser'
/**
* A row out of the Files panel, dropped on this card.
*
* Takes the *event* rather than a file, because the bytes behind such a row
* have not been fetched and a drop handler cannot await — see
* `MatrixPanel.pickOnEdge`, which is the same door onto the map's own edges.
* Absent means the drop is ignored, which is what a card with no session
* behind it should do with one.
*/
onPickDropped?(event: DragEvent): void
/**
* Write a synthetic row out (§18.5, §18.7) — the analysis workspace's, which
* holds the values a computed lane is drawn from. Absent, the row offers no
* exports rather than buttons that write nothing.
*/
onExport?(entry: AnnotationEntry, format: SyntheticFormat): void
/**
* Run a detached computed row's task again, from the recipe it kept. Absent,
* the row says where to run it instead of offering a button.
*/
onRecompute?(entry: AnnotationEntry): void
}
/**
* Whether a row is drawn as a lane beside the map — a loaded track, a feature
* set or a synthetic track — rather than on the matrix face (an overlay).
*
* `synthetic` is a lane exactly as `linear` is (`AnnotationStore.drawnOnMap`
* asks only that a row is not an overlay), so the two get one set of lane
* controls here: the height, the style, the scale, the flip, the edges.
*/
function isLaneRow(entry: AnnotationEntry): boolean {
return entry.card === 'linear' || entry.card === 'synthetic'
}
export class AnnotationCard {
/**
* Why the lanes are not on screen, when the map is shown on its own.
*
* `V` cycles the map with its tracks, the tracks alone, and the map alone,
* and the third stop leaves this card listing eight files that are drawn
* nowhere — which reads as the tracks having been lost rather than put away.
* One line, above the list, saying where they are and the key that brings
* them back.
*/
private readonly mapOnlyNote = el('p', {
class: 'ann-map-only hidden',
text: 'Put away while the map is shown on its own — V brings them back beside it.',
})
readonly el: HTMLElement
private readonly list = el('div', { class: 'ann-list' })
private readonly unsubscribe: () => void
private readonly unsubscribeReveal: () => void
/** Rows the reader has opened, so a repaint does not fold them shut. */
private readonly open = new Set<string>()
/** A rebuild that arrived while a control was being edited. */
private pending = false
/** The focus is moving between two fields of the list; see the listeners in the constructor. */
private committing = false
/** The row being dragged, while one is; see {@link grip}. */
private dragging: string | null = null
/** A control that should get the focus back after the next rebuild. */
private refocus: { attribute: 'data-grip' | 'data-tick' | 'data-display'; id: string } | null = null
/**
* The rows that are ticked, for the actions that apply to several at once.
*
* Putting six tracks in one group, or giving them one height, is six trips
* through six rows otherwise — and the six are almost always chosen together
* because they are the same experiment. Held here rather than in the store: a
* selection is a gesture in progress, not a property of a track, and it should
* not survive a reload the way a colour does.
*/
private readonly ticked = new Set<string>()
/**
* The row a shift-click counts *from*, and the reason ten tracks are two
* gestures rather than ten.
*
* Ticking the first ten rows of a long stack was ten aims at an eleven-pixel
* box, so the gesture every file list has — click one, shift-click another,
* take everything between — is here too, and this is the "one" it counts
* from. Every plain click sets it; a shift-click deliberately leaves it
* alone, so a second shift-click re-extends the same span from the same start
* rather than walking that start down the list one box at a time.
*
* An **id and never a position**, which is the subtle part. A position in this
* list is only true for as long as nobody touches the list, and touching it is
* ordinary: the rows are reorderable by drag and by arrow key ({@link grip}),
* a row can be removed between the two clicks, and {@link trackRows} is a
* *filter* over the store, so a store index is not a list index either. An
* index remembered at the first click would name a different track by the
* second. The id is resolved against {@link trackRows} at the moment the span
* is taken, which is also what makes a stale anchor harmless: a row that has
* gone is simply not found there, and the shift-click falls back to being a
* plain one rather than selecting a span nobody asked for.
*/
private anchor: string | null = null
/**
* Whether the toggle now being committed was made with shift held.
*
* A relay between two events, and it exists because neither of them carries
* both halves of what the gesture needs: `change` says a checkbox has a new
* value and carries no modifier keys, while `click` — which a checkbox fires
* first, for a pointer press and for the space bar alike — carries the
* modifiers and not yet the decision to act. So the shift is caught on the way
* past and read a moment later by the handler that acts on it. Cleared as it
* is read, so a `change` arriving with no click in front of it is an ordinary
* toggle rather than one that inherits a shift from the click before.
*/
private extending = false
constructor(private readonly props: AnnotationCardProps) {
// Where a file goes in, above the list of what is already in. What was here
// instead was five labelled rectangles — one per format — which between
// them asked the reader to know, before dropping, an answer the application
// works out from the bytes a moment later, and which said "wrong rectangle"
// when they guessed. One zone, and the card around it takes a file too.
const load = loadZone(props.loader)
this.el = el(
'section',
{ class: 'card annotation-card' },
cardTitle('Tracks', 'chart-area'),
this.mapOnlyNote,
...load.nodes,
...(props.demo === undefined
? []
: [
button(props.demo.label, props.demo.run, {
class: 'btn btn-wide ann-demo',
title: props.demo.hint,
}),
]),
this.list,
)
this.cardDrops(this.el, load.zone)
// Focus leaving the list is what releases a deferred rebuild. Deferred to a
// task, because at `focusout` time the *new* focus owner is not set yet, so
// the check would see the document body and repaint into a control the
// reader has merely tabbed to.
this.list.addEventListener('focusout', () => {
setTimeout(() => {
if (this.pending) this.render()
}, 0)
})
// A commit made by *leaving* a field for another — Tab from one limit to
// the next, or a press on the next — is a `change` fired while the focus is
// between the two, on nobody: the check below saw no caret and rebuilt the
// row under the field being moved to, and the caret fell out of the card.
// So the move is marked, from the key or the press that starts it to the
// end of the task, when the focus has landed.
const moving = (): void => {
this.committing = true
setTimeout(() => {
this.committing = false
}, 0)
}
this.list.addEventListener('keydown', (event) => {
if (event.key === 'Tab') moving()
}, true)
this.list.addEventListener('pointerdown', (event) => {
const at = event.target
if (at instanceof HTMLInputElement || at instanceof HTMLSelectElement || at instanceof HTMLTextAreaElement) {
if (!(at instanceof HTMLInputElement && at.type === 'checkbox')) moving()
}
}, true)
this.unsubscribe = props.store.subscribe(() => this.render())
// A press on the lane itself asks for this row (`ui/revealTrack.ts`). The
// card is where the answer lives, so the card listens: it opens the fold,
// and brings the row into view — a card scrolled somewhere else would
// otherwise answer a click by changing something nobody can see.
this.unsubscribeReveal = subscribeReveal((id) => this.reveal(id))
this.render()
}
destroy(): void {
this.unsubscribe()
this.unsubscribeReveal()
}
/**
* Open one row's settings and scroll to it.
*
* Opens rather than toggles: the gesture is "show me this", and a second click
* on a lane a reader is already working on would otherwise shut the panel they
* are working in. Ignored for a row this card does not hold, because both
* cards in a session listen and only one of them has any given track.
*/
private reveal(id: string): void {
if (this.props.store.byId(id) === undefined) return
const already = this.open.has(id)
this.open.add(id)
if (!already) this.render()
const row = this.list.querySelector<HTMLElement>(`[data-id="${cssEscape(id)}"]`)
row?.scrollIntoView({ block: 'nearest' })
}
// -------------------------------------------------------------------------
// Getting files in
// -------------------------------------------------------------------------
/**
* Make the card take a dropped file, and make the *zone* say so.
*
* The card still accepts a file anywhere on it — a reader whose pointer is
* over the eighth row when they let go meant to load the file, and refusing
* on a technicality would be pedantry. What changed is what lights up: the
* whole card used to, which is a two-hundred-pixel rectangle going blue
* around a dashed box that is *already* the drawn target, so the one thing on
* screen saying "put it here" was the thing that did not react. Now the zone
* lights the moment a file crosses the card, wherever on it the pointer is.
*/
/**
* A map or a tracks cell was renamed (§16.10, §25.3). The location rows name
* every place a track can be drawn, so they are drawn again — a name changes
* nothing in the store this card otherwise redraws on.
*/
renamed(): void {
this.render()
}
/** Whether the lanes are put away under the map-only view; see {@link mapOnlyNote}. */
setMapOnly(on: boolean): void {
this.mapOnlyNote.classList.toggle('hidden', !on)
}
private cardDrops(host: HTMLElement, zone: HTMLElement): void {
host.addEventListener('dragover', (event) => {
// A row out of the Files panel is the same gesture with the bytes
// somewhere else, and this card is the plainest place to aim one: it is
// the list the track is about to join.
if (!dragHasFiles(event) && !dragHasFileRow(event)) return
event.preventDefault()
zone.classList.add('over')
})
host.addEventListener('dragleave', (event) => {
// Only when the pointer has actually left the card, not when it crosses
// from the card onto a row inside it — `dragleave` fires for every child
// boundary, and without this the highlight flickers all the way down.
if (event.relatedTarget instanceof Node && host.contains(event.relatedTarget)) return
zone.classList.remove('over')
})
host.addEventListener('drop', (event) => {
event.preventDefault()
event.stopPropagation()
zone.classList.remove('over')
if (dragHasFileRow(event)) {
// Handed to the session rather than to the loader, and that is not a
// detour: a `.cool` dragged onto the Tracks card is still a map, and
// `Session.addPicked` is where that is decided — for a dropped file it
// has always been decided the same way, one level up in `dropped`.
this.props.onPickDropped?.(event)
return
}
const files = filesFrom(event.dataTransfer)
if (files.length > 0) this.props.loader.addFiles(files)
})
}
// -------------------------------------------------------------------------
// The rows
// -------------------------------------------------------------------------
/**
* `true` while a control in the list is being edited.
*
* Only the controls carrying a value the reader has not finished giving. A
* colour picker was the case that forced this: it fires `input` on every drag,
* each of which is a store change, and replacing the `<input>` underneath the
* platform's picker closed it. The app's own picker (`colourPicker.ts`) lives
* outside the card and survives that. A half-typed name is the same problem —
* the caret and the untyped rest of the word go with the node.
*
* A checkbox is the case that forces the *exception*. It commits on the click
* that focuses it, so there is never a half-given value to protect — and
* treating it like the rest deferred the rebuild that the tick was asking for,
* which is exactly the batch bar it exists to open. The focus is put back on
* the same box afterwards, so a second tick does not need a second aim.
*/
private holdsFocus(): boolean {
const active = document.activeElement
// Between two fields of the list, on a commit made by leaving one: held, and
// the focusout above releases it once the focus has landed, or held on.
if (this.committing && (active === null || active === document.body)) {
setTimeout(() => {
if (this.pending) this.render()
}, 0)
return true
}
if (!(active instanceof HTMLElement) || !this.list.contains(active)) return false
if (active instanceof HTMLInputElement && active.type === 'checkbox') return false
return (
active instanceof HTMLInputElement ||
active instanceof HTMLSelectElement ||
active instanceof HTMLTextAreaElement
)
}
private render(): void {
if (this.holdsFocus()) {
this.pending = true
return
}
this.pending = false
const entries = this.trackRows()
// Rows that have gone take their tick with them, or a batch would act on
// ids the store has never heard of.
for (const id of [...this.ticked]) {
if (!entries.some((entry) => entry.id === id)) this.ticked.delete(id)
}
// **The bar goes above the rows.** Under them it was the last thing in a
// column that already scrolled: with seven tracks loaded and two ticked at
// the top of the list, the bar appeared off the bottom of the screen, so
// ticking two boxes visibly did nothing and the ticks read as highlights.
// Sticky is not the answer either — `.panel-column .card` makes the card its
// own scroll box, so a bar pinned to its bottom pins exactly where it was.
const batch = this.batchBar(entries)
this.list.replaceChildren(
...(batch === null ? [] : [batch]),
...entries.map((entry, index) => this.row(entry, index, entries.length)),
)
// A keyboard reorder rebuilt the list out from under the grip that asked for
// it; put the focus back on the row at the position it moved to, so a second
// press moves the same row again rather than starting over from the mouse.
const wanted = this.refocus
this.refocus = null
if (wanted !== null) {
this.list
.querySelector<HTMLElement>(`[${wanted.attribute}="${cssEscape(wanted.id)}"]`)
?.focus()
}
}
private row(entry: AnnotationEntry, index: number, total: number): HTMLElement {
const body = el('div', { class: 'ann-body' })
const isOpen = this.open.has(entry.id)
body.classList.toggle('hidden', !isOpen)
if (isOpen) body.replaceChildren(...this.settings(entry))
const row = el('div', {
class: `ann-row ann-${entry.status}${isSynthetic(entry) ? ' is-synthetic' : ''}`,
attrs: { 'data-id': entry.id },
})
const more = iconButton(
isOpen ? 'chevron-down' : 'chevron-right',
isOpen ? 'Hide this track’s settings' : 'Show this track’s settings',
() => {
if (this.open.has(entry.id)) this.open.delete(entry.id)
else this.open.add(entry.id)
this.render()
},
{ class: 'btn btn-icon ann-more', size: 14 },
)
more.setAttribute('aria-expanded', String(isOpen))
row.appendChild(
el(
'div',
{ class: 'ann-head' },
// A grip wherever it does something. Reordering needs two rows — a list
// of one has no order, and a handle that does nothing is a control to be
// tried and then doubted — but beside a *map* it has a second job even
// for a single track: dragging it onto the bottom or right rectangle is
// what moves that track between the map's two edges.
...(total > 1 || this.props.context !== 'browser'
? [this.grip(entry, index, total)]
: []),
...(total > 1 ? [this.tick(entry)] : []),
this.swatch(entry),
this.name(entry),
// The `*` a resolution this file does not store wears (`3 kb*`), for
// the same fact: this row is not in any file the reader gave the app.
...(isSynthetic(entry) ? [syntheticMark(entry)] : []),
...(entry.payload?.kind === 'features' && searchesByName(entry.kind) ? [this.star(entry)] : []),
this.visibility(entry),
more,
iconButton('x', 'Remove this track', () => this.props.store.remove(entry.id), {
class: 'btn-close',
size: 14,
}),
),
)
if (entry.status === 'loading') {
// A bar where the worker has said how far it has got — a BAM's index, a
// whole FASTA, a text track being downloaded — and the word alone where
// it has not, which is every read short enough not to be watched.
const progress = entry.progress
if (progress === undefined) {
row.appendChild(el('p', { class: 'ann-note', text: 'reading…' }))
} else {
const fraction = progress.total > 0 ? Math.min(1, progress.done / progress.total) : 0.5
const words =
progress.total > 0
? `reading… ${fmt.bytes(progress.done)} of ${fmt.bytes(progress.total)}`
: `reading… ${fmt.bytes(progress.done)}`
const fill = el('span', { class: 'ann-progress-fill' })
fill.style.width = `${Math.round(fraction * 100)}%`
row.appendChild(
el(
'div',
{ class: 'ann-progress', attrs: { role: 'progressbar', 'aria-valuenow': String(Math.round(fraction * 100)), 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-label': words } },
el('p', { class: 'ann-note', text: words }),
el('span', { class: 'ann-progress-bar' }, fill),
),
)
}
} else if (entry.status === 'error') {
row.appendChild(el('p', { class: 'inline-error ann-note', text: entry.error ?? 'failed' }))
} else if (entry.status === 'detached') {
row.appendChild(this.reattachNote(entry))
} else if (isSynthetic(entry)) {
// What made it, and how many — on the line a loaded row's summary takes,
// so the two kinds of row are one shape. No overlap report: a computed
// row was measured on this map's own grid and has nothing to disagree
// with it about.
const note = syntheticNote(entry)
if (note !== null) row.appendChild(el('p', { class: 'ann-note', text: note }))
} else {
if (entry.summary !== undefined) {
row.appendChild(el('p', { class: 'ann-note', text: entry.summary }))
}
// Two tones from one function: a **warning** is a file that does not fit
// this map, and a **note** is a file that fits part of it, which on a
// metagenome is what fitting looks like (§8.5). Painting the second one
// yellow would make the ordinary case look like a fault.
const warning = overlapWarning(entry.overlap)
if (warning !== null) {
row.appendChild(
el('p', { class: warning.tone === 'warn' ? 'ann-warn' : 'ann-note', text: warning.text }),
)
}
}
row.appendChild(body)
this.acceptDrops(row, index)
return row
}
/**
* The way back from a reload.
*
* A dropped file cannot be re-read without a gesture, which is a privacy
* property rather than a limitation (§11) — so the row keeps everything the
* reader chose and asks for the file again, rather than vanishing and taking
* the settings with it.
*/
private reattachNote(entry: AnnotationEntry): HTMLElement {
if (entry.source.kind === 'computed') {
// There is no file to ask for. A computed row is the answer to a task that
// was run against a framing, and what survives a reload is the recipe
// rather than the numbers (§18.5) — so the row offers to run it again,
// instead of opening a file picker that could never be satisfied.
const recompute = this.props.onRecompute
if (recompute !== undefined) return recomputeNote(entry, recompute)
return el('p', {
class: 'ann-note ann-detached',
text: `computed by ${entry.source.task} · run it again from the Analysis panel to redraw it`,
})
}
// `multiple`, because some formats are a *pair*. A BAM without its index
// re-attaches and immediately refuses (§8.13), and a bgzipped feature file
// without its `.tbi` re-attaches and silently reads itself whole — so the
// picker takes both and `pairIndexes` sorts out which is which, exactly as
// a drop of the two does.
const input = el('input', { class: 'hidden-input', type: 'file' })
input.multiple = true
input.addEventListener('change', () => {
const chosen = [...(input.files ?? [])]
if (chosen.length === 0) return
// The pair whose file is the one this row is waiting for, where the
// reader picked several; otherwise simply the first.
const paired = pairIndexes(chosen)
const match = paired.find((pair) => pair.file.name === entry.source.name) ?? paired[0]
this.props.loader.reattach(entry.id, match.file, match.index)
})
const pick = button('re-open this file', () => input.click(), {
class: 'btn btn-quiet',
title: 'The settings below were kept; only the data has to be read again.',
})
// "or re-open this file" travels as one piece, so a narrow column wraps it
// whole onto the next line rather than leaving a word hanging off this one.
const or = el('span', { text: 'or ' })
const allow = this.allowButton(entry.source.name, () => or.remove())
return el(
'p',
{ class: 'ann-note ann-detached' },
`${entry.source.name} · `,
...(allow === null ? [pick] : [allow, el('span', {}, or, pick)]),
input,
)
}
/**
* *allow access*, where this browser still holds a grant for the file that
* has lapsed to ask first (§5.6) — the click the permission prompt needs, spent
* on this file and then on every other one the browser remembers, as the
* picker's button spends it. The row comes back through the announcement every
* tab hears, with every other file the answer covers, so nothing is
* reattached here.
* A grant forgotten on the way — the file moved, or reading it refused for
* good — takes the button away, and `gone` the word that joined it to the next.
*/
private allowButton(name: string, gone: () => void): HTMLButtonElement | null {
if (!hasHandle(name)) return null
const allow = button(
'allow access',
() => {
allow.disabled = true
void allowHandles(name).then((file) => {
if (file !== null) return
if (hasHandle(name)) {
allow.disabled = false
} else {
allow.remove()
gone()
}
})
},
{
class: 'btn btn-quiet',
title:
'This browser remembers the file and only needs your permission to read it again — and ' +
'every other file it remembers, at the same time.',
},
)
return allow
}
// -------------------------------------------------------------------------
// Reordering
// -------------------------------------------------------------------------
/**
* The drag handle, and the only part of the row that starts a drag.
*
* Dragging from the row itself would fight every control on it — a text
* selection in the name box, a slider, the swatch — so the gesture is given
* one small target that does nothing else. Arrow keys do the same job, because
* a reorder that needs a pointer is a reorder some readers cannot perform.
*/
/**
* The tick box that puts a row into the batch.
*
* Beside the name rather than in a mode of its own: a selection you have to
* turn on is a selection nobody finds, and the box costs eleven pixels in a
* row that already has five controls in it.
*
* Three listeners on one control, which is one more than a checkbox usually
* needs and one fewer than a div pretending to be one would need. The control
* stays a real `input[type=checkbox]` — the space bar still toggles it, a
* screen reader still calls it a checkbox — and the range gesture is layered
* on top of it rather than reimplemented around it. See {@link anchor} for
* what a span is counted from and {@link extending} for why the modifier has
* to be caught an event early.
*/
private tick(entry: AnnotationEntry): HTMLElement {
const input = el('input', {
class: 'checkbox ann-tick',
type: 'checkbox',
// The gesture is named in the tooltip because there is nowhere else a
// reader could meet it: a modifier held over a box that looks exactly like
// every other box is undiscoverable otherwise, and this card has no room
// for a line of help text.
title:
`Tick ${entry.label} to set it with the others ticked, in the box above the ` +
`list — shift-click to take every row between this one and the last one you clicked`,
attrs: { 'data-tick': entry.id },
})
input.checked = this.ticked.has(entry.id)
// A shift-click anywhere in a document also extends the *text* selection,
// which comes up as the rows between the two boxes turning blue — the card
// looking mid-drag every time a span is taken. That default belongs to
// `mousedown`, so `mousedown` is the only place it can be refused; refusing
// the *click* would cancel the tick itself, which is the whole gesture. The
// focus that the prevented `mousedown` would have given the box is put back
// by `refocus` a moment later, after the rebuild the tick asks for.
input.addEventListener('mousedown', (event) => {
if (event.shiftKey) event.preventDefault()
})
input.addEventListener('click', (event) => {
this.extending = event.shiftKey
})
input.addEventListener('change', () => {
const extending = this.extending
this.extending = false
// The rows **as they are now**, and the anchor found in them by id: this
// is the resolution {@link anchor} exists to defer, and doing it here is
// what survives a reorder, a removal or a filter between the two clicks.
const rows = this.trackRows()
const from =
extending && this.anchor !== null
? rows.findIndex((row) => row.id === this.anchor)
: -1
const to = rows.findIndex((row) => row.id === entry.id)
// No anchor, or an anchor whose row has gone: a plain toggle of the one
// box, which is also what the gesture degrades to for a keyboard toggle —
// there is no such thing as a shift-held space bar here.
const span =
from < 0 || to < 0
? [entry.id]
: rows.slice(Math.min(from, to), Math.max(from, to) + 1).map((row) => row.id)
// The state the *clicked* box is moving to, given to the whole span, in
// either direction: shift-clicking an empty box fills the span and
// shift-clicking a full one empties it, which is what a file explorer and
// a diff view both do and what makes the second gesture undo the first.
for (const id of span) {
if (input.checked) this.ticked.add(id)
else this.ticked.delete(id)
}
// Only a click that was *not* a range moves the anchor. A shift-click that
// moved it would walk the start of the span forward, so extending a
// selection by one more row would shrink it to two.
if (from < 0) this.anchor = entry.id
this.refocus = { attribute: 'data-tick', id: entry.id }
// One rebuild for the whole span, not one per row: the set is filled
// first and the list redrawn once, so ten rows cost the single repaint a
// single tick costs — see {@link render}, which rebuilds every row.
this.render()
})
return input
}
/**
* The ticked rows' settings, as one — and what can be done to them — while
* two or more are ticked.
*
* It was four actions: a shared autoscale group, one colour, the tallest
* height, hide and remove. Everything else a track has — the limits, the
* style, the scale, the flip, the edges — was one trip per row, and those are
* exactly what six replicates ticked together want set once. So the box
* carries a row's settings (`ui/batchSettings.ts`), showing what the ticked
* rows share and *mixed* where they differ, and writing each to every ticked
* row it means something to. The actions sit on its first line with the
* count: combine a pair, hide or show, remove, and clear the ticks.
*/
private batchBar(entries: AnnotationEntry[]): HTMLElement | null {
const chosen = entries.filter((entry) => this.ticked.has(entry.id))
if (chosen.length < 2) return null
// Exactly two, and both of them profiles. A comparison of three tracks is
// not a thing, and a comparison of a gene model against a bigWig is not
// either — so the control appears when the selection *is* a pair rather than
// appearing always and refusing when clicked. The order is the order they
// are listed in, which is the order the reader put them in and the only one
// this code could justify: `a / b` and `b / a` are different questions and
// nothing here can guess which was meant. The row that results is named for
// the operation it is, so which way round it went is legible on the card;
// the other way round is a second combine of the same two rows.
const pair =
chosen.length === 2 && chosen.every((entry) => entry.kind === 'track')
? this.combineControl(chosen[0], chosen[1])
: null
// Show when every ticked row is hidden, hide otherwise: a set with one
// hidden row in it is a set the reader is putting away, not bringing back.
const hidden = chosen.every((entry) => !entry.visible)
return el(
'div',
{
class: 'ann-batch',
attrs: { role: 'group', 'aria-label': `Settings for the ${chosen.length} ticked tracks` },
},
el(
'div',
{ class: 'ann-batch-head' },
el('span', { class: 'ann-batch-count', text: `${chosen.length} selected` }),
...(pair === null ? [] : [pair]),
el(
'span',
{ class: 'ann-batch-actions' },
button(
hidden ? 'show' : 'hide',
() => {
for (const entry of chosen) this.props.store.update(entry.id, { visible: hidden })
},
{
class: 'btn btn-quiet',
title: hidden ? 'Show them all again' : 'Hide them all, keeping their settings',
},
),
button(
'remove',
() => {
for (const entry of chosen) {
this.ticked.delete(entry.id)
this.props.store.remove(entry.id)
}
},
{ class: 'btn btn-quiet ann-batch-remove', title: 'Remove them all' },
),
button(
'clear',
() => {
this.ticked.clear()
this.render()
},
{ class: 'btn btn-quiet', title: 'Untick them, changing nothing' },
),
),
),
el(
'div',
{ class: 'ann-batch-body' },
...batchFields({
store: this.props.store,
rows: chosen,
maps: mapsOrLead(this.props.maps),
context: this.props.context ?? 'map',
groups: this.groupNames(),
// Naming a group is the end of the gesture that selected these rows:
// the set now exists as a group, and leaving the ticks on leaves a
// second selection saying the same thing — which the next batch action
// would silently act on. Clearing is exactly what `clear` does.
onCoined: () => {
this.ticked.clear()
this.render()
},
}),
),
)
}
/**
* Make one lane out of the two that are ticked.
*
* A dropdown that acts on selection rather than a button beside a picker,
* because the operation *is* the whole of the decision: the two operands are
* already chosen, and asking for them again in a dialog would be asking the
* reader to repeat the gesture they just made.
*
* The ticks are cleared afterwards for {@link batchBar}'s reason — the gesture
* is finished, the result is a row of its own, and leaving the pair selected
* leaves the next batch action pointing at the operands of something.
*/
private combineControl(a: AnnotationEntry, b: AnnotationEntry): HTMLElement {
const select = el('select', { class: 'select' })
select.appendChild(el('option', { value: '', text: 'combine…' }))
for (const op of TRACK_OPS) {
select.appendChild(el('option', { value: op, text: TRACK_OP_LABELS[op] }))
}
select.value = ''
select.title = `One lane from ${a.label} and ${b.label}, drawn as their comparison`
select.addEventListener('change', () => {
const op = select.value
if (!isTrackOp(op)) return
const entry = this.props.store.begin({
// **Synthetic, not linear.** A comparison is a track this app computed
// (§18.5): it is listed here with a `*` after its name, and its settings
// carry the exports — which is the whole of what the reader wants from
// it once it is on screen and not a file.
card: 'synthetic',
kind: 'track',
// Nothing to name and nothing to re-read: the values are computed from
// two rows that are already here, exactly as the 4C profile is computed
// from the block already on screen.
source: { kind: 'derived', name: derivedTrackLabel(a.label, b.label, op) },
label: derivedTrackLabel(a.label, b.label, op),
placement: a.placement,
})
this.props.store.update(entry.id, {
derivedFrom: { a: a.id, b: b.id, op },
// Ready the moment it exists: there is no file behind it to wait for,
// and a lane is drawn when it is ready.
status: 'ready',
// No `summary`. It said `ratio of two tracks`, which is word for word
// what the row's own provenance line says about a derived row
// (`syntheticNote`). The count a summary carries on every other
// computed row is a fact a comparison has not got: it holds no
// elements, only a value wherever both its operands have one.
})
this.ticked.clear()
this.render()
})
return select
}
private grip(entry: AnnotationEntry, index: number, total: number): HTMLElement {
const node = el(
'span',
{
class: 'ann-grip',
// Where a row can be let go (§16.9): a rectangle on any of the four
// edges of any map, a `+` of the deck, or an empty cell, the last two
// opening a tracks cell drawing it. It named two edges of one map.
title:
this.props.context === 'browser'
? 'Drag, or press the arrow keys, to reorder this track'
: 'Drag to reorder — or onto any edge of any map to draw it there, or onto a + or an ' +
'empty cell to give it a tracks cell of its own. Arrow keys reorder.',
attrs: {
draggable: 'true',
tabindex: '0',
role: 'button',
'aria-label': `Reorder ${entry.label}, ${index + 1} of ${total}`,
'data-grip': entry.id,
},
},
icon('grip-vertical', { size: 12 }),
)
node.addEventListener('dragstart', (event) => {
this.dragging = entry.id
const row = node.closest('.ann-row')
row?.classList.add('is-dragging')
if (event.dataTransfer !== null) {
// Moved, up and down this list and onto a map's edge; *placed* — a copy
// — onto a cell of the deck, where the track stays on every map it was
// on (§16.9). `move` alone made the browser refuse that drop outright.
event.dataTransfer.effectAllowed = 'copyMove'
// Some browsers cancel a drag that carries nothing at all.
event.dataTransfer.setData('text/plain', entry.id)
// And the same id under a type only this app writes, so a drop target
// outside this card — the map's two edge rectangles — can tell a track
// being moved from a file being loaded and from a dragged word, while
// the drag is still in the air and only `types` can be read.
event.dataTransfer.setData(TRACK_DRAG_TYPE, entry.id)
// …and, for an overlay, that it is one: a map offers it its face and
// not its four edges, and only the types say so before the drop.
if (entry.card === 'overlay') event.dataTransfer.setData(OVERLAY_DRAG_TYPE, entry.id)
// The row, not the handle: a 12-pixel ghost says nothing about what is
// being moved.
if (row instanceof HTMLElement) event.dataTransfer.setDragImage(row, 12, 12)
}
})
node.addEventListener('dragend', () => {
this.dragging = null
this.clearDropMarks()
this.list.querySelector('.is-dragging')?.classList.remove('is-dragging')
})
node.addEventListener('keydown', (event) => {
const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
if (step === 0) return
event.preventDefault()
const to = index + step
if (to < 0 || to >= total) return
// Past the row it is swapping with, by identity: the rows on this card are
// the store's *files*, and the store holds more than files (§8.9), so a
// step in this list is not always a step in the store's own.
const neighbour = this.trackRows()[to]
const at = this.props.store.entries.findIndex((row) => row.id === neighbour?.id)
if (neighbour === undefined || at < 0) return
this.refocus = { attribute: 'data-grip', id: entry.id }
this.props.store.moveTo(entry.id, at)
})
return node
}
/**
* Make one row a drop target.
*
* The half of the row the pointer is in decides which side of it the dragged
* row lands on, which is the convention every list with this gesture uses and
* the only one that can express "past the last row".
*/
private acceptDrops(row: HTMLElement, index: number): void {
const sideOf = (event: DragEvent): 'before' | 'after' => {
const box = row.getBoundingClientRect()
return event.clientY < box.top + box.height / 2 ? 'before' : 'after'
}
row.addEventListener('dragover', (event) => {
if (this.dragging === null || this.dragging === row.dataset['id']) return
// Without this the drop is refused and the ghost springs back.
event.preventDefault()
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
this.clearDropMarks()
row.classList.add(sideOf(event) === 'before' ? 'drop-before' : 'drop-after')
})
row.addEventListener('dragleave', () => {
row.classList.remove('drop-before', 'drop-after')
})
row.addEventListener('drop', (event) => {
const moved = this.dragging
this.clearDropMarks()
if (moved === null || moved === row.dataset['id']) return
event.preventDefault()
this.dragging = null
// By **identity**, not by index. The rows on this card are the store's
// *files*, which is not everything the store holds — the 4C profile lives
// there too and is not listed — so a position in what is being looked at
// is not a position in the store's own list. The row the drop landed on
// names an entry, and the entry's place in the store is looked up, which
// is what every other drag in the application does (`AnnotationStore.moveTo`).
const rows = this.trackRows()
const target = rows[index]
if (target === undefined) return
const entries = this.props.store.entries
const from = entries.findIndex((candidate) => candidate.id === moved)
const onto = entries.findIndex((candidate) => candidate.id === target.id)
if (from < 0 || onto < 0) return
// Where it goes in the list as it stands, then the same place counted in
// the list with the dragged row already taken out of it — which is the
// list `moveTo` inserts into.
const insertAt = onto + (sideOf(event) === 'after' ? 1 : 0)
this.props.store.moveTo(moved, insertAt > from ? insertAt - 1 : insertAt)
})
}
/**
* The rows this card lists: every loaded **file**, and every **synthetic**
* track, in one list.
*
* Which of the store's cards an entry belongs to still decides where it is
* *drawn* — a pair goes on the map, everything else beside it — but that is a
* fact about the format and not a shelf the reader has to put it on.
*
* **A synthetic row is here** (§18.5): a task's profile, its called set, and a
* comparison of two tracks. It had a card of its own, above this one, with
* the exports and the Recompute button only a computed lane has and almost
* none of the controls a loaded one has — so an insulation track could not be
* given a height, limits, a group or a flip, and could not be ticked into a
* batch with the bigWig it was being compared against. One list, the same
* controls, and a `*` after the name (`ui/synthetic.ts`); what only a
* synthetic row has — its provenance, its exports, Recompute — rides in the
* same places a loaded row's summary, origin and re-open button do.
*
* **A metadata column is out** (§8.12). It came from no file — it is a column
* of the map that is already open — and it has a card of its own, which is
* where the drop-down that adds one lives.
*/
private trackRows(): AnnotationEntry[] {
// …and a structure is a row of the Structures card (§25), whatever road it
// came by — loaded, or computed by *Model from the map*: it is never a lane
// and has nothing this card's controls do.
return this.props.store.entries.filter(
(entry) => entry.source.kind !== 'metadata' && entry.kind !== 'structure',
)
}
private clearDropMarks(): void {
for (const node of this.list.querySelectorAll('.drop-before, .drop-after')) {
node.classList.remove('drop-before', 'drop-after')
}
}
// -------------------------------------------------------------------------
// The head's own controls
// -------------------------------------------------------------------------
/**
* The colour, as the picker itself rather than as a button that opens one.
*
* `input[type=color]` styled down to the size of the swatch it replaces: one
* click on the colour opens the picker, which is what a colour swatch in a
* list of layers does everywhere else. The old arrangement — a dead square
* here, the real control two clicks away behind the settings — made the
* commonest change to an annotation one of the most expensive.
*/
private swatch(entry: AnnotationEntry): HTMLElement {
const input = el('input', {
class: 'ann-swatch',
type: 'color',
value: entry.style.colour,
title: `Colour for ${entry.label}`,
})
input.addEventListener('input', () =>
this.props.store.restyle(entry.id, { colour: input.value }),
)
return input
}
/**
* What the lane is called, on screen and in an exported figure.
*
* Editable in place, by clicking the name — the file name is a starting point
* rather than an answer (`Yeast_ATAC.bw` and `Yeast_ATAC.narrowPeak` are two
* annotations with one name, and the lane label is what tells them apart on
* the plot). It is an `<input>` that looks like text until it has the caret,
* rather than a label that swaps itself for one: the swap is a frame of
* flicker and a lost first keystroke, and there is nothing the plain text
* could do that the quiet input does not.
*/
private name(entry: AnnotationEntry): HTMLElement {
const input = el('input', {
class: 'input ann-name',
type: 'text',
value: entry.label,
title: `${entry.label} — click to rename`,
})
input.addEventListener('change', () => {
const label = input.value.trim()
// An empty name would leave a lane with nothing drawn on it and a row that
// cannot be told from its neighbour; refused, by restoring what was there.
if (label === '' || label === entry.label) {
input.value = entry.label
return
}
this.props.store.update(entry.id, { label })
})
input.addEventListener('keydown', (event) => {
// Enter that goes on to the next row's name, Shift+Tab the one before (§26.8).
if (tabAmong(event, input, '.ann-list', 'input.ann-name')) return
if (event.key === 'Enter') {
event.preventDefault()
input.blur()
} else if (event.key === 'Escape') {
event.preventDefault()
input.value = entry.label
input.blur()
}
})
return input
}
/**
* Which feature set the search reads.
*
* Several gene models can be drawn at once — comparing two annotations of one
* genome is a thing people do — but a search box answering from all of them
* returns the same gene four times over, differing only in which file it came
* from. So exactly one is the one being searched: filled here, empty on the
* others, and one press moves it.
*/
private star(entry: AnnotationEntry): HTMLElement {
const current = this.props.store.searchSource()?.id === entry.id
const node = iconButton(
'star',
current
? `${entry.label} is what the feature search reads`
: `Search ${entry.label} instead`,
() => this.props.store.makePrimary(entry.id),
{ class: `btn btn-icon ann-star${current ? ' is-on' : ''}`, size: 13 },
)
node.setAttribute('aria-pressed', String(current))
node.disabled = current
return node
}
private visibility(entry: AnnotationEntry): HTMLElement {
// An eye, drawn at the size of the fold arrow beside it. The two glyphs it
// used — a filled circle and an empty one — were a radio button as far as
// anyone could tell, they were set in whatever font the OS chose, and they
// were a different size from every other control on the row.
const node = iconButton(
entry.visible ? 'eye' : 'eye-off',
entry.visible ? 'Hide, keeping the settings' : 'Show again',
() => this.props.store.update(entry.id, { visible: !entry.visible }),
{ class: 'btn btn-icon ann-eye', size: 14 },
)
node.setAttribute('aria-pressed', String(entry.visible))
return node
}
// -------------------------------------------------------------------------
// Per-annotation settings
// -------------------------------------------------------------------------
/**
* What is left once the head has taken the colour, the name and the order.
*
* Everything here is a decision about *how the data is drawn*, which is the
* part worth folding away: it is settled once when a track is loaded and then
* left alone, unlike the three the head carries.
*/
private settings(entry: AnnotationEntry): HTMLElement[] {
const out: HTMLElement[] = []
// A called set's display first (§18.7), because it decides which of the
// controls below the row has: a 1D track's height, edges and colouring, or
// an overlay's look, opacity and maps. Moving it rebuilds the row around the
// dropdown — this card holds its rebuild while a select has the focus, so
// the focus is let go, and given back to the same dropdown after.
const called = calledSetFields(this.props.store, entry, {
map: this.props.context !== 'browser',
onDisplay: (select) => {
this.refocus = { attribute: 'data-display', id: entry.id }
select.blur()
},
})
if (called !== null) out.push(called)
// A lane has a thickness; an overlay is drawn on the map and has no lane.
if (isLaneRow(entry)) out.push(this.laneRow(entry))
// Which edge of the map — a choice a 1D browser does not have, since there
// is one axis and everything is drawn along it. And not offered for a pile
// either: reads are 1D-only (§8.13), so beside a map the row is there to be
// closed and renamed and nothing else. A control that silently does nothing
// is worse than an absent one.
// …nor for a structure, which is a row and not a lane (§25): its picture
// is the deck's structure cell, not an edge of the map.
if (
isLaneRow(entry) &&
entry.kind !== 'reads' &&
entry.kind !== 'structure' &&
this.props.context !== 'browser'
) {
out.push(placementField(this.props.store, entry, mapsOrLead(this.props.maps)))
}
if (hasTrackControls(entry.kind)) out.push(this.limits(entry), this.group(entry))
// Which reads count, on both of a BAM's rows (§8.13). Written through to
// the other one — a mapping-quality floor applied to the pile and not to
// the depth beside it is two pictures of two different files under one name
// — which `patchReads` does, so the two controls need not know about each
// other.
if (readsAlignments(entry.kind)) {
out.push(
el(
'div',
{ class: 'ann-inline' },
mapqField(this.props.store, entry),
duplicatesToggle(this.props.store, entry),
),
)
}
// …and the depth's own arcs, the sashimi plot (§8.23): drawn over the
// curve, so the depth row has them and the pile does not — its reads draw
// their gaps themselves.
if (entry.kind === 'alignments') {
out.push(sashimiField(this.props.store, entry), modFractionField(this.props.store, entry))
}
// …and how the pile is drawn, which only the pile has. A colouring and a
// pairing mean nothing to a depth curve, so they are not offered on the row
// that draws one.
if (entry.kind === 'reads') {
// The key is built first and handed to the picker, so a colouring chosen
// with the focus still on the select refreshes it immediately — this card
// defers its own rebuild while a control holds focus, and the lane does
// not.
const key = colourLegend(entry)
// …and, under a colouring by base modification, which one and how sure
// (§8.13). In a slot filled from the picker for the key's reason: the
// card's own rebuild waits for the select to lose focus.
const mods = el('div', { class: 'ann-mods' })
const fillMods = (by: ColourBy): void => {
mods.replaceChildren(...(by === 'basemod' ? [modField(this.props.store, entry)] : []))
}
fillMods(readsOf(entry.style).colourBy)
out.push(
el(
'div',
{ class: 'ann-inline' },
colourByField(this.props.store, entry, (by) => {
fillLegend(key, entry.style.colour, by)
fillMods(by)
}),
layoutField(this.props.store, entry),
),
key,
mods,
splitsToggle(this.props.store, entry),
)
}
// …and how a lane of intervals is drawn (§8.16). Every kind that is a
// `FeatureSet` gets it — a BED, a peak set, a gene model, a VCF — because
// the question "what are these coloured by" has the same answer and the
// same list of answers on all four. Not on an overlay, where the segments
// are drawn on the map as points, markers or corners along the diagonal in
// the row's one colour and there is no lane to colour: a control that
// silently does nothing is worse than an absent one.
if (isLaneRow(entry) && isIntervalSet(entry.kind)) {
// …with one exception, and it is the same rule as the two above rather
// than a special case: a control that silently does nothing is worse than
// an absent one. A barChart's ink is its categories' and is not a choice
// (§8.17), and its glyph is bars rather than a box, so neither the
// picker nor the arrowheads have anything to change. The **key** is what
// is left, and it is the one of the three this lane needs most: it is
// where the bars get their names.
const key = featureLegend(entry, this.props.store)
if (entry.kind === 'ideogram') {
// A karyotype's ink is its stains' and its glyph is one shape rather
// than boxes on rows (§8.22), so the picker, the arrowheads and the row
// modes would all be controls that silently do nothing. The key is
// what is left, and it is where a reader learns which grey is which.
out.push(key)
} else if (entry.kind === 'junctions') {
// The value axis first, for the barChart's reason (§8.17, §8.23): two
// samples' arcs are comparable only against one ceiling, and the floor
// is the minimum count a junction is drawn at. Then what the arcs are
// coloured by — strand, or any column the aligner wrote — and no
// arrowheads or row modes, since an arc points nowhere and is not
// packed into rows.
out.push(
this.limits(entry),
featureColourField(this.props.store, entry, (by) => fillFeatureLegend(key, entry, by, this.props.store)),
key,
)
} else if (entry.kind === 'barchart') {
// …and the value axis, which is the one control §8.17 left it without.
// Both readings a barChart had were *relative* — every chart against the
// file's tallest bar, or each against its own — so two lanes of two
// files could not be compared by eye however carefully they were laid
// out: the quieter file drew the taller bars. The limits row is the same
// control a bigWig has and means the same thing, and it is above the
// per-chart switch because it overrides it.
out.push(this.limits(entry), barScaleToggle(this.props.store, entry), key)
} else {
// The key is built first and handed to the picker, for the reason the
// pile's is: this card defers its own rebuild while a control holds
// focus, and the lane does not.
out.push(
el(
'div',
{ class: 'ann-inline' },
featureColourField(this.props.store, entry, (by) =>
fillFeatureLegend(key, entry, by, this.props.store),
),
arrowsToggle(this.props.store, entry),
),
featureDisplayField(this.props.store, entry),
key,
)
}
}
// A sequence lane's value axis is GC, and it is worth being able to clip:
// between 0.3 and 0.5 is where a genome's variation actually lives. No
// group, though — GC shares no units with anything else on the stack, so
// resolving it together with a coverage track would flatten both.
//
// And its reading frames, with the code they are read in (§8.8): six rows
// under the residues, drawn wherever the residues are.
if (entry.kind === 'sequence') {
out.push(this.limits(entry), framesField(this.props.store, entry))
}
// On the map, a set of places has its look beside its display, above; a
// set of extents has one, its corners; a set of pairs has a ring's size.
if (entry.card === 'overlay') {
if (entry.kind === 'pairs') out.push(this.radius(entry))
out.push(this.opacity(entry))
// …and which maps it is drawn on, which a deck makes a question: the
// location field's, one mark per map rather than four edges. Not in the
// 1D browser, which has no map to draw one on.
if (this.props.context !== 'browser') {
const maps = overlayMapsField(this.props.store, entry, mapsOrLead(this.props.maps))
if (maps !== null) out.push(maps)
}
}
// What only a synthetic row has, where a loaded row has its path: the
// exports, then the framing it was computed against — the framing in the
// origin line's monospace, because it is looked up rather than read. A
// detached row has no numbers to export until it is recomputed.
if (isSynthetic(entry)) {
const exportTo = this.props.onExport
if (exportTo !== undefined && entry.status === 'ready') {
out.push(syntheticExports(entry, exportTo))
}
const framing = framingOf(entry)
if (framing !== null) out.push(el('p', { class: 'ann-origin', text: framing, title: framing }))
return out
}
// A loaded set of pairs leaves as its anchors (§8.1); the pairs are the file.
if (entry.kind === 'pairs' && entry.payload?.kind === 'pairs' && this.props.onExport !== undefined) {
out.push(pairExports(entry, this.props.onExport))
}
out.push(this.origin(entry))
return out
}
/**
* Where this came from, in small type at the bottom of the settings.
*
* A name is renameable and the default one is a file name with its suffix
* taken off, so after an hour's work a panel of five tracks says nothing about
* which file any of them is. Last and quiet, because it is looked up rather
* than read: `title` carries the whole of a URL that is too long for the
* column, and the text is selectable so it can be copied into a note.
*/
private origin(entry: AnnotationEntry): HTMLElement {
const source = entry.source
const shown =
source.kind === 'remote'
? source.url
: source.kind === 'detached'
? `${source.name} — put down; the file is no longer readable`
: source.name
return el('p', { class: 'ann-origin', text: shown, title: shown })
}
/**
* Lane thickness in pixels, with the drawing style beside it.
*
* The controls themselves are `ui/laneSettings.ts`'s, shared with the
* Metadata card (§8.12); what is decided here is which of them a *kind* gets.
* They share a row because neither fills one, and a settings body of six
* half-empty rows is one that has to be scrolled.
*/
private laneRow(entry: AnnotationEntry): HTMLElement {
return el(
'div',
{ class: 'ann-inline' },
heightField(this.props.store, entry),
// The colours picker rides with the style and shows itself only under a
// heatmap: on a line the colour is the chip on the head, and a second
// colour control there would be two answers to one question.
...(hasTrackControls(entry.kind)
? [
...styleFields(this.props.store, entry),
scaleField(this.props.store, entry),
this.flip(entry),
]
: []),
...(entry.kind === 'sequence' ? [this.mode(entry)] : []),
)
}
/** The value-axis flip; the shared control (`ui/laneSettings.ts`). */
private flip(entry: AnnotationEntry): HTMLElement {
return flipButton(this.props.store, entry)
}
private mode(entry: AnnotationEntry): HTMLElement {
return modeField(this.props.store, entry)
}
/** Auto, or a pair of numbers — the shared control (`ui/laneSettings.ts`). */
private limits(entry: AnnotationEntry): HTMLElement {
return limitsField(this.props.store, entry)
}
/**
* The autoscale group: the ones that exist, plus a way to start another.
*
* This was a free-text box, on the reasoning that the groups that exist are
* the ones the reader has typed and that offering a list of them would mean
* inventing a separate place to create one. That was wrong in the direction
* that matters: a group is only a group once two tracks are in it, so the
* second track has to be given the *same* name as the first, and a box gives
* no way to find out what that was without opening the other row and reading
* it. A misremembered name raises nothing — it quietly makes a group of one,
* which looks exactly like a group that is not working. The list is the
* memory; `new group…` is where a name is coined, so nothing had to move
* elsewhere to get one.
*/
private group(entry: AnnotationEntry): HTMLElement {
const host = el('div', { class: 'ann-group' })
const chooser = (): void => {
const select = el('select', { class: 'select' })
// `no group`, the batch bar's word for the same state: two names for one
// empty option, one card apart, is a reader wondering what the difference
// is between belonging to none and belonging to `none`.
select.appendChild(el('option', { value: '', text: 'no group' }))
for (const name of this.groupNames()) {
select.appendChild(el('option', { value: name, text: name }))
}
select.appendChild(el('option', { value: NEW_GROUP, text: 'new group…' }))
select.value = entry.style.group
select.addEventListener('change', () => {
if (select.value === NEW_GROUP) naming()
else this.props.store.restyle(entry.id, { group: select.value })
})
host.replaceChildren(select)
}
const naming = (): void => {
const input = el('input', {
class: 'input',
type: 'text',
placeholder: 'group name',
title: 'Enter names the group; Escape leaves it as it was.',
})
// Committed on Enter and on leaving the box, abandoned on Escape. An empty
// name is an abandonment too: it cannot be stored, since `''` already
// means "no group".
let settled = false
const commit = (): void => {
if (settled) return
settled = true
const name = input.value.trim()
if (name === '') chooser()
else this.props.store.restyle(entry.id, { group: name })
}
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
} else if (event.key === 'Escape') {
event.preventDefault()
settled = true
chooser()
}
})
input.addEventListener('blur', commit)
host.replaceChildren(input)
input.focus()
}
chooser()
return this.field(
'group',
host,
'Tracks sharing a group share one range, over everything they show. That is the only way ' +
'two tracks can be compared by eye.',
)
}
/** Every group name in use on this file, sorted, without duplicates. */
private groupNames(): string[] {
const names = new Set<string>()
for (const row of this.props.store.entries) {
if (hasTrackControls(row.kind) && row.style.group !== '') names.add(row.style.group)
}
return [...names].sort((a, b) => a.localeCompare(b))
}
private radius(entry: AnnotationEntry): HTMLElement {
return radiusField(this.props.store, entry)
}
private opacity(entry: AnnotationEntry): HTMLElement {
return opacityField(this.props.store, entry)
}
private field(label: string, control: HTMLElement, hint?: string): HTMLElement {
return el(
'label',
{ class: 'field ann-field', title: hint ?? '' },
el('span', { class: 'field-label', text: label }),
control,
)
}
}
/**
* `CSS.escape`, with a fallback.
*
* The ids this escapes are minted here and are always `a<digits>`, so nothing
* needs escaping in practice. It is used anyway because the day one comes from
* somewhere else is not the day to discover that the selector was built by
* concatenation — and `CSS` is absent in a non-browser environment, which the
* test runner is.
*/
function cssEscape(value: string): string {
return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(value)
: value.replace(/["\\]/g, '\\$&')
}
/**
* What to say when a file and a map disagree about contig names.
*
* §8.2 requires this to be impossible to miss, and it applies to every format
* rather than only to bigWig: a silent zero-overlap — a GTF using `chrI` against
* a map using `I` — is the most likely support question there is, and it
* presents as "the feature does not work" rather than as "these two files
* disagree about names". The two failures want different fixes and are
* therefore worded differently: no overlap at all is a naming convention
* mismatch, and partial overlap is usually a partial annotation.
*
* Since §5.9 the first of those two is a much smaller category, and the wording
* had to move with it. A track spelling its sequences `chr1` against a map
* spelling them `1` is now *renamed on the way in* and matches in full, so a
* zero here no longer means "these two use different conventions" — that case
* is handled. It means the matcher looked and found nothing it could justify,
* which is a different and more serious claim: two files about different
* genomes. Telling a reader to go and rename their chromosomes would now be
* advice for a problem they do not have.
*/
export interface OverlapNote {
/**
* How loudly to say it.
*
* `warn` is *this file does not fit this map* — no overlap at all, a partial
* one on a single-genome file, or a cross-convention match whose lengths
* disagree. `note` is *this file fits part of it*, which on a metagenome is
* what fitting looks like: a track over a co-assembly covers the organisms it
* was made about, and painting that yellow would make the ordinary case read
* as a fault (§8.5).
*/
tone: 'warn' | 'note'
text: string
}
export function overlapWarning(overlap: ContigOverlap | undefined): OverlapNote | null {
if (overlap === undefined || overlap.total === 0) return null
if (overlap.matched === 0) {
const sample = overlap.extra.length > 0 ? ` This file names ${overlap.extra.join(', ')}…` : ''
return {
tone: 'warn',
text:
`No sequence in this file matches the map (0 of ${fmt.int(overlap.total)}).${sample} ` +
'The usual naming conventions were tried, so this is likely a file about another genome.',
}
}
// **On a metagenome the unit of the answer is the MAG** (§8.5). A track over
// a co-assembly routinely covers a few organisms out of fifty — coverage of
// one MAG, an annotation of another — and the sequence counts state that as
// `412 of 20,566 sequences matched`, which is arithmetically true and reads
// exactly like the failure above it. So the sentence is about genomes, and it
// is not a warning: partial coverage is the *expected* shape of a track here,
// and the reader is told which organisms it landed on rather than which
// twenty thousand sequences it did not.
const genomes = overlap.genomes
if (genomes !== undefined && genomes.matched > 0) {
if (genomes.matched === genomes.total) {
return {
tone: 'note',
text:
`${fmt.int(overlap.matched)} of ${fmt.int(overlap.total)} sequences, across all ` +
`${fmt.int(genomes.total)} genomes.`,
}
}
const more = genomes.matched > genomes.named.length ? '…' : ''
return {
tone: 'note',
text:
`${fmt.int(overlap.matched)} of ${fmt.int(overlap.total)} sequences, in ` +
`${fmt.int(genomes.matched)} of ${fmt.int(genomes.total)} genomes: ` +
`${genomes.named.join(', ')}${more}.`,
}
}
if (overlap.matched < overlap.total) {
return {
tone: 'warn',
text:
`${fmt.int(overlap.matched)} of ${fmt.int(overlap.total)} sequences matched; ` +
`nothing is drawn on ${overlap.missing.join(', ')}${overlap.missing.length < overlap.total - overlap.matched ? '…' : ''}.`,
}
}
// Matched in full, and *that is the problem*: a rename this file was not sure
// of counts as a match, so the two clauses above go quiet on the one case
// that most warrants a word. hg19's `MT` against hg38's `chrM` is one name
// apiece and two sequences (§5.9).
if (overlap.contested !== undefined && overlap.contested.length > 0) {
return {
tone: 'warn',
text:
`Matched across two naming conventions, but the sequences are different ` +
`lengths: ${overlap.contested.join(', ')}. Two assembly versions would look ` +
`exactly like this — the coordinates still land, they just may not mean the same place.`,
}
}
return null
}
/**
* Where a file goes in: a dashed zone, and a URL under a fold.
*
* At the *top* of the Tracks card, above the list, rather than in a card of its
* own below it. The two questions — *what is loaded* and *put something else
* here* — are asked of the same thing, and separating them put the answer to the
* second below however many rows the first had: with eight tracks loaded, the
* way to load a ninth was to scroll past the eight. Above the list it stays
* where it was whatever the list does.
*
* The zone carries the dashes because being a target is all it is. The card
* around it takes a dropped file as well, anywhere on it — the hint does not say
* so, because a rectangle drawn as a target is where a hand aims and a sentence
* about the rest of the card is a sentence about a gesture nobody was about to
* make; what it says instead is the thing the other two zones in this column say
* and this one did not, which is that it can also be clicked.
*/
function loadZone(loader: AnnotationLoader): { nodes: HTMLElement[]; zone: HTMLElement } {
// Through `chooseFiles` so a track *chosen* is remembered as readily as one
// dropped — the picker hands back a grant where the browser has one, and falls
// back to the input this used to hold everywhere else.
const choose = async (): Promise<void> => {
const files = await chooseFiles({ multiple: true })
if (files.length > 0) loader.addFiles(files)
}
const zone = el(
'div',
{ class: 'ann-drop', attrs: { tabindex: '0', role: 'button' } },
el('span', { class: 'ann-drop-label', text: 'Load a track' }),
el('span', {
class: 'ann-drop-hint',
// The whole list, and the same last sentence the other two zones in this
// column say (§20.2). It read `bigWig, bigBed, GTF, BED, BEDPE, VCF,
// FASTA or a BAM` — an article on one format of eight, and six formats
// short: a reader holding the bedGraph that MACS2 or deepTools wrote, or
// a 2bit, was told this viewer would not take it. BED's variants are not
// listed because content decides (§8.3), so a narrowPeak *is* a BED — and
// a junctions BED is one too; a cytoBand and STAR's table are not, and
// are named (§8.22, §8.23).
text:
'bigWig, bedGraph, wig, bigBed, BED, GTF, genePred, BEDPE, interact, VCF, BAM, FASTA, ' +
'2bit, cytoBand or SJ.out.tab. Drop one here, or click to choose.',
}),
)
zone.addEventListener('click', () => void choose())
zone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
void choose()
}
})
// No drop handling of its own: the card around it takes a file anywhere on
// it, a drop landing on the zone bubbles there, and the card lights *this*
// element while it happens — so the target the reader is looking at is the
// one that reacts, and one gesture still has one handler.
// **The zone and nothing under it.** This card used to carry a box to paste a
// URL into and, beside it, a button onto the places this window is connected
// to. Both are gone: what a card that asks for a file needs is the target
// above, and browsing a connected folder is what the Files column is
// (§20) — a second door to it, folded into the bottom of every card that takes
// a file, was three ways to do one thing in a column that lists what is drawn.
return { zone, nodes: [zone] }
}
/**
* The lowest opacity a track may be set to.
*
* Not zero. A track at zero is a track that is not drawn, hidden under a control
* that says nothing about visibility — a reader who typed it would be looking
* for a missing track in the wrong card. `hide` is beside every row and says
* what it does. It is also the floor the per-row slider has kept all along
* (`laneSettings.opacityField`), which is the same argument reached twice.
*/
const MIN_OPACITY = 10
/**
* Opacity, for every track at once.
*
* A property of the *picture* rather than of any one track: what it is for is
* reading a gene model through the coverage drawn over it, or telling two
* overlaid strands apart, and both of those are answered by everything on screen
* moving together. Set per track it would be a slider to visit six times, and
* the sixth visit would be to undo the first.
*
* **Opacity and not transparency**, which is what this said until it was put
* beside the per-row control that writes the same field: that one is labelled
* `opacity` and reads 10 to 100, this one read `transparency` from 0 to 90, and
* one field of one store named in opposite senses on two surfaces of one column
* meant a track set to 60 in its row read 40 here — a subtraction for the reader
* to do to check that the two agreed. A stepper rather than the row's slider
* because `trackControls.ts` argues the stepper for a card-level bulk value, and
* the number is now the same number either way.
*
* Lives here, beside the per-track controls, because it writes the same field of
* the same store; the two Tracks cards each mount one.
*/
export function everyTrackOpacityField(store: AnnotationStore): HTMLElement {
return stepperField({
label: 'opacity',
// Both halves of the pair are percentages and only one of them said so:
// `text size` prints `135%` between its chevrons while this printed a bare
// `0`, which reads as a different kind of control (§26.2).
unit: '%',
title:
`Opacity of every track, as a percentage — 100 is solid, ${MIN_OPACITY} is nearly gone. ` +
'Applies to all of them at once.',
// Five, because one per cent of opacity is not a visible change and a
// reader pressing this is watching the picture, not counting.
step: 5,
min: MIN_OPACITY,
max: 100,
// Read from the *store*, so the box shows what the tracks actually are: a
// config loaded with faded tracks, or a single row's own opacity changed
// from its settings, both land here.
// A hundred rather than `null` for an empty store: `null` means "leave the
// box alone", which on a session with no tracks yet left it *blank* — a
// number field showing nothing, in a card whose other two boxes show
// numbers. There is a true answer here and it is solid: nothing is faded,
// because there is nothing to fade, and a track dropped in a moment later
// arrives opaque.
read: () => {
const first = store.entries[0]
return first === undefined ? 100 : Math.round(first.style.opacity * 100)
},
apply: (percent) => {
const opacity = percent / 100
for (const entry of store.entries) store.restyle(entry.id, { opacity })
},
subscribe: (onChange) => store.subscribe(onChange),
})
}
|