higlass 1.13.4 → 1.13.6

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 (139) hide show
  1. package/README.md +48 -54
  2. package/app/globals.d.ts +1 -1
  3. package/app/missing-types.d.ts +4 -1
  4. package/app/scripts/AddTrackDialog.jsx +3 -3
  5. package/app/scripts/AddTrackPositionMenu.jsx +2 -2
  6. package/app/scripts/Annotations1dTrack.js +1 -1
  7. package/app/scripts/Annotations2dTrack.js +3 -5
  8. package/app/scripts/Autocomplete.jsx +14 -21
  9. package/app/scripts/AxisPixi.js +10 -12
  10. package/app/scripts/BarTrack.js +3 -3
  11. package/app/scripts/BedLikeTrack.js +12 -13
  12. package/app/scripts/Button.jsx +1 -1
  13. package/app/scripts/CNVIntervalTrack.js +1 -1
  14. package/app/scripts/CenterTrack.jsx +8 -7
  15. package/app/scripts/Chromosome2DAnnotations.js +1 -1
  16. package/app/scripts/Chromosome2DLabels.js +1 -1
  17. package/app/scripts/ChromosomeGrid.js +49 -38
  18. package/app/scripts/ChromosomeInfo.js +4 -2
  19. package/app/scripts/CombinedTrack.js +3 -1
  20. package/app/scripts/ConfigTrackMenu.jsx +1 -1
  21. package/app/scripts/ConfigViewMenu.jsx +2 -2
  22. package/app/scripts/ContextMenuContainer.jsx +1 -2
  23. package/app/scripts/ContextMenuItem.jsx +1 -0
  24. package/app/scripts/CrossRule.js +1 -1
  25. package/app/scripts/CustomTrackDialog.jsx +2 -2
  26. package/app/scripts/Dialog.jsx +2 -2
  27. package/app/scripts/DragListeningDiv.jsx +1 -1
  28. package/app/scripts/DraggableDiv.jsx +2 -3
  29. package/app/scripts/ExportLinkDialog.jsx +1 -1
  30. package/app/scripts/GalleryTracks.jsx +77 -78
  31. package/app/scripts/GenomePositionSearchBox.jsx +10 -9
  32. package/app/scripts/HeatmapOptions.jsx +8 -3
  33. package/app/scripts/HeatmapTiledPixiTrack.js +72 -53
  34. package/app/scripts/HiGlassComponent.jsx +75 -98
  35. package/app/scripts/Horizontal1dHeatmapTrack.js +1 -1
  36. package/app/scripts/Horizontal2DDomainsTrack.js +1 -1
  37. package/app/scripts/HorizontalChromosomeLabels.js +28 -22
  38. package/app/scripts/HorizontalGeneAnnotationsTrack.js +1 -1
  39. package/app/scripts/HorizontalHeatmapTrack.js +2 -2
  40. package/app/scripts/HorizontalMultivecTrack.js +6 -7
  41. package/app/scripts/HorizontalRule.js +1 -2
  42. package/app/scripts/HorizontalTiled1DPixiTrack.js +4 -4
  43. package/app/scripts/HorizontalTiledPlot.jsx +9 -9
  44. package/app/scripts/LeftTrackModifier.js +4 -0
  45. package/app/scripts/ListWrapper.jsx +1 -2
  46. package/app/scripts/MapboxTilesTrack.js +4 -4
  47. package/app/scripts/Modal.jsx +2 -2
  48. package/app/scripts/MoveableTrack.jsx +10 -12
  49. package/app/scripts/NestedContextMenu.jsx +2 -1
  50. package/app/scripts/OSMTileIdsTrack.js +1 -1
  51. package/app/scripts/OverlayTrack.js +4 -4
  52. package/app/scripts/PixiTrack.js +58 -17
  53. package/app/scripts/PlotTypeChooser.jsx +3 -4
  54. package/app/scripts/RasterTilesTrack.js +3 -2
  55. package/app/scripts/SearchField.js +5 -5
  56. package/app/scripts/SeriesListItems.jsx +3 -4
  57. package/app/scripts/SeriesListMenu.jsx +81 -11
  58. package/app/scripts/SeriesListSubmenuMixin.jsx +5 -1
  59. package/app/scripts/SketchInlinePicker.jsx +2 -2
  60. package/app/scripts/SortableList.jsx +1 -1
  61. package/app/scripts/Tiled1DPixiTrack.js +5 -1
  62. package/app/scripts/TiledPixiTrack.js +221 -76
  63. package/app/scripts/TiledPlot.jsx +35 -43
  64. package/app/scripts/TilesetFinder.jsx +12 -4
  65. package/app/scripts/Track.js +2 -2
  66. package/app/scripts/TrackArea.jsx +4 -0
  67. package/app/scripts/TrackControl.jsx +2 -2
  68. package/app/scripts/TrackRenderer.jsx +30 -31
  69. package/app/scripts/UnknownPixiTrack.js +1 -1
  70. package/app/scripts/ValueIntervalTrack.js +1 -1
  71. package/app/scripts/VerticalRule.js +2 -2
  72. package/app/scripts/VerticalTiledPlot.jsx +7 -7
  73. package/app/scripts/ViewConfigEditor.jsx +1 -1
  74. package/app/scripts/ViewContextMenu.jsx +4 -4
  75. package/app/scripts/ViewHeader.jsx +6 -7
  76. package/app/scripts/ViewportTracker2D.js +1 -1
  77. package/app/scripts/api.js +5 -6
  78. package/app/scripts/configs/available-track-types.js +1 -1
  79. package/app/scripts/configs/positions-by-datatype.js +2 -2
  80. package/app/scripts/configs/themes.js +0 -1
  81. package/app/scripts/configs/tracks-info-by-type.js +11 -8
  82. package/app/scripts/configs/tracks-info.js +2 -2
  83. package/app/scripts/d3-context-menu.js +3 -4
  84. package/app/scripts/data-fetchers/DataFetcher.js +107 -91
  85. package/app/scripts/data-fetchers/genbank-fetcher.js +6 -10
  86. package/app/scripts/data-fetchers/local-tile-fetcher.js +2 -6
  87. package/app/scripts/gosling-exports.js +29 -0
  88. package/app/scripts/hglib.jsx +3 -1
  89. package/app/scripts/hocs/with-modal.jsx +32 -10
  90. package/app/scripts/hocs/with-pub-sub.js +28 -0
  91. package/app/scripts/hocs/with-theme.jsx +21 -14
  92. package/app/scripts/icons.jsx +3 -2
  93. package/app/scripts/mixwith.js +2 -2
  94. package/app/scripts/plugins/get-data-fetcher.js +2 -3
  95. package/app/scripts/services/chrom-info.js +32 -4
  96. package/app/scripts/services/element-resize-listener.js +2 -2
  97. package/app/scripts/services/index.js +0 -1
  98. package/app/scripts/services/tile-proxy.js +370 -282
  99. package/app/scripts/services/worker.js +36 -34
  100. package/app/scripts/test-helpers/test-helpers.jsx +3 -3
  101. package/app/scripts/types.ts +73 -38
  102. package/app/scripts/utils/DenseDataExtrema1D.js +1 -1
  103. package/app/scripts/utils/DenseDataExtrema2D.js +2 -1
  104. package/app/scripts/utils/LruCache.js +3 -2
  105. package/app/scripts/utils/assert.js +19 -0
  106. package/app/scripts/utils/background-task-scheduler.js +2 -0
  107. package/app/scripts/utils/color-domain-to-rgba-array.js +13 -3
  108. package/app/scripts/utils/color-to-hex.js +1 -1
  109. package/app/scripts/utils/dict-items.js +1 -0
  110. package/app/scripts/utils/dict-keys.js +1 -0
  111. package/app/scripts/utils/dict-values.js +1 -0
  112. package/app/scripts/utils/expand-combined-tracks.js +11 -7
  113. package/app/scripts/utils/fake-pub-sub.js +12 -0
  114. package/app/scripts/utils/fill-in-min-widths.js +47 -21
  115. package/app/scripts/utils/flatten.js +0 -1
  116. package/app/scripts/utils/get-aggregation-function.js +1 -1
  117. package/app/scripts/utils/get-default-track-for-datatype.js +36 -10
  118. package/app/scripts/utils/get-higlass-components.js +27 -3
  119. package/app/scripts/utils/get-track-position-by-uid.js +8 -1
  120. package/app/scripts/utils/get-xylofon.js +12 -9
  121. package/app/scripts/utils/has-parent.js +5 -5
  122. package/app/scripts/utils/hex-string-to-int.js +1 -1
  123. package/app/scripts/utils/interval-tree.js +222 -177
  124. package/app/scripts/utils/load-chrom-infos.js +4 -1
  125. package/app/scripts/utils/pixi-text-to-svg.js +5 -9
  126. package/app/scripts/utils/range-query-2d.js +3 -3
  127. package/app/scripts/utils/reduce.js +12 -5
  128. package/app/scripts/utils/segments-to-rows.js +14 -11
  129. package/app/scripts/utils/show-mouse-position.js +17 -1
  130. package/app/scripts/utils/svg-line.js +7 -8
  131. package/app/scripts/utils/type-guards.js +16 -7
  132. package/app/scripts/utils/visit-positioned-tracks.js +7 -5
  133. package/app/styles/d3-context-menu.css +0 -1
  134. package/app/styles/prism.css +1 -0
  135. package/dist/hglib.js +85885 -85618
  136. package/dist/hglib.min.js +110 -109
  137. package/dist/higlass.mjs +86301 -86034
  138. package/package.json +13 -17
  139. package/app/scripts/hocs/with-pub-sub.jsx +0 -28
