All files / src/render laneLabel.ts

97.55% Statements 399/409
90.04% Branches 208/231
95.83% Functions 23/24
97.55% Lines 399/409

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                                                    2x 2x   2x 2x                                                                                                         2x                                               2x                             2x     2x                 2x                                   2x 28x 28x 28x 28x 28x 28x 28x   28x 20x 20x         12x 20x 20x 12x 12x                   32x 32x 32x 32x 32x 32x         32x 32x 803x 722x 803x 32x 310x 32x 32x 81x       81x 81x 81x 81x 81x       23x 32x 32x 23x 32x 32x                           2x 24x 24x 24x 212x 211x 212x 212x 210x 212x 212x 212x 24x 23x 23x   2x 2x                                                                                                                                                                                                                                                                                                                                                   2x         2x 2x 2x   2x   2x 2x 2x                                                                                     2x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x 1771x             1771x 1771x 1771x 1780x 1780x           1771x 45x 45x 45x 35x 45x 45x                 1771x 2235x   1771x 1771x 1771x 1771x           1771x 1771x   1771x 1775x 1771x 1771x 1771x 1772x                               1771x 27x 25x 2x 2x 2x 2x 2x 2x                         1771x 2167x 2167x 2166x 2166x   2167x 1771x 1771x 2233x 2233x 27x 2206x 2167x 39x 2233x 1771x 1771x 3542x 1771x 462x 462x   1771x 1771x         1771x 1771x 1309x 1771x                       1771x 1771x 1771x 35x 1736x 849x 887x             1771x 1771x           1771x 1771x 1771x 1773x 1773x 1773x 1773x 1773x 1773x 1773x 1771x 1771x 1771x 1771x 669x 1102x                             1771x 909x 862x                     1771x 1736x 1736x 1736x 1736x 1736x 1736x 1736x 1736x 1736x   1771x 1771x 1771x 1771x           1771x   1771x 1771x 35x 25x 25x 1x 1771x 1771x 1773x 1773x     1773x 1773x 1773x 1773x 1773x 1773x 1773x 1773x 1773x 1736x 37x 37x 37x 37x 1773x             1776x 1774x 1774x 1774x 1774x 1774x 1774x 1774x 1774x 1774x 1774x 1774x 1774x     1776x 1776x 1776x                   1776x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1609x 1776x 1776x 1773x 1773x 1771x   1771x 461x 461x 461x   1310x 1310x 1310x 1771x           1771x   1771x             425x 161x 264x 132x 132x 425x 425x 425x       425x 425x 425x 425x 425x 425x 425x 425x 425x   1734x     426x 426x 426x 426x 426x 426x 426x 426x 426x 426x 426x 426x 426x     426x 426x 426x 426x 426x 426x 426x 426x     1771x 1771x       1771x 1771x 1771x       459x 459x 1733x 918x 918x 918x 918x 802x 802x 802x 802x 802x 802x 802x 802x 802x 802x 802x 802x 802x 918x 918x 459x 459x 459x 918x       1771x 401x 401x 401x 401x 401x 401x 401x 401x 401x 459x 459x 459x                                                     2x 1789x 1789x 1310x 479x 1789x 1789x                 2x                                   2x 9x 9x 9x 9x 9x                     1771x 1771x 1771x 1771x 1771x 1771x 1310x 1310x 1310x 1734x 425x 1734x 426x 426x 885x 459x 459x 459x 459x 459x 459x 459x 1310x 1310x 1310x   9260x 9260x 9260x     2x                   2x     2x                 2x                 2x  
/**
 * What is written on a lane, drawn once and *reported back*.
 *
 * Every lane in this application carries the same block of small print: a
 * colour chip, one name or several, and under them what the value axis spans.
 * It was written twice — once in the 1D browser and once beside the map — and
 * the two copies drifted exactly where it mattered. The browser's version
 * measured each end of the range separately and remembered the box, which is
 * what makes a limit something a reader can put a caret in; the map's joined the
 * pair into one string and forgot where it had put it, which is what made the
 * same two numbers inert there.
 *
 * So there is one implementation, and its return value is the point of it: a
 * label that only *draws* can never be pointed at. The boxes come back in canvas
 * coordinates from the same pass that measured the text into them, because any
 * second measurement is a second opinion about the same font — and the
 * difference between the two opinions is exactly the pixel the click misses by.
 * Both halves of the block are reported, for the same reason: the range is what
 * a reader reads the values against, and the **name** is what tells this lane
 * from the one under it. Neither should have to be corrected somewhere else.
 *
 * Orientation-agnostic, like everything else in this directory: every mark is
 * placed through {@link laneRect} and {@link laneText}, so a right-hand lane's
 * quarter turn is `render/lane.ts`'s business and not this module's (§8.4).
 */
 
import { laneIsHorizontal, laneRect, laneText, valueAcross, type LaneFrame } from './lane'
import { formatScaledLimit, normaliser } from './tracks'
import type { TrackScale } from '../annotations/types'
import { formatPosition } from '../genome/assembly'
import { fmt } from '../units'
 
/** A rectangle in canvas pixels: what a pointer is tested against. */
export type LabelBox = [x: number, y: number, width: number, height: number]
 
/** One name on a lane, and the colour its ink is drawn in. */
export interface LaneLabelName {
  /** The entry this name belongs to, so a control can be tied back to it. */
  id: string
  text: string
  colour: string
}
 
/**
 * The sizes a surface writes its small print at.
 *
 * Passed in rather than chosen here, and that is deliberate. Beside a map a lane
 * can be sixteen pixels tall and its text has to be nine; in the browser a lane
 * is ninety and its name is the thing a reader looks for, so it is twelve and
 * heavier. Neither set is a default the other should inherit — a shared module
 * that picked one would reflow the surface it did not pick.
 */
