@genome-spy/core 0.72.0 → 0.73.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 (103) hide show
  1. package/LICENSE +1 -1
  2. package/dist/bundle/index.es.js +6779 -5393
  3. package/dist/bundle/index.js +133 -121
  4. package/dist/schema.json +281 -17
  5. package/dist/src/data/formats/bed.d.ts +8 -0
  6. package/dist/src/data/formats/bed.d.ts.map +1 -0
  7. package/dist/src/data/formats/bed.js +53 -0
  8. package/dist/src/data/formats/bedpe.d.ts +8 -0
  9. package/dist/src/data/formats/bedpe.d.ts.map +1 -0
  10. package/dist/src/data/formats/bedpe.js +160 -0
  11. package/dist/src/data/sources/dataUtils.d.ts +16 -0
  12. package/dist/src/data/sources/dataUtils.d.ts.map +1 -1
  13. package/dist/src/data/sources/dataUtils.js +53 -3
  14. package/dist/src/data/sources/urlSource.d.ts +4 -0
  15. package/dist/src/data/sources/urlSource.d.ts.map +1 -1
  16. package/dist/src/data/sources/urlSource.js +133 -14
  17. package/dist/src/genome/assemblyPreflight.d.ts +31 -0
  18. package/dist/src/genome/assemblyPreflight.d.ts.map +1 -0
  19. package/dist/src/genome/assemblyPreflight.js +99 -0
  20. package/dist/src/genome/genome.d.ts +2 -2
  21. package/dist/src/genome/genome.d.ts.map +1 -1
  22. package/dist/src/genome/genome.js +4 -0
  23. package/dist/src/genome/genomeStore.d.ts +34 -3
  24. package/dist/src/genome/genomeStore.d.ts.map +1 -1
  25. package/dist/src/genome/genomeStore.js +409 -18
  26. package/dist/src/genome/rootGenomeConfig.d.ts +26 -0
  27. package/dist/src/genome/rootGenomeConfig.d.ts.map +1 -0
  28. package/dist/src/genome/rootGenomeConfig.js +94 -0
  29. package/dist/src/genomeSpy/interactionController.d.ts +5 -1
  30. package/dist/src/genomeSpy/interactionController.d.ts.map +1 -1
  31. package/dist/src/genomeSpy/interactionController.js +244 -29
  32. package/dist/src/genomeSpy/renderCoordinator.js +1 -1
  33. package/dist/src/genomeSpy.d.ts +13 -3
  34. package/dist/src/genomeSpy.d.ts.map +1 -1
  35. package/dist/src/genomeSpy.js +81 -7
  36. package/dist/src/gl/canvasSizeHelper.d.ts +74 -0
  37. package/dist/src/gl/canvasSizeHelper.d.ts.map +1 -0
  38. package/dist/src/gl/canvasSizeHelper.js +203 -0
  39. package/dist/src/gl/webGLHelper.d.ts +25 -11
  40. package/dist/src/gl/webGLHelper.d.ts.map +1 -1
  41. package/dist/src/gl/webGLHelper.js +59 -33
  42. package/dist/src/index.d.ts.map +1 -1
  43. package/dist/src/index.js +5 -2
  44. package/dist/src/marks/link.d.ts.map +1 -1
  45. package/dist/src/marks/link.js +5 -3
  46. package/dist/src/marks/mark.d.ts.map +1 -1
  47. package/dist/src/marks/mark.js +6 -1
  48. package/dist/src/scales/domainPlanner.d.ts +34 -3
  49. package/dist/src/scales/domainPlanner.d.ts.map +1 -1
  50. package/dist/src/scales/domainPlanner.js +247 -26
  51. package/dist/src/scales/scaleInstanceManager.d.ts +2 -1
  52. package/dist/src/scales/scaleInstanceManager.d.ts.map +1 -1
  53. package/dist/src/scales/scaleInstanceManager.js +10 -11
  54. package/dist/src/scales/scaleInteractionController.d.ts.map +1 -1
  55. package/dist/src/scales/scaleInteractionController.js +16 -14
  56. package/dist/src/scales/scaleResolution.d.ts +16 -0
  57. package/dist/src/scales/scaleResolution.d.ts.map +1 -1
  58. package/dist/src/scales/scaleResolution.js +314 -54
  59. package/dist/src/scales/scaleResolutionTestUtils.d.ts +21 -0
  60. package/dist/src/scales/scaleResolutionTestUtils.d.ts.map +1 -0
  61. package/dist/src/scales/scaleResolutionTestUtils.js +33 -0
  62. package/dist/src/scales/selectionDomainUtils.d.ts +22 -0
  63. package/dist/src/scales/selectionDomainUtils.d.ts.map +1 -0
  64. package/dist/src/scales/selectionDomainUtils.js +79 -0
  65. package/dist/src/scales/zoomDomainUtils.d.ts +18 -0
  66. package/dist/src/scales/zoomDomainUtils.d.ts.map +1 -0
  67. package/dist/src/scales/zoomDomainUtils.js +69 -0
  68. package/dist/src/screenshotHarness.d.ts +16 -0
  69. package/dist/src/screenshotHarness.d.ts.map +1 -0
  70. package/dist/src/screenshotHarness.js +242 -0
  71. package/dist/src/singlePageApp.js +1 -1
  72. package/dist/src/spec/data.d.ts +23 -3
  73. package/dist/src/spec/genome.d.ts +22 -2
  74. package/dist/src/spec/parameter.d.ts +39 -2
  75. package/dist/src/spec/root.d.ts +20 -1
  76. package/dist/src/spec/scale.d.ts +41 -5
  77. package/dist/src/styles/genome-spy.css +8 -0
  78. package/dist/src/styles/genome-spy.css.d.ts +1 -1
  79. package/dist/src/styles/genome-spy.css.d.ts.map +1 -1
  80. package/dist/src/styles/genome-spy.css.js +8 -0
  81. package/dist/src/tooltip/dataTooltipHandler.js +59 -10
  82. package/dist/src/types/embedApi.d.ts +19 -0
  83. package/dist/src/utils/inferSpecBaseUrl.d.ts +14 -0
  84. package/dist/src/utils/inferSpecBaseUrl.d.ts.map +1 -0
  85. package/dist/src/utils/inferSpecBaseUrl.js +73 -0
  86. package/dist/src/utils/interactionEvent.d.ts +53 -3
  87. package/dist/src/utils/interactionEvent.d.ts.map +1 -1
  88. package/dist/src/utils/interactionEvent.js +62 -1
  89. package/dist/src/view/containerMutationHelper.d.ts.map +1 -1
  90. package/dist/src/view/containerMutationHelper.js +8 -0
  91. package/dist/src/view/dataReadiness.d.ts +2 -2
  92. package/dist/src/view/dataReadiness.d.ts.map +1 -1
  93. package/dist/src/view/dataReadiness.js +63 -58
  94. package/dist/src/view/facetView.js +1 -1
  95. package/dist/src/view/gridView/gridChild.d.ts +7 -0
  96. package/dist/src/view/gridView/gridChild.d.ts.map +1 -1
  97. package/dist/src/view/gridView/gridChild.js +180 -11
  98. package/dist/src/view/gridView/gridView.d.ts.map +1 -1
  99. package/dist/src/view/gridView/gridView.js +60 -17
  100. package/dist/src/view/zoom.d.ts +14 -2
  101. package/dist/src/view/zoom.d.ts.map +1 -1
  102. package/dist/src/view/zoom.js +373 -76
  103. package/package.json +4 -2
