react-msaview 9.2.0 → 9.4.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 +38 -18
  40. package/dist/model.js +107 -56
  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 +159 -74
  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
@@ -3554,12 +3595,20 @@ function stateModelFactory({
3554
3595
  /**
3555
3596
  * #getter
3556
3597
  * categorical feature types (InterPro domains and the like) that each get
3557
- * their own color and a legend entry
3598
+ * their own color and a legend entry, in palette order: grouped by
3599
+ * feature type, types in the order they first appear. A GenPept
3600
+ * record's CDD Sites follow every Region, so the palette spends the
3601
+ * colors after the domains' on the sites inside them
3558
3602
  */
3559
3603
  get categoricalDomainTypes() {
3560
- return [...self.annotationTypes.values()].filter(
3604
+ const types = [...self.annotationTypes.values()].filter(
3561
3605
  d => !segmentFeatureTypes.has(d.featureType ?? ''),
3562
3606
  )
3607
+ const typeOrder = [...new Set(types.map(d => d.featureType))]
3608
+ return types.toSorted(
3609
+ (a, b) =>
3610
+ typeOrder.indexOf(a.featureType) - typeOrder.indexOf(b.featureType),
3611
+ )
3563
3612
  },
3564
3613
  get fillPalette() {
3565
3614
  const segments = Object.fromEntries(
@@ -3643,42 +3692,41 @@ function stateModelFactory({
3643
3692
  * the categorical color keys drawn for this view, shared by the on-screen
3644
3693
  * legend overlay and the SVG export's reserved column. The domain overlay
3645
3694
  * 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
3695
+ * names one. Every scale a row-table encoding or a row panel reads
3696
+ * produces one more, keyed by its declaration, so two channels or two
3697
+ * strips declaring one scale list it once, and strips over eight fields
3698
+ * naming one `title` list one legend between them
3651
3699
  */
3652
3700
  get legends(): Legend[] {
3653
3701
  const { featureFillEncoding, fillPalette, visibleDomainTypes } = this
3654
3702
  const byKey = new Map<string, Legend>()
3703
+ const ids = new Set<string>()
3655
3704
  // the accession palette keys on no field, so it takes a key of its own
3656
3705
  const add = (
3657
- field: string | undefined,
3706
+ key: string | undefined,
3707
+ title: string | undefined,
3658
3708
  entries: LegendEntry[],
3659
- id?: string,
3709
+ overlay = false,
3660
3710
  ) => {
3661
- if (entries.length === 0) {
3711
+ const k = key ?? 'domains'
3712
+ if (entries.length === 0 || byKey.has(k)) {
3662
3713
  return
3663
3714
  }
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
- })
3715
+ const base =
3716
+ overlay || key === undefined ? 'domains' : `rowData-${title}`
3717
+ let id = base
3718
+ for (let n = 2; ids.has(id); n++) {
3719
+ id = `${base}-${n}`
3675
3720
  }
3721
+ ids.add(id)
3722
+ byKey.set(k, { id, title: title ?? 'Domains', entries })
3676
3723
  }
3677
3724
  // the overlay draws the domain key; with no columns it draws nothing,
3678
3725
  // and a features panel taking the same colors lists them instead
3679
3726
  if (self.actuallyShowDomains && self.numColumns > 0) {
3680
3727
  add(
3681
- featureFillEncoding?.field,
3728
+ featureFillEncoding?.key,
3729
+ featureFillEncoding?.title,
3682
3730
  featureFillEncoding
3683
3731
  ? featureFillEncoding.legend
3684
3732
  : visibleDomainTypes.map(d => ({
@@ -3686,16 +3734,16 @@ function stateModelFactory({
3686
3734
  label: d.name,
3687
3735
  color: d.color ?? fillPalette[d.accession]!,
3688
3736
  })),
3689
- 'domains',
3737
+ true,
3690
3738
  )
3691
3739
  }
3692
- for (const { channel, field, legend } of this.resolvedEncodings) {
3740
+ for (const { channel, key, title, legend } of this.resolvedEncodings) {
3693
3741
  if (!featureChannels.has(channel)) {
3694
- add(field, legend)
3742
+ add(key, title, legend)
3695
3743
  }
3696
3744
  }
3697
- for (const { legendTitle, legend } of this.rowPanelScales) {
3698
- add(legendTitle, legend)
3745
+ for (const { legendKey, legendTitle, legend } of this.rowPanelScales) {
3746
+ add(legendKey, legendTitle, legend)
3699
3747
  }
3700
3748
  return [...byKey.values()]
3701
3749
  },
@@ -4001,23 +4049,68 @@ function stateModelFactory({
4001
4049
  * table or the encodings, never per row per frame.
4002
4050
  */
4003
4051
  get resolvedEncodings(): ResolvedEncoding[] {
4004
- const rows = Object.values(self.rowData)
4005
4052
  return self.encodings.map(encoding => ({
4006
4053
  ...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
- )),
4054
+ ...this.scaleOf(
4055
+ encoding,
4056
+ featureChannels.has(encoding.channel) ? 'features' : 'rows',
4057
+ ),
4018
4058
  }))
4019
4059
  },
4020
4060
 
4061
+ /**
4062
+ * #getter
4063
+ * every color object the encodings and the row panels declare, keyed
4064
+ * by declaration (`declarationKey`) and resolved once over the union
4065
+ * of the values its records' fields take, so a value two strips share
4066
+ * takes one color in both. A feature field reads every feature loaded,
4067
+ * so hiding a type in the filter dialog leaves the rest in their
4068
+ * colors, and its legend lists the values of the drawn features.
4069
+ */
4070
+ get resolvedScales() {
4071
+ const rows = Object.values(self.rowData)
4072
+ const featureValues = (list: Annotation[], field: string) =>
4073
+ list.map(a => featureField(a, field)).filter(notEmpty)
4074
+ const use = (channel: ColorChannel, table: ScaleTable): ScaleUse => ({
4075
+ spec: channel,
4076
+ field: channel.field,
4077
+ table,
4078
+ ...(table === 'rows'
4079
+ ? {
4080
+ values: rows.map(row => row?.[channel.field]).filter(notEmpty),
4081
+ }
4082
+ : {
4083
+ values: featureValues(self.allAnnotations, channel.field),
4084
+ shown: featureValues(self.filteredAnnotations, channel.field),
4085
+ }),
4086
+ })
4087
+ return resolveShared([
4088
+ ...self.encodings.map(encoding =>
4089
+ use(
4090
+ encoding,
4091
+ featureChannels.has(encoding.channel) ? 'features' : 'rows',
4092
+ ),
4093
+ ),
4094
+ ...self.rowPanels.flatMap(panel =>
4095
+ panel.kind === 'strip'
4096
+ ? [use(panel, 'rows')]
4097
+ : panel.encoding?.color
4098
+ ? [use(panel.encoding.color, 'features')]
4099
+ : [],
4100
+ ),
4101
+ ])
4102
+ },
4103
+
4104
+ /**
4105
+ * #method
4106
+ * the shared scale a record's color object resolves to
4107
+ */
4108
+ scaleOf(channel: ColorChannel, table: ScaleTable): SharedScale {
4109
+ return this.resolvedScales.get(
4110
+ declarationKey({ spec: channel, field: channel.field, table }),
4111
+ )!
4112
+ },
4113
+
4021
4114
  /**
4022
4115
  * #getter
4023
4116
  * each row panel's scale resolved against the values its field takes,
@@ -4032,12 +4125,7 @@ function stateModelFactory({
4032
4125
  if (panel.kind === 'features') {
4033
4126
  const color = panel.encoding?.color
4034
4127
  const encoding = color
4035
- ? resolveFeatureScale(
4036
- color.field,
4037
- color.scale,
4038
- self.allAnnotations,
4039
- annotations,
4040
- )
4128
+ ? { field: color.field, ...this.scaleOf(color, 'features') }
4041
4129
  : featureFillEncoding
4042
4130
  const label = panel.encoding?.label
4043
4131
  return {
@@ -4045,7 +4133,8 @@ function stateModelFactory({
4045
4133
  x: panel.x,
4046
4134
  header: panel.header ?? '',
4047
4135
  field: encoding?.field,
4048
- legendTitle: encoding?.field,
4136
+ legendKey: encoding?.key,
4137
+ legendTitle: encoding?.title,
4049
4138
  colors: color
4050
4139
  ? featureColorMap(annotations, encoding, fillPalette)
4051
4140
  : this.featureColors,
@@ -4061,17 +4150,13 @@ function stateModelFactory({
4061
4150
  })),
4062
4151
  }
4063
4152
  }
4064
- const { colorOf, legend } = resolveScale(
4065
- panel.scale,
4066
- Object.values(rowData)
4067
- .map(row => row?.[panel.field])
4068
- .filter(notEmpty),
4069
- )
4153
+ const { colorOf, legend, key, title } = this.scaleOf(panel, 'rows')
4070
4154
  return {
4071
4155
  kind: panel.kind,
4072
4156
  field: panel.field,
4073
4157
  header: panel.header ?? panel.field,
4074
- legendTitle: panel.legend ?? panel.field,
4158
+ legendKey: key,
4159
+ legendTitle: title,
4075
4160
  colors: colorByRow(rowData, panel.field, colorOf),
4076
4161
  legend,
4077
4162
  }