export interface LaneLabelStyle {
  nameFont: string
  rangeFont: string
  /** How long the bar under a name is while its read is on its way, in pixels. */
  progressWidth: number
  /** …and how thick. */
  progressThickness: number
  /** The colour chip before a name, in CSS pixels. `0` draws none. */
  swatch: number
  /** Where the first name's line sits, measured from the label's own end. */
  nameTop: number
  /** Line height for a column of names, one per overlaid track. */
  nameLine: number
  /** How far under the name the range sits, when the lane is tall enough. */
  detailDrop: number
  /** Clear space between the lane's near end and the start of the block. */
  inset: number
  /** How tall a limit's click target is: the text, plus a little either side. */
  hitHeight: number
  /** Room kept after the range for the lane's autoscale button. `0` for none. */
  autoSlot: number
  /**
   * How close to the lane's far border counts as being on it.
   *
   * Not decoration: it is what the resize drag grabs, and a limit's click target
   * that reached into it would promise a caret where the press resizes.
   */
  grip: number
}
 
/** The ellipsis a cut name is joined with; `ui/vector.ts` can draw it into a PDF. */
export const NAME_CUT = '…'
 
/**
 * Where the pointer is, at the precision the picture can actually resolve.
 *
 * One rule for both lane surfaces and for the 1D browser's own status line,
 * because they are one surface (§8.8) and they were answering the same hover
 * with two different instruments: the lane tooltip beside a map said
 * `IV:1.18 Mb` over 1 kb bins while the matrix tooltip one pixel above said
 * `IV:1,162,001`, and the browser said `IV:1.27 Mb` at 47 bases per pixel,
 * where the string does not change across a fifth of the window. A readout that
 * does not move as the pointer moves is not a readout.
 *
 * So: the **base**, 1-based and grouped through {@link formatPosition}, while a
 * pixel is worth less than a kilobase — which is every zoom a reader reads a
 * peak position at, and is where `fmt.bp`'s two decimals of a megabase name a
 * ten-kilobase neighbourhood instead of a place. Above that the unit form is
 * the honest one: at thirteen thousand bases to the pixel, six digits are five
 * digits of noise.
 *
 * @param bpPerPixel How many bases one pixel of this picture covers. Positive;
 * a non-finite value is read as "very wide", which is what an axis with no
 * resolvable scale is.
 */
export function positionLabel(contig: string, bp: number, bpPerPixel: number): string {
  return Number.isFinite(bpPerPixel) && bpPerPixel < EXACT_BP_PER_PIXEL
    ? formatPosition(contig, Math.round(bp))
    : `${contig}:${fmt.bp(bp)}`
}
 
/**
 * The pixel width at which a readout stops naming the base.
 *
 * A kilobase, which is the coarsest bin anyone browses at and the point at
 * which `fmt.bp` still gains a digit per zoom step. It was ten, chosen so the
 * exact form appeared only at base resolution — and ten bases per pixel is a
 * window of seven kilobases on a wide screen, so in practice the readout was
 * never exact anywhere a reader works.
 */
const EXACT_BP_PER_PIXEL = 1000
 
/** Where a name is allowed to break: the punctuation experiments are named with. */
const BREAKS = /[ _.:/-]/
 
/**
 * How a case-change break is marked and ranked, in one number.
 *
 * Larger than any name anybody types, so `at + CASE_PENALTY` both tags the
 * candidate and sorts it after every punctuation break — and `at % CASE_PENALTY`
 * gets the index back. One array, one sort, and no parallel list of flags.
 */
const CASE_PENALTY = 100_000
 
/**
 * A name written into `width`, in at most `lines` lines.
 *
 * Three answers in order of preference, and the order is the whole design. A
 * name that fits is left alone. One that does not is **broken** — at the
 * separator nearest its middle whose two halves both fit, because
 * `Yeast_ChIP_Pol2-vs-input` breaks at a word and `YeastChIPPol2vsinput` has no
 * word to break at. One that will not fit even broken is **cut in the middle**,
 * `Yeast_…-input`, because the ends of an experiment's name are where the
 * information is: a run of tracks from one experiment share their beginnings,
 * and a truncation from the right leaves eight identical rows.
 *
 * `measure` is injected rather than a `ctx`, so the arithmetic is testable
 * without a canvas — and so the caller can subtract whatever the colour chip
 * takes from the first line's budget.
 */
export function fitName(
  measure: (text: string) => number,
  text: string,
  width: number,
  lines: number,
): string[] {
  if (text === '' || !(width > 0)) return [text]
  if (measure(text) <= width) return [text]
 
  if (lines >= 2) {
    const broken = breakInTwo(measure, text, width)
    if (broken !== null) return broken
    // Too long for two lines even broken: cut to what two lines hold, and break
    // *that*. It was cut to two lines' width and handed back as one line, so a
    // regtools BED dropped under its own name ran twice the column's width out
    // over the arcs it named.
    const cut = breakInTwo(measure, cutMiddle(measure, text, width * lines), width)
    if (cut !== null) return cut
  }
  return [cutMiddle(measure, text, width)]
}
 
/**
 * Two lines that both fit, or `null`.
 *
 * The break nearest the middle rather than the last one that fits, because a
 * name split as `Yeast_ChIP_Pol2-vs-` / `input` is one line of name and one of
 * remainder; the reader is looking for two halves. A hard split between
 * characters is the fallback for a name with no separator in it at all.
 */