@@ -24,10 +24,19 @@ import {
24
24
  } from "./scaleResolutionConstants.js";
25
25
 
26
26
  import { getAccessorDomainKey } from "../encoder/accessor.js";
27
- import { isSecondaryChannel } from "../encoder/encoder.js";
27
+ import {
28
+ isPrimaryPositionalChannel,
29
+ isSecondaryChannel,
30
+ } from "../encoder/encoder.js";
28
31
  import { NominalDomain } from "../utils/domainArray.js";
29
32
  import { shallowArrayEquals } from "../utils/arrayUtils.js";
30
33
  import createIndexer from "../utils/indexer.js";
34
+ import { resolveUrl } from "../utils/url.js";
35
+ import {
36
+ normalizeIntervalForSelection,
37
+ requireIntervalSelection,
38
+ requireParamRuntime,
39
+ } from "./selectionDomainUtils.js";
31
40
 
32
41
  // Register scaleLocus to Vega-Scale.
33
42
  // Loci are discrete but the scale's domain can be adjusted in a continuous manner.
@@ -111,6 +120,11 @@ export default class ScaleResolution {
111
120
 
112
121
  #categoricalIndexerExplicit = false;
113
122
 
123
+ /** @type {(() => void)[]} */
124
+ #selectionDomainParamUnsubscribers = [];
125
+
126
+ #selectionReverseSyncSuppressionDepth = 0;
127
+
114
128
  /**
115
129
  * @param {Channel} channel
116
130
  */
@@ -127,7 +141,7 @@ export default class ScaleResolution {
127
141
  getDataMembers: () =>
128
142
  this.#getActiveMembers(this.#dataDomainMembers),
129
143
  getType: () => this.type,
130
- getLocusExtent: () => this.#getLocusExtent(),
144
+ getLocusExtent: (assembly) => this.#getLocusExtent(assembly),
131
145
  fromComplexInterval: this.fromComplexInterval.bind(this),
132
146
  });
