react-msaview 9.2.0 → 9.3.0

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 (80) hide show
  1. package/bundle/index.js +74 -74
  2. package/bundle/index.js.map +4 -4
  3. package/dist/components/DragHandle.js +2 -1
  4. package/dist/components/DragHandle.js.map +1 -1
  5. package/dist/components/MSAViewer.d.ts +29 -19
  6. package/dist/components/MSAViewer.js +2 -1
  7. package/dist/components/MSAViewer.js.map +1 -1
  8. package/dist/components/Track.js +2 -5
  9. package/dist/components/Track.js.map +1 -1
  10. package/dist/components/VerticalScrollbar.js +1 -1
  11. package/dist/components/VerticalScrollbar.js.map +1 -1
  12. package/dist/components/header/Header.js +2 -1
  13. package/dist/components/header/Header.js.map +1 -1
  14. package/dist/components/minimap/Minimap.js +1 -1
  15. package/dist/components/minimap/Minimap.js.map +1 -1
  16. package/dist/components/msa/MSACanvas.js +16 -27
  17. package/dist/components/msa/MSACanvas.js.map +1 -1
  18. package/dist/components/rowpanels/RowPanels.js +8 -6
  19. package/dist/components/rowpanels/RowPanels.js.map +1 -1
  20. package/dist/components/settings/SettingsPanel.js +2 -1
  21. package/dist/components/settings/SettingsPanel.js.map +1 -1
  22. package/dist/components/tree/TreeCanvas.js +8 -6
  23. package/dist/components/tree/TreeCanvas.js.map +1 -1
  24. package/dist/constants.d.ts +1 -0
  25. package/dist/constants.js +2 -0
  26. package/dist/constants.js.map +1 -1
  27. package/dist/element.d.ts +11 -7
  28. package/dist/element.js +46 -8
  29. package/dist/element.js.map +1 -1
  30. package/dist/featurePanels.d.ts +0 -7
  31. package/dist/featurePanels.js +1 -16
  32. package/dist/featurePanels.js.map +1 -1
  33. package/dist/ggplotPalettes.js +12 -1
  34. package/dist/ggplotPalettes.js.map +1 -1
  35. package/dist/groupKeys.d.ts +6 -0
  36. package/dist/groupKeys.js +52 -0
  37. package/dist/groupKeys.js.map +1 -0
  38. package/dist/index.d.ts +1 -1
  39. package/dist/model.d.ts +34 -17
  40. package/dist/model.js +100 -54
  41. package/dist/model.js.map +1 -1
  42. package/dist/scales.d.ts +114 -13
  43. package/dist/scales.js +154 -19
  44. package/dist/scales.js.map +1 -1
  45. package/dist/svgTestUtil.d.ts +20 -12
  46. package/dist/types.d.ts +29 -27
  47. package/dist/useDragScroll.d.ts +4 -3
  48. package/dist/useDragScroll.js +9 -6
  49. package/dist/useDragScroll.js.map +1 -1
  50. package/dist/useWheelScroll.d.ts +26 -8
  51. package/dist/useWheelScroll.js +108 -71
  52. package/dist/useWheelScroll.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/dist/viewerSnapshot.js +2 -1
  56. package/dist/viewerSnapshot.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/components/DragHandle.tsx +2 -1
  59. package/src/components/MSAViewer.tsx +11 -8
  60. package/src/components/Track.tsx +2 -8
  61. package/src/components/VerticalScrollbar.tsx +1 -1
  62. package/src/components/header/Header.tsx +2 -1
  63. package/src/components/minimap/Minimap.tsx +1 -1
  64. package/src/components/msa/MSACanvas.tsx +14 -35
  65. package/src/components/rowpanels/RowPanels.tsx +8 -10
  66. package/src/components/settings/SettingsPanel.tsx +2 -1
  67. package/src/components/tree/TreeCanvas.tsx +8 -10
  68. package/src/constants.ts +2 -0
  69. package/src/element.ts +53 -8
  70. package/src/featurePanels.ts +1 -25
  71. package/src/ggplotPalettes.ts +13 -1
  72. package/src/groupKeys.ts +54 -0
  73. package/src/index.ts +13 -2
  74. package/src/model.ts +149 -72
  75. package/src/scales.ts +268 -25
  76. package/src/types.ts +28 -25
  77. package/src/useDragScroll.ts +11 -8
  78. package/src/useWheelScroll.ts +124 -84
  79. package/src/version.ts +1 -1
  80. package/src/viewerSnapshot.ts +3 -1
