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 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 58x 58x 58x 15x 58x 43x 43x 43x 119x 119x 33x 33x 33x 86x 104x 33x 119x 43x 43x 43x 43x 43x 81x 81x 81x 43x 58x 58x 1x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 1x 1x 1x 1x 1x 1x 1x 1x 1x 80x 80x 102x 80x 80x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 1x 153x 153x 153x 153x 153x 153x 153x 153x 153x 153x 153x 153x 153x 153x 153x 210x 153x 153x 153x 153x 153x 153x 153x 153x 153x 131x 22x 153x 153x 153x 153x 153x 153x 1x 153x 153x 153x 153x 153x 60x 93x 1x 92x 153x 1x 68x 67x 68x 68x 58x 24x 24x | /**
* The rows of a portal store that survive the narrowing, grouped by what they
* belong to.
*
* A 4DN experiment set is a contact map and the bigWigs and peak files
* published beside it, and a reader who narrows to one set means the set: so
* rows are drawn under a heading per group — the set's accession, what it is
* of, how many files — with a tick box that takes the whole group and, where
* the generator wrote one, *open with its tracks*, which is the pre-configured
* session for that group (§24.3). The session shelves stopped being a menu of
* their own the day this heading existed.
*
* ## A thousand rows are drawn, and every row is counted
*
* The cap `StoreTable` had, for the reason it had it: the largest ENCODE shard
* is 55,000 rows, a list of them is a quarter of a million elements, and what
* a reader does with a catalogue that size is narrow it. The cap is on
* drawing only — the counts, the facets and the tray see every row — and the
* line above the list says so when it bites.
*/
import { button, el, fmt } from '../dom'
import { icon } from '../icons'
import { withNewMark } from '../rowVerbs'
import { valueOf } from '../../state/facets'
import type { StoreColumn, StoreEntry } from '../../state/store'
import { rowActions, rowDrag, type StoreHost } from './host'
import { labelOf } from './FacetRail'
/** How many rows are put in the document at once. */
export const DRAWN = 1000
export interface ResultListProps {
host: StoreHost
/** A row was ticked or unticked. */
onTick(entry: StoreEntry, on: boolean): void
/** A whole group was ticked or unticked. */
onTickAll(entries: readonly StoreEntry[], on: boolean): void
}
export interface ResultListPaint {
/** The rows that passed the query, in the store's order. */
entries: readonly StoreEntry[]
columns: readonly StoreColumn[]
/** The column rows are grouped under, or none for a flat list. */
group?: string
/** The columns a group's heading repeats. */
headline?: readonly string[]
/** Pre-configured sessions by `${group}|${assembly}`. */
sessions: ReadonlyMap<string, StoreEntry>
/** The ticked rows, by URI. */
selected: ReadonlySet<string>
}
export class ResultList {
readonly el = el('div', { class: 'portal-rows' })
constructor(private readonly props: ResultListProps) {}
paint(paint: ResultListPaint): void {
const drawn = paint.entries.slice(0, DRAWN)
const nodes: HTMLElement[] = []
if (paint.group === undefined) {
for (const entry of drawn) nodes.push(this.row(entry, paint, paint.columns.slice(0, 4)))
} else {
// Group by first appearance, so the store's own order decides which set
// is first and a diff between two weeks of it stays readable — except
// the rows that belong to no set, which go last whatever the order: a
// heading that says *not in a set* at the top of a portal is the least
// interesting thing in it standing in the way of the rest.
const groups = new Map<string, StoreEntry[]>()
const loose: StoreEntry[] = []
for (const entry of drawn) {
const key = valueOf(entry, paint.group)
if (key === '') {
loose.push(entry)
continue
}
const held = groups.get(key)
if (held === undefined) groups.set(key, [entry])
else held.push(entry)
}
if (loose.length > 0) groups.set('', loose)
const detail = paint.columns
.filter((column) => column.key !== paint.group && !(paint.headline ?? []).includes(column.key))
.slice(0, 4)
for (const [key, entries] of groups) {
nodes.push(this.groupHead(key, entries, paint))
for (const entry of entries) nodes.push(this.row(entry, paint, detail))
}
}
this.el.replaceChildren(...nodes)
}
private groupHead(key: string, entries: readonly StoreEntry[], paint: ResultListPaint): HTMLElement {
const first = entries[0]
const chips = (paint.headline ?? [])
.map((column) => valueOf(first, column))
.filter((value) => value !== '')
.map((value) => el('span', { class: 'portal-chip', text: value }))
const allOn = entries.every((entry) => paint.selected.has(entry.uri))
const box = el('input', {
type: 'checkbox',
class: 'portal-box',
title: `Tick every file of ${labelOf(key)}`,
attrs: { 'aria-label': `tick every file of ${labelOf(key)}` },
})
box.checked = allOn
box.addEventListener('change', () => this.props.onTickAll(entries, box.checked))
// One *open with its tracks* per assembly the group was published on: a
// session is one set of coordinates (§24.3), and a set on two assemblies
// is two sessions.
//
// Two things can supply that verb, and the second is what gives a track hub
// the same heading a portal's has (§24.10). A **published session** is the
// richer answer: coordinates, lane order, everything its author placed.
// Where there is none, a row that is *the thing the group is about* and
// carries a shelf of tracks does the same job — opening it registers that
// shelf (`App.noteShelf`), so the reader lands on the genome with its
// tracks one press away. A hub has no sessions and every assembly has such
// a row; nothing else changes, because a store with neither still gets no
// button.
const assemblies = [...new Set(entries.map((entry) => valueOf(entry, 'assembly')))]
const sessions = assemblies.flatMap((assembly) => {
const label = assemblies.length > 1 ? `open with its tracks · ${assembly}` : 'open with its tracks'
const session = paint.sessions.get(`${key}|${assembly}`)
if (session !== undefined) {
return [
withNewMark(
button(label, () => this.props.host.open(session), {
class: 'btn btn-quiet portal-session',
title: `${session.label} — a pre-configured session: the map with the tracks published beside it, in one tab`,
}),
),
]
}
const lead = entries.find(
(entry) =>
valueOf(entry, 'assembly') === assembly && entry.tracks !== undefined && entry.tracks.length > 0,
)
if (lead === undefined) return []
const count = lead.tracks?.length ?? 0
return [
withNewMark(
button(label, () => this.props.host.openWithTracks(lead), {
class: 'btn btn-quiet portal-session',
title: `${lead.label} — opens with its ${count} ${count === 1 ? 'track' : 'tracks'} already on`,
}),
),
]
})
return el(
'div',
{ class: 'portal-group' },
box,
el('span', { class: 'portal-group-key', text: key === '' ? 'not in a set' : key }),
...chips,
el('span', { class: 'portal-group-count', text: `${fmt.int(entries.length)} file${entries.length === 1 ? '' : 's'}` }),
...sessions,
)
}
private row(entry: StoreEntry, paint: ResultListPaint, detail: readonly StoreColumn[]): HTMLElement {
const box = el('input', {
type: 'checkbox',
class: 'portal-box',
attrs: { 'aria-label': `tick ${entry.label}` },
})
box.checked = paint.selected.has(entry.uri)
box.addEventListener('change', () => this.props.onTick(entry, box.checked))
const name = button(entry.label, () => this.props.host.open(entry), {
class: 'btn-link portal-name',
title: entry.note ?? '',
})
const chips = detail
.map((column) => [column, valueOf(entry, column.key)] as const)
.filter(([, value]) => value !== '')
.map(([column, value]) =>
el('span', { class: 'portal-chip portal-chip-dim', text: value, title: column.label }),
)
return rowDrag(
el(
'div',
{ class: `portal-row${paint.selected.has(entry.uri) ? ' is-on' : ''}` },
box,
el('span', { class: `portal-kind portal-kind-${entry.kind}`, title: kindTitle(entry.kind) }, icon(kindIcon(entry.kind), { size: 12 })),
el('div', { class: 'portal-row-text' }, name, ...chips),
entry.size === undefined
? null
: el('span', { class: 'portal-size', text: fmt.bytes(entry.size) }),
el('div', { class: 'portal-row-actions' }, ...rowActions(entry, this.props.host)),
),
entry,
this.props.host,
)
}
}
function kindIcon(kind: StoreEntry['kind']): 'grid-2x2' | 'dna' | 'layers' {
return kind === 'map' ? 'grid-2x2' : kind === 'workspace' ? 'layers' : 'dna'
}
function kindTitle(kind: StoreEntry['kind']): string {
return kind === 'map'
? 'A contact map'
: kind === 'workspace'
? 'A saved session: a file with its tracks placed'
: 'A genome file: a track, a sequence, an annotation or a 3D model'
}
/**
* The line over the list: how many rows, of how many, and whether the cap bit.
*
* Pure, so the wording is testable without a DOM: "my row is not here" and "my
* row is filtered out" look the same on screen and only one of them is
* something the reader can undo.
*/
export function countLine(total: number, matching: number, narrowed: boolean): string {
if (total === 0) return 'This store lists nothing.'
const capped =
matching > DRAWN ? ` The first ${fmt.int(DRAWN)} are drawn; narrow further to see the rest.` : ''
if (!narrowed) return `${fmt.int(total)} ${total === 1 ? 'file' : 'files'}.${capped}`
if (matching === 0) return `Nothing matches. ${fmt.int(total)} ${total === 1 ? 'file' : 'files'} in this store.`
return `${fmt.int(matching)} of ${fmt.int(total)} files.${capped}`
}
|