@genome-spy/core 0.59.0 → 0.60.1

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 (85) hide show
  1. package/dist/bundle/index.es.js +6100 -5545
  2. package/dist/bundle/index.js +144 -119
  3. package/dist/schema.json +304 -22
  4. package/dist/src/data/collector.d.ts.map +1 -1
  5. package/dist/src/data/collector.js +1 -0
  6. package/dist/src/data/sources/dataUtils.d.ts +2 -1
  7. package/dist/src/data/sources/dataUtils.d.ts.map +1 -1
  8. package/dist/src/data/sources/dataUtils.js +3 -4
  9. package/dist/src/data/sources/inlineSource.d.ts +8 -0
  10. package/dist/src/data/sources/inlineSource.d.ts.map +1 -1
  11. package/dist/src/data/sources/inlineSource.js +17 -1
  12. package/dist/src/data/sources/urlSource.d.ts +1 -0
  13. package/dist/src/data/sources/urlSource.d.ts.map +1 -1
  14. package/dist/src/data/sources/urlSource.js +33 -4
  15. package/dist/src/encoder/encoder.d.ts +1 -1
  16. package/dist/src/genomeSpy.d.ts.map +1 -1
  17. package/dist/src/genomeSpy.js +39 -6
  18. package/dist/src/gl/colorUtils.d.ts +4 -0
  19. package/dist/src/gl/colorUtils.d.ts.map +1 -1
  20. package/dist/src/gl/colorUtils.js +8 -0
  21. package/dist/src/gl/glslScaleGenerator.d.ts +1 -1
  22. package/dist/src/gl/glslScaleGenerator.d.ts.map +1 -1
  23. package/dist/src/gl/glslScaleGenerator.js +1 -9
  24. package/dist/src/gl/includes/common.glsl.js +1 -1
  25. package/dist/src/marks/link.d.ts.map +1 -1
  26. package/dist/src/marks/link.js +8 -0
  27. package/dist/src/marks/mark.d.ts +8 -0
  28. package/dist/src/marks/mark.d.ts.map +1 -1
  29. package/dist/src/marks/mark.js +101 -3
  30. package/dist/src/marks/point.fragment.glsl.js +1 -1
  31. package/dist/src/marks/point.vertex.glsl.js +1 -1
  32. package/dist/src/marks/rect.common.glsl.js +1 -1
  33. package/dist/src/marks/rect.d.ts.map +1 -1
  34. package/dist/src/marks/rect.fragment.glsl.js +1 -1
  35. package/dist/src/marks/rect.js +41 -0
  36. package/dist/src/marks/rect.vertex.glsl.js +1 -1
  37. package/dist/src/selection/selection.d.ts +27 -2
  38. package/dist/src/selection/selection.d.ts.map +1 -1
  39. package/dist/src/selection/selection.js +53 -3
  40. package/dist/src/spec/data.d.ts +18 -1
  41. package/dist/src/spec/mark.d.ts +58 -1
  42. package/dist/src/spec/parameter.d.ts +71 -31
  43. package/dist/src/spec/view.d.ts +9 -2
  44. package/dist/src/styles/genome-spy.css.d.ts +1 -1
  45. package/dist/src/styles/genome-spy.css.d.ts.map +1 -1
  46. package/dist/src/styles/genome-spy.css.js +12 -1
  47. package/dist/src/styles/genome-spy.scss +19 -1
  48. package/dist/src/types/selectionTypes.d.ts +4 -7
  49. package/dist/src/utils/expression.d.ts.map +1 -1
  50. package/dist/src/utils/expression.js +4 -0
  51. package/dist/src/utils/ui/tooltip.d.ts +6 -10
  52. package/dist/src/utils/ui/tooltip.d.ts.map +1 -1
  53. package/dist/src/utils/ui/tooltip.js +74 -42
  54. package/dist/src/view/concatView.d.ts +1 -1
  55. package/dist/src/view/concatView.d.ts.map +1 -1
  56. package/dist/src/view/concatView.js +1 -1
  57. package/dist/src/view/gridView/gridChild.d.ts +53 -0
  58. package/dist/src/view/gridView/gridChild.d.ts.map +1 -0
  59. package/dist/src/view/gridView/gridChild.js +758 -0
  60. package/dist/src/view/gridView/gridView.d.ts +64 -0
  61. package/dist/src/view/gridView/gridView.d.ts.map +1 -0
  62. package/dist/src/view/{gridView.js → gridView/gridView.js} +40 -595
  63. package/dist/src/view/gridView/scrollbar.d.ts +32 -0
  64. package/dist/src/view/gridView/scrollbar.d.ts.map +1 -0
  65. package/dist/src/view/gridView/scrollbar.js +186 -0
  66. package/dist/src/view/gridView/selectionRect.d.ts +10 -0
  67. package/dist/src/view/gridView/selectionRect.d.ts.map +1 -0
  68. package/dist/src/view/gridView/selectionRect.js +182 -0
  69. package/dist/src/view/layout/rectangle.d.ts +11 -1
  70. package/dist/src/view/layout/rectangle.d.ts.map +1 -1
  71. package/dist/src/view/layout/rectangle.js +22 -2
  72. package/dist/src/view/layout/rectangle.test.js +12 -0
  73. package/dist/src/view/paramMediator.d.ts.map +1 -1
  74. package/dist/src/view/paramMediator.js +9 -0
  75. package/dist/src/view/scaleResolution.d.ts +1 -0
  76. package/dist/src/view/scaleResolution.d.ts.map +1 -1
  77. package/dist/src/view/scaleResolution.js +43 -33
  78. package/dist/src/view/view.d.ts +6 -0
  79. package/dist/src/view/view.d.ts.map +1 -1
  80. package/dist/src/view/view.js +19 -0
  81. package/dist/src/view/viewFactory.d.ts.map +1 -1
  82. package/dist/src/view/viewFactory.js +13 -1
  83. package/package.json +2 -2
  84. package/dist/src/view/gridView.d.ts +0 -135
  85. package/dist/src/view/gridView.d.ts.map +0 -1