133
147
 
@@ -200,21 +214,24 @@ export default class ScaleResolution {
200
214
  }
201
215
 
202
216
  /**
217
+ * @param {import("../spec/scale.js").Scale["assembly"]} [assembly]
203
218
  * @returns {number[]}
204
219
  */
205
- #getLocusExtent() {
206
- return getGenomeExtent(this.#getGenomeSource());
220
+ #getLocusExtent(assembly) {
221
+ return getGenomeExtent(this.#getGenomeSource(assembly));
207
222
  }
208
223
 
209
224
  /**
225
+ * @param {import("../spec/scale.js").Scale["assembly"]} [assembly]
210
226
  * @returns {import("../genome/scaleLocus.js").GenomeSource}
211
227
  */
212
- #getGenomeSource() {
228
+ #getGenomeSource(assembly) {
213
229
  if (this.type !== LOCUS) {
214
230
  return undefined;
215
231
  }
216
232
  return /** @type {import("../genome/scaleLocus.js").GenomeSource} */ (
217
- this.#scaleManager.scale ?? this.#scaleManager.getLocusGenome()
233
+ this.#scaleManager.scale ??
234
+ this.#scaleManager.getLocusGenome(assembly)
218
235
  );
219
236
  }
220
237
 
@@ -242,6 +259,13 @@ export default class ScaleResolution {
242
259
  * @param {ScaleResolutionEventType} type
243
260
  */
244
261
  #notifyListeners(type) {
262
+ if (
263
+ type === "domain" &&
264
+ this.#selectionReverseSyncSuppressionDepth === 0
265
+ ) {
266
+ this.#syncLinkedSelectionFromDomain();
267
+ }
268
+
245
269
  for (const listener of this.#listeners[type].values()) {
246
270
  listener({
247
271
  type,
@@ -250,14 +274,92 @@ export default class ScaleResolution {
250
274
  }
251
275
  }
252
276
 
277
+ /**
278
+ * @param {() => void} callback
279
+ */
280
+ #withSelectionReverseSyncSuppressed(callback) {
281
+ this.#selectionReverseSyncSuppressionDepth += 1;
282
+ try {
283
+ callback();
284
+ } finally {
285
+ this.#selectionReverseSyncSuppressionDepth -= 1;
286
+ }
287
+ }
288
+
289
+ #syncLinkedSelectionFromDomain() {
290
+ const linkInfo =
291
+ this.#domainAggregator.getSelectionConfiguredDomainInfo();
292
+ if (!linkInfo) {
293
+ return;
294
+ }
295
+
296
+ const shouldReverseSync =
297
+ linkInfo.sync === "twoWay" ||
298
+ (linkInfo.sync === "auto" && this.isZoomable());
299
+ if (!shouldReverseSync) {
300
+ return;
301
+ }
302
+
303
+ const runtime = requireParamRuntime(
304
+ this.#firstMemberView.paramRuntime,
305
+ linkInfo.param
306
+ );
307
+
308
+ const selection = requireIntervalSelection(
309
+ runtime.getValue(linkInfo.param),
310
+ linkInfo.param
311
+ );
312
+
313
+ const interval = this.#normalizeDomainIntervalForLinkedSelection(
314
+ this.getScale().domain()
315
+ );
316
+ if (!interval) {
317
+ return;
318
+ }
319
+
320
+ const fallbackInterval =
321
+ this.#normalizeDomainIntervalForLinkedSelection(
322
+ this.#domainAggregator.getDefaultDomain(true)
323
+ );
324
+
325
+ const syncedInterval =
326
+ fallbackInterval && shallowArrayEquals(interval, fallbackInterval)
327
+ ? null
328
+ : interval;
329
+
330
+ const previousInterval = selection.intervals[linkInfo.encoding] ?? null;
331
+ if (intervalsEqual(previousInterval, syncedInterval)) {
332
+ return;
333
+ }
334
+
335
+ runtime.setValue(linkInfo.param, {
336
+ ...selection,
337
+ type: "interval",
338
+ intervals: {
339
+ ...selection.intervals,
340
+ [linkInfo.encoding]: syncedInterval,
341
+ },
342
+ });
343
+ }
344
+
345
+ /**
346
+ * @param {any[]} domain
347
+ * @returns {[number, number] | undefined}
348
+ */
349
+ #normalizeDomainIntervalForLinkedSelection(domain) {
350
+ return normalizeIntervalForSelection(domain, this.zoomExtent);
351
+ }
352
+
253
353
  /**
254
354
  * Add a view to this resolution.
255
355
  * N.B. This is expected to be called in depth-first order
256
356
  *
257
357
  * @param {ScaleResolutionMember} newMember
358
+ * @returns {ScaleResolutionMember}
258
359
  */
