All files / src/render lane.ts

83.59% Statements 214/256
91.04% Branches 61/67
92.59% Functions 25/27
83.59% Lines 214/256

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                                                                                                                        2x     2x 31282x 31282x                 2x 22340x 22340x                               2x 239x 239x                                                                 2x 576x 576x     2x 21637x 21637x     2x 42x 42x                                         2x 84x 84x 84x 84x 84x 84x                       2x 41x 41x 41x 41x 41x 41x 41x 41x 41x 41x 84x 84x 84x 84x 41x 41x                     2x 220x 220x 164x 56x 220x     2x 6243x 6243x     2x 6289x 6289x     2x 231x 231x 231x 231x 231x 231x 231x     2x 785x 785x 785x 785x 785x 785x 785x                   2x 541x 541x 541x 541x 541x 541x 541x 541x 541x 541x 541x 541x 541x 541x                     2x 7611x 7611x 7611x 7611x 7611x 7611x 7611x 7611x       7611x 7611x 7611x 7611x 7611x 4073x 3538x 7611x                 2x                           2x     2x 34x 34x 34x 34x 34x 34x                                         2x 2x                     2x     2x                                           2x 59x 44x 15x 59x               2x 18x 18x 18x                     2x 66x 66x 66x     2x 6x 6x 6x     2x     2x                                           2x 406x 406x 406x                 2x 34x 34x 34x 34x 34x 34x 34x 34x 34x 34x 34x 68x 204x 204x 204x 204x 204x 204x 204x 204x 204x 68x 34x 34x     2x 2x                     2x                                                                       2x 37x 37x 37x 37x 37x 42x 4x 4x 4x 42x 1x 1x 1x 3x 3x 37x 37x                                 2x 34x 34x 34x                                 34x 34x                                                                                             2x 4064x 4064x 4064x 4064x 4064x 4064x 4064x 4064x 4064x 4064x 4064x 4064x       4064x 4064x 4052x 4052x 4052x 4052x   4052x 4064x   4064x 4064x  
/**
 * The geometry every lane is drawn in.
 *
 * A lane is a strip beside the matrix — under it, over it, or down either side
 * — where one of the map's two axes is the genomic one and the strip's own
 * thickness is the other. Everything else about the four is identical, so
 * rather than four renderers there is one, written in terms of *along* (the
 * genomic direction) and *across* (the value or stacking direction), and this
 * module is the only place that knows which is x, which way each of them
 * points, and where zero is.
 *
 * **Across always runs outward, away from the map.** `acrossFrom` is the canvas
 * pixel of the lane's edge *nearest the matrix* and `across` counts from there
 * towards the reader, which on a top or left stack is the direction the canvas
 * coordinate runs *backwards* in. That one convention is what lets every hit
 * test, every label and every clip in this application say "the far border" and
 * mean the same border on all four edges: the alternative — canvas offsets —
 * puts the resize border at `thickness` on two edges and at `0` on the other
 * two, and every caller has to know which.
 *
 * `toPixel` comes from the heatmap's own `Scale` (spec §8.4). Nothing here
 * computes a coordinate mapping; it is handed one, and that is what makes a
 * track and the matrix column above it structurally incapable of drifting.
 */
 
import type { LaneEdge } from '../annotations/types'
 
/** Where a lane is, and how its two directions map to the canvas. */
export interface LaneFrame {
  /** Which edge of the map this lane is on. */
  orientation: LaneEdge
  /** Canvas pixel for a matrix coordinate, along the genomic axis. */
  toPixel(unit: number): number
  /** Matrix coordinate for a canvas pixel, along the genomic axis. */
  toUnit(pixel: number): number
  /** The visible matrix domain, as the shared `Scale` holds it. */
  domain: readonly [number, number]
  /** Pixel extent of the *data* along the genomic axis — the heatmap's dataRect. */
  alongFrom: number
  alongTo: number
  /**
   * Canvas pixel of the lane's edge nearest the map; `across` counts *outward*
   * from it. On a bottom or right stack that is the smaller canvas coordinate
   * and the two run together; on a top or left stack it is the larger, and the
   * across direction is the canvas direction reversed.
   */
  acrossFrom: number
  /** How thick the lane is, in canvas pixels. */
  thickness: number
}
 
