@eodash/eodash 5.7.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/core/client/components/EodashOverlay.vue +27 -4
  2. package/core/client/composables/DefineEodash.js +0 -1
  3. package/core/client/composables/DefineWidgets.js +3 -1
  4. package/core/client/eodashSTAC/EodashCollection.js +87 -24
  5. package/core/client/eodashSTAC/cql.js +64 -0
  6. package/core/client/eodashSTAC/createLayers.js +353 -187
  7. package/core/client/eodashSTAC/helpers.js +694 -110
  8. package/core/client/eodashSTAC/mosaic.js +422 -0
  9. package/core/client/plugins/axios.js +31 -1
  10. package/core/client/store/actions.js +1 -1
  11. package/core/client/store/stac.js +54 -4
  12. package/core/client/store/states.js +20 -2
  13. package/core/client/types.ts +152 -28
  14. package/core/client/utils/bands-editor/exampleSchema.json +50 -50
  15. package/core/client/utils/states.js +10 -0
  16. package/dist/client/{DashboardLayout-kRsmsNW5.js → DashboardLayout-DEs4f_Vy.js} +2 -2
  17. package/dist/client/{DynamicWebComponent-CnKhYo0K.js → DynamicWebComponent-PvnV88BA.js} +1 -1
  18. package/dist/client/EodashChart-D-3AGKZY.js +260 -0
  19. package/dist/client/{EodashDatePicker-CMPDT-ce.js → EodashDatePicker-Dm_goZzs.js} +3 -3
  20. package/dist/client/{EodashItemFilter-BpdA8QQa.js → EodashItemFilter-CJy3zYzD.js} +2 -1
  21. package/dist/client/{EodashLayerControl-BHpDA4_0.js → EodashLayerControl-rL8UnpkA.js} +55 -33
  22. package/dist/client/{EodashLayoutSwitcher-B1fRUt7G.js → EodashLayoutSwitcher-ZRlESI9O.js} +3 -3
  23. package/dist/client/{EodashMapBtns-Cpned0tc.js → EodashMapBtns-CG7u2Aq8.js} +227 -112
  24. package/dist/client/EodashStacInfo-CJhhxthD.js +169 -0
  25. package/dist/client/{EodashTools-1zHz26gv.js → EodashTools-CWyK8QJT.js} +4 -4
  26. package/dist/client/{ExportState-DbOm8hBI.js → ExportState-BXhy5TOU.js} +20 -174
  27. package/dist/client/{Footer-BKg-VVkj.js → Footer-DZPvnhcT.js} +1 -1
  28. package/dist/client/{Header-DCrwCvmY.js → Header-CiGsDE_a.js} +2 -2
  29. package/dist/client/{MobileLayout-vWSHpluo.js → MobileLayout-BJHW7x5V.js} +2 -2
  30. package/dist/client/{PopUp-9uimfK49.js → PopUp-7_XnhvCc.js} +4 -168
  31. package/dist/client/{ProcessList-CTTIZa06.js → ProcessList-LL_UCbvc.js} +5 -5
  32. package/dist/client/VDivider-DWYM0WUr.js +168 -0
  33. package/dist/client/{VImg-CrmB76ZY.js → VImg-B75dgw2K.js} +2 -2
  34. package/dist/client/{VMain-OKZNpcEG.js → VMain-DvMmOL4f.js} +1 -1
  35. package/dist/client/{VTooltip-CJw2FyWk.js → VTooltip-ClWHUmWL.js} +2 -2
  36. package/dist/client/{WidgetsContainer-BaWOnE5R.js → WidgetsContainer-BqaWs4ch.js} +1 -1
  37. package/dist/client/{asWebComponent-BHWpMuE3.js → asWebComponent-IZanwL-A.js} +677 -379
  38. package/dist/client/{async-DSKj6g1f.js → async-CiNIOBVz.js} +31 -29
  39. package/dist/client/{create-layers-config-CBoYedqB.js → create-layers-config-3S3y_kZg.js} +17 -17
  40. package/dist/client/dialog-transition-c4ZSnzac.js +170 -0
  41. package/dist/client/eo-dash.js +1 -1
  42. package/dist/client/{forwardRefs-DH51zPUr.js → forwardRefs-B-c1_6yo.js} +8 -87
  43. package/dist/client/{handling-BrcDDlgS.js → handling-NvY8Csry.js} +3 -4
  44. package/dist/client/{helpers-DjZVl_WF.js → helpers-BFuh19CP.js} +1094 -194
  45. package/dist/client/{index-1JKE68n3.js → index-1wMAyuNL.js} +219 -67
  46. package/dist/client/{index-CSG_JoOA.js → index-B8jGJSTA.js} +62 -56
  47. package/dist/client/{index-Cu3-13Xg.js → index-BKQQJkhy.js} +167 -61
  48. package/dist/client/index-D0jUyol8.js +3167 -0
  49. package/dist/client/{index-CUjl-ZsI.js → index-KGxz5Egv.js} +2 -2
  50. package/dist/client/mosaic-vHvSuih6.js +459 -0
  51. package/dist/client/templates.js +251 -121
  52. package/dist/client/{transition-OHcz5Qq1.js → transition-BkyUsZh_.js} +1 -1
  53. package/dist/node/cli.js +5 -5
  54. package/dist/node/types.d.ts +4 -0
  55. package/dist/types/core/client/composables/DefineEodash.d.ts +1 -1
  56. package/dist/types/core/client/eodashSTAC/EodashCollection.d.ts +57 -25
  57. package/dist/types/core/client/eodashSTAC/cql.d.ts +15 -0
  58. package/dist/types/core/client/eodashSTAC/createLayers.d.ts +42 -23
  59. package/dist/types/core/client/eodashSTAC/helpers.d.ts +118 -19
  60. package/dist/types/core/client/eodashSTAC/mosaic.d.ts +56 -0
  61. package/dist/types/core/client/store/stac.d.ts +38 -8
  62. package/dist/types/core/client/store/states.d.ts +24 -2
  63. package/dist/types/core/client/types.d.ts +128 -24
  64. package/dist/types/core/client/utils/states.d.ts +12 -0
  65. package/dist/types/templates/baseConfig.d.ts +1 -100
  66. package/dist/types/templates/compare.d.ts +32 -3
  67. package/dist/types/templates/expert.d.ts +18 -0
  68. package/dist/types/templates/explore.d.ts +10 -1
  69. package/dist/types/templates/lite.d.ts +11 -0
  70. package/dist/types/widgets/EodashChart.vue.d.ts +5 -3
  71. package/dist/types/widgets/EodashItemCatalog/index.vue.d.ts +129 -2
  72. package/dist/types/widgets/EodashItemCatalog/methods/filters.d.ts +28 -6
  73. package/dist/types/widgets/EodashItemCatalog/methods/handlers.d.ts +17 -4
  74. package/dist/types/widgets/EodashItemCatalog/methods/map.d.ts +16 -4
  75. package/dist/types/widgets/EodashLayerControl.vue.d.ts +35 -16
  76. package/dist/types/widgets/EodashMap/EodashMapBtns.vue.d.ts +14 -10
  77. package/dist/types/widgets/EodashMap/index.vue.d.ts +86 -37
  78. package/dist/types/widgets/EodashMap/methods/create-layers-config.d.ts +1 -1
  79. package/dist/types/widgets/EodashMap/methods/index.d.ts +5 -1
  80. package/dist/types/widgets/EodashMap/methods/use-map-loading.d.ts +1 -0
  81. package/dist/types/widgets/EodashProcess/index.vue.d.ts +5 -3
  82. package/dist/types/widgets/EodashProcess/methods/custom-endpoints/layers/eoxhub-workspaces-endpoint.d.ts +1 -1
  83. package/dist/types/widgets/EodashProcess/states.d.ts +1 -1
  84. package/dist/types/widgets/EodashStacInfo.vue.d.ts +20 -0
  85. package/dist/types/widgets/EodashTimeSlider/index.vue.d.ts +20 -0
  86. package/dist/types/widgets/EodashTimeSlider/methods/index.d.ts +3 -8
  87. package/dist/types/widgets/EodashTimeSlider/types.d.ts +6 -16
  88. package/dist/types/widgets/EodashTools.vue.d.ts +5 -5
  89. package/package.json +34 -34
  90. package/templates/baseConfig.js +37 -6
  91. package/templates/compare.js +61 -7
  92. package/templates/expert.js +24 -2
  93. package/templates/explore.js +12 -5
  94. package/templates/lite.js +13 -0
  95. package/widgets/EodashChart.vue +162 -21
  96. package/widgets/EodashItemCatalog/index.vue +363 -35
  97. package/widgets/EodashItemCatalog/methods/filters.js +134 -70
  98. package/widgets/EodashItemCatalog/methods/handlers.js +113 -19
  99. package/widgets/EodashItemCatalog/methods/map.js +141 -28
  100. package/widgets/EodashLayerControl.vue +51 -25
  101. package/widgets/EodashMap/EodashMapBtns.vue +109 -48
  102. package/widgets/EodashMap/index.vue +91 -105
  103. package/widgets/EodashMap/methods/btns.js +64 -31
  104. package/widgets/EodashMap/methods/create-layers-config.js +16 -16
  105. package/widgets/EodashMap/methods/index.js +71 -8
  106. package/widgets/EodashMap/methods/use-map-loading.js +58 -0
  107. package/widgets/EodashProcess/index.vue +136 -16
  108. package/widgets/EodashProcess/methods/composables.js +13 -0
  109. package/widgets/EodashProcess/methods/outputs.js +0 -1
  110. package/widgets/EodashProcess/methods/utils.js +29 -27
  111. package/widgets/EodashProcess/states.js +1 -1
  112. package/widgets/EodashStacInfo.vue +64 -2
  113. package/widgets/EodashTimeSlider/index.vue +58 -2
  114. package/widgets/EodashTimeSlider/methods/index.js +5 -48
  115. package/widgets/EodashTimeSlider/types.ts +7 -17
  116. package/widgets/ExportState.vue +13 -4
  117. package/dist/client/EodashChart-CLDqE93C.js +0 -144
  118. package/dist/client/EodashStacInfo-BQspAHKH.js +0 -107
  119. package/dist/client/index-5Jhsa8Xe.js +0 -570