259
360
  #addMember(newMember) {
260
- const { channel, channelDef } = newMember;
361
+ const member = normalizeMember(newMember);
362
+ const { channel, channelDef } = member;
261
363
 
262
364
  // A convenience hack for cases where the new member should adapt
263
365
  // the scale type to the existing one. For example: SelectionRect
@@ -283,6 +385,20 @@ export default class ScaleResolution {
283
385
  // @ts-expect-error "sample" is not really a channel with scale
284
386
  const type = channel == "sample" ? "nominal" : channelDef.type;
285
387
  const name = channelDef?.scale?.name;
388
+ const explicitScaleType = channelDef.scale?.type;
389
+ const effectiveScaleType =
390
+ explicitScaleType ??
391
+ (type === INDEX || type === LOCUS ? type : undefined);
392
+
393
+ if (
394
+ effectiveScaleType &&
395
+ [INDEX, LOCUS].includes(effectiveScaleType) &&
396
+ !isPrimaryPositionalChannel(this.channel)
397
+ ) {
398
+ throw new Error(
399
+ `Index and locus scales are only supported on positional channels (x/y). Channel "${this.channel}" resolves to scale type "${effectiveScaleType}".`
400
+ );
401
+ }
286
402
 
287
403
  if (name) {
288
404
  if (this.name !== undefined && name != this.name) {
@@ -306,11 +422,13 @@ export default class ScaleResolution {
306
422
  }
307
423
  }
308
424
 
309
- this.#members.add(newMember);
310
- if (newMember.contributesToDomain) {
311
- this.#dataDomainMembers.add(newMember);
425
+ this.#members.add(member);
426
+ if (member.contributesToDomain) {
427
+ this.#dataDomainMembers.add(member);
312
428
  }
313
429
  this.#domainAggregator.invalidateConfiguredDomain();
430
+ this.#refreshSelectionDomainParamSubscriptions();
431
+ return member;
314
432
  }
315
433
 
316
434
  /**
@@ -318,23 +436,58 @@ export default class ScaleResolution {
318
436
  * @returns {() => boolean}
319
437
  */
320
438
  registerMember(member) {
321
- this.#addMember(member);
439
+ const registeredMember = this.#addMember(member);
322
440
  return () => {
323
- const removed = this.#members.delete(member);
441
+ const removed = this.#members.delete(registeredMember);
324
442
  if (removed) {
325
- this.#dataDomainMembers.delete(member);
443
+ this.#dataDomainMembers.delete(registeredMember);
326
444
  this.#domainAggregator.invalidateConfiguredDomain();
445
+ this.#refreshSelectionDomainParamSubscriptions();
327
446
  }
328
447
  return removed && this.#members.size === 0;
329
448
  };
330
449
  }
331
450
 
332
451
  dispose() {
452
+ this.#clearSelectionDomainParamSubscriptions();
333
453
  this.#listeners.domain.clear();
334
454
  this.#listeners.range.clear();
335
455
  this.#scaleManager.dispose();
336
456
  }
337
457
 
458
+ #clearSelectionDomainParamSubscriptions() {
459
+ for (const unsubscribe of this.#selectionDomainParamUnsubscribers) {
460
+ unsubscribe();
461
+ }
462
+ this.#selectionDomainParamUnsubscribers = [];
463
+ }
464
+
465
+ #refreshSelectionDomainParamSubscriptions() {
466
+ this.#clearSelectionDomainParamSubscriptions();
467
+
468
+ if (this.#members.size === 0) {
469
+ return;
470
+ }
471
+
472
+ const linkInfo =
473
+ this.#domainAggregator.getSelectionConfiguredDomainInfo();
474
+ if (!linkInfo) {
475
+ return;
476
+ }
477
+
478
+ const runtime = requireParamRuntime(
479
+ this.#firstMemberView.paramRuntime,
480
+ linkInfo.param
481
+ );
482
+
483
+ this.#selectionDomainParamUnsubscribers.push(
484
+ runtime.subscribe(linkInfo.param, () => {
485
+ this.#domainAggregator.invalidateConfiguredDomain();
486
+ this.reconfigureDomain();
487
+ })
488
+ );
489
+ }
490
+
338
491
  #hasRenderedMember() {
