@genome-spy/core 0.19.0 → 0.21.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 (101) hide show
  1. package/dist/index.js +46 -119
  2. package/dist/schema.json +213 -25
  3. package/package.json +4 -3
  4. package/src/data/collector.test.js +2 -0
  5. package/src/data/dataFlow.test.js +2 -0
  6. package/src/data/flow.test.js +1 -0
  7. package/src/data/flowNode.test.js +1 -0
  8. package/src/data/flowOptimizer.test.js +1 -0
  9. package/src/data/formats/fasta.test.js +1 -0
  10. package/src/data/sources/inlineSource.test.js +1 -0
  11. package/src/data/sources/sequenceSource.test.js +1 -0
  12. package/src/data/transforms/clone.test.js +1 -0
  13. package/src/data/transforms/coverage.test.js +1 -0
  14. package/src/data/transforms/filter.test.js +1 -0
  15. package/src/data/transforms/flattenDelimited.test.js +1 -0
  16. package/src/data/transforms/flattenSequence.test.js +1 -0
  17. package/src/data/transforms/formula.test.js +1 -0
  18. package/src/data/transforms/identifier.test.js +1 -0
  19. package/src/data/transforms/pileup.test.js +1 -0
  20. package/src/data/transforms/project.test.js +1 -0
  21. package/src/data/transforms/regexExtract.test.js +1 -0
  22. package/src/data/transforms/regexFold.test.js +1 -0
  23. package/src/data/transforms/sample.test.js +1 -0
  24. package/src/data/transforms/stack.test.js +1 -0
  25. package/src/encoder/accessor.test.js +1 -0
  26. package/src/encoder/encoder.test.js +1 -0
  27. package/src/genome/genome.test.js +1 -0
  28. package/src/genome/scaleIndex.js +3 -2
  29. package/src/genome/scaleIndex.test.js +23 -6
  30. package/src/genome/scaleLocus.test.js +1 -0
  31. package/src/genomeSpy.js +16 -11
  32. package/src/gl/dataToVertices.js +52 -52
  33. package/src/gl/includes/common.glsl +12 -12
  34. package/src/gl/includes/picking.fragment.glsl +0 -2
  35. package/src/gl/includes/picking.vertex.glsl +0 -2
  36. package/src/gl/includes/scales.glsl +33 -2
  37. package/src/gl/point.vertex.glsl +0 -2
  38. package/src/gl/rule.vertex.glsl +1 -1
  39. package/src/gl/webGLHelper.js +0 -3
  40. package/src/marks/link.js +32 -39
  41. package/src/marks/mark.js +176 -106
  42. package/src/marks/pointMark.js +28 -59
  43. package/src/marks/rectMark.js +38 -33
  44. package/src/marks/rule.js +31 -21
  45. package/src/marks/text.js +18 -14
  46. package/src/scale/glslScaleGenerator.js +56 -17
  47. package/src/scale/scale.test.js +1 -0
  48. package/src/scale/ticks.test.js +1 -0
  49. package/src/spec/mark.d.ts +0 -3
  50. package/src/spec/scale.d.ts +0 -9
  51. package/src/spec/title.d.ts +102 -0
  52. package/src/spec/view.d.ts +6 -4
  53. package/src/tooltip/dataTooltipHandler.js +3 -2
  54. package/src/utils/addBaseUrl.test.js +1 -0
  55. package/src/utils/binnedIndex.js +147 -0
  56. package/src/utils/binnedIndex.test.js +73 -0
  57. package/src/utils/cloner.test.js +1 -0
  58. package/src/utils/coalesce.test.js +1 -0
  59. package/src/utils/concatIterables.test.js +1 -0
  60. package/src/utils/domainArray.test.js +1 -0
  61. package/src/utils/indexer.test.js +1 -0
  62. package/src/utils/iterateNestedMaps.test.js +1 -0
  63. package/src/utils/kWayMerge.test.js +1 -0
  64. package/src/utils/layout/flexLayout.js +35 -3
  65. package/src/utils/layout/flexLayout.test.js +15 -0
  66. package/src/utils/layout/grid.js +95 -0
  67. package/src/utils/layout/grid.test.js +71 -0
  68. package/src/utils/layout/padding.js +13 -0
  69. package/src/utils/layout/rectangle.js +6 -0
  70. package/src/utils/layout/rectangle.test.js +1 -0
  71. package/src/utils/mergeObjects.test.js +1 -0
  72. package/src/utils/numberExtractor.test.js +1 -0
  73. package/src/utils/propertyCacher.test.js +1 -0
  74. package/src/utils/propertyCoalescer.test.js +1 -0
  75. package/src/utils/reservationMap.test.js +1 -0
  76. package/src/utils/topK.test.js +1 -0
  77. package/src/utils/variableTools.test.js +1 -0
  78. package/src/view/axisResolution.test.js +1 -0
  79. package/src/view/axisView.js +3 -5
  80. package/src/view/concatView.js +24 -275
  81. package/src/view/flowBuilder.test.js +1 -0
  82. package/src/view/gridView.js +774 -0
  83. package/src/view/implicitRootView.js +14 -0
  84. package/src/view/layerView.js +15 -1
  85. package/src/view/renderingContext/deferredViewRenderingContext.js +3 -1
  86. package/src/view/renderingContext/simpleViewRenderingContext.js +3 -1
  87. package/src/view/scaleResolution.js +5 -11
  88. package/src/view/scaleResolution.test.js +1 -0
  89. package/src/view/title.js +165 -0
  90. package/src/view/unitView.js +9 -5
  91. package/src/view/view.js +35 -14
  92. package/src/view/view.test.js +1 -0
  93. package/src/view/viewContext.d.ts +6 -1
  94. package/src/view/viewFactory.test.js +1 -0
  95. package/src/view/viewUtils.js +1 -93
  96. package/src/view/zoom.js +89 -0
  97. package/src/gl/includes/fp64-arithmetic.glsl +0 -187
  98. package/src/gl/includes/fp64-utils.js +0 -142
  99. package/src/gl/includes/scales_fp64.glsl +0 -30
  100. package/src/utils/binnedRangeIndex.js +0 -83
  101. package/src/view/decoratorView.js +0 -513
