@eodash/eodash 5.6.1 → 5.7.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 (133) hide show
  1. package/core/client/components/DashboardLayout.vue +1 -1
  2. package/core/client/components/EodashOverlay.vue +27 -4
  3. package/core/client/composables/DefineEodash.js +0 -1
  4. package/core/client/composables/DefineWidgets.js +3 -1
  5. package/core/client/composables/index.js +6 -2
  6. package/core/client/eodashSTAC/EodashCollection.js +79 -45
  7. package/core/client/eodashSTAC/cql.js +64 -0
  8. package/core/client/eodashSTAC/createLayers.js +192 -125
  9. package/core/client/eodashSTAC/helpers.js +423 -97
  10. package/core/client/eodashSTAC/mosaic.js +416 -0
  11. package/core/client/plugins/axios.js +31 -1
  12. package/core/client/store/actions.js +1 -1
  13. package/core/client/store/stac.js +25 -4
  14. package/core/client/store/states.js +20 -2
  15. package/core/client/types.ts +155 -29
  16. package/core/client/utils/bands-editor/exampleSchema.json +50 -50
  17. package/core/client/utils/index.js +4 -0
  18. package/core/client/utils/states.js +10 -0
  19. package/dist/client/{DashboardLayout-CqMb7OXc.js → DashboardLayout-ePbdvUTE.js} +5 -5
  20. package/dist/client/{DynamicWebComponent-DqKqTf7l.js → DynamicWebComponent-g5SdRbjY.js} +1 -1
  21. package/dist/client/{EodashChart-PkUQfH0k.js → EodashChart-ChrkA5oS.js} +7 -6
  22. package/dist/client/{EodashDatePicker-DLJGdTtX.js → EodashDatePicker-CY1TrsDo.js} +3 -3
  23. package/dist/client/{EodashItemFilter-Dk8KjFcX.js → EodashItemFilter-DzshHDlS.js} +2 -1
  24. package/dist/client/{EodashLayerControl-CC1Cogrn.js → EodashLayerControl-DMCeMkSd.js} +70 -22
  25. package/dist/client/{EodashLayoutSwitcher-5WZYd6rr.js → EodashLayoutSwitcher-iJ8mX1rF.js} +3 -3
  26. package/dist/client/EodashMapBtns-C7Cr7q5Y.js +592 -0
  27. package/dist/client/EodashStacInfo-K7-iuXa7.js +169 -0
  28. package/dist/client/{EodashTools-CGZtTh45.js → EodashTools-BEK7e7JM.js} +4 -4
  29. package/dist/client/{ExportState-Ce0ZnrX_.js → ExportState-CqIzfMYQ.js} +6 -168
  30. package/dist/client/{Footer-CnZqEiop.js → Footer-1P0cmBOZ.js} +1 -1
  31. package/dist/client/{Header-Dr5PBIm8.js → Header-CkkBaBNh.js} +2 -2
  32. package/dist/client/MobileLayout-BPf49kDU.js +118 -0
  33. package/dist/client/{PopUp-DjvE_71e.js → PopUp-DPZq5SWI.js} +4 -168
  34. package/dist/client/{ProcessList-kf3olI7W.js → ProcessList-DuI6WpS1.js} +5 -5
  35. package/dist/client/VDivider-Bj9LG_JV.js +168 -0
  36. package/dist/client/{VImg-Bj6uWt6j.js → VImg-Ddv5OyMz.js} +2 -2
  37. package/dist/client/{VMain-Cm0UmKGi.js → VMain-7yqk_qC3.js} +1 -1
  38. package/dist/client/{VTooltip-DbBu-ky3.js → VTooltip-2hAJyEoa.js} +2 -2
  39. package/dist/client/{WidgetsContainer-CfLnd44U.js → WidgetsContainer-2DOrlwCB.js} +1 -1
  40. package/dist/client/{asWebComponent-DfULAbpq.js → asWebComponent-BlFN2lkB.js} +493 -335
  41. package/dist/client/{async-CIbKXEEe.js → async-B2H8ZwUK.js} +21 -10
  42. package/dist/client/create-layers-config-u_vn5hPk.js +182 -0
  43. package/dist/client/dialog-transition-BrMkMiNa.js +170 -0
  44. package/dist/client/eo-dash.js +1 -1
  45. package/dist/client/{forwardRefs-yj3VWOK5.js → forwardRefs-DAlror6d.js} +8 -87
  46. package/dist/client/{handling-s8xt2JwF.js → handling-B6I1V5eG.js} +123 -85
  47. package/dist/client/{helpers-DAVGYl_f.js → helpers-CBqIUSXJ.js} +3034 -1649
  48. package/dist/client/index-BWq1ZKaV.js +3168 -0
  49. package/dist/client/{index-CYz6dT7K.js → index-CJ9UI3ln.js} +2 -2
  50. package/dist/client/index-DXLz4sFR.js +442 -0
  51. package/dist/client/{index-4TrjfJ32.js → index-DhKU6KqM.js} +230 -231
  52. package/dist/client/{index-Bu8BMA9q.js → index-DjQlbWbJ.js} +70 -83
  53. package/dist/client/material-symbols-outlined.woff2 +0 -0
  54. package/dist/client/material-symbols-rounded.woff2 +0 -0
  55. package/dist/client/material-symbols-sharp.woff2 +0 -0
  56. package/dist/client/material-symbols-subset.woff2 +0 -0
  57. package/dist/client/mosaic-BFYLEwyx.js +455 -0
  58. package/dist/client/templates.js +250 -119
  59. package/dist/client/{transition-DaOsiD4u.js → transition-D_W6Wn9v.js} +1 -1
  60. package/dist/node/cli.js +5 -5
  61. package/dist/node/types.d.ts +4 -0
  62. package/dist/types/core/client/composables/DefineEodash.d.ts +1 -1
  63. package/dist/types/core/client/composables/index.d.ts +1 -3
  64. package/dist/types/core/client/eodashSTAC/EodashCollection.d.ts +62 -31
  65. package/dist/types/core/client/eodashSTAC/cql.d.ts +15 -0
  66. package/dist/types/core/client/eodashSTAC/createLayers.d.ts +44 -25
  67. package/dist/types/core/client/eodashSTAC/helpers.d.ts +78 -18
  68. package/dist/types/core/client/eodashSTAC/mosaic.d.ts +56 -0
  69. package/dist/types/core/client/store/stac.d.ts +31 -7
  70. package/dist/types/core/client/store/states.d.ts +24 -2
  71. package/dist/types/core/client/types.d.ts +131 -25
  72. package/dist/types/core/client/utils/states.d.ts +12 -0
  73. package/dist/types/templates/baseConfig.d.ts +1 -88
  74. package/dist/types/templates/compare.d.ts +32 -3
  75. package/dist/types/templates/expert.d.ts +18 -0
  76. package/dist/types/templates/explore.d.ts +9 -1
  77. package/dist/types/templates/lite.d.ts +11 -0
  78. package/dist/types/widgets/EodashChart.vue.d.ts +5 -3
  79. package/dist/types/widgets/EodashItemCatalog/index.vue.d.ts +129 -2
  80. package/dist/types/widgets/EodashItemCatalog/methods/filters.d.ts +28 -6
  81. package/dist/types/widgets/EodashItemCatalog/methods/handlers.d.ts +17 -4
  82. package/dist/types/widgets/EodashItemCatalog/methods/map.d.ts +16 -4
  83. package/dist/types/widgets/EodashLayerControl.vue.d.ts +36 -17
  84. package/dist/types/widgets/EodashMap/EodashMapBtns.vue.d.ts +23 -10
  85. package/dist/types/widgets/EodashMap/index.vue.d.ts +91 -35
  86. package/dist/types/widgets/EodashMap/methods/create-layers-config.d.ts +1 -8
  87. package/dist/types/widgets/EodashMap/methods/index.d.ts +5 -1
  88. package/dist/types/widgets/EodashMap/methods/use-map-loading.d.ts +1 -0
  89. package/dist/types/widgets/EodashProcess/index.vue.d.ts +5 -3
  90. package/dist/types/widgets/EodashProcess/methods/composables.d.ts +1 -1
  91. package/dist/types/widgets/EodashProcess/methods/handling.d.ts +13 -8
  92. package/dist/types/widgets/EodashProcess/states.d.ts +1 -1
  93. package/dist/types/widgets/EodashStacInfo.vue.d.ts +20 -0
  94. package/dist/types/widgets/EodashTimeSlider/index.vue.d.ts +54 -0
  95. package/dist/types/widgets/EodashTimeSlider/methods/index.d.ts +34 -0
  96. package/dist/types/widgets/EodashTimeSlider/types.d.ts +52 -0
  97. package/dist/types/widgets/EodashTools.vue.d.ts +5 -5
  98. package/package.json +46 -46
  99. package/templates/baseConfig.js +36 -6
  100. package/templates/compare.js +62 -7
  101. package/templates/expert.js +23 -0
  102. package/templates/explore.js +11 -5
  103. package/templates/lite.js +14 -0
  104. package/widgets/EodashChart.vue +1 -0
  105. package/widgets/EodashItemCatalog/index.vue +363 -35
  106. package/widgets/EodashItemCatalog/methods/filters.js +134 -70
  107. package/widgets/EodashItemCatalog/methods/handlers.js +113 -19
  108. package/widgets/EodashItemCatalog/methods/map.js +142 -28
  109. package/widgets/EodashLayerControl.vue +64 -14
  110. package/widgets/EodashMap/EodashMapBtns.vue +153 -47
  111. package/widgets/EodashMap/index.vue +101 -105
  112. package/widgets/EodashMap/methods/btns.js +37 -5
  113. package/widgets/EodashMap/methods/create-layers-config.js +18 -16
  114. package/widgets/EodashMap/methods/index.js +89 -8
  115. package/widgets/EodashMap/methods/use-map-loading.js +52 -0
  116. package/widgets/EodashProcess/index.vue +2 -1
  117. package/widgets/EodashProcess/methods/async.js +19 -8
  118. package/widgets/EodashProcess/methods/composables.js +63 -77
  119. package/widgets/EodashProcess/methods/custom-endpoints/chart/sentinelhub-endpoint.js +9 -5
  120. package/widgets/EodashProcess/methods/custom-endpoints/chart/veda-endpoint.js +7 -4
  121. package/widgets/EodashProcess/methods/handling.js +105 -72
  122. package/widgets/EodashProcess/states.js +1 -1
  123. package/widgets/EodashStacInfo.vue +64 -2
  124. package/widgets/EodashTimeSlider/index.vue +208 -0
  125. package/widgets/EodashTimeSlider/methods/index.js +236 -0
  126. package/widgets/EodashTimeSlider/types.ts +54 -0
  127. package/dist/client/EodashMapBtns-BHrSDNoC.js +0 -435
  128. package/dist/client/EodashStacInfo-B90wobPc.js +0 -107
  129. package/dist/client/EodashTimeSlider-5vm7F3ju.js +0 -53
  130. package/dist/client/MobileLayout-DyMjaeif.js +0 -118
  131. package/dist/client/index-CugS__t5.js +0 -570
  132. package/dist/types/widgets/EodashTimeSlider.vue.d.ts +0 -15
  133. package/widgets/EodashTimeSlider.vue +0 -40
