@jbrowse/core 5.0.0-beta.10 → 5.0.0-beta.11

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.
Files changed (67) hide show
  1. package/README.md +113 -5
  2. package/esm/BaseFeatureWidget/BaseFeatureDetail/ArrayValue.js +0 -2
  3. package/esm/BaseFeatureWidget/BaseFeatureDetail/BasicValue.js +22 -6
  4. package/esm/BaseFeatureWidget/BaseFeatureDetail/jexlFilterActions.js +3 -4
  5. package/esm/BaseFeatureWidget/SequenceFeatureDetails/useSequenceData.js +8 -5
  6. package/esm/CorePlugin.js +32 -1
  7. package/esm/ReExports/coreModules.generated.js +248 -246
  8. package/esm/ReExports/coreWorkerModules.generated.js +246 -244
  9. package/esm/ReExports/list.js +3 -2
  10. package/esm/configuration/index.d.ts +2 -0
  11. package/esm/configuration/index.js +1 -0
  12. package/esm/configuration/plot.d.ts +61 -0
  13. package/esm/configuration/plot.js +142 -0
  14. package/esm/pluggableElementTypes/DisplayType.d.ts +7 -0
  15. package/esm/pluggableElementTypes/DisplayType.js +6 -0
  16. package/esm/pluggableElementTypes/WidgetType.d.ts +6 -0
  17. package/esm/pluggableElementTypes/WidgetType.js +6 -0
  18. package/esm/pluggableElementTypes/index.js +1 -8
  19. package/esm/pluggableElementTypes/models/BaseDisplayModel.d.ts +57 -1
  20. package/esm/pluggableElementTypes/models/BaseDisplayModel.js +84 -2
  21. package/esm/pluggableElementTypes/models/BaseViewModel.d.ts +2 -2
  22. package/esm/pluggableElementTypes/models/BaseViewModel.js +1 -1
  23. package/esm/ui/JexlFilterDialog.d.ts +1 -1
  24. package/esm/ui/JexlFilterDialog.js +2 -3
  25. package/esm/util/TimeTraveller.d.ts +3 -0
  26. package/esm/util/TimeTraveller.js +34 -2
  27. package/esm/util/baseDisplayConfig.d.ts +8 -0
  28. package/esm/util/baseDisplayConfig.js +27 -0
  29. package/esm/util/cachedSetup.d.ts +16 -6
  30. package/esm/util/cachedSetup.js +108 -38
  31. package/esm/util/cellMatches.d.ts +23 -0
  32. package/esm/util/cellMatches.js +264 -0
  33. package/esm/util/cellsStep.d.ts +29 -0
  34. package/esm/util/cellsStep.js +437 -0
  35. package/esm/util/copyToClipboard.js +8 -1
  36. package/esm/util/createAbortRotation.js +3 -3
  37. package/esm/util/featureTransforms.d.ts +7 -6
  38. package/esm/util/featureTransforms.js +131 -479
  39. package/esm/util/fetchPhases.d.ts +1 -1
  40. package/esm/util/formatBytes.js +1 -1
  41. package/esm/util/formatGuessers.d.ts +25 -14
  42. package/esm/util/formatGuessers.js +42 -27
  43. package/esm/util/formatRelativeTime.js +1 -1
  44. package/esm/util/getLocationUri.d.ts +2 -1
  45. package/esm/util/getLocationUri.js +8 -2
  46. package/esm/util/groupKeys.d.ts +4 -5
  47. package/esm/util/groupKeys.js +7 -6
  48. package/esm/util/intervals.js +9 -4
  49. package/esm/util/io/index.js +3 -1
  50. package/esm/util/jexlFilters.d.ts +31 -49
  51. package/esm/util/jexlFilters.js +29 -39
  52. package/esm/util/locString.js +5 -2
  53. package/esm/util/markEncoding.js +48 -8
  54. package/esm/util/markEncodingTypes.d.ts +8 -1
  55. package/esm/util/springAnimate.js +1 -1
  56. package/esm/util/stepTables.d.ts +14 -0
  57. package/esm/util/stepTables.js +38 -4
  58. package/esm/util/tabix.d.ts +6 -2
  59. package/esm/util/tabix.js +2 -2
  60. package/esm/util/tracks.d.ts +3 -30
  61. package/esm/util/tracks.js +4 -39
  62. package/esm/util/unknownSnapshotKeys.d.ts +3 -1
  63. package/esm/util/unknownSnapshotKeys.js +5 -11
  64. package/esm/util/usePanZoom.js +10 -3
  65. package/esm/util/useRafCommit.js +1 -0
  66. package/esm/util/withFeatureDetails.js +4 -0
  67. package/package.json +9 -8