/**
 * How much ink a hand-drawn rectangle's wash over the tracks is worth, in the
 * 1D browser and beside a map alike (§8.1).
 *
 * A tenth, because the thing it is drawn over is the reason the reader put it
 * there: a band that made the tracks under it harder to read would be a mark
 * that hid its own subject. Enough to be seen against both themes' plot
 * backgrounds and not enough to change the colour of a track through it.
 */
export const MARK_WASH = 0.1
 
/** Whether this edge's genomic axis runs across the page rather than down it. */
export function laneIsHorizontal(edge: LaneEdge): boolean {
  return edge === 'top' || edge === 'bottom'
}
 
/**
 * Which way the canvas coordinate moves as `across` grows — outward, away from
 * the map.
 *
 * `+1` below and beside the map, where outward is down and right; `-1` above it
 * and to its left, where outward is up and back.
 */
export function laneOutward(edge: LaneEdge): 1 | -1 {
  return edge === 'top' || edge === 'left' ? -1 : 1
}
 
/**
 * Whether an unflipped lane on this edge grows *towards* the matrix.
 *
 * The rule underneath is one rule and it is the conventional orientation of a
 * plot: a horizontal lane's values grow **up** the page and a vertical lane's
 * grow **right** across it, whichever side of the map the lane happens to be
 * on. Which of those points at the matrix then depends on the side — up is
 * towards a map that is above and away from one that is below — and this is
 * that question answered once.
 *
 * Pure geometry, and the only thing {@link valueAcross} consults. What a lane
 * *starts* at is a separate question with a separate answer:
 * {@link laneStartsFlipped}.
 */
export function laneGrowsInward(edge: LaneEdge): boolean {
  return edge === 'bottom' || edge === 'left'
}
 
/**
 * Whether a lane added to this edge has its value axis turned round.
 *
 * A policy rather than a geometry, and it is two rules because the two axes are
 * not alike.
 *
 * **A horizontal lane always grows up the page**, above the map and below it
 * alike. There is a universal reading convention for a track on a genomic axis
 * — every browser ever built draws coverage rising from a floor under a ruler —
 * and it beats every argument from the picture's own symmetry: a profile above
 * a contact map hanging from its ceiling is a profile a reader has to mirror in
 * their head before they can compare it with the one below.
 *
 * **A vertical lane is a horizontal one mirrored in the diagonal**, which is
 * what it is: the rows are the same genome as the columns, and a contact map is
 * read across that line. The stack on the left is the stack on top turned over
 * it, so it grows *away* from the matrix as the top one does; the stack on the
 * right is the bottom one turned, so it grows *towards* the matrix as the
 * bottom one does. Both of them grow leftward, then — the vertical "up the
 * page" — and both are turned from a plot's own "values grow right".
 *
 * The left-hand stack used to grow towards the matrix, on the argument that
 * the stacks meeting at a corner should agree about which way is more. It
 * pointed a profile's peaks *into* the map beside it, where the same profile
 * above the map points them away, so the two copies of one track either side
 * of the diagonal read as two different shapes.
 *
 * Moving a lane between two edges turns its axis exactly when this answer
 * changes, which is what keeps a reader's own deviation from the default
 * through the move (`AnnotationStore.place`).
 */
export function laneStartsFlipped(edge: LaneEdge): boolean {
  return !laneIsHorizontal(edge)
}
 
/** Canvas coordinate of an across-offset, on the axis the lane is thick in. */
export function acrossToCanvas(frame: LaneFrame, across: number): number {
  return frame.acrossFrom + laneOutward(frame.orientation) * across
}
 
/** The inverse: how far outward a canvas coordinate is, in lane coordinates. */
export function canvasToAcross(frame: LaneFrame, canvas: number): number {
  return laneOutward(frame.orientation) * (canvas - frame.acrossFrom)
}
 