339
492
  for (const member of this.#members) {
340
493
  if (member.view.hasRendered()) {
@@ -424,6 +577,33 @@ export default class ScaleResolution {
424
577
  });
425
578
  }
426
579
 
580
+ /**
581
+ * Returns locus assembly requirements without initializing the scale.
582
+ *
583
+ * This is intentionally side-effect free: it only inspects merged scale
584
+ * properties from registered members and does not touch default domains or
585
+ * instantiate scale instances.
586
+ *
587
+ * @returns {{
588
+ * assembly: import("../spec/scale.js").Scale["assembly"] | undefined,
589
+ * needsDefaultAssembly: boolean
590
+ * }}
591
+ */
592
+ getAssemblyRequirement() {
593
+ const props = this.#getMergedScaleProps();
594
+ if (props === null || props.type === "null" || props.type !== LOCUS) {
595
+ return {
596
+ assembly: undefined,
597
+ needsDefaultAssembly: false,
598
+ };
599
+ }
600
+
601
+ return {
602
+ assembly: props.assembly,
603
+ needsDefaultAssembly: props.assembly === undefined,
604
+ };
605
+ }
606
+
427
607
  /**
428
608
  * Returns the merged scale properties supplemented with inferred properties
429
609
  * and domain.
@@ -439,10 +619,10 @@ export default class ScaleResolution {
439
619
  return { type: "null" };
440
620
  }
441
621
 
442
- const domain =
443
- this.#domainAggregator.getConfiguredOrDefaultDomain(
444
- extractDataDomain
445
- );
622
+ const domain = this.#domainAggregator.getConfiguredOrDefaultDomain(
623
+ extractDataDomain,
624
+ props.type === LOCUS ? props.assembly : undefined
625
+ );
446
626
 
447
627
  if (isDiscrete(props.type)) {
448
628
  const isExplicit = this.#isExplicitDomain();
@@ -512,15 +692,17 @@ export default class ScaleResolution {
512
692
  * or when scale properties are otherwise re-resolved from the view hierarchy.
513
693
  */
514
694
  reconfigure() {
515
- this.#domainAggregator.invalidateConfiguredDomain();
516
- const state = this.#computeScaleState(true);
517
- if (!state) {
518
- return;
519
- }
520
- this.#applyReconfigure(state, (scale, props) =>
521
- this.#scaleManager.reconfigureScale(props)
522
- );
523
- this.#finalizeReconfigure(state);
695
+ this.#withSelectionReverseSyncSuppressed(() => {
696
+ this.#domainAggregator.invalidateConfiguredDomain();
697
+ const state = this.#computeScaleState(true);
698
+ if (!state) {
699
+ return;
700
+ }
701
+ this.#applyReconfigure(state, (scale, props) =>
702
+ this.#scaleManager.reconfigureScale(props)
703
+ );
704
+ this.#finalizeReconfigure(state);
705
+ });
524
706
  }
525
707
 
526
708
  /**
@@ -530,28 +712,30 @@ export default class ScaleResolution {
530
712
  *
531
713
  */
532
714
  reconfigureDomain() {
533
- const state = this.#computeScaleState(true, true);
534
- if (!state) {
535
- return;
536
- }
537
- const { domainConfig, targetDomain } = state;
538
- const domainMatches =
539
- targetDomain != null &&
540
- shallowArrayEquals(targetDomain, state.scale.domain());
541
-
542
- if (targetDomain != null && !domainMatches) {
543
- this.#applyReconfigure(state, (scale) => {
544
- scale.domain(targetDomain);
545
- if (domainConfig.applyOrdinalUnknown) {
546
- // Keep ordinal unknown handling close to the domain write so
547
- // domainImplicit semantics stay aligned with the applied domain.
548
- /** @type {any} */ (scale).unknown(
549
- domainConfig.ordinalUnknown
550
- );
551
- }
552
- });
553
- }
554
- this.#finalizeReconfigure(state);
715
+ this.#withSelectionReverseSyncSuppressed(() => {
716
+ const state = this.#computeScaleState(true, true);
717
+ if (!state) {
718
+ return;
719
+ }
720
+ const { domainConfig, targetDomain } = state;
721
+ const domainMatches =
722
+ targetDomain != null &&
723
+ shallowArrayEquals(targetDomain, state.scale.domain());
724
+
725
+ if (targetDomain != null && !domainMatches) {
726
+ this.#applyReconfigure(state, (scale) => {
727
+ scale.domain(targetDomain);
728
+ if (domainConfig.applyOrdinalUnknown) {
729
+ // Keep ordinal unknown handling close to the domain write so
730
+ // domainImplicit semantics stay aligned with the applied domain.
731
+ /** @type {any} */ (scale).unknown(
732
+ domainConfig.ordinalUnknown
733
+ );
734
+ }
735
+ });
736
+ }
737
+ this.#finalizeReconfigure(state);
738
+ });
555
739
  }
