Extension points
Extension points are named callback chains. A producer fires one with
evaluateExtensionPoint; plugins register callbacks with addToExtensionPoint,
each receiving the previous callback's return value. A point that accumulates a
list takes contributeToExtensionPoint, whose callback returns the entries to
add. A point that only notifies takes listenToExtensionPoint, whose callback
returns nothing.
Using extension points
A producer fires a point:
const ret = pluginManager.evaluateExtensionPoint('ExtensionPointName', {
value: 1,
})Consumers register callbacks against the same point (multiple plugins can each register one):
pluginManager.addToExtensionPoint(
'ExtensionPointName',
(arg: { value: number }) => {
return { value: arg.value + 1 }
},
)Callbacks are chained: each receives the previous one's return value, so with
two such callbacks registered ret is {value:3}, as the test both snippets
come from asserts.
TypeScript types for extension points
Built-in points are registered in the ExtensionPointRegistry interface in
@jbrowse/core/PluginManager. addToExtensionPoint / evaluateExtensionPoint
/ evaluateAsyncExtensionPoint narrow to the registered types when you pass a
known name, so callbacks get typed args and evaluate calls return the correct
type without a cast.
Register your own point the same way the spreadsheet view's launcher does, with an exported args interface and then the augmentation naming it:
export interface LaunchSpreadsheetViewArgs
extends
Omit<
SnapshotIn<SpreadsheetViewStateModel>,
| 'type'
| 'init'
| 'launch'
| 'spreadsheet'
| 'importWizard'
| keyof SpreadsheetViewCommands
>,
SpreadsheetViewCommands {
session: AbstractViewContainer
}
declare module '@jbrowse/core/PluginManager' {
interface ExtensionPointRegistry {
'LaunchView-SpreadsheetView': {
args: LaunchSpreadsheetViewArgs
result: LaunchSpreadsheetViewArgs
}
}
}Three things generalize from it:
argsandresultare the same type on a point that threads one payload through, and a callback returns what it was given. A point that accumulates declares an array for both.props, the third and optional key, declares the context object passed unchanged to every callback; seeCore-replaceWidgetfor one that has it.- The
declare moduleblock goes in any file that is part of your plugin's compilation; putting it beside the registration keeps the two consistent.
Points that resolve to UI
A point whose value is a component or an element declares one of three shapes,
so a seam is one line:
'Core-extraFeaturePanel': ComponentList<FeaturePanelProps> is the whole
declaration. The shape also determines which producer renders the point and
which helper registers on it:
/**
* A point that resolves to one component — a slot with a default, which a
* plugin wraps or replaces. Declared as
* `'Core-replaceWidget': ComponentSlot<ReplaceWidgetProps>`, produced by
* {@link PluggableComponent}, registered on with `wrapComponent`.
*/
export interface ComponentSlot<P> {
args: ComponentType<P>
result: ComponentType<P>
props: P
/**
* Type-only: never present at runtime, and what makes the shape *declared*
* rather than guessed. Reading it off the value cannot work — a
* `TrackTypeGuesser` takes an argument and returns a string, which is also
* what a function component does, so a structural test admits it as a slot.
*/
kind: 'componentSlot'
}
/**
* A point that accumulates an array of components — the panel points. Produced
* by {@link PluggableComponents}, registered on with
* {@link PluginManager.contributeToExtensionPoint}; each panel scopes itself
* and draws its own chrome.
*/
export interface ComponentList<P> {
args: ComponentType<P>[]
result: ComponentType<P>[]
props: P
/** type-only, see {@link ComponentSlot.kind} */
kind: 'componentList'
}
/**
* A point that accumulates already-rendered elements — the overlay points.
* Produced by {@link PluggableElements}, registered on with
* `addExtensionElement`, which fixes the React key at registration time.
*/
export interface ElementList<P> {
args: ReactNode[]
result: ReactNode[]
props: P
/** type-only, see {@link ComponentSlot.kind} */
kind: 'elementList'
}args alone does not identify a point's shape. A TrackTypeGuesser takes an
argument and returns a string, the same signature a function component has, so a
structural test admits it as a wrappable slot. kind exists only in the type
and is absent at runtime. extensionPointShapes.test.ts pins each seam against
the points of the other shapes, and fails a new ComponentList or ElementList
declared the long way, which would otherwise be a point no producer accepts.
API
// props is an extra context object your callbacks receive, required for the
// points that declare one and omitted for the points that don't
pluginManager.evaluateExtensionPoint(extensionPointName, args, props)
pluginManager.evaluateAsyncExtensionPoint(extensionPointName, args, props)
pluginManager.addToExtensionPoint(extensionPointName, args => {
return newArgs // passed as args to the next callback in the chain
})
// points whose args are an array take contributions instead, and never hand
// you the array — see "Accumulating points" below
pluginManager.contributeToExtensionPoint(extensionPointName, props => {
return myEntry // or [myEntry, ...], or undefined to contribute nothing
})
// points whose args are `undefined` carry everything in props and read nothing
// back — see "Notification points" below
pluginManager.listenToExtensionPoint(extensionPointName, props => {
react(props) // returns nothing; an async callback's promise reaches the producer
})
// a producer whose point resolves to UI fires it as JSX instead. One component
// per shape — see "Firing a point that renders" below
<PluggableComponent name={extensionPointName} component={Default} props={props} pluginManager={pluginManager} />
<PluggableComponents name={extensionPointName} props={props} pluginManager={pluginManager} />
<PluggableElements name={extensionPointName} props={props} pluginManager={pluginManager} />Only addToExtensionPoint threads a value: each callback's return value becomes
the next callback's args. props is passed unchanged to every callback
whichever method registered it, and any of the three creates the point if it
doesn't exist yet. Each rejects the points the other two own, so the method you
can call is the one the point's shape calls for.
There is a fourth fire method, evaluateAsyncExtensionPointStrict, which is
evaluateAsyncExtensionPoint without the swallow-and-continue: a callback that
throws propagates to the producer instead of being logged. Every LaunchView-
point is fired that way, so a launcher that throws reaches the user as an error.
Producers of accumulating points want the plain runner, where one plugin failing
does not cost the others their entries.
The API block above holds the only signatures on this page that are not generated from source. It uses placeholder argument names so the seven read side by side.
Registering on a point
Accumulating points
Register on a list point with contributeToExtensionPoint. The callback
gets the props and returns what to add — one entry, an array, or undefined for
none. The GC content plugin, adding an item to the reference sequence track's
menu:
import { addExtraTrackMenuItems } from '@jbrowse/core/ui/buildExtraTrackMenuItems'
import {
addAndShowTrack,
isSessionWithAddSessionTrack,
makeTrackId,
} from '@jbrowse/core/util'
import { getConfAssemblyNames } from '@jbrowse/core/util/tracks'
import type PluginManager from '@jbrowse/core/PluginManager'
/**
* Adds "Add GC content track" to the reference sequence track's menu, in the
* track selector and under the in-view menu's Track actions. The new track
* reads the sequence of the assembly it is shown against.
*/
export default function GCContentExtraTrackMenuItemsF(
pluginManager: PluginManager,
) {
addExtraTrackMenuItems(pluginManager, ({ session, config, view }) =>
config.type === 'ReferenceSequenceTrack' &&
isSessionWithAddSessionTrack(session)
? {
label: 'Add GC content track',
onClick: () => {
const name = 'GC content'
addAndShowTrack(
session,
{
trackId: makeTrackId({ name }),
type: 'GCContentTrack',
name,
assemblyNames: getConfAssemblyNames(config),
adapter: { type: 'GCContentAdapter' },
},
view,
)
},
}
: undefined,
)
}The concatenation happens once inside the plugin manager, and a callback never
sees the accumulated array, so no plugin can drop another plugin's entries.
addToExtensionPoint covers the points that thread a single value, and
type-errors on a list point.
For list points that accumulate rendered elements, register with
addExtensionElement, which fixes the React key for you:
import { addExtensionElement } from '@jbrowse/core/ui'
import SequenceFeatureHoverHighlight from './SequenceFeatureHoverHighlight.tsx'
import type PluginManager from '@jbrowse/core/PluginManager'
export default function SequenceFeatureHoverHighlightExtensionF(
pluginManager: PluginManager,
) {
addExtensionElement(
pluginManager,
'LinearGenomeView-TracksContainerComponent',
SequenceFeatureHoverHighlight,
)
}wrapComponent: filling a component slot
A point declared ComponentSlot resolves to one
component, and a plugin fills it with wrapComponent, from @jbrowse/core/ui.
Your component is handed whatever fills the slot so far as DefaultComponent:
render it and you have added to the default, leave it out and you have replaced
it. There is no separate "replace" call, and writing it this way lets the next
plugin still wrap yours.
wrapComponent(pm, 'Core-replaceWidget', ({ DefaultComponent, ...rest }) => (
<div>
<div>custom</div>
<DefaultComponent {...rest} />
</div>
))wrapComponent takes the point's name, so the same call fills any
single-component slot: Core-replaceAbout and the desktop start-screen panels
work exactly like this.
Wrappers from different plugins nest, so two plugins can both add content without either one disappearing. Two callbacks registered on the point by hand cannot, and JBrowse logs a warning naming the slot when that happens.
matchesTrackSelector: which tracks a contribution is for
Every track-scoped point fires for every track, so a contribution with no track
selector applies to all of them: a wrapper takes over every widget that opens,
and a panel appears on every feature. matchesTrackSelector, also from
@jbrowse/core/ui, answers whether the props you were handed belong to a track
select names. What you do with the answer depends on the point:
- a wrapper renders the component it was handed
- a panel renders
null - a data transform returns its argument unchanged
function scopedToOneTrack(pm: PluginManager) {
wrapComponent(pm, 'Core-replaceWidget', ({ DefaultComponent, ...rest }) =>
matchesTrackSelector({ trackId: 'volvox.inv.vcf' }, rest) ? (
<div>mine</div>
) : (
<DefaultComponent {...rest} />
),
)
}select accepts any combination of the fields below, and all of the ones you
give must match. An empty selector matches everything.
export interface TrackSelector {
/** track type, e.g. `'VariantTrack'`, usually what "for my tracks" means */
trackType?: string | string[]
/** track id; a plain string also matches the user's copies of that track */
trackId?: string | RegExp | (string | RegExp)[]
/** widget model type, e.g. `'AlignmentsFeatureWidget'`, for the slot points */
widgetType?: string | string[]
}Prefer trackType when what you mean is "my kind of track". A plain-string
trackId also matches the user's copies of that track (the "Copy track" menu
item appends a timestamp to the id), so scoping by id keeps applying after the
first time someone copies the track. Pass a RegExp if you want to control the
matching yourself.
matchesTrackSelector reads either the widget model these points provide or the
track config the About points provide, so one call scopes a contribution to any
of them. Anything the fields cannot express joins the same condition; the panel
below adds depth to it.
Don't reach for matchTrackId from @jbrowse/core/util. It tests an id against
patterns you supply, so you have to handle the copy-track normalization
yourself.
Matching is on the model because the config that produced a feature details widget isn't always retrievable.
pm.addToExtensionPoint('Core-replaceWidget', Default => {
// declared inside the callback, so every evaluation is a new component type
return function NewWidget(props: ReplaceWidgetProps) {
return (
<div>
<div>custom</div>
<Default {...props} />
</div>
)
}
})Use wrapComponent instead, which
builds the wrapped component once and caches it.
Core-replaceWidget is re-evaluated on every render of the drawer, so returning
a component declared inside the callback hands React a brand new component type
each time. React unmounts and remounts the widget inside, which loses its scroll
position, any text typed into it, and any panel the user had expanded.
Firing a point that renders
A producer whose point resolves to UI fires it as JSX from @jbrowse/core/ui.
There is one component per shape, and the API block above shows all three:
PluggableComponentfor asinglepoint, wherecomponentis the default the slot resolves to when no plugin claims it.PluggableComponentsfor alistpoint whose entries are components — the panel points. It renders each one with the point's props, inside a<Suspense>for each panel, so a panel can beReact.lazy.PluggableElementsfor alistpoint whose entries are already-renderedReactNodes — the overlay points.
All three components are observers, so a contributor that scopes itself on an observable is re-evaluated when that observable changes. A point's registry entry decides which component the point takes: each component accepts only the points of its shape, so pointing one at another shape's point is a compile error.
A point fired this way has no string-literal call site, so its #extensionPoint
docs tag goes on its ExtensionPointRegistry entry instead.
Notification points
A point declaring args: undefined has its whole payload in props and reads
nothing back. Register on it with listenToExtensionPoint, whose callback
returns nothing:
pluginManager.listenToExtensionPoint(
'LinearGenomeView-searchResultSelected',
({ result, model, assemblyName }) => {
highlightSearchResultFeature({ result, model, assemblyName })
},
)Every callback registered on a notification point runs, since there is no value
for a later one to overwrite; the listing below marks these points notify.
addToExtensionPoint rejects these names because of the promise handling. An
async callback's promise is the point's completion signal, and two of them are
joined, so a producer waiting on the folded value can tell when every
handler has finished. A hand-written callback that returns a promise breaks this
join and raises no error: the producer stops waiting early, which looks like a
race.
Core-handleUnrecognizedAssembly is a notification point of this kind: a
handler supplies the assembly out of band, and its promise lets
waitForAssembly stop waiting without a timeout. The producer there fires the
point with the sync runner and awaits the folded value itself; only
evaluateAsyncExtensionPoint awaits each callback in turn.
Extension point listing
Generated from the #extensionPoint tags at each point's fire/registration
site. The detailed sections that follow are hand-written.
The Shape column shows what happens when a second plugin registers on the
same point, and the generator reads it off the point's ExtensionPointRegistry
entry:
listaccumulates, so every plugin's contribution survives; register withcontributeToExtensionPoint.notifyhas no value at all, so every plugin's callback runs; register withlistenToExtensionPoint.singlethreads one value along, so each callback overwrites what the one before it returned and only the last plugin to register is visible; register withaddToExtensionPoint.ComponentSlot,ComponentListandElementListare the UI shapes, each with its own registering helper; see Points that resolve to UI.
The names don't show this: Desktop-StartScreenMenuItems accumulates and
Desktop-StartScreenLaunchPanel does not. Check the Shape column before
registering — a single point is a slot, and taking it hides whatever the
plugin before you put there.
| Extension point | Type | Shape | Description |
|---|---|---|---|
Core-addTrackComponent | sync | ComponentSlot | Inject a custom React component into the add-track widget |
Core-addTrackComponentAdapterTypes | sync | list | Adapter types whose add-track picker supplies the assembly |
Core-describeAssemblies | async | single | Describe, in one batch, assemblies absent from the session: each one's assembly config and gene adapter, read without connecting anything. Each callback adds to the descriptions the one before it returned |
Core-extendPluggableElement | sync | single | Mutate any pluggable element after it is created |
Core-extendSession | sync | single | Extend the session model with extra state or actions |
Core-extendWorker | sync | single | Take a booted RPC web worker: subscribe to the events it emits, post to it, or wrap its call. Fired once per booted worker, not per call |
Core-extraAboutPanel | sync | ComponentList | Add extra panels to a track's About dialog |
Core-extraFeaturePanel | sync | ComponentList | Add extra panels to the feature details widget |
Core-extraTrackMenuItems | sync | list | Add items to a single track's menu |
Core-guessAdapterForLocation | sync | single | Guess an adapter config from a file location |
Core-guessTrackTypeForLocation | sync | single | Guess a track type from a file location |
Core-handleUnrecognizedAssembly | sync | notify | Supply an assembly config when a referenced assembly is unknown. May return a promise settling when the handler has finished trying, so waitForAssembly can stop waiting without a timeout |
Core-preferencesDialogPanels | sync | list | Add tabs to the preferences dialog |
Core-preProcessTrackConfig | sync | single | Rewrite a track config snapshot before it is instantiated |
Core-replaceAbout | sync | ComponentSlot | Replace or wrap a track's About dialog body |
Core-replaceWidget | sync | ComponentSlot | Replace or wrap the component that renders a widget |
Desktop-StartScreenLaunchPanel | sync | ComponentSlot | Replace or wrap the "Launch new session" panel |
Desktop-StartScreenMenuItems | sync | list | Add items to the start screen menu |
Desktop-StartScreenRecentSessionsPanel | sync | ComponentSlot | Replace or wrap the recent sessions panel |
DotplotView-OverlayHTMLComponent | sync | ElementList | Add an HTML overlay component to the dotplot view |
DotplotView-OverlaySVGComponent | sync | ElementList | Add an SVG overlay component to the dotplot view |
LaunchView-BreakpointSplitView | async | single | Programmatically launch a breakpoint split view |
LaunchView-CircularView | async | single | Programmatically launch a circular view |
LaunchView-DotplotView | async | single | Programmatically launch a dotplot view |
LaunchView-LinearGenomeView | async | single | Programmatically launch a linear genome view |
LaunchView-LinearSyntenyView | async | single | Programmatically launch a linear synteny view |
LaunchView-SpreadsheetView | async | single | Programmatically launch a spreadsheet view |
LaunchView-SvInspectorView | async | single | Programmatically launch the SV inspector view |
LinearGenomeView-HighlightSVGComponent | sync | ElementList | Add an SVG highlight overlay in the LGV SVG export |
LinearGenomeView-OverviewScalebarComponent | sync | ElementList | Add a component to the overview scalebar |
LinearGenomeView-ScalebarHighlightComponent | sync | ElementList | Add a highlight component to the scalebar |
LinearGenomeView-searchResultSelected | async | notify | Invoked when a search result is selected |
LinearGenomeView-TracksContainerComponent | sync | ElementList | Add a component into the LGV tracks container |
SyntenyImportForm-FileFormats | sync | list | Add synteny file formats to the "New track" panel of every synteny import form |
SyntenyImportForm-Options | sync | list | Add track options beside None / Existing track / New track in every synteny import form |
TrackSelector-folderDialog | sync | ComponentSlot | Replace the dialog shown when a folder category is clicked |
TrackSelector-multiTrackMenuItems | sync | list | Add items to the multi-track (shopping cart) menu |
Core-extendPluggableElement
type: synchronous
args-PluggableElementType- the pluggable element being installedprops-{ group }, which of the ten kinds it is ('view','display','adapter', …)
Add functionality to pluggable elements, e.g. extra right-click context menus. Your callback receives every pluggable element registered to the system, so it must select the one it means.
Most view and display types register their state model lazily. For those the
point fires when the state model loads rather than at startup, so
elt.stateModel = extend(elt.stateModel) in the callback sees a loaded model
and works as it did in v4. A callback that instead changes something the host
reads before any model loads, such as a display's configSchema, runs too late
for a lazy element.
For the two common cases use extendViewType / extendDisplayType, which check
the group and look the name up in a registry:
extendViewType(pluginManager, 'LinearGenomeView', stateModel =>
types.compose(
stateModel,
types.model().actions(() => ({
zoomTo: () => {},
scrollTo: () => {},
})),
),
)stateModel arrives typed, so self is typed through the rest of the chain
with no as. The name is checked too: register your view or display in the
registry beside its state model type, and a typo or a rename becomes a compile
error, where an unchecked name would leave the extension unapplied with no
error.
extendViewType and extendDisplayType take either one name or an array of
names. An array lets a contribution reach a family of displays, which share a
mixin set and have no parent type to name.
To add an entry to somebody else's menu, the commonest reason to extend a type,
use addViewMenuItems / addDisplayMenuItems, which handle the super-capture
and the concatenation. See
Adding track context-menu items.
Extend the state model when the contribution needs new state or actions, so the
menu items and what they call stay in one place.
declare module '@jbrowse/core/PluginManager' {
interface ViewTypeRegistry {
LinearGenomeView: LinearGenomeViewStateModel
}
}See Menus for a worked example adding track context-menu items.
Core-guessAdapterForLocation
type: synchronous
args-AdapterGuesser- the guesser accumulated so far
Infer an adapter type from a location in the "Add track" workflow. See the add track workflow guide.
The formats JBrowse ships with are rows in @jbrowse/add-track-core's table
(filename regex, adapter type, location field, index layout, track type), which
CorePlugin guesses from and @jbrowse/cli's add-track reads too, so a file
resolves to the same adapter config in the app and on the command line. Core
guesses a row only when pluginManager.hasAdapterType returns true for that
adapter, so a build without the alignments plugin guesses nothing for a .bam.
Register on this point to add a format the table does not describe.
Use addAdapterGuesser rather than calling addToExtensionPoint directly:
these two points are chains of responsibility, where each callback wraps the
previously registered guesser and delegates to it when it has no match. The
helper does that wiring, so your callback just returns a config when it
recognizes the file and undefined when it doesn't. Delegating by hand risks
dropping the optional file argument on the way through, which hides it from
every guesser registered before yours.
The chain is first-match-wins, so it returns exactly one adapter and cannot express "or this other one". Where two adapters read the same extension, the one the chain does not pick declares that in its adapter registration, and the "Add track" form offers it alongside the guess:
export default function MultiGenomePAFAdapterF(pluginManager: PluginManager) {
pluginManager.addAdapterType(
() =>
new AdapterType({
name: 'MultiGenomePAFAdapter',
displayName: 'Multi-genome PAF adapter',
configSchema,
adapterMetadata: {
category: 'Synteny adapters',
// a .paf is claimed by PAFAdapter, and a multi-genome one is
// indistinguishable by name; read as pairwise it attributes one
// genome's contigs to another rather than merely dropping them
alsoReads: /\.paf(\.gz)?$/i,
},
getAdapterClass: () =>
import('./MultiGenomePAFAdapter.ts').then(r => r.default),
}),
)
}alsoReads is a form hint only. It does not enter this extension point, so a
file still resolves to one adapter headlessly, from the CLI, and in every
existing guesser. It also covers an extension no guesser claims at all, where
the file resolves to UNKNOWN and the adapter would otherwise have to be found
by name.
Core-guessTrackTypeForLocation
type: synchronous
args-TrackTypeGuesser- the guesser accumulated so far
Infer a track type from an adapter name (and, optionally, the file) in the "Add
track" workflow. Register it with addTrackTypeGuesser, the companion to
addAdapterGuesser above.
A plugin adding a format registers both together, one guessing the adapter and
one naming the track type to draw it with. The file argument lets one adapter
serve two track types — a .bedmethyl.gz and a plain .bed.gz are both read by
BedTabixAdapter:
function installFormatGuessers(pluginManager: PluginManager) {
addAdapterGuesser(pluginManager, (file, index, adapterHint) => {
const adapter = guessTrackConfFromTable(file, index, adapterHint)?.adapter
return adapter && pluginManager.hasAdapterType(adapter.type)
? adapter
: undefined
})
addTrackTypeGuesser(pluginManager, (adapterName, file) =>
pluginManager.hasAdapterType(adapterName)
? trackTypeForAdapter(adapterName, file && getFileName(file))
: undefined,
)
}Core-extendSession
type: synchronous
Extend the session model itself with new features.
args-IAnyModelType- the session model type, not an instance. Callbacks compose: each one builds on the model the one before it returned.
Core-replaceAbout
type: synchronous
Provide a different component for the "About this track" dialog.
args- aReactComponent, by default the AboutTrack dialogprops-AboutPanelProps, shared by both About points. The dialog hydrates the track config before either point fires, soconfigis always a live node:
export interface AboutPanelProps {
session: AbstractSessionModel
/** the track's config, hydrated by the dialog before any panel sees it */
config: AnyConfigurationModel
}No in-tree plugin registers on either. To change the About dialog of a track you
own, set its formatAbout config slot; these points are the programmatic
equivalent for tracks you do not own.
Core-extraAboutPanel and Core-replaceAbout are declared together — one
accumulates an array, the other threads a single component:
declare module '@jbrowse/core/PluginManager' {
interface ExtensionPointRegistry {
'Core-extraAboutPanel': ComponentList<AboutPanelProps>
// fired via PluggableComponent's `name` prop, so there is no string-literal
// call site and the docs tag lives here at the contract
/** #extensionPoint Core-replaceAbout | sync | Replace or wrap a track's About dialog body */
'Core-replaceAbout': ComponentSlot<AboutPanelProps>
}
}Core-replaceAbout is a single-component slot, so a plugin fills it with
wrapComponent exactly as it fills
Core-replaceWidget. This example gives one track a new About dialog and leaves
every other track's alone.
function addReplaceAbout(pluginManager: PluginManager) {
wrapComponent(
pluginManager,
'Core-replaceAbout',
({ DefaultComponent, ...rest }) =>
matchesTrackSelector({ trackId: 'volvox_sv_test' }, rest) ? (
<div>my about dialog</div>
) : (
<DefaultComponent {...rest} />
),
)
}Core-extraAboutPanel
type: synchronous
Adds extra panels to the "About this track" dialog, rendered below the built-in
Configuration/Metadata cards. This is a list point, like
Core-extraFeaturePanel: args is an accumulating
array of components, empty by default, and every plugin's panel is kept in
registration order.
args-ComponentType<AboutPanelProps>[],[]by defaultprops-AboutPanelProps, also passed to your component
Return value: your component, or undefined to add no panel. A callback never
receives the array, so nothing you return can drop another plugin's panel. A
panel supplies the card chrome around its content, so use BaseCard for a
titled section.
Example: adds an extra about dialog panel for a particular track ID
function ExtraAboutPanel(props: AboutPanelProps) {
return matchesTrackSelector({ trackId: 'volvox_sv_test' }, props) ? (
<BaseCard title="Extra">…</BaseCard>
) : null
}
function addExtraAboutPanel(pluginManager: PluginManager) {
pluginManager.contributeToExtensionPoint(
'Core-extraAboutPanel',
() => ExtraAboutPanel,
)
}ExtraAboutPanel is declared at module scope and renders BaseCard, from
@jbrowse/core/BaseFeatureWidget/BaseFeatureDetail/BaseCard, which gives a
titled section. If the callback declares the panel, each evaluation creates a
new element type, so the panel remounts and loses its state. Panels render
inside a <Suspense>, so React.lazy is fine.
The dialog fires this point for whichever track was opened, so a panel that
renders unconditionally appears in every track's About dialog. A panel selects
its tracks with
matchesTrackSelector,
as a feature panel does. Here the selector reads the track config these points
provide, so a trackId selector also matches the user's copies of that track.
Core-replaceWidget
type: synchronous
Provide a different component for a given widget, drawer, or modal. This is a
single point, since one widget renders: a callback returns a component to take
the slot, or the accumulated one to opt out.
Core-extraFeaturePanel is the point for adding a
panel to a feature details widget.
args- aReactComponentprops- an object of the type below
Import the props type from @jbrowse/core/PluginManager:
export interface ReplaceWidgetProps {
session: AbstractSessionModel
/** has `type`; feature detail widgets also have `trackId` and `trackType` */
model: WidgetModel
toolbarHeight?: number
}Return value: the new React component.
No plugin in this repo registers on it: a display that wants a dedicated feature
details widget names one with a featureWidgetType getter instead. Use this
point to replace a widget you do not own.
Core-replaceWidget fires whenever any widget opens, so a callback that
does not scope itself takes over the drawer, the modal, and every feature
details panel. Fill it with
wrapComponent and scope it with
matchesTrackSelector,
as for every component slot.
Core-extraFeaturePanel
type: synchronous
Adds panels to the feature details widget, below the built-in Attributes and
Sequence sections. This is a list point: every plugin's panel is kept, in
registration order, so panels compose.
Register with contributeToExtensionPoint, returning your component. The
score-example plugin's panel, which reports the value its display draws:
export default function ScoreFeaturePanelF(pluginManager: PluginManager) {
pluginManager.contributeToExtensionPoint(
'Core-extraFeaturePanel',
() => ScoreFeaturePanel,
)
}Return value: your component, or undefined to add no panel.
The point fires for every feature details widget, so the panel selects its
tracks with the same
matchesTrackSelector
the widget points use. The panel also supplies its card chrome, so it starts at
BaseCard:
function ScoreFeaturePanel(props: FeaturePanelProps) {
const { feature, depth } = props
return depth === 0 &&
feature.score !== undefined &&
matchesTrackSelector({ trackType: 'FeatureTrack' }, props) ? (
<BaseCard title="Score">
<div>{String(feature.score)}</div>
</BaseCard>
) : null
}A feature detail widget's type varies by track type, so widgetType has no use
here and trackType is the field for "my kind of track". A plain-string
trackId matches the user's copies of that track too.
No selector can test the feature being shown or the depth of the card
showing it, so a panel tests both with ordinary conditions around its JSX, as
above. The point fires once per card, including the nested card for every
subfeature, so a panel tests depth === 0 to match only the feature the user
clicked.
Your panel receives the point's props:
export interface FeaturePanelProps {
/** has `trackId` and `trackType` */
model: FeatureWidgetModel
/** snapshot of the feature being shown, as `formatDetails` reshaped it */
feature: SimpleFeatureSerialized
/** the same feature before `formatDetails` ran */
unformatted?: SimpleFeatureSerialized
/**
* how far down the subfeature tree this card is: 0 is the feature the user
* clicked, 1 its subfeatures, and so on. The point fires for every card, so a
* panel that belongs only on the clicked feature selects on `depth === 0`
*/
depth: number
}model also has track, which is undefined if the user closed the track while
the widget was open; trackId and trackType stay defined either way. Derive
the session with getSession(model) if you need it.
If you fire this point yourself, or need a panel that decides per render,
register on the underlying contract with
contributeToExtensionPoint: args is
React.ComponentType<FeaturePanelProps>[], empty by default. The helper is
still the better default, since it does the track scoping.
Core-preProcessTrackConfig
type: synchronous
Rewrite a track config snapshot before it is instantiated. Registered contract:
interface DisplayConfigSnapshot {
type?: string
[key: string]: unknown
}
export interface TrackConfigSnapshot {
displays?: DisplayConfigSnapshot[]
[key: string]: unknown
}
// A data transform, not a component fold: each callback receives the previous
// callback's rewritten snapshot. The snapshot is already a defensive clone at
// both fire sites, so a callback may return a mutated `snap` as well as a new
// object.
declare module '../../PluginManager.ts' {
interface ExtensionPointRegistry {
'Core-preProcessTrackConfig': {
args: TrackConfigSnapshot
result: TrackConfigSnapshot
}
}
}The snapshot is an open record because the point fires before anything validates
it. Return a new snapshot (or the mutated one; both fire sites clone first).
This declaration is inside core, so it augments the module by relative path; a
plugin writes declare module '@jbrowse/core/PluginManager' for the same
effect.
For the common case of a display's config across a format change, declare it
rather than registering a handler. A name an older release used goes in the
config schema's retired:
export const retiredConfigSpellings: Record<string, RetiredSpelling> = {
colorBy: colorSlotsOf,
jexlFilters: () => ({}),
pileupDisplay: block => subDisplaySlotsOf(block, 'pileup'),
snpCoverageDisplay: block => subDisplaySlotsOf(block, 'coverage'),
defaultRendering: () => ({}),
colorScheme: () => ({}),
scales: retiredScalesSpelling(COVERAGE_Y_PATHS.members),
...retiredScaleSpellings(COVERAGE_Y_PATHS.members),
multiTicks: () => ({}),
jitter: () => ({}),
lineWidth: v => ({
readConnections: { encoding: { strokeWidth: { value: v } } },
}),
featureHeight: v => ({ pileup: { mark: { height: v } } }),
maxHeight: v => ({ pileup: { maxLayoutHeight: v } }),
hideSmallIndels: () => ({}),
hideMismatches: v => ({ pileup: { mismatches: { show: !v } } }),
mismatchAlpha: v => ({ pileup: { mismatches: mismatchFadeOf(v) } }),
hideLargeIndels: () => ({}),
minSubfeatureWidth: () => ({}),
flipStrandLongReadChains: () => ({}),
// last: where two lifts land on one member the first declared wins, and a
// display's own featureHeight or mismatchAlpha beats its renderer's
renderers: rendererSlotsOf,
}Each entry maps the old name to the members its value becomes. One declaration
covers every entry point into a display setting: a displays entry, the
displayDefaults shorthand (which routes an old name to the display that
retired it instead of reporting a key no display declares), a session spec or
share link, an agent's settings bag, and the session migration.
A retired value in a slot the display still declares is the schema's
preProcessSnapshot, which runs on the same entry points.
retiredTypes names the display types this one replaced, on the DisplayType:
retiredTypes: [{ type: 'LinearFeatureDisplay' }],
retiredState: retiredFilterState,A config or a session naming one loads as this display, with the entry rewritten
first by that entry's migrate, which sets the settings the old type's picture
needs. migrate runs before every Core-preProcessTrackConfig handler, so a
handler reads current names.
retiredState does the same for an old session's display instance: keys names
the props it held that are config slots now, and lift returns the slots they
become, which the session migration writes into the track's config.
Core-addTrackComponent
type: synchronous
args-ComponentType<AddTrackComponentProps>- the picker rendered so farprops-{ model }- the add-track widget model
Adapter-specific fields shown in the "Add track" widget, below the adapter and track-type selectors. The point is a single-component fold: return your own component when the selected adapter is one you handle, and the accumulated component otherwise.
Register with addAddTrackComponent (from @jbrowse/core/util) rather than
with wrapComponent, the general
way to fill a slot. This slot has a dedicated helper because the helper writes
two points from one declaration of your adapter types: the fold here, and
Core-addTrackComponentAdapterTypes, a plain list of the same claims for
callers that have an adapter name and no model.
export default function GWASAddTrackComponentF(pluginManager: PluginManager) {
addAddTrackComponent(pluginManager, {
adapterTypes: ['GWASAdapter'],
component: GWASAddTrackComponent,
})
}Your component keeps its fields with useAddTrackFields (from
@jbrowse/core/util), which takes a function from the fields to what they add:
trackConfigis merged into the track config the widget buildsmissing, when set, is the reason the fields cannot make a track yet; the widget shows it and holds the Add button
The hook keeps the fields on the widget model, so they survive the drawer showing another widget, and takes the contribution back when the user picks another adapter.
TrackSelector-multiTrackMenuItems
type: synchronous
Registered contract:
// lets plugins act on the whole checked selection in the hierarchical
// track selector's shopping-cart menu
'TrackSelector-multiTrackMenuItems': {
args: MenuItem[]
result: MenuItem[]
props: MultiTrackMenuItemsProps
}Add menu items to the "shopping cart" in the header of the hierarchical track menu when tracks are added to the selection.
CreateMultiWiggleExtension, which turns a multi-track selection into one
multi-row track, is the whole registration:
export default function CreateMultiWiggleExtensionF(pm: PluginManager) {
addMultiTrackMenuItems(pm, ({ session, model }) => {
const tracks = model.selection.filter(t => stackKindOf(t.type))
const leftOut = model.selection.filter(t => !stackKindOf(t.type))
// contributing nothing is `undefined`, not an empty array to spread into
// someone else's — the accumulated items are not this callback's to see
return isSessionWithAddSessionTrack(session) && tracks.length > 0
? {
label: 'Create multi-row track...',
onClick: () => {
getDialogHost(model).queueDialog(handleClose => [
ConfirmDialog,
{
tracks,
leftOut,
assemblyManager: session.assemblyManager,
onClose: (result?: MakeTrackArg) => {
if (result) {
makeTrack({ model, arg: result })
}
handleClose()
},
},
])
},
}
: undefined
})
}A plugin whose item does not apply returns undefined.
TrackSelector-folderDialog
type: synchronous
Replaces the dialog that opens when a user clicks a folder category (supertrack) in the hierarchical track selector. The default shows a faceted track selector scoped to that category; use this point to provide custom UI for a specific category.
args- a React component (the defaultDefaultFolderDialog)props-FolderDialogProps
Fill it with wrapComponent, the
same call every component slot takes, and render the default you were handed for
the categories you do not want. Return value: a React component rendered as the
dialog. It receives the same FolderDialogProps the point was fired with:
export interface FolderDialogProps {
model: HierarchicalTrackSelectorModel
/** e.g. "Tracks-Wiggle,My Subcategory" */
categoryId: string
/** display name of the category */
title: string
/** flat list of every track node under this category, recursively */
subtracks: TreeTrackNode[]
handleClose: () => void
}The categoryId format is Tracks-{categoryPath}, where categoryPath is the
comma-joined path of category names from the track's category config field, so
"category": ["Wiggle", "My Subcategory"] produces
categoryId = "Tracks-Wiggle,My Subcategory".
LaunchView points
type: async
One point per launchable view type, named LaunchView- plus the view type.
loadSessionSpec fires it with the spec's view object as its args plus
session, and the launcher forwards that object to addView, where the view's
preProcessSnapshot sorts the launch keys from the properties. A view type with
no registered point cannot be launched from a spec, and loadSessionSpec
reports that by name.
Register one to make your own view type launchable; see Custom view types. A second callback on a built-in one receives the launch args the chain passes along, not the view the first callback created.
Each launcher's args are that view type's spec fields, documented once in the
URL parameter guide and typed by the Launch*Args interface exported beside the
registration:
| Extension point | Spec fields | Args type |
|---|---|---|
LaunchView-BreakpointSplitView | Breakpoint split view | LaunchBreakpointSplitViewArgs |
LaunchView-CircularView | Circular view | LaunchCircularViewArgs |
LaunchView-DotplotView | Dotplot view | LaunchDotplotViewArgs |
LaunchView-LinearGenomeView | Linear genome view | LaunchLinearGenomeViewArgs |
LaunchView-LinearSyntenyView | Linear synteny view | LaunchLinearSyntenyViewArgs |
LaunchView-SpreadsheetView | Spreadsheet view | LaunchSpreadsheetViewArgs |
LaunchView-SvInspectorView | SV inspector | LaunchSvInspectorViewArgs |
Two spec keys never reach a launcher:
typeis the dispatch key.displayNameis applied byloadSessionSpecto whatever view the launch created, so it works for plugin-provided types whose launcher does not handle it.
Each launcher handles id itself, and every one above honors it.
LinearGenomeView-TracksContainerComponent
type: synchronous
Registered contract, an accumulator of nodes rendered inside the TracksContainer div:
/** #extensionPoint LinearGenomeView-TracksContainerComponent | sync | Add a component into the LGV tracks container */
'LinearGenomeView-TracksContainerComponent': ElementList<{
model: LinearGenomeViewModel
}>Render a custom overlay inside the LinearGenomeView TracksContainer, e.g.
highlights as a full-height div over the tracks area. Contribute the node with
addExtensionElement, which fixes the React key for
you, or contributeToExtensionPoint if you need to build it yourself.
LinearGenomeView-OverviewScalebarComponent
type: synchronous
Registered contract, an accumulator of nodes rendered inside the overview scalebar:
/** #extensionPoint LinearGenomeView-OverviewScalebarComponent | sync | Add a component to the overview scalebar */
'LinearGenomeView-OverviewScalebarComponent': ElementList<{
model: LinearGenomeViewModel
overview: ViewLayout
}>Render custom overlays inside the overview scalebar. Contribute the node with
addExtensionElement, or contributeToExtensionPoint if you need to build it
yourself.
LinearGenomeView-searchResultSelected
type: async
Registered contract:
'LinearGenomeView-searchResultSelected': {
// nothing to accumulate: the point exists to react to the selection
args: undefined
result: undefined | Promise<void>
props: {
session: AssemblyHost & NotificationSink & TrackCatalog
/** the search result that was selected */
result: BaseResult
model: LinearGenomeViewModel
assemblyName: string
}
}Called when a search result is selected in the LinearGenomeView search box,
after navigation (if the result has a location). Useful for taking further
action, e.g. selecting a corresponding feature. It's a
notification point: the payload is in props (passed
unchanged to every callback), so callbacks can't alter what later callbacks see,
and every plugin registered on it runs. Register with listenToExtensionPoint;
the canvas plugin's registration, which highlights the feature the result names,
is the worked example in that section.
SyntenyImportForm-Options
type: synchronous
Registered contract:
'SyntenyImportForm-Options': {
args: SyntenyImportFormOption[]
result: SyntenyImportFormOption[]
props: SyntenyImportFormOptionProps
}Add radio options beside None / Existing track / New track in the linear
synteny, dotplot and circular import forms; selecting one renders the plugin's
React component. props.model.type names the view, and props.rowIndex the row
pair being configured, which is only ever above 0 in a linear synteny view. The
component gets the same props and writes its track with
model.setImportFormSyntenyTrack(rowIndex, …). Each option:
export interface SyntenyImportFormOption {
/** unique identifier for the radio option */
value: string
/** display text for the radio option */
label: string
ReactComponent: React.FC<SyntenyImportFormOptionProps>
}Example: adding a custom synteny option that fetches data from a server
function addSyntenyOption(pluginManager: PluginManager) {
pluginManager.contributeToExtensionPoint(
'SyntenyImportForm-Options',
({ model, assembly1, assembly2, rowIndex }) => ({
value: `my-server-${assembly1}-${assembly2}`,
label:
model.type === 'LinearSyntenyView'
? `Load rows ${rowIndex + 1} and ${rowIndex + 2} from my server`
: 'Load from my server',
ReactComponent: MySyntenyServerComponent,
}),
)
}SyntenyImportForm-FileFormats
type: synchronous
args-SyntenyFileFormatOption[]- array of file format options for the "New track" panel
Add support for new synteny file formats to the "New track" panel of the linear
synteny, dotplot and circular import forms and the synteny view's Add row
dialog. The built-in formats (.paf, .delta, .out, .chain, .anchors,
.anchors.simple, .pif.gz) are the point's initial value and are always kept;
a contribution is added after them. Each option:
export interface SyntenyFileFormatOption {
/** label and radio button value, e.g. '.maf' */
extension: string
/**
* the tool that emits this format, e.g. 'minimap2'. Shown under the radio, so
* a user who knows what produced their file can find it without matching
* extensions. Optional: a format nobody would name by tool just omits it.
*/
producer?: string
Component: React.FC<{
assembly1: string
assembly2: string
onAdapterChange: (r: { adapter: object; name: string } | undefined) => void
}>
}onAdapterChange should be called with the built adapter config whenever the
user's file selection is complete, or undefined when the selection is cleared.
Four of the built-in formats (.paf, .delta, .out, .chain) come from one
helper in @jbrowse/synteny-core, which is the smallest complete example of a
format. The three .anchors/.pif.gz variants need a second file or a
different selector, so they are defined separately:
function makeSimpleFormat(
extension: string,
adapterType: string,
locationKey: string,
producer: string,
): SyntenyFileFormatOption {
const Component = observer(function SyntenyFormat({
assembly1,
assembly2,
onAdapterChange,
}: FormatProps) {
const [fileLocation, setFileLocation] = useState<FileLocation>()
const [swap, setSwap] = useState(false)
const buildAdapter = (loc: FileLocation, sw: boolean) => ({
type: adapterType,
[locationKey]: loc,
queryAssembly: sw ? assembly2 : assembly1,
targetAssembly: sw ? assembly1 : assembly2,
})
return (
<StandardFormatSelector
radioOption={extension}
fileLocation={fileLocation}
assembly1={assembly1}
assembly2={assembly2}
swap={swap}
setFileLocation={loc => {
setFileLocation(loc)
onAdapterChange({
name: resolvedName(loc),
adapter: buildAdapter(loc, swap),
})
}}
setSwap={sw => {
setSwap(sw)
if (fileLocation) {
onAdapterChange({
name: resolvedName(fileLocation),
adapter: buildAdapter(fileLocation, sw),
})
}
}}
/>
)
})
return { extension, producer, Component }
}Two things to copy from it:
- The component holds the file-location state and calls
onAdapterChangeon every change, including the swap toggle after a file is already chosen. If the component reports only on the file pick, the form holds an adapter with the assemblies the wrong way round. StandardFormatSelectoris exported from@jbrowse/synteny-core, so a plugin format gets the same file/swap UI as the built-ins.
Register it with
pluginManager.contributeToExtensionPoint('SyntenyImportForm-FileFormats', () => myFormat).
Desktop-StartScreenMenuItems
type: synchronous
args-MenuItem[]- the start screen menu's itemsprops-{ pluginManager, setPluginManager, loadPluginManager }
Desktop only. The start screen runs before any session exists, so these points fire on a plugin manager built from the user's global plugins alone (the ones in the "Global plugins" dialog). A plugin listed only in a config is not loaded yet and cannot extend this screen.
loadPluginManager(configPath) builds a session's plugin manager and
setPluginManager hands it to the app, so a menu item can open a session
itself:
function addStartScreenMenuItem(
pluginManager: PluginManager,
configPath: string,
) {
pluginManager.contributeToExtensionPoint(
'Desktop-StartScreenMenuItems',
({ setPluginManager, loadPluginManager }) => ({
label: 'Open my thing...',
onClick: () => {
loadPluginManager(configPath)
.then(setPluginManager)
.catch(console.error)
},
}),
)
}A callback that throws here costs the plugin its menu items only: the fold reports it and continues, so the other plugins' items still appear and the dialog that can uninstall the misbehaving one stays reachable.
Desktop-StartScreenLaunchPanel
type: synchronous
args-ComponentType<StartScreenPanelProps>- the "Launch new session" panel componentprops-{ setPluginManager, loadPluginManager }
Desktop only. It is the same component slot as Core-replaceWidget, so fill it
with wrapComponent, rendering the
DefaultComponent you were handed to keep the built-in panel below your own
chrome. If your component throws while rendering, the start screen falls back to
the built-in panel and shows an error above it.
Desktop-StartScreenRecentSessionsPanel
type: synchronous
Desktop-StartScreenRecentSessionsPanel is a component slot like
Desktop-StartScreenLaunchPanel, for the recent sessions panel.
Adding your own extension points
Fire any name with evaluateExtensionPoint('YourCustomNameHere', value, props)
and let other code register against it with addToExtensionPoint (see the API
section above). The Core- prefix just marks points from the core codebase;
choose your own prefix to avoid collisions.
See also
- Developer guide: Pluggable elements
- Developer guide: Custom view types
- Developer guide: Menus
- Developer guide: Add-track workflows