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 | 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 85x 85x 85x 85x 85x 85x 85x 85x 17x 1x 85x 85x 16x 16x 85x 85x 85x 85x 85x 85x 85x 85x 85x 34x 51x 85x 85x 85x 85x 22x 22x 5x 5x 5x 17x 17x 22x 85x 7x 7x 7x 5x 7x 85x 2x 16x 16x 12x 16x 16x 16x 16x 3x 3x 3x 9x 9x 21x 21x 21x 9x 9x 9x 16x 2x 28x 28x 28x 28x 2x 2x 2x 2x 2x 2x 1x 2x 2x 2x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 2x 2x 266x 267x 266x 266x 77x 77x 57x 57x 57x 57x 57x 58x 147x 147x 29x 29x 29x 147x 28x 267x 266x 266x 266x 266x 36x 36x 36x 230x 266x 2x 2x 266x 2x 2x 180x 180x 180x 180x 180x 180x 180x 180x 180x 180x 180x 180x 2x 133x 133x 133x 133x 133x 133x | /**
* The one box a view is typed into, wearing the key that reaches it.
*
* Every view in this app answers a name: a heatmap opens the contig or the
* genome you type, a table filters to it, the browser goes there. Those boxes
* were built one per view, which was fine while each was the only one on
* screen — and wrong the moment `/` became a key the whole app answers, because
* a key needs one place to put the caret and four views had four spellings of
* it, two of which had no box at all.
*
* So the box is one object with one shape, and it prints its own shortcut. The
* badge is the discoverable half: a tooltip is found by someone who already
* suspects there is something to find, while a `/` sitting in the box is read
* by everyone who looks at the box. It steps out of the way on focus, because
* from that moment the reader is typing rather than looking for the way in.
*/
import { button, el } from './dom'
import { UNBINNED_NAME } from '../constants'
import { formatLocus } from '../genome/assembly'
import { KEYS, keyLabel } from './commands'
import { findFeatures, hitLabel, hitLocus } from '../annotations/search'
import type { AnnotationStore } from '../annotations/store'
/** One offered answer: what it reads as, and what it puts in the box. */
export interface Suggestion {
label: string
value: string
/**
* Handed on as typed rather than as a hit: the sequence the text names, which
* the view opens by its name as it would with no list at all.
*/
asTyped?: boolean
}
export interface SearchBoxOptions {
placeholder: string
/**
* Draw a bordered `go` beside the box.
*
* For the boxes a reader *aims* with rather than types into by reflex — the
* map's and the browser's locus bars, where the text came from somewhere else
* and the gesture is paste-then-click. Enter still submits either way; the
* button is there so that the one thing you can do with the box is visible
* from across the room, which on a filter box it already is (the list moves as
* you type) and here it is not.
*/
go?: boolean
/** The box's own tooltip: what typing here will do. */
title?: string
/** What it holds before anyone types — the current region, usually. */
value?: string
/** Extra classes for the input, for the views that size their own. */
inputClass?: string
/**
* Enter was pressed, or the `go` button beside it was clicked — with the hit
* it took, where it took one rather than the text as typed.
*/
onSubmit(text: string, hit?: Suggestion): void
/** Every keystroke, for a box that answers as you type. */
onInput?(text: string): void
/**
* What this box can offer for what has been typed so far.
*
* Absent for a box with nothing to offer — a table filter answers as you type
* and has no second list to drop. Given, the box grows a hit list under
* itself and Enter takes the first hit when the text is not something the
* view could already act on.
*/
suggest?(text: string): Suggestion[]
/**
* The sequence the text names, as the file names it, or `null`.
*
* For a box that opens a sequence by its name and offers genes too. A name
* the view acts on is not a question for the list — the hit list is for what
* the view could not act on as typed — and on the yeast genome `IV` is both
* chromosome IV and the first letters of IVY1, so Enter took the gene. Where
* the text names a sequence and genes are listed, the sequence is the list's
* first row, and so what Enter takes; the genes stay under it, a click away.
*/
sequence?(text: string): string | null
}
export class SearchBox {
/** The wrapper: the badge is positioned against it, so it goes in the DOM. */
readonly el: HTMLElement
readonly input: HTMLInputElement
private readonly hits = el('div', { class: 'search-hits hidden' })
constructor(private readonly options: SearchBoxOptions) {
this.input = el('input', {
class: `input search-input${options.inputClass === undefined ? '' : ` ${options.inputClass}`}`,
type: 'text',
placeholder: options.placeholder,
// The caller's own tooltip where it wrote one; otherwise the full offer,
// which {@link fitPlaceholder} may have shortened in the box itself.
title: options.title ?? options.placeholder,
...(options.value === undefined ? {} : { value: options.value }),
})
this.input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') this.submit()
else if (event.key === 'Escape') this.close()
})
this.input.addEventListener('input', () => {
options.onInput?.(this.input.value)
this.offer()
})
// A press on a hit is a `mousedown` before it is a `click`, and the
// `mousedown` blurs the box — so closing on blur immediately would take the
// list away before the click landed on anything.
this.input.addEventListener('blur', () => window.setTimeout(() => this.close(), 150))
fitPlaceholder(this.input, options.placeholder)
this.el = el(
'div',
{ class: options.go === true ? 'search-box search-box-go' : 'search-box' },
this.input,
searchKey(),
...(options.go === true
? [button('go', () => this.submit(), { class: 'btn', title: 'Go to what is in the box' })]
: []),
...(options.suggest === undefined ? [] : [this.hits]),
)
}
/**
* Act on what is in the box: the first hit, or the text itself.
*
* The text wins whenever the box has nothing to offer for it, which is what
* makes coordinates beat names without either being special-cased —
* `findFeatures` returns nothing for a string with a colon in it, so
* `chrI:1-2000` is never intercepted by a gene whose name it happens to
* prefix. A sequence's name wins too, by heading the list where genes are
* offered for it ({@link SearchBoxOptions.sequence}). Public because the `go`
* button beside the box means the same thing as Enter, and two spellings of
* that is how the two come to disagree.
*/
submit(): void {
const first = this.offered[0]
if (first !== undefined) {
this.take(first)
return
}
this.close()
this.options.onSubmit(this.input.value.trim())
}
/** Act on one offered answer: put it in the box, and hand it on as a hit or as typed. */
private take(hit: Suggestion): void {
this.input.value = hit.value
this.close()
if (hit.asTyped === true) this.options.onSubmit(hit.value)
else this.options.onSubmit(hit.value, hit)
}
/** What the box is offering right now, in the order it is showing them. */
private offered: Suggestion[] = []
private offer(): void {
const suggest = this.options.suggest
if (suggest === undefined) return
const hits = suggest(this.input.value)
const named = hits.length === 0 ? null : (this.options.sequence?.(this.input.value.trim()) ?? null)
this.offered =
named === null ? hits : [{ label: `${named} · the whole sequence`, value: named, asTyped: true }, ...hits]
if (this.offered.length === 0) {
// Nothing rather than "no match": below two characters and for anything
// that looks like coordinates the box is not searching at all, and a card
// saying it failed would be reporting on a search nobody ran.
this.close()
return
}
this.hits.replaceChildren(
...this.offered.map((hit) =>
button(hit.label, () => this.take(hit), {
class: hit.asTyped === true ? 'search-hit search-hit-typed' : 'search-hit',
}),
),
)
this.hits.classList.remove('hidden')
}
/**
* Put the list away, and *empty* it.
*
* Hiding alone leaves the buttons live and `offered` populated, so Enter would
* jump to a hit nobody could see — which makes Escape look like a cancel and
* behave like a deferred navigation.
*/
private close(): void {
this.offered = []
this.hits.classList.add('hidden')
this.hits.replaceChildren()
}
/**
* Take the caret, and select what is there.
*
* Selected rather than appended to, because what is in the box is where the
* reader already is: a caret placed at the end of `IV:400,000-500,000` turns
* "find GAL4" into `IV:400,000-500,000GAL4`. Typing replaces; an arrow key
* still keeps the text.
*/
focus(): void {
this.input.focus()
this.input.select()
}
/** Put text in the box without telling anybody — a view that has moved. */
set(text: string): void {
if (this.input === document.activeElement) return
this.input.value = text
}
}
/**
* Feature hits as a box's suggestions, in whichever grammar that box speaks.
*
* `FeatureHit` carries the window 0-based and half-open, which is the machine
* form (§26.1) and is what `metacooler/range.ts` takes; a box that **shows** a
* locus and reads back what it shows speaks the display form, 1-based and
* inclusive. This used to answer in the machine form only, and the hint under
* the browser's box said so — which was true until the browser's box began
* reading what was typed as the display form, at which point picking a gene off
* the list landed a base to the left of the gene.
*
* So the base is a parameter, named at the call site, and the label always
* matches the value: a row that prints one coordinate over a box that fills with
* another is the defect either way round.
*
* The *label* is `hitLocus`, which writes the gene's **own** span. It used to
* print `hit.start` — the start of the padded window the click then navigates
* to, which is a quarter of the gene's length before the gene — so the row named
* a coordinate the gene does not begin at.
*
* @param form `'machine'` for a box whose value is a region string handed
* straight to the region reader — the map's — and `'display'` for one a reader
* reads and Enter re-reads through `typedLocusToRegion`.
*/
export function featureSuggestions(
store: AnnotationStore,
text: string,
form: 'machine' | 'display' = 'machine',
): Suggestion[] {
return findFeatures(store, text).map((hit) => ({
label: `${hitLabel(hit)} · ${hitLocus(hit)} · ${hit.source}`,
value:
form === 'display'
? formatLocus({ contig: hit.contig, start: hit.start, end: hit.end })
: `${hit.contig}:${hit.start}-${hit.end}`,
}))
}
/** What a locus box says it takes, beyond coordinates. */
export interface LocusAccepts {
/** A bare sequence name — the whole of one chromosome or contig. */
sequence?: boolean
/** A genome's name, on a file that holds more than one. */
genome?: boolean
/** A gene or feature name, where an annotation is loaded to search. */
gene?: boolean
/** A cytogenetic band — `Xq28` — where a karyotype is loaded (§8.22). */
band?: boolean
/** `*`, for everything at once. */
whole?: boolean
}
/** A ruler shared by every box, because a canvas context is not free to make. */
let ruler: CanvasRenderingContext2D | null | undefined
/**
* Fit an offer of the form `example, a thing, or another` to the box it is in,
* and keep it fitted.
*
* A locus box offers an example and then what else it takes —
* `I:1,000-20,000, a sequence, a genome, or a gene` — and the map's box is 168
* pixels wide, which holds the first fourteen characters of that. A truncated
* offer teaches nothing: `I:1,000-20,000, a sequ…` reads as a box that has run
* out of room rather than as a list of what may be typed into it — and an input
* clips its placeholder with no ellipsis at all, so there is not even a mark to
* say that something was cut. The clause that goes is the one the reader did not
* already know about.
*
* So the clauses come off the end, last first, until what is left fits — the
* example always survives, because it is the one part that shows the *grammar* —
* and the whole offer belongs in the box's `title`, which is readable at any
* width. Re-measured whenever the box changes size, so widening a panel gives
* the rest of the sentence back.
*
* Measured on a canvas rather than by overflow, because an empty input reports
* none: `scrollWidth === clientWidth` however long its placeholder is, which is
* why these went unnoticed until they were measured.
*/
export function fitPlaceholder(input: HTMLInputElement, full: string): void {
const fit = (): void => {
if (!full.includes(', ')) return
const width = input.clientWidth
if (width === 0) return
ruler ??= document.createElement('canvas').getContext('2d')
if (ruler === null) return
const style = window.getComputedStyle(input)
ruler.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
// `|| 0` because a stylesheet that says nothing about padding leaves these
// empty, and `NaN` room would trim every clause off every box.
const pad = (value: string): number => Number.parseFloat(value) || 0
const room = width - pad(style.paddingLeft) - pad(style.paddingRight)
// `, or ` joins the last two clauses, so the conjunction only ever sits in
// the final part: dropping from the end cannot leave a dangling `or`.
const parts = full.split(', ')
for (let keep = parts.length; keep > 1; keep--) {
const text = parts.slice(0, keep).join(', ')
if (ruler.measureText(text).width <= room) {
input.placeholder = text
return
}
}
// The example, even where the box is too narrow for it: it is the one part
// that shows the grammar, and there is nothing shorter worth showing.
input.placeholder = parts[0]
}
input.placeholder = full
// The offer, whole, wherever the caller has not written its own sentence:
// trimming must never be the only copy of a clause.
if (input.title === '') input.title = full
fit()
// One observer per box. This is called again whenever what the box takes
// changes — a file with an annotation in it starts offering a gene name — and
// a second observer would re-measure the same input on every resize.
if (fitted.has(input)) {
fitted.set(input, fit)
return
}
fitted.set(input, fit)
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(() => fitted.get(input)?.()).observe(input)
}
}
/** The boxes already being watched, and the latest offer each one is fitting. */
const fitted = new WeakMap<HTMLInputElement, () => void>()
/**
* What a locus box offers, in one sentence, built the same way everywhere.
*
* Six boxes across the app asked for the same thing in five different phrasings
* — `contig, or contig:start-end` in the tables, `genome, contig, or
* contig:start-end` on one of them, `a genome, or a contig` on the overview,
* and `chrI:1,000-20,000, a sequence, a gene, or * for the whole genome` in the
* two browser bars, which named `chrI` on a yeast assembly whose sequences are
* called `I`. So: one builder, one order, and the *example* is taken from the
* file in front of the reader rather than from a human genome they may not have
* open.
*
* @param first The assembly's first sequence name, used as the example when it
* is short enough to leave room for the coordinates — past a dozen characters a
* scaffold name is the whole box and says less than the word `contig` does.
*/
export function locusPlaceholder(options: { first?: string; accepts?: LocusAccepts }): string {
// **Never `__unbinned__`.** It is the first row of a metacool's `/contigs`
// table and §7.4 refuses to draw it at any resolution, so a box offering
// `__unbinned__:1,000-20,000` was holding up as its example the one name in
// the file that cannot be opened. Caught here rather than at each call site,
// because every caller reaches for `contigs[0]`.
const offered = options.first === UNBINNED_NAME ? undefined : options.first
const first = offered !== undefined && offered.length <= 12 ? offered : 'contig'
const accepts = options.accepts ?? {}
const parts = [`${first}:1,000-20,000`]
if (accepts.sequence === true) parts.push('a sequence')
if (accepts.genome === true) parts.push('a genome')
if (accepts.gene === true) parts.push('a gene')
if (accepts.band === true) parts.push('a band')
if (accepts.whole === true) parts.push('* for the whole genome')
// `or` before the last one, whatever the last one is. It was put in with the
// `*` and nowhere else, so a box that does not take `*` read
// `contig:1,000-20,000, a sequence, a genome, a gene` — a list with no
// conjunction, which reads as an unfinished sentence rather than as a set of
// alternatives.
if (parts.length < 2) return parts[0]
return `${parts.slice(0, -1).join(', ')}, or ${parts[parts.length - 1]}`
}
/**
* The `/` badge, for a box that is built by hand rather than by this class.
*
* The genome browser's box is one: it carries a hit list positioned against the
* same wrapper and a click-to-select gesture of its own, so it stays where it
* is and borrows the mark.
*/
export function searchKey(): HTMLElement {
return el('kbd', {
class: 'kbd search-key',
text: keyLabel(KEYS.find),
title: `Press ${keyLabel(KEYS.find)} to put the caret here`,
})
}
|