function breakInTwo(
  measure: (text: string) => number,
  text: string,
  width: number,
): [string, string] | null {
  const middle = text.length / 2
  // Punctuation before case, and only then nearest the middle. A separator is
  // where the *writer* said one part ends; a case change is this module's guess
  // at one, and `Yeast_Ch` / `IP_input` is what happens when the guess is
  // allowed to outrank the fact for being two characters better placed.
  const points: number[] = []
  for (let i = 1; i < text.length; i++) {
    if (BREAKS.test(text[i - 1])) points.push(i)
    else if (isLower(text[i - 1]) && isUpper(text[i])) points.push(i + CASE_PENALTY)
  }
  const rank = (at: number): number =>
    Math.abs((at % CASE_PENALTY) - middle) + (at >= CASE_PENALTY ? CASE_PENALTY : 0)
  points.sort((a, b) => rank(a) - rank(b))
  for (const point of points) {
    const at = point % CASE_PENALTY
    // The separator goes with the line it ends, not onto the next one, and it is
    // dropped rather than drawn: `Yeast_` over `ChIP_input` reads as a name that
    // was cut, which is what the ellipsis is for and this is not.
    const head = text.slice(0, at).replace(/[\s_.:/-]+$/, '')
    const tail = text.slice(at).trimStart()
    if (head === '' || tail === '') continue
    if (measure(head) <= width && measure(tail) <= width) return [head, tail]
  }
  // No separator worth breaking at. Split between characters, as far along as
  // the first line will take, and only where the remainder fits as well —
  // otherwise this is a name for the middle cut rather than for two lines.
  let at = 0
  while (at < text.length && measure(text.slice(0, at + 1)) <= width) at++
  if (at === 0 || at === text.length) return null
  const tail = text.slice(at)
  return measure(tail) <= width ? [text.slice(0, at), tail] : null
}
 
/**
 * `beginni…end` — as much of both ends as `width` will take.
 *
 * Grown from the two ends alternately so neither is favoured, and the head goes
 * first so an odd pixel lands at the beginning, which is where the eye starts.
 *
 * Exported for the feature names, which are written by `render/features.ts` and
 * are the other run of text in this directory that outgrows its room: a MACS
 * peak name is its caller's run id plus a counter, forty-six characters over a
 * peak three pixels wide, and `LM331_…peak_1116` says the half of it a reader
 * can act on. One implementation, because two would cut at different pixels.
 */
export function cutMiddle(measure: (text: string) => number, text: string, width: number): string {
  let head = 0
  let tail = 0
  for (;;) {
    const grewHead = head + tail < text.length &&
      measure(`${text.slice(0, head + 1)}${NAME_CUT}${text.slice(text.length - tail)}`) <= width
    if (grewHead) head++
    const grewTail = head + tail < text.length &&
      measure(`${text.slice(0, head)}${NAME_CUT}${text.slice(text.length - tail - 1)}`) <= width
    if (grewTail) tail++
    if (!grewHead && !grewTail) break
  }
  if (head + tail >= text.length) return text
  return `${text.slice(0, head)}${NAME_CUT}${text.slice(text.length - tail)}`
}
 
const isLower = (ch: string): boolean => ch >= 'a' && ch <= 'z'
const isUpper = (ch: string): boolean => ch >= 'A' && ch <= 'Z'
 
/**
 * A column to align the block in, when the surface has one: the browser's
 * gutter, and beside a map the stack's margin running on into the corner
 * beside it (§8.8) — both while the reader has the *name column* on.
 *
 * `null` draws the block over the data, starting at the lane's near end, which
 * is what both surfaces do with the name column off, the default, and what the
 * assembly editor's stage always does.
 */
export interface LaneLabelColumn {
  /**
   * The column's two ends along the lane, `start` the smaller: past whatever
   * hangs in the margin, and clear of the data and of the handle. On a lane
   * down the page the text reads *up* it, so there `end` is where a line of
   * text starts and `start` where it stops.
   */
  start: number
  end: number
  /**
   * Which end of the column the block is set flush with, in the order the text
   * reads: `end` (the default, and the browser's) puts the last glyph at the
   * column's far end, beside the data after it; `start` puts the first glyph
   * at its near end, beside the data before it. The corners beside a map want
   * both (§8.8): the names left of a bottom stack end against it, the names
   * right of a top stack start against it.
   */
  align?: 'start' | 'end'
  /**
   * Hold a range written *beside* a name — on a lane too thin to stack it —
   * as part of the block: sized into what the column wants and aligned with
   * the name, rather than run on past the column's end. The corners beside a
   * map (§8.8), where past the end is the handle and then the data; the
   * browser's column leaves it as it was.
   */
  besideToo?: boolean
  /**
   * Set the lines against `across` rather than along it: beside a stack above
   * the map or left of it, where `across` runs up the page or leftward, so the
   * range still reads *under* its name — on the line after it in the order a
   * reader takes the lines — as it does beside the other two (§8.8).
   */
  flip?: boolean
  /** What to add to a measured width when reporting what the column wants. */
  pad: number
  /**
   * How many lines one name may be written on. Absent keeps the single line.
   *
   * Only a column can afford a second line, which is why it lives here: over the
   * data a name is one line beside its own signal, and wrapping it there would
   * write the second line across the track.
   */
  nameLines?: number
}
 
export interface LaneLabelSpec {
  /** One name, or one per track when a group is drawn into a single lane. */
  names: readonly LaneLabelName[]
  /**
   * What goes under the names: a phrase, a pair of limits, or nothing.
   *
   * The pair is not a string. Written as one it can be drawn and no more; kept
   * as two numbers it is drawn as two separately measured runs, each of which
   * comes back as a box — which is the whole difference between a range you read
   * and a range you edit.
   */
  detail:
    | { kind: 'text'; text: string }
    | {
        kind: 'limits'
        min: number
        max: number
        /**
         * The lane's scale and unit, so a `log2` lane's ends are written as the
         * quantity they stand for — `1 kb – 106 kb` on a scalogram's lane —
         * rather than as the exponents `9.97 – 16.7` (§18.12).
         */
        scale?: TrackScale
        unit?: 'bp'
      }
    /**
     * A read on its way: a phrase and a bar. `total` is `0` where the host
     * would not say how much is coming, and the bar is then drawn half full
     * rather than empty — a bar at nought says nothing has happened, and
     * something has.
     */
    | { kind: 'progress'; text: string; done: number; total: number }
    | null
  /**
   * Which end of the lane the block sits at, across.
   *
   * The end the signal is *not* growing from, which is a fact about where the
   * baseline is and therefore about the edge and the flip together — see
   * {@link labelEndFor}. A caption at the other end is written through the data
   * it is naming, which is the one place a caption must not be. Feature lanes
   * have no baseline and pass `near`.
   */
  labelAt: 'near' | 'far'
  column: LaneLabelColumn | null
  /**
   * Keep the detail on the name's own line, however tall the lane is.
   *
   * For a lane whose *signal sits where the second line would go*, which over
   * the data is one lane in particular: a GC track hovers around 0.4 of a fixed
   * 0–1 axis, and on the 46-pixel default the range line lands at across 26
   * while the trace is at 27. `0 – 1` was written straight along the line it
   * measures, at every zoom. Beside the name the label is one band of the lane
   * instead of two, and the plate below has one row to cover.
   *
   * Only where there is no column to write in: in the browser's gutter the
   * range under the name is over nothing, and it is a text box a reader puts a
   * caret in (§5.8).
   */
  detailBeside?: boolean
  style: LaneLabelStyle
  palette: { text: string; halo: string }
}
 