@@ -0,0 +1,774 @@
1
+ /* eslint-disable max-depth */
2
+ import {
3
+ FlexDimensions,
4
+ getLargestSize,
5
+ mapToPixelCoords,
6
+ ZERO_SIZEDEF,
7
+ } from "../utils/layout/flexLayout";
8
+ import Grid from "../utils/layout/grid";
9
+ import Padding from "../utils/layout/padding";
10
+ import Rectangle from "../utils/layout/rectangle";
11
+ import AxisView, { CHANNEL_ORIENTS } from "./axisView";
12
+ import ContainerView from "./containerView";
13
+ import LayerView from "./layerView";
14
+ import createTitle from "./title";
15
+ import UnitView from "./unitView";
16
+ import interactionToZoom from "./zoom";
17
+
18
+ /**
19
+ * @typedef {"row" | "column"} Direction
20
+ * @typedef {import("./view").default} View
21
+ *
22
+ * @typedef {object} GridChild
23
+ * @prop {View} view
24
+ * @prop {UnitView} [background]
25
+ * @prop {Partial<Record<import("../spec/axis").AxisOrient, AxisView>>} axes
26
+ * @prop {UnitView} [title]
27
+ * @prop {Rectangle} coords Coordinates of the view. Recorded for mouse tracking, etc.
28
+ */
29
+
30
+ /**
31
+ * Modeled after: https://vega.github.io/vega/docs/layout/
32
+ *
33
+ * This should take care of the following:
34
+ * - Composition: [hv]concat / facet / repeat
35
+ * - Views
36
+ * - Axes
37
+ * - Grid lines
38
+ * - View background
39
+ * - View titles
40
+ * - Facet (column / row) titles
41
+ * - Header / footer
42
+ * - Zoom / pan
43
+ * - And later on, brushing, legend(?)
44
+ */
45
+ export default class GridView extends ContainerView {
46
+ #columns = Infinity;
47
+
48
+ #spacing = 10;
49
+
50
+ /**
51
+ * @type { GridChild[] }
52
+ */
53
+ #children = [];
54
+
55
+ #childSerial = 0;
56
+
57
+ /**
58
+ *
59
+ * @param {import("./viewUtils").AnyConcatSpec} spec
60
+ * @param {import("./viewUtils").ViewContext} context
61
+ * @param {ContainerView} parent
62
+ * @param {string} name
63
+ * @param {number} columns
64
+ */
65
+ constructor(spec, context, parent, name, columns) {
66
+ super(spec, context, parent, name);
67
+ this.spec = spec;
68
+
69
+ this.#spacing = spec.spacing ?? 10;
70
+ this.#columns = columns;
71
+
72
+ this.#children = [];
73
+
74
+ this.wrappingFacet = false;
75
+
76
+ this._createChildren();
77
+ }
78
+
79
+ _createChildren() {
80
+ // Override
81
+ }
82
+
83
+ /**
84
+ * @param {View} view
85
+ */
86
+ #makeGridChild(view) {
87
+ /** @type {GridChild} */
88
+ const gridChild = {
89
+ view,
90
+ background: undefined,
91
+ axes: {},
92
+ coords: Rectangle.ZERO,
93
+ };
94
+
95
+ if (view instanceof UnitView || view instanceof LayerView) {
96
+ /** @type {import("../spec/view").ViewBackground} */
97
+ const viewBackground = view.spec?.view;
98
+ if (viewBackground?.fill || viewBackground?.stroke) {
99
+ const unitView = new UnitView(
100
+ createBackground(viewBackground),
101
+ this.context,
102
+ view,
103
+ "background" + this.#childSerial
104
+ );
105
+ // TODO: Make configurable through spec:
106
+ unitView.blockEncodingInheritance = true;
107
+ gridChild.background = unitView;
108
+ }
109
+
110
+ const title = createTitle(view.spec.title);
111
+ if (title) {
112
+ const unitView = new UnitView(
113
+ title,
114
+ this.context,
115
+ view,
116
+ "title" + this.#childSerial
117
+ );
118
+ // TODO: Make configurable through spec:
119
+ unitView.blockEncodingInheritance = true;
120
+ gridChild.title = unitView;
121
+ }
122
+ }
123
+
124
+ return gridChild;
125
+ }
126
+
127
+ /**
128
+ * @param {View} view
129
+ */
130
+ appendChild(view) {
131
+ view.parent ??= this;
132
+ this.#children.push(this.#makeGridChild(view));
133
+ this.#childSerial++;
134
+ }
135
+
136
+ get #visibleChildren() {
137
+ return this.#children.filter((gridChild) => gridChild.view.isVisible());
138
+ }
139
+
140
+ get #grid() {
141
+ return new Grid(
142
+ this.#visibleChildren.length,
143
+ this.#columns ?? Infinity
144
+ );
145
+ }
146
+
147
+ /**
148
+ * @param {View[]} views
149
+ */
150
+ setChildren(views) {
151
+ //this.#children = []; // TODO: Check why this breaks summary track
152
+ for (const view of views) {
153
+ this.appendChild(view);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {import("./view").default} child
159
+ * @param {import("./view").default} replacement
160
+ */
161
+ replaceChild(child, replacement) {
162
+ const i = this.#children.findIndex(
163
+ (gridChild) => gridChild.view == child
164
+ );
165
+ if (i >= 0) {
166
+ this.#children[i] = this.#makeGridChild(replacement);
167
+ } else {
168
+ throw new Error("Not my child view!");
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Read-only view to children
174
+ */
175
+ get children() {
176
+ return this.#children.map((gridChild) => gridChild.view);
177
+ }
178
+
179
+ get childCount() {
180
+ return this.#children.length;
181
+ }
182
+
183
+ onScalesResolved() {
184
+ super.onScalesResolved();
185
+
186
+ this.#createAxes();
187
+ }
188
+
189
+ #createAxes() {
190
+ if (Object.keys(this.resolutions.axis).length) {
191
+ throw new Error(
192
+ "ConcatView does not (currently) support shared axes!"
193
+ );
194
+ }
195
+
196
+ // Create axes
197
+ for (const gridChild of this.#children) {
198
+ const { view, axes } = gridChild;
199
+
200
+ /**
201
+ * @param {import("./view").AxisResolution} r
202
+ * @param {import("../spec/channel").PrimaryPositionalChannel} channel
203
+ * @param {UnitView | LayerView} axisParent
204
+ */
205
+ const createAxis = (r, channel, axisParent) => {
206
+ const props = r.getAxisProps();
207
+ if (props === null) {
208
+ return;
209
+ }
210
+
211
+ // Pick a default orient based on what is available
212
+ if (!props.orient) {
213
+ for (const orient of CHANNEL_ORIENTS[channel]) {
214
+ if (!axes[orient]) {
215
+ props.orient = orient;
216
+ break;
217
+ }
218
+ }
219
+ if (!props.orient) {
220
+ throw new Error(
221
+ "No slots available for an axis! Perhaps a LayerView has more than two children?"
222
+ );
223
+ }
224
+ }
225
+
226
+ props.title ??= r.getTitle();
227
+
228
+ if (!CHANNEL_ORIENTS[channel].includes(props.orient)) {
229
+ throw new Error(
230
+ `Invalid axis orientation "${props.orient}" on channel "${channel}"!`
231
+ );
232
+ }
233
+
234
+ if (axes[props.orient]) {
235
+ throw new Error(
236
+ `An axis with the orient "${props.orient}" already exists!`
237
+ );
238
+ }
239
+
240
+ axes[props.orient] = new AxisView(
241
+ props,
242
+ r.scaleResolution.type,
243
+ this.context,
244
+ // Note: Axisview has a unit/layerView as parent so that scale/axis resolutions are inherited correctly
245
+ axisParent
246
+ );
247
+ };
248
+
249
+ // Handle shared axes
250
+ if (view instanceof UnitView || view instanceof LayerView) {
251
+ for (const channel of /** @type {import("../spec/channel").PrimaryPositionalChannel[]} */ ([
252
+ "x",
253
+ "y",
254
+ ])) {
255
+ const r = view.resolutions.axis[channel];
256
+ if (!r) {
257
+ continue;
258
+ }
259
+
260
+ createAxis(r, channel, view);
261
+ }
262
+ }
263
+
264
+ // Handle LayerView's possible independent axes
265
+ if (view instanceof LayerView) {
266
+ // First create axes that have an orient preference
267
+ for (const layerChild of view.children) {
268
+ for (const [channel, r] of Object.entries(
269
+ layerChild.resolutions.axis
270
+ )) {
271
+ const props = r.getAxisProps();
272
+ if (props && props.orient) {
273
+ createAxis(r, channel, layerChild);
274
+ }
275
+ }
276
+ }
277
+
278
+ // Then create axes in a priority order
279
+ for (const layerChild of view.children) {
280
+ for (const [channel, r] of Object.entries(
281
+ layerChild.resolutions.axis
282
+ )) {
283
+ const props = r.getAxisProps();
284
+ if (props && !props.orient) {
285
+ createAxis(r, channel, layerChild);
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * @returns {IterableIterator<View>}
295
+ */
296
+ *[Symbol.iterator]() {
297
+ for (const gridChild of this.#children) {
298
+ if (gridChild.background) {
299
+ yield gridChild.background;
300
+ }
301
+
302
+ for (const axisView of Object.values(gridChild.axes)) {
303
+ if (axisView) {
304
+ yield axisView;
305
+ }
306
+ }
307
+
308
+ yield gridChild.view;
309
+
310
+ if (gridChild.title) {
311
+ yield gridChild.title;
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * @param {Direction} direction
318
+ */
319
+ #getSizes(direction) {
320
+ /** @type {import("../spec/axis").AxisOrient[]} */
321
+ const orients =
322
+ direction == "column" ? ["left", "right"] : ["top", "bottom"];
323
+
324
+ const dim = direction == "column" ? "width" : "height";
325
+
326
+ /**
327
+ * @type {(indices: number[], side: 0 | 1) => number}
328
+ */
329
+ const getMaxAxisSize = (indices, side) =>
330
+ indices
331
+ .map((index) => {
332
+ // Axis view is only present for unit and layer views
333
+ const axisView =
334
+ this.#visibleChildren[index].axes[orients[side]];
335
+ if (axisView) {
336
+ return Math.max(
337
+ axisView.getPerpendicularSize() +
338
+ axisView.axisProps.offset ?? 0,
339
+ 0
340
+ );
341
+ }
342
+
343
+ // For views other than unit or layer, use overhang instead
344
+ const overhang =
345
+ this.#visibleChildren[index].view.getOverhang();
346
+ if (direction == "column") {
347
+ return side ? overhang.right : overhang.left;
348
+ } else {
349
+ return side ? overhang.bottom : overhang.top;
350
+ }
351
+ })
352
+ .reduce((a, b) => Math.max(a, b), 0);
353
+
354
+ return this.#grid[
355
+ direction == "column" ? "colIndices" : "rowIndices"
356
+ ].map((col) => ({
357
+ axisBefore: getMaxAxisSize(col, 0),
358
+ axisAfter: getMaxAxisSize(col, 1),
359
+ view: getLargestSize(
360
+ col.map(
361
+ (rowIndex) =>
362
+ this.#visibleChildren[rowIndex].view.getSize()[dim]
363
+ )
364
+ ),
365
+ }));
366
+ }
367
+
368
+ /**
369
+ * An example layout with two children, either column or row-based direction:
370
+ *
371
+ * 0. title
372
+ * 1. header
373
+ * 2. axis/padding
374
+ * 3. view
375
+ * 4. axis/padding
376
+ * 5. footer (if column and wrapping)
377
+ * 5. spacing
378
+ * 6. header (if column and wrapping)
379
+ * 7. axis/padding
380
+ * 8. view
381
+ * 9. axis/padding
382
+ * 10. footer
383
+ *
384
+ * @param {Direction} direction
385
+ */
386
+ #makeFlexItems(direction) {
387
+ const sizes = this.#getSizes(direction);
388
+
389
+ /** @type {import("../utils/layout/flexLayout").SizeDef[]} */
390
+ const items = [];
391
+
392
+ // Title
393
+ items.push(ZERO_SIZEDEF);
394
+
395
+ for (const [i, size] of sizes.entries()) {
396
+ if (i > 0) {
397
+ // Spacing
398
+ items.push({ px: this.#spacing, grow: 0 });
399
+ }
400
+
401
+ if (i == 0 || this.wrappingFacet) {
402
+ // Header
403
+ items.push(ZERO_SIZEDEF);
404
+ }
405
+
406
+ // Axis/padding
407
+ items.push({ px: size.axisBefore, grow: 0 });
408
+
409
+ // View
410
+ items.push(size.view);
411
+
412
+ // Axis/padding
413
+ items.push({ px: size.axisAfter, grow: 0 });
414
+
415
+ if (i == sizes.length - 1 || this.wrappingFacet) {
416
+ //Footer
417
+ items.push(ZERO_SIZEDEF);
418
+ }
419
+ }
420
+
421
+ return items;
422
+ }
423
+
424
+ /**
425
+ * @param {Direction} direction
426
+ * @return {import("../utils/layout/flexLayout").SizeDef}
427
+ */
428
+ #getFlexSize(direction) {
429
+ let grow = 0;
430
+ let px = 0;
431
+
432
+ const sizes = this.#getSizes(direction);
433
+
434
+ for (const [i, size] of sizes.entries()) {
435
+ if (i > 0) {
436
+ // Spacing
437
+ px += this.#spacing;
438
+ }
439
+
440
+ if (i == 0 || this.wrappingFacet) {
441
+ // Header
442
+ px += 0;
443
+ }
444
+
445
+ // Axis/padding
446
+ px += size.axisBefore;
447
+
448
+ // View
449
+ px += size.view.px ?? 0;
450
+ grow += size.view.grow ?? 0;
451
+
452
+ // Axis/padding
453
+ px += size.axisAfter;
454
+
455
+ if (i == sizes.length - 1 || this.wrappingFacet) {
456
+ //Footer
457
+ px += 0;
458
+ }
459
+ }
460
+
461
+ return { px, grow };
462
+ }
463
+
464
+ /**
465
+ * Locates a view slot in FlexLayout
466
+ *
467
+ * @param {Direction} direction
468
+ * @param {number} index column/row number
469
+ */
470
+ #getViewSlot(direction, index) {
471
+ return direction == "row" && this.wrappingFacet
472
+ ? // Views have header/footer on every row
473
+ 1 + 6 * index + 2
474
+ : // Only first row has header, last row has footer.
475
+ 2 + 4 * index + 1;
476
+ }
477
+
478
+ /**
479
+ * @return {Padding}
480
+ */
481
+ getOverhang() {
482
+ const cols = this.#getSizes("column");
483
+ const rows = this.#getSizes("row");
484
+
485
+ if (!cols.length || !rows.length) {
486
+ return Padding.zero();
487
+ }
488
+
489
+ const p = new Padding(
490
+ rows.at(0).axisBefore,
491
+ cols.at(-1).axisAfter,
492
+ rows.at(-1).axisAfter,
493
+ cols.at(0).axisBefore
494
+ );
495
+ return p;
496
+ }
497
+
498
+ /**
499
+ * @returns {FlexDimensions}
500
+ */
501
+ getSize() {
502
+ return this._cache("size", () =>
503
+ new FlexDimensions(
504
+ this.#getFlexSize("column"),
505
+ this.#getFlexSize("row")
506
+ )
507
+ .subtractPadding(this.getOverhang())
508
+ .addPadding(this.getPadding())
509
+ );
510
+ }
511
+
512
+ /**
513
+ * @param {import("./renderingContext/viewRenderingContext").default} context
514
+ * @param {import("../utils/layout/rectangle").default} coords
515
+ * @param {import("./view").RenderingOptions} [options]
516
+ */
517
+ render(context, coords, options = {}) {
518
+ if (!this.isVisible()) {
519
+ return;
520
+ }
521
+
522
+ coords = coords.shrink(this.getPadding()); // TODO: Only applicable at view root
523
+ context.pushView(this, coords);
524
+
525
+ const flexOpts = {
526
+ devicePixelRatio: this.context.glHelper.dpr,
527
+ };
528
+ const columnFlexCoords = mapToPixelCoords(
529
+ this.#makeFlexItems("column"),
530
+ coords.width,
531
+ flexOpts
532
+ );
533
+
534
+ const rowFlexCoords = mapToPixelCoords(
535
+ this.#makeFlexItems("row"),
536
+ coords.height,
537
+ flexOpts
538
+ );
539
+
540
+ const grid = new Grid(
541
+ this.#visibleChildren.length,
542
+ this.#columns ?? Infinity
543
+ );
544
+
545
+ for (const [i, gridChild] of this.#visibleChildren.entries()) {
546
+ const { view, axes, background, title } = gridChild;
547
+
548
+ const [col, row] = grid.getCellCoords(i);
549
+ const colLocSize =
550
+ columnFlexCoords[this.#getViewSlot("column", col)];
551
+ const rowLocSize = rowFlexCoords[this.#getViewSlot("row", row)];
552
+
553
+ const viewSize = view.getSize();
554
+ const viewPadding = view.getPadding().subtract(view.getOverhang());
555
+
556
+ const x = colLocSize.location + viewPadding.left;
557
+ const y = rowLocSize.location + viewPadding.top;
558
+
559
+ const width =
560
+ (viewSize.width.grow ? colLocSize.size : viewSize.width.px) -
561
+ viewPadding.width;
562
+ const height =
563
+ (viewSize.height.grow ? rowLocSize.size : viewSize.height.px) -
564
+ viewPadding.height;
565
+
566
+ const childCoords = new Rectangle(
567
+ () => coords.x + x,
568
+ () => coords.y + y,
569
+ () => width,
570
+ () => height
571
+ );
572
+
573
+ gridChild.coords = childCoords;
574
+
575
+ background?.render(context, childCoords, options);
576
+
577
+ // If clipped, the axes should be drawn on top of the marks (because clipping may not be pixel-perfect)
578
+ const clipped = isClippedChildren(view);
579
+ if (clipped) {
580
+ view.render(context, childCoords, options);
581
+ }
582
+
583
+ for (const [orient, axisView] of Object.entries(axes)) {
584
+ const props = axisView.axisProps;
585
+
586
+ /** @type {import("../utils/layout/rectangle").default} */
587
+ let axisCoords;
588
+
589
+ const ps = axisView.getPerpendicularSize();
590
+
591
+ if (orient == "bottom") {
592
+ axisCoords = childCoords
593
+ .translate(0, childCoords.height + props.offset)
594
+ .modify({ height: ps });
595
+ } else if (orient == "top") {
596
+ axisCoords = childCoords
597
+ .translate(0, -ps - props.offset)
598
+ .modify({ height: ps });
599
+ } else if (orient == "left") {
600
+ axisCoords = childCoords
601
+ .translate(-ps - props.offset, 0)
602
+ .modify({ width: ps });
603
+ } else if (orient == "right") {
604
+ axisCoords = childCoords
605
+ .translate(childCoords.width + props.offset, 0)
606
+ .modify({ width: ps });
607
+ }
608
+
609
+ // Axes have no faceted data, thus, pass undefined facetId
610
+ axisView.render(context, axisCoords);
611
+ }
612
+
613
+ if (!clipped) {
614
+ view.render(context, childCoords, options);
615
+ }
616
+
617
+ title?.render(context, childCoords, {
618
+ ...options,
619
+ clipRect: undefined, // Hack for SampleAttributePanel. TODO: Proper fix
620
+ });
621
+ }
622
+
623
+ context.popView(this);
624
+ }
625
+
626
+ /**
627
+ * @param {import("../utils/interactionEvent").default} event
628
+ */
629
+ propagateInteractionEvent(event) {
630
+ this.handleInteractionEvent(undefined, event, true);
631
+
632
+ if (event.stopped) {
633
+ return;
634
+ }
635
+
636
+ const pointedChild = this.#visibleChildren.find((gridChild) =>
637
+ gridChild.coords.containsPoint(event.point.x, event.point.y)
638
+ );
639
+ const pointedView = pointedChild?.view;
640
+ if (pointedView) {
641
+ pointedView.propagateInteractionEvent(event);
642
+
643
+ if (
644
+ pointedView instanceof UnitView ||
645
+ pointedView instanceof LayerView
646
+ ) {
647
+ interactionToZoom(
648
+ event,
649
+ pointedChild.coords,
650
+ (zoomEvent) =>
651
+ this.#handleZoom(
652
+ pointedChild.coords,
653
+ pointedChild.view,
654
+ zoomEvent
655
+ ),
656
+ this.context.getCurrentHover()
657
+ );
658
+ }
659
+ }
660
+
661
+ if (event.stopped) {
662
+ return;
663
+ }
664
+
665
+ this.handleInteractionEvent(undefined, event, false);
666
+ }
667
+
668
+ /**
669
+ *
670
+ * @param {import("../utils/layout/rectangle").default} coords Coordinates
671
+ * @param {View} view
672
+ * @param {import("./zoom").ZoomEvent} zoomEvent
673
+ */
674
+ #handleZoom(coords, view, zoomEvent) {
675
+ for (const [channel, resolutionSet] of Object.entries(
676
+ getZoomableResolutions(view)
677
+ )) {
678
+ if (resolutionSet.size <= 0) {
679
+ continue;
680
+ }
681
+
682
+ const p = coords.normalizePoint(zoomEvent.x, zoomEvent.y);
683
+ const tp = coords.normalizePoint(
684
+ zoomEvent.x + zoomEvent.xDelta,
685
+ zoomEvent.y + zoomEvent.yDelta
686
+ );
687
+
688
+ const delta = {
689
+ x: tp.x - p.x,
690
+ y: tp.y - p.y,
691
+ };
692
+
693
+ for (const resolution of resolutionSet) {
694
+ resolution.zoom(
695
+ 2 ** zoomEvent.zDelta,
696
+ channel == "y" ? 1 - p[channel] : p[channel],
697
+ channel == "x" ? delta.x : -delta.y
698
+ );
699
+ }
700
+ }
701
+
702
+ this.context.animator.requestRender();
703
+ }
704
+
705
+ /**
706
+ * @param {string} channel
707
+ * @param {import("./containerView").ResolutionTarget} resolutionType
708
+ * @returns {import("../spec/view").ResolutionBehavior}
709
+ */
710
+ getDefaultResolution(channel, resolutionType) {
711
+ // TODO: Default to shared when working with genomic coordinates
712
+ return "independent";
713
+ }
714
+ }
715
+
716
+ /**
717
+ * @param {import("../spec/view").ViewBackground} viewBackground
718
+ * @returns {import("../spec/view").UnitSpec}
719
+ */
720
+ function createBackground(viewBackground) {
721
+ return {
722
+ configurableVisibility: false,
723
+ data: { values: [{}] },
724
+ mark: {
725
+ fill: null,
726
+ strokeWidth: 1.0,
727
+ fillOpacity: viewBackground.fill ? 1.0 : 0, // TODO: This should be handled at lower level
728
+ ...viewBackground,
729
+ type: "rect",
730
+ clip: false, // Shouldn't be needed
731
+ tooltip: null,
732
+ },
733
+ };
734
+ }
735
+
736
+ /**
737
+ *
738
+ * @param {View} view
739
+ * @returns
740
+ */
741
+ function getZoomableResolutions(view) {
742
+ /** @type {Record<import("../spec/channel").PrimaryPositionalChannel, Set<import("./scaleResolution").default>>} */
743
+ const resolutions = {
744
+ x: new Set(),
745
+ y: new Set(),
746
+ };
747
+
748
+ // Find all resolutions (scales) that are candidates for zooming
749
+ view.visit((v) => {
750
+ for (const [channel, resolutionSet] of Object.entries(resolutions)) {
751
+ const resolution = v.getScaleResolution(channel);
752
+ if (resolution && resolution.isZoomable()) {
753
+ resolutionSet.add(resolution);
754
+ }
755
+ }
756
+ });
757
+
758
+ return resolutions;
759
+ }
760
+
761
+ /**
762
+ * @param {View} view
763
+ */
764
+ export function isClippedChildren(view) {
765
+ let clipped = true;
766
+
767
+ view.visit((v) => {
768
+ if (v instanceof UnitView) {
769
+ clipped &&= v.mark.properties.clip;
770
+ }
771
+ });
772
+
773
+ return clipped;
774
+ }