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 | 2x 526x 526x 526x 2x 63x 63x 63x 63x 63x 63x 46x 46x 46x 2x 226x 226x 226x 221x 221x 218x 226x 2x 32x 32x 32x 32x 32x 32x 32x 277x 277x 277x 2x 2x 265x 265x 265x 2x 103x 103x 103x 2x 234x 234x 234x 2x 9x 9x 2x 3x 3x 3x 2x 262x 262x 253x 262x 250x 250x 250x 250x 250x 250x 250x 250x 225x 224x 262x 217x 217x 33x 33x 262x 2x 13x 12x 13x 2x 21x 11x 21x 2x 44x 44x 44x 44x 44x 30x 28x 27x 27x 27x 27x 27x 27x 29x 30x 44x 44x 44x 44x 2x | /**
* The navigation lock: what makes N maps one navigation (§16.3).
*
* The tension this resolves is that two panels showing the same region are not
* showing the same *grid*. A 1 kb file and a 5 kb file both answer
* `chrI:20,000-80,000`, and the answers are 60 cells wide and 12 cells wide.
* A lock whose currency is the cell — origin and zoom, which is what
* {@link HeatmapView} actually holds — would move the coarse panel five times
* too far and would have to be refused between files of different resolution,
* which is exactly the refusal `compareRefusal` makes for the blended square
* and exactly the one side-by-side panels do not need. So **the currency is
* base pairs**, measured from each block's own start, and each panel converts
* on the way in and on the way out. That is one division and one multiplication
* per axis, and it is what lets panels of unequal resolution follow each other
* honestly.
*
* **The one exception is above bin level**, where a cell is a whole sequence
* and therefore has no width in base pairs to convert through — see
* {@link LockUnit}. There the currency is the cell index itself, which is the
* same statement arrived at from the other side: carry the thing the two grids
* genuinely share.
*
* The conversion is not lossless in the coarse direction and must not pretend
* to be. A window of 60 fine cells lands between two coarse cells; the coarse
* panel is given the smallest cell window that **contains** the request, so it
* shows at least what was asked for and never less. Round-tripping through it
* therefore widens the window slightly, which is the second thing this module
* has to survive.
*
* **The feedback loop.** A panel that is moved programmatically redraws, and
* redrawing is how a panel reports where it is — so every push comes back as a
* report one frame later, and a lock that acted on it would broadcast the
* widened window, which would come back wider still. Three guards, and each
* catches a different shape of echo:
*
* 1. {@link ViewportLock.driving} swallows the reports a push provokes
* synchronously;
* 2. a pushed panel is owed exactly **one** report and it is ignored, which is
* what stops a panel that *clamped* the window from becoming the driver —
* without it, panning the lead past the end of a shorter companion's contig
* drags the lead back to where the companion stopped;
* 3. {@link windowsAgree} swallows anything that arrives later at what is, to
* within half a cell of the reporting panel, the window already broadcast —
* a resize, a re-fit, a second frame of the same gesture.
*
* Without (3) a two-panel deck at two resolutions drifts one coarse cell per
* frame until it hits a wall. With them the round trip converges in one
* exchange, which is what `test/unit/panel-lock.test.ts` pins down.
*/
/** A window on one axis, in base pairs from the block's own first cell. */
export interface AxisWindow {
lo: number
hi: number
}
/**
* What the deck's window is counted in, for one panel.
*
* `'bp'` is the module header's currency and the one almost every deck uses.
* `'cell'` is the exception the header did not foresee: **above bin level a
* cell is a whole sequence**, so it has no width in base pairs at all — the
* number {@link LockMember.bpPerCell} answers there is the file's bin size,
* which is a fact about the file and not a length of anything on screen.
* Multiplying by it turned a window on chromosomes 10–14 of a 1 kb roll-up into
* chromosomes 2–3 of the 5 kb roll-up beside it.
*
* The honest currency there is the **cell index itself**: at contig level cell
* `i` is sequence `i`, and two panels over the same sequence list share that
* index however they bin. So a contig-level deck locks in cells and, for the
* first time, genuinely navigates at chromosome level. That is only true while
* the two lists *are* the same list, which is the deck's own gate to check
* ({@link spansAgree} and an equal cell count — see `panels/PanelDeck.ts`).
*
* Two panels in different currencies are never moved to one another's window.
* The deck's gate refuses that pairing and says why, so this is the second
* belt: a mixed deck stands still rather than moving somebody to a number that
* means something else in their grid.
*/
export type LockUnit = 'bp' | 'cell'
/** What every panel in a locked deck is showing, in {@link LockWindow.unit}s. */
export interface LockWindow {
rows: AxisWindow
cols: AxisWindow
/** What `lo` and `hi` count — see {@link LockUnit}. */
unit: LockUnit
}
/** A half-open cell interval `[lo, hi)` on one axis of one panel's grid. */
export type CellSpan = [number, number]
/**
* A panel's cell window turned into base pairs.
*
* Exact: a cell is `bpPerCell` base pairs wide by construction (§3.3's uniform
* bin size, times whatever pooling the read chose), and the window's bounds are
* cell offsets from the block's start. A step of one is the cell index itself,
* which is what a panel above bin level carries ({@link LockUnit}).
*/
export function cellsToBp(span: CellSpan, bpPerCell: number): AxisWindow {
const k = bpPerCell > 0 ? bpPerCell : 1
return { lo: span[0] * k, hi: span[1] * k }
}
/**
* A base-pair window turned into this panel's cells — outward, then slid inside
* the block.
*
* `floor` on the low bound and `ceil` on the high one, so the cells handed back
* cover the whole of what was asked for. A coarse panel asked for a window
* narrower than one of its cells is given that one cell, which is the finest
* thing it can say; giving it an empty span instead would be a division by zero
* one frame later.
*
* The window is then **slid**, not truncated. Two files agree about a
* coordinate and not about how much of it they hold: a contig that is 400 kb in
* one assembly and 380 kb in another is the same contig, and a window at its far
* end runs off the shorter one. Truncating it to whatever overlaps would answer
* a 40 kb view with a one-bin view — a wildly different zoom, in a deck whose
* whole promise is that the panels are showing the same thing. Sliding keeps the
* span and puts it at the block's own end, which is what `HeatmapView.clampView`
* does for exactly this reason, and is the answer a reader can read.
*
* A window wider than the whole block is the whole block, which is the same rule
* seen from the other side.
*/
export function bpToCells(window: AxisWindow, bpPerCell: number, cells: number): CellSpan {
const k = bpPerCell > 0 ? bpPerCell : 1
const limit = Math.max(1, Math.floor(cells))
const lo = Math.floor(Math.min(window.lo, window.hi) / k)
const hi = Math.ceil(Math.max(window.lo, window.hi) / k)
const span = Math.max(1, hi - lo)
if (span >= limit) return [0, limit]
const start = Math.max(0, Math.min(limit - span, lo))
return [start, start + span]
}
/**
* Whether two windows are the same window within one panel's ability to say so.
*
* `tolBp` is half a cell of whichever panel is being asked, so this answers
* "did this report come back from something I pushed" rather than "are these
* numbers equal". See the module header: it is the guard that closes the loop.
*/
export function windowsAgree(a: LockWindow, b: LockWindow, tolBp: number): boolean {
const tol = Math.max(0, tolBp)
return (
Math.abs(a.rows.lo - b.rows.lo) <= tol &&
Math.abs(a.rows.hi - b.rows.hi) <= tol &&
Math.abs(a.cols.lo - b.cols.lo) <= tol &&
Math.abs(a.cols.hi - b.cols.hi) <= tol
)
}
/**
* Whether two blocks describe the same stretch of genome, and may therefore be
* locked to each other.
*
* The whole compatibility gate for a locked deck, and it is deliberately much
* weaker than {@link compareRefusal}'s (§16.4). That gate refuses two files
* that differ in bin size or in chromosome list, because one *grid* cannot be
* two resolutions and one coordinate cannot index two lists. Neither objection
* survives being drawn in two panels: two grids may be two resolutions, and
* each panel indexes its own list.
*
* What is left is the one claim side-by-side panels actually make — *these two
* pictures are of the same place* — and it is checked against the blocks that
* came back rather than against the files, because that is the claim. Two
* panels that resolved the same name to spans of different length resolved it
* against different sequences, whatever the name says.
*
* The tolerance is one cell of the coarser panel **per sequence on the axis**:
* the last bin of a contig may be short (§3.3), so two files at two resolutions
* round the same length to spans that differ by less than one of the coarser
* cells, and refusing that would refuse every honest pair with a bin size that
* does not divide the contig.
*
* Per sequence, because a cell never straddles one (`metacooler/pooling.ts`):
* an axis of sixteen chromosomes rounds sixteen last cells up, not one, and a
* single-cell allowance there refused two roll-ups of the same genome — the
* false accusation §16.4 calls worse than saying nothing. `sequences` defaults
* to one, which is the single-contig region view the old allowance was written
* for and the only case it was ever right for.
*/
export function spansAgree(
a: { spanBp: number; bpPerCell: number },
b: { spanBp: number; bpPerCell: number },
sequences = 1,
): boolean {
const tol = Math.max(a.bpPerCell, b.bpPerCell) * Math.max(1, sequences)
return Math.abs(a.spanBp - b.spanBp) <= tol
}
/**
* One panel, as the lock sees it.
*
* Read through functions rather than as fields because every one of them
* changes under the lock: a block arrives at a coarser resolution than the last
* one, a panel is unlocked mid-drag, a panel is removed. Snapshotting them at
* join time is how a lock ends up driving a panel that is showing something
* else.
*/
export interface LockMember {
readonly id: string
/**
* What this panel's window is counted in — see {@link LockUnit}.
*
* Asked per call like everything else here: a level toggle changes it under a
* live deck, and a panel snapshotted as positional while it was reading bins
* is one the lock would drive in the wrong currency a frame later.
*/
unit(): LockUnit
/**
* Base pairs one matrix cell covers, on this panel's current block. Read only
* where {@link LockMember.unit} is `'bp'`; above bin level a cell has no
* width in base pairs and the index is carried instead.
*/
bpPerCell(): number
/** Cells per axis in the block this panel is showing. */
cells(): { rows: number; cols: number }
/** Whether this panel is following the deck, or has been let off the lead. */
locked(): boolean
/**
* Show this cell window. Implementations must not report back synchronously;
* the frame-later echo is expected and is handled by {@link windowsAgree}.
*
* @returns `false` where the panel could not move — it has no block yet — so
* that no report is recorded as owed for a report that will never come.
*/
show(rows: CellSpan, cols: CellSpan): boolean
}
/**
* How wide one of this panel's cells is, in the currency it locks in.
*
* One for a panel counting cells, which makes every conversion below the
* identity rather than a second code path: `cellsToBp` at a step of one is the
* cell index, and `bpToCells` at a step of one is that index slid back inside
* the block, which is exactly the rule a shorter sequence list needs anyway.
*/
function stepOf(member: LockMember): number {
return member.unit() === 'cell' ? 1 : member.bpPerCell()
}
/**
* The one owner of where every locked panel is looking.
*
* Single owner by construction: panels never speak to each other, they report
* here and are told. That is what makes the loop analysable at all — with N
* panels each echoing to N−1 others there are N² conversations and no frame in
* which the deck is in one state.
*/
export class ViewportLock {
private members: LockMember[] = []
/**
* Where the deck is looking, in the currency of whichever panel established
* it ({@link LockUnit}); `null` until anybody has moved.
*/
private current: LockWindow | null = null
/** Set while a broadcast is in progress, so its synchronous echo is ignored. */
private driving = false
/**
* Panels that have been pushed and have not answered yet.
*
* Each is owed one report and that report is thrown away. A panel that
* *clamped* what it was given — its block is shorter, so it stopped at its own
* last row — otherwise reports a window that genuinely differs from the
* broadcast, becomes the driver, and drags every other panel back to where it
* ran out. Which turns "pan past the end of the short one" into "you cannot
* pan past the end of the short one", in a deck the reader assembled precisely
* because the files are not identical.
*/
private pending = new Set<string>()
join(member: LockMember): void {
this.leave(member.id)
this.members.push(member)
}
leave(id: string): void {
this.members = this.members.filter((member) => member.id !== id)
this.pending.delete(id)
}
/** Where the deck is looking, for a panel that has just finished loading. */
get window(): LockWindow | null {
return this.current
}
/**
* Forget where the deck was looking, because it is no longer a place.
*
* The currency is base pairs **from each block's own first cell**, which makes
* a window a statement about one block rather than about the genome. Navigate
* from a 100 kb contig to a 3 Mb one and `20,000–60,000` still parses, still
* pushes, and means somewhere else entirely — so a companion that finished
* reading before the lead would be scrolled to a window nobody asked for, and
* with a table mounted there is no lead report to correct it.
*
* Called by the deck when the state selects a different block. Deliberately
* not inferred from a panel's own extent changing: the first panel to settle
* would then reset a window the others are already following.
*/
reset(): void {
this.current = null
this.pending.clear()
}
/**
* A panel moved. Adopt it as the deck's window and put every other locked
* panel there, unless this is the echo of a move the deck itself made.
*/
report(id: string, rows: CellSpan, cols: CellSpan): void {
if (this.driving) return
if (this.pending.delete(id)) return
const source = this.members.find((member) => member.id === id)
if (source === undefined || !source.locked()) return
const step = stepOf(source)
const window: LockWindow = {
rows: cellsToBp(rows, step),
cols: cellsToBp(cols, step),
unit: source.unit(),
}
// Half a cell of the *reporting* panel: the report is quantised to this
// panel's grid, so anything finer than that is not a movement it could have
// expressed deliberately. Compared only against a window in the same
// currency — the numbers are not otherwise about the same thing, and a
// level toggle would look like a nudge under the tolerance.
if (
this.current !== null &&
this.current.unit === window.unit &&
windowsAgree(this.current, window, step / 2)
) {
return
}
this.current = window
this.pushTo(this.members.filter((member) => member.id !== id))
}
/**
* Adopt a window as the deck's **without moving anybody to it**.
*
* The gap {@link catchUp} leaves. A window is established by a panel
* *reporting*, and a panel reports when it draws — so a deck whose lead has
* not redrawn since the lock was built has no window, and a companion that
* arrives into that gap is left at its own fitted view. Which is the one
* thing a deck must not do: two maps side by side under a bar saying they are
* one navigation, one of them zoomed into a locus and the other showing its
* whole block.
*
* The gap is not hypothetical and it is not a race in the renderer. A deck is
* built the moment a reader adds a second map, and the lead they added it
* beside was drawn — and reported nothing, because nothing was listening —
* before the lock existed. Every window the reader had chosen up to that point
* is therefore invisible to the deck until they touch the map again.
*
* So the deck seeds it from the lead's *present* window, which is a thing the
* lead can be asked for at any time. Deliberately **not** a
* {@link ViewportLock.report}: a report is a gesture and would push every
* member, recording a debt against panels that are already where they should
* be and swallowing their next real move. Seeding sets the window and moves
* nothing; whoever needs to catch up says so.
*
* Only where there is no window — an established one is a reader's choice and
* outranks whatever the lead happens to be showing.
*/
seed(rows: CellSpan, cols: CellSpan, step: number, unit: LockUnit): void {
if (this.current !== null) return
this.current = { rows: cellsToBp(rows, step), cols: cellsToBp(cols, step), unit }
}
/**
* Put one panel where the deck is looking — a panel that has just arrived, or
* one whose lock was just switched back on.
*
* Nothing happens where the deck has no window yet: the first panel to move
* is what establishes one, and forcing a newcomer to a window nobody chose
* would move it away from its own fitted view for no reason. {@link seed} is
* how the deck says what that window is when it knows and nobody has moved.
*/
catchUp(id: string): void {
if (this.current === null) return
this.pushTo(this.members.filter((member) => member.id === id))
}
private pushTo(targets: readonly LockMember[]): void {
const window = this.current
if (window === null) return
this.driving = true
try {
for (const member of targets) {
if (!member.locked()) continue
// A window counted in sequences is not a window this panel can read as
// base pairs, nor the other way about. The deck's gate refuses such a
// pairing and prints why, so reaching here at all is a panel mid-read
// between two levels — and standing still is the only honest thing to
// do with a number that means something else in its grid.
if (member.unit() !== window.unit) continue
const step = stepOf(member)
const extent = member.cells()
// A report is owed only where the panel actually moved. A panel whose
// block has not arrived cannot, and never reports — so recording the
// debt anyway left it owed forever and threw away that panel's first
// real gesture once its block landed.
const moved = member.show(
bpToCells(window.rows, step, extent.rows),
bpToCells(window.cols, step, extent.cols),
)
if (moved) this.pending.add(member.id)
}
} finally {
this.driving = false
}
}
}
|