556
740
 
557
741
  /**
@@ -562,6 +746,7 @@ export default class ScaleResolution {
562
746
  * props: import("../spec/scale.js").Scale,
563
747
  * previousDomain: any[],
564
748
  * domainWasInitialized: boolean,
749
+ * hasSelectionConfiguredDomain: boolean,
565
750
  * domainConfig?: ReturnType<typeof configureDomain>,
566
751
  * targetDomain?: any[] | null,
567
752
  * } | undefined}
@@ -578,6 +763,8 @@ export default class ScaleResolution {
578
763
  props: this.#getScaleProps(extractDataDomain),
579
764
  previousDomain: scale.domain(),
580
765
  domainWasInitialized: this.#isDomainInitialized(),
766
+ hasSelectionConfiguredDomain:
767
+ this.#domainAggregator.hasSelectionConfiguredDomain(),
581
768
  };
582
769
 
583
770
  if (includeDomainConfig) {
@@ -610,10 +797,16 @@ export default class ScaleResolution {
610
797
  * scale: ScaleWithProps,
611
798
  * previousDomain: any[],
612
799
  * domainWasInitialized: boolean,
800
+ * hasSelectionConfiguredDomain: boolean,
613
801
  * }} inputs
614
802
  */
615
803
  #finalizeReconfigure(inputs) {
616
- const { scale, previousDomain, domainWasInitialized } = inputs;
804
+ const {
805
+ scale,
806
+ previousDomain,
807
+ domainWasInitialized,
808
+ hasSelectionConfiguredDomain,
809
+ } = inputs;
617
810
 