/**
 * A band inside a lane, as a frame of its own.
 *
 * `from` and `span` are across-offsets in the parent's coordinates, so `from`
 * of 0 is the band against the map and `from + span === frame.thickness` is the
 * lane's far border. Everything *along* the genomic axis is the parent's,
 * untouched — the same `toPixel`, the same `domain`, the same `alongFrom` and
 * `alongTo` — which is what keeps a coverage band, a consensus strip and a pile
 * of reads registered with each other and with whatever is above them (§8.13).
 *
 * Through {@link acrossToCanvas} rather than `acrossFrom + from`, because on a
 * top or left stack `across` runs *backwards* against the canvas: a bare
 * addition puts every band on the wrong side of its own lane, and does it on
 * exactly two of the four edges, which is the hardest kind of wrong to notice.
 *
 * The parent frame stays the lane's own. The label, the grip, the resize drag
 * and the hit test all read `Lane.frame`, and re-pointing it at a band would
 * shrink the lane's handle to the height of its first stripe.
 */
export function laneBand(frame: LaneFrame, from: number, span: number): LaneFrame {
  return {
    ...frame,
    acrossFrom: acrossToCanvas(frame, from),
    thickness: Math.max(0, span),
  }
}
 
/** One part of a lane: a fixed thickness, or a share of what is left. */
export type LanePart = { fixed: number } | { fill: true }
 
/**
 * Divide a lane into bands, outward from the map, with `gap` between them.
 *
 * In one place rather than at each caller for the same reason the runs of a
 * group are: the arithmetic is small and getting it slightly different in two
 * places is how two bands come to overlap by a pixel.
 */
export function laneBands(
  frame: LaneFrame,
  parts: readonly LanePart[],
  gap = 0,
): LaneFrame[] {
  const fixed = parts.reduce((sum, part) => sum + ('fixed' in part ? part.fixed : 0), 0)
  const fills = parts.filter((part) => !('fixed' in part)).length
  const spare = Math.max(0, frame.thickness - fixed - gap * Math.max(0, parts.length - 1))
  const each = fills === 0 ? 0 : spare / fills
  let at = 0
  return parts.map((part) => {
    const span = 'fixed' in part ? part.fixed : each
    const band = laneBand(frame, at, span)
    at += span + gap
    return band
  })
}
 
/**
 * The across-offset a normalised value sits at.
 *
 * A lane grows towards the map or away from it, and {@link laneGrowsInward} is
 * what decides — `across` already points outward, so the whole difference
 * between the four edges is which end of `[0, thickness]` a value of 1 lands
 * on. Both readings put the baseline against one edge of the strip and the
 * ceiling against the other; neither depends on which axis is x.
 */
export function valueAcross(frame: LaneFrame, normalised: number): number {
  const clamped = normalised < 0 ? 0 : normalised > 1 ? 1 : normalised
  return laneGrowsInward(frame.orientation)
    ? frame.thickness * (1 - clamped)
    : frame.thickness * clamped
}
 
/** Canvas x for a point given in lane coordinates. */
export function laneX(frame: LaneFrame, along: number, across: number): number {
  return laneIsHorizontal(frame.orientation) ? along : acrossToCanvas(frame, across)
}
 
/** Canvas y for a point given in lane coordinates. */
export function laneY(frame: LaneFrame, along: number, across: number): number {
  return laneIsHorizontal(frame.orientation) ? acrossToCanvas(frame, across) : along
}
 
/** `ctx.moveTo` in lane coordinates. */
export function laneMove(
  ctx: CanvasRenderingContext2D,
  frame: LaneFrame,
  along: number,
  across: number,
): void {
  ctx.moveTo(laneX(frame, along, across), laneY(frame, along, across))
}
 
/** `ctx.lineTo` in lane coordinates. */
export function laneLine(
  ctx: CanvasRenderingContext2D,
  frame: LaneFrame,
  along: number,
  across: number,
): void {
  ctx.lineTo(laneX(frame, along, across), laneY(frame, along, across))
}
 
/**
 * `ctx.quadraticCurveTo` in lane coordinates: a curve bent towards the control
 * point, which is how a glyph's corners are rounded.
 *
 * A quadratic rather than `arcTo`, because the PDF export's context has the one
 * and not the other (`ui/vector.ts` raises a quadratic to PDF's cubic exactly),
 * and at a corner of two or three pixels the two are the same curve.
 */