@@ -1,7 +1,10 @@
1
1
  import { toAbsolute } from "stac-js/src/http.js";
2
2
  import axios from "@/plugins/axios";
3
3
  import log from "loglevel";
4
+ import mustache from "mustache";
5
+ import { updateVectorLayerStyle } from "@eox/layercontrol";
4
6
  import { getStyleVariablesState } from "./triggers.js";
7
+ import { itemsCache, splitItemsCache } from "@/utils/states.js";
5
8
 
6
9
  /**
7
10
  * @param {import("stac-ts").StacLink[]} [links]
@@ -41,6 +44,26 @@ export function generateFeatures(links, extraProperties = {}, rel = "item") {
41
44
  return geojsonObject;
42
45
  }
43
46
 
47
+ /**
48
+ * Fetches or extracts the raster form configuration for a STAC object.
49
+ * Supports direct JSON objects, data URIs, and URL strings.
50
+ *
51
+ * @param {string|object|undefined} rasterform - The rasterform property from the STAC object.
52
+ * @returns {Promise<import("@/types").EodashRasterJSONForm|undefined>}
53
+ */
54
+ export async function fetchRasterForm(rasterform) {
55
+ if (!rasterform) {
56
+ return undefined;
57
+ }
58
+ if (typeof rasterform === "object") {
59
+ return /** @type {import("@/types").EodashRasterJSONForm} */ (rasterform);
60
+ }
61
+ if (typeof rasterform === "string") {
62
+ return await axios.get(rasterform).then((resp) => resp.data);
63
+ }
64
+ return undefined;
65
+ }
66
+
44
67
  /**
45
68
  * Spearates and extracts layerConfig (jsonform schema & legend) from a style json
46
69
  *
@@ -97,6 +120,39 @@ export function extractLayerConfig(
97
120
  return { layerConfig, style };
98
121
  }
99
122
 
123
+ /**
124
+ * Recursively extracts URL keys from a JSON Schema.
125
+ * Maps schema property names to their defined `url_key`.
126
+ * @param {Record<string, any> | null | undefined} schema
127
+ * @returns {Record<string, string>}
128
+ */
129
+ export function extractUrlKeys(schema) {
130
+ /** @type {Record<string, string>} */
131
+ const keys = {};
132
+ if (!schema || typeof schema !== "object") return keys;
133
+
134
+ if (schema.properties) {
135
+ for (const [key, propDef] of Object.entries(schema.properties)) {
136
+ if (propDef && typeof propDef === "object") {
137
+ if (typeof propDef.url_key === "string") {
138
+ keys[key] = propDef.url_key;
139
+ }
140
+ Object.assign(keys, extractUrlKeys(propDef));
141
+ }
142
+ }
143
+ }
144
+
145
+ for (const combinator of ["oneOf", "allOf", "anyOf"]) {
146
+ if (Array.isArray(schema[combinator])) {
147
+ for (const sub of schema[combinator]) {
148
+ Object.assign(keys, extractUrlKeys(sub));
149
+ }
150
+ }
151
+ }
152
+
153
+ return keys;
154
+ }
155
+
100
156
  /**
101
157
  *
102
158
  * @param {number[]} bbox
@@ -171,7 +227,7 @@ export const extractRoles = (properties, linkOrAsset) => {
171
227
 
172
228
  /**
173
229
  * 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
230
+ * @param { import("stac-ts").StacItem | import("stac-ts").StacCollection | null | undefined} stacObject
175
231
  * @param {string | undefined} linkKey
176
232
  * @param {string | undefined} assetKey
177
233
  * @returns
@@ -181,6 +237,7 @@ export const fetchStyle = async (
181
237
  linkKey = undefined,
182
238
  assetKey = undefined,
183
239
  ) => {
240
+ if (!stacObject) return undefined;
184
241
  let styleLink = null;
185
242
  if (linkKey) {
186
243
  styleLink = stacObject.links.find(
@@ -212,6 +269,20 @@ export const fetchStyle = async (
212
269
  }
213
270
  };
214
271
 
272
+ /**
273
+ * Resolves a style by preferring the item's own `style` link and falling back
274
+ * to the collection's. Takes the same key arguments as `fetchStyle`.
275
+ *
276
+ * @param {import("stac-ts").StacItem | import("stac-ts").StacCollection} item
277
+ * @param {import("stac-ts").StacCollection | null | undefined} collection
278
+ * @param {string} [linkKey]
279
+ * @param {string} [assetKey]
280
+ * @returns {Promise<import("@/types").EodashStyleJson | undefined>}
281
+ */
282
+ export const resolveStyle = async (item, collection, linkKey, assetKey) =>
283
+ (await fetchStyle(item, linkKey, assetKey)) ??
284
+ (await fetchStyle(collection, linkKey, assetKey));
285
+
215
286
  /**
216
287
  * Fetches all style JSONs from a STAC Item and returns an array with style objects
217
288
  * @param {import("stac-ts").StacItem | import("stac-ts").StacCollection} stacObject
@@ -222,6 +293,7 @@ export const fetchAllStyles = async (stacObject) => {
222
293
  link.rel.includes("style"),
223
294
  );
224
295
  const fetchPromises = styleLinks.map(async (link) => {
296
+ /** @type {import("@/types").EodashStyleJson} */
225
297
  const styleJson = await axios.get(link.href).then((resp) => resp.data);