@@ -1,110 +1,104 @@
1
- // @ts-nocheck
2
1
  import { range } from 'd3-array';
3
2
  import slugid from 'slugid';
4
3
 
5
- import { workerGetTiles, workerSetPix } from './worker';
4
+ import { workerFetchTiles, workerSetPix } from './worker';
6
5
 
7
- import { trimTrailingSlash as tts, timeout as sleep } from '../utils';
6
+ import sleep from '../utils/timeout';
7
+ import tts from '../utils/trim-trailing-slash';
8
+ import {
9
+ isLegacyTilesetInfo,
10
+ isResolutionsTilesetInfo,
11
+ } from '../utils/type-guards';
8
12
 
9
13
  // Config
10
- import { TILE_FETCH_DEBOUNCE } from '../configs';
14
+ import { TILE_FETCH_DEBOUNCE } from '../configs/primitives';
11
15
 
16
+ /** @import { PubSub } from 'pub-sub-es' */
17
+ /** @import { Scale, TilesetInfo, TilesRequest } from '../types' */
18
+ /** @import { CompletedTileData, TileResponse, SelectedRowsOptions } from './worker' */
19
+
20
+ /** @type {number} */
12
21
  const MAX_FETCH_TILES = 15;
13
22
 
14
- /*
15
- const str = document.currentScript.src
16
- const pathName = str.substring(0, str.lastIndexOf("/"));
17
- const workerPath = `${pathName}/worker.js`;
18
-
19
- const setPixPool = new Pool(1);
20
-
21
- setPixPool.run(function(params, done) {
22
- try {
23
- const array = new Float32Array(params.data);
24
- const pixData = worker.workerSetPix(
25
- params.size,
26
- array,
27
- params.valueScaleType,
28
- params.valueScaleDomain,
29
- params.pseudocount,
30
- params.colorScale,
31
- );
23
+ /** @type {string} */
24
+ const sessionId = import.meta.env.DEV ? 'dev' : slugid.nice();
25
+ /** @type {number} */
26
+ export let requestsInFlight = 0;
27
+ /** @type {string | null} */
28
+ export let authHeader = null;
32
29
 