export function laneCurve(
  ctx: CanvasRenderingContext2D,
  frame: LaneFrame,
  controlAlong: number,
  controlAcross: number,
  along: number,
  across: number,
): void {
  ctx.quadraticCurveTo(
    laneX(frame, controlAlong, controlAcross),
    laneY(frame, controlAlong, controlAcross),
    laneX(frame, along, across),
    laneY(frame, along, across),
  )
}
 
/**
 * A rectangle spanning `[alongFrom, alongTo)` and `[acrossFrom, acrossTo)`, in
 * lane coordinates.
 *
 * Widths are floored to one pixel: a peak 40 bp wide on a whole-genome view is a
 * fifth of a pixel, and a `fillRect` of width 0.2 is an antialiased smear that
 * reads as absent. One crisp pixel is the honest answer — the feature is there
 * and it is smaller than the picture can resolve.
 */
export function laneRect(
  frame: LaneFrame,
  along0: number,
  along1: number,
  across0: number,
  across1: number,
): [number, number, number, number] {
  const alongLow = Math.min(along0, along1)
  const alongSpan = Math.max(1, Math.abs(along1 - along0))
  // Through the canvas conversion rather than a bare `min`, because on a top or
  // left stack the larger across-offset is the *smaller* canvas coordinate, and
  // a rectangle is always given by its low corner and a positive span.
  const canvas0 = acrossToCanvas(frame, across0)
  const canvas1 = acrossToCanvas(frame, across1)
  const acrossLow = Math.min(canvas0, canvas1)
  const acrossSpan = Math.max(1, Math.abs(canvas1 - canvas0))
  return laneIsHorizontal(frame.orientation)
    ? [alongLow, acrossLow, alongSpan, acrossSpan]
    : [acrossLow, alongLow, acrossSpan, alongSpan]
}
 
/**
 * Put a hairline on the device-pixel grid.
 *
 * The same rule the heatmap's separators follow, and for the same reason: a
 * one-pixel rule on a fractional coordinate is antialiased across two pixels and
 * reads as a real feature twice as wide as it is.
 */
export function snapTo(value: number, dpr: number): number {
  return (Math.round(value * dpr - 0.5) + 0.5) / dpr
}
 
/**
 * How wide an outline is: a hairline, the floor rule's own width — the rule along
 * the top of a coverage track, and the edge round a feature's block.
 *
 * Sub-pixel deliberately. An outline describes the edge of a shape that is
 * already there rather than adding a mark of its own, and at a full pixel over a
 * saturated area it reads as a border drawn round the track — which is a second
 * thing on the lane, not a clearer view of the first. Round a block it is the
 * block's own edge for the same reason, not a frame put round it.
 */
export const OUTLINE_WIDTH = 0.75
 
/** Restrict drawing to one lane's rectangle. The caller must `ctx.restore()`. */
export function clipLane(ctx: CanvasRenderingContext2D, frame: LaneFrame): void {
  ctx.save()
  ctx.beginPath()
  const [x, y, width, height] = laneRect(frame, frame.alongFrom, frame.alongTo, 0, frame.thickness)
  ctx.rect(x, y, width, height)
  ctx.clip()
}
 
/**
 * The handle a lane is dragged by, in lane coordinates.
 *
 * Two columns of three dots — the same glyph the Tracks card puts at the head of
 * every row, because it is the same gesture on the same thing, and a reader who
 * has learned one should not have to discover the other. It is drawn *on the
 * canvas* rather than laid over it as a control, for the reason every other
 * affordance here is: the cursor is an arrowhead pointing at a base pair, and a
 * handle that lived in the cursor would not exist until the pointer was already
 * on it.
 *
 * It hangs **outside** the lane, in the margin before the data starts — which is
 * what lets every lane show one at all times without a single track name moving
 * to make room. A handle inside the lane had to be paid for twice: a slot taken
 * off the front of the label, and a glyph that could only appear on hover
 * because a permanent one would sit on the signal.
 *
 * Six pixels along and ten across: a target, not a decoration.
 */