/** A colour chip, and the square it was drawn in. */
export interface SwatchBox {
  id: string
  box: LabelBox
}
 
/** One end of a lane's value axis, and the box it was drawn in. */
export interface LimitBox {
  part: 'min' | 'max'
  /** Exactly the text on screen, so an editor opens on what a reader sees. */
  value: string
  box: LabelBox
  /** How far the text ran *along* the lane — see {@link NameBox.along}. */
  along: number
}
 
/** One track's name on a lane, and the box it was drawn in. */
export interface NameBox {
  /** The entry the name belongs to, so a rename lands on the track it names. */
  id: string
  /** Exactly the text on screen, so an editor opens on what a reader sees. */
  text: string
  box: LabelBox
  /**
   * How far the text ran *along* the lane.
   *
   * Not `box[2]`: on a right-hand lane the text reads upward, so the box's width
   * is the hit height and its *height* is the text — and a caret sized from the
   * rectangle would be twelve pixels wide over a twelve-character name. The
   * extent in reading order is the one an input has to be as wide as, on either
   * edge, which is why it is reported rather than derived.
   */
  along: number
  /**
   * The font it was measured in.
   *
   * The two surfaces write their names at different sizes — twelve and semibold
   * in the browser, nine beside a map — and a caret that is not the size of the
   * text it stands on moves the figure the moment it opens, which is the one
   * thing measuring here rather than in the caller exists to prevent.
   */
  font: string
}
 
/**
 * The detail line for a lane whose read is on its way.
 *
 * "fetching 1.2 of 4.0 MB" where the host said how much, "fetching…" where it
 * did not, and the bar beside it either way. One function, so the browser
 * and the lanes beside a map cannot phrase the same wait two ways.
 */
export function progressDetail(pending: { done: number; total: number }): {
  kind: 'progress'
  text: string
  done: number
  total: number
} {
  const text =
    pending.total > 0
      ? `fetching ${fmt.bytes(pending.done)} of ${fmt.bytes(pending.total)}`
      : pending.done > 0
        ? `fetching… ${fmt.bytes(pending.done)}`
        : 'fetching…'
  return { kind: 'progress', text, done: pending.done, total: pending.total }
}
 
export interface LaneLabelBoxes {
  swatches: SwatchBox[]
  /**
   * Empty when the range is a phrase rather than a pair, and — deliberately —
   * when the lane is too short to hold a click target clear of its own resize
   * border. The numbers are still drawn; only the caret is given up, and it
   * comes back the moment the lane is dragged taller.
   */
  limits: LimitBox[]
  /**
   * Every name drawn, with the box it was measured into.
   *
   * Given up on a short lane for exactly the reason {@link limits} is: the name
   * is still drawn, only the caret is withheld, and it comes back the moment the
   * lane is dragged clear of its own resize border.
   */
  names: NameBox[]
  /** Centre of the lane's autoscale button, in canvas pixels. */
  auto: { x: number; y: number } | null
  /** Where across the lane the first name's line fell: the handle's row. */
  nameAcross: number
  /** How wide a column this block would like, including the column's padding. */
  wanted: number
  /**
   * What the block covers over the data — its plate, names, range and all — or
   * `null` where it was written in a column beside the lane, or not at all.
   *
   * Drawn whatever the lane's height, unlike the click targets above, because
   * it is what anything else written on the lane keeps out of: the words of a
   * scalogram's distance axis (§18.12) are left out where they would land on it.
   */
  plate: LabelBox | null
}
 
/**
 * Draw a lane's label block and hand back everything that can be pointed at.
 *
 * The caller clips the *data* and releases the clip before calling this: with a
 * name column the block sits outside the lane's own rectangle, and a clip to the
 * data would take it away.
 */