33
- done.transfer({
34
- pixData: pixData
35
- }, [pixData.buffer]);
36
- } catch (err) {
37
- console.log('err:', err);
30
+ /**
31
+ * Iterator helper to chunk an array into smaller arrays of a fixed size.
32
+ *
33
+ * @template T
34
+ * @param {Iterable<T>} iterable
35
+ * @param {number} size
36
+ * @returns {Generator<Array<T>, void, unknown>}
37
+ */
38
+ function* chunkIterable(iterable, size) {
39
+ let chunk = [];
40
+ for (const item of iterable) {
41
+ chunk.push(item);
42
+ if (chunk.length === size) {
43
+ yield chunk;
44
+ chunk = [];
45
+ }
38
46
  }
39
- }, [workerPath]);
40
-
41
-
42
- const fetchTilesPool = new Pool(10);
43
- fetchTilesPool.run(function(params, done) {
44
- try {
45
- worker.workerGetTiles(params.outUrl, params.server, params.theseTileIds,
46
- params.authHeader, done);
47
- // done.transfer({
48
- // pixData: pixData
49
- // }, [pixData.buffer]);
50
- } catch (err) {
51
- console.log('err:', err);
47
+ if (chunk.length) {
48
+ yield chunk;
52
49
  }
53
- }, [workerPath]);
54
- */
50
+ }
55
51
 
56
- const sessionId = import.meta.env.DEV ? 'dev' : slugid.nice();
57
- export let requestsInFlight = 0; // eslint-disable-line import/no-mutable-exports
58
- export let authHeader = null; // eslint-disable-line import/no-mutable-exports
52
+ /**
53
+ * @template T
54
+ * @template U
55
+ * @typedef {{ value: T, resolve: (value: U) => void, reject: (err: unknown) => void }} WithResolvers
56
+ */
59
57
 