export const GRIP_ALONG = 6
export const GRIP_ACROSS = 10
 
/**
 * Clear space between the handle and the edge of the data it belongs to.
 *
 * Four, not two. Two was enough for the handle to be *outside* the track and
 * not enough for it to look outside it: beside the map's bottom stack the plot's
 * own left margin adds a few pixels of air and the handle read as separate,
 * while the right-hand stack — where the space is measured from the data itself
 * — came out with the dots against the signal.
 */
export const GRIP_GAP = 4
 
/** What a lane must leave before its data for a handle to hang in it. */
export const GRIP_ROOM = GRIP_ALONG + GRIP_GAP
 
/**
 * Where along the axis the handle hangs: immediately before the track's name.
 *
 * Which end of the lane that is depends on which way the lane *reads*, which is
 * a fact about the axis rather than about the side: a horizontal lane runs left
 * to right and its name starts at the left, so the handle goes off the left,
 * above the map and below it alike. A vertical lane turns a quarter turn and
 * reads *upward*: its name starts at the bottom, so the handle goes below it —
 * the end the label begins at, not the end the axis does. A handle at the far
 * end of the track from its own name is a handle belonging to nothing in
 * particular.
 *
 * It never overlaps the data, in either direction, and that is the one thing it
 * must not do — a handle sitting on the signal is both illegible and a target
 * you cannot tell from the picture. Before a horizontal lane it is clamped at
 * the canvas edge, because the space there is the plot's own left margin and
 * there is nothing a stack can do to make more of it. *After* a vertical lane
 * the space is the stack's own canvas, so the caller sizes that canvas to fit
 * (`AnnotationLanes.tailFor`) rather than the handle backing into the track.
 */
export function gripFrom(frame: LaneFrame): number {
  return laneIsHorizontal(frame.orientation)
    ? Math.max(0, frame.alongFrom - GRIP_GAP - GRIP_ALONG)
    : frame.alongTo + GRIP_GAP
}
 
/**
 * The box a lane's handle occupies, in canvas coordinates.
 *
 * Both orientations from one pair of numbers: `laneRect` is what knows which of
 * them is x, which is the whole reason this module exists (§8.4).
 */
export function gripBox(frame: LaneFrame, across: number): [number, number, number, number] {
  const from = gripFrom(frame)
  return laneRect(frame, from, from + GRIP_ALONG, across, across + GRIP_ACROSS)
}
 
/**
 * Where the handle sits across its lane: on the same line as the track's name.
 *
 * `labelAcross` is the middle of that name, which is where every view already
 * draws it, so the two line up by construction rather than by two constants
 * that agree today. A lane can be dragged down to a line, so the result is
 * clamped inside it — level with the name where there is room, and as close as
 * the lane allows where there is not.
 */
export function gripAcross(frame: LaneFrame, labelAcross: number): number {
  const room = Math.max(0, frame.thickness - GRIP_ACROSS)
  return Math.max(0, Math.min(labelAcross - GRIP_ACROSS / 2, room))
}
 
/** Whether a point is on a handle, with a few pixels of slack around it. */
export function withinGrip(box: [number, number, number, number], x: number, y: number): boolean {
  const [bx, by, width, height] = box
  return x >= bx - GRIP_PAD && x <= bx + width + GRIP_PAD && y >= by - GRIP_PAD && y <= by + height + GRIP_PAD
}
 
/** Slack around the drawn glyph. A six-pixel target is one nobody hits. */
const GRIP_PAD = 4
 
/** How thick the rule marking a lane's draggable far border is. */
export const BORDER_RULE = 2
 
