@jbrowse/launch-args 5.0.0-beta.17 → 5.0.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/comparative.d.ts +62 -0
- package/esm/comparative.js +187 -0
- package/esm/displaySettings.d.ts +160 -0
- package/esm/displaySettings.js +686 -0
- package/esm/displaySlots.generated.d.ts +2 -0
- package/esm/displaySlots.generated.js +23 -0
- package/esm/fileAdapter.d.ts +2 -0
- package/esm/fileAdapter.js +5 -0
- package/esm/fileInput.d.ts +35 -16
- package/esm/fileInput.js +96 -30
- package/esm/hostedGenomes.d.ts +39 -0
- package/esm/hostedGenomes.js +45 -0
- package/esm/index.d.ts +8 -4
- package/esm/index.js +8 -4
- package/esm/launchPlan.d.ts +73 -9
- package/esm/launchPlan.js +154 -69
- package/esm/modifierValues.d.ts +10 -0
- package/esm/modifierValues.js +47 -0
- package/esm/parseArgv.d.ts +35 -5
- package/esm/parseArgv.js +56 -23
- package/esm/slotPath.d.ts +48 -0
- package/esm/slotPath.js +211 -0
- package/package.json +7 -4
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { samFlagNames } from '@jbrowse/cigar-utils';
|
|
2
|
+
import { displaySlotPaths } from './displaySlots.generated.js';
|
|
3
|
+
import { invalidModifierValue, parseBool, parseEnum, parseJsonModifier, parseNum, parseStr, } from './modifierValues.js';
|
|
4
|
+
import { applySlotWrite, isMemberWrite, isSlotPathOption, mergeSettings, slotWrite, } from './slotPath.js';
|
|
5
|
+
export { mergeSettings };
|
|
6
|
+
export const ALL_CATEGORIES = [
|
|
7
|
+
'alignments',
|
|
8
|
+
'wiggle',
|
|
9
|
+
'feature',
|
|
10
|
+
'variant',
|
|
11
|
+
'hic',
|
|
12
|
+
'other',
|
|
13
|
+
];
|
|
14
|
+
// jb2export's `trackCategory.test.ts` lists every registered display type and
|
|
15
|
+
// fails on one missing here, so a new display picks its family instead of
|
|
16
|
+
// falling to `other`.
|
|
17
|
+
const categoryByDisplayType = {
|
|
18
|
+
LinearAlignmentsDisplay: 'alignments',
|
|
19
|
+
LinearWiggleDisplay: 'wiggle',
|
|
20
|
+
LinearHicDisplay: 'hic',
|
|
21
|
+
LinearBasicDisplay: 'feature',
|
|
22
|
+
LinearMultiRowFeatureDisplay: 'feature',
|
|
23
|
+
LinearVariantDisplay: 'variant',
|
|
24
|
+
LinearMultiSampleVariantDisplay: 'variant',
|
|
25
|
+
LinearMarkDisplay: 'other',
|
|
26
|
+
LinearManhattanDisplay: 'other',
|
|
27
|
+
LinearMafDisplay: 'other',
|
|
28
|
+
LDTrackDisplay: 'other',
|
|
29
|
+
LinearReferenceSequenceDisplay: 'other',
|
|
30
|
+
LinearSyntenyDisplay: 'other',
|
|
31
|
+
LGVSyntenyDisplay: 'other',
|
|
32
|
+
MultiWaySyntenyDisplay: 'other',
|
|
33
|
+
DotplotDisplay: 'other',
|
|
34
|
+
ChordSyntenyDisplay: 'other',
|
|
35
|
+
ChordVariantDisplay: 'other',
|
|
36
|
+
};
|
|
37
|
+
export const categorizedDisplayTypes = Object.keys(categoryByDisplayType);
|
|
38
|
+
export function displayCategory(displayType) {
|
|
39
|
+
return ((displayType ? lookup(categoryByDisplayType, displayType) : undefined) ??
|
|
40
|
+
'other');
|
|
41
|
+
}
|
|
42
|
+
// A display's slot paths, and which of them hold sub-slots: a written path is
|
|
43
|
+
// declared where it is a slot, or sits inside one that holds no sub-slots (a
|
|
44
|
+
// frozen object, a list).
|
|
45
|
+
const slotIndex = new Map();
|
|
46
|
+
function slotIndexOf(displayType) {
|
|
47
|
+
let index = slotIndex.get(displayType);
|
|
48
|
+
if (!index) {
|
|
49
|
+
const paths = lookup(displaySlotPaths, displayType);
|
|
50
|
+
if (!paths) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const parents = new Set();
|
|
54
|
+
for (const path of paths) {
|
|
55
|
+
const segments = path.split('.');
|
|
56
|
+
for (let i = 1; i < segments.length; i++) {
|
|
57
|
+
parents.add(segments.slice(0, i).join('.'));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
index = { slots: new Set(paths), parents };
|
|
61
|
+
slotIndex.set(displayType, index);
|
|
62
|
+
}
|
|
63
|
+
return index;
|
|
64
|
+
}
|
|
65
|
+
function declares(index, path) {
|
|
66
|
+
const segments = path.split('.');
|
|
67
|
+
return segments.some((_, i) => {
|
|
68
|
+
const prefix = segments.slice(0, i + 1).join('.');
|
|
69
|
+
return (index.slots.has(prefix) && (prefix === path || !index.parents.has(prefix)));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function isPlainObject(v) {
|
|
73
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
74
|
+
}
|
|
75
|
+
// The leaf paths `after` writes that `before` did not hold as they are
|
|
76
|
+
function changedPaths(before, after, at = '') {
|
|
77
|
+
if (isPlainObject(after) && Object.keys(after).length > 0) {
|
|
78
|
+
const was = isPlainObject(before) ? before : {};
|
|
79
|
+
return Object.entries(after).flatMap(([key, value]) => changedPaths(was[key], value, at ? `${at}.${key}` : key));
|
|
80
|
+
}
|
|
81
|
+
return JSON.stringify(before) === JSON.stringify(after) ? [] : [at];
|
|
82
|
+
}
|
|
83
|
+
// `Object.hasOwn` because every table here is keyed by raw command-line input,
|
|
84
|
+
// and `constructor` is inherited from Object.prototype.
|
|
85
|
+
function lookup(table, key) {
|
|
86
|
+
return Object.hasOwn(table, key) ? table[key] : undefined;
|
|
87
|
+
}
|
|
88
|
+
export const ALIGNMENTS_COLOR_FIELDS = [
|
|
89
|
+
'strand',
|
|
90
|
+
'firstOfPairStrand',
|
|
91
|
+
'mapq',
|
|
92
|
+
'insertSize',
|
|
93
|
+
'pairOrientation',
|
|
94
|
+
'insertSizeAndOrientation',
|
|
95
|
+
'mateRefName',
|
|
96
|
+
'mateAssembly',
|
|
97
|
+
];
|
|
98
|
+
const ALIGNMENTS_COLOR_FIELD_SET = new Set(ALIGNMENTS_COLOR_FIELDS);
|
|
99
|
+
export const FACET_ONLY_FIELDS = [
|
|
100
|
+
'pairing',
|
|
101
|
+
'split',
|
|
102
|
+
'spliced',
|
|
103
|
+
'svSignal',
|
|
104
|
+
];
|
|
105
|
+
const FACET_ONLY_FIELD_SET = new Set(FACET_ONLY_FIELDS);
|
|
106
|
+
// `methylation` is the fill-unmarked view of the modifications field: one word
|
|
107
|
+
// for the everyday CpG picture.
|
|
108
|
+
const BASE_COLORS = [
|
|
109
|
+
'modifications',
|
|
110
|
+
'methylation',
|
|
111
|
+
'bisulfite',
|
|
112
|
+
'baseQuality',
|
|
113
|
+
'base',
|
|
114
|
+
];
|
|
115
|
+
const BASE_COLOR_NAMES = new Set(BASE_COLORS);
|
|
116
|
+
export const COLOR_SCHEMES = [
|
|
117
|
+
'viridis',
|
|
118
|
+
'magma',
|
|
119
|
+
'inferno',
|
|
120
|
+
'cividis',
|
|
121
|
+
'juicebox',
|
|
122
|
+
'fall',
|
|
123
|
+
'reds',
|
|
124
|
+
'blues',
|
|
125
|
+
'redblue',
|
|
126
|
+
'purpleorange',
|
|
127
|
+
'redgreyblue',
|
|
128
|
+
'redyellowblue',
|
|
129
|
+
];
|
|
130
|
+
/** The alignments compactness presets' read heights. */
|
|
131
|
+
export const ALIGNMENTS_COMPACTNESS = {
|
|
132
|
+
normal: 7,
|
|
133
|
+
compact: 3,
|
|
134
|
+
'super-compact': 1,
|
|
135
|
+
};
|
|
136
|
+
export const HEIGHT_MODES = ['fixed', 'grow', 'fit'];
|
|
137
|
+
export const READ_CATEGORY_KEYS = [
|
|
138
|
+
'spliced',
|
|
139
|
+
'svSignal',
|
|
140
|
+
'alone',
|
|
141
|
+
'split',
|
|
142
|
+
];
|
|
143
|
+
const isScalar = (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
|
|
144
|
+
const isNumber = (v) => typeof v === 'number';
|
|
145
|
+
const FILTER_TESTS = {
|
|
146
|
+
equal: isScalar,
|
|
147
|
+
lt: isNumber,
|
|
148
|
+
lte: isNumber,
|
|
149
|
+
gt: isNumber,
|
|
150
|
+
gte: isNumber,
|
|
151
|
+
oneOf: v => Array.isArray(v) && v.every(isScalar),
|
|
152
|
+
valid: v => typeof v === 'boolean',
|
|
153
|
+
};
|
|
154
|
+
function badFilterEntry(entry, why) {
|
|
155
|
+
throw new Error(`filter entry ${JSON.stringify(entry)} ${why}; an entry is { field, ${[...Object.keys(FILTER_TESTS), 'range'].join(' | ')} }, or a string, which means { field: <string>, equal: true }`);
|
|
156
|
+
}
|
|
157
|
+
// a string is the config schema's shorthand
|
|
158
|
+
function filterEntryOf(entry) {
|
|
159
|
+
if (typeof entry === 'string') {
|
|
160
|
+
return entry
|
|
161
|
+
? { field: entry, equal: true }
|
|
162
|
+
: badFilterEntry(entry, 'is empty');
|
|
163
|
+
}
|
|
164
|
+
else if (typeof entry !== 'object' ||
|
|
165
|
+
entry === null ||
|
|
166
|
+
Array.isArray(entry)) {
|
|
167
|
+
return badFilterEntry(entry, 'is not an object');
|
|
168
|
+
}
|
|
169
|
+
const { field, range, ...written } = entry;
|
|
170
|
+
if (typeof field !== 'string' || !field) {
|
|
171
|
+
return badFilterEntry(entry, 'names no field');
|
|
172
|
+
}
|
|
173
|
+
const tests = range === undefined ? written : withRange(entry, written, range);
|
|
174
|
+
for (const [name, value] of Object.entries(tests)) {
|
|
175
|
+
const valid = lookup(FILTER_TESTS, name);
|
|
176
|
+
if (!valid) {
|
|
177
|
+
badFilterEntry(entry, `has no test "${name}"`);
|
|
178
|
+
}
|
|
179
|
+
else if (!valid(value)) {
|
|
180
|
+
badFilterEntry(entry, `has a ${name} of the wrong type`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { field, ...tests };
|
|
184
|
+
}
|
|
185
|
+
// Vega-Lite's `range`, as the config schema lifts it: [min, max], a null end
|
|
186
|
+
// open, the tighter bound winning beside a written gte or lte
|
|
187
|
+
function withRange(entry, tests, range) {
|
|
188
|
+
if (!Array.isArray(range) ||
|
|
189
|
+
range.length !== 2 ||
|
|
190
|
+
!range.every(end => end === null || typeof end === 'number')) {
|
|
191
|
+
return badFilterEntry(entry, 'has a range that is not [min, max]');
|
|
192
|
+
}
|
|
193
|
+
const [lo, hi] = range;
|
|
194
|
+
const { gte, lte } = tests;
|
|
195
|
+
return {
|
|
196
|
+
...tests,
|
|
197
|
+
...(lo === null
|
|
198
|
+
? {}
|
|
199
|
+
: { gte: typeof gte === 'number' ? Math.max(gte, lo) : lo }),
|
|
200
|
+
...(hi === null
|
|
201
|
+
? {}
|
|
202
|
+
: { lte: typeof lte === 'number' ? Math.min(lte, hi) : hi }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* A `filter` list as a JSON bag or a `filter=` write gives it, each entry
|
|
207
|
+
* checked as the config schema checks one and a string read as its
|
|
208
|
+
* shorthand. Throws on what the schema would refuse, v4's object form
|
|
209
|
+
* included.
|
|
210
|
+
*/
|
|
211
|
+
export function filterEntriesOf(list) {
|
|
212
|
+
if (!Array.isArray(list)) {
|
|
213
|
+
throw new Error(`a filter is a list of { field, ... } entries, not ${JSON.stringify(list)}`);
|
|
214
|
+
}
|
|
215
|
+
return list.map(filterEntryOf);
|
|
216
|
+
}
|
|
217
|
+
// `entries` replace what the settings wrote on each field they name
|
|
218
|
+
function writeFilterFields(r, entries) {
|
|
219
|
+
const named = new Set(entries.map(p => p.field));
|
|
220
|
+
r.snap.filter = [
|
|
221
|
+
...(r.snap.filter ?? []).filter(p => !named.has(p.field)),
|
|
222
|
+
...entries,
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
function listedValues(entries) {
|
|
226
|
+
return entries.flatMap(p => [
|
|
227
|
+
...(p.equal === undefined ? [] : [p.equal]),
|
|
228
|
+
...(p.oneOf ?? []),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
function setColor(r, color) {
|
|
232
|
+
r.snap.encoding = { ...r.snap.encoding, color };
|
|
233
|
+
}
|
|
234
|
+
// display-kit's `colorForField`, as the Color by menu picks a field over a
|
|
235
|
+
// color a JSON modifier may have started: its scale and key stay only while
|
|
236
|
+
// the field is the one they were written for.
|
|
237
|
+
function colorByField(r, field) {
|
|
238
|
+
const written = r.snap.encoding?.color;
|
|
239
|
+
const current = typeof written === 'string' ? { field: written } : written;
|
|
240
|
+
setColor(r, field === current?.field
|
|
241
|
+
? structuredClone(written)
|
|
242
|
+
: current?.value === undefined
|
|
243
|
+
? field
|
|
244
|
+
: { value: current.value, field });
|
|
245
|
+
}
|
|
246
|
+
// Aliases for the displays a track type has beyond its default, with any
|
|
247
|
+
// settings the alias implies. `display:<anything-else>` passes through.
|
|
248
|
+
const displayTypeAliases = {
|
|
249
|
+
marks: { type: 'LinearMarkDisplay' },
|
|
250
|
+
multivariant: { type: 'LinearMultiSampleVariantDisplay' },
|
|
251
|
+
multivariantmatrix: {
|
|
252
|
+
type: 'LinearMultiSampleVariantDisplay',
|
|
253
|
+
settings: { variantLayout: 'columns' },
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
function displayAlias(name) {
|
|
257
|
+
return lookup(displayTypeAliases, name) ?? { type: name };
|
|
258
|
+
}
|
|
259
|
+
/** The display a `display:` modifier asks for; the last one given wins. */
|
|
260
|
+
export function requestedDisplayType(modifiers) {
|
|
261
|
+
const name = modifiers.findLast(mod => mod.startsWith('display:'))?.slice(8);
|
|
262
|
+
return name ? displayAlias(name).type : undefined;
|
|
263
|
+
}
|
|
264
|
+
// `sort:basePair` is the spelling before the slot named the field `base`.
|
|
265
|
+
const sortFieldAliases = {
|
|
266
|
+
basePair: 'base',
|
|
267
|
+
};
|
|
268
|
+
// A SAM flag mask, written as a number or as samtools' flag names, whose
|
|
269
|
+
// comma is an OR: `SECONDARY,DUP`.
|
|
270
|
+
function parseFlagMask(prefix, val) {
|
|
271
|
+
return /^[0-9]+$/.test(val.trim())
|
|
272
|
+
? parseNum(prefix, val.trim())
|
|
273
|
+
: val
|
|
274
|
+
.split(',')
|
|
275
|
+
.map(name => {
|
|
276
|
+
const i = samFlagNames.indexOf(name.trim().toUpperCase());
|
|
277
|
+
return i < 0
|
|
278
|
+
? invalidModifierValue(prefix, name, `a number or one of ${samFlagNames.join(', ')}`)
|
|
279
|
+
: 1 << i;
|
|
280
|
+
})
|
|
281
|
+
.reduce((a, b) => a | b, 0);
|
|
282
|
+
}
|
|
283
|
+
// The feature display and the variant display share LinearCanvasBaseDisplay's
|
|
284
|
+
// schema, so a modifier reading one of its slots takes both.
|
|
285
|
+
const CANVAS = ['feature', 'variant'];
|
|
286
|
+
function settingsOf(result) {
|
|
287
|
+
return result.snap;
|
|
288
|
+
}
|
|
289
|
+
// `all` writes the bare field, which clears what the track's config set and
|
|
290
|
+
// makes a category scriptable from a variable that may be empty.
|
|
291
|
+
function readCategoryModifiers() {
|
|
292
|
+
return Object.fromEntries(READ_CATEGORY_KEYS.map(field => [
|
|
293
|
+
field,
|
|
294
|
+
{
|
|
295
|
+
on: ['alignments'],
|
|
296
|
+
apply: (r, v) => {
|
|
297
|
+
const choice = parseEnum(field, v, [
|
|
298
|
+
'all',
|
|
299
|
+
'only',
|
|
300
|
+
'exclude',
|
|
301
|
+
]);
|
|
302
|
+
writeFilterFields(r, [
|
|
303
|
+
choice === 'all' ? { field } : { field, equal: choice === 'only' },
|
|
304
|
+
]);
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
]));
|
|
308
|
+
}
|
|
309
|
+
// samtools' `-f` then `-F` over every flag field: a bit in neither mask
|
|
310
|
+
// clears its field, so `flags::256` also lifts the default exclusions
|
|
311
|
+
function flagEntries(include, exclude) {
|
|
312
|
+
return samFlagNames.map((name, i) => {
|
|
313
|
+
const field = `flags.${name}`;
|
|
314
|
+
const bit = 1 << i;
|
|
315
|
+
return include & bit
|
|
316
|
+
? { field, equal: true }
|
|
317
|
+
: exclude & bit
|
|
318
|
+
? { field, equal: false }
|
|
319
|
+
: { field };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const modifiers = {
|
|
323
|
+
height: {
|
|
324
|
+
on: ALL_CATEGORIES,
|
|
325
|
+
apply: (r, v) => {
|
|
326
|
+
r.snap.height = parseNum('height', v);
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
// a display that never gates a large region has nothing to force
|
|
330
|
+
force: {
|
|
331
|
+
on: ALL_CATEGORIES,
|
|
332
|
+
vacuous: true,
|
|
333
|
+
apply: (r, v) => {
|
|
334
|
+
r.snap.forceLoad = parseBool('force', v);
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
display: {
|
|
338
|
+
on: ALL_CATEGORIES,
|
|
339
|
+
apply: (r, v) => {
|
|
340
|
+
const { type, settings } = displayAlias(parseStr('display', v, 'display name'));
|
|
341
|
+
r.displayType = type;
|
|
342
|
+
Object.assign(r.snap, settings);
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
// consumed while building the track's config
|
|
346
|
+
index: { on: ALL_CATEGORIES, values: 'rest', apply: () => { } },
|
|
347
|
+
name: { on: ALL_CATEGORIES, values: 'rest', apply: () => { } },
|
|
348
|
+
// `heightMode:fit:200` sets the track height in the same modifier
|
|
349
|
+
heightMode: {
|
|
350
|
+
on: ['alignments', ...CANVAS],
|
|
351
|
+
values: 2,
|
|
352
|
+
apply: (r, v, height) => {
|
|
353
|
+
r.snap.heightMode = parseEnum('heightMode', v, HEIGHT_MODES);
|
|
354
|
+
if (height !== undefined) {
|
|
355
|
+
r.snap.height = parseNum('heightMode', height);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
// alignments write `pileup.mark.height`, the canvas displays `displayMode`
|
|
360
|
+
// or `mark.height`
|
|
361
|
+
featureHeight: {
|
|
362
|
+
on: ['alignments', ...CANVAS],
|
|
363
|
+
byCategory: true,
|
|
364
|
+
apply: (r, v, _v2, category) => {
|
|
365
|
+
const preset = v === 'normal' || v === 'compact' || v === 'super-compact'
|
|
366
|
+
? v
|
|
367
|
+
: undefined;
|
|
368
|
+
const height = preset
|
|
369
|
+
? undefined
|
|
370
|
+
: parseNum('featureHeight', v, 'normal, compact, super-compact, or a number');
|
|
371
|
+
if (category === 'alignments') {
|
|
372
|
+
mergeSettings(settingsOf(r), {
|
|
373
|
+
pileup: {
|
|
374
|
+
mark: {
|
|
375
|
+
height: preset ? ALIGNMENTS_COMPACTNESS[preset] : height,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
else if (preset) {
|
|
381
|
+
r.snap.displayMode =
|
|
382
|
+
preset === 'super-compact' ? 'superCompact' : preset;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
r.snap.mark = { height };
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
sort: {
|
|
390
|
+
on: ['alignments'],
|
|
391
|
+
values: 2,
|
|
392
|
+
apply: (r, v, tag) => {
|
|
393
|
+
const field = parseStr('sort', v, 'sort field');
|
|
394
|
+
if (field === 'tag' && !tag) {
|
|
395
|
+
invalidModifierValue('sort', v, 'tag:<TAG>');
|
|
396
|
+
}
|
|
397
|
+
else if (field !== 'tag' && tag !== undefined) {
|
|
398
|
+
throw new Error(strayValueMessage('sort', 1, [tag]));
|
|
399
|
+
}
|
|
400
|
+
r.sort = {
|
|
401
|
+
field: field === 'tag'
|
|
402
|
+
? `tags.${tag}`
|
|
403
|
+
: (lookup(sortFieldAliases, field) ?? field),
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
arcs: {
|
|
408
|
+
on: ['alignments'],
|
|
409
|
+
apply: (r, v) => {
|
|
410
|
+
const mode = parseEnum('arcs', v, ['off', 'up', 'down', 'cloud']);
|
|
411
|
+
mergeSettings(settingsOf(r), {
|
|
412
|
+
readConnections: mode === 'off'
|
|
413
|
+
? { show: false }
|
|
414
|
+
: mode === 'cloud'
|
|
415
|
+
? { show: true, mark: 'rule' }
|
|
416
|
+
: { show: true, mark: 'link', placement: mode },
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
sashimi: {
|
|
421
|
+
on: ['alignments'],
|
|
422
|
+
apply: (r, v) => {
|
|
423
|
+
const mode = parseEnum('sashimi', v, ['off', 'up', 'down', 'auto']);
|
|
424
|
+
mergeSettings(settingsOf(r), {
|
|
425
|
+
sashimi: mode === 'off' ? { show: false } : { show: true, placement: mode },
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
// They filter before the coverage pipeline, so `svSignal:only` on an
|
|
430
|
+
// ordinary sample removes almost every read and the coverage band with them.
|
|
431
|
+
...readCategoryModifiers(),
|
|
432
|
+
// an omitted half is mask 0
|
|
433
|
+
flags: {
|
|
434
|
+
on: ['alignments'],
|
|
435
|
+
values: 2,
|
|
436
|
+
apply: (r, include, exclude) => {
|
|
437
|
+
writeFilterFields(r, flagEntries(include ? parseFlagMask('flags', include) : 0, exclude ? parseFlagMask('flags', exclude) : 0));
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
// samtools' `-q`
|
|
441
|
+
mapq: {
|
|
442
|
+
on: ['alignments'],
|
|
443
|
+
apply: (r, v) => {
|
|
444
|
+
writeFilterFields(r, [{ field: 'mapq', gte: parseNum('mapq', v) }]);
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
// a second value on one tag is an alternative, and a value keeps its
|
|
448
|
+
// colons, as in `filterTag:RG:HG002:fc1:1`
|
|
449
|
+
filterTag: {
|
|
450
|
+
on: ['alignments'],
|
|
451
|
+
values: 'rest',
|
|
452
|
+
apply: (r, tag, value) => {
|
|
453
|
+
const field = `tags.${parseStr('filterTag', tag, 'a SAM tag name')}`;
|
|
454
|
+
const values = value
|
|
455
|
+
? [
|
|
456
|
+
...new Set([
|
|
457
|
+
...listedValues((r.snap.filter ?? []).filter(p => p.field === field)),
|
|
458
|
+
value,
|
|
459
|
+
]),
|
|
460
|
+
]
|
|
461
|
+
: [];
|
|
462
|
+
writeFilterFields(r, [
|
|
463
|
+
values.length === 0
|
|
464
|
+
? { field, valid: true }
|
|
465
|
+
: values.length === 1
|
|
466
|
+
? { field, equal: values[0] }
|
|
467
|
+
: { field, oneOf: values },
|
|
468
|
+
]);
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
// the per-base layer over the reads: `color:tag:HP baseColor:methylation`
|
|
472
|
+
baseColor: {
|
|
473
|
+
on: ['alignments'],
|
|
474
|
+
apply: (r, v) => {
|
|
475
|
+
const value = parseEnum('baseColor', v, BASE_COLORS);
|
|
476
|
+
mergeSettings(settingsOf(r), {
|
|
477
|
+
pileup: {
|
|
478
|
+
bases: {
|
|
479
|
+
show: true,
|
|
480
|
+
encoding: {
|
|
481
|
+
color: value === 'methylation' ? 'modifications' : value,
|
|
482
|
+
},
|
|
483
|
+
...(value === 'methylation' ? { unmodified: { show: true } } : {}),
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
// a constant, a read field on alignments, or `strand` on a canvas display;
|
|
490
|
+
// any other field is `encoding.color.field=tags.HP`
|
|
491
|
+
color: {
|
|
492
|
+
on: ALL_CATEGORIES,
|
|
493
|
+
byCategory: true,
|
|
494
|
+
values: 'rest',
|
|
495
|
+
apply: (r, v, v2, category) => {
|
|
496
|
+
const value = parseStr('color', v, 'color scheme or CSS color');
|
|
497
|
+
if (value === 'tag' || value === 'attribute') {
|
|
498
|
+
invalidModifierValue('color', value, `a CSS color or a field name; a field of that name is encoding.color.field=${value === 'tag' ? 'tags.<TAG>' : '<name>'}`);
|
|
499
|
+
}
|
|
500
|
+
else if (v2 !== undefined) {
|
|
501
|
+
throw new Error(strayValueMessage('color', 1, [v2]));
|
|
502
|
+
}
|
|
503
|
+
else if (category === 'alignments') {
|
|
504
|
+
if (BASE_COLOR_NAMES.has(value)) {
|
|
505
|
+
invalidModifierValue('color', value, `a read field; baseColor:${value} draws it`);
|
|
506
|
+
}
|
|
507
|
+
else if (FACET_ONLY_FIELD_SET.has(value)) {
|
|
508
|
+
invalidModifierValue('color', value, `a field color paints; reads keep their arcs' colors, and facet=${value} groups them by ${value}`);
|
|
509
|
+
}
|
|
510
|
+
else if (ALIGNMENTS_COLOR_FIELD_SET.has(value)) {
|
|
511
|
+
colorByField(r, value);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
setColor(r, { value });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else if (category === 'hic') {
|
|
518
|
+
// `memberWrites` writes it as `encoding.color.scale.scheme`, over the
|
|
519
|
+
// track's own color
|
|
520
|
+
if (!COLOR_SCHEMES.includes(value)) {
|
|
521
|
+
invalidModifierValue('color', value, `a color scheme: ${COLOR_SCHEMES.join(', ')}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else if ((category === 'feature' || category === 'variant') &&
|
|
525
|
+
value === 'strand') {
|
|
526
|
+
colorByField(r, 'strand');
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
setColor(r, { value });
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
function strayValueMessage(prefix, count, extra) {
|
|
535
|
+
return `track option "${prefix}:" takes ${count === 1 ? 'one value' : `${count} values`}, so ":${extra.join(':')}" applies to nothing`;
|
|
536
|
+
}
|
|
537
|
+
function modifierArgs(prefix, modifier, values) {
|
|
538
|
+
const [val1 = '', ...after] = values;
|
|
539
|
+
if (modifier.values === 'rest') {
|
|
540
|
+
return [val1, after.length ? after.join(':') : undefined];
|
|
541
|
+
}
|
|
542
|
+
const count = modifier.values ?? 1;
|
|
543
|
+
if (values.length > count) {
|
|
544
|
+
throw new Error(strayValueMessage(prefix, count, values.slice(count)));
|
|
545
|
+
}
|
|
546
|
+
return [val1, after[0]];
|
|
547
|
+
}
|
|
548
|
+
/** The named track modifiers; any other setting is a `path=value` write. */
|
|
549
|
+
export const trackModifierNames = Object.keys(modifiers);
|
|
550
|
+
// An unknown name throws, as a misspelled slot does. A known one aimed at the
|
|
551
|
+
// wrong track type warns, because reusing one modifier list across a mixed set
|
|
552
|
+
// of files is a reasonable thing to script.
|
|
553
|
+
function applyModifier(result, opened, [prefix = '', ...values], warn) {
|
|
554
|
+
const displayType = result.displayType ?? opened;
|
|
555
|
+
const category = displayType ? displayCategory(displayType) : undefined;
|
|
556
|
+
const modifier = lookup(modifiers, prefix);
|
|
557
|
+
if (!modifier) {
|
|
558
|
+
throw new Error(`unknown track option "${prefix}:"; the options are ${trackModifierNames.join(', ')}, or a slot written as path=value`);
|
|
559
|
+
}
|
|
560
|
+
else if (!category && modifier.byCategory) {
|
|
561
|
+
throw new Error(`track option "${prefix}:" writes a different setting on each kind of track, and this app opens the track without knowing which; write the setting as path=value`);
|
|
562
|
+
}
|
|
563
|
+
else if (category && !modifier.on.includes(category)) {
|
|
564
|
+
warn(`track option "${prefix}" has no effect on ${category === 'other' ? 'this track type' : `a ${category} track`} (applies to: ${modifier.on.join(', ')})`);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
const [val1, val2] = modifierArgs(prefix, modifier, values);
|
|
568
|
+
const before = structuredClone(result);
|
|
569
|
+
modifier.apply(result, val1, val2, category);
|
|
570
|
+
const landsOn = result.displayType ?? displayType;
|
|
571
|
+
const index = landsOn ? slotIndexOf(landsOn) : undefined;
|
|
572
|
+
const undeclared = index
|
|
573
|
+
? [
|
|
574
|
+
...changedPaths(before.snap, result.snap),
|
|
575
|
+
...changedPaths(before.sort, result.sort, 'sort'),
|
|
576
|
+
].filter(path => !declares(index, path))
|
|
577
|
+
: [];
|
|
578
|
+
if (undeclared.length > 0) {
|
|
579
|
+
Object.assign(result, before);
|
|
580
|
+
for (const key of ['sort', 'displayType']) {
|
|
581
|
+
if (!(key in before)) {
|
|
582
|
+
delete result[key];
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (!modifier.vacuous) {
|
|
586
|
+
warn(`track option "${prefix}" has no effect on ${landsOn}, which has no ${undeclared.join(', ')} setting`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A track's modifiers read into display settings for `displayType`, the
|
|
593
|
+
* display the track opens as. A named modifier writes only slots that display
|
|
594
|
+
* declares. Without a display type, a modifier whose write depends on it
|
|
595
|
+
* throws, and every other one writes its setting for the display to accept or
|
|
596
|
+
* refuse. A member write is `memberWrites`'s, and `warn` hears of a modifier
|
|
597
|
+
* that does nothing on the display.
|
|
598
|
+
*/
|
|
599
|
+
export function buildDisplaySnapshot(displayType, opts, warn = (message) => {
|
|
600
|
+
console.warn(`Warning: ${message}`);
|
|
601
|
+
}) {
|
|
602
|
+
const result = { snap: {} };
|
|
603
|
+
for (const opt of opts) {
|
|
604
|
+
if (opt.startsWith('{')) {
|
|
605
|
+
const { filter, ...rest } = parseJsonModifier(opt);
|
|
606
|
+
mergeSettings(settingsOf(result), rest);
|
|
607
|
+
if (filter !== undefined) {
|
|
608
|
+
writeFilterFields(result, filterEntriesOf(filter));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
else if (isSlotPathOption(opt)) {
|
|
612
|
+
if (!isMemberWrite(opt)) {
|
|
613
|
+
const write = slotWrite(opt);
|
|
614
|
+
if (write.segments[0] === 'filter') {
|
|
615
|
+
writeFilterFields(result, filterEntriesOf([write.value].flat()));
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
applySlotWrite(settingsOf(result), write);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
applyModifier(result, displayType, opt.split(':'), warn);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Throws for a modifier no kind of track takes: an unknown name, a malformed
|
|
630
|
+
* slot write, or a value each display it applies to refuses. An app that
|
|
631
|
+
* applies the modifiers only once the track is open checks them first, so the
|
|
632
|
+
* command line fails rather than the open.
|
|
633
|
+
*/
|
|
634
|
+
export function checkModifiers(opts) {
|
|
635
|
+
for (const opt of opts) {
|
|
636
|
+
if (isMemberWrite(opt)) {
|
|
637
|
+
slotWrite(opt);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
let refusal;
|
|
641
|
+
const taken = categorizedDisplayTypes.some(displayType => {
|
|
642
|
+
let applies = true;
|
|
643
|
+
try {
|
|
644
|
+
buildDisplaySnapshot(displayType, [opt], () => {
|
|
645
|
+
applies = false;
|
|
646
|
+
});
|
|
647
|
+
return applies;
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
refusal ??= e;
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
if (!taken) {
|
|
655
|
+
throw refusal instanceof Error
|
|
656
|
+
? refusal
|
|
657
|
+
: new Error(`track option "${opt}" applies to no kind of track`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* A track's member writes: each `encoding.color.field=…` modifier, and a hic
|
|
663
|
+
* track's `color:<scheme>`, which names one member of the color channel the
|
|
664
|
+
* track already configures.
|
|
665
|
+
*/
|
|
666
|
+
export function memberWrites(displayType, opts) {
|
|
667
|
+
return opts.flatMap(opt => isMemberWrite(opt)
|
|
668
|
+
? [opt]
|
|
669
|
+
: displayType !== undefined &&
|
|
670
|
+
displayCategory(displayType) === 'hic' &&
|
|
671
|
+
opt.startsWith('color:')
|
|
672
|
+
? [`encoding.color.scale.scheme=${opt.slice('color:'.length)}`]
|
|
673
|
+
: []);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* The patch a track's member writes make: each write applied over the value
|
|
677
|
+
* `configured` gives the setting it names, so the rest of that setting stays.
|
|
678
|
+
*/
|
|
679
|
+
export function memberPatch(members, configured) {
|
|
680
|
+
const writes = members.map(slotWrite);
|
|
681
|
+
const seed = Object.fromEntries([...new Set(writes.map(write => write.segments[0]))].flatMap(setting => {
|
|
682
|
+
const value = configured(setting);
|
|
683
|
+
return value === undefined ? [] : [[setting, value]];
|
|
684
|
+
}));
|
|
685
|
+
return writes.reduce(applySlotWrite, structuredClone(seed));
|
|
686
|
+
}
|