60
- const throttleAndDebounce = (func, interval, finalWait) => {
61
- let timeout;
62
- let bundledRequest = [];
63
- let requestMapper = {};
58
+ /**
59
+ * Create a function that batches calls at intervals, with a final debounce.
60
+ *
61
+ * The returned function collects individual items and executes `processBatch` at the specified interval.
62
+ * If additional calls occur after the last batch, a final debounce ensures they are included.
63
+ *
64
+ * @template T
65
+ * @template U
66
+ * @template {Array<unknown>} Args
67
+ *
68
+ * @param {Object} options
69
+ * @param {(items: Array<WithResolvers<T, U>>, ...args: Args) => void} options.processBatch
70
+ * @param {number} options.interval
71
+ * @param {number} options.finalWait
72
+ */
73
+ function createBatchedExecutor({ processBatch, interval, finalWait }) {
74
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
75
+ let timeout = undefined;
76
+ /** @type {Array<WithResolvers<T, U>>} */
77
+ let pending = [];
78
+ /** @type {number} */
64
79
  let blockedCalls = 0;
65
80
 
66
- const bundleRequests = (request) => {
67
- const requestId = requestMapper[request.id];
68
-
69
- if (requestId && bundledRequest[requestId]) {
70
- bundledRequest[requestId].ids = bundledRequest[requestId].ids.concat(
71
- request.ids,
72
- );
73
- } else {
74
- requestMapper[request.id] = bundledRequest.length;
75
- bundledRequest.push(request);
76
- }
77
- };
78
-
79
81
  const reset = () => {
80
- timeout = null;
81
- bundledRequest = [];
82
- requestMapper = {};
82
+ timeout = undefined;
83
+ pending = [];
83
84
  };
84
85
 
85
- // In a normal situation we would just call `func(...args)` but since we
86
- // modify the first argument and always trigger `reset()` afterwards I created
87
- // this helper function to avoid code duplication. Think of this function
88
- // as the actual function call that is being throttled and debounced.
89
- const callFunc = (request, ...args) => {
90
- func(
91
- {
92
- sessionId,
93
- requests: bundledRequest,
94
- },
95
- ...args,
96
- );
86
+ /** @param {Args} args */
87
+ const callFunc = (...args) => {
88
+ // Flush the "bundle" (of collected items) to the processor
89
+ processBatch(pending, ...args);
97
90
  reset();
98
91
  };
99
92
 
100
- const debounced = (request, ...args) => {
93
+ /** @param {Args} args */
94
+ const debounced = (...args) => {
101
95
  const later = () => {
102
96
  // Since we throttle and debounce we should check whether there were
103
97
  // actually multiple attempts to call this function after the most recent
104
98
  // throttled call. If there were no more calls we don't have to call
105
99
  // the function again.
106
100
  if (blockedCalls > 0) {
107
- callFunc(request, ...args);
101
+ callFunc(...args);
108
102
  blockedCalls = 0;
109
103
  }
110
104
  };
@@ -113,25 +107,20 @@ const throttleAndDebounce = (func, interval, finalWait) => {
113
107
  timeout = setTimeout(later, finalWait);
114
108
  };
115
109
 
116
- debounced.cancel = () => {
117
- clearTimeout(timeout);
118
- reset();
119
- };
120
-
121
- debounced.immediate = () => {
122
- func({
123
- sessionId,
124
- requests: bundledRequest,
125
- });
126
- };
127
-
128
110
  let wait = false;
129
- const throttled = (request, ...args) => {
130
- bundleRequests(request);
111
+ /**
112
+ * @param {T} value
113
+ * @param {Args} args
114
+ * @returns {Promise<U>}
115
+ */
116
+ const throttled = (value, ...args) => {
117
+ // Collect items into the current queue any time the caller makes a request
118
+ const { promise, resolve, reject } = Promise.withResolvers();
119
+ pending.push({ value, resolve, reject });
131
120
 
132
121
  if (!wait) {
133
- callFunc(request, ...args);
134
- debounced(request, ...args);
122
+ callFunc(...args);
123
+ debounced(...args);
135
124
  wait = true;
136
125
  blockedCalls = 0;
137
126
  setTimeout(() => {
@@ -140,152 +129,247 @@ const throttleAndDebounce = (func, interval, finalWait) => {
140
129
  } else {
141
130
  blockedCalls++;
142
131
  }
132
+
133
+ return promise;
143
134
  };
144
135
 
145
136
  return throttled;
146
- };
137
+ }
147
138
 
139
+ /** @param {string} newHeader */
148
140
  export const setTileProxyAuthHeader = (newHeader) => {
149
141
  authHeader = newHeader;
150
142
  };
151
143
 
144
+ /** @returns {string | null} */
152
145
  export const getTileProxyAuthHeader = () => authHeader;
153
146
 
154
- // Fritz: is this function used anywhere?
155
- export function fetchMultiRequestTiles(req, pubSub) {
156
- const requests = req.requests;
147
+ /**
148
+ * Merges an array of request objects by combining requests
149
+ * that share the same `id`, reducing the total number of requests.
150
+ *
151
+ * If multiple requests have the same `id`, their `tileIds` arrays are merged
152
+ * into a single request entry in the output array.
153
+ *
154
+ * @example
155
+ * ```js
156
+ * const requests = [
157
+ * { id: "A", tileIds: ["1", "2"] },
158
+ * { id: "B", tileIds: ["3"] },
159
+ * { id: "A", tileids: ["4", "5"] },
160
+ * ];
161
+ *
162
+ * const bundled = bundleRequests(requests);
163
+ * console.log(bundled);
164
+ * // [
165
+ * // { id: "A", tileIds: ["1", "2", "4", "5"] },
166
+ * // { id: "B", tileIds: ["3"] },
167
+ * // ]
168
+ * ```
169
+ *
170
+ * @template {{ id: string, tileIds: ReadonlyArray<string> }} T
171
+ * @param {Array<T>} requests - The list of requests to bundle
172
+ * @returns {Array<T>} - A new array with merged requests
173
+ */
174
+ export function bundleRequestsById(requests) {
175
+ /** @type {Array<T>} */
176
+ const bundledRequests = [];
177
+ /** @type {Record<string, number>} */
178
+ const mapper = {};
157
179
 
158
- const fetchPromises = [];
180
+ for (const request of requests) {
181
+ if (mapper[request.id] === undefined) {
182
+ mapper[request.id] = bundledRequests.length;
183
+ bundledRequests.push({ ...request, tileIds: [] });
184
+ }
185
+ const bundle = bundledRequests[mapper[request.id]];
186
+ bundle.tileIds = bundle.tileIds.concat(request.tileIds);
187
+ }
159
188
 
160
- const requestsByServer = {};
161
- const requestBodyByServer = {};
189
+ return bundledRequests;
190
+ }
191
+
192
+ /**
193
+ * Groups request objects by `server`, merging their `tileIds` and structuring tileset-related
194
+ * data into `body`.
195
+ *
196
+ * **Note:** The first request for each `server` sets the `options` for all grouped requests.
197
+ * Each tileset in `body` also inherits these `options`. A tileset is only added to `body`
198
+ * if the request includes `options`.
199
+ *
200
+ * Trevor (2025-02-20): This follows the original "server bundling" logic. It’s unclear if `body` is
201
+ * actually used in practice. Omitting requests without `options` might be an unintended
202
+ * behavior, but we're maintaining it for now.
203
+ *
204
+ * @example
205
+ * ```js
206
+ * const requests = [
207
+ * { server: "A", tileIds: ["tileset1.1", "tileset2.2"], options: { foo: "bar" } },
208
+ * { server: "B", tileIds: ["tileset3.3"], options: { baz: "qux" } },
209
+ * { server: "A", tileIds: ["tileset1.4"] },
210
+ * ];
211
+ *
212
+ * const bundled = bundleRequestsByServer(requests);
213
+ * console.log(bundled);
214
+ * // [
215
+ * // {
216
+ * // server: "A",
217
+ * // tileIds: ["tileset1.1", "tileset2.2", "tileset1.4"],
218
+ * // options: { foo: "bar" },
219
+ * // body: [
220
+ * // { tilesetUid: "tileset1", tileIds: ["1"], options: { foo: "bar" } },
221
+ * // { tilesetUid: "tileset2", tileIds: ["2"], options: { foo: "bar" } }
222
+ * // ]
223
+ * // },
224
+ * // {
225
+ * // server: "B",
226
+ * // tileIds: ["tileset3.3"],
227
+ * // options: { baz: "qux" },
228
+ * // body: [
229
+ * // { tilesetUid: "tileset3", tileIds: ["3"], options: { baz: "qux" } }
230
+ * // ]
231
+ * // }
232
+ * // ]
233
+ * ```
234
+ *
235
+ * @template {{ tileIds: ReadonlyArray<string>, server: string, options?: Record<string, any> }} T
236
+ * @param {Array<T>} requests - The list of requests to bundle
237
+ * @returns {Array<T & { body: ReadonlyArray<ServerTilesetBody> }> }>} - A new array with merged requests per server
238
+ */
239
+ export function bundleRequestsByServer(requests) {
240
+ /** @typedef {{ tilesetUid: string, tileIds: Array<string>, options: Record<string, any> }} ServerTilesetBody */
241
+ /** @type {Array<T & { body: Array<ServerTilesetBody> }>} */
242
+ const bundle = [];
243
+ /** @type {Record<string, number>} */
244
+ const mapper = {};
162
245
 
163
246
  // We're converting the array of IDs into an object in order to filter out duplicated requests.
164
247
  // In case different instances request the same data it won't be loaded twice.
165
248
  for (const request of requests) {
166
- if (!requestsByServer[request.server]) {
167
- requestsByServer[request.server] = {};
168
- requestBodyByServer[request.server] = [];
249
+ if (mapper[request.server] === undefined) {
250
+ mapper[request.server] = bundle.length;
251
+ bundle.push({ ...request, tileIds: [], body: [] });
169
252
  }
170
- for (const id of request.ids) {
171
- requestsByServer[request.server][id] = true;
172
-
253
+ const server = bundle[mapper[request.server]];
254
+ server.tileIds = server.tileIds.concat(request.tileIds);
255
+ for (const id of request.tileIds) {
173
256
  if (request.options) {
174
257
  const firstSepIndex = id.indexOf('.');
175
- const tilesetUuid = id.substring(0, firstSepIndex);
258
+ const tilesetUid = id.substring(0, firstSepIndex);
176
259
  const tileId = id.substring(firstSepIndex + 1);
177
- const tilesetObject = requestBodyByServer[request.server].find(
178
- (t) => t.tilesetUid === tilesetUuid,
260
+ let tilesetObject = server.body.find(
261
+ (t) => t.tilesetUid === tilesetUid,
179
262
  );
180
- if (tilesetObject) {
181
- tilesetObject.tileIds.push(tileId);
182
- } else {
183
- requestBodyByServer[request.server].push({
184
- tilesetUid: tilesetUuid,
185
- tileIds: [tileId],
263
+ if (!tilesetObject) {
264
+ tilesetObject = {
265
+ tilesetUid: tilesetUid,
266
+ tileIds: [],
186
267
  options: request.options,
187
- });
268
+ };
269
+ server.body.push(tilesetObject);
188
270
  }
271
+ tilesetObject.tileIds.push(tileId);
189
272
  }
190
273
  }
191
274
  }
192
275
 
193
- const servers = Object.keys(requestsByServer);
194
-
195
- for (const server of servers) {
196
- const ids = Object.keys(requestsByServer[server]);
197
- // console.log('ids:', ids);
198
-
199
- const requestBody = requestBodyByServer[server];
200
-
201
- // if we request too many tiles, then the URL can get too long and fail
202
- // so we'll break up the requests into smaller subsets
203
- for (let i = 0; i < ids.length; i += MAX_FETCH_TILES) {
204
- const theseTileIds = ids.slice(
205
- i,
206
- i + Math.min(ids.length - i, MAX_FETCH_TILES),
207
- );
208
-
209
- const renderParams = theseTileIds.map((x) => `d=${x}`).join('&');
210
- const outUrl = `${server}/tiles/?${renderParams}&s=${sessionId}`;
211
-
212
- /* eslint-disable no-loop-func */
213
- /* eslint-disable no-unused-vars */
214
- const p = new Promise((resolve, reject) => {
215
- pubSub.publish('requestSent', outUrl);
216
- const params = {};
217
-
218
- params.outUrl = outUrl;
219
- params.server = server;
220
- params.theseTileIds = theseTileIds;
221
- params.authHeader = authHeader;
222
-
223
- workerGetTiles(
224
- params.outUrl,
225
- params.server,
226
- params.theseTileIds,
227
- params.authHeader,
228
- resolve,
229
- requestBody,
230
- );
276
+ return bundle;
277
+ }
231
278
 
232
- /*
233
- fetchTilesPool.send(params)
234
- .promise()
235
- .then(ret => {
236
- resolve(ret);
237
- });
238
- */
239
- pubSub.publish('requestReceived', outUrl);
240
- });
241
-
242
- fetchPromises.push(p);
279
+ /**
280
+ * Consolidates requests into a (potentially) smaller, optimized set
281
+ *
282
+ * Requests are first bundled to merge duplicates, then grouped by `server` to
283
+ * consolidate requests targeting the same endpoint. The resulting set is split
284
+ * into smaller batches based on `maxSize`.
285
+ *
286
+ * @template {TilesRequest} T
287
+ * @param {Array<T>} requests - The list of requests to optimize.
288
+ * @param {{ maxSize?: number }} [options] - Configuration options.
289
+ */
290
+ function* optimizeRequests(requests, { maxSize = MAX_FETCH_TILES } = {}) {
291
+ const byRequestId = bundleRequestsById(requests);
292
+ const byServer = bundleRequestsByServer(byRequestId);
293
+ for (const request of byServer) {
294
+ for (const tileIds of chunkIterable(new Set(request.tileIds), maxSize)) {
295
+ yield { ...request, tileIds };
243
296
  }
244
297
  }
298
+ }
245
299
 
246
- Promise.all(fetchPromises).then((datas) => {
247
- const tiles = {};
248
-
249
- // merge back all the tile requests
250
- for (const data of datas) {
251
- const tileIds = Object.keys(data);
300
+ /** @typedef {CompletedTileData<TileResponse>} TileData */
252
301
 
253
- for (const tileId of tileIds) {
254
- tiles[`${data[tileId].server}/${tileId}`] = data[tileId];
255
- }
302
+ /**
303
+ * Collects independent tile responses into a shared index.
304
+ *
305
+ * Allows requests to retrieve associated tiles by server and tile IDs.
306
+ *
307
+ * @param {Array<Record<string, TileData> | void>} responses
308
+ */
309
+ function indexTiles(responses) {
310
+ /** @type {Record<string, TileData>} */
311
+ const tileMap = {};
312
+ /** @type {(server: string, tileId: string) => string} */
313
+ const keyFor = (server, tileId) => `${server}/${tileId}`;
314
+
315
+ // merge back all the tile requests
316
+ for (const response of responses) {
317
+ if (!response) continue;
318
+ for (const [tileId, tileData] of Object.entries(response)) {
319
+ tileMap[keyFor(tileData.server, tileId)] = response[tileId];
256
320
  }
321
+ }
257
322
 
258
- // trigger the callback for every request
259
- for (const request of requests) {
260
- const reqDate = {};
261
- const { server } = request;
262
-
263
- // pull together the data per request
264
- for (const id of request.ids) {
265
- reqDate[id] = tiles[`${server}/${id}`];
323
+ return {
324
+ /**
325
+ * Retrieve data for a specific request from the shared index.
326
+ *
327
+ * @param {{ server: string, tileIds: Array<string> }} request
328
+ */
329
+ resolveTileDataForRequest(request) {
330
+ /** @type {Record<string, TileData>} */
331
+ const response = {};
332
+ for (const tileId of request.tileIds) {
333
+ const entry = tileMap[keyFor(request.server, tileId)];
334
+ if (entry) response[tileId] = entry;
266
335
  }
267
-
268
- request.done(reqDate);
269
- }
270
- });
336
+ return response;
337
+ },
338
+ };
271
339
  }
272
340
 
273
341
  /**
274
- * Retrieve a set of tiles from the server
342
+ * Retrieve a set of tiles from the server.
275
343
  *
276
- * Plenty of room for optimization and caching here.
277
- *
278
- * @param server: A string with the server's url (e.g. "http://127.0.0.1")
279
- * @param tileIds: The ids of the tiles to fetch (e.g. asdf-sdfs-sdfs.0.0.0)
344
+ * @type {(request: TilesRequest, pubSub: PubSub) => Promise<Record<string, TileData>>}
280
345
  */
281
- export const fetchTilesDebounced = throttleAndDebounce(
282
- fetchMultiRequestTiles,
283
- TILE_FETCH_DEBOUNCE,
284
- TILE_FETCH_DEBOUNCE,
285
- );
346
+ export const fetchTilesDebounced = createBatchedExecutor({
347
+ /**
348
+ * Fetch and process a batch of tile requests.
349
+ *
350
+ * @param {Array<WithResolvers<TilesRequest, Record<string, TileData>>>} requests
351
+ * @param {PubSub} pubSub
352
+ */
353
+ processBatch: async (requests, pubSub) => {
354
+ const promises = Array.from(
355
+ optimizeRequests(requests.map((r) => r.value)),
356
+ (request) => workerFetchTiles(request, { authHeader, sessionId, pubSub }),
357
+ );
358
+ const index = indexTiles(await Promise.all(promises));
359
+ for (const request of requests) {
360
+ request.resolve(index.resolveTileDataForRequest(request.value));
361
+ }
362
+ },
363
+ interval: TILE_FETCH_DEBOUNCE,
364
+ finalWait: TILE_FETCH_DEBOUNCE,
365
+ });
286
366
 
287
367
  /**
288
368
  * Calculate the zoom level from a list of available resolutions
369
+ *
370
+ * @param {Array<string>} resolutions
371
+ * @param {Scale} scale
372
+ * @returns {number}
289
373
  */
290
374
  export const calculateZoomLevelFromResolutions = (resolutions, scale) => {
291
375
  const sortedResolutions = resolutions.map((x) => +x).sort((a, b) => b - a);
@@ -308,8 +392,13 @@ export const calculateZoomLevelFromResolutions = (resolutions, scale) => {
308
392
  );
309
393
  };
310
394
 
395
+ /**
396
+ * @param {TilesetInfo} tilesetInfo
397
+ * @param {number} zoomLevel
398
+ * @returns {number}
399
+ */
311
400
  export const calculateResolution = (tilesetInfo, zoomLevel) => {
312
- if (tilesetInfo.resolutions) {
401
+ if (isResolutionsTilesetInfo(tilesetInfo)) {
313
402
  const sortedResolutions = tilesetInfo.resolutions
314
403
  .map((x) => +x)
315
404
  .sort((a, b) => b - a);
@@ -319,7 +408,7 @@ export const calculateResolution = (tilesetInfo, zoomLevel) => {
319
408
  }
320
409
 
321
410
  const maxWidth = tilesetInfo.max_width;
322
- const binsPerDimension = +tilesetInfo.bins_per_dimension;
411
+ const binsPerDimension = +(tilesetInfo?.bins_per_dimension ?? 256);
323
412
  const resolution = maxWidth / (2 ** zoomLevel * binsPerDimension);
324
413
 
325
414
  return resolution;
@@ -327,6 +416,12 @@ export const calculateResolution = (tilesetInfo, zoomLevel) => {
327
416
 
328
417
  /**
329
418
  * Calculate the current zoom level.
419
+ *
420
+ * @param {Scale} scale
421
+ * @param {number} minX
422
+ * @param {number} maxX
423
+ * @param {number} binsPerTile
424
+ * @returns {number}
330
425
  */
331
426
  export const calculateZoomLevel = (scale, minX, maxX, binsPerTile) => {
332
427
  const rangeWidth = scale.range()[1] - scale.range()[0];
@@ -366,13 +461,13 @@ export const calculateZoomLevel = (scale, minX, maxX, binsPerTile) => {
366
461
  * Returns the tile position and position within the tile for
367
462
  * the given element.
368
463
  *
369
- * @param {object} tilesetInfo: The information about this tileset
370
- * @param {Number} maxDim: The maximum width of the dataset (only used for
371
- * tilesets without resolutions)
372
- * @param {Number} dataStartPos: The position where the data begins
373
- * @param {int} zoomLevel: The current zoomLevel
374
- * @param {Number} position: The position (in absolute coordinates) to caculate
375
- * the tile and position in tile for
464
+ * @param {TilesetInfo} tilesetInfo - The information about this tileset
465
+ * @param {number} maxDim - The maximum width of the dataset (only used for tilesets without resolutions)
466
+ * @param {number} dataStartPos - The position where the data begins
467
+ * @param {number} zoomLevel - The (integer) current zoomLevel
468
+ * @param {number} position -The position (in absolute coordinates) to caculate the tile and position in tile for
469
+ *
470
+ * @returns {Array<number>}
376
471
  */
377
472
  export function calculateTileAndPosInTile(
378
473
  tilesetInfo,
@@ -382,17 +477,20 @@ export function calculateTileAndPosInTile(
382
477
  position,
383
478
  ) {
384
479
  let tileWidth = null;
385
- const PIXELS_PER_TILE = tilesetInfo.bins_per_dimension || 256;
386
480
 
387
- if (tilesetInfo.resolutions) {
388
- tileWidth = tilesetInfo.resolutions[zoomLevel] * PIXELS_PER_TILE;
481
+ const pixelsPerTile = isLegacyTilesetInfo(tilesetInfo)
482
+ ? (tilesetInfo.bins_per_dimension ?? 256)
483
+ : 256;
484
+
485
+ if (!isLegacyTilesetInfo(tilesetInfo)) {
486
+ tileWidth = tilesetInfo.resolutions[zoomLevel] * pixelsPerTile;
389
487
  } else {
390
488
  tileWidth = maxDim / 2 ** zoomLevel;
391
489
  }
392
490
 
393
491
  const tilePos = Math.floor((position - dataStartPos) / tileWidth);
394
492
  const posInTile = Math.floor(
395
- (PIXELS_PER_TILE * (position - tilePos * tileWidth)) / tileWidth,
493
+ (pixelsPerTile * (position - tilePos * tileWidth)) / tileWidth,
396
494
  );
397
495
 
398
496
  return [tilePos, posInTile];
@@ -408,19 +506,19 @@ export function calculateTileAndPosInTile(
408
506
  * @param {number} zoomLevel - The zoom level at which to find the tiles (can be
409
507
  * calculated using this.calcaulteZoomLevel, but needs to synchronized across
410
508
  * both x and y scales so should be calculated externally)
411
- * @param {import('../type').Scale} scale - A d3 scale mapping data domain to visible values
509
+ * @param {Scale} scale - A d3 scale mapping data domain to visible values
412
510
  * @param {number} minX - The minimum possible value in the dataset
413
- * @param {number} maxX - The maximum possible value in the dataset
511
+ * @param {number} _maxX - The maximum possible value in the dataset
414
512
  * @param {number} maxZoom - The maximum zoom value in this dataset
415
513
  * @param {number} maxDim - The largest dimension of the tileset (e.g., width or height)
416
514
  * (roughlty equal to 2 ** maxZoom * tileSize * tileResolution)
417
- * @returns {number[]} The indices of the tiles that should be visible
515
+ * @returns {Array<number>} The indices of the tiles that should be visible
418
516
  */
419
517
  export const calculateTiles = (
420
518
  zoomLevel,
421
519
  scale,
422
520
  minX,
423
- maxX,
521
+ _maxX,
424
522
  maxZoom,
425
523
  maxDim,
426
524
  ) => {
@@ -431,16 +529,8 @@ export const calculateTiles = (
431
529
  // be calculated according to cumulative width
432
530
 
433
531
  const tileWidth = maxDim / 2 ** zoomLevelFinal;
434
- // console.log('maxDim:', maxDim);
435
-
436
532
  const epsilon = 0.0000001;
437
533
 
438
- /*
439
- console.log('minX:', minX, 'zoomLevel:', zoomLevel);
440
- console.log('domain:', scale.domain(), scale.domain()[0] - minX,
441
- ((scale.domain()[0] - minX) / tileWidth))
442
- */
443
-
444
534
  return range(
445
535
  Math.max(0, Math.floor((scale.domain()[0] - minX) / tileWidth)),
446
536
  Math.min(
@@ -450,8 +540,13 @@ export const calculateTiles = (
450
540
  );
451
541
  };
452
542
 
543
+ /**
544
+ * @param {TilesetInfo} tilesetInfo
545
+ * @param {number} zoomLevel
546
+ * @param {number} binsPerTile
547
+ */
453
548
  export const calculateTileWidth = (tilesetInfo, zoomLevel, binsPerTile) => {
454
- if (tilesetInfo.resolutions) {
549
+ if (!isLegacyTilesetInfo(tilesetInfo)) {
455
550
  const sortedResolutions = tilesetInfo.resolutions
456
551
  .map((x) => +x)
457
552
  .sort((a, b) => b - a);
@@ -465,7 +560,7 @@ export const calculateTileWidth = (tilesetInfo, zoomLevel, binsPerTile) => {
465
560
  * the minX and maxX values for the region
466
561
  *
467
562
  * @param {number} resolution - The number of base pairs per bin
468
- * @param {import('../type').Scale} scale - The scale to use to calculate the currently visible tiles
563
+ * @param {Scale} scale - The scale to use to calculate the currently visible tiles
469
564
  * @param {number} minX - The minimum x position of the tileset
470
565
  * @param {number} maxX - The maximum x position of the tileset
471
566
  * @param {number=} pixelsPerTile - The number of pixels per tile
@@ -485,7 +580,7 @@ export const calculateTilesFromResolution = (
485
580
  // console.log('PIXELS_PER_TILE:', PIXELS_PER_TILE);
486
581
 
487
582
  if (!maxX) {
488
- maxX = Number.MAX_VALUE; // eslint-disable-line no-param-reassign
583
+ maxX = Number.MAX_VALUE;
489
584
  }
490
585
 
491
586
  const lowerBound = Math.max(
@@ -513,21 +608,16 @@ export const calculateTilesFromResolution = (
513
608
  * Render 2D tile data. Convert the raw values to an array of
514
609
  * color values
515
610
  *
516
- * @param finished: A callback to let the caller know that the worker thread
517
- * has converted tileData to pixData
518
- * @param minVisibleValue: The minimum visible value (used for setting the color
519
- * scale)
520
- * @param maxVisibleValue: The maximum visible value
521
- * @param valueScaleType: Either 'log' or 'linear'
522
- * @param valueScaleDomain: The domain of the scale (the range is always [254,0])
523
- * @param colorScale: a 255 x 4 rgba array used as a color scale
524
- * @param synchronous: Render this tile synchronously or pass it on to the threadpool (which doesn't exist yet).
525
- * @param ignoreUpperRight: If this is a tile along the diagonal and there will
526
- * be mirrored tiles present ignore the upper right values
527
- * @param ignoreLowerLeft: If this is a tile along the diagonal and there will be
528
- * mirrored tiles present ignore the lower left values
529
- * @param {array} zeroValueColor: The color to use for rendering zero data values, [r, g, b, a].
530
- * @param {object} selectedRowsOptions Rendering options when using a `selectRows` track option.
611
+ * @param {{ mirrored?: boolean, isMirrored?: boolean, tileData: { dense: Float32Array, tilePos: readonly [a: number, b?: number], shape: readonly [number, number] }}} tile
612
+ * @param {"log" | "linear"} valueScaleType - Either 'log' or 'linear'
613
+ * @param {[min: number, max: number]} valueScaleDomain - The domain of the scale (the range is always [254,0])
614
+ * @param {number} pseudocount
615
+ * @param {ReadonlyArray<readonly [r: number, g: number, b: number, a: number]>} colorScale - a 255 x 4 rgba array used as a color scale
616
+ * @param {(x: null | { pixData: Uint8ClampedArray }) => void} finished
617
+ * @param {boolean | undefined} ignoreUpperRight - If this is a tile along the diagonal and there will be mirrored tiles present ignore the upper right values
618
+ * @param {boolean | undefined} ignoreLowerLeft - If this is a tile along the diagonal and there will be mirrored tiles present ignore the lower left values
619
+ * @param {[r: number, g:number, b: number, a: number]} zeroValueColor - The color to use for rendering zero data values
620
+ * @param {Partial<SelectedRowsOptions>} selectedRowsOptions Rendering options when using a `selectRows` track option.
531
621
  */
532
622
  export const tileDataToPixData = (
533
623
  tile,
@@ -572,19 +662,13 @@ export const tileDataToPixData = (
572
662
  if (ignoreLowerLeft) {
573
663
  for (let row = 0; row < tileWidth; row++) {
574
664
  for (let col = 0; col < row; col++) {
575
- tile.tileData.dense[row * tileWidth + col] = NaN;
665
+ tile.tileData.dense[row * tileWidth + col] = Number.NaN;
576
666
  }
577
667
  }
578
668
  }
579
669
  tile.isMirrored = true;
580
670
  }
581
671
 
582
- // console.log('tile', tile);
583
- // clone the tileData so that the original array doesn't get neutered
584
- // when being passed to the worker script
585
- // const newTileData = tileData.dense;
586
-
587
- // comment this and uncomment the code afterwards to enable threading
588
672
  const pixData = workerSetPix(
589
673
  tileData.dense.length,
590
674
  tileData.dense,
@@ -600,31 +684,35 @@ export const tileDataToPixData = (
600
684
  );
601
685
 
602
686
  finished({ pixData });
687
+ };
603
688
 
604
- // const newTileData = new Float32Array(tileData.dense.length);
605
- // newTileData.set(tileData.dense);
606
- /*
607
- var params = {
608
- size: newTileData.length,
609
- data: newTileData,
610
- valueScaleType: valueScaleType,
611
- valueScaleDomain: valueScaleDomain,
612
- pseudocount: pseudocount,
613
- colorScale: colorScale
614
- };
689
+ /**
690
+ * @template T
691
+ * @overload
692
+ * @param {string | URL} url
693
+ * @param {(err: Error | undefined, value: T | undefined) => void} callback
694
+ * @param {"json"} textOrJson
695
+ * @param {import("pub-sub-es").PubSub} pubSub
696
+ * @returns {Promise<T>}
697
+ */
615
698
 
616
- setPixPool.send(params, [ newTileData.buffer ])
617
- .promise()
618
- .then(returned => {
619
- finished(returned);
620
- })
621
- .catch(reason => {
622
- finished(null);
623
- });
624
- ;
625
- */
626
- };
699
+ /**
700
+ * @overload
701
+ * @param {string | URL} url
702
+ * @param {(err: Error | undefined, value: string | undefined) => void} callback
703
+ * @param {"text"} textOrJson
704
+ * @param {import("pub-sub-es").PubSub} pubSub
705
+ * @returns {Promise<string>}
706
+ */
627
707
 
708
+ /**
709
+ * @template T
710
+ * @param {string | URL} url
711
+ * @param {(err: Error | undefined, value: T | undefined) => void} callback
712
+ * @param {"text" | "json"} textOrJson
713
+ * @param {import("pub-sub-es").PubSub} pubSub
714
+ * @returns {Promise<T>}
715
+ */
628
716
  function fetchEither(url, callback, textOrJson, pubSub) {
629
717
  requestsInFlight += 1;
630
718
  pubSub.publish('requestSent', url);
@@ -637,6 +725,7 @@ function fetchEither(url, callback, textOrJson, pubSub) {
637
725
  } else {
638
726
  throw new Error(`fetch either "text" or "json", not "${textOrJson}"`);
639
727
  }
728
+ /** @type {Record<string, string>} */
640
729
  const headers = {};
641
730
 
642
731
  if (mime) {
@@ -671,8 +760,9 @@ function fetchEither(url, callback, textOrJson, pubSub) {
671
760
  /**
672
761
  * Send a text request and mark it so that we can tell how many are in flight
673
762
  *
674
- * @param url: URL to fetch
675
- * @param callback: Callback to execute with content from fetch
763
+ * @param {string | URL} url
764
+ * @param {(err: Error | undefined, value: string | undefined) => void} callback
765
+ * @param {import("pub-sub-es").PubSub} pubSub
676
766
  */
677
767
  function text(url, callback, pubSub) {
678
768
  return fetchEither(url, callback, 'text', pubSub);
@@ -681,15 +771,16 @@ function text(url, callback, pubSub) {
681
771
  /**
682
772
  * Send a JSON request and mark it so that we can tell how many are in flight
683
773
  *
684
- * @param url: URL to fetch
685
- * @param callback: Callback to execute with content from fetch
774
+ * @template T
775
+ * @param {string} url
776
+ * @param {(err: Error | undefined, value: T | undefined) => void} callback
777
+ * @param {import("pub-sub-es").PubSub} pubSub
686
778
  */
687
779
  async function json(url, callback, pubSub) {
688
780
  // Fritz: What is going on here? Can someone explain?
689
781
  if (url.indexOf('hg19') >= 0) {
690
782
  await sleep(1);
691
783
  }
692
- // console.log('url:', url);
693
784
  return fetchEither(url, callback, 'json', pubSub);
694
785
  }
695
786
 
@@ -698,8 +789,10 @@ async function json(url, callback, pubSub) {
698
789
  *
699
790
  * @param {string} server: The server where the data resides
700
791
  * @param {string} tilesetUid: The identifier for the dataset
701
- * @param {func} doneCb: A callback that gets called when the data is retrieved
702
- * @param {func} errorCb: A callback that gets called when there is an error
792
+ * @param {(info: Record<string, TilesetInfo>) => void} doneCb: A callback that gets called when the data is retrieved
793
+ * @param {(error: string) => void} errorCb: A callback that gets called when there is an error
794
+ * @param {import("pub-sub-es").PubSub} pubSub
795
+ * @returns {void}
703
796
  */
704
797
  export const trackInfo = (server, tilesetUid, doneCb, errorCb, pubSub) => {
705
798
  const url = `${tts(server)}/tileset_info/?d=${tilesetUid}&s=${sessionId}`;
@@ -708,19 +801,14 @@ export const trackInfo = (server, tilesetUid, doneCb, errorCb, pubSub) => {
708
801
  json(
709
802
  url,
710
803
  (error, data) => {
711
- // eslint-disable-line
712
804
  pubSub.publish('requestReceived', url);
713
805
  if (error) {
714
- // console.log('error:', error);
715
- // don't do anything
716
- // no tileset info just means we can't do anything with this file...
717
806
  if (errorCb) {
718
807
  errorCb(`Error retrieving tilesetInfo from: ${server}`);
719
808
  } else {
720
809
  console.warn('Error retrieving: ', url);
721
810
  }
722
811
  } else {
723
- // console.log('got data', data);
724
812
  doneCb(data);
725
813
  }
726
814
  },