/**
 * Where to centre that rule, given the border it marks.
 *
 * A pixel past the lane's far edge, out in the gap before the next lane, where
 * it is not ink on anybody's data — **except on the last lane of a stack**,
 * where there is no gap: its far edge is the canvas's own, and a rule centred a
 * pixel past that is a rule drawn entirely outside the canvas. That was
 * invisible while the drag it announces worked perfectly, which is the worst of
 * both, because the reader finds the gesture by accident or not at all.
 *
 * So it is held inside the canvas by its own half-width, which costs every other
 * lane nothing: they are nowhere near either end. Pure arithmetic, and out here
 * rather than inline in the paint because a rule that knew its own width in one
 * place and was clamped by a number written down in another would go back to
 * being invisible the day either changed.
 *
 * @param border Canvas coordinate of the lane's far edge.
 * @param outward `+1` or `-1`; see {@link laneOutward}.
 * @param total The stack's whole thickness — the canvas's extent across.
 */
export function borderRuleAt(border: number, outward: 1 | -1, total: number): number {
  const room = BORDER_RULE / 2
  return Math.max(room, Math.min(total - room, border + outward * room))
}
 
/**
 * Paint the handle.
 *
 * No backing: it hangs in the margin, where there is nothing to be lost
 * against. `alpha` is the one piece of feedback it gives — every lane shows its
 * handle, and the one under the pointer shows it at full strength.
 */
export function drawGrip(
  ctx: CanvasRenderingContext2D,
  frame: LaneFrame,
  across: number,
  colour: string,
  alpha: number,
): void {
  const from = gripFrom(frame)
  ctx.save()
  ctx.globalAlpha = alpha
  ctx.fillStyle = colour
  for (const column of [0, 4]) {
    for (const row of [0, 4, 8]) {
      const [x, y, width, height] = laneRect(
        frame,
        from + column,
        from + column + 2,
        across + row,
        across + row + 2,
      )
      ctx.fillRect(x, y, width, height)
    }
  }
  ctx.restore()
}
 
/** How wide the bar marking a run of grouped lanes is, in CSS pixels. */
export const GROUP_BAR = 3
export const GROUP_FONT = '600 9px system-ui, sans-serif'
 
/**
 * A bar's outline: rounded where the context has `roundRect`, square where it
 * does not.
 *
 * The export replays these through `VectorPage`, whose op list has no rounded
 * rectangle, and a bar that vanished from the PDF would be worse than a
 * square-cornered one. Here rather than at each caller because the fallback is
 * the kind of two-line rule that is only ever remembered at the first of them.
 */
export function barPath(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
): void {
  if (ctx.roundRect === undefined) ctx.rect(x, y, width, height)
  else ctx.roundRect(x, y, width, height, Math.min(width, height) / 2)
}
 
/** A run of adjacent lanes that share one value axis. */
export interface GroupRun {
  group: string
  colour: string
  /**
   * The run's two ends on the stacking axis, in **canvas** pixels and in
   * ascending order.
   *
   * Canvas rather than lane coordinates because a bar is a fact about a run of
   * lanes rather than about any one of them, so there is no `across` for it to
   * be measured in — and because the caller that lays a stack out is the one
   * that knows which way its slots run.
   */
  from: number
  to: number
}
 
/**
 * Coalesce a stack's lanes into the runs a group bar is drawn for.
 *
 * Adjacent members only: two lanes of one group with a third track between them
 * are two runs, because a single bar spanning all three would say the middle one
 * shares their axis, which is false. `gap` is the stack's own lane gap, plus a
 * pixel of tolerance for the rounding a fractional height leaves behind.
 */
export function groupRuns(
  lanes: ReadonlyArray<{ group: string; colour: string; acrossFrom: number; thickness: number }>,
  gap: number,
): GroupRun[] {
  const runs: GroupRun[] = []
  for (const lane of lanes) {
    if (lane.group === '') continue
    const top = lane.acrossFrom
    const bottom = top + lane.thickness
    const open = runs[runs.length - 1]
    if (open !== undefined && open.group === lane.group && Math.abs(open.to - top) <= gap + 1) {
      open.to = bottom
      continue
    }
    runs.push({ group: lane.group, colour: lane.colour, from: top, to: bottom })
  }
  return runs
}
 