226
298
  log.debug("fetched styles JSON", JSON.parse(JSON.stringify(styleJson)));
227
299
  return styleJson;
@@ -251,94 +323,74 @@ export const getProjectionCode = (projection) => {
251
323
  };
252
324
 
253
325
  /**
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) {
260
- return { layerDatetime: undefined, timeControlValues: undefined };
261
- }
262
-
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) {
326
+ * Builds layercontrol LayerDatetime + timeControlValues from a list of dates.
327
+ *
328
+ * @param {Date[] | undefined} dates
329
+ * @param {string | null} [currentStep] - target datetime; snapped to the closest available date
330
+ * @returns {{ layerDatetime: Record<string, any> | undefined, timeControlValues: { date: string }[] | undefined }}
331
+ */
332
+ export const extractLayerTimeValues = (dates, currentStep) => {
333
+ if (!currentStep || !dates?.length || dates.length <= 1) {
313
334
  return { layerDatetime: undefined, timeControlValues: undefined };
314
335
  }
315
336
 
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);
337
+ const controlValues = dates.map((d) => d.toISOString()).sort();
338
+ const timeControlValues = controlValues.map((date) => ({ date }));
339
+
340
+ currentStep = new Date(currentStep).toISOString();
341
+ if (!controlValues.includes(currentStep)) {
342
+ const target = new Date(currentStep).getTime();
343
+ currentStep = controlValues.reduce((best, d) =>
344
+ Math.abs(new Date(d).getTime() - target) <
345
+ Math.abs(new Date(best).getTime() - target)
346
+ ? d
347
+ : best,
348
+ );
324
349
  }
325
350
 
326
351
  const layerDatetime = {
327
- controlValues: timeValues.map((d) => d.date).sort(),
352
+ controlValues,
328
353
  currentStep,
329
354
  slider: true,
330
355
  navigation: true,
331
356
  play: false,
332
357
  displayFormat: "DD.MM.YYYY HH:mm",
333
358
  animateOnClickInterval: false,
359
+ showUTC: true,
334
360
  };
335
361
 
336
- return {
337
- layerDatetime,
338
- timeControlValues: timeValues,
339
- };
362
+ return { layerDatetime, timeControlValues };
340
363
  };
341
364
 