export function drawLaneLabel(
  ctx: CanvasRenderingContext2D,
  frame: LaneFrame,
  spec: LaneLabelSpec,
): LaneLabelBoxes {
  const { style, palette, names, column } = spec
  const out: LaneLabelBoxes = {
    swatches: [],
    limits: [],
    names: [],
    auto: null,
    nameAcross: style.nameTop,
    wanted: 0,
    plate: null,
  }
  if (names.length === 0) return out
 
  // Which way the text advances along the axis, which is a fact about the axis
  // rather than about the side of the map the lane is on: a horizontal lane
  // reads left to right, above the map and below it alike, and a vertical one
  // turns a quarter turn anticlockwise and reads *upward*, so every run after
  // the first starts at a smaller `along`.
  const horizontal = laneIsHorizontal(frame.orientation)
  const dir = horizontal ? 1 : -1
  const want = (width: number): void => {
    if (width > out.wanted) out.wanted = width
  }
  /**
   * Where a run `length` long starts along the lane, in a column: flush with
   * the end its `align` names, in reading order — up the page on a lane down
   * it — and never back past the other end.
   */
  const inColumn = (col: LaneLabelColumn, length: number): number => {
    const first = horizontal ? col.start : col.end
    const last = horizontal ? col.end : col.start
    if (col.align === 'start') return first
    const at = last - dir * length
    return horizontal ? Math.max(first, at) : Math.min(first, at)
  }
  /**
   * Whether a click target on this line would clear the lane's resize border.
   *
   * The one rule both the names and the limits answer to, written once: an
   * overlap is worse than either mark alone, because the canvas draws the resize
   * hairline — promising a drag — and the press opens a text box instead. A lane
   * too short for both keeps the gesture and gives up the caret.
   */
  const clearOfGrip = (across: number): boolean =>
    across + style.hitHeight / 2 <= frame.thickness - style.grip
 
  ctx.save()
  ctx.font = style.nameFont
  ctx.textBaseline = 'middle'
  ctx.textAlign = 'left'
 
  // In the column, one name per line — because the column is as wide as the
  // widest of them, and two names side by side would make it as wide as both.
  // Only where the lane has the height for it; a short overlay lane keeps the
  // single row it has always had, and the column simply widens to fit.
  const perName =
    column !== null && frame.thickness >= style.nameTop + names.length * style.nameLine + 6
 
  const widthOf = (name: LaneLabelName): number =>
    swatchRoom(style) + ctx.measureText(name.text).width
  const runs: Array<readonly LaneLabelName[]> =
    perName ? names.map((name) => [name]) : [names]
  const runWidth = (run: readonly LaneLabelName[]): number =>
    run.reduce((total, name) => total + widthOf(name), 0) + (run.length - 1) * NAME_GAP
 
  /**
   * The lines each run is written on, once the column has had its say.
   *
   * A name too long for the column is broken or cut rather than run out over the
   * data — but only in a *column*, and only when one name has a row to itself:
   * with two names sharing a row there is no line to continue onto, and over the
   * data the block simply starts at the lane's near end as it always has.
   *
   * How many lines are affordable is a question about the lane's height, and it
   * is asked twice: a name is offered two lines, and if the block that produces
   * would not fit the lane it is re-fitted onto one. A lane dragged short loses
   * the *layout* rather than the fact, which is the rule the range below already
   * follows.
   */
  const fitRun = (run: readonly LaneLabelName[], lines: number): string[] =>
    column === null || !perName || run.length !== 1
      ? [run.map((name) => name.text).join('')]
      : fitName(
          (text) => ctx.measureText(text).width,
          run[0].text,
          column.end - column.start - swatchRoom(style),
          lines,
        )
  /**
   * The same cut, for a block written **over the data** with no column at all.
   *
   * It used to be no cut: `fitRun` answered with the raw text whenever there was
   * no column, so a name longer than the lane was simply clipped by the lane's
   * own clip — and a clipped name is a *different* name. Beside a small map cell
   * the reader was shown `Distance to IV:100000` for a track called
   * `Distance to IV:1000000`, a wrong coordinate stated as a fact, and
   * `Local compaction (±5` for a window with no unit on it. Cut in the middle
   * the end survives, which for these names is where the information is; the
   * hit box and the card still carry the whole of it.
   */
  const overData = (text: string): string => {
    const room = Math.abs(frame.alongTo - frame.alongFrom) - 2 * style.inset - swatchRoom(style)
    if (!(room > 0)) return text
    return ctx.measureText(text).width <= room
      ? text
      : cutMiddle((part) => ctx.measureText(part).width, text, room)
  }
  const maxLines = column?.nameLines ?? 1
  const planFor = (lines: number): Array<Array<string | null>> =>
    runs.map((run) =>
      lines > 1 && column !== null
        ? fitRun(run, lines)
        : column === null && run.length === 1
          ? [overData(run[0].text)]
          : [null],
    )
  let plan = planFor(maxLines)
  const linesIn = (rows: Array<Array<string | null>>): number =>
    rows.reduce((total, row) => total + row.length, 0)
  if (frame.thickness < style.nameTop + linesIn(plan) * style.nameLine + 6) {
    plan = planFor(1)
  }
 
  const rows = linesIn(plan)
  const block = (rows - 1) * style.nameLine
  // Under the names when the lane has room for a second line, beside them when
  // it has not: a clipped lane should lose the *layout* rather than the fact.
  // …and beside them whatever the room, where the caller says the second line
  // would land on the signal — see {@link LaneLabelSpec.detailBeside}.
  const stacked =
    frame.thickness >= style.nameTop + block + style.detailDrop + STACK_AIR &&
    !(column === null && spec.detailBeside === true)
  const label = block + (stacked ? style.detailDrop : 0)
  // In the column, centred on the lane — because there the label is beside its
  // track rather than on top of it, and a caption in a column of them belongs
  // level with the middle of the thing it names. Over the data it stays at the
  // end of the lane the signal is *not* growing from, which for an unflipped
  // track is where it has always been.
  // At the far end the block is measured back from that edge — and back by
  // enough that its *last* line still clears the lane's own resize border. The
  // near end can use the plain inset because the border is at the other end;
  // here `nameTop` on its own would put the range inside the grip, which is
  // exactly the overlap that costs a lane its caret (see `clear`, below). A
  // flipped track used to lose the ability to type its limits for that reason.
  const farGuard = Math.max(style.nameTop, style.grip + style.hitHeight / 2)
  const nameTop =
    column !== null
      ? Math.max(6, Math.round((frame.thickness - label) / 2))
      : spec.labelAt === 'far'
        ? Math.max(6, frame.thickness - farGuard - (stacked ? style.detailDrop : 0) - block)
        : style.nameTop
  /**
   * Where a line set `across` into the block stands across the lane: as set,
   * or mirrored about the lane's middle for a column that sets its lines the
   * other way ({@link LaneLabelColumn.flip}). The block is centred in a column,
   * so the mirror keeps it where it was and only turns the order of its lines.
   */
  const place = (across: number): number => (column?.flip === true ? frame.thickness - across : across)
  out.nameAcross = place(nameTop)
 
  // What the block is about to occupy along the lane, measured before anything
  // is drawn. Two things want it: the plate underneath, and — on a vertical lane
  // — the anchor, since a quarter turn anticlockwise reads *upward* from wherever
  // it starts.
  const lineAlong: number[] = []
  for (const [index, run] of runs.entries()) {
    for (const [part, text] of plan[index].entries()) {
      const texts = text === null ? run.map((name) => name.text) : [text]
      lineAlong.push(
        texts.reduce((sum, item) => sum + ctx.measureText(item).width, 0) +
          (part === 0 ? texts.length * swatchRoom(style) : 0) +
          (texts.length - 1) * NAME_GAP,
      )
    }
  }
  const namesAlong = Math.max(0, ...lineAlong)
  const detailRoom = detailAlong(ctx, spec.detail, style)
  const blockAlong = stacked
    ? Math.max(namesAlong, detailRoom)
    : namesAlong + (detailRoom > 0 ? NAME_GAP + detailRoom : 0)
 
  /**
   * Where the block starts along the lane.
   *
   * On a horizontal lane, the near end, which is the axis's own start. On a
   * vertical one the text turns anticlockwise and therefore advances towards
   * smaller `along`, so anchoring at the far end put every name, range and grip
   * of the right-hand stack at the *bottom* of the column while the stack under
   * the map had its at the left — the same track named at opposite ends of one
   * genome (§8.9). Anchored by the block's own measured length instead, its last
   * glyph lands at the axis start and a reader looks for a lane's name in one
   * place on all four edges. Clamped, so a block longer than the lane still
   * begins inside it.
   */
  const near = horizontal
    ? frame.alongFrom + style.inset
    : Math.min(frame.alongTo - style.inset, frame.alongFrom + style.inset + blockAlong)
 
  // **A plate under the block, where the block is written over the data.**
  // Beside a map there is no column to write in — the margin there belongs to
  // the heatmap's own labels — and the 3 px halo around each glyph was the whole
  // of the protection. On a 16-pixel peak lane the label *is* the lane: `ATAC
  // peaks` came out with orange bars running through every letter, and `230
  // features in view` lay across a density block. A halo-coloured plate keeps
  // the argued placement and makes the words readable, which is what HiGlass
  // does; it is drawn through `laneRect` so it turns with the lane, and it stops
  // at what was measured so the rest of the window is untouched.
  if (column === null && blockAlong > 0) {
    const from = nameTop - style.hitHeight / 2
    const to = (stacked ? nameTop + block + style.detailDrop : nameTop + block) + style.hitHeight / 2
    out.plate = laneRect(frame, near - dir * 2, near + dir * (blockAlong + 2), from, to)
    ctx.save()
    ctx.globalAlpha = PLATE_ALPHA
    ctx.fillStyle = palette.halo
    ctx.fillRect(...out.plate)
    ctx.restore()
  }
 
  let lastEnd = near
  let line = 0
  for (const [index, run] of runs.entries()) {
    const cut = plan[index]
    // The *natural* width, unwrapped, whatever was drawn. This is what makes the
    // column's measure-then-apply loop safe: `BrowserView.wantGutter` is a frame
    // behind, and its only proof against oscillating is that a name's width does
    // not depend on where the name is drawn. Reporting a wrapped width would
    // shrink the column, un-wrap the name, grow the column, wrap it again.
    want(runWidth(run) + (column?.pad ?? 0))
    // The range on the last name's line, in a column that holds it there.
    const besideHere =
      column !== null &&
      column.besideToo === true &&
      !stacked &&
      detailRoom > 0 &&
      index === runs.length - 1
    if (besideHere && column !== null) want(runWidth(run) + NAME_GAP + detailRoom + column.pad)
    for (const [part, text] of cut.entries()) {
      const across = place(nameTop + line * style.nameLine)
      line++
      // Only the first line of a name carries its chip; a continuation is the
      // same name, and a second chip would read as a second track.
      const first = part === 0
      const drawn: Array<{ name: LaneLabelName; text: string }> =
        text === null ? run.map((name) => ({ name, text: name.text })) : [{ name: run[0], text }]
      const total =
        drawn.reduce((sum, item) => sum + ctx.measureText(item.text).width, 0) +
        (first ? drawn.length * swatchRoom(style) : 0) +
        (drawn.length - 1) * NAME_GAP
      let at =
        column === null
          ? near
          : inColumn(
              column,
              total + (besideHere && part === cut.length - 1 ? NAME_GAP + detailRoom : 0),
            )
      for (const item of drawn) {
        // The colour, immediately before the name it belongs to. Drawn on the
        // canvas rather than laid over it, so it is a legend rather than a
        // control: it survives into the exported figure, where a reader who never
        // saw the screen needs to know which line is which. What makes it
        // *clickable* is a transparent colour input positioned over it, from the
        // box handed back here.
        if (style.swatch > 0 && first) {
          const low = across - style.swatch / 2
          const box = laneRect(frame, at, at + dir * style.swatch, low, low + style.swatch)
          ctx.save()
          ctx.strokeStyle = palette.halo
          ctx.lineWidth = 2
          ctx.strokeRect(...box)
          ctx.fillStyle = item.name.colour
          ctx.fillRect(...box)
          ctx.restore()
          out.swatches.push({ id: item.name.id, box })
          at += dir * swatchRoom(style)
        }
        // Each name in its own track's colour, which is the whole legend an
        // overlaid lane needs: the line is that colour and so is its name.
        ctx.fillStyle = names.length > 1 ? item.name.colour : palette.text
        laneText(ctx, frame, item.text, at, across, { halo: palette.halo })
        const width = ctx.measureText(item.text).width
        // Measured in the pass that drew it, for the reason the limits are: a name
        // is the other thing on a lane a reader changes, and it should be changed
        // where it is read. One box per name and not one per lane — an overlaid
        // lane draws several, each its own track's, which is what keeps a rename
        // on the track that was pointed at.
        //
        // The box carries the *whole* name however little of it was drawn: a
        // caret over `Yeast_…-input` opens on the name, not on the cut, or the
        // first Enter would commit the ellipsis.
        if (clearOfGrip(across)) {
          out.names.push({
            id: item.name.id,
            text: item.name.text,
            along: Math.max(...cut.map((part2) => ctx.measureText(part2 ?? item.text).width)),
            font: style.nameFont,
            box: laneRect(
              frame,
              at,
              at + dir * width,
              across - style.hitHeight / 2,
              across + style.hitHeight / 2,
            ),
          })
        }
        at += dir * (width + NAME_GAP)
      }
      if (index === runs.length - 1 && part === cut.length - 1) lastEnd = at - dir * NAME_GAP
    }
  }
 
  if (spec.detail === null) {
    ctx.restore()
    return out
  }
 
  ctx.font = style.rangeFont
  ctx.fillStyle = palette.text
  ctx.textAlign = 'left'
  const across = place(stacked ? nameTop + block + style.detailDrop : nameTop + block)
  // The same slot the names gave up, so the range stays under the name it
  // belongs to rather than starting a handle's width to the side of it — and a
  // gap after the name when it goes *beside* one instead, which a thin lane
  // makes the ordinary case (§18.11): flush against it, `80 kb` and `-1.46` are
  // read as one mangled word.
  const along = stacked ? (column === null ? near : column.start) : lastEnd + dir * NAME_GAP
 
  if (spec.detail.kind === 'text') {
    // Beside the name on a thin lane there is only what is left of the lane to
    // write in, and nothing used to say so: `ATAC peaks 175 features in view`
    // ran off the end of a small map cell as `ATAC peaks 175 fe`, which is a
    // number followed by two letters and could be a count, a unit or a name.
    // Cut in the middle instead, so what survives is a phrase; the count is on
    // the tooltip and on the card either way.
    const room = stacked
      ? Number.POSITIVE_INFINITY
      : dir > 0
        ? frame.alongTo - style.inset - along
        : along - (frame.alongFrom + style.inset)
    const text =
      spec.detail.text === '' || ctx.measureText(spec.detail.text).width <= room
        ? spec.detail.text
        : room >= DETAIL_MIN
          ? cutMiddle((part) => ctx.measureText(part).width, spec.detail.text, room)
          : ''
    if (text !== '') {
      const width = ctx.measureText(text).width
      const at = column !== null && stacked ? inColumn(column, width) : along
      if (column !== null && stacked) want(width + column.pad)
      laneText(ctx, frame, text, at, across, { halo: palette.halo })
    }
    ctx.restore()
    return out
  }
 
  if (spec.detail.kind === 'progress') {
    // The phrase where a note goes, and the bar after it along the lane: a
    // short rule in the lane's own text colour, filled to the fraction read.
    const text = spec.detail.text
    const textWidth = text === '' ? 0 : ctx.measureText(text).width
    const bar = style.progressWidth
    const gap = text === '' ? 0 : NAME_GAP
    const whole = textWidth + gap + bar
    const at = column !== null && stacked ? inColumn(column, whole) : along
    if (column !== null && stacked) want(whole + column.pad)
    if (text !== '') laneText(ctx, frame, text, at, across, { halo: palette.halo })
    const fraction =
      spec.detail.total > 0 ? Math.max(0, Math.min(1, spec.detail.done / spec.detail.total)) : 0.5
    const barFrom = at + dir * (textWidth + gap)
    const barTo = barFrom + dir * bar
    const thick = Math.max(2, style.progressThickness)
    // Centred on the text's baseline band: the bar sits where the letters'
    // x-height is, so it reads as part of the same line.
    const top = across - thick - 1
    ctx.fillStyle = palette.halo
    ctx.fillRect(...laneRect(frame, barFrom, barTo, top, top + thick))
    ctx.fillStyle = palette.text
    ctx.fillRect(...laneRect(frame, barFrom, barFrom + dir * bar * fraction, top, top + thick))
    ctx.restore()
    return out
  }
 
  // Each end written separately, so each end has a box of its own to point at.
  const min = formatScaledLimit(spec.detail.min, spec.detail.scale ?? 'linear', spec.detail.unit)
  const max = formatScaledLimit(spec.detail.max, spec.detail.scale ?? 'linear', spec.detail.unit)
  // The range goes wherever its names went, so the two stay one label — and in
  // the column it is right-aligned like them, with the lane's autoscale button
  // kept inside the column rather than pushed onto the data.
  const range = ctx.measureText(`${min} ${DASH} ${max}`).width
  const from = column !== null && stacked ? inColumn(column, range + style.autoSlot) : along
  if (column !== null && stacked) want(range + style.autoSlot + column.pad)
  // …but the click targets are recorded only where the box is clear of the
  // lane's own far border, which is the resize grip — see `clearOfGrip`, which
  // the names answer to as well.
  const clear = clearOfGrip(across)
  let at = from
  for (const part of ['min', 'max'] as const) {
    const value = part === 'min' ? min : max
    const width = ctx.measureText(value).width
    laneText(ctx, frame, value, at, across, { halo: palette.halo })
    if (clear) {
      out.limits.push({
        part,
        value,
        along: width,
        box: laneRect(
          frame,
          at,
          at + dir * width,
          across - style.hitHeight / 2,
          across + style.hitHeight / 2,
        ),
      })
    }
    at += dir * width
    if (part === 'min') {
      laneText(ctx, frame, ` ${DASH} `, at, across, { halo: palette.halo })
      at += dir * ctx.measureText(` ${DASH} `).width
    }
  }
  // Where the lane's own autoscale button goes: immediately after the number it
  // hands back to the data. Recorded here for the same reason the limits are —
  // the button is placed against text this pass measured.
  if (clear && style.autoSlot > 0) {
    const spot = laneRect(
      frame,
      at + dir * AUTO_GAP,
      at + dir * (AUTO_GAP + style.autoSlot),
      across - style.autoSlot / 2,
      across + style.autoSlot / 2,
    )
    out.auto = { x: spot[0] + spot[2] / 2, y: spot[1] + spot[3] / 2 }
  }
  ctx.restore()
  return out
}
 