618
811
  if (
619
812
  this.#domainAggregator.captureInitialDomain(
@@ -633,12 +826,22 @@ export default class ScaleResolution {
633
826
  );
634
827
 
635
828
  if (action === "restore") {
636
- // Don't mess with zoomed views, restore the previous domain
637
- this.#scaleManager.withDomainNotificationsSuppressed(() => {
638
- scale.domain(previousDomain);
639
- });
829
+ if (hasSelectionConfiguredDomain) {
830
+ // Selection-linked domains are the source of truth and must not
831
+ // be overridden by previously zoomed domains.
832
+ this.#notifyListeners("domain");
833
+ } else {
834
+ // Don't mess with zoomed views, restore the previous domain
835
+ this.#scaleManager.withDomainNotificationsSuppressed(() => {
836
+ scale.domain(previousDomain);
837
+ });
838
+ }
640
839
  } else if (action === "animate") {
641
- if (this.#hasRenderedMember()) {
840
+ if (hasSelectionConfiguredDomain) {
841
+ // Linked domains can update continuously (e.g., brushing), so
842
+ // skip zoomTo transitions and apply domain updates directly.
843
+ this.#notifyListeners("domain");
844
+ } else if (this.#hasRenderedMember()) {
642
845
  // It can be zoomed, so lets make a smooth transition.
643
846
  // Restore the previous domain and zoom smoothly to the new domain.
644
847
  this.#scaleManager.withDomainNotificationsSuppressed(() => {
@@ -847,3 +1050,60 @@ export default class ScaleResolution {
847
1050
  return /** @type {number[]} */ (interval);
848
1051
  }
849
1052
  }
1053
+
1054
+ /**
1055
+ * @param {number[] | null} a
1056
+ * @param {number[] | null} b
1057
+ * @returns {boolean}
1058
+ */
1059
+ function intervalsEqual(a, b) {
1060
+ if (a === b) {
1061
+ return true;
1062
+ }
1063
+
1064
+ if (!a || !b) {
1065
+ return false;
1066
+ }
1067
+
1068
+ return a.length === b.length && shallowArrayEquals(a, b);
1069
+ }
1070
+
1071
+ /**
1072
+ * Normalizes member-specific scale URLs so that inline `scale.assembly.url`
1073
+ * values resolve against the member view's base URL before scale props are
1074
+ * merged.
1075
+ *
1076
+ * @template {ChannelWithScale}[T=ChannelWithScale]
1077
+ * @param {ScaleResolutionMember<T>} member
1078
+ * @returns {ScaleResolutionMember<T>}
1079
+ */
1080
+ function normalizeMember(member) {
1081
+ const scale = member.channelDef.scale;
1082
+ const assembly = scale?.assembly;
1083
+ if (!scale || !assembly || typeof assembly !== "object") {
1084
+ return member;
1085
+ }
1086
+
1087
+ if (!("url" in assembly)) {
1088
+ return member;
1089
+ }
1090
+
1091
+ const resolvedUrl = resolveUrl(member.view.getBaseUrl(), assembly.url);
1092
+ if (resolvedUrl === assembly.url) {
1093
+ return member;
1094
+ }
1095
+
1096
+ return {
1097
+ ...member,
1098
+ channelDef: {
1099
+ ...member.channelDef,
1100
+ scale: {
1101
+ ...scale,
1102
+ assembly: {
1103
+ ...assembly,
1104
+ url: resolvedUrl,
1105
+ },
1106
+ },
1107
+ },
1108
+ };
1109
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @param {import("../spec/view.js").ViewSpec} spec
3
+ * @param {{ new(...args: any[]): import("../view/view.js").default }} [viewType]
4
+ * @returns {Promise<import("../view/view.js").default>}
5
+ */
6
+ export function initView(spec: import("../spec/view.js").ViewSpec, viewType?: {
7
+ new (...args: any[]): import("../view/view.js").default;
8
+ }): Promise<import("../view/view.js").default>;
9
+ /**
10
+ * @param {import("../view/view.js").default} view
11
+ * @param {import("../spec/channel.js").ChannelWithScale} channel
12
+ * @returns {import("./scaleResolution.js").default}
13
+ */
14
+ export function getRequiredScaleResolution(view: import("../view/view.js").default, channel: import("../spec/channel.js").ChannelWithScale): import("./scaleResolution.js").default;
15
+ /**
16
+ * @param {import("../view/view.js").default} view
17
+ * @param {import("../spec/channel.js").ChannelWithScale} channel
18
+ * @returns {any[]}
19
+ */
20
+ export function getScaleDomain(view: import("../view/view.js").default, channel: import("../spec/channel.js").ChannelWithScale): any[];
21
+ //# sourceMappingURL=scaleResolutionTestUtils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scaleResolutionTestUtils.d.ts","sourceRoot":"","sources":["../../../src/scales/scaleResolutionTestUtils.js"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,+BAJW,OAAO,iBAAiB,EAAE,QAAQ,aAClC;IAAE,KAAI,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,iBAAiB,EAAE,OAAO,CAAA;CAAE,GACxD,OAAO,CAAC,OAAO,iBAAiB,EAAE,OAAO,CAAC,CAItD;AAED;;;;GAIG;AACH,iDAJW,OAAO,iBAAiB,EAAE,OAAO,WACjC,OAAO,oBAAoB,EAAE,gBAAgB,GAC3C,OAAO,sBAAsB,EAAE,OAAO,CAQlD;AAED;;;;GAIG;AACH,qCAJW,OAAO,iBAAiB,EAAE,OAAO,WACjC,OAAO,oBAAoB,EAAE,gBAAgB,GAC3C,GAAG,EAAE,CAIjB"}
@@ -0,0 +1,33 @@
1
+ import { createAndInitialize } from "../view/testUtils.js";
2
+ import UnitView from "../view/unitView.js";
3
+
4
+ /**
5
+ * @param {import("../spec/view.js").ViewSpec} spec
6
+ * @param {{ new(...args: any[]): import("../view/view.js").default }} [viewType]
7
+ * @returns {Promise<import("../view/view.js").default>}
8
+ */
9
+ export async function initView(spec, viewType = UnitView) {
10
+ return createAndInitialize(spec, viewType);
11
+ }
12
+
13
+ /**
14
+ * @param {import("../view/view.js").default} view
15
+ * @param {import("../spec/channel.js").ChannelWithScale} channel
16
+ * @returns {import("./scaleResolution.js").default}
17
+ */
18
+ export function getRequiredScaleResolution(view, channel) {
19
+ const resolution = view.getScaleResolution(channel);
20
+ if (!resolution) {
21
+ throw new Error(`Expected ${channel} scale resolution.`);
22
+ }
23
+ return resolution;
24
+ }
25
+
26
+ /**
27
+ * @param {import("../view/view.js").default} view
28
+ * @param {import("../spec/channel.js").ChannelWithScale} channel
29
+ * @returns {any[]}
30
+ */
31
+ export function getScaleDomain(view, channel) {
32
+ return getRequiredScaleResolution(view, channel).scale.domain();
33
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @param {{ findRuntimeForParam: (name: string) => any }} paramRuntime
3
+ * @param {string} paramName
4
+ */
5
+ export function requireParamRuntime(paramRuntime: {
6
+ findRuntimeForParam: (name: string) => any;
7
+ }, paramName: string): any;
8
+ /**
9
+ * @param {any} selection
10
+ * @param {string} paramName
11
+ */
12
+ export function requireIntervalSelection(selection: any, paramName: string): import("../types/selectionTypes.js").IntervalSelection;
13
+ /**
14
+ * @param {number[]} interval
15
+ * @param {number[]} zoomExtent
16
+ * @param {{ roundToIntegers?: boolean }} [options]
17
+ * @returns {[number, number] | undefined}
18
+ */
19
+ export function normalizeIntervalForSelection(interval: number[], zoomExtent: number[], options?: {
20
+ roundToIntegers?: boolean;
21
+ }): [number, number] | undefined;
22
+ //# sourceMappingURL=selectionDomainUtils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selectionDomainUtils.d.ts","sourceRoot":"","sources":["../../../src/scales/selectionDomainUtils.js"],"names":[],"mappings":"AAEA;;;GAGG;AACH,kDAHW;IAAE,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,CAAA;CAAE,aAC9C,MAAM,OAUhB;AAED;;;GAGG;AACH,oDAHW,GAAG,aACH,MAAM,0DAgBhB;AAED;;;;;GAKG;AACH,wDALW,MAAM,EAAE,cACR,MAAM,EAAE,YACR;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAE,GAC3B,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAsCxC"}
@@ -0,0 +1,79 @@
1
+ import { isIntervalSelection } from "../selection/selection.js";
2
+
3
+ /**
4
+ * @param {{ findRuntimeForParam: (name: string) => any }} paramRuntime
5
+ * @param {string} paramName
6
+ */
7
+ export function requireParamRuntime(paramRuntime, paramName) {
8
+ const runtime = paramRuntime.findRuntimeForParam(paramName);
9
+ if (!runtime) {
10
+ throw new Error(
11
+ `Selection domain parameter "${paramName}" was not found.`
12
+ );
13
+ }
14
+ return runtime;
15
+ }
16
+
17
+ /**
18
+ * @param {any} selection
19
+ * @param {string} paramName
20
+ */
21
+ export function requireIntervalSelection(selection, paramName) {
22
+ if (!selection) {
23
+ throw new Error(
24
+ `Selection domain parameter "${paramName}" was not found.`
25
+ );
26
+ }
27
+
28
+ if (!isIntervalSelection(selection)) {
29
+ throw new Error(
30
+ `Selection domain parameter "${paramName}" must be an interval selection.`
31
+ );
32
+ }
33
+
34
+ return selection;
35
+ }
36
+
37
+ /**
38
+ * @param {number[]} interval
39
+ * @param {number[]} zoomExtent
40
+ * @param {{ roundToIntegers?: boolean }} [options]
41
+ * @returns {[number, number] | undefined}
42
+ */
43
+ export function normalizeIntervalForSelection(
44
+ interval,
45
+ zoomExtent,
46
+ options = {}
47
+ ) {
48
+ if (!interval || interval.length !== 2) {
49
+ return;
50
+ }
51
+
52
+ const a = Number(interval[0]);
53
+ const b = Number(interval[1]);
54
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
55
+ return;
56
+ }
57
+
58
+ let min = Math.min(a, b);
59
+ let max = Math.max(a, b);
60
+
61
+ min = Math.max(zoomExtent[0], min);
62
+ max = Math.min(zoomExtent[1], max);
63
+
64
+ if (min > max) {
65
+ return;
66
+ }
67
+
68
+ if (options.roundToIntegers) {
69
+ min = Math.ceil(min);
70
+ max = Math.ceil(max);
71
+ min = Math.max(zoomExtent[0], min);
72
+ max = Math.min(zoomExtent[1], max);
73
+ if (min > max) {
74
+ return;
75
+ }
76
+ }
77
+
78
+ return [min, max];
79
+ }