365
+ /**
366
+ * Fetches the daily pre-aggregation data for a STAC collection if available.
367
+ * Returns the raw AggregationCollection,
368
+ * or null if no daily pre-aggregation link exists, or the fetch fails.
369
+ *
370
+ * @param {import("stac-ts").StacCollection | undefined} stacCollection
371
+ * @param {string} fallbackBaseUrl - base for relative href resolution when no `self` link is present
372
+ * @returns {Promise<any | null>}
373
+ */
374
+ export async function fetchPreAggregations(stacCollection, fallbackBaseUrl) {
375
+ if (!stacCollection) return null;
376
+ const preAggregationLink = stacCollection.links?.find(
377
+ (l) => l.rel === "pre-aggregation" && l["aggregation:interval"] === "daily",
378
+ );
379
+ if (!preAggregationLink) return null;
380
+
381
+ try {
382
+ const selfLink = stacCollection.links?.find((l) => l.rel === "self")?.href;
383
+ const url = toAbsolute(
384
+ preAggregationLink.href,
385
+ selfLink || fallbackBaseUrl,
386
+ );
387
+ return await axios.get(url).then((resp) => resp.data);
388
+ } catch (e) {
389
+ console.warn("[eodash] Failed to fetch pre-aggregation", e);
390
+ return null;
391
+ }
392
+ }
393
+
342
394
  /**
343
395
  * Recursively find all layers whose ID up to the first ; is same as given layer
344
396
  *
@@ -347,6 +399,9 @@ export const extractLayerTimeValues = (items, currentStep) => {
347
399
  * @returns {import("@eox/map").EoxLayer[]} Matching layer objects.
348
400
  */