/**
 * How far across a lane its label block can reach, from the lane's near edge.
 *
 * The lane's own thickness wherever the block fits inside it, and more where it
 * does not — which is two choices a reader makes separately meeting: how tall
 * the lane is (its border, *track height*) and how large the writing is
 * (`ui/textScale.ts`). At the default text size a name sits eleven pixels into
 * its lane on a plate sixteen tall, so the block wants nineteen; the 14-pixel
 * peak lanes the yeast demo opens with, and any lane dragged short, hold
 * neither the plate nor the chip, and the block runs a few pixels past the
 * lane's far edge. Between two lanes that is the gap. Past the last lane of a
 * stack it was the canvas's edge, which cut the chip and the descenders off the
 * name on screen and in every figure saved from it — `AnnotationLanes.
 * thicknessOf` leaves this much room there now.
 *
 * The plate is what reaches furthest, half a hit height either side of its line
 * — further than the chip, or the glyphs with their halos — and it is measured
 * for the block {@link drawLaneLabel} writes over the data: one line of names,
 * or, on a lane tall enough to stack the range under them, that line and the
 * range's. A bound rather than a replay, so it can be asked before anything is
 * drawn. A block at the far end ({@link labelEndFor}) is set back at least half
 * a plate from that edge, or six pixels in on a lane too thin for that, and
 * `nameTop` is past six at every text size offered, so the near-end placement
 * is the one that reaches furthest.
 */