package/src/constants.ts CHANGED
@@ -4,6 +4,8 @@ import type { TreeOrder } from './types.ts'
4
4
  export const defaultRowHeight = 16
5
5
  export const defaultColWidth = 12
6
6
  export const defaultHeight = 550
7
+ // the dividers' thickness, between the tree and the alignment and under the view
8
+ export const resizeHandleWidth = 5
7
9
  export const defaultScrollX = 0
8
10
  export const defaultScrollY = 0
9
11
  export const defaultCurrentAlignment = 0
package/src/element.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { when } from 'mobx'
2
2
 
3
+ import { defaultHeight, resizeHandleWidth } from './constants.ts'
3
4
  import { mount } from './mount.tsx'
4
5
 
5
6
  import type { MSAViewerProps } from './components/MSAViewer.tsx'
@@ -46,8 +47,13 @@ const ownAttributes = [
46
47
  'theme',
47
48
  'reference-row',
48
49
  'tree-area-width',
50
+ 'loading',
49
51
  ] as const
50
52
 
53
+ // how far outside the viewport a lazy element starts mounting, so the viewer
54
+ // is drawing by the time the reader scrolls to it
55
+ const lazyMargin = '200px'
56
+
51
57
  // the attributes a <nightingale-manager> writes on the elements it holds
52
58
  const nightingaleAttributes = ['display-start', 'display-end', 'highlight']
53
59
 