/**
 * The bar that says which lanes share a value axis, and its name beside it.
 *
 * A group is honoured *numerically* the moment it is made — its members resolve
 * to one pair of limits — and until this is drawn that is a fact with no
 * picture: two tracks on the same axis and two tracks that happen to agree look
 * identical, on screen and in the exported figure. The bar is the claim made
 * visible.
 *
 * `along` is where the bar sits on the genomic axis; the caller decides, because
 * the two surfaces have different room. The browser keeps a margin at the start
 * of every lane; a stack beside a map has none at the start — that margin
 * belongs to the heatmap's own block labels — so its bar goes past the last
 * lane, where the loop anchors already live.
 */
export function drawGroupBars(
  ctx: CanvasRenderingContext2D,
  runs: readonly GroupRun[],
  options: {
    /** Whether the lanes' genomic axis runs across the page rather than down it. */
    horizontal: boolean
    along: number
    /**
     * Where the group's *name* goes, when it is not simply beside its bar.
     *
     * The browser puts it past the whole handle margin rather than two pixels
     * from the rule: with no name column those nine pixels are exactly where the
     * handles hang, and a word written through a column of grips is a word
     * nobody reads.
     */
    labelAlong?: number
    text: boolean
    /** The size a group's name is written at, or absent for this module's own. */
    font?: string
  },
): void {
  if (runs.length === 0) return
  const bottom = options.horizontal
  ctx.save()
  for (const run of runs) {
    // The bar spans the lanes it groups, so it is long *across* the stack and
    // thin *along* it — the opposite way round from everything else in this
    // module, which is why it is written out rather than put through `laneRect`.
    const room = run.to - run.from
    const x = bottom ? options.along : run.from
    const y = bottom ? run.from : options.along
    const width = bottom ? GROUP_BAR : room
    const height = bottom ? room : GROUP_BAR
    ctx.fillStyle = run.colour
    ctx.globalAlpha = 0.85
    ctx.beginPath()
    barPath(ctx, x, y, width, height)
    ctx.fill()
 
    // The name runs *with* the bar — the only orientation that fits a word
    // beside a three-pixel rule. Dropped rather than clipped when the run is too
    // short to hold it: half a group's name is a different group's name.
    if (!options.text || room < 34) continue
    ctx.save()
    ctx.globalAlpha = 0.9
    ctx.font = options.font ?? GROUP_FONT
    ctx.fillStyle = run.colour
    ctx.textAlign = 'center'
    ctx.textBaseline = 'top'
    const mid = (run.from + run.to) / 2
    const past = options.labelAlong ?? options.along + GROUP_BAR + 2
    ctx.translate(bottom ? past : mid, bottom ? mid : past)
    if (bottom) ctx.rotate(-Math.PI / 2)
    if (ctx.measureText(run.group).width <= room - 6) ctx.fillText(run.group, 0, 0)
    ctx.restore()
  }
  ctx.restore()
}
 
/**
 * Draw `text` so it reads correctly on any edge.
 *
 * A vertical lane runs top to bottom, so its labels turn a quarter turn
 * anticlockwise — the same rotation the heatmap gives its column labels, and for
 * the same reason: a name needs the length of the axis it belongs to, not the
 * width of the strip. Which side of the map the lane is on makes no difference:
 * a name above the map reads the same way up as one below it.
 */
export function laneText(
  ctx: CanvasRenderingContext2D,
  frame: LaneFrame,
  text: string,
  along: number,
  across: number,
  options: { halo?: string; maxWidth?: number } = {},
): void {
  const x = laneX(frame, along, across)
  const y = laneY(frame, along, across)
  ctx.save()
  ctx.translate(x, y)
  if (!laneIsHorizontal(frame.orientation)) ctx.rotate(-Math.PI / 2)
  // Given to the canvas rather than measured against, so a phrase too long for
  // its lane is condensed rather than run off the end of it. Absent for every
  // caller that has already fitted its own text.
  const width = options.maxWidth
  if (options.halo !== undefined) {
    ctx.strokeStyle = options.halo
    ctx.lineWidth = 3
    ctx.lineJoin = 'round'
    if (width === undefined) ctx.strokeText(text, 0, 0)
    else ctx.strokeText(text, 0, 0, width)
  }
  if (width === undefined) ctx.fillText(text, 0, 0)
  else ctx.fillText(text, 0, 0, width)
  ctx.restore()
}