export function labelReach(thickness: number, style: LaneLabelStyle): number {
  const line =
    thickness >= style.nameTop + style.detailDrop + STACK_AIR
      ? style.nameTop + style.detailDrop
      : style.nameTop
  return Math.max(thickness, line + style.hitHeight / 2)
}
 
/**
 * The room a block stacked over the data leaves past its last line, in pixels.
 *
 * Under it the range goes beside the name instead of below it. Shared by
 * {@link drawLaneLabel}, which decides the stacking, and {@link labelReach},
 * which has to decide it the same way to say how far the block reaches.
 */
const STACK_AIR = 6
 
/**
 * Which end of a lane a value track's caption belongs at.
 *
 * The end farthest from the **baseline**, because the baseline is where the ink
 * starts: a coverage track fills from it outwards, so the opposite end is the
 * part of the lane that is usually empty and the only place a name can go
 * without being written across a peak.
 *
 * Which end that is depends on the edge *and* on the flip, and the two do not
 * agree. A bottom lane's zero is at its floor, so its name is at the top and a
 * flipped one moves to the floor. A right-hand lane's zero is at the edge
 * nearest the map and it grows outwards, so the empty end is the *far* one —
 * the opposite answer — and a flipped one moves back in. Deriving it from
 * `valueAcross` rather than writing the four cases down is what makes the label
 * and the picture incapable of disagreeing (§8.4).
 */