349
401
  export const findLayersByLayerPrefix = (layers, referenceLayer) => {
402
+ if (!layers || !referenceLayer) {
403
+ return [];
404
+ }
350
405
  const refId = referenceLayer?.properties?.id;
351
406
 
352
407
  if (typeof refId !== "string" || !refId.includes(";:;")) {
@@ -561,20 +616,150 @@ export function assignProjID(item, linkOrAsset, id, layer) {
561
616
  *
562
617
  * @param {Record<string,any>[]} layers
563
618
  */
564
- export const removeUnneededProperties = (layers) => {
565
- const cloned = structuredClone(layers);
566
- cloned.forEach((layer) => {
567
- const id = layer.properties.id;
568
- const title = layer.properties.title;
569
- layer.properties = { id, title };
570
- if (layer["interactions"]) {
571
- delete layer["interactions"];
619
+ export const removeUnneededProperties = (layers, formValues = {}) => {
620
+ /**
621
+ * @param {Record<string,any>} layer
622
+ * @returns {Record<string,any>[]}
623
+ */
624
+ const processLayer = (layer) => {
625
+ // If the layer (or group) is explicitly marked as not visible, skip it and all children
626
+ if (layer.properties?.visible === false) {
627
+ return [];
572
628
  }
573
- if (layer.type === "Group") {
574
- layer.layers = removeUnneededProperties(layer.layers);
629
+
630
+ // If it's a Group, we just want its children
631
+ if (layer.type === "Group" && Array.isArray(layer.layers)) {
632
+ return layer.layers.flatMap(processLayer);
575
633
  }
576
- });
577
- return cloned;
634
+
635
+ // Break any Vue Proxies/OpenLayers getters by stringifying first
636
+ let clonedLayer;
637
+ try {
638
+ clonedLayer = JSON.parse(JSON.stringify(layer));
639
+ } catch (_e) {
640
+ clonedLayer = structuredClone(layer);
641
+ }
642
+
643
+ // Flatten formValues to handle nested properties (e.g., vminmax: { vmin, vmax })
644
+ /**
645
+ * @param {Record<string,any>} obj
646
+ * @returns {Record<string,any>}
647
+ */
648
+ const flattenFormValues = (obj) => {
649
+ /** @type {Record<string,any>} */
650
+ let result = {};
651
+ for (const key in obj) {
652
+ if (
653
+ obj[key] !== null &&
654
+ typeof obj[key] === "object" &&
655
+ !Array.isArray(obj[key])
656
+ ) {
657
+ Object.assign(result, flattenFormValues(obj[key]));
658
+ } else {
659
+ result[key] = obj[key];
660
+ }
661
+ }
662
+ return result;
663
+ };
664
+ const flatFormValues = flattenFormValues(formValues);
665
+
666
+ // Burn in style variables using Mustache if formValues are provided
667
+ if (Object.keys(flatFormValues).length > 0) {
668
+ // Stringify, render mustache, then parse back
669
+ try {
670
+ const renderedString = mustache.render(
671
+ JSON.stringify(clonedLayer),
672
+ flatFormValues,
673
+ );
674
+ clonedLayer = JSON.parse(renderedString);
675
+ } catch (e) {
676
+ log.warn(
677
+ "[eodash] Failed to apply mustache templating during export cleanup:",
678
+ e,
679
+ );
680
+ }
681
+ }
682
+
683
+ const {
684
+ id,
685
+ title,
686
+ mapboxStyle,
687
+ projection,
688
+ applyOptions,
689
+ layerConfig,
690
+ visible,
691
+ } = clonedLayer.properties || {};
692
+
693
+ // If style was not at root but in properties (layerConfig), move it to root early
694
+ if (!clonedLayer.style && layerConfig?.style) {
695
+ clonedLayer.style = layerConfig.style;
696
+ }
697
+
698
+ // Burn in OpenLayers ["var", "name"] variables using flatFormValues overriding style.variables
699
+ const styleVariables = {
700
+ ...(clonedLayer.style?.variables || {}),
701
+ ...flatFormValues,
702
+ };
703
+
704
+ if (clonedLayer.style && Object.keys(styleVariables).length > 0) {
705
+ clonedLayer.style = updateVectorLayerStyle({
706
+ ...clonedLayer.style,
707
+ variables: styleVariables,
708
+ });
709
+ }
710
+
711
+ clonedLayer.properties = {
712
+ id,
713
+ title,
714
+ ...(mapboxStyle && { mapboxStyle }),
715
+ ...(projection && { projection }),
716
+ ...(applyOptions && { applyOptions }),
717
+ ...(visible !== undefined && { visible }),
718
+ };
719
+
720
+ if (clonedLayer["interactions"]) {
721
+ delete clonedLayer["interactions"];
722
+ }
723
+
724
+ // Cleanup unnecessary properties
725
+ /**
726
+ * @param {any} obj
727
+ */
728
+ const cleanupProperties = (obj) => {
729
+ if (!obj || typeof obj !== "object") return;
730
+
731
+ for (const key in obj) {
732
+ if (obj[key] === null) {
733
+ delete obj[key];
734
+ } else if (Array.isArray(obj[key])) {
735
+ if (obj[key].length === 0) {
736
+ // Remove empty arrays like empty interactions
737
+ delete obj[key];
738
+ } else {
739
+ obj[key].forEach(cleanupProperties);
740
+ }
741
+ } else if (typeof obj[key] === "object") {
742
+ cleanupProperties(obj[key]);
743
+ if (Object.keys(obj[key]).length === 0) {
744
+ // we don't delete empty objects for now as it might break schema requirements
745
+ }
746
+ }
747
+ }
748
+ };
749
+ cleanupProperties(clonedLayer);
750
+
751
+ return [clonedLayer];
752
+ };
753
+
754
+ // ensure the top level array is also deeply un-proxied if needed
755
+ let rawLayers = layers;
756
+ try {
757
+ rawLayers = JSON.parse(JSON.stringify(layers));
758
+ } catch (_e) {
759
+ rawLayers = structuredClone(layers);
760
+ }
761
+
762
+ return rawLayers.flatMap(processLayer);
578
763
  };
579
764
 
580
765
  /**
@@ -734,7 +919,12 @@ export function getDatetimeProperty(linksOrItems) {
734
919
  }
735
920
 
736
921
  // TODO: consider other properties for datetime ranges
737
- const datetimeProperties = ["datetime", "start_datetime", "end_datetime"];
922
+
923
+ const datetimeProperties = /** @type {const} */ ([
924
+ "datetime",
925
+ "start_datetime",
926
+ "end_datetime",
927
+ ]);
738
928
  if (checkProperties) {
739
929
  for (const prop of datetimeProperties) {
740
930
  const propExists = linksOrItems.some(
@@ -760,6 +950,7 @@ export function getDatetimeProperty(linksOrItems) {
760
950
  return prop;
761
951
  }
762
952
  }
953
+
763
954
  /**
764
955
  *
765
956
  * @param {*} stacObject
@@ -776,6 +967,65 @@ export function isSTACItem(stacObject) {
776
967
  );
777
968
  }
778
969
 
970
+ /**
971
+ * Fetches items using a split strategy (past/future) around a center date
972
+ * @param {string} itemsUrl
973
+ * @param {string} query
974
+ * @param {string | Date} centerDatetime
975
+ * @param {number} maxNumber
976
+ * @returns {Promise<import("stac-ts").StacItem[]>}
977
+ */
978
+ export const fetchSplitItems = async (
979
+ itemsUrl,
980
+ query,
981
+ centerDatetime,
982
+ maxNumber,
983
+ ) => {
984
+ const center = new Date(centerDatetime).toISOString();
985
+ const limit = Math.ceil(maxNumber / 2);
986
+
987
+ /** @param {"past"|"future"} direction */
988
+ const fetchSide = async (direction) => {
989
+ const isPast = direction === "past";
990
+ const datetimeRange = isPast ? `../${center}` : `${center}/..`;
991
+ const sortOrder = isPast ? "-datetime" : "+datetime";
992
+ const queryParams = new URLSearchParams(query);
993
+
994
+ queryParams.set("limit", limit.toString());
995
+ queryParams.set("datetime", datetimeRange);
996
+ queryParams.set("sortby", sortOrder);
997
+
998
+ const splitUrl = itemsUrl.split("/");
999
+ const collectionId = /** @type {string} */ (splitUrl.at(-2));
1000
+ queryParams.set("collection", collectionId);
1001
+
1002
+ const searchEndpoint = `${splitUrl.slice(0, -3).join("/")}/search`;
1003
+ const items = await axios
1004
+ .get(searchEndpoint, { params: queryParams })
1005
+ .then((res) => res.data.features);
1006
+ return items;
1007
+ };
1008
+
1009
+ const [pastItems, futureItems] = await Promise.all([
1010
+ fetchSide("past"),
1011
+ fetchSide("future"),
1012
+ ]);
1013
+
1014
+ const allItems = [...pastItems, ...futureItems];
1015
+
1016
+ // check for duplicates by ids
1017
+ const seen = new Set();
1018
+ const uniqueItems = [];
1019
+ for (const item of allItems) {
1020
+ if (!seen.has(item.id)) {
1021
+ seen.add(item.id);
1022
+ uniqueItems.push(item);
1023
+ }
1024
+ }
1025
+
1026
+ return uniqueItems;
1027
+ };
1028
+
779
1029
  /**
780
1030
  * Fetch all STAC items from a STAC API endpoint.
781
1031
  * @param {string} itemsUrl
@@ -783,6 +1033,8 @@ export function isSTACItem(stacObject) {
783
1033
  * @param {number} [limit=100] - The maximum number of items to fetch per request.
784
1034
  * @param {boolean} [returnFirst] - If true, only the first page of results will be returned.
785
1035
  * @param {number} [maxNumber=1000] - if the matched number of items exceed this, only the first page will be returned.
1036
+ * @param {string | Date} [centerDatetime] - Date to center the search around if items exceed maxNumber
1037
+ * @returns {Promise<import("stac-ts").StacItem[]>}
786
1038
  */
787
1039
  export async function fetchApiItems(
788
1040
  itemsUrl,
@@ -790,23 +1042,42 @@ export async function fetchApiItems(
790
1042
  limit = 100,
791
1043
  returnFirst = false,
792
1044
  maxNumber = 1000,
1045
+ centerDatetime,
793
1046
  ) {
794
- itemsUrl = itemsUrl.includes("?") ? itemsUrl.split("?")[0] : itemsUrl;
795
- itemsUrl += query ? `?limit=${limit}&${query}` : `?limit=${limit}`;
1047
+ // Exclude centerDatetime from cache key - it's only used for split search fallback
1048
+ const cacheKey = JSON.stringify({
1049
+ itemsUrl,
1050
+ query,
1051
+ limit,
1052
+ returnFirst,
1053
+ maxNumber,
1054
+ });
1055
+
1056
+ if (itemsCache.has(cacheKey)) {
1057
+ return itemsCache.get(cacheKey) ?? [];
1058
+ }
1059
+
1060
+ const urlQuery = new URLSearchParams(query);
1061
+ urlQuery.set("limit", limit.toString());
1062
+
1063
+ let finalItemsUrl = itemsUrl.includes("?")
1064
+ ? itemsUrl.split("?")[0]
1065
+ : itemsUrl;
1066
+ finalItemsUrl += urlQuery.keys().toArray().length
1067
+ ? `?${urlQuery.toString()}`
1068
+ : "";
796
1069
 
797
1070
  const itemsFeatureCollection = await axios
798
- .get(itemsUrl)
1071
+ .get(finalItemsUrl)
799
1072
  .then((resp) => resp.data);
1073
+
800
1074
  /** @type {import("stac-ts").StacItem[]} */
801
1075
  const items = itemsFeatureCollection.features;
802
- const nextLink = itemsFeatureCollection.links?.find(
803
- //@ts-expect-error TODO: itemsFeatureCollection is not typed
804
- (link) => link.rel === "next",
805
- );
806
-
807
- if (!nextLink || returnFirst) {
1076
+ if (returnFirst) {
1077
+ itemsCache.set(cacheKey, items);
808
1078
  return items;
809
1079
  }
1080
+
810
1081
  /** @type {number} */
811
1082
  const matchedItems = itemsFeatureCollection.numberMatched;
812
1083
  // Avoid fetching too many items
@@ -814,26 +1085,70 @@ export async function fetchApiItems(
814
1085
  console.warn(
815
1086
  `[eodash] The number of items matched (${matchedItems}) exceeds the maximum allowed (${maxNumber})`,
816
1087
  );
817
- return items;
818
- }
1088
+ // we try to narrow down the search around the center datetime
1089
+ if (centerDatetime) {
1090
+ // Check if we have a cached split result that covers this centerDatetime
1091
+ const splitCacheKey = JSON.stringify({ itemsUrl, query, maxNumber });
1092
+ const cachedSplit = splitItemsCache.get(splitCacheKey);
1093
+ const centerTime = new Date(centerDatetime).getTime();
1094
+
1095
+ if (
1096
+ cachedSplit &&
1097
+ centerTime >= cachedSplit.minTime &&
1098
+ centerTime <= cachedSplit.maxTime
1099
+ ) {
1100
+ return cachedSplit.items;
1101
+ }
819
1102
 
820
- let [nextLinkURL, nextLinkQuery] = nextLink.href.split("?");
821
- nextLinkQuery = nextLinkQuery.replace(/limit=\d+/, "");
822
- if (query) {
823
- const queryParams = new URLSearchParams(query);
824
- const nextLinkParams = new URLSearchParams(nextLinkQuery);
1103
+ const narrowedItems = await fetchSplitItems(
1104
+ itemsUrl,
1105
+ query ?? "",
1106
+ centerDatetime,
1107
+ maxNumber,
1108
+ );
825
1109
 
826
- for (const key of nextLinkParams.keys()) {
827
- queryParams.delete(key);
828
- }
829
- const remainingQuery = queryParams.toString();
830
- if (remainingQuery) {
831
- nextLinkQuery += `&${remainingQuery}`;
1110
+ if (!narrowedItems.length) {
1111
+ return narrowedItems;
1112
+ }
1113
+ const datetimeProperty = getDatetimeProperty(narrowedItems);
1114
+ if (!datetimeProperty) {
1115
+ return narrowedItems;
1116
+ }
1117
+
1118
+ const times = narrowedItems
1119
+ .map((i) =>
1120
+ i.properties[datetimeProperty]
1121
+ ? new Date(i.properties[datetimeProperty]).getTime()
1122
+ : null,
1123
+ )
1124
+ .filter((t) => t !== null);
1125
+ if (!times.length) {
1126
+ return narrowedItems;
1127
+ }
1128
+ splitItemsCache.set(splitCacheKey, {
1129
+ items: narrowedItems,
1130
+ minTime: Math.min(...times),
1131
+ maxTime: Math.max(...times),
1132
+ });
1133
+
1134
+ return narrowedItems;
832
1135
  }
1136
+
1137
+ itemsCache.set(cacheKey, items);
1138
+ return items;
833
1139
  }
834
1140
 
835
- const nextPage = await fetchApiItems(nextLinkURL, nextLinkQuery);
836
- items.push(...nextPage);
1141
+ urlQuery.set("limit", maxNumber.toString());
1142
+
1143
+ const allItems = await axios
1144
+ .get(itemsUrl + "?" + urlQuery.toString())
1145
+ .then((resp) => resp.data.features)
1146
+ .catch((err) => {
1147
+ console.error(err);
1148
+ return [];
1149
+ });
1150
+ items.splice(0, items.length, ...allItems);
1151
+ itemsCache.set(cacheKey, items);
837
1152
  return items;
838
1153
  }
839
1154
  /**
@@ -871,3 +1186,272 @@ export function extractEoxLegendLink(link) {
871
1186
  }
872
1187
  return extraProperties;
873
1188
  }
1189
+
1190
+ /**
1191
+ * Locate the first sub-schema whose `format` matches by walking `properties`
1192
+ * and the `oneOf` / `allOf` / `anyOf` combinators. Returns the schema path
1193
+ * (array of keys/indices) from the root schema to the matched node, or
1194
+ * undefined if not found. Empty array means the input schema itself matched.
1195
+ *
1196
+ * @param {Record<string, any> | null | undefined} schema
1197
+ * @param {string} [format="bands"]
1198
+ * @returns {(string | number)[] | undefined}
1199
+ */
1200
+ export function getBandsProperty(schema, format = "bands") {
1201
+ if (!schema || typeof schema !== "object") return undefined;
1202
+ if (schema.format === format) return [];
1203
+
1204
+ if (schema.properties) {
1205
+ for (const key of Object.keys(schema.properties)) {
1206
+ const sub = getBandsProperty(schema.properties[key], format);
1207
+ if (sub) return ["properties", key, ...sub];
1208
+ }
1209
+ }
1210
+
1211
+ for (const combinator of ["oneOf", "allOf", "anyOf"]) {
1212
+ if (!Array.isArray(schema[combinator])) continue;
1213
+ for (let i = 0; i < schema[combinator].length; i++) {
1214
+ const sub = getBandsProperty(schema[combinator][i], format);
1215
+ if (sub) return [combinator, i, ...sub];
1216
+ }
1217
+ }
1218
+
1219
+ return undefined;
1220
+ }
1221
+
1222
+ /**
1223
+ * Checks whether a GeoZarr layer's bands changed in the jsonform output and,
1224
+ * if so, rebuilds the source with the new 3 selected bands.
1225
+ * Gamma and rescale are handled automatically by `applyUpdatedStyles` via
1226
+ * `updateStyleVariables` — this function only manages source reconstruction.
1227
+ * Uses the existing source constructor to avoid import-version mismatches.
1228
+ *
1229
+ * @param {import("ol/layer/Layer").default} olLayer - Layer from layerConfig:change event
1230
+ * @param {Record<string, any>} jsonformValue - Current jsonform output
1231
+ * @returns {boolean} true if the source was rebuilt
1232
+ */
1233
+ export function updateGeoZarrBands(olLayer, jsonformValue) {
1234
+ /** @type {import("@eox/map/src/layers").EOxLayerType<"WebGLTile","GeoZarr">} */
1235
+ const jsonLayer = olLayer.get("_jsonDefinition");
1236
+ const updatedBands = jsonformValue.bands;
1237
+ const isGeoZarr =
1238
+ jsonLayer?.type === "WebGLTile" && jsonLayer?.source?.type === "GeoZarr";
1239
+ if (!jsonLayer || !jsonLayer.source || !isGeoZarr || !updatedBands) {
1240
+ return false;
1241
+ }
1242
+
1243
+ const oldBands = jsonLayer.source?.bands;
1244
+ if (JSON.stringify(updatedBands) === JSON.stringify(oldBands)) {
1245
+ return false;
1246
+ }
1247
+ jsonLayer.source.bands = [...updatedBands];
1248
+ olLayer.setSource(
1249
+ new window.eoxMapAdvancedOlSources.GeoZarr(jsonLayer.source),
1250
+ );
1251
+ return true;
1252
+ }
1253
+
1254
+ /**
1255
+ * Safely appends query parameters to a URL string, preserving templates like {z}/{x}/{y}
1256
+ * @param {string} url
1257
+ * @param {Record<string, string>} params
1258
+ * @returns {string}
1259
+ */
1260
+ function appendQueryParams(url, params) {
1261
+ const [base, query] = url.split("?");
1262
+ const searchParams = new URLSearchParams(query || "");
1263
+
1264
+ for (const [key, val] of Object.entries(params)) {
1265
+ if (val !== undefined && val !== null && val !== "") {
1266
+ searchParams.set(key, val);
1267
+ } else {
1268
+ searchParams.delete(key);
1269
+ }
1270
+ }
1271
+
1272
+ const newQuery = searchParams.toString();
1273
+ return newQuery ? `${base}?${newQuery}` : base;
1274
+ }
1275
+
1276
+ /**
1277
+ * Checks whether a VectorTile layer's URL needs to be updated based on jsonform output.
1278
+ * If the style's jsonform schema has properties with `url_key` defined, their values
1279
+ * are injected as query parameters into the source URL.
1280
+ *
1281
+ * @param {import("ol/layer/Layer").default} olLayer - Layer from layerConfig:change event
1282
+ * @param {Record<string, any>} jsonformValue - Current jsonform output
1283
+ * @returns {boolean} true if the URL was updated
1284
+ */
1285
+ export function updateLayerUrl(olLayer, jsonformValue) {
1286
+ const jsonLayer = olLayer.get("_jsonDefinition");
1287
+ if (!jsonLayer || jsonLayer.type !== "VectorTile") {
1288
+ return false;
1289
+ }
1290
+
1291
+ const schema = jsonLayer.properties?.layerConfig?.schema;
1292
+ const urlKeys = extractUrlKeys(schema);
1293
+
1294
+ if (Object.keys(urlKeys).length === 0) {
1295
+ return false;
1296
+ }
1297
+
1298
+ let originalUrl = olLayer.get("originalUrl") || jsonLayer.source?.url;
1299
+
1300
+ if (!originalUrl || typeof originalUrl !== "string") {
1301
+ return false;
1302
+ }
1303
+
1304
+ if (!olLayer.get("originalUrl")) {
1305
+ olLayer.set("originalUrl", originalUrl);
1306
+ }
1307
+
1308
+ /** @type {Record<string, string>} */
1309
+ const queryParamsToInject = {};
1310
+ for (const [propName, urlKey] of Object.entries(urlKeys)) {
1311
+ queryParamsToInject[urlKey] = jsonformValue[propName];
1312
+ }
1313
+
1314
+ const newUrl = appendQueryParams(originalUrl, queryParamsToInject);
1315
+
1316
+ if (jsonLayer.source?.url) {
1317
+ if (jsonLayer.source.url === newUrl) {
1318
+ return false;
1319
+ }
1320
+ jsonLayer.source.url = newUrl;
1321
+ if (olLayer.get("injectedUrl") === newUrl) {
1322
+ return false;
1323
+ }
1324
+ const source = olLayer.getSource();
1325
+ olLayer.set("injectedUrl", newUrl);
1326
+ if (source && "setUrl" in source) {
1327
+ /** @type {any} */ (source).setUrl(newUrl);
1328
+ return true;
1329
+ }
1330
+ if (source && "setUrls" in source) {
1331
+ /** @type {any} */ (source).setUrls([newUrl]);
1332
+ return true;
1333
+ }
1334
+ }
1335
+
1336
+ return false;
1337
+ }
1338
+
1339
+ /**
1340
+ * Applies titiler upscaling to an XYZ tile URL based on the matched endpoint config.
1341
+ * - titiler v1: appends `@2x` to the `{y}` tile coordinate
1342
+ * - titiler v2: adds `tilesize=512` query parameter (v2 removed the `@2x` suffix)
1343
+ * Plain strings in the config default to v1 behavior for backward compatibility.
1344
+ *
1345
+ * @param {string} url - The XYZ tile URL template
1346
+ * @param {Array<string | { url: string; titilerVersion?: 1 | 2 }>} upscalingEndpoints
1347
+ * @returns {{ url: string; tileSize: [number, number] } | null} null if no endpoint matches
1348
+ */
1349
+ export function applyTitilerUpscaling(url, upscalingEndpoints) {
1350
+ const match = upscalingEndpoints.find((entry) => {
1351
+ const endpointUrl = typeof entry === "string" ? entry : entry.url;
1352
+ return url.includes(endpointUrl);
1353
+ });
1354
+
1355
+ if (!match) {
1356
+ return null;
1357
+ }
1358
+
1359
+ const version = typeof match === "string" ? 1 : (match.titilerVersion ?? 1);
1360
+
1361
+ if (version === 2) {
1362
+ const [base, query] = url.split("?");
1363
+ const params = new URLSearchParams(query);
1364
+ params.set("tilesize", "512");
1365
+ return { url: `${base}?${params.toString()}`, tileSize: [512, 512] };
1366
+ }
1367
+
1368
+ return { url: url.replace("{y}", "{y}@2x"), tileSize: [512, 512] };
1369
+ }
1370
+
1371
+ /**
1372
+ * Picks the render presets for a collection, preferring client-provided config
1373
+ * (`options.renders[collectionId]`) and falling back to the collection's own STAC
1374
+ * `renders` extension when no config entry exists.
1375
+ * @param {import("stac-ts").StacCollection | null | undefined} collection
1376
+ * @param {Record<string, Record<string, import("@/types").Render>> | undefined} [configRenders]
1377
+ * @returns {Record<string, import("@/types").Render> | undefined}
1378
+ */
1379
+ export function resolveRenders(collection, configRenders) {
1380
+ const config = collection?.id ? configRenders?.[collection.id] : undefined;
1381
+ if (config) return config;
1382
+ return /** @type {Record<string, import("@/types").Render>|undefined} */ (
1383
+ collection?.renders ?? undefined
1384
+ );
1385
+ }
1386
+
1387
+ /**
1388
+ * TiTiler expects rescale as [min,max] pairs; chunks a flat numeric list
1389
+ * into pairs (e.g. [0,0.4,0,0.1] -> [[0,0.4],[0,0.1]]). Nested input passes through.
1390
+ * @param {number[]|number[][]|undefined} rescale - flat or nested rescale values
1391
+ * @returns {number[][]|undefined} rescale as [min,max] pairs
1392
+ */
1393
+ export function normalizeRescale(rescale) {
1394
+ if (!rescale?.length || Array.isArray(rescale[0])) {
1395
+ return /** @type {number[][]|undefined} */ (rescale);
1396
+ }
1397
+ const pairs = [];
1398
+ for (let i = 0; i < rescale.length; i += 2) {
1399
+ pairs.push(/** @type {number[]} */ (rescale).slice(i, i + 2));
1400
+ }
1401
+ return pairs;
1402
+ }
1403
+
1404
+ /**
1405
+ * Drops NaN nodata values; NaN is already the implicit fill for float data,
1406
+ * so forwarding `nodata=nan` to TiTiler is redundant.
1407
+ * @param {string|number|undefined} nodata - nodata value from render/asset metadata
1408
+ * @returns {string|number|undefined} nodata, or undefined when it is NaN
1409
+ */
1410
+ export function normalizeNodata(nodata) {
1411
+ if (typeof nodata === "number" && Number.isNaN(nodata)) return undefined;
1412
+ if (typeof nodata === "string" && nodata.trim().toLowerCase() === "nan")
1413
+ return undefined;
1414
+ return nodata;
1415
+ }
1416
+
1417
+ /**
1418
+ * Serializes an object into a TiTiler query string. Arrays repeat the key per
1419
+ * element (TiTiler list params, e.g. `assets=a&assets=b`); nested elements
1420
+ * comma-join (`rescale: [[0,1],[0,2]]` -> `rescale=0,1&rescale=0,2`); objects
1421
+ * are JSON-encoded. Shared by the render-extension and mosaic paths.
1422
+ * @param {Record<string,any>} obj
1423
+ * @returns {string}
1424
+ */
1425
+ export function encodeURLObject(obj) {
1426
+ let str = "";
1427
+ for (const key in obj) {
1428
+ const value = obj[key];
1429
+ if (value === null || value === undefined || value === "") {
1430
+ continue;
1431
+ }
1432
+
1433
+ const valueType = Array.isArray(value) ? "array" : typeof value;
1434
+
1435
+ switch (valueType) {
1436
+ case "array": {
1437
+ for (const val of value) {
1438
+ if (Array.isArray(val)) {
1439
+ str += `${key}=${val.join(",")}&`;
1440
+ } else {
1441
+ str += `${key}=${encodeURIComponent(val)}&`;
1442
+ }
1443
+ }
1444
+ break;
1445
+ }
1446
+ case "object": {
1447
+ str += `${key}=${encodeURI(JSON.stringify(value))}&`;
1448
+ break;
1449
+ }
1450
+ default: {
1451
+ str += `${key}=${encodeURIComponent(value)}&`;
1452
+ break;
1453
+ }
1454
+ }
1455
+ }
1456
+ return str;
1457
+ }