@@ -2,6 +2,7 @@ import { toAbsolute } from "stac-js/src/http.js";
2
2
  import axios from "@/plugins/axios";
3
3
  import log from "loglevel";
4
4
  import { getStyleVariablesState } from "./triggers.js";
5
+ import { itemsCache, splitItemsCache } from "@/utils/states.js";
5
6
 
6
7
  /**
7
8
  * @param {import("stac-ts").StacLink[]} [links]
@@ -171,7 +172,7 @@ export const extractRoles = (properties, linkOrAsset) => {
171
172
 
172
173
  /**
173
174
  * Extracts a single non-link style JSON from a STAC Item optionally for a selected key mapping
174
- * @param { import("stac-ts").StacItem | import("stac-ts").StacCollection } stacObject
175
+ * @param { import("stac-ts").StacItem | import("stac-ts").StacCollection | null | undefined} stacObject
175
176
  * @param {string | undefined} linkKey
176
177
  * @param {string | undefined} assetKey
177
178
  * @returns
@@ -181,6 +182,7 @@ export const fetchStyle = async (
181
182
  linkKey = undefined,
182
183
  assetKey = undefined,
183
184
  ) => {
185
+ if (!stacObject) return undefined;
184
186
  let styleLink = null;
185
187
  if (linkKey) {
186
188
  styleLink = stacObject.links.find(
@@ -212,6 +214,20 @@ export const fetchStyle = async (
212
214
  }
213
215
  };
214
216
 
217
+ /**
218
+ * Resolves a style by preferring the item's own `style` link and falling back
219
+ * to the collection's. Takes the same key arguments as `fetchStyle`.
220
+ *
221
+ * @param {import("stac-ts").StacItem | import("stac-ts").StacCollection} item
222
+ * @param {import("stac-ts").StacCollection | null | undefined} collection
223
+ * @param {string} [linkKey]
224
+ * @param {string} [assetKey]
225
+ * @returns {Promise<import("@/types").EodashStyleJson | undefined>}
226
+ */
227
+ export const resolveStyle = async (item, collection, linkKey, assetKey) =>
228
+ (await fetchStyle(item, linkKey, assetKey)) ??
229
+ (await fetchStyle(collection, linkKey, assetKey));
230
+
215
231
  /**
216
232
  * Fetches all style JSONs from a STAC Item and returns an array with style objects
217
233
  * @param {import("stac-ts").StacItem | import("stac-ts").StacCollection} stacObject
@@ -251,94 +267,74 @@ export const getProjectionCode = (projection) => {
251
267
  };
252
268
 
253
269
  /**
254
- * Extracts layercontrol LayerDatetime property from STAC Links
255
- * @param {import("stac-ts").StacLink[] | import("stac-ts").StacItem[] | undefined} [items]
256
- * @param {string|null} [currentStep]
257
- **/
258
- export const extractLayerTimeValues = (items, currentStep) => {
259
- if (!currentStep || !items?.length) {
270
+ * Builds layercontrol LayerDatetime + timeControlValues from a list of dates.
271
+ *
272
+ * @param {Date[] | undefined} dates
273
+ * @param {string | null} [currentStep] - target datetime; snapped to the closest available date
274
+ * @returns {{ layerDatetime: Record<string, any> | undefined, timeControlValues: { date: string }[] | undefined }}
275
+ */
276
+ export const extractLayerTimeValues = (dates, currentStep) => {
277
+ if (!currentStep || !dates?.length || dates.length <= 1) {
260
278
  return { layerDatetime: undefined, timeControlValues: undefined };
261
279
  }
262
280
 
263
- // check if items has a datetime value
264
- const dateProperty = getDatetimeProperty(items);
265
-
266
- if (!dateProperty) {
267
- return { layerDatetime: undefined, timeControlValues: undefined };
268
- }
269
- /** @type {{date:string;itemId:string}[]} */
270
- const timeValues = [];
271
- try {
272
- /**
273
- * @param {typeof timeValues} vals
274
- * @param {import("stac-ts").StacLink} link
275
- */
276
- const reduceLinks = (vals, link) => {
277
- if (link[dateProperty] && link.rel === "item") {
278
- vals.push({
279
- itemId: /** @type {string} */ (link.id),
280
- date: new Date(
281
- /** @type {string} */ (link[dateProperty]),
282
- ).toISOString(),
283
- });
284
- }
285
- return vals;
286
- };
287
-
288
- /**
289
- *
290
- * @param {typeof timeValues} vals
291
- * @param {import("stac-ts").StacItem} item
292
- */
293
- const reduceItems = (vals, item) => {
294
- const date = item.properties?.[dateProperty];
295
- if (date) {
296
- vals.push({
297
- itemId: /** @type {string} */ (item.id),
298
- date: new Date(/** @type {string} */ (date)).toISOString(),
299
- ...item.properties,
300
- });
301
- }
302
- return vals;
303
- };
304
- currentStep = new Date(currentStep).toISOString();
305
- //@ts-expect-error TODO
306
- items.reduce(isSTACItem(items[0]) ? reduceItems : reduceLinks, timeValues);
307
- } catch (e) {
308
- console.warn("[eodash] not supported datetime format was provided", e);
309
- return { layerDatetime: undefined, timeControlValues: undefined };
310
- }
311
- // not enough timeValues
312
- if (timeValues.length <= 1) {
313
- return { layerDatetime: undefined, timeControlValues: undefined };
314
- }
281
+ const controlValues = dates.map((d) => d.toISOString()).sort();
282
+ const timeControlValues = controlValues.map((date) => ({ date }));
315
283
 
316
- // item datetime is not included in the item links datetime
317
- if (!timeValues.some((val) => val.date === currentStep)) {
318
- const currentStepTime = new Date(currentStep).getTime();
319
- currentStep = timeValues.reduce((time, step) => {
320
- const aDiff = Math.abs(new Date(time).getTime() - currentStepTime);
321
- const bDiff = Math.abs(new Date(step.date).getTime() - currentStepTime);
322
- return bDiff < aDiff ? step.date : time;
323
- }, timeValues[0].date);
284
+ currentStep = new Date(currentStep).toISOString();
285
+ if (!controlValues.includes(currentStep)) {
286
+ const target = new Date(currentStep).getTime();
287
+ currentStep = controlValues.reduce((best, d) =>
288
+ Math.abs(new Date(d).getTime() - target) <
289
+ Math.abs(new Date(best).getTime() - target)
290
+ ? d
291
+ : best,
292
+ );
324
293
  }
325
294
 
326
295
  const layerDatetime = {
327
- controlValues: timeValues.map((d) => d.date).sort(),
296
+ controlValues,
328
297
  currentStep,
329
298
  slider: true,
330
299
  navigation: true,
331
300
  play: false,
332
301
  displayFormat: "DD.MM.YYYY HH:mm",
333
302
  animateOnClickInterval: false,
303
+ showUTC: true,
334
304
  };
335
305
 
336
- return {
337
- layerDatetime,
338
- timeControlValues: timeValues,
339
- };
306
+ return { layerDatetime, timeControlValues };
340
307
  };
341
308
 
309
+ /**
310
+ * Fetches the daily pre-aggregation data for a STAC collection if available.
311
+ * Returns the raw AggregationCollection,
312
+ * or null if no daily pre-aggregation link exists, or the fetch fails.
313
+ *
314
+ * @param {import("stac-ts").StacCollection | undefined} stacCollection
315
+ * @param {string} fallbackBaseUrl - base for relative href resolution when no `self` link is present
316
+ * @returns {Promise<any | null>}
317
+ */
318
+ export async function fetchPreAggregations(stacCollection, fallbackBaseUrl) {
319
+ if (!stacCollection) return null;
320
+ const preAggregationLink = stacCollection.links?.find(
321
+ (l) => l.rel === "pre-aggregation" && l["aggregation:interval"] === "daily",
322
+ );
323
+ if (!preAggregationLink) return null;
324
+
325
+ try {
326
+ const selfLink = stacCollection.links?.find((l) => l.rel === "self")?.href;
327
+ const url = toAbsolute(
328
+ preAggregationLink.href,
329
+ selfLink || fallbackBaseUrl,
330
+ );
331
+ return await axios.get(url).then((resp) => resp.data);
332
+ } catch (e) {
333
+ console.warn("[eodash] Failed to fetch pre-aggregation", e);
334
+ return null;
335
+ }
336
+ }
337
+
342
338
  /**
343
339
  * Recursively find all layers whose ID up to the first ; is same as given layer
344
340
  *
@@ -347,6 +343,9 @@ export const extractLayerTimeValues = (items, currentStep) => {
347
343
  * @returns {import("@eox/map").EoxLayer[]} Matching layer objects.
348
344
  */
349
345
  export const findLayersByLayerPrefix = (layers, referenceLayer) => {
346
+ if (!layers || !referenceLayer) {
347
+ return [];
348
+ }
350
349
  const refId = referenceLayer?.properties?.id;
351
350
 
352
351
  if (typeof refId !== "string" || !refId.includes(";:;")) {
@@ -391,6 +390,39 @@ export const findLayer = (layers, layer) => {
391
390
  }
392
391
  };
393
392
 
393
+ /**
394
+ * Removes JSON layers by ID from the layer tree
395
+ * @param {import("@eox/map").EoxLayer[]} layers
396
+ * @param {string[]} layerIds
397
+ * @returns {import("@eox/map").EoxLayer[]}
398
+ **/
399
+ export const removeLayers = (layers, layerIds) => {
400
+ const result = [];
401
+ for (const layer of layers) {
402
+ // if the layer is hidden, do not include it without checking if it's a group
403
+ if (layer.properties?.id && layerIds.includes(layer.properties.id)) {
404
+ continue;
405
+ }
406
+ if (layer.type === "Group" && Array.isArray(layer.layers)) {
407
+ const newGroupLayers = removeLayers(layer.layers, layerIds);
408
+ // if the group is not hidden, add it with the updated layers (if any were removed)
409
+ result.push(
410
+ newGroupLayers !== layer.layers
411
+ ? { ...layer, layers: newGroupLayers }
412
+ : layer,
413
+ );
414
+ continue;
415
+ }
416
+
417
+ result.push(layer);
418
+ }
419
+
420
+ return result.length === layers.length &&
421
+ result.every((l, i) => l === layers[i])
422
+ ? layers
423
+ : result;
424
+ };
425
+
394
426
  /**
395
427
  * Removes one or more layers (by id) from a layer/group structure and inserts
396
428
  * new layers in place of the first removed one.
@@ -701,7 +733,12 @@ export function getDatetimeProperty(linksOrItems) {
701
733
  }
702
734
 
703
735
  // TODO: consider other properties for datetime ranges
704
- const datetimeProperties = ["datetime", "start_datetime", "end_datetime"];
736
+
737
+ const datetimeProperties = /** @type {const} */ ([
738
+ "datetime",
739
+ "start_datetime",
740
+ "end_datetime",
741
+ ]);
705
742
  if (checkProperties) {
706
743
  for (const prop of datetimeProperties) {
707
744
  const propExists = linksOrItems.some(
@@ -743,6 +780,65 @@ export function isSTACItem(stacObject) {
743
780
  );
744
781
  }
745
782
 
783
+ /**
784
+ * Fetches items using a split strategy (past/future) around a center date
785
+ * @param {string} itemsUrl
786
+ * @param {string} query
787
+ * @param {string | Date} centerDatetime
788
+ * @param {number} maxNumber
789
+ * @returns {Promise<import("stac-ts").StacItem[]>}
790
+ */
791
+ export const fetchSplitItems = async (
792
+ itemsUrl,
793
+ query,
794
+ centerDatetime,
795
+ maxNumber,
796
+ ) => {
797
+ const center = new Date(centerDatetime).toISOString();
798
+ const limit = Math.ceil(maxNumber / 2);
799
+
800
+ /** @param {"past"|"future"} direction */
801
+ const fetchSide = async (direction) => {
802
+ const isPast = direction === "past";
803
+ const datetimeRange = isPast ? `../${center}` : `${center}/..`;
804
+ const sortOrder = isPast ? "-datetime" : "+datetime";
805
+ const queryParams = new URLSearchParams(query);
806
+
807
+ queryParams.set("limit", limit.toString());
808
+ queryParams.set("datetime", datetimeRange);
809
+ queryParams.set("sortby", sortOrder);
810
+
811
+ const splitUrl = itemsUrl.split("/");
812
+ const collectionId = /** @type {string} */ (splitUrl.at(-2));
813
+ queryParams.set("collection", collectionId);
814
+
815
+ const searchEndpoint = `${splitUrl.slice(0, -3).join("/")}/search`;
816
+ const items = await axios
817
+ .get(searchEndpoint, { params: queryParams })
818
+ .then((res) => res.data.features);
819
+ return items;
820
+ };
821
+
822
+ const [pastItems, futureItems] = await Promise.all([
823
+ fetchSide("past"),
824
+ fetchSide("future"),
825
+ ]);
826
+
827
+ const allItems = [...pastItems, ...futureItems];
828
+
829
+ // check for duplicates by ids
830
+ const seen = new Set();
831
+ const uniqueItems = [];
832
+ for (const item of allItems) {
833
+ if (!seen.has(item.id)) {
834
+ seen.add(item.id);
835
+ uniqueItems.push(item);
836
+ }
837
+ }
838
+
839
+ return uniqueItems;
840
+ };
841
+
746
842
  /**
747
843
  * Fetch all STAC items from a STAC API endpoint.
748
844
  * @param {string} itemsUrl
@@ -750,6 +846,8 @@ export function isSTACItem(stacObject) {
750
846
  * @param {number} [limit=100] - The maximum number of items to fetch per request.
751
847
  * @param {boolean} [returnFirst] - If true, only the first page of results will be returned.
752
848
  * @param {number} [maxNumber=1000] - if the matched number of items exceed this, only the first page will be returned.
849
+ * @param {string | Date} [centerDatetime] - Date to center the search around if items exceed maxNumber
850
+ * @returns {Promise<import("stac-ts").StacItem[]>}
753
851
  */
754
852
  export async function fetchApiItems(
755
853
  itemsUrl,
@@ -757,23 +855,42 @@ export async function fetchApiItems(
757
855
  limit = 100,
758
856
  returnFirst = false,
759
857
  maxNumber = 1000,
858
+ centerDatetime,
760
859
  ) {
761
- itemsUrl = itemsUrl.includes("?") ? itemsUrl.split("?")[0] : itemsUrl;
762
- itemsUrl += query ? `?limit=${limit}&${query}` : `?limit=${limit}`;
860
+ // Exclude centerDatetime from cache key - it's only used for split search fallback
861
+ const cacheKey = JSON.stringify({
862
+ itemsUrl,
863
+ query,
864
+ limit,
865
+ returnFirst,
866
+ maxNumber,
867
+ });
868
+
869
+ if (itemsCache.has(cacheKey)) {
870
+ return itemsCache.get(cacheKey) ?? [];
871
+ }
872
+
873
+ const urlQuery = new URLSearchParams(query);
874
+ urlQuery.set("limit", limit.toString());
875
+
876
+ let finalItemsUrl = itemsUrl.includes("?")
877
+ ? itemsUrl.split("?")[0]
878
+ : itemsUrl;
879
+ finalItemsUrl += urlQuery.keys().toArray().length
880
+ ? `?${urlQuery.toString()}`
881
+ : "";
763
882
 
764
883
  const itemsFeatureCollection = await axios
765
- .get(itemsUrl)
884
+ .get(finalItemsUrl)
766
885
  .then((resp) => resp.data);
886
+
767
887
  /** @type {import("stac-ts").StacItem[]} */
768
888
  const items = itemsFeatureCollection.features;
769
- const nextLink = itemsFeatureCollection.links?.find(
770
- //@ts-expect-error TODO: itemsFeatureCollection is not typed
771
- (link) => link.rel === "next",
772
- );
773
-
774
- if (!nextLink || returnFirst) {
889
+ if (returnFirst) {
890
+ itemsCache.set(cacheKey, items);
775
891
  return items;
776
892
  }
893
+
777
894
  /** @type {number} */
778
895
  const matchedItems = itemsFeatureCollection.numberMatched;
779
896
  // Avoid fetching too many items
@@ -781,26 +898,70 @@ export async function fetchApiItems(
781
898
  console.warn(
782
899
  `[eodash] The number of items matched (${matchedItems}) exceeds the maximum allowed (${maxNumber})`,
783
900
  );
784
- return items;
785
- }
901
+ // we try to narrow down the search around the center datetime
902
+ if (centerDatetime) {
903
+ // Check if we have a cached split result that covers this centerDatetime
904
+ const splitCacheKey = JSON.stringify({ itemsUrl, query, maxNumber });
905
+ const cachedSplit = splitItemsCache.get(splitCacheKey);
906
+ const centerTime = new Date(centerDatetime).getTime();
786
907
 
787
- let [nextLinkURL, nextLinkQuery] = nextLink.href.split("?");
788
- nextLinkQuery = nextLinkQuery.replace(/limit=\d+/, "");
789
- if (query) {
790
- const queryParams = new URLSearchParams(query);
791
- const nextLinkParams = new URLSearchParams(nextLinkQuery);
908
+ if (
909
+ cachedSplit &&
910
+ centerTime >= cachedSplit.minTime &&
911
+ centerTime <= cachedSplit.maxTime
912
+ ) {
913
+ return cachedSplit.items;
914
+ }
792
915
 
793
- for (const key of nextLinkParams.keys()) {
794
- queryParams.delete(key);
795
- }
796
- const remainingQuery = queryParams.toString();
797
- if (remainingQuery) {
798
- nextLinkQuery += `&${remainingQuery}`;
916
+ const narrowedItems = await fetchSplitItems(
917
+ itemsUrl,
918
+ query ?? "",
919
+ centerDatetime,
920
+ maxNumber,
921
+ );
922
+
923
+ if (!narrowedItems.length) {
924
+ return narrowedItems;
925
+ }
926
+ const datetimeProperty = getDatetimeProperty(narrowedItems);
927
+ if (!datetimeProperty) {
928
+ return narrowedItems;
929
+ }
930
+
931
+ const times = narrowedItems
932
+ .map((i) =>
933
+ i.properties[datetimeProperty]
934
+ ? new Date(i.properties[datetimeProperty]).getTime()
935
+ : null,
936
+ )
937
+ .filter((t) => t !== null);
938
+ if (!times.length) {
939
+ return narrowedItems;
940
+ }
941
+ splitItemsCache.set(splitCacheKey, {
942
+ items: narrowedItems,
943
+ minTime: Math.min(...times),
944
+ maxTime: Math.max(...times),
945
+ });
946
+
947
+ return narrowedItems;
799
948
  }
949
+
950
+ itemsCache.set(cacheKey, items);
951
+ return items;
800
952
  }
801
953
 
802
- const nextPage = await fetchApiItems(nextLinkURL, nextLinkQuery);
803
- items.push(...nextPage);
954
+ urlQuery.set("limit", maxNumber.toString());
955
+
956
+ const allItems = await axios
957
+ .get(itemsUrl + "?" + urlQuery.toString())
958
+ .then((resp) => resp.data.features)
959
+ .catch((err) => {
960
+ console.error(err);
961
+ return [];
962
+ });
963
+ items.splice(0, items.length, ...allItems);
964
+ itemsCache.set(cacheKey, items);
804
965
  return items;
805
966
  }
806
967
  /**
@@ -838,3 +999,168 @@ export function extractEoxLegendLink(link) {
838
999
  }
839
1000
  return extraProperties;
840
1001
  }
1002
+
1003
+ /**
1004
+ * Locate the first sub-schema whose `format` matches by walking `properties`
1005
+ * and the `oneOf` / `allOf` / `anyOf` combinators. Returns the schema path
1006
+ * (array of keys/indices) from the root schema to the matched node, or
1007
+ * undefined if not found. Empty array means the input schema itself matched.
1008
+ *
1009
+ * @param {Record<string, any> | null | undefined} schema
1010
+ * @param {string} [format="bands"]
1011
+ * @returns {(string | number)[] | undefined}
1012
+ */
1013
+ export function getBandsProperty(schema, format = "bands") {
1014
+ if (!schema || typeof schema !== "object") return undefined;
1015
+ if (schema.format === format) return [];
1016
+
1017
+ if (schema.properties) {
1018
+ for (const key of Object.keys(schema.properties)) {
1019
+ const sub = getBandsProperty(schema.properties[key], format);
1020
+ if (sub) return ["properties", key, ...sub];
1021
+ }
1022
+ }
1023
+
1024
+ for (const combinator of ["oneOf", "allOf", "anyOf"]) {
1025
+ if (!Array.isArray(schema[combinator])) continue;
1026
+ for (let i = 0; i < schema[combinator].length; i++) {
1027
+ const sub = getBandsProperty(schema[combinator][i], format);
1028
+ if (sub) return [combinator, i, ...sub];
1029
+ }
1030
+ }
1031
+
1032
+ return undefined;
1033
+ }
1034
+
1035
+ /**
1036
+ * Checks whether a GeoZarr layer's bands changed in the jsonform output and,
1037
+ * if so, rebuilds the source with the new 3 selected bands.
1038
+ * Gamma and rescale are handled automatically by `applyUpdatedStyles` via
1039
+ * `updateStyleVariables` — this function only manages source reconstruction.
1040
+ * Uses the existing source constructor to avoid import-version mismatches.
1041
+ *
1042
+ * @param {import("ol/layer/Layer").default} olLayer - Layer from layerConfig:change event
1043
+ * @param {Record<string, any>} jsonformValue - Current jsonform output
1044
+ * @returns {boolean} true if the source was rebuilt
1045
+ */
1046
+ export function updateGeoZarrBands(olLayer, jsonformValue) {
1047
+ /** @type {import("@eox/map/src/layers").EOxLayerType<"WebGLTile","GeoZarr">} */
1048
+ const jsonLayer = olLayer.get("_jsonDefinition");
1049
+ const updatedBands = jsonformValue.bands;
1050
+ const isGeoZarr =
1051
+ jsonLayer?.type === "WebGLTile" && jsonLayer?.source?.type === "GeoZarr";
1052
+ if (!jsonLayer || !jsonLayer.source || !isGeoZarr || !updatedBands) {
1053
+ return false;
1054
+ }
1055
+
1056
+ const oldBands = jsonLayer.source?.bands;
1057
+ if (JSON.stringify(updatedBands) === JSON.stringify(oldBands)) {
1058
+ return false;
1059
+ }
1060
+ jsonLayer.source.bands = [...updatedBands];
1061
+ olLayer.setSource(
1062
+ new window.eoxMapAdvancedOlSources.GeoZarr(jsonLayer.source),
1063
+ );
1064
+ return true;
1065
+ }
1066
+
1067
+ /**
1068
+ * Applies titiler upscaling to an XYZ tile URL based on the matched endpoint config.
1069
+ * - titiler v1: appends `@2x` to the `{y}` tile coordinate
1070
+ * - titiler v2: adds `tilesize=512` query parameter (v2 removed the `@2x` suffix)
1071
+ * Plain strings in the config default to v1 behavior for backward compatibility.
1072
+ *
1073
+ * @param {string} url - The XYZ tile URL template
1074
+ * @param {Array<string | { url: string; titilerVersion?: 1 | 2 }>} upscalingEndpoints
1075
+ * @returns {{ url: string; tileSize: [number, number] } | null} null if no endpoint matches
1076
+ */
1077
+ export function applyTitilerUpscaling(url, upscalingEndpoints) {
1078
+ const match = upscalingEndpoints.find((entry) => {
1079
+ const endpointUrl = typeof entry === "string" ? entry : entry.url;
1080
+ return url.includes(endpointUrl);
1081
+ });
1082
+
1083
+ if (!match) {
1084
+ return null;
1085
+ }
1086
+
1087
+ const version = typeof match === "string" ? 1 : (match.titilerVersion ?? 1);
1088
+
1089
+ if (version === 2) {
1090
+ const [base, query] = url.split("?");
1091
+ const params = new URLSearchParams(query);
1092
+ params.set("tilesize", "512");
1093
+ return { url: `${base}?${params.toString()}`, tileSize: [512, 512] };
1094
+ }
1095
+
1096
+ return { url: url.replace("{y}", "{y}@2x"), tileSize: [512, 512] };
1097
+ }
1098
+
1099
+ /**
1100
+ * Picks the render presets for a collection, preferring the collection's own
1101
+ * STAC `renders` extension and falling back to client-provided config
1102
+ *
1103
+ * @param {import("stac-ts").StacCollection | null | undefined} collection
1104
+ * @param {Record<string, Record<string, import("@/types").Render>> | undefined} [configRenders]
1105
+ * @returns {Record<string, import("@/types").Render> | undefined}
1106
+ */
1107
+ export function resolveRenders(collection, configRenders) {
1108
+ if (collection?.renders) {
1109
+ return /** @type {Record<string, import("@/types").Render>} */ (
1110
+ collection.renders
1111
+ );
1112
+ }
1113
+ return collection?.id ? configRenders?.[collection.id] : undefined;
1114
+ }
1115
+
1116
+ /**
1117
+ * Serializes an object into a TiTiler query string. Arrays join with commas;
1118
+ * nested arrays repeat the key (e.g. `rescale: [[0,1]]` -> `rescale=0,1`);
1119
+ * objects are JSON-encoded. Shared by the render-extension and mosaic paths.
1120
+ * @param {Record<string,any>} obj
1121
+ * @returns {string}
1122
+ */
1123
+ export function encodeURLObject(obj) {
1124
+ let str = "";
1125
+ for (const key in obj) {
1126
+ const value = obj[key];
1127
+ if (value === null || value === undefined || value === "") {
1128
+ continue;
1129
+ }
1130
+
1131
+ const valueType = Array.isArray(value) ? "array" : typeof value;
1132
+
1133
+ switch (valueType) {
1134
+ case "array": {
1135
+ // Check if any element in the array is itself an array (multi-dimensional)
1136
+ const hasNestedArrays = value.some((/** @type {any} */ item) =>
1137
+ Array.isArray(item),
1138
+ );
1139
+
1140
+ if (hasNestedArrays) {
1141
+ // For multi-dimensional arrays, repeat the key with different values
1142
+ for (const val of value) {
1143
+ if (Array.isArray(val)) {
1144
+ str += `${key}=${val.join(",")}&`;
1145
+ } else {
1146
+ str += `${key}=${val}&`;
1147
+ }
1148
+ }
1149
+ } else {
1150
+ // For simple arrays, join with commas
1151
+ str += `${key}=${value.join(",")}&`;
1152
+ }
1153
+ break;
1154
+ }
1155
+ case "object": {
1156
+ str += `${key}=${encodeURI(JSON.stringify(value))}&`;
1157
+ break;
1158
+ }
1159
+ default: {
1160
+ str += `${key}=${encodeURIComponent(value)}&`;
1161
+ break;
1162
+ }
1163
+ }
1164
+ }
1165
+ return str;
1166
+ }