@@ -87,13 +93,17 @@ function residueRange(model: MsaViewModel, row: string, viewport: Viewport) {
87
93
  /**
88
94
  * Registers a custom element that renders MSAViewer, for pages with no React.
89
95
  * Attributes: msa-url, tree-url, gff-url, color-scheme, height, hide-header,
90
- * theme, tree-area-width and reference-row. Properties: msa, tree and gff text,
91
- * features, colorScheme, highlights, columnTracks, residueMappings and
92
- * selection. The
93
- * colorScheme property takes a `{map}` the color-scheme attribute cannot hold,
94
- * and wins over the attribute while set. Events: cell-hover, cell-click,
95
- * viewport-change and selection-change, each with the MSAViewer callback's
96
- * value as detail.
96
+ * theme, tree-area-width, reference-row and loading. Properties: msa, tree and
97
+ * gff text, features, colorScheme, highlights, columnTracks, residueMappings
98
+ * and selection. The colorScheme property takes a `{domain, range}` the
99
+ * color-scheme attribute cannot hold, and wins over the attribute while set. Events:
100
+ * cell-hover, cell-click, viewport-change and selection-change, each with the
101
+ * MSAViewer callback's value as detail.
102
+ *
103
+ * `loading="lazy"` mounts the viewer once the element comes within 200 pixels
104
+ * of the viewport, as `<img loading="lazy">` loads its image. Until then the
105
+ * element shows its own children, such as an `<img>` of the first frame, and
106
+ * reserves the viewer's height.
97
107
  *
98
108
  * Inside a <nightingale-manager>, the element registers with it and speaks its
99
109
  * protocol: it follows the display-start, display-end and highlight attributes
@@ -110,6 +120,7 @@ export function defineMsaElement(tagName = 'jbrowse-msa') {
110
120
  static observedAttributes = [...ownAttributes, ...nightingaleAttributes]
111
121
 
112
122
  #viewer?: MountedViewer
123
+ #nearby?: IntersectionObserver
113
124
  #model?: MsaViewModel
114
125
  #manager?: NightingaleManager
115
126
  #cancelRange?: () => void
@@ -289,7 +300,22 @@ export function defineMsaElement(tagName = 'jbrowse-msa') {
289
300
  if (!this.style.display) {
290
301
  this.style.display = 'block'
291
302
  }
292
- this.#viewer = mount(this, this.#props())
303
+ if (this.#lazy) {
304
+ const height =
305
+ numberAttribute(this.getAttribute('height')) ?? defaultHeight
306
+ this.style.minHeight = `${height + resizeHandleWidth}px`
307
+ this.#nearby = new IntersectionObserver(
308
+ entries => {
309
+ if (entries.some(entry => entry.isIntersecting)) {
310
+ this.#mount()
311
+ }
312
+ },
313
+ { rootMargin: lazyMargin },
314
+ )
315
+ this.#nearby.observe(this)
316
+ } else {
317
+ this.#mount()
318
+ }
293
319
  const manager = this.closest<NightingaleManager>('nightingale-manager')
294
320
  if (manager) {
295
321
  void customElements.whenDefined('nightingale-manager').then(() => {
@@ -301,7 +327,22 @@ export function defineMsaElement(tagName = 'jbrowse-msa') {
301
327
  }
302
328
  }
303
329
 
330
+ get #lazy() {
331
+ return (
332
+ this.getAttribute('loading') === 'lazy' &&
333
+ typeof IntersectionObserver !== 'undefined'
334
+ )
335
+ }
336
+
337
+ #mount() {
338
+ this.#nearby?.disconnect()
339
+ this.#nearby = undefined
340
+ this.#viewer ??= mount(this, this.#props())
341
+ }
342
+
304
343
  disconnectedCallback() {
344
+ this.#nearby?.disconnect()
345
+ this.#nearby = undefined
305
346
  this.#manager?.unregister(this)
306
347
  this.#manager = undefined
307
348
  this.#cancelRange?.()
@@ -323,6 +364,10 @@ export function defineMsaElement(tagName = 'jbrowse-msa') {
323
364
  }
324
365
  } else if (name === 'highlight') {
325
366
  this.#followHighlight()
367
+ } else if (name === 'loading') {
368
+ if (this.#nearby && !this.#lazy) {
369
+ this.#mount()
370
+ }
326
371
  } else {
327
372
  this.#viewer?.update(this.#props())
328
373
  }
@@ -1,12 +1,8 @@
1
- import { notEmpty } from '@jbrowse/core/util'
2
-
3
1
  import { packDomainLanes } from './components/msa/packDomainLanes.ts'
4
2
  import { defaultFeaturePanelWidth } from './constants.ts'
5
3
  import { featureField } from './featureFields.ts'
6
- import { resolveScale } from './scales.ts'
7
4
  import { outlineColor } from './util.ts'
8
5
 
9
- import type { ScaleSpec } from './scales.ts'
10
6
  import type {
11
7
  Annotation,
12
8
  DomainBand,
@@ -16,7 +12,7 @@ import type {
16
12
  } from './types.ts'
17
13
 
18
14
  // the fill of a span under a scale that gives its value no color, such as a
19
- // gene a `map` over gene names leaves out
15
+ // gene a `domain` of gene names leaves out
20
16
  const UNSCALED_FEATURE = '#d9d9d9'
21
17
 
22
18
  // the fraction of a span a following one may cover before it takes a lane of
@@ -247,23 +243,3 @@ export function featurePanelSpans({
247
243
  ]),
248
244
  )
249
245
  }
250
-
251
- // a scale over the values a field takes across every feature loaded, so hiding
252
- // a type in the filter dialog leaves the rest in their colors, and a legend of
253
- // the values the features drawn carry
254
- export function resolveFeatureScale(
255
- field: string,
256
- scale: ScaleSpec | undefined,
257
- annotations: Annotation[],
258
- drawn: Annotation[],
259
- ) {
260
- const valuesOf = (list: Annotation[]) =>
261
- list.map(a => featureField(a, field)).filter(notEmpty)
262
- const resolved = resolveScale(scale, valuesOf(annotations))
263
- const shown = new Set(valuesOf(drawn))
264
- return {
265
- field,
266
- ...resolved,
267
- legend: resolved.legend.filter(entry => shown.has(entry.id)),
268
- }
269
- }
@@ -72,10 +72,22 @@ export const namedPalettes: Record<string, readonly string[]> = {
72
72
  ],
73
73
  }
74
74
 
75
+ // the spellings jbrowse-components' `paletteFromSpec` takes for two of them
76
+ const aliases: Record<string, string> = {
77
+ okabeIto: 'okabeito',
78
+ tableau10: 'tableau',
79
+ }
80
+
75
81
  /**
76
82
  * The colors a palette name asks for. `ggplot`, an unknown name, and no name at
77
83
  * all give undefined, which `createPaletteMap` answers with the hue ramp.
78
84
  */
79
85
  export function paletteNamed(name?: string) {
80
- return name === undefined ? undefined : namedPalettes[name]
86
+ return name === undefined
87
+ ? undefined
88
+ : Object.hasOwn(namedPalettes, name)
89
+ ? namedPalettes[name]
90
+ : Object.hasOwn(aliases, name)
91
+ ? namedPalettes[aliases[name]!]
92
+ : undefined
81
93
  }
@@ -0,0 +1,54 @@
1
+ // copied from jbrowse-components packages/core/src/util/groupKeys.ts, which
2
+ // @jbrowse/core 5.0.0-beta.9 ships but the hosts the plugin runs on do not
3
+
4
+ const NUMERIC = /^-?\d+(\.\d+)?$/
5
+ const DIGIT_RUNS = /\d+|\D+/g
6
+
7
+ function compareRuns(a: string, b: string) {
8
+ const runsA = a.match(DIGIT_RUNS) ?? []
9
+ const runsB = b.match(DIGIT_RUNS) ?? []
10
+ const shared = Math.min(runsA.length, runsB.length)
11
+ for (let i = 0; i < shared; i++) {
12
+ const x = runsA[i]!
13
+ const y = runsB[i]!
14
+ if (x === y) {
15
+ continue
16
+ }
17
+ if (/^\d/.test(x) && /^\d/.test(y)) {
18
+ const nx = Number(x)
19
+ const ny = Number(y)
20
+ if (nx !== ny) {
21
+ return nx < ny ? -1 : 1
22
+ }
23
+ return x.length < y.length ? -1 : 1
24
+ }
25
+ return x < y ? -1 : 1
26
+ }
27
+ return runsA.length - runsB.length
28
+ }
29
+
30
+ /**
31
+ * Numbers first by value, then every other value run by run with digit runs
32
+ * by magnitude, so `2` before `10`, `chr2` before `chr10` and `2.3.2.1a`
33
+ * before `2.3.4.4b`. The empty value sorts last.
34
+ */
35
+ export function compareGroupKeys(a: string, b: string) {
36
+ if (a === b) {
37
+ return 0
38
+ }
39
+ if (a === '' || b === '') {
40
+ return a === '' ? 1 : -1
41
+ }
42
+ const numA = NUMERIC.test(a)
43
+ const numB = NUMERIC.test(b)
44
+ if (numA !== numB) {
45
+ return numA ? -1 : 1
46
+ }
47
+ if (numA) {
48
+ const diff = Number(a) - Number(b)
49
+ if (diff !== 0) {
50
+ return diff < 0 ? -1 : 1
51
+ }
52
+ }
53
+ return compareRuns(a, b)
54
+ }
package/src/index.ts CHANGED
@@ -51,8 +51,19 @@ export type { InterProScanResults } from 'msa-parsers'
51
51
  export type { ColumnCounts } from './columnCounts.ts'
52
52
  export type { ColumnStats } from './columnStats.ts'
53
53
  export type { ZoomSnapshot } from './zoomSnapshot.ts'
54
- // the scale an encoding reads its field through, and what resolving one gives
55
- export type { ResolvedScale, ScaleSpec } from './scales.ts'
54
+ // the color object a channel reads its field through, and what resolving one
55
+ // gives
56
+ export type {
57
+ ColorChannel,
58
+ ColorSpec,
59
+ LegacyColumnTrack,
60
+ LegacyEncoding,
61
+ LegacyRowPanel,
62
+ LegacyScale,
63
+ ResolvedScale,
64
+ ScaleTable,
65
+ SharedScale,
66
+ } from './scales.ts'
56
67
  export type {
57
68
  Accession,
58
69
  Annotation,
package/src/model.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  minRowHeight,
61
61
  minTreeWidth,
62
62
  pxPerTreeDepth,
63
+ resizeHandleWidth,
63
64
  rowPanelHeaderHeight,
64
65
  rowTintAlpha,
65
66
  scrollZoomAxes,
@@ -70,13 +71,12 @@ import {
70
71
  } from './constants.ts'
71
72
  import { createPaletteMap } from './createPaletteMap.ts'
72
73
  import { exportFileName } from './exportFileName.ts'
73
- import { featureName } from './featureFields.ts'
74
+ import { featureField, featureName } from './featureFields.ts'
74
75
  import {
75
76
  colorByRow,
76
77
  featureColorMap,
77
78
  featureLabelMap,
78
79
  featurePanelSpans,
79
- resolveFeatureScale,
80
80
  rowPanelWidth,
81
81
  stringFields,
82
82
  } from './featurePanels.ts'
@@ -102,7 +102,14 @@ import {
102
102
  visibleColToGlobalCol,
103
103
  visibleColsBefore,
104
104
  } from './rowCoordinateCalculations.ts'
105
- import { resolveScale } from './scales.ts'
105
+ import {
106
+ colorTable,
107
+ declarationKey,
108
+ readColumnTrack,
109
+ readEncoding,
110
+ readRowPanel,
111
+ resolveShared,
112
+ } from './scales.ts'
106
113
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
107
114
  import { maxBitsFor } from './sequenceLogo.ts'
108
115
  import {
@@ -136,6 +143,15 @@ import type { ColumnStats } from './columnStats.ts'
136
143
  import type { ScrollZoomAxis } from './constants.ts'
137
144
  import type { HierarchyNode } from './hierarchy.ts'
138
145
  import type { ExportSvgOptions } from './renderToSvg.tsx'
146
+ import type {
147
+ ColorChannel,
148
+ LegacyColumnTrack,
149
+ LegacyEncoding,
150
+ LegacyRowPanel,
151
+ ScaleTable,
152
+ ScaleUse,
153
+ SharedScale,
154
+ } from './scales.ts'
139
155
  import type {
140
156
  Annotation,
141
157
  Arc,
@@ -600,7 +616,11 @@ function stateModelFactory({
600
616
  * per-column string drawn as a text track. See docs/layers.md
601
617
  */
602
618
  columnTracks: stripDefault(
603
- types.array(types.frozen<ColumnTrackSpec>()),
619
+ types.array(
620
+ types.snapshotProcessor(types.frozen<ColumnTrackSpec>(), {
621
+ preProcessor: readColumnTrack,
622
+ }),
623
+ ),
604
624
  [],
605
625
  ),
606
626
 
@@ -688,20 +708,35 @@ function stateModelFactory({
688
708
  clades: stripDefault(types.array(types.frozen<Clade>()), []),
689
709
  /**
690
710
  * #property
691
- * what the viewer's own marks read from `rowData`:
692
- * `{channel, field, scale?}` per channel, where `channel` is
693
- * `tipLabel` or `rowTint`. See docs/layers.md
711
+ * what the viewer's own marks read from `rowData` or the feature
712
+ * table: `{channel, field, range?, domain?, title?}` per channel. The
713
+ * earlier `scale: {palette}` and `scale: {map}` read as `range` and
714
+ * `domain`. See docs/layers.md
694
715
  */
695
- encodings: stripDefault(types.array(types.frozen<Encoding>()), []),
716
+ encodings: stripDefault(
717
+ types.array(
718
+ types.snapshotProcessor(types.frozen<Encoding>(), {
719
+ preProcessor: readEncoding,
720
+ }),
721
+ ),
722
+ [],
723
+ ),
696
724
  /**
697
725
  * #property
698
726
  * panels drawn between the tree and the alignment:
699
- * `{kind: "strip", field, scale?, width?, header?}` colors one cell
700
- * per row from a `rowData` field, and
727
+ * `{kind: "strip", field, range?, domain?, title?, width?, header?}`
728
+ * colors one cell per row from a `rowData` field, and
701
729
  * `{kind: "features", x, encoding?, transform?, width?, header?}`
702
730
  * draws the spans the GFF carries. See docs/layers.md
703
731
  */
704
- rowPanels: stripDefault(types.array(types.frozen<RowPanelSpec>()), []),
732
+ rowPanels: stripDefault(
733
+ types.array(
734
+ types.snapshotProcessor(types.frozen<RowPanelSpec>(), {
735
+ preProcessor: readRowPanel,
736
+ }),
737
+ ),
738
+ [],
739
+ ),
705
740
  }),
706
741
  )
707
742
  .volatile(() => ({
@@ -765,9 +800,10 @@ function stateModelFactory({
765
800
  volatileWidth: undefined as number | undefined,
766
801
  /**
767
802
  * #volatile
768
- * resize handle width between tree and msa area, px
803
+ * thickness of the dividers, between the tree and the alignment and under
804
+ * the view, px
769
805
  */
770
- resizeHandleWidth: 5,
806
+ resizeHandleWidth,
771
807
 
772
808
  /**
773
809
  * #volatile
@@ -2310,7 +2346,7 @@ function stateModelFactory({
2310
2346
  return [track.id, { arcs }] as const
2311
2347
  }
2312
2348
  if (track.kind === 'bar') {
2313
- const max = track.max ?? 1
2349
+ const max = track.scales?.y?.domainMax ?? 1
2314
2350
  const values = skip(project(track, track.values ?? [], 0)).map(
2315
2351
  v => Math.min(1, Math.max(0, v / max)),
2316
2352
  )
@@ -2361,7 +2397,12 @@ function stateModelFactory({
2361
2397
  height: this.trackHeight(track.kind, heightKey, track.height),
2362
2398
  barColor: track.color,
2363
2399
  arcColor: track.color,
2364
- customColorScheme: track.colors,
2400
+ customColorScheme: track.encoding?.color
2401
+ ? colorTable(track.encoding.color, [
2402
+ ...(track.encoding.color.domain ?? []),
2403
+ ...new Set(track.data),
2404
+ ])
2405
+ : undefined,
2365
2406
  data: content?.data,
2366
2407
  arcs: content?.arcs,
2367
2408
  },
@@ -3418,8 +3459,8 @@ function stateModelFactory({
3418
3459
  /**
3419
3460
  * #action
3420
3461
  */
3421
- setColumnTracks(tracks: ColumnTrackSpec[]) {
3422
- self.columnTracks.replace(tracks)
3462
+ setColumnTracks(tracks: (ColumnTrackSpec | LegacyColumnTrack)[]) {
3463
+ self.columnTracks.replace(tracks.map(readColumnTrack))
3423
3464
  },
3424
3465
  /**
3425
3466
  * #action
@@ -3433,15 +3474,15 @@ function stateModelFactory({
3433
3474
  * #action
3434
3475
  * replace what the viewer's marks read from the row table
3435
3476
  */
3436
- setEncodings(encodings: Encoding[]) {
3437
- self.encodings.replace(encodings)
3477
+ setEncodings(encodings: (Encoding | LegacyEncoding)[]) {
3478
+ self.encodings.replace(encodings.map(readEncoding))
3438
3479
  },
3439
3480
  /**
3440
3481
  * #action
3441
3482
  * replace the panels drawn between the tree and the alignment
3442
3483
  */
3443
- setRowPanels(panels: RowPanelSpec[]) {
3444
- self.rowPanels.replace(panels)
3484
+ setRowPanels(panels: (RowPanelSpec | LegacyRowPanel)[]) {
3485
+ self.rowPanels.replace(panels.map(readRowPanel))
3445
3486
  },
3446
3487
  /**
3447
3488
  * #action
@@ -3643,42 +3684,41 @@ function stateModelFactory({
3643
3684
  * the categorical color keys drawn for this view, shared by the on-screen
3644
3685
  * legend overlay and the SVG export's reserved column. The domain overlay
3645
3686
  * produces the first, listing the `featureFill` scale where an encoding
3646
- * names one. Every field a row-table encoding or a row panel reads
3647
- * produces one more, so two channels over one field, or two strips over
3648
- * it, list that field once. A strip's `legend` names the title it lists
3649
- * under, so strips over eight fields with one set of colors list one
3650
- * legend between them
3687
+ * names one. Every scale a row-table encoding or a row panel reads
3688
+ * produces one more, keyed by its declaration, so two channels or two
3689
+ * strips declaring one scale list it once, and strips over eight fields
3690
+ * naming one `title` list one legend between them
3651
3691
  */
3652
3692
  get legends(): Legend[] {
3653
3693
  const { featureFillEncoding, fillPalette, visibleDomainTypes } = this
3654
3694
  const byKey = new Map<string, Legend>()
3695
+ const ids = new Set<string>()
3655
3696
  // the accession palette keys on no field, so it takes a key of its own
3656
3697
  const add = (
3657
- field: string | undefined,
3698
+ key: string | undefined,
3699
+ title: string | undefined,
3658
3700
  entries: LegendEntry[],
3659
- id?: string,
3701
+ overlay = false,
3660
3702
  ) => {
3661
- if (entries.length === 0) {
3703
+ const k = key ?? 'domains'
3704
+ if (entries.length === 0 || byKey.has(k)) {
3662
3705
  return
3663
3706
  }
3664
- const key = field === undefined ? 'domains' : `field:${field}`
3665
- const legend = byKey.get(key)
3666
- if (legend) {
3667
- const seen = new Set(legend.entries.map(e => e.id))
3668
- legend.entries.push(...entries.filter(e => !seen.has(e.id)))
3669
- } else {
3670
- byKey.set(key, {
3671
- id: id ?? (field === undefined ? 'domains' : `rowData-${field}`),
3672
- title: field ?? 'Domains',
3673
- entries: [...entries],
3674
- })
3707
+ const base =
3708
+ overlay || key === undefined ? 'domains' : `rowData-${title}`
3709
+ let id = base
3710
+ for (let n = 2; ids.has(id); n++) {
3711
+ id = `${base}-${n}`
3675
3712
  }
3713
+ ids.add(id)
3714
+ byKey.set(k, { id, title: title ?? 'Domains', entries })
3676
3715
  }
3677
3716
  // the overlay draws the domain key; with no columns it draws nothing,
3678
3717
  // and a features panel taking the same colors lists them instead
3679
3718
  if (self.actuallyShowDomains && self.numColumns > 0) {
3680
3719
  add(
3681
- featureFillEncoding?.field,
3720
+ featureFillEncoding?.key,
3721
+ featureFillEncoding?.title,
3682
3722
  featureFillEncoding
3683
3723
  ? featureFillEncoding.legend
3684
3724
  : visibleDomainTypes.map(d => ({
@@ -3686,16 +3726,16 @@ function stateModelFactory({
3686
3726
  label: d.name,
3687
3727
  color: d.color ?? fillPalette[d.accession]!,
3688
3728
  })),
3689
- 'domains',
3729
+ true,
3690
3730
  )
3691
3731
  }
3692
- for (const { channel, field, legend } of this.resolvedEncodings) {
3732
+ for (const { channel, key, title, legend } of this.resolvedEncodings) {
3693
3733
  if (!featureChannels.has(channel)) {
3694
- add(field, legend)
3734
+ add(key, title, legend)
3695
3735
  }
3696
3736
  }
3697
- for (const { legendTitle, legend } of this.rowPanelScales) {
3698
- add(legendTitle, legend)
3737
+ for (const { legendKey, legendTitle, legend } of this.rowPanelScales) {
3738
+ add(legendKey, legendTitle, legend)
3699
3739
  }
3700
3740
  return [...byKey.values()]
3701
3741
  },
@@ -4001,23 +4041,68 @@ function stateModelFactory({
4001
4041
  * table or the encodings, never per row per frame.
4002
4042
  */
4003
4043
  get resolvedEncodings(): ResolvedEncoding[] {
4004
- const rows = Object.values(self.rowData)
4005
4044
  return self.encodings.map(encoding => ({
4006
4045
  ...encoding,
4007
- ...(featureChannels.has(encoding.channel)
4008
- ? resolveFeatureScale(
4009
- encoding.field,
4010
- encoding.scale,
4011
- self.allAnnotations,
4012
- self.filteredAnnotations,
4013
- )
4014
- : resolveScale(
4015
- encoding.scale,
4016
- rows.map(row => row?.[encoding.field]).filter(notEmpty),
4017
- )),
4046
+ ...this.scaleOf(
4047
+ encoding,
4048
+ featureChannels.has(encoding.channel) ? 'features' : 'rows',
4049
+ ),
4018
4050
  }))
4019
4051
  },
4020
4052
 
4053
+ /**
4054
+ * #getter
4055
+ * every color object the encodings and the row panels declare, keyed
4056
+ * by declaration (`declarationKey`) and resolved once over the union
4057
+ * of the values its records' fields take, so a value two strips share
4058
+ * takes one color in both. A feature field reads every feature loaded,
4059
+ * so hiding a type in the filter dialog leaves the rest in their
4060
+ * colors, and its legend lists the values of the drawn features.
4061
+ */
4062
+ get resolvedScales() {
4063
+ const rows = Object.values(self.rowData)
4064
+ const featureValues = (list: Annotation[], field: string) =>
4065
+ list.map(a => featureField(a, field)).filter(notEmpty)
4066
+ const use = (channel: ColorChannel, table: ScaleTable): ScaleUse => ({
4067
+ spec: channel,
4068
+ field: channel.field,
4069
+ table,
4070
+ ...(table === 'rows'
4071
+ ? {
4072
+ values: rows.map(row => row?.[channel.field]).filter(notEmpty),
4073
+ }
4074
+ : {
4075
+ values: featureValues(self.allAnnotations, channel.field),
4076
+ shown: featureValues(self.filteredAnnotations, channel.field),
4077
+ }),
4078
+ })
4079
+ return resolveShared([
4080
+ ...self.encodings.map(encoding =>
4081
+ use(
4082
+ encoding,
4083
+ featureChannels.has(encoding.channel) ? 'features' : 'rows',
4084
+ ),
4085
+ ),
4086
+ ...self.rowPanels.flatMap(panel =>
4087
+ panel.kind === 'strip'
4088
+ ? [use(panel, 'rows')]
4089
+ : panel.encoding?.color
4090
+ ? [use(panel.encoding.color, 'features')]
4091
+ : [],
4092
+ ),
4093
+ ])
4094
+ },
4095
+
4096
+ /**
4097
+ * #method
4098
+ * the shared scale a record's color object resolves to
4099
+ */
4100
+ scaleOf(channel: ColorChannel, table: ScaleTable): SharedScale {
4101
+ return this.resolvedScales.get(
4102
+ declarationKey({ spec: channel, field: channel.field, table }),
4103
+ )!
4104
+ },
4105
+
4021
4106
  /**
4022
4107
  * #getter
4023
4108
  * each row panel's scale resolved against the values its field takes,
@@ -4032,12 +4117,7 @@ function stateModelFactory({
4032
4117
  if (panel.kind === 'features') {
4033
4118
  const color = panel.encoding?.color
4034
4119
  const encoding = color
4035
- ? resolveFeatureScale(
4036
- color.field,
4037
- color.scale,
4038
- self.allAnnotations,
4039
- annotations,
4040
- )
4120
+ ? { field: color.field, ...this.scaleOf(color, 'features') }
4041
4121
  : featureFillEncoding
4042
4122
  const label = panel.encoding?.label
4043
4123
  return {
@@ -4045,7 +4125,8 @@ function stateModelFactory({
4045
4125
  x: panel.x,
4046
4126
  header: panel.header ?? '',
4047
4127
  field: encoding?.field,
4048
- legendTitle: encoding?.field,
4128
+ legendKey: encoding?.key,
4129
+ legendTitle: encoding?.title,
4049
4130
  colors: color
4050
4131
  ? featureColorMap(annotations, encoding, fillPalette)
4051
4132
  : this.featureColors,
@@ -4061,17 +4142,13 @@ function stateModelFactory({
4061
4142
  })),
4062
4143
  }
4063
4144
  }
4064
- const { colorOf, legend } = resolveScale(
4065
- panel.scale,
4066
- Object.values(rowData)
4067
- .map(row => row?.[panel.field])
4068
- .filter(notEmpty),
4069
- )
4145
+ const { colorOf, legend, key, title } = this.scaleOf(panel, 'rows')
4070
4146
  return {
4071
4147
  kind: panel.kind,
4072
4148
  field: panel.field,
4073
4149
  header: panel.header ?? panel.field,
4074
- legendTitle: panel.legend ?? panel.field,
4150
+ legendKey: key,
4151
+ legendTitle: title,
4075
4152
  colors: colorByRow(rowData, panel.field, colorOf),
4076
4153
  legend,
4077
4154
  }