@@ -0,0 +1,758 @@
1
+ import { isContinuous } from "vega-scale";
2
+ import {
3
+ asSelectionConfig,
4
+ createIntervalSelection,
5
+ isActiveIntervalSelection,
6
+ isIntervalSelectionConfig,
7
+ selectionContainsPoint,
8
+ } from "../../selection/selection.js";
9
+ import AxisGridView from "../axisGridView.js";
10
+ import AxisView, { CHANNEL_ORIENTS } from "../axisView.js";
11
+ import LayerView from "../layerView.js";
12
+ import Padding from "../layout/padding.js";
13
+ import Point from "../layout/point.js";
14
+ import Rectangle from "../layout/rectangle.js";
15
+ import createTitle from "../title.js";
16
+ import UnitView from "../unitView.js";
17
+ import Scrollbar from "./scrollbar.js";
18
+ import SelectionRect from "./selectionRect.js";
19
+
20
+ export default class GridChild {
21
+ /**
22
+ * @param {import("../view.js").default} view
23
+ * @param {import("../containerView.js").default} layoutParent
24
+ * @param {number} serial
25
+ */
26
+ constructor(view, layoutParent, serial) {
27
+ this.layoutParent = layoutParent;
28
+ this.view = view;
29
+ this.serial = serial;
30
+
31
+ /** @type {UnitView} */
32
+ this.background = undefined;
33
+
34
+ /** @type {UnitView} */
35
+ this.backgroundStroke = undefined;
36
+
37
+ /** @type {Partial<Record<import("../../spec/axis.js").AxisOrient, AxisView>>} axes */
38
+ this.axes = {};
39
+
40
+ /** @type {Partial<Record<import("../../spec/axis.js").AxisOrient, AxisGridView>>} gridLines */
41
+ this.gridLines = {};
42
+
43
+ /** @type {Partial<Record<import("./scrollbar.js").ScrollDirection, Scrollbar>>} */
44
+ this.scrollbars = {};
45
+
46
+ /** @type {SelectionRect} */
47
+ this.selectionRect = undefined;
48
+
49
+ /** @type {UnitView} */
50
+ this.title = undefined;
51
+
52
+ /** @type {Rectangle} */
53
+ this.coords = Rectangle.ZERO;
54
+
55
+ if (view.needsAxes.x || view.needsAxes.y) {
56
+ const spec = view.spec;
57
+ const viewBackground = "view" in spec ? spec?.view : undefined;
58
+
59
+ const backgroundSpec = createBackground(viewBackground);
60
+ if (backgroundSpec) {
61
+ this.background = new UnitView(
62
+ backgroundSpec,
63
+ layoutParent.context,
64
+ layoutParent,
65
+ view,
66
+ "background" + serial,
67
+ {
68
+ blockEncodingInheritance: true,
69
+ }
70
+ );
71
+ }
72
+
73
+ const backgroundStrokeSpec = createBackgroundStroke(viewBackground);
74
+ if (backgroundStrokeSpec) {
75
+ this.backgroundStroke = new UnitView(
76
+ backgroundStrokeSpec,
77
+ layoutParent.context,
78
+ layoutParent,
79
+ view,
80
+ "backgroundStroke" + serial,
81
+ {
82
+ blockEncodingInheritance: true,
83
+ }
84
+ );
85
+ }
86
+
87
+ const title = createTitle(view.spec.title);
88
+ if (title) {
89
+ const unitView = new UnitView(
90
+ title,
91
+ layoutParent.context,
92
+ layoutParent,
93
+ view,
94
+ "title" + serial,
95
+ {
96
+ blockEncodingInheritance: true,
97
+ }
98
+ );
99
+ this.title = unitView;
100
+ }
101
+ }
102
+
103
+ // TODO: More specific getter for this
104
+ if (view.spec.viewportWidth != null) {
105
+ this.scrollbars.horizontal = new Scrollbar(this, "horizontal");
106
+ }
107
+
108
+ if (view.spec.viewportHeight != null) {
109
+ this.scrollbars.vertical = new Scrollbar(this, "vertical");
110
+ }
111
+
112
+ this.#setupIntervalSelection();
113
+ }
114
+
115
+ #setupIntervalSelection() {
116
+ const view = this.view;
117
+
118
+ // TODO: Move to context
119
+ const setCursor = (/** @type {string} */ cursor) => {
120
+ this.view.context.glHelper.canvas.style.cursor = cursor;
121
+ };
122
+
123
+ // TODO: If the child is a LayerView, selection params should be pulled from its children as well
124
+ for (const [name, param] of view.paramMediator.paramConfigs) {
125
+ if (!("select" in param)) {
126
+ continue;
127
+ }
128
+
129
+ const select = asSelectionConfig(param.select);
130
+
131
+ if (!isIntervalSelectionConfig(select)) {
132
+ continue;
133
+ }
134
+
135
+ const channels = select.encodings;
136
+
137
+ const scaleResolutions = Object.fromEntries(
138
+ channels.map((channel) => {
139
+ const resolution = this.view.getScaleResolution(channel);
140
+ const scale = resolution?.scale;
141
+
142
+ if (!scale || !isContinuous(scale.type)) {
143
+ throw new Error(
144
+ `No continuous scale found for interval selection param "${name}" on channel "${channel}"! Scale type is "${scale?.type ?? "none"}".`
145
+ );
146
+ }
147
+ return [channel, resolution];
148
+ })
149
+ );
150
+
151
+ if (this.selectionRect) {
152
+ throw new Error(
153
+ "Only one interval selection per container is currently allowed!"
154
+ );
155
+ }
156
+
157
+ // --- Validation and early exits done ---
158
+
159
+ let mouseOver = false;
160
+ let preventNextClickPropagation = false;
161
+ let nowBrushing = false;
162
+
163
+ /**
164
+ * Selection rectangle in screen coordinates. Used when translating
165
+ * an existing selection.
166
+ * @type {Rectangle}
167
+ */
168
+ let translatedRectangle = null;
169
+
170
+ /**
171
+ * @param {{x: number, y: number}} a
172
+ * @param {{x: number, y: number}} b
173
+ * @return {Partial<Record<import("../../spec/channel.js").PrimaryPositionalChannel, [number, number]>>}
174
+ */
175
+ const pointsToIntervals = (a, b) =>
176
+ Object.fromEntries(
177
+ channels.map((channel) => [
178
+ channel,
179
+ [
180
+ Math.min(a[channel], b[channel]),
181
+ Math.max(a[channel], b[channel]),
182
+ ],
183
+ ])
184
+ );
185
+
186
+ const selectionExpr = view.paramMediator.createExpression(name);
187
+ const setter = view.paramMediator.getSetter(name);
188
+
189
+ if (param.value) {
190
+ setter({ type: "interval", intervals: param.value });
191
+ }
192
+
193
+ const clearSelection = () => {
194
+ setter(createIntervalSelection(channels));
195
+ setCursor(null);
196
+ };
197
+
198
+ this.selectionRect = new SelectionRect(
199
+ this,
200
+ selectionExpr,
201
+ select.mark
202
+ );
203
+
204
+ // WARNING! The following is an async method! Seems to work (by chance).
205
+ // TODO: Should be called and awaited in a sensible place. Maybe provide some
206
+ // registration logic for such post-creation initializations?
207
+ this.selectionRect.initializeChildren();
208
+
209
+ const invertPoint = (
210
+ /** @type {import("../layout/point.js").default} */ point
211
+ ) => {
212
+ const inverted = { x: 0, y: 0 };
213
+
214
+ const np = view.coords.normalizePoint(point.x, point.y, true);
215
+
216
+ for (const channel of channels) {
217
+ const scale = scaleResolutions[channel].scale;
218
+ // @ts-ignore
219
+ const val = scale.invert(channel == "x" ? np.x : np.y);
220
+ inverted[channel] =
221
+ val +
222
+ (["index", "locus"].includes(scale.type) ? 0.5 : 0);
223
+ }
224
+
225
+ return inverted;
226
+ };
227
+
228
+ /**
229
+ * Converts the current selection intervals (in scale domain) to a rectangle
230
+ * in screen coordinates.
231
+ * @param {import("../../types/selectionTypes.js").IntervalSelection} selection
232
+ */
233
+ const selectionToRect = (selection) => {
234
+ const { intervals } = selection;
235
+
236
+ const mapCorner = (
237
+ /** @type {number} */ xVal,
238
+ /** @type {number} */ yVal,
239
+ /** @type {number} */ i
240
+ ) => {
241
+ const getCoord = (
242
+ /** @type {import("../../spec/channel.js").PrimaryPositionalChannel} */ channel,
243
+ /** @type {number} */ val
244
+ ) => {
245
+ if (val == null) return null;
246
+ return scaleResolutions[channel].scale(val);
247
+ };
248
+ const px = getCoord("x", xVal) ?? i;
249
+ const py = getCoord("y", yVal) ?? i;
250
+ return view.coords.denormalizePoint(px, py, true);
251
+ };
252
+
253
+ const a = mapCorner(intervals.x?.[0], intervals.y?.[0], 0);
254
+ const b = mapCorner(intervals.x?.[1], intervals.y?.[1], 1);
255
+
256
+ return Rectangle.create(a.x, a.y, b.x - a.x, b.y - a.y);
257
+ };
258
+
259
+ view.addInteractionEventListener("mousedown", (coords, event) => {
260
+ if (/** @type {MouseEvent} */ (event.uiEvent).button != 0) {
261
+ return;
262
+ }
263
+
264
+ // Coordinates of the selection rectangle, if it exists.
265
+ // Must be operated in the view's coordinate system, not in data domain,
266
+ // as non-linear scales may be used.
267
+ translatedRectangle = mouseOver
268
+ ? selectionToRect(selectionExpr())
269
+ : null;
270
+
271
+ if (translatedRectangle) {
272
+ // Started dragging an existing selection
273
+ setCursor("grabbing");
274
+ // Start of dragging should prevent click propagation so that
275
+ // no other selections or events are triggered.
276
+ preventNextClickPropagation = true;
277
+ } else {
278
+ const mouseDownPoint = event.point;
279
+ if (isActiveIntervalSelection(selectionExpr())) {
280
+ // If there's a selection, prevent the next click from propagating.
281
+ // The first click will clear the selection, not trigger
282
+ // any other possible selections.
283
+ preventNextClickPropagation = true;
284
+ }
285
+
286
+ const startSelection = /** @type {MouseEvent} */ (
287
+ event.uiEvent
288
+ ).shiftKey;
289
+
290
+ if (startSelection) {
291
+ clearSelection();
292
+ nowBrushing = true;
293
+ } else if (isActiveIntervalSelection(selectionExpr())) {
294
+ // If mouse button is released and there was a selection,
295
+ // it should be cleared unless the viewport was panned by dragging.
296
+ /** @type {import("../view.js").InteractionEventListener} */
297
+ const listener = (coords, event) => {
298
+ view.removeInteractionEventListener(
299
+ "mouseup",
300
+ listener
301
+ );
302
+ const mouseUpPoint = event.point;
303
+
304
+ // Retain selection if the viewport is panned by dragging
305
+ const movementThreshold = 2; // pixels
306
+ if (
307
+ mouseDownPoint.subtract(mouseUpPoint).length <
308
+ movementThreshold
309
+ ) {
310
+ clearSelection();
311
+ }
312
+ };
313
+ view.addInteractionEventListener("mouseup", listener);
314
+ return;
315
+ } else {
316
+ return;
317
+ }
318
+ }
319
+
320
+ // Prevent panning interaction
321
+ event.stopPropagation();
322
+
323
+ const start = event.point;
324
+ const viewOffset = Point.fromMouseEvent(
325
+ /** @type {MouseEvent} */ (event.uiEvent)
326
+ ).subtract(start);
327
+
328
+ const mouseMoveListener = (/** @type {MouseEvent} */ event) => {
329
+ // This listener is added to the document so that events are captured even if the mouse leaves the view.
330
+ // Thus, coordinates need to be adjusted to the view's coordinate system.
331
+ const current =
332
+ Point.fromMouseEvent(event).subtract(viewOffset);
333
+
334
+ /** @type {ReturnType<typeof pointsToIntervals>} */
335
+ let intervals;
336
+
337
+ if (translatedRectangle) {
338
+ const offset = current.subtract(start);
339
+ const newRect = translatedRectangle.translate(
340
+ offset.x,
341
+ offset.y
342
+ );
343
+
344
+ intervals = pointsToIntervals(
345
+ invertPoint(new Point(newRect.x, newRect.y)),
346
+ invertPoint(new Point(newRect.x2, newRect.y2))
347
+ );
348
+ } else {
349
+ intervals = pointsToIntervals(
350
+ invertPoint(start),
351
+ invertPoint(current)
352
+ );
353
+ }
354
+
355
+ for (const channel of channels) {
356
+ const scaleResolution = scaleResolutions[channel];
357
+ const { zoomExtent, scale } = scaleResolution;
358
+ const interval = intervals[channel];
359
+
360
+ if (["index", "locus"].includes(scale.type)) {
361
+ // These scales use integer values. Need to round them.
362
+ interval[0] = Math.ceil(interval[0]);
363
+ interval[1] = Math.ceil(interval[1]);
364
+ }
365
+
366
+ if (translatedRectangle) {
367
+ // When dragging, clamp the interval so that the size stays the same and the interval doesn't exceed zoomExtent
368
+ const size = interval[1] - interval[0];
369
+ const min = zoomExtent[0];
370
+ const max = zoomExtent[1];
371
+
372
+ // Clamp the start and end so the interval stays within bounds
373
+ // Note: Only works reliably with linear scales. TODO: Handle other scales.
374
+ if (interval[0] < min) {
375
+ interval[0] = min;
376
+ interval[1] = min + size;
377
+ }
378
+ if (interval[1] > max) {
379
+ interval[1] = max;
380
+ interval[0] = max - size;
381
+ }
382
+ } else {
383
+ interval[0] = Math.max(zoomExtent[0], interval[0]);
384
+ interval[1] = Math.min(zoomExtent[1], interval[1]);
385
+ }
386
+ interval[1] = Math.min(zoomExtent[1], interval[1]);
387
+ }
388
+
389
+ setter({ type: "interval", intervals });
390
+ };
391
+
392
+ const mouseUpListener = () => {
393
+ document.removeEventListener(
394
+ "mousemove",
395
+ mouseMoveListener
396
+ );
397
+ document.removeEventListener("mouseup", mouseUpListener);
398
+
399
+ nowBrushing = false;
400
+ if (translatedRectangle) {
401
+ setCursor("move");
402
+ translatedRectangle = null;
403
+ }
404
+ };
405
+ document.addEventListener("mousemove", mouseMoveListener);
406
+
407
+ document.addEventListener("mouseup", mouseUpListener);
408
+ });
409
+
410
+ view.addInteractionEventListener(
411
+ "click",
412
+ (coords, event) => {
413
+ if (/** @type {MouseEvent} */ (event.uiEvent).button == 0) {
414
+ if (preventNextClickPropagation) {
415
+ event.stopPropagation();
416
+ preventNextClickPropagation = false;
417
+ }
418
+ }
419
+ },
420
+ true
421
+ );
422
+
423
+ const isPointInsideSelection = (/** @type {Point} */ point) =>
424
+ selectionContainsPoint(selectionExpr(), invertPoint(point));
425
+
426
+ // TODO: Make behavior configurable
427
+ view.addInteractionEventListener(
428
+ "dblclick",
429
+ (coords, event) => {
430
+ if (isPointInsideSelection(event.point)) {
431
+ clearSelection();
432
+ event.stopPropagation();
433
+ }
434
+ },
435
+ true
436
+ );
437
+
438
+ // Handle mouse cursor changes
439
+ view.addInteractionEventListener("mousemove", (coords, event) => {
440
+ if (isPointInsideSelection(event.point)) {
441
+ // Brushing and translating the existing brush are different actions.
442
+ if (!nowBrushing) {
443
+ mouseOver = true;
444
+ // When translation is active, the cursor shows a grabbing hand.
445
+ if (!translatedRectangle) {
446
+ setCursor("move");
447
+ }
448
+ }
449
+ } else {
450
+ mouseOver = false;
451
+ if (!translatedRectangle) {
452
+ setCursor(null);
453
+ }
454
+ }
455
+ });
456
+ }
457
+ }
458
+
459
+ *getChildren() {
460
+ if (this.background) {
461
+ yield this.background;
462
+ }
463
+ if (this.backgroundStroke) {
464
+ yield this.backgroundStroke;
465
+ }
466
+ if (this.title) {
467
+ yield this.title;
468
+ }
469
+ yield* Object.values(this.axes);
470
+ yield* Object.values(this.gridLines);
471
+ yield this.view;
472
+ yield* Object.values(this.scrollbars);
473
+ if (this.selectionRect) {
474
+ yield this.selectionRect;
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Create view decorations, grid lines, axes, etc.
480
+ */
481
+ async createAxes() {
482
+ const { view, axes, gridLines } = this;
483
+
484
+ /**
485
+ * @param {import("../axisResolution.js").default} r
486
+ * @param {import("../../spec/channel.js").PrimaryPositionalChannel} channel
487
+ */
488
+ const getAxisPropsWithDefaults = (r, channel) => {
489
+ const propsWithoutDefaults = r.getAxisProps();
490
+ if (propsWithoutDefaults === null) {
491
+ return;
492
+ }
493
+
494
+ const props = propsWithoutDefaults
495
+ ? { ...propsWithoutDefaults }
496
+ : {};
497
+
498
+ // Pick a default orient based on what is available.
499
+ // This logic is needed for layer views that have independent axes.
500
+ if (!props.orient) {
501
+ for (const orient of CHANNEL_ORIENTS[channel]) {
502
+ if (!axes[orient]) {
503
+ props.orient = orient;
504
+ break;
505
+ }
506
+ }
507
+ if (!props.orient) {
508
+ throw new Error(
509
+ "No slots available for an axis! Perhaps a LayerView has more than two children?"
510
+ );
511
+ }
512
+ }
513
+
514
+ props.title ??= r.getTitle();
515
+
516
+ if (!CHANNEL_ORIENTS[channel].includes(props.orient)) {
517
+ throw new Error(
518
+ `Invalid axis orientation "${props.orient}" on channel "${channel}"!`
519
+ );
520
+ }
521
+
522
+ return props;
523
+ };
524
+
525
+ /**
526
+ * @param {import("../axisResolution.js").default} r
527
+ * @param {import("../../spec/channel.js").PrimaryPositionalChannel} channel
528
+ * @param {import("../view.js").default} axisParent
529
+ */
530
+ const createAxis = async (r, channel, axisParent) => {
531
+ const props = getAxisPropsWithDefaults(r, channel);
532
+
533
+ if (props) {
534
+ if (axes[props.orient]) {
535
+ throw new Error(
536
+ `An axis with the orient "${props.orient}" already exists!`
537
+ );
538
+ }
539
+
540
+ const axisView = new AxisView(
541
+ props,
542
+ r.scaleResolution.type,
543
+ this.layoutParent.context,
544
+ this.layoutParent,
545
+ axisParent
546
+ );
547
+ axes[props.orient] = axisView;
548
+ await axisView.initializeChildren();
549
+ }
550
+ };
551
+
552
+ /**
553
+ * @param {import("../axisResolution.js").default} r
554
+ * @param {import("../../spec/channel.js").PrimaryPositionalChannel} channel
555
+ * @param {import("../view.js").default} axisParent
556
+ */
557
+ const createAxisGrid = async (r, channel, axisParent) => {
558
+ const props = getAxisPropsWithDefaults(r, channel);
559
+
560
+ if (props && (props.grid || props.chromGrid)) {
561
+ const axisGridView = new AxisGridView(
562
+ props,
563
+ r.scaleResolution.type,
564
+ this.layoutParent.context,
565
+ this.layoutParent,
566
+ axisParent
567
+ );
568
+ gridLines[props.orient] = axisGridView;
569
+ await axisGridView.initializeChildren();
570
+ }
571
+ };
572
+
573
+ // Handle children that have caught axis resolutions. Create axes for them.
574
+ for (const channel of /** @type {import("../../spec/channel.js").PrimaryPositionalChannel[]} */ ([
575
+ "x",
576
+ "y",
577
+ ])) {
578
+ if (view.needsAxes[channel]) {
579
+ const r = view.resolutions.axis[channel];
580
+ if (!r) {
581
+ continue;
582
+ }
583
+
584
+ await createAxis(r, channel, view);
585
+ }
586
+ }
587
+
588
+ // Handle gridlines of children. Note: children's axis resolution may be caught by
589
+ // this view or some of this view's ancestors.
590
+ for (const channel of /** @type {import("../../spec/channel.js").PrimaryPositionalChannel[]} */ ([
591
+ "x",
592
+ "y",
593
+ ])) {
594
+ if (
595
+ view.needsAxes[channel] &&
596
+ // Handle a special case where the child view has an excluded resolution
597
+ // but no scale or axis, e.g., when only values are used on a channel.
598
+ view.getConfiguredOrDefaultResolution(channel, "axis") !=
599
+ "excluded"
600
+ ) {
601
+ const r = view.getAxisResolution(channel);
602
+ if (!r) {
603
+ continue;
604
+ }
605
+
606
+ await createAxisGrid(r, channel, view);
607
+ }
608
+ }
609
+
610
+ // Handle LayerView's possible independent axes
611
+ if (view instanceof LayerView) {
612
+ // First create axes that have an orient preference
613
+ for (const layerChild of view) {
614
+ for (const [channel, r] of Object.entries(
615
+ layerChild.resolutions.axis
616
+ )) {
617
+ const props = r.getAxisProps();
618
+ if (props && props.orient) {
619
+ await createAxis(r, channel, layerChild);
620
+ }
621
+ }
622
+ }
623
+
624
+ // Then create axes in a priority order
625
+ for (const layerChild of view) {
626
+ for (const [channel, r] of Object.entries(
627
+ layerChild.resolutions.axis
628
+ )) {
629
+ const props = r.getAxisProps();
630
+ if (props && !props.orient) {
631
+ await createAxis(r, channel, layerChild);
632
+ }
633
+ }
634
+ }
635
+
636
+ // TODO: Axis grid
637
+ }
638
+
639
+ // Axes are created after scales are resolved, so we need to resolve possible new scales here
640
+ [...Object.values(axes), ...Object.values(gridLines)].forEach((v) =>
641
+ v.visit((view) => {
642
+ if (view instanceof UnitView) {
643
+ view.resolve("scale");
644
+ }
645
+ })
646
+ );
647
+ }
648
+
649
+ getOverhang() {
650
+ const calculate = (
651
+ /** @type {import("../../spec/axis.js").AxisOrient} */ orient
652
+ ) => {
653
+ const axisView = this.axes[orient];
654
+ return axisView
655
+ ? Math.max(
656
+ axisView.getPerpendicularSize() +
657
+ (axisView.axisProps.offset ?? 0),
658
+ 0
659
+ )
660
+ : 0;
661
+ };
662
+
663
+ // Axes and overhang should be mutually exclusive, so we can just add them together
664
+ return new Padding(
665
+ calculate("top"),
666
+ calculate("right"),
667
+ calculate("bottom"),
668
+ calculate("left")
669
+ ).add(this.view.getOverhang());
670
+ }
671
+
672
+ getOverhangAndPadding() {
673
+ return this.getOverhang().add(this.view.getPadding());
674
+ }
675
+ }
676
+
677
+ /**
678
+ * @param {import("../../spec/view.js").ViewBackground} viewBackground
679
+ * @returns {import("../../spec/view.js").UnitSpec}
680
+ */
681
+ export function createBackground(viewBackground) {
682
+ const required =
683
+ viewBackground?.fill ||
684
+ viewBackground?.fillOpacity ||
685
+ viewBackground?.shadowOpacity;
686
+ if (!required) {
687
+ return;
688
+ }
689
+
690
+ return {
691
+ configurableVisibility: false,
692
+ data: { values: [{}] },
693
+ mark: {
694
+ color: viewBackground.fill,
695
+ opacity:
696
+ viewBackground.fillOpacity ?? (viewBackground.fill ? 1.0 : 0.0),
697
+ type: "rect",
698
+ clip: false, // Shouldn't be needed
699
+ tooltip: null,
700
+ minHeight: 1,
701
+ minOpacity: 0,
702
+ shadowBlur: viewBackground.shadowBlur,
703
+ shadowColor: viewBackground.shadowColor,
704
+ shadowOffsetX: viewBackground.shadowOffsetX,
705
+ shadowOffsetY: viewBackground.shadowOffsetY,
706
+ shadowOpacity: viewBackground.shadowOpacity,
707
+ },
708
+ };
709
+ }
710
+
711
+ /**
712
+ * @param {import("../../spec/view.js").ViewBackground} viewBackground
713
+ * @returns {import("../../spec/view.js").UnitSpec}
714
+ */
715
+ export function createBackgroundStroke(viewBackground) {
716
+ if (
717
+ !viewBackground ||
718
+ !viewBackground.stroke ||
719
+ viewBackground.strokeWidth === 0 ||
720
+ viewBackground.strokeOpacity === 0
721
+ ) {
722
+ return;
723
+ }
724
+
725
+ // Using rules to draw a non-filled rectangle.
726
+ // We are not using a rect mark because it is not optimized for outlines.
727
+ // TODO: Implement "hollow" mesh for non-filled rectangles
728
+ return {
729
+ configurableVisibility: false,
730
+ resolve: {
731
+ scale: { x: "excluded", y: "excluded" },
732
+ axis: { x: "excluded", y: "excluded" },
733
+ },
734
+ data: {
735
+ values: [
736
+ { x: 0, y: 0, x2: 1, y2: 0 },
737
+ { x: 1, y: 0, x2: 1, y2: 1 },
738
+ { x: 1, y: 1, x2: 0, y2: 1 },
739
+ { x: 0, y: 1, x2: 0, y2: 0 },
740
+ ],
741
+ },
742
+ mark: {
743
+ size: viewBackground.strokeWidth ?? 1.0,
744
+ color: viewBackground.stroke ?? "lightgray",
745
+ strokeCap: "square",
746
+ opacity: viewBackground.strokeOpacity ?? 1.0,
747
+ type: "rule",
748
+ clip: false,
749
+ tooltip: null,
750
+ },
751
+ encoding: {
752
+ x: { field: "x", type: "quantitative", scale: null },
753
+ y: { field: "y", type: "quantitative", scale: null },
754
+ x2: { field: "x2" },
755
+ y2: { field: "y2" },
756
+ },
757
+ };
758
+ }