@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** Each display type's config slot paths, three segments deep, a list's members under its name. */
2
+ export declare const displaySlotPaths: Record<string, readonly string[]>;