@@ -43,7 +43,7 @@ export interface FetchPhases<TArgs, TResult, TCtx> {
43
43
  * gets a dead Retry button — see there.
44
44
  *
45
45
  * What it must **not** do is move a trigger read of its own under a bail-out.
46
- * See reference/FETCH_SKELETON.md §"The global-fetch trigger list must be read
46
+ * See reference/FETCH_KEYS.md §"The global-fetch trigger list must be read
47
47
  * unconditionally".
48
48
  */
49
49
  prepare: () => TArgs | undefined;
@@ -10,7 +10,7 @@ const units = ['bytes', 'kB', 'MB', 'GB', 'TB'];
10
10
  export function formatBytes(bytes) {
11
11
  let n = bytes;
12
12
  let i = 0;
13
- while (n >= 1000 && i < units.length - 1) {
13
+ while (Number(n.toFixed(1)) >= 1000 && i < units.length - 1) {
14
14
  n /= 1000;
15
15
  i++;
16
16
  }
@@ -1,7 +1,23 @@
1
- import type PluginManager from '../PluginManager.ts';
2
1
  import type { AdapterConfig } from './tracks.ts';
3
2
  import type { FileLocation } from './types/data.ts';
4
3
  import type { AdapterSpec } from '@jbrowse/add-track-core';
4
+ /**
5
+ * creates a new location from the provided location including the appropriate
6
+ * suffix and location type
7
+ *
8
+ * @param location - the FileLocation
9
+ * @param suffix - the file suffix (e.g. .bam)
10
+ * @returns the constructed location object from the provided parameters
11
+ */
12
+ export declare function makeIndex(location: FileLocation, suffix: string): import("./index.ts").BlobLocation | import("./index.ts").FileHandleLocation | {
13
+ uri: string;
14
+ locationType: string;
15
+ baseUri?: string | undefined;
16
+ localPath?: undefined;
17
+ } | {
18
+ localPath: string;
19
+ locationType: string;
20
+ };
5
21
  /**
6
22
  * The adapter config one format-table entry describes: the data file under the
7
23
  * field that format's adapter reads it from, plus wherever that adapter expects
@@ -14,17 +30,12 @@ import type { AdapterSpec } from '@jbrowse/add-track-core';
14
30
  */
15
31
  export declare function adapterConfigFromSpec(spec: AdapterSpec, file: FileLocation, index?: FileLocation): AdapterConfig | undefined;
16
32
  /**
17
- * Guess every format `@jbrowse/add-track-core`'s table describes — the same
18
- * table `@jbrowse/cli`'s `add-track` reads, so a file resolves to the same
19
- * adapter config in the app and on the command line.
20
- *
21
- * Whether a build can open a format is decided by `hasAdapterType`, not by the
22
- * table: guessing `BamAdapter` in a build with no alignments plugin writes a
23
- * track config that fails at render. A plugin therefore registers its adapters
24
- * and nothing else — there is no format list to keep in step. ADR-077.
25
- *
26
- * `CorePlugin` installs this first, so any `addAdapterGuesser` a plugin
27
- * registers is later in the chain and wins over the table. That is how a format
28
- * the table cannot express is added, and how a third-party plugin claims one.
33
+ * The track the format table makes of a file: its adapter, the track type that
34
+ * draws it, and the file name as its name. `undefined` for a file no format
35
+ * claims, which is where `guessTrackConf` throws.
29
36
  */
30
- export declare function installFormatGuessers(pluginManager: PluginManager): void;
37
+ export declare function guessTrackConfFromTable(file: FileLocation, index?: FileLocation, adapterHint?: string): {
38
+ type: string;
39
+ name: string;
40
+ adapter: AdapterConfig;
41
+ } | undefined;
@@ -1,6 +1,33 @@
1
1
  import { matchFormat, resolveIndexType, trackTypeForAdapter, } from '@jbrowse/add-track-core';
2
2
  import { getFileName } from './getFileName.js';
3
- import { addAdapterGuesser, addTrackTypeGuesser, makeIndex } from './tracks.js';
3
+ /**
4
+ * creates a new location from the provided location including the appropriate
5
+ * suffix and location type
6
+ *
7
+ * @param location - the FileLocation
8
+ * @param suffix - the file suffix (e.g. .bam)
9
+ * @returns the constructed location object from the provided parameters
10
+ */
11
+ export function makeIndex(location, suffix) {
12
+ if ('uri' in location) {
13
+ return {
14
+ uri: location.uri + suffix,
15
+ locationType: 'UriLocation',
16
+ // carry the parent's baseUri so a derived sibling index resolves against
17
+ // the same config location as the file it indexes
18
+ ...(location.baseUri ? { baseUri: location.baseUri } : {}),
19
+ };
20
+ }
21
+ else if ('localPath' in location) {
22
+ return {
23
+ localPath: location.localPath + suffix,
24
+ locationType: 'LocalPathLocation',
25
+ };
26
+ }
27
+ else {
28
+ return location;
29
+ }
30
+ }
4
31
  /**
5
32
  * The adapter config one format-table entry describes: the data file under the
6
33
  * field that format's adapter reads it from, plus wherever that adapter expects
@@ -39,31 +66,19 @@ export function adapterConfigFromSpec(spec, file, index) {
39
66
  }
40
67
  }
41
68
  /**
42
- * Guess every format `@jbrowse/add-track-core`'s table describes — the same
43
- * table `@jbrowse/cli`'s `add-track` reads, so a file resolves to the same
44
- * adapter config in the app and on the command line.
45
- *
46
- * Whether a build can open a format is decided by `hasAdapterType`, not by the
47
- * table: guessing `BamAdapter` in a build with no alignments plugin writes a
48
- * track config that fails at render. A plugin therefore registers its adapters
49
- * and nothing else — there is no format list to keep in step. ADR-077.
50
- *
51
- * `CorePlugin` installs this first, so any `addAdapterGuesser` a plugin
52
- * registers is later in the chain and wins over the table. That is how a format
53
- * the table cannot express is added, and how a third-party plugin claims one.
69
+ * The track the format table makes of a file: its adapter, the track type that
70
+ * draws it, and the file name as its name. `undefined` for a file no format
71
+ * claims, which is where `guessTrackConf` throws.
54
72
  */
55
- // #region installFormatGuessers
56
- export function installFormatGuessers(pluginManager) {
57
- addAdapterGuesser(pluginManager, (file, index, adapterHint) => {
58
- const spec = matchFormat(getFileName(file), adapterHint)?.spec;
59
- return spec &&
60
- 'adapterType' in spec &&
61
- pluginManager.hasAdapterType(spec.adapterType)
62
- ? adapterConfigFromSpec(spec, file, index)
63
- : undefined;
64
- });
65
- addTrackTypeGuesser(pluginManager, (adapterName, file) => pluginManager.hasAdapterType(adapterName)
66
- ? trackTypeForAdapter(adapterName, file && getFileName(file))
67
- : undefined);
73
+ export function guessTrackConfFromTable(file, index, adapterHint) {
74
+ const name = getFileName(file);
75
+ const spec = matchFormat(name, adapterHint)?.spec;
76
+ const adapter = spec && adapterConfigFromSpec(spec, file, index);
77
+ return adapter
78
+ ? {
79
+ type: trackTypeForAdapter(adapter.type, name) ?? 'FeatureTrack',
80
+ name,
81
+ adapter,
82
+ }
83
+ : undefined;
68
84
  }
69
- // #endregion
@@ -15,7 +15,7 @@ export function formatRelativeTime(date, now = Date.now()) {
15
15
  let duration = (Number(date) - now) / 1000;
16
16
  let result = rtf.format(Math.round(duration), 'years');
17
17
  for (const { amount, unit } of DIVISIONS) {
18
- if (Math.abs(duration) < amount) {
18
+ if (Math.round(Math.abs(duration)) < amount) {
19
19
  result = rtf.format(Math.round(duration), unit);
20
20
  break;
21
21
  }
@@ -9,7 +9,8 @@ export declare function resolveUri({ uri, baseUri, }: {
9
9
  baseUri?: string;
10
10
  }): string;
11
11
  /**
12
- * An address with its query string dropped, which is what makes it safe to put
12
+ * An address with its query string and any username:password dropped, which is
13
+ * what makes it safe to put
13
14
  * on screen. A presigned S3 or GCS link carries its credential in the query — a
14
15
  * few hundred characters of `?X-Amz-Signature=…` — and a notice that renders one
15
16
  * puts it in front of whoever is looking at the screen and into every screenshot
@@ -13,7 +13,8 @@ export function resolveUri({ uri, baseUri = '', }) {
13
13
  }
14
14
  }
15
15
  /**
16
- * An address with its query string dropped, which is what makes it safe to put
16
+ * An address with its query string and any username:password dropped, which is
17
+ * what makes it safe to put
17
18
  * on screen. A presigned S3 or GCS link carries its credential in the query — a
18
19
  * few hundred characters of `?X-Amz-Signature=…` — and a notice that renders one
19
20
  * puts it in front of whoever is looking at the screen and into every screenshot
@@ -25,7 +26,12 @@ export function resolveUri({ uri, baseUri = '', }) {
25
26
  * than a typed location.
26
27
  */
27
28
  export function redactSource(source) {
28
- return source.includes('://') ? source.split(/[?#]/)[0] : source;
29
+ if (!source.includes('://')) {
30
+ return source;
31
+ }
32
+ return source
33
+ .replace(/^([a-z][a-z\d+.-]*:\/\/)[^/?#]*@/i, '$1')
34
+ .split(/[?#]/)[0];
29
35
  }
30
36
  /**
31
37
  * Where a location's bytes come from, as an address worth showing someone, or
@@ -46,14 +46,13 @@ export declare function carryGroupDomain<T extends {
46
46
  export declare function overflowLabel(count: number): string;
47
47
  /**
48
48
  * Which section each key stacks into once the cap applies: the first
49
- * `limit` keys (`MAX_GROUPS` by default) in natural order keep their own and
50
- * the rest fold into the overflow section; `Infinity` folds none. The
51
- * catch-all `''` is held out of the merge, since "lacking the value" is an
52
- * answer users look for. A domain orders the
49
+ * `MAX_GROUPS` keys in natural order keep their own and the rest fold into
50
+ * the overflow section. The catch-all `''` is held out of the merge, since
51
+ * "lacking the value" is an answer users look for. A domain orders the
53
52
  * sections and never decides which exist, so reordering them changes no
54
53
  * key's section.
55
54
  */
56
- export declare function capGroupKeys(keys: Iterable<string>, limit?: number): {
55
+ export declare function capGroupKeys(keys: Iterable<string>): {
57
56
  mergedCount: number;
58
57
  sectionOf: (key: string) => string;
59
58
  };
@@ -125,18 +125,19 @@ export function overflowLabel(count) {
125
125
  }
126
126
  /**
127
127
  * Which section each key stacks into once the cap applies: the first
128
- * `limit` keys (`MAX_GROUPS` by default) in natural order keep their own and
129
- * the rest fold into the overflow section; `Infinity` folds none. The
130
- * catch-all `''` is held out of the merge, since "lacking the value" is an
131
- * answer users look for. A domain orders the
128
+ * `MAX_GROUPS` keys in natural order keep their own and the rest fold into
129
+ * the overflow section. The catch-all `''` is held out of the merge, since
130
+ * "lacking the value" is an answer users look for. A domain orders the
132
131
  * sections and never decides which exist, so reordering them changes no
133
132
  * key's section.
134
133
  */
135
- export function capGroupKeys(keys, limit = MAX_GROUPS) {
134
+ export function capGroupKeys(keys) {
136
135
  const ordered = [...new Set(keys)].sort(compareGroupKeys);
137
136
  const hasUntagged = ordered.includes('');
138
137
  const named = ordered.filter(key => key !== '');
139
- const merged = ordered.length > limit ? named.slice(limit - (hasUntagged ? 2 : 1)) : [];
138
+ const merged = ordered.length > MAX_GROUPS
139
+ ? named.slice(MAX_GROUPS - (hasUntagged ? 2 : 1))
140
+ : [];
140
141
  const mergedSet = new Set(merged);
141
142
  return {
142
143
  mergedCount: merged.length,
@@ -28,10 +28,15 @@ export function mergeIntervals(intervals, padding = 5000) {
28
28
  * features. `padding` carries the same per-side meaning.
29
29
  */
30
30
  export function gatherOverlaps(regions, padding = 5000) {
31
- const memo = {};
31
+ const memo = new Map();
32
32
  for (const x of regions) {
33
- memo[x.refName] ??= [];
34
- memo[x.refName].push(x);
33
+ const group = memo.get(x.refName);
34
+ if (group) {
35
+ group.push(x);
36
+ }
37
+ else {
38
+ memo.set(x.refName, [x]);
39
+ }
35
40
  }
36
- return Object.values(memo).flatMap(group => mergeIntervals(group, padding));
41
+ return [...memo.values()].flatMap(group => mergeIntervals(group, padding));
37
42
  }
@@ -77,7 +77,9 @@ export function openLocation(location, pluginManager) {
77
77
  if (!file) {
78
78
  throw new Error(`file ("${location.name}") requires permission. Please reopen the file from track settings`);
79
79
  }
80
- return new CachedFilehandle(new BlobFile(file), `filehandle://${location.handleId}`);
80
+ // A File is a snapshot: the handle yields a new one once the file changes
81
+ // on disk, and its chunks must not be the old snapshot's
82
+ return new CachedFilehandle(new BlobFile(file), `filehandle://${location.handleId}@${file.lastModified}:${file.size}`);
81
83
  }
82
84
  if (isUriLocation(location)) {
83
85
  // Check for empty string
@@ -1,68 +1,50 @@
1
1
  import { getConf } from '../configuration/getConf.ts';
2
+ import type { RetiredDisplayState } from '../pluggableElementTypes/DisplayType.ts';
2
3
  import type { Reversible } from '../ui/filterMenuItems.ts';
3
4
  /**
4
- * The two-tier feature-filter contract, shared by every display offering a
5
- * "Filter by..." row. Two tiers because the setting has two authors:
6
- *
7
- * - the **`filter` config slot**, which an admin declares in a track config:
8
- * `jexlFilterConfigSchemaFields` in display-kit, spread by the canvas base
9
- * display schema, the mark display schema and the shared multi-sample
10
- * variant schema — the three whose models read it.
11
- * - the **`filterSetting` display property**, which the dialog writes. Set —
12
- * *even to an empty list* — it replaces the config tier entirely, which is
13
- * what makes "clear the filters an admin declared" expressible.
14
- *
15
- * Both hold `jexl:` expressions and refuse a bare string.
5
+ * The feature-filter contract, shared by every display offering a "Filter
6
+ * by..." row: the **`filter` config slot** holds the filters applied, as
7
+ * `jexl:` expressions (`jexlFilterConfigSchemaFields` in display-kit, spread
8
+ * by the canvas base display schema, the mark display schema and the shared
9
+ * multi-sample variant schema). The dialog, a feature's "Filter" actions and
10
+ * "Edit plot..." all write that slot, and the session keeps the write as it
11
+ * keeps any track setting, so "Reset track settings" returns it with the
12
+ * rest.
16
13
  */
17
14
  export interface JexlFilterSource {
18
- /**
19
- * The dialog's override. `undefined` means "follow the config slot"; an
20
- * empty array means "the user cleared them".
21
- */
22
- filterSetting?: readonly string[];
23
- /**
24
- * The config tier — in practice `configuredJexlFilters(self)`. A member rather than a read this module does
25
- * itself, so the two things that consume this contract off a **duck-typed**
26
- * model — LD's structural menu builder and its shape test — need no live
27
- * config node to answer a count with.
28
- */
15
+ /** The slot's filters, in practice `configuredJexlFilters(self)`. */
29
16
  configuredFilters: () => string[];
30
17
  }
31
18
  /**
32
19
  * The writing half, for the dialog and the menu's clear row. Separate from
33
- * {@link JexlFilterSource} because a display declares `activeFilters()` in an
34
- * earlier `.views()` block than the `.actions()` block declaring its setter, so
35
- * requiring the setter to *read* the filters would put the two in an order MST
36
- * cannot satisfy.
20
+ * {@link JexlFilterSource} because a display declares its readers in an
21
+ * earlier `.views()` block than the `.actions()` block declaring its setter.
37
22
  */
38
23
  export interface JexlFilterModel extends JexlFilterSource {
24
+ /** What the track's config declares, which "Clear all filters" returns to. */
25
+ baseFilters: () => string[];
26
+ /** Write the slot; undefined returns it to {@link baseFilters}. */
39
27
  setFilter: (filters?: string[]) => void;
40
28
  }
41
- /** The `filterSetting` property, for a display model to declare. */
42
- export declare const FilterSetting: import("@jbrowse/mobx-state-tree").IMaybe<import("@jbrowse/mobx-state-tree").IArrayType<import("@jbrowse/mobx-state-tree").ISimpleType<string>>>;
43
- /**
44
- * A snapshot's v4 `jexlFiltersSetting` as `filterSetting`, for the
45
- * `preProcessSnapshot` of the model declaring {@link FilterSetting}.
46
- */
47
- export declare function liftRetiredFilterSetting<T>(snap: T): T;
48
- /** What the `filter` config slot alone declares: a display's `configuredFilters`. */
29
+ /** What the `filter` config slot holds: a display's `configuredFilters`. */
49
30
  export declare function configuredJexlFilters(self: Parameters<typeof getConf>[0]): string[];
50
31
  /**
51
- * The filters actually applied — the single source of truth
52
- * for the worker (via `rpcProps`), for the "Filter by..." dialog (so config
53
- * filters show up and are editable), and for the narrowing count below.
32
+ * What the track's config declares for this display's `filter`, before the
33
+ * session wrote any: a display's `baseFilters`. Empty in a session that keeps
34
+ * no base.
54
35
  */
55
- export declare function activeJexlFilters(self: JexlFilterSource): string[];
36
+ export declare function baseJexlFilters(self: object): string[];
56
37
  /**
57
- * The `Reversible` a display declares for its jexl filters.
58
- *
59
- * The count is whether the override **differs from the configured baseline**,
60
- * not the number of expressions: a filter an admin declared is not something
61
- * the user narrowed, and "Clear all filters" could not undo it anyway. It
62
- * counts in both directions when they differ — narrowing further, and (emptied
63
- * over a slot that declares filters) widening past what the config asked for.
64
- *
65
- * No per-item row: a list of jexl expressions has no recovery to name beyond
66
- * restoring the config default, which the group clear already is.
38
+ * The `Reversible` a display declares for its jexl filters. The count is
39
+ * whether the filters differ from what the track's config declares, not the
40
+ * number of expressions: a filter an admin declared is not something the user
41
+ * narrowed. It counts in both directions, narrowing further and, emptied over
42
+ * a config that declares filters, widening past it.
67
43
  */
68
44
  export declare function jexlFilterNarrowing(self: JexlFilterModel): Reversible;
45
+ /**
46
+ * A v4 session's display-instance `jexlFiltersSetting`, which is the `filter`
47
+ * slot now: the retired state the canvas and single-sample variant displays
48
+ * declare (ADR-168).
49
+ */
50
+ export declare const retiredFilterState: RetiredDisplayState;
@@ -1,57 +1,36 @@
1
1
  // own module, not the configuration barrel: that barrel re-exports modules
2
2
  // which import this one, and a value import back into it is the cycle shape
3
3
  // `openFeatureWidget` documents
4
- import { types } from '@jbrowse/mobx-state-tree';
5
4
  import { getConf } from '../configuration/getConf.js';
5
+ import { baseDisplayConfig } from './baseDisplayConfig.js';
6
6
  import { ensureJexlPrefix } from './jexlStrings.js';
7
- import { JexlExpressionString } from './types/mst.js';
8
- /** The `filterSetting` property, for a display model to declare. */
9
- export const FilterSetting = types.maybe(types.array(JexlExpressionString));
10
- /**
11
- * A snapshot's v4 `jexlFiltersSetting` as `filterSetting`, for the
12
- * `preProcessSnapshot` of the model declaring {@link FilterSetting}.
13
- */
14
- export function liftRetiredFilterSetting(snap) {
15
- if (!snap || typeof snap !== 'object' || !('jexlFiltersSetting' in snap)) {
16
- return snap;
17
- }
18
- const { jexlFiltersSetting, ...rest } = snap;
19
- return (Array.isArray(jexlFiltersSetting) && !('filterSetting' in rest)
20
- ? { ...rest, filterSetting: jexlFiltersSetting.map(ensureJexlPrefix) }
21
- : rest);
22
- }
23
- /** What the `filter` config slot alone declares: a display's `configuredFilters`. */
7
+ /** What the `filter` config slot holds: a display's `configuredFilters`. */
24
8
  export function configuredJexlFilters(self) {
25
9
  return getConf(self, 'filter');
26
10
  }
27
11
  /**
28
- * The filters actually applied — the single source of truth
29
- * for the worker (via `rpcProps`), for the "Filter by..." dialog (so config
30
- * filters show up and are editable), and for the narrowing count below.
12
+ * What the track's config declares for this display's `filter`, before the
13
+ * session wrote any: a display's `baseFilters`. Empty in a session that keeps
14
+ * no base.
31
15
  */
32
- export function activeJexlFilters(self) {
33
- const { filterSetting } = self;
34
- return filterSetting ? [...filterSetting] : self.configuredFilters();
16
+ export function baseJexlFilters(self) {
17
+ const declared = baseDisplayConfig(self).filter;
18
+ return Array.isArray(declared)
19
+ ? declared.map(f => ensureJexlPrefix(String(f)))
20
+ : [];
35
21
  }
36
22
  /**
37
- * The `Reversible` a display declares for its jexl filters.
38
- *
39
- * The count is whether the override **differs from the configured baseline**,
40
- * not the number of expressions: a filter an admin declared is not something
41
- * the user narrowed, and "Clear all filters" could not undo it anyway. It
42
- * counts in both directions when they differ — narrowing further, and (emptied
43
- * over a slot that declares filters) widening past what the config asked for.
44
- *
45
- * No per-item row: a list of jexl expressions has no recovery to name beyond
46
- * restoring the config default, which the group clear already is.
23
+ * The `Reversible` a display declares for its jexl filters. The count is
24
+ * whether the filters differ from what the track's config declares, not the
25
+ * number of expressions: a filter an admin declared is not something the user
26
+ * narrowed. It counts in both directions, narrowing further and, emptied over
27
+ * a config that declares filters, widening past it.
47
28
  */
48
29
  export function jexlFilterNarrowing(self) {
49
- const override = self.filterSetting;
50
- const configured = self.configuredFilters();
30
+ const current = self.configuredFilters();
31
+ const base = self.baseFilters();
51
32
  return {
52
- count: override !== undefined &&
53
- (override.length !== configured.length ||
54
- override.some((f, i) => f !== configured[i]))
33
+ count: current.length !== base.length || current.some((f, i) => f !== base[i])
55
34
  ? 1
56
35
  : 0,
57
36
  clear: () => {
@@ -59,3 +38,14 @@ export function jexlFilterNarrowing(self) {
59
38
  },
60
39
  };
61
40
  }
41
+ /**
42
+ * A v4 session's display-instance `jexlFiltersSetting`, which is the `filter`
43
+ * slot now: the retired state the canvas and single-sample variant displays
44
+ * declare (ADR-168).
45
+ */
46
+ export const retiredFilterState = {
47
+ keys: ['jexlFiltersSetting'],
48
+ lift: ({ jexlFiltersSetting }) => Array.isArray(jexlFiltersSetting)
49
+ ? { filter: jexlFiltersSetting.map(f => ensureJexlPrefix(String(f))) }
50
+ : {},
51
+ };
@@ -36,12 +36,12 @@ export function parseLocStringOneBased(locString, isValidRefName) {
36
36
  throw new Error('no location string provided, could not parse');
37
37
  }
38
38
  // handle reverse strand notation e.g. "chr1:1-100[rev]"
39
+ locString = locString.replaceAll(/\s/g, '');
39
40
  let reversed = false;
40
41
  if (locString.endsWith('[rev]')) {
41
42
  reversed = true;
42
43
  locString = locString.slice(0, -5);
43
44
  }
44
- locString = locString.replaceAll(/\s/g, '');
45
45
  // extract optional assembly name in braces e.g. "{hg19}chr1:1-100"
46
46
  const assemblyMatch = ASSEMBLY_REGEX.exec(locString);
47
47
  if (!assemblyMatch) {
@@ -174,7 +174,10 @@ function assembleLocStringWith(region, cb = (n) => n) {
174
174
  }
175
175
  let endString;
176
176
  if (end !== undefined) {
177
- endString = start !== undefined && start + 1 === end ? '' : `..${cb(end)}`;
177
+ endString =
178
+ start !== undefined && (start + 1 === end || start === end)
179
+ ? ''
180
+ : `..${cb(end)}`;
178
181
  }
179
182
  else {
180
183
  endString = start !== undefined ? '..' : '';
@@ -51,6 +51,22 @@ function channelReader(table, ref, jexl) {
51
51
  const read = fieldReader(ref, jexl);
52
52
  return i => read(table.row(i));
53
53
  }
54
+ // A text channel's reader, printing a float32 lane's value at the digits the
55
+ // lane holds rather than the double it widens to: 0.3865, not 0.3865000009.
56
+ function textChannelReader(table, ref, jexl) {
57
+ const read = channelReader(table, ref, jexl);
58
+ const column = typeof ref !== 'function' && isPlainFieldRef(ref)
59
+ ? table.column(ref)
60
+ : undefined;
61
+ return column?.kind === 'number' && column.values instanceof Float32Array
62
+ ? i => {
63
+ const v = read(i);
64
+ return typeof v === 'number' && !Number.isInteger(v)
65
+ ? String(Number(v.toPrecision(7)))
66
+ : valueText(v);
67
+ }
68
+ : i => valueText(read(i));
69
+ }
54
70
  function numberChannelReader(table, ref, jexl) {
55
71
  if (typeof ref !== 'function' && isPlainFieldRef(ref)) {
56
72
  return numberReaderOf(table.column(ref));
@@ -457,10 +473,10 @@ export function encodeFeatures(input, encoding, lanes, ctx = {}) {
457
473
  text.fill('');
458
474
  }
459
475
  else {
460
- const read = channelReader(table, textEncoding, jexl);
476
+ const read = textChannelReader(table, textEncoding, jexl);
461
477
  for (let k = 0; k < count; k++) {
462
478
  report?.(k);
463
- text[k] = valueText(read(rowAt(k)));
479
+ text[k] = read(rowAt(k));
464
480
  }
465
481
  }
466
482
  }
@@ -519,30 +535,47 @@ export function encodeFeatures(input, encoding, lanes, ctx = {}) {
519
535
  ? y
520
536
  : new Float32Array(count)
521
537
  : undefined;
522
- const paintsColor = has('color') && !colorValue;
538
+ const colorKey = declaredScale?.scale === 'categorical' && has('colorKey')
539
+ ? new Uint32Array(count)
540
+ : undefined;
541
+ const paintsColor = has('color') && !colorValue && !colorKey;
523
542
  const unscaled = paintsColor && declaredScale?.scale !== 'categorical' && !quantitative
524
543
  ? unscaledColor(table, colorEncoding, jexl)
525
544
  : undefined;
526
545
  const color = paintsColor && typeof unscaled !== 'number'
527
546
  ? new Uint32Array(count)
528
547
  : undefined;
529
- const scaled = paintsColor || colorValue ? declaredScale : undefined;
548
+ const scaled = paintsColor || colorValue || colorKey ? declaredScale : undefined;
549
+ const categoryLane = colorKey ?? color;
530
550
  let scale;
531
551
  let missingMet = false;
532
552
  let notNumberMet = false;
533
- if (scaled?.scale === 'categorical' && color) {
553
+ if (scaled?.scale === 'categorical' && categoryLane) {
534
554
  const colorField = categoricalField(scaled.field, {
535
555
  domain: scaled.domain?.map(String),
536
556
  range: scaled.range,
537
557
  });
538
- const entries = paintCategories(table, scaled.field, jexl, colorField, { kept, count, report }, color, key => cssColorToABGR(colorField.color(key)), abgr => abgr);
558
+ let resolved = 0;
559
+ const entries = paintCategories(table, scaled.field, jexl, colorField, { kept, count, report }, categoryLane, key => ({
560
+ color: cssColorToABGR(colorField.color(key)),
561
+ order: resolved++,
562
+ }), entry => (colorKey ? entry.order : entry.color));
563
+ if (colorKey) {
564
+ const position = new Uint32Array(resolved);
565
+ for (const [i, e] of entries.entries()) {
566
+ position[e.entry.order] = i;
567
+ }
568
+ for (let k = 0; k < count; k++) {
569
+ colorKey[k] = position[colorKey[k]];
570
+ }
571
+ }
539
572
  scale = {
540
573
  kind: 'categorical',
541
574
  field: scaled.field,
542
575
  domain: [...colorField.domain],
543
576
  ...(scaled.range ? { range: [...scaled.range] } : {}),
544
577
  ...(keysAreNumeric(entries) ? { numericKeys: true } : {}),
545
- entries: entries.map(e => ({ value: e.value, color: e.entry })),
578
+ entries: entries.map(e => ({ value: e.value, color: e.entry.color })),
546
579
  };
547
580
  }
548
581
  else if (scaled && quantitative) {
@@ -570,10 +603,13 @@ export function encodeFeatures(input, encoding, lanes, ctx = {}) {
570
603
  const cuts = thresholdCuts(thresholdEncoding.domain ?? []);
571
604
  if (color) {
572
605
  const binColors = Uint32Array.from(thresholdPalette(cuts.length + 1, thresholdEncoding.range), c => cssColorToABGR(c));
606
+ // in float32 on both sides, as the shader and the key compare, so a
607
+ // value off a float32 lane sits at a cut like 0.7 rather than under it
608
+ const cuts32 = cuts.map(cut => Math.fround(cut));
573
609
  for (let k = 0; k < count; k++) {
574
610
  report?.(k);
575
611
  const v = read(rowAt(k));
576
- const bin = thresholdIndex(v, cuts);
612
+ const bin = thresholdIndex(Math.fround(numericValue(v)), cuts32);
577
613
  if (bin >= 0) {
578
614
  color[k] = binColors[bin];
579
615
  }
@@ -656,6 +692,9 @@ export function encodeFeatures(input, encoding, lanes, ctx = {}) {
656
692
  // the y view itself where the colour reads y, so a reader can tell
657
693
  encoded.colorValue = colorValue;
658
694
  }
695
+ if (colorKey) {
696
+ encoded.colorKey = colorKey;
697
+ }
659
698
  if (glyph) {
660
699
  encoded.glyph = glyph;
661
700
  }
@@ -830,6 +869,7 @@ export function encodedChannelTransferables(c) {
830
869
  c.row,
831
870
  typeof c.color === 'number' ? undefined : c.color,
832
871
  c.colorValue,
872
+ c.colorKey,
833
873
  c.glyph,
834
874
  c.size,
835
875
  c.x2Ref,
@@ -149,7 +149,7 @@ export interface MarkEncoding {
149
149
  * allocated nor transferred, and a caller that never hovers declines the
150
150
  * index, which is most of the encoder's cost after the walk.
151
151
  */
152
- export type LaneName = 'y' | 'color' | 'colorValue' | 'glyph' | 'row' | 'text' | 'size' | 'x2Ref' | 'index';
152
+ export type LaneName = 'y' | 'color' | 'colorValue' | 'colorKey' | 'glyph' | 'row' | 'text' | 'size' | 'x2Ref' | 'index';
153
153
  /**
154
154
  * #api
155
155
  * The scale a colour channel was resolved through, as the legend reads it —
@@ -318,6 +318,13 @@ export interface EncodedChannels {
318
318
  * colour reads the field `y` plots, this IS the `y` array, not a copy.
319
319
  */
320
320
  colorValue?: Float32Array;
321
+ /**
322
+ * Each instance's index into `scale.entries` under a categorical colour, for
323
+ * a caller that named the `colorKey` lane in place of `color`: the display
324
+ * then paints each key through its own `domain` and `range`, which never
325
+ * reach the worker.
326
+ */
327
+ colorKey?: Uint32Array;
321
328
  /** The point painter's code for each feature's `shape`. */
322
329
  glyph?: Uint8Array;
323
330
  row?: Uint32Array;