export function labelEndFor(frame: LaneFrame, limits: { min: number; max: number }, flip: boolean): 'near' | 'far' {
  const linear = normaliser(limits)
  const norm = flip ? (value: number) => 1 - linear(value) : linear
  const zero = limits.min <= 0 && limits.max >= 0 ? norm(0) : flip ? 1 : 0
  return valueAcross(frame, zero) > frame.thickness / 2 ? 'near' : 'far'
}
 
/** The chip plus the air between it and the name it belongs to. */
/**
 * How far along the lane the detail line will run, in the range font.
 *
 * Measured here rather than where it is drawn because two callers need it before
 * the drawing: the plate has to know how much to cover, and a vertical lane's
 * anchor has to know how long the whole block is. Leaves `ctx.font` as it found
 * it — the names are measured in a different one, on either side of this.
 */
function detailAlong(
  ctx: CanvasRenderingContext2D,
  detail: LaneLabelSpec['detail'],
  style: LaneLabelStyle,
): number {
  if (detail === null) return 0
  const font = ctx.font
  ctx.font = style.rangeFont
  let width = 0
  if (detail.kind === 'text') {
    width = detail.text === '' ? 0 : ctx.measureText(detail.text).width
  } else if (detail.kind === 'progress') {
    const text = detail.text === '' ? 0 : ctx.measureText(detail.text).width
    width = text + (detail.text === '' ? 0 : NAME_GAP) + style.progressWidth
  } else {
    width =
      ctx.measureText(
        `${formatScaledLimit(detail.min, detail.scale ?? 'linear', detail.unit)} ${DASH} ` +
          formatScaledLimit(detail.max, detail.scale ?? 'linear', detail.unit),
      ).width +
      style.autoSlot
  }
  ctx.font = font
  return width
}
 
function swatchRoom(style: LaneLabelStyle): number {
  return style.swatch === 0 ? 0 : style.swatch + 5
}
 
/** Air between two names sharing a line. */
const NAME_GAP = 12
 
/**
 * How solid the plate under a label written over the data is.
 *
 * Not opaque. The plate exists so the words can be read, not so the lane can be
 * hidden: at 0.85 a peak under the label is still visible as a shape, which
 * matters because the first eighty pixels of a peak set are data a reader may
 * be counting. Opaque was tried and reads as a hole cut in the track.
 */
const PLATE_ALPHA = 0.85
 
/** Air between the top of the range and the button that resets it. */
const AUTO_GAP = 5
 
/**
 * The least room worth writing a cut detail into, in pixels.
 *
 * Under this the phrase is dropped rather than cut: `1…w` is not a fact, and a
 * reader who sees two characters of small print at the end of a lane reads them
 * as the end of the track's name.
 */
const DETAIL_MIN = 28
 
/**
 * An en dash with a space either side, which is what a range reads as.
 *
 * Here rather than at the two places it is written, because the width measured
 * for the gap and the string drawn into it must be the same string — otherwise
 * the second limit's box is a couple of pixels off the number in it.
 */
const DASH = '–'