Configuration schema
JBrowse configuration is built with ConfigurationSchema, a thin wrapper around
MST (@jbrowse/mobx-state-tree) models. Every adapter, track, and display
declares a schema of typed slots; instances are created from config JSON and
observed reactively. Read slots with getConf (from a state model) or
readConfObject (from a raw config node).
Defining a schema
ConfigurationSchema(name, slots, options). BedGraphAdapter is about as small
as a real one gets:
import {
ConfigurationSchema,
expandUriShorthand,
} from '@jbrowse/core/configuration'
import type { Instance } from '@jbrowse/mobx-state-tree'
export function normalizeSnapshot(snap: Record<string, unknown>) {
return expandUriShorthand(snap, 'bedGraphLocation')
}
/**
* #config BedGraphAdapter
* #trackType QuantitativeTrack
* #fileFormat quantitative | BedGraph (plain) | Loaded entirely into memory; for small files
* Reads plain-text bedGraph signal files. The adapter loads the whole file into
* memory, so use `BedGraphTabixAdapter` for large files.
*
* Several signals in one file are a `MultiQuantitativeTrack`'s subtracks:
* one value column each, named by the header, or a `source` column naming each
* row's, which is the form Save track data writes.
*
* #example
* ```js
* {
* type: 'BedGraphAdapter',
* uri: 'https://example.com/signal.bedGraph',
* }
* ```
*/
const BedGraphAdapter = ConfigurationSchema(
'BedGraphAdapter',
{
/**
* #slot
* location of the plain-text bedGraph (`chrom start end value`, one line
* per interval). May be gzipped.
*/
bedGraphLocation: {
type: 'fileLocation',
defaultValue: {
uri: '/path/to/my.bedgraph',
locationType: 'UriLocation',
},
},
/**
* #slot
*/
columnNames: {
type: 'stringArray',
description: 'List of column names',
defaultValue: [],
},
},
{
explicitlyTyped: true,
closed: true,
/**
* #preProcessSnapshot
*
*
* preprocessor to allow minimal config:
* ```json
* {
* "type": "BedGraphAdapter",
* "uri": "yourfile.bed"
* }
* ```
*/
preProcessSnapshot: normalizeSnapshot,
},
)
export type BedGraphAdapterConfig = Instance<typeof BedGraphAdapter>
export default BedGraphAdapterThe name must match the type field in config JSON, and explicitlyTyped: true
requires that field to be present. Each slot becomes an observable MST property.
A display's schema is closed without saying so, and an adapter's says
closed: true: a config carrying a key the schema does not declare loads
without it and names the key on the console, and a write of one through
setConf throws. An adapter's preProcessSnapshot therefore strips the
shorthand keys it consumed, which fillLocations does for uri and baseUri.
A key a shipped release declared and a later one dropped goes in the schema's
retired map instead, lifted onto the slot that took it over or dropped with
() => ({}), so a config written for that release loads in silence and
jbrowse validate reports a dropped key as never read.
The Instance<typeof …> export at the bottom is how the rest of the codebase
gets a typed handle on the schema — it is the CONF in
BaseFeatureDataAdapter<BedGraphAdapterConfig>, which types this.getConf(...)
reads. Export one from every schema you write.
The #config, #slot, and #preProcessSnapshot JSDoc tags generate the
config reference pages; they are not part of the schema API.
Slot types
A slot's type is one of a closed set. Everything downstream keys off the name:
the MST type the value is built from, what a getConf read of it is typed as,
and which control the configuration editor renders for it. Because the system is
typed, a slot can be edited graphically without an author writing any UI.
type | MST model | Reads as | Config editor renders |
|---|---|---|---|
boolean | types.boolean | boolean | checkbox |
maybeBoolean | types.maybe(types.boolean) | boolean | undefined | checkbox |
color | CssColorType | string | text field beside a swatch that opens a color picker |
maybeColor | types.maybe(CssColorType) | string | undefined | text field beside a swatch that opens a color picker |
colorArray | types.array(CssColorEntryType) | string[] | a text field and color picker per entry, with add and delete |
expressionArray | types.array(JexlExpressionString) | string[] | "todolist" of text fields, each after a fixed jexl: |
featureField | types.string | string | single-line text field |
fileLocation | FileLocation | FileLocation | URL, local file path (desktop) or file blob (browser) |
maybeFileLocation | MaybeFileLocation | FileLocation | undefined | URL, local file path (desktop) or file blob (browser) |
frozen | types.frozen() | any | monospace textarea holding arbitrary JSON |
maybeFrozen | types.maybe(types.frozen()) | any | monospace textarea holding arbitrary JSON |
integer | types.integer | number | numeric text field that rounds to an integer |
number | types.number | number | numeric text field |
maybeNumber | types.maybe(types.number) | number | undefined | numeric text field |
numberMap | types.map(types.number) | Record<string, number> | one card per key, each holding that key's numeric field |
string | types.string | string | single-line text field |
maybeString | types.maybe(types.string) | string | undefined | single-line text field |
stringArray | types.array(types.string) | string[] | "todolist" of text fields, one per entry, with add and delete |
stringArrayMap | types.map(types.array(types.string)) | Record<string, string[]> | one card per key, each holding that key's "todolist" of strings |
stringEnum | the model the slot declares | the model enumeration's members | dropdown of the model's members |
maybeStringEnum | the model the slot declares | the model enumeration's members | dropdown of the model's members |
stringEnumArray | the model the slot declares | a list of the model enumeration's members | a dropdown of the model's members per entry, with add and delete |
stringMap | types.map(types.string) | Record<string, string> | one card per key, each holding that key's text field |
text | types.string | string | multi-line textarea |
A name outside this set is rejected at schema construction. That check exists
because such a slot otherwise still works — the value round-trips as long as
you supply a model — and the only symptom is that everything keyed off the
type name stops recognising it.
The maybe* forms are undefined while unset. Unset is the one state no config
can spell, so it stays distinguishable from every real value a user might write,
and a display reads it as "decide this from the data" — a MAF track's height
fits its rows when nobody has fixed one, and a feature color left unset lets
the feature's own BED color through.
Because unset is their default, maybe* slots omit defaultValue — every
other type must declare one, and leaving it off is a type error. Writing
defaultValue: undefined on a maybe* slot is legal but usually adds nothing.
The exception is a maybe* slot overriding a base slot that has a concrete
default: the override merges field-by-field, so omitting defaultValue
inherits the base's value and the slot is never unset. State
defaultValue: undefined there to overwrite it.
frozen and maybeFrozen hold arbitrary JSON. The value is not deeply
reactive, and reads are typed any — the structure is the caller's to assert.
For enums, use type: 'stringEnum' and add a model field. The value scale's
type member:
type: {
type: 'stringEnum',
model: types.enumeration('ValueScaleType', [...VALUE_SCALE_TYPES]),
defaultValue: 'linear',
description: VALUE_SCALE_TYPES.join(' or '),
},stringEnum (and maybeStringEnum) are the only types the config editor reads
the model's choices from, so a slot typed anything else renders as a free text
input however valid its model is. A maybeStringEnum dropdown has a leading
"default" entry above the members, which is how the unset state is both shown
and set.
Schema inheritance with baseConfiguration
Displays inherit base display slots by passing baseConfiguration in the
options argument. LinearMafDisplay declares its own slots and takes the rest
from the base linear display schema:
{
/**
* #baseConfiguration
*/
baseConfiguration: baseLinearDisplayConfigSchema,
explicitlyTyped: true,
},fillLocations settles precedence: the derived locations fill in the slots, and
any slot the config spells out for itself wins. So a config naming both a uri
and an index that does not sit next to the data file keeps the index it named.
Passed as preProcessSnapshot: normalizeSnapshot in the schema's options, that
allows minimal configs in config.json — neither bamLocation nor index is
written out:
Goes in the tracks array of config.json. See Tracks.
{
"type": "AlignmentsTrack",
"trackId": "my_alignments_track",
"name": "My Alignments",
"assemblyNames": ["hg19"],
"adapter": {
"type": "BamAdapter",
"uri": "https://yourhost/file.bam"
}
}jbrowse add-track https://yourhost/file.bam \
--trackId my_alignments_track \
--name "My Alignments" \
--assemblyNames hg19In JBrowse Desktop, or in any running JBrowse Web session, open a view on this track’s assembly, then File → Open track... and, in Add a track from file or URL, enter:
- Main file:
https://yourhost/file.bam
Click Next. JBrowse reads the adapter and track type off the file name. Then fill in:
- Track name:
My Alignments - Assembly:
hg19
Click Add.
preProcessSnapshot also runs on track configs to inject missing display stubs
for each display type the track supports.
Reading config values
Which reader you want follows from what you are holding and what the slot is. The full signatures are in the configuration API reference.
Use getConf when you hold a state model that has a .configuration member
(a track model, display model, etc.) — LinearMarkDisplay's origin getter is
getConf(self, 'origin'), and its typed conf getter, in a block of its own,
is what every later block reads a sub-schema through:
.views(self => ({
/**
* #getter
* the config typed off the concrete schema
*/
get conf(): LinearMarkDisplayConfig {
return self.configuration
},
}))Use readConfObject when you hold a config node itself, such as a
sub-config you resolved yourself. An entry of session.tracks is not one: the
session holds each track config as the frozen plain object it was written as, a
TrackConfigEntry, which omits a slot at its default, so readConfObject
refuses one at compile time. Read its trackId and type directly, and any
other slot with its default supplied, such as getConfAssemblyNamesOrNone for
its assemblies. The multi-wiggle "combine selected tracks" menu item works on
the track selector's selection, which holds those entries; its stackAssemblies
reads each entry's assemblies that way:
// `tracks` are the selection's track configs, mostly frozen entries
// that omit a slot at its default, so each read supplies the default
assemblyNames: stackAssemblies(tracks, session.assemblyManager)
.assemblyNames,
adapter: {
subadapters: tracks.map(toTrackConfigEntry).map(c => ({
...(c.adapter as Record<string, unknown>),
source: (c.name as string | undefined) ?? '',
})),
},A TypeScript error "Property 'configuration' is missing" is the signal that you
hold a config rather than a model: readConfObject for a node, and the reads
above for a TrackConfigEntry.
getConf and readConfObject both accept a path array for nested access —
getConf(self, ['adapter', 'sequenceAdapter']), or the adapter form shown under
configuration internals below.
Writes go through setConf, not a bare
self.configuration.setSlot('x', v). setConf constrains the slot name against
the schema the same way getConf does, so on a model whose schema is concrete a
typo is a compile error. setSlot takes a plain string and cannot do that; it
throws at runtime instead, naming the slots the schema does declare. That
runtime check is the backstop for the writes the compile-time one cannot see —
anything through a mixin or a widened factory, where the concrete schema is
erased.
ConfigurationReference
State models refer to their config via ConfigurationReference, alongside the
type literal that discriminates them. This is one argument of the model's
types.compose(...) chain, which is where the model gets its name:
types.model({
type: types.literal('LinearMarkDisplay'),
/**
* #property
*/
configuration: ConfigurationReference(configSchema),ConfigurationReference is a union of a string ID reference and the full config
snapshot. At runtime it resolves to the MST config node, either by looking up
the ID in the session's config registry, or by hydrating the inline snapshot.
The resolution dispatch is based on explicitIdentifier in the schema options:
'trackId'→TrackConfigurationReference(resolves throughsession.getTrackById(id))'displayId'→DisplayConfigurationReference- anything else → plain reference
ConfigurationReference passes the schema through to self.configuration, so
getConf(self, slot) and readConfObject(self.configuration, slot) check the
slot name and return its real value type — but only when the schema is
concrete. Type the state model factory's configSchema parameter to the
schema's own type, not to AnyConfigurationSchemaType, or the reads degrade to
any with no compile error to say so. A conf getter typed off the concrete
schema (get conf(): LinearMarkDisplayConfig) is still the tidy way to name it
once, the same move as BaseAdapter<CONF>, but it no longer provides the
checking.
Frozen track hydration
jbrowse.tracks is stored as types.frozen (plain JS objects) for performance
with thousands of tracks. Track configs become MST nodes lazily, only when a
track is opened and TrackConfigurationReference.get() is called. The hydrated
node is cached on the PluginManager: MST's custom-reference get() memoizes
nothing, so without that cache every read of track.configuration would
fabricate a fresh non-identical node.
The cache is why session.getTrackById(id) hands back a plain object for a
track nobody has opened: access it with readConfObject, not getConf. (There
is a getTracksById() returning the whole map, but it is deprecated — reading
it subscribes the caller to every track, so an edit to any one of them wakes
it.)
Config callbacks (jexl)
A slot takes a callback in place of a plain value where it declares
contextVariable, the arguments the callback reads; the calling code supplies
them as the third argument to readConfObject. The mark display's color object
declares one on its value:
/**
* #slot value
* A CSS color, or a jexl callback over `feature` returning one, for a
* mark whose color is not a scale. Unset, a mark paints in the default
* blue, `#0068d1`, and a text mark prints in the page's text color.
*/
value: {
type: 'maybeColor',
description: 'CSS color or jexl callback; unset is the default blue',
contextVariable: ['feature'],
},The multi-way synteny display reads its gene utrColor that way, once per
feature and memoized, and not at all when the slot holds a plain color:
// the slot as written, not resolved: a jexl color read without a feature
// evaluates against an empty context and hands back the fallout (adr-066)
const utrConstant = isJexl(utrColor)
? undefined
: cssColorToABGR(String(utrColor))
const utr = (feature: Feature) =>
utrConstant ??
memo(utrByFeature, feature.id(), () =>
cssColorToABGR(String(readConfObject(conf, 'utrColor', { feature }))),
)getConf takes the context object in the same third position. Evaluate these
once per feature when the features or config change, not per frame: panning only
moves pixels, and re-running a jexl expression per feature per frame is the
usual cause of a display that scrolls badly.
The context object is readConfObject's and getConf's optional third
argument, so "what is this setting" and "what is this setting for this feature"
are the same call with and without it. On a slot holding a jexl: value the
arg-less form still evaluates, against a context where every name the expression
mentions is undefined, and hands back the fallout as the setting. Nothing
throws at the reader, and the two ways it goes wrong look nothing alike:
- the expression touches a member of the missing value (
get(feature,…)) and throws out of whatever getter did the read, which shows up as the display erroring; - every function in it is total (
split(feature.name,…)), and a plausible wrong value comes back —'',NaN— and travels on as a real setting.
A value going into rpcProps(), a renderer, or a worker is one something
downstream will still bind a feature to. Read it raw (self.conf.someSlot),
not through a reader. A value used on the main thread here and now, such as a
swatch, a menu label, or arithmetic, is a resolving read instead, and needs
either a feature in the third argument or an isJexl guard and a fallback,
since no single swatch can show a per-feature expression.
Callbacks are written in jexl. For example,
a VariantTrack can color SNVs green and everything else purple:
Goes in the tracks array of config.json. See Tracks.
{
"type": "VariantTrack",
"trackId": "my_variant_track",
"name": "Variants colored by type",
"assemblyNames": ["hg19"],
"adapter": {
"type": "VcfTabixAdapter",
"uri": "https://yourhost/file.vcf.gz"
},
"displayDefaults": {
"color": "jexl:feature.type=='SNV'?'green':'purple'"
}
}jbrowse add-track https://yourhost/file.vcf.gz \
--trackId my_variant_track \
--name "Variants colored by type" \
--assemblyNames hg19 \
--displayDefaults "{\"color\":\"jexl:feature.type=='SNV'?'green':'purple'\"}"In JBrowse Desktop, or in any running JBrowse Web session, open a view on this track’s assembly, then File → Open track..., choose Add track from pasted JSON, and paste:
{
"type": "VariantTrack",
"trackId": "my_variant_track",
"name": "Variants colored by type",
"assemblyNames": ["hg19"],
"adapter": {
"type": "VcfTabixAdapter",
"uri": "https://yourhost/file.vcf.gz"
},
"displayDefaults": {
"color": "jexl:feature.type=='SNV'?'green':'purple'"
}
}A callback may call custom jexl functions your plugin registers with
pluginManager.jexl.addFunction (see Writing a no-build plugin
for a worked example). The jexl config guide covers
the expression language itself.
A slot that declares no contextVariable refuses a jexl: string: loading a
config, setSlot, the config editor and the generated JSON Schema each name the
slot. A callback the code evaluates is one the schema says it takes, so a
missing declaration shows up the first time a config writes one.
A slot naming a field the display reads per feature, such as a color or facet
field, is a featureField rather than a callback. It takes a field name, a
dotted path, or a jexl: expression deriving the value, and the display
evaluates that expression per feature, so readConfObject and getConf hand it
over as written.
Configuration internals
A configuration is a @jbrowse/mobx-state-tree model tree: leaf nodes are
config-slot types and inner nodes are ConfigurationSchema types. All
configurations descend from a single root, root.configuration.
Schema
/ | \
Slot Schema Slot
| \
Slot SlotA schema can nest a sub-schema as a slot. BamAdapter embeds its index config
that way:
/**
* #slot
* location of the BAM file. Per-base mismatches come from the record's MD
* tag when it has one, and are otherwise computed against the assembly's
* reference sequence.
*/
bamLocation: {
type: 'fileLocation',
defaultValue: {
uri: '/path/to/my.bam',
locationType: 'UriLocation',
},
},
/**
* #slot
* The BAM index location and its kind. The `uri` shorthand derives
* both, so a config using it states neither.
*/
index: bamIndexSchema(),Read a nested slot with a path array. From inside an adapter that is
BaseAdapter<CONF>, the read goes through this.getConf:
// a path array reaches into the nested `index` sub-schema; reading
// `getConf('index').indexType` instead would bypass default resolution
const csi = this.getConf(['index', 'indexType']) === 'CSI'
const location = this.getConf(['index', 'location'])Avoid reading properties directly off the result (e.g.
readConfObject(config, ['index']).indexType), which bypasses default-value
resolution.
See also
- Developer guide: MST patterns
- Developer guide: Custom track and display types
- CONFIG_PATTERN.md — the whole path a display's config takes to reach a renderer: config to MST snapshot to plain object to RPC payload, and what each hop may hold