@geolibre/core 3.4.0 → 3.5.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.
@@ -1,6 +1,6 @@
1
1
  import { Color, createExpression } from "@maplibre/maplibre-gl-style-spec";
2
2
  import { v4 } from "uuid";
3
- //#region src/types.ts
3
+ // src/types.ts
4
4
  const OPENFREEMAP_BASEMAPS = [
5
5
  {
6
6
  id: "liberty",
@@ -273,15 +273,18 @@ function styleValue(style, key) {
273
273
  */
274
274
  const LARGE_VECTOR_FEATURE_THRESHOLD = 5e4;
275
275
  /**
276
- * Decide whether a GeoJSON layer should use the tiled rendering path.
276
+ * Decide whether a vector layer should use the tiled rendering path.
277
277
  *
278
- * @param geojson - The layer's feature collection (may be undefined for
278
+ * @param data - The layer's feature collection or GeoArrow batch (may be undefined for
279
279
  * non-vector layers).
280
- * @returns `true` when the collection exceeds
281
- * {@link LARGE_VECTOR_FEATURE_THRESHOLD} features.
280
+ * @returns `true` when the feature count exceeds
281
+ * {@link LARGE_VECTOR_FEATURE_THRESHOLD}.
282
282
  */
283
- function shouldUseTiledRendering(geojson) {
284
- return (geojson?.features.length ?? 0) > LARGE_VECTOR_FEATURE_THRESHOLD;
283
+ function shouldUseTiledRendering(data) {
284
+ if (!data) return false;
285
+ if ("featureCount" in data && typeof data.featureCount === "number") return data.featureCount > LARGE_VECTOR_FEATURE_THRESHOLD;
286
+ if ("features" in data && Array.isArray(data.features)) return data.features.length > LARGE_VECTOR_FEATURE_THRESHOLD;
287
+ return false;
285
288
  }
286
289
  /**
287
290
  * Metadata `sourceKind` marking a live SQL query layer: a GeoJSON-backed layer
@@ -451,8 +454,7 @@ function detectNonGeographicCoordinates(geojson, sampleLimit = 1e3) {
451
454
  maxAbsY
452
455
  } : null;
453
456
  }
454
- //#endregion
455
- //#region src/ellipsoids.ts
457
+ // src/ellipsoids.ts
456
458
  /**
457
459
  * Built-in ellipsoids. Earth is WGS 84; every other body uses IAU-adopted
458
460
  * figures. Only Earth and Mars are appreciably oblate; the rest are modelled as
@@ -945,8 +947,7 @@ const PLANET_SWITCHER_OPTIONS = [
945
947
  basemapId: "venus-magellan-color"
946
948
  }
947
949
  ];
948
- //#endregion
949
- //#region src/regional-basemaps.ts
950
+ // src/regional-basemaps.ts
950
951
  const REGIONAL_BASEMAP_SENTINEL_PREFIX = "geolibre://regional-basemap/";
951
952
  const AMAP_ATTRIBUTION = "&copy; <a href=\"https://www.amap.com\">高德地图 Amap</a>";
952
953
  const TENCENT_ATTRIBUTION = "&copy; <a href=\"https://map.qq.com\">腾讯地图 Tencent Maps</a>";
@@ -1044,8 +1045,7 @@ function getRegionalBasemapById(id) {
1044
1045
  function isRegionalBasemapSentinel(styleUrl) {
1045
1046
  return Boolean(styleUrl?.startsWith(REGIONAL_BASEMAP_SENTINEL_PREFIX));
1046
1047
  }
1047
- //#endregion
1048
- //#region src/cesium-imagery.ts
1048
+ // src/cesium-imagery.ts
1049
1049
  /**
1050
1050
  * Maps the project's basemap onto imagery the Cesium globe can render.
1051
1051
  *
@@ -1357,10 +1357,9 @@ function basemapToCesiumImagery(styleUrl, cesiumBasemap = "project") {
1357
1357
  const analogue = vectorStyleAnalogue(url.startsWith("geolibre://") ? DEFAULT_BASEMAP : url);
1358
1358
  return analogue ? toImagery(analogue) : { kind: "default" };
1359
1359
  }
1360
- //#endregion
1361
- //#region src/arcgis-projection.ts
1360
+ // src/arcgis-projection.ts
1362
1361
  /** WKIDs that are Web Mercator, i.e. the default map. */
1363
- const WEB_MERCATOR_WKIDS = /* @__PURE__ */ new Set([
1362
+ const WEB_MERCATOR_WKIDS$1 = /* @__PURE__ */ new Set([
1364
1363
  3857,
1365
1364
  102100,
1366
1365
  102113,
@@ -1422,10 +1421,399 @@ const ARCGIS_PROJECTION_PRESETS = [
1422
1421
  function normalizeArcgisWkid(value) {
1423
1422
  const wkid = typeof value === "string" ? /^\s*\d+\s*$/.test(value) ? Number(value) : void 0 : value;
1424
1423
  if (typeof wkid !== "number" || !Number.isSafeInteger(wkid) || wkid <= 0) return void 0;
1425
- return WEB_MERCATOR_WKIDS.has(wkid) ? void 0 : wkid;
1424
+ return WEB_MERCATOR_WKIDS$1.has(wkid) ? void 0 : wkid;
1425
+ }
1426
+ // src/map-crs.ts
1427
+ /**
1428
+ * Window event the map fires when it enters or leaves a CRS, or is torn down
1429
+ * while in one. It carries no detail: the plugin API's `getMapCrs` reads the
1430
+ * new value, and its `onMapCrsChange` listens for this.
1431
+ */
1432
+ const MAP_CRS_CHANGE_EVENT = "geolibre:map-crs-change";
1433
+ /** Codes that are the default Web Mercator map rather than a custom CRS. */
1434
+ const WEB_MERCATOR_CODES = /* @__PURE__ */ new Set([
1435
+ 3857,
1436
+ 900913,
1437
+ 102100,
1438
+ 102113
1439
+ ]);
1440
+ const USGS_WMS = "https://planetarymaps.usgs.gov/cgi-bin/mapserv";
1441
+ const USGS_ATTRIBUTION = "<a href=\"https://astrogeology.usgs.gov/\">USGS Astrogeology</a>";
1442
+ const GIBS_ATTRIBUTION = "Imagery: <a href=\"https://earthdata.nasa.gov/gibs\">NASA GIBS</a> / Blue Marble";
1443
+ /**
1444
+ * A WMS 1.1.1 GetMap template. 1.1.1 because its BBOX is always x,y (east,
1445
+ * north) order, which is what `{bbox}` fills in; 1.3.0 flips it for
1446
+ * geographic CRSs.
1447
+ */
1448
+ function getMapTemplate(base, srs, layer, format) {
1449
+ return `${base}${base.includes("?") ? "&" : "?"}SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&SRS=${srs}&STYLES=&LAYERS=${layer}&WIDTH=512&HEIGHT=512&FORMAT=${format}&TRANSPARENT=TRUE&BBOX={bbox}`;
1450
+ }
1451
+ /** A USGS Astrogeology MapServer layer in the CRS of its `.map` file. */
1452
+ function usgsBasemap(mapFile, srs, layer, maxZoom) {
1453
+ return {
1454
+ tiles: getMapTemplate(`${USGS_WMS}?map=${mapFile}`, srs, layer, "image/png"),
1455
+ attribution: USGS_ATTRIBUTION,
1456
+ maxZoom
1457
+ };
1458
+ }
1459
+ /** A NASA GIBS Blue Marble layer from the WMS endpoint of one of its grids. */
1460
+ function gibsBasemap(epsg) {
1461
+ return {
1462
+ tiles: getMapTemplate(`https://gibs.earthdata.nasa.gov/wms/epsg${epsg}/best/wms.cgi`, `EPSG:${epsg}`, "BlueMarble_ShadedRelief_Bathymetry", "image/jpeg"),
1463
+ attribution: GIBS_ATTRIBUTION,
1464
+ maxZoom: 7
1465
+ };
1466
+ }
1467
+ /** Lng/lat degrees on the whole world, centered so MapLibre can find its edges. */
1468
+ const GEOGRAPHIC_GRID = {
1469
+ origin: [-180, 180],
1470
+ extentAtZoom0: 360
1471
+ };
1472
+ /** A square centered on a pole, `halfWidth` CRS units to each side. */
1473
+ function polarGrid(halfWidth) {
1474
+ return {
1475
+ origin: [-halfWidth, halfWidth],
1476
+ extentAtZoom0: 2 * halfWidth
1477
+ };
1478
+ }
1479
+ const sphere = (radius) => `+R=${radius}`;
1480
+ const longlat = (radius) => `+proj=longlat ${sphere(radius)} +no_defs`;
1481
+ const polarStereographic = (north, radius) => `+proj=stere +lat_0=${north ? 90 : -90} +lon_0=0 +k=1 +x_0=0 +y_0=0 ${sphere(radius)} +units=m +no_defs`;
1482
+ const MOON_RADIUS = 1737400;
1483
+ const MARS_RADIUS = 3396190;
1484
+ /**
1485
+ * A simple-cylindrical (lng/lat degrees) preset for one body's USGS map file.
1486
+ *
1487
+ * Args:
1488
+ * body: The body, which is also the ellipsoid id.
1489
+ * name: English name of the preset.
1490
+ * naifCode: The body's IAU 2015 code (NAIF id × 100), e.g. `49900` for Mars.
1491
+ * radius: Sphere radius in metres.
1492
+ * mapFile: The USGS `.map` file path.
1493
+ * layer: The WMS layer drawn as the basemap.
1494
+ *
1495
+ * Returns:
1496
+ * The preset.
1497
+ */
1498
+ function cylindrical(body, name, naifCode, radius, mapFile, layer) {
1499
+ return {
1500
+ id: `${body}-cylindrical`,
1501
+ name,
1502
+ code: `IAU_2015:${naifCode}`,
1503
+ wmsSrs: "EPSG:4326",
1504
+ definition: longlat(radius),
1505
+ ellipsoidId: body,
1506
+ tileMatrixSet: GEOGRAPHIC_GRID,
1507
+ bounds: [
1508
+ -180,
1509
+ -90,
1510
+ 180,
1511
+ 90
1512
+ ],
1513
+ basemap: usgsBasemap(mapFile, "EPSG:4326", layer, 7)
1514
+ };
1515
+ }
1516
+ /**
1517
+ * Presets verified to render against their WMS on 2026-10-19.
1518
+ *
1519
+ * The USGS polar map files serve their native polar stereographic grid under
1520
+ * `EPSG:32661`/`EPSG:32761` (MapServer's PROJ rejects the IAU codes they also
1521
+ * list), centred on the pole with no false origin, so those labels are used for
1522
+ * the basemap requests while the map registers the body's IAU code.
1523
+ */
1524
+ const MAP_CRS_PRESETS = [
1525
+ {
1526
+ id: "earth-arctic",
1527
+ name: "Arctic Polar Stereographic",
1528
+ code: "EPSG:3413",
1529
+ definition: "+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs",
1530
+ ellipsoidId: "earth",
1531
+ tileMatrixSet: polarGrid(4194304),
1532
+ bounds: [
1533
+ -180,
1534
+ 60,
1535
+ 180,
1536
+ 90
1537
+ ],
1538
+ basemap: gibsBasemap(3413)
1539
+ },
1540
+ {
1541
+ id: "earth-antarctic",
1542
+ name: "Antarctic Polar Stereographic",
1543
+ code: "EPSG:3031",
1544
+ definition: "+proj=stere +lat_0=-90 +lat_ts=-71 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs",
1545
+ ellipsoidId: "earth",
1546
+ tileMatrixSet: polarGrid(4194304),
1547
+ bounds: [
1548
+ -180,
1549
+ -90,
1550
+ 180,
1551
+ -60
1552
+ ],
1553
+ basemap: gibsBasemap(3031)
1554
+ },
1555
+ {
1556
+ id: "earth-cylindrical",
1557
+ name: "Plate Carrée (WGS 84)",
1558
+ code: "EPSG:4326",
1559
+ definition: "+proj=longlat +datum=WGS84 +no_defs",
1560
+ ellipsoidId: "earth",
1561
+ tileMatrixSet: GEOGRAPHIC_GRID,
1562
+ bounds: [
1563
+ -180,
1564
+ -90,
1565
+ 180,
1566
+ 90
1567
+ ],
1568
+ basemap: gibsBasemap(4326)
1569
+ },
1570
+ cylindrical("moon", "Moon Simple Cylindrical", 30100, MOON_RADIUS, "/maps/earth/moon_simp_cyl.map", "LROC_WAC"),
1571
+ {
1572
+ id: "moon-north-polar",
1573
+ name: "Moon North Polar Stereographic",
1574
+ code: "IAU_2015:30118",
1575
+ wmsSrs: "EPSG:32661",
1576
+ definition: polarStereographic(true, MOON_RADIUS),
1577
+ ellipsoidId: "moon",
1578
+ tileMatrixSet: polarGrid(931e3),
1579
+ bounds: [
1580
+ -180,
1581
+ 70,
1582
+ 180,
1583
+ 90
1584
+ ],
1585
+ basemap: usgsBasemap("/maps/earth/moon_npole.map", "EPSG:32661", "LRO_WAC_north", 8)
1586
+ },
1587
+ {
1588
+ id: "moon-south-polar",
1589
+ name: "Moon South Polar Stereographic",
1590
+ code: "IAU_2015:30120",
1591
+ wmsSrs: "EPSG:32761",
1592
+ definition: polarStereographic(false, MOON_RADIUS),
1593
+ ellipsoidId: "moon",
1594
+ tileMatrixSet: polarGrid(931e3),
1595
+ bounds: [
1596
+ -180,
1597
+ -90,
1598
+ 180,
1599
+ -70
1600
+ ],
1601
+ basemap: usgsBasemap("/maps/earth/moon_spole.map", "EPSG:32761", "LRO_WAC_south", 8)
1602
+ },
1603
+ cylindrical("mars", "Mars Simple Cylindrical", 49900, MARS_RADIUS, "/maps/mars/mars_simp_cyl.map", "MOLA_color"),
1604
+ {
1605
+ id: "mars-north-polar",
1606
+ name: "Mars North Polar Stereographic",
1607
+ code: "IAU_2015:49918",
1608
+ wmsSrs: "EPSG:32661",
1609
+ definition: polarStereographic(true, MARS_RADIUS),
1610
+ ellipsoidId: "mars",
1611
+ tileMatrixSet: polarGrid(2357030),
1612
+ bounds: [
1613
+ -180,
1614
+ 65,
1615
+ 180,
1616
+ 90
1617
+ ],
1618
+ basemap: usgsBasemap("/maps/mars/mars_npole.map", "EPSG:32661", "MOLA_color_north", 7)
1619
+ },
1620
+ {
1621
+ id: "mars-south-polar",
1622
+ name: "Mars South Polar Stereographic",
1623
+ code: "IAU_2015:49920",
1624
+ wmsSrs: "EPSG:32761",
1625
+ definition: polarStereographic(false, MARS_RADIUS),
1626
+ ellipsoidId: "mars",
1627
+ tileMatrixSet: polarGrid(2357030),
1628
+ bounds: [
1629
+ -180,
1630
+ -90,
1631
+ 180,
1632
+ -65
1633
+ ],
1634
+ basemap: usgsBasemap("/maps/mars/mars_spole.map", "EPSG:32761", "MOLA_color_south", 7)
1635
+ },
1636
+ cylindrical("mercury", "Mercury Simple Cylindrical", 19900, 2439400, "/maps/mercury/mercury_simp_cyl.map", "MESSENGER"),
1637
+ cylindrical("venus", "Venus Simple Cylindrical", 29900, 6051800, "/maps/venus/venus_simp_cyl.map", "MAGELLAN"),
1638
+ cylindrical("io", "Io Simple Cylindrical", 50100, 1821600, "/maps/jupiter/io_simp_cyl.map", "SSI_color"),
1639
+ cylindrical("europa", "Europa Simple Cylindrical", 50200, 1560800, "/maps/jupiter/europa_simp_cyl.map", "GALILEO_VOYAGER"),
1640
+ cylindrical("ganymede", "Ganymede Simple Cylindrical", 50300, 2631200, "/maps/jupiter/ganymede_simp_cyl.map", "GALILEO_VOYAGER"),
1641
+ cylindrical("callisto", "Callisto Simple Cylindrical", 50400, 2410300, "/maps/jupiter/callisto_simp_cyl.map", "GALILEO_VOYAGER"),
1642
+ cylindrical("titan", "Titan Simple Cylindrical", 60600, 2574730, "/maps/saturn/titan_simp_cyl.map", "Titan_ISS_Controlled_Mosaic"),
1643
+ cylindrical("pluto", "Pluto Simple Cylindrical", 99900, 1188300, "/maps/pluto/pluto_simp_cyl.map", "NEWHORIZONS_PLUTO_MOSAIC"),
1644
+ cylindrical("charon", "Charon Simple Cylindrical", 90100, 606e3, "/maps/pluto/charon_simp_cyl.map", "NEWHORIZONS_CHARON_MOSAIC")
1645
+ ];
1646
+ /**
1647
+ * Look a preset up by its id.
1648
+ *
1649
+ * Args:
1650
+ * id: A `preferences.map.mapCrs` value.
1651
+ *
1652
+ * Returns:
1653
+ * The preset, or `undefined` when `id` is not a preset id.
1654
+ */
1655
+ function getMapCrsPreset(id) {
1656
+ return id === void 0 ? void 0 : MAP_CRS_PRESETS.find((preset) => preset.id === id);
1657
+ }
1658
+ /**
1659
+ * Read an `EPSG:<code>` map CRS value.
1660
+ *
1661
+ * Args:
1662
+ * value: A `preferences.map.mapCrs` value.
1663
+ *
1664
+ * Returns:
1665
+ * The numeric EPSG code, or `null` when the value is not an EPSG CRS.
1666
+ */
1667
+ function parseMapCrsEpsgCode(value) {
1668
+ const match = /^\s*EPSG:(\d+)\s*$/i.exec(value ?? "");
1669
+ if (!match) return null;
1670
+ const code = Number(match[1]);
1671
+ return Number.isSafeInteger(code) && code > 0 ? code : null;
1672
+ }
1673
+ /**
1674
+ * Normalize a stored map CRS.
1675
+ *
1676
+ * Args:
1677
+ * value: The raw `preferences.map.mapCrs` value.
1678
+ *
1679
+ * Returns:
1680
+ * A preset id, `"EPSG:<code>"`, or `undefined` for the default Web Mercator
1681
+ * map (absent, malformed or a Web Mercator code).
1682
+ */
1683
+ function normalizeMapCrs(value) {
1684
+ if (typeof value !== "string") return void 0;
1685
+ if (getMapCrsPreset(value)) return value;
1686
+ const code = parseMapCrsEpsgCode(value);
1687
+ if (code === null || WEB_MERCATOR_CODES.has(code)) return void 0;
1688
+ const epsg = `EPSG:${code}`;
1689
+ return MAP_CRS_PRESETS.find((preset) => preset.code === epsg)?.id ?? epsg;
1690
+ }
1691
+ /**
1692
+ * Point a Web Mercator WMS or ArcGIS export template at a map CRS.
1693
+ *
1694
+ * GeoLibre's WMS layers request `BBOX={bbox-epsg-3857}` in `EPSG:3857`. On a
1695
+ * map in another CRS each tile is a box in that CRS instead, which MapLibre
1696
+ * writes into the `{bbox}` token, so the template asks for `{bbox}` in `code`.
1697
+ * WMS requests move to version 1.1.1, whose `SRS` BBOX is east, north for every
1698
+ * CRS (1.3.0 swaps the axes of geographic ones), and an ArcGIS `export`
1699
+ * template has its `bboxSR`/`imageSR` set to the EPSG number.
1700
+ *
1701
+ * Args:
1702
+ * template: A raster tile template.
1703
+ * code: The map CRS's authority code, e.g. `EPSG:3031` or `IAU_2015:49918`.
1704
+ *
1705
+ * Returns:
1706
+ * The template for the CRS, the template unchanged when it already uses
1707
+ * `{bbox}`, or `null` for a template with no bounding box (an XYZ or TMS
1708
+ * template), which cannot be drawn in another CRS.
1709
+ */
1710
+ function adaptBboxTemplateToCrs(template, code) {
1711
+ if (template.includes("{bbox}")) return template;
1712
+ if (!template.includes("{bbox-epsg-3857}")) return null;
1713
+ let url = template.replace(/\{bbox-epsg-3857\}/g, "{bbox}");
1714
+ const epsg = /^EPSG:(\d+)$/i.exec(code)?.[1];
1715
+ const setParam = (name, value) => {
1716
+ const pattern = new RegExp(`([?&])${name}=[^&]*`, "i");
1717
+ url = pattern.test(url) ? url.replace(pattern, `$1${name}=${value}`) : `${url}${url.includes("?") ? "&" : "?"}${name}=${value}`;
1718
+ };
1719
+ const removeParam = (name) => {
1720
+ url = url.replace(new RegExp(`([?&])${name}=[^&]*(&?)`, "i"), (_, lead, trail) => trail ? lead : "");
1721
+ };
1722
+ if (/[?&]bboxSR=/i.test(url)) {
1723
+ if (!epsg) return null;
1724
+ setParam("bboxSR", epsg);
1725
+ setParam("imageSR", epsg);
1726
+ return url;
1727
+ }
1728
+ removeParam("CRS");
1729
+ setParam("SRS", code);
1730
+ setParam("VERSION", "1.1.1");
1731
+ return url;
1732
+ }
1733
+ /**
1734
+ * The smallest centered square grid over the part of a CRS worth mapping, for
1735
+ * an EPSG code without a published tile grid.
1736
+ *
1737
+ * Samples a 2° lng/lat lattice and keeps the points `projection` takes forward
1738
+ * and back to where they started (a transverse Mercator's far side does not)
1739
+ * whose local scale is at most {@link MAX_RELATIVE_SCALE} times the CRS's
1740
+ * smallest. A polar stereographic map round-trips almost to the other pole,
1741
+ * but out there a kilometre spans thousands of CRS units, and a grid that wide
1742
+ * leaves the CRS's own area a speck at every useful zoom. The extent of the
1743
+ * kept points is padded by 4%. MapLibre finds the edge of a world projection
1744
+ * by walking out from the grid's center, so the grid is centered on them.
1745
+ *
1746
+ * Args:
1747
+ * projection: Lng/lat to CRS and back, the shape of a proj4 converter.
1748
+ * geographic: Whether the CRS is in degrees, which gets the whole-world grid.
1749
+ *
1750
+ * Returns:
1751
+ * The grid, or `null` when no sampled point survives the round trip.
1752
+ */
1753
+ function deriveTileMatrixSet(projection, geographic) {
1754
+ if (geographic) return GEOGRAPHIC_GRID;
1755
+ const forward = (lng, lat) => {
1756
+ try {
1757
+ const [x, y] = projection.forward([lng, lat]) ?? [];
1758
+ return Number.isFinite(x) && Number.isFinite(y) && Math.abs(x) <= 1e9 && Math.abs(y) <= 1e9 ? [x, y] : null;
1759
+ } catch {
1760
+ return null;
1761
+ }
1762
+ };
1763
+ const roundTrips = (point, lng, lat) => {
1764
+ let back;
1765
+ try {
1766
+ back = projection.inverse(point);
1767
+ } catch {
1768
+ return false;
1769
+ }
1770
+ if (!back || !Number.isFinite(back[0]) || !Number.isFinite(back[1])) return false;
1771
+ const dLng = Math.abs(((back[0] - lng) % 360 + 540) % 360 - 180);
1772
+ return Math.abs(back[1] - lat) <= 1e-4 && dLng <= 1e-4;
1773
+ };
1774
+ const STEP = .01;
1775
+ const radians = STEP * Math.PI / 180;
1776
+ const samples = [];
1777
+ for (let lng = -180; lng <= 180; lng += 2) for (let lat = -88; lat <= 88; lat += 2) {
1778
+ const point = forward(lng, lat);
1779
+ if (!point || !roundTrips(point, lng, lat)) continue;
1780
+ const north = forward(lng, lat + STEP);
1781
+ const east = forward(lng + STEP, lat);
1782
+ if (!north || !east) continue;
1783
+ const scale = Math.max(Math.hypot(north[0] - point[0], north[1] - point[1]) / radians, Math.hypot(east[0] - point[0], east[1] - point[1]) / (radians * Math.cos(lat * Math.PI / 180)));
1784
+ if (scale > 0 && Number.isFinite(scale)) samples.push({
1785
+ x: point[0],
1786
+ y: point[1],
1787
+ scale
1788
+ });
1789
+ }
1790
+ if (samples.length === 0) return null;
1791
+ const limit = MAX_RELATIVE_SCALE * Math.min(...samples.map((sample) => sample.scale));
1792
+ let minX = Infinity;
1793
+ let minY = Infinity;
1794
+ let maxX = -Infinity;
1795
+ let maxY = -Infinity;
1796
+ for (const { x, y, scale } of samples) {
1797
+ if (scale > limit) continue;
1798
+ minX = Math.min(minX, x);
1799
+ minY = Math.min(minY, y);
1800
+ maxX = Math.max(maxX, x);
1801
+ maxY = Math.max(maxY, y);
1802
+ }
1803
+ const side = Math.max(maxX - minX, maxY - minY) * 1.04;
1804
+ if (!(side > 0)) return null;
1805
+ return {
1806
+ origin: [(minX + maxX - side) / 2, (minY + maxY + side) / 2],
1807
+ extentAtZoom0: side
1808
+ };
1426
1809
  }
1427
- //#endregion
1428
- //#region src/geojson-z.ts
1810
+ /**
1811
+ * How much more a derived grid's edge may stretch distances than the CRS's
1812
+ * least distorted place: about 11°N for an Antarctic polar stereographic map,
1813
+ * and some 65° either side of a transverse Mercator's central meridian.
1814
+ */
1815
+ const MAX_RELATIVE_SCALE = 2.5;
1816
+ // src/geojson-z.ts
1429
1817
  /**
1430
1818
  * Helpers for vector data whose coordinates carry a Z value (elevation), e.g.
1431
1819
  * GPX tracks with `<ele>` readings or LineStringZ/PointZ geometries. MapLibre's
@@ -1564,8 +1952,7 @@ function mapGeometryPositions(geometry, mapPosition) {
1564
1952
  default: return geometry;
1565
1953
  }
1566
1954
  }
1567
- //#endregion
1568
- //#region src/polyline.ts
1955
+ // src/polyline.ts
1569
1956
  /**
1570
1957
  * Pure TypeScript Google Encoded Polyline algorithm codec.
1571
1958
  *
@@ -1827,8 +2214,176 @@ function batchDecodePolylines(input, options = {}) {
1827
2214
  features
1828
2215
  };
1829
2216
  }
1830
- //#endregion
1831
- //#region src/selection.ts
2217
+ // src/runtime-env.ts
2218
+ const buildEnv = typeof __GEOLIBRE_BUILD_ENV__ === "undefined" ? {} : __GEOLIBRE_BUILD_ENV__;
2219
+ /**
2220
+ * The allowlisted build-time environment, with no runtime overlay.
2221
+ *
2222
+ * For settings that must be fixed by the build and NOT overridable by a
2223
+ * `.geolibre.json` a user opened — the auth gate, the deployment profile, the
2224
+ * embed origin allowlist. Routing those through {@link getRuntimeEnvironment}
2225
+ * would let project-supplied `__GEOLIBRE_RUNTIME_ENV__` values relax them.
2226
+ *
2227
+ * Callers should prefer this over reading `import.meta.env` directly: a
2228
+ * whole-object read defeats Vite's per-key replacement and inlines every
2229
+ * `VITE_` var on the build machine into the chunk.
2230
+ *
2231
+ * @returns The build-time environment record.
2232
+ */
2233
+ function getBuildEnvironment() {
2234
+ return buildEnv;
2235
+ }
2236
+ /**
2237
+ * Merges build-time env with project runtime env (the latter wins). Falls back
2238
+ * to build-time env alone outside a browser (e.g. in tests).
2239
+ *
2240
+ * @returns The resolved environment variables.
2241
+ */
2242
+ function getRuntimeEnvironment() {
2243
+ if (typeof window === "undefined") return buildEnv;
2244
+ return {
2245
+ ...buildEnv,
2246
+ ...window.__GEOLIBRE_RUNTIME_ENV__ ?? {}
2247
+ };
2248
+ }
2249
+ /**
2250
+ * Resolves a local DuckDB spatial extension path from the runtime environment.
2251
+ *
2252
+ * When `VITE_DUCKDB_SPATIAL_EXTENSION_PATH` is set, DuckDB consumers (the
2253
+ * desktop app's own loader and the Add Vector panel's maplibre-gl-vector
2254
+ * control) load the spatial extension from this path with `LOAD '<path>'`
2255
+ * instead of installing it from the remote repository, which hangs in
2256
+ * sandboxed or firewalled environments. Lives in `@geolibre/core` so every
2257
+ * consumer shares one implementation.
2258
+ *
2259
+ * @param env - Environment record (defaults to the runtime environment);
2260
+ * injectable for testing.
2261
+ * @returns The trimmed extension path, or undefined when unset.
2262
+ */
2263
+ function getSpatialExtensionPath(env) {
2264
+ return (env ?? getRuntimeEnvironment()).VITE_DUCKDB_SPATIAL_EXTENSION_PATH?.trim() || void 0;
2265
+ }
2266
+ /**
2267
+ * Resolves the Protomaps API key from the runtime environment.
2268
+ *
2269
+ * Protomaps' hosted styles require an API key embedded in the style URL. The
2270
+ * key is supplied via `VITE_PROTOMAPS_API_KEY` (baked in at build time for the
2271
+ * web demo; see the deploy workflow). When unset, the Protomaps basemaps are
2272
+ * unavailable and should be hidden from the UI.
2273
+ *
2274
+ * @param env - Environment record (defaults to the runtime environment);
2275
+ * injectable for testing.
2276
+ * @returns The trimmed API key, or undefined when unset.
2277
+ */
2278
+ function getProtomapsApiKey(env) {
2279
+ return (env ?? getRuntimeEnvironment()).VITE_PROTOMAPS_API_KEY?.trim() || void 0;
2280
+ }
2281
+ /**
2282
+ * Resolves the Google Maps API key from the runtime environment.
2283
+ *
2284
+ * GeoLibre's browser-facing builds normally use `VITE_GOOGLE_MAPS_API_KEY`.
2285
+ * The bare `GOOGLE_MAPS_API_KEY` fallback is reached two ways: (1) the desktop
2286
+ * Vite config copies it to `VITE_GOOGLE_MAPS_API_KEY` at build time for local
2287
+ * shell testing (`vite.config.ts`'s `envPrefix` does not include the bare
2288
+ * name), and (2) a project's own runtime environment variables
2289
+ * (`window.__GEOLIBRE_RUNTIME_ENV__`), which are not subject to Vite's
2290
+ * envPrefix allowlist at all.
2291
+ *
2292
+ * @param env - Environment record (defaults to the runtime environment);
2293
+ * injectable for testing.
2294
+ * @returns The trimmed API key, or undefined when unset.
2295
+ */
2296
+ function getGoogleMapsApiKey(env) {
2297
+ const runtimeEnv = env ?? getRuntimeEnvironment();
2298
+ return runtimeEnv.VITE_GOOGLE_MAPS_API_KEY?.trim() || runtimeEnv.GOOGLE_MAPS_API_KEY?.trim() || void 0;
2299
+ }
2300
+ /**
2301
+ * Resolves the Mapbox access token from the runtime environment.
2302
+ *
2303
+ * The basemap control's Mapbox styles authenticate with the user's own token.
2304
+ * It is supplied via `VITE_MAPBOX_ACCESS_TOKEN` (baked in at build time from the
2305
+ * bare `MAPBOX_TOKEN` env var, which `vite.config.ts` copies into the prefixed
2306
+ * name — the spelling Mapbox's own tooling uses) or set at runtime through
2307
+ * Settings → Environment variables (`window.__GEOLIBRE_RUNTIME_ENV__`, which
2308
+ * bypasses Vite's envPrefix allowlist, so a bare `MAPBOX_TOKEN` entry works
2309
+ * there too). When unset, the Mapbox basemaps prompt for a token in the basemap
2310
+ * panel's API keys view instead.
2311
+ *
2312
+ * Note the precedence, shared with {@link getGoogleMapsApiKey} and
2313
+ * {@link getCesiumIonToken}: the prefixed name always wins over the bare one,
2314
+ * and `getRuntimeEnvironment` merges build-time and runtime vars into one
2315
+ * record. So a build that baked in `VITE_MAPBOX_ACCESS_TOKEN` is overridden at
2316
+ * runtime by a Settings entry under that *same* prefixed name; a bare
2317
+ * `MAPBOX_TOKEN` entry is a fallback for when nothing was baked in, not a way
2318
+ * to override a baked token.
2319
+ *
2320
+ * @param env - Environment record (defaults to the runtime environment);
2321
+ * injectable for testing.
2322
+ * @returns The trimmed token, or undefined when unset.
2323
+ */
2324
+ function getMapboxAccessToken(env) {
2325
+ const runtimeEnv = env ?? getRuntimeEnvironment();
2326
+ return runtimeEnv.VITE_MAPBOX_ACCESS_TOKEN?.trim() || runtimeEnv.MAPBOX_TOKEN?.trim() || void 0;
2327
+ }
2328
+ /**
2329
+ * Resolves the ArcGIS API key from the runtime environment.
2330
+ *
2331
+ * The ArcGIS renderer draws the translated project basemap and non-Esri layers
2332
+ * without a key; Esri's basemap styles and location services require one
2333
+ * (an ArcGIS Location Platform or ArcGIS Online API key credential). It is
2334
+ * supplied via `VITE_ARCGIS_API_KEY` (baked in at build time from the bare
2335
+ * `ARCGIS_API_KEY` env var, which `vite.config.ts` copies into the prefixed
2336
+ * name) or set at runtime through Settings → Environment variables
2337
+ * (`window.__GEOLIBRE_RUNTIME_ENV__`, so a bare `ARCGIS_API_KEY` entry works
2338
+ * there too). Same precedence as {@link getMapboxAccessToken}.
2339
+ *
2340
+ * @param env - Environment record (defaults to the runtime environment);
2341
+ * injectable for testing.
2342
+ * @returns The trimmed key, or undefined when unset.
2343
+ */
2344
+ function getArcgisApiKey(env) {
2345
+ const runtimeEnv = env ?? getRuntimeEnvironment();
2346
+ return runtimeEnv.VITE_ARCGIS_API_KEY?.trim() || runtimeEnv.ARCGIS_API_KEY?.trim() || void 0;
2347
+ }
2348
+ /**
2349
+ * Resolves the Cesium Ion access token from the runtime environment.
2350
+ *
2351
+ * Cesium World Terrain and Ion World Imagery need a Cesium Ion token. It is
2352
+ * supplied via `VITE_CESIUM_TOKEN` (baked in at build time from the bare
2353
+ * `CESIUM_TOKEN` env var, which `vite.config.ts` copies into the prefixed name)
2354
+ * or set at runtime through Settings → Environment variables
2355
+ * (`window.__GEOLIBRE_RUNTIME_ENV__`, which bypasses Vite's envPrefix allowlist,
2356
+ * so a bare `CESIUM_TOKEN` entry works there too).
2357
+ *
2358
+ * The 3D-globe view itself does **not** need one: it draws the project basemap
2359
+ * as its base imagery whenever that basemap has a raster form (see
2360
+ * {@link basemapToCesiumImagery}), and falls back to Ion World Imagery — or, with
2361
+ * no token, to OpenStreetMap — for the ones that do not. So an absent token
2362
+ * costs terrain and the quality of that fallback, not the view.
2363
+ *
2364
+ * @param env - Environment record (defaults to the runtime environment);
2365
+ * injectable for testing.
2366
+ * @returns The trimmed token, or undefined when unset.
2367
+ */
2368
+ function getCesiumIonToken(env) {
2369
+ const runtimeEnv = env ?? getRuntimeEnvironment();
2370
+ return runtimeEnv.VITE_CESIUM_TOKEN?.trim() || runtimeEnv.CESIUM_TOKEN?.trim() || void 0;
2371
+ }
2372
+ /**
2373
+ * Builds a full Protomaps v5 style URL for a flavor, injecting the API key.
2374
+ *
2375
+ * @param flavor - The Protomaps flavor name (e.g. `light`, `dark`, `white`,
2376
+ * `grayscale`, `black`).
2377
+ * @param env - Environment record (defaults to the runtime environment);
2378
+ * injectable for testing.
2379
+ * @returns The resolved style URL, or undefined when no API key is configured.
2380
+ */
2381
+ function getProtomapsStyleUrl(flavor, env) {
2382
+ const key = getProtomapsApiKey(env);
2383
+ if (!key) return void 0;
2384
+ return `https://api.protomaps.com/styles/v5/${encodeURIComponent(flavor)}/en.json?key=${encodeURIComponent(key)}`;
2385
+ }
2386
+ // src/selection.ts
1832
2387
  /** Selection modes in display order. */
1833
2388
  const SELECTION_MODES = [
1834
2389
  "new",
@@ -1873,8 +2428,7 @@ function invertSelection(allIds, current) {
1873
2428
  const selected = new Set(current);
1874
2429
  return allIds.filter((id) => !selected.has(id));
1875
2430
  }
1876
- //#endregion
1877
- //#region src/expressions.ts
2431
+ // src/expressions.ts
1878
2432
  const fn = (name, snippet, docKey) => ({
1879
2433
  name,
1880
2434
  snippet,
@@ -2275,8 +2829,7 @@ function inferFieldTypes(features, names) {
2275
2829
  }
2276
2830
  return result;
2277
2831
  }
2278
- //#endregion
2279
- //#region src/layer-descriptive-metadata.ts
2832
+ // src/layer-descriptive-metadata.ts
2280
2833
  /** STAC specification version the Item export targets. */
2281
2834
  const STAC_VERSION = "1.0.0";
2282
2835
  /** Schema URL of the STAC processing extension, declared for `processing:lineage`. */
@@ -2698,8 +3251,7 @@ function buildLayerStacItem(layer, options = {}) {
2698
3251
  assets
2699
3252
  };
2700
3253
  }
2701
- //#endregion
2702
- //#region src/virtual-fields.ts
3254
+ // src/virtual-fields.ts
2703
3255
  /**
2704
3256
  * Virtual fields (QGIS Field Calculator → "Create virtual field", issue
2705
3257
  * #1321): expression-backed columns that recompute live instead of being
@@ -2833,8 +3385,7 @@ function applyLayerVirtualFields(features, fields) {
2833
3385
  })
2834
3386
  };
2835
3387
  }
2836
- //#endregion
2837
- //#region src/joins.ts
3388
+ // src/joins.ts
2838
3389
  /**
2839
3390
  * Persistent attribute joins (QGIS Layer Properties → Joins), issue #1315.
2840
3391
  *
@@ -3040,8 +3591,9 @@ function applyJoinsToLayer(layer, allLayers, nextJoins, nextVirtualFields) {
3040
3591
  return allLayers.find((candidate) => candidate.id === joinLayerId)?.geojson;
3041
3592
  });
3042
3593
  const withVirtual = applyLayerVirtualFields(applied.features, virtualFields);
3594
+ const { geoarrow: _staleGeoarrow, ...rest } = layer;
3043
3595
  return {
3044
- ...layer,
3596
+ ...rest,
3045
3597
  geojson: {
3046
3598
  ...geojson,
3047
3599
  features: withVirtual.features
@@ -3171,8 +3723,7 @@ function reapplyLayerJoins(layers) {
3171
3723
  for (const id of topologicalJoinOrder(joined)) current = current.map((layer) => layer.id === id ? applyJoinsToLayer(layer, current) : layer);
3172
3724
  return current;
3173
3725
  }
3174
- //#endregion
3175
- //#region src/capabilities.ts
3726
+ // src/capabilities.ts
3176
3727
  /**
3177
3728
  * Default inferred capabilities for a layer based on its type and metadata.
3178
3729
  *
@@ -3361,8 +3912,7 @@ function normalizeAppPrivileges(raw) {
3361
3912
  for (const item of raw) if (typeof item === "string" && validSet.has(item)) result.push(item);
3362
3913
  return result.length > 0 ? [...new Set(result)] : [];
3363
3914
  }
3364
- //#endregion
3365
- //#region src/layer-groups.ts
3915
+ // src/layer-groups.ts
3366
3916
  /** Opacity a freshly created {@link LayerGroup} starts at (fully opaque). */
3367
3917
  const DEFAULT_LAYER_GROUP_OPACITY = 1;
3368
3918
  /**
@@ -3980,8 +4530,7 @@ function layerGroupSortability(layers, groups, locale) {
3980
4530
  });
3981
4531
  return result;
3982
4532
  }
3983
- //#endregion
3984
- //#region src/layer-ref-scrub.ts
4533
+ // src/layer-ref-scrub.ts
3985
4534
  /**
3986
4535
  * Normalize the removed-layer-ids argument into a Set for uniform lookup.
3987
4536
  */
@@ -4052,8 +4601,7 @@ function scrubLegendForRemovedLayers(legend, layerIds) {
4052
4601
  customEntries
4053
4602
  };
4054
4603
  }
4055
- //#endregion
4056
- //#region src/print-layout-config.ts
4604
+ // src/print-layout-config.ts
4057
4605
  /**
4058
4606
  * Print Layout composer settings that belong to a project.
4059
4607
  *
@@ -4447,8 +4995,7 @@ function scrubPrintLayoutForRemovedLayers(config, layerIds) {
4447
4995
  if (removed.size === 0) return config;
4448
4996
  return clearBlocksForMissingLayers(config, (id) => removed.has(id));
4449
4997
  }
4450
- //#endregion
4451
- //#region src/style-library.ts
4998
+ // src/style-library.ts
4452
4999
  /**
4453
5000
  * Layer types whose symbology is driven by the vector {@link LayerStyle}
4454
5001
  * fields and can therefore receive a Style Manager entry (or a style-file
@@ -4941,8 +5488,405 @@ const BUILT_IN_STYLE_PRESETS = [
4941
5488
  updatedAt: ""
4942
5489
  }
4943
5490
  ];
4944
- //#endregion
4945
- //#region src/czml.ts
5491
+ // src/raster-source-defaults.ts
5492
+ /**
5493
+ * Older projects and manually added Wayback templates may omit the native
5494
+ * zoom limit. Cap their requests at level 19 so renderers overzoom available
5495
+ * imagery instead of requesting missing higher-level tiles (issue #3116).
5496
+ * Explicit numeric limits remain authoritative, including lower regional limits.
5497
+ * Renderers ignore string limits, so only numeric minzoom can constrain this default.
5498
+ */
5499
+ function withRasterSourceDefaults(layer) {
5500
+ if (![
5501
+ "raster",
5502
+ "xyz",
5503
+ "wmts"
5504
+ ].includes(layer.type) || !layer.source || typeof layer.source.maxzoom === "number" && Number.isFinite(layer.source.maxzoom) || typeof layer.source.minzoom === "number" && layer.source.minzoom > 19) return layer;
5505
+ if (!(Array.isArray(layer.source.tiles) && layer.source.tiles.length ? layer.source.tiles : [layer.source.url]).every(isWaybackTileUrl)) return layer;
5506
+ return {
5507
+ ...layer,
5508
+ source: {
5509
+ ...layer.source,
5510
+ maxzoom: 19
5511
+ }
5512
+ };
5513
+ }
5514
+ function isWaybackTileUrl(value) {
5515
+ if (typeof value !== "string") return false;
5516
+ try {
5517
+ const url = new URL(value);
5518
+ return (url.protocol === "https:" || url.protocol === "http:") && /^wayback(?:-[a-z])?\.maptiles\.arcgis\.com$/i.test(url.hostname) && /^\/arcgis\/rest\/services\/World_Imagery\/(?:WMTS\/1\.0\.0\/[^/]+\/)?MapServer\/tile\/\d+\//i.test(url.pathname);
5519
+ } catch {
5520
+ return false;
5521
+ }
5522
+ }
5523
+ // src/geoarrow.ts
5524
+ /**
5525
+ * Type guard for {@link GeoArrowBatch}.
5526
+ * Validates structure and basic buffer invariants without deep scanning.
5527
+ */
5528
+ function isGeoArrowBatch(value) {
5529
+ if (!value || typeof value !== "object") return false;
5530
+ const candidate = value;
5531
+ if (![
5532
+ "Point",
5533
+ "MultiPoint",
5534
+ "LineString",
5535
+ "MultiLineString",
5536
+ "Polygon",
5537
+ "MultiPolygon"
5538
+ ].includes(candidate.geometryType ?? "")) return false;
5539
+ if (!(candidate.coordinates instanceof Float64Array)) return false;
5540
+ if (candidate.coordDimensions !== 2 && candidate.coordDimensions !== 3) return false;
5541
+ if (!Number.isSafeInteger(candidate.featureCount) || candidate.featureCount < 0) return false;
5542
+ if (candidate.coordinates.length % candidate.coordDimensions !== 0) return false;
5543
+ if (candidate.geomOffsets !== void 0 && !(candidate.geomOffsets instanceof Int32Array)) return false;
5544
+ if (candidate.ringOffsets !== void 0 && !(candidate.ringOffsets instanceof Int32Array)) return false;
5545
+ if (candidate.polygonOffsets !== void 0 && !(candidate.polygonOffsets instanceof Int32Array)) return false;
5546
+ const vertexCount = candidate.coordinates.length / candidate.coordDimensions;
5547
+ const validOffsets = (offsets, length, end) => {
5548
+ if (!offsets || offsets.length !== length || offsets[0] !== 0) return false;
5549
+ for (let i = 1; i < offsets.length; i++) if (offsets[i] < offsets[i - 1] || offsets[i] > end) return false;
5550
+ return offsets[offsets.length - 1] === end;
5551
+ };
5552
+ const count = candidate.featureCount;
5553
+ if (candidate.geometryType === "Point") {
5554
+ if (vertexCount !== count) return false;
5555
+ } else if (candidate.geometryType === "LineString" || candidate.geometryType === "MultiPoint") {
5556
+ if (!validOffsets(candidate.geomOffsets, count + 1, vertexCount)) return false;
5557
+ } else {
5558
+ const rings = (candidate.ringOffsets?.length ?? 0) - 1;
5559
+ if (!validOffsets(candidate.ringOffsets, rings + 1, vertexCount)) return false;
5560
+ if (candidate.geometryType === "MultiPolygon") {
5561
+ const polygons = (candidate.geomOffsets?.length ?? 0) - 1;
5562
+ if (!validOffsets(candidate.geomOffsets, polygons + 1, rings)) return false;
5563
+ if (!validOffsets(candidate.polygonOffsets, count + 1, polygons)) return false;
5564
+ } else if (!validOffsets(candidate.geomOffsets, count + 1, rings)) return false;
5565
+ }
5566
+ if (candidate.properties !== void 0 && (!Array.isArray(candidate.properties) || candidate.properties.length !== count)) return false;
5567
+ if (candidate.featureIds !== void 0 && (!Array.isArray(candidate.featureIds) || candidate.featureIds.length !== count)) return false;
5568
+ return true;
5569
+ }
5570
+ /**
5571
+ * Computes a 2D bounding box [minX, minY, maxX, maxY] for a {@link GeoArrowBatch}
5572
+ * in a single fast, vectorized pass over the flat coordinates buffer.
5573
+ * Returns null if the batch contains no finite coordinates.
5574
+ */
5575
+ function computeGeoArrowBBox(batch) {
5576
+ if (batch.bbox) return [...batch.bbox];
5577
+ const { coordinates, coordDimensions } = batch;
5578
+ if (coordinates.length === 0) return null;
5579
+ let minX = Infinity;
5580
+ let minY = Infinity;
5581
+ let maxX = -Infinity;
5582
+ let maxY = -Infinity;
5583
+ const step = coordDimensions;
5584
+ for (let i = 0; i < coordinates.length; i += step) {
5585
+ const x = coordinates[i];
5586
+ const y = coordinates[i + 1];
5587
+ if (Number.isFinite(x) && Number.isFinite(y)) {
5588
+ if (x < minX) minX = x;
5589
+ if (y < minY) minY = y;
5590
+ if (x > maxX) maxX = x;
5591
+ if (y > maxY) maxY = y;
5592
+ }
5593
+ }
5594
+ return minX === Infinity ? null : [
5595
+ minX,
5596
+ minY,
5597
+ maxX,
5598
+ maxY
5599
+ ];
5600
+ }
5601
+ /**
5602
+ * Convert a {@link GeoArrowBatch} to a standard GeoJSON {@link FeatureCollection}.
5603
+ * Lossless round-trip converter for renderers or components (Cesium, Attribute Table, Turf)
5604
+ * that expect standard GeoJSON objects.
5605
+ */
5606
+ function geoArrowToFeatureCollection(batch) {
5607
+ const { geometryType, coordinates, coordDimensions: dim, geomOffsets, ringOffsets, polygonOffsets, featureCount, properties, featureIds } = batch;
5608
+ const features = new Array(featureCount);
5609
+ function readPosition(vertexIdx) {
5610
+ const o = vertexIdx * dim;
5611
+ return dim === 3 ? [
5612
+ coordinates[o],
5613
+ coordinates[o + 1],
5614
+ coordinates[o + 2]
5615
+ ] : [coordinates[o], coordinates[o + 1]];
5616
+ }
5617
+ function readLine(startVertex, endVertex) {
5618
+ const count = endVertex - startVertex;
5619
+ const line = new Array(count);
5620
+ for (let i = 0; i < count; i++) line[i] = readPosition(startVertex + i);
5621
+ return line;
5622
+ }
5623
+ for (let i = 0; i < featureCount; i++) {
5624
+ const props = properties ? { ...properties[i] } : {};
5625
+ const id = featureIds ? featureIds[i] : void 0;
5626
+ let geometry = null;
5627
+ switch (geometryType) {
5628
+ case "Point": {
5629
+ const o = i * dim;
5630
+ if (o + 1 < coordinates.length && Number.isFinite(coordinates[o]) && Number.isFinite(coordinates[o + 1])) geometry = {
5631
+ type: "Point",
5632
+ coordinates: readPosition(i)
5633
+ };
5634
+ break;
5635
+ }
5636
+ case "MultiPoint":
5637
+ if (geomOffsets) {
5638
+ const start = geomOffsets[i];
5639
+ const end = geomOffsets[i + 1];
5640
+ if (end > start) geometry = {
5641
+ type: "MultiPoint",
5642
+ coordinates: readLine(start, end)
5643
+ };
5644
+ }
5645
+ break;
5646
+ case "LineString":
5647
+ if (geomOffsets) {
5648
+ const start = geomOffsets[i];
5649
+ const end = geomOffsets[i + 1];
5650
+ if (end > start) geometry = {
5651
+ type: "LineString",
5652
+ coordinates: readLine(start, end)
5653
+ };
5654
+ }
5655
+ break;
5656
+ case "MultiLineString":
5657
+ if (geomOffsets && ringOffsets) {
5658
+ const startLine = geomOffsets[i];
5659
+ const endLine = geomOffsets[i + 1];
5660
+ const lines = [];
5661
+ for (let l = startLine; l < endLine; l++) {
5662
+ const vStart = ringOffsets[l];
5663
+ const vEnd = ringOffsets[l + 1];
5664
+ if (vEnd > vStart) lines.push(readLine(vStart, vEnd));
5665
+ }
5666
+ if (lines.length > 0) geometry = {
5667
+ type: "MultiLineString",
5668
+ coordinates: lines
5669
+ };
5670
+ }
5671
+ break;
5672
+ case "Polygon":
5673
+ if (geomOffsets && ringOffsets) {
5674
+ const startRing = geomOffsets[i];
5675
+ const endRing = geomOffsets[i + 1];
5676
+ const rings = [];
5677
+ for (let r = startRing; r < endRing; r++) {
5678
+ const vStart = ringOffsets[r];
5679
+ const vEnd = ringOffsets[r + 1];
5680
+ if (vEnd > vStart) rings.push(readLine(vStart, vEnd));
5681
+ }
5682
+ if (rings.length > 0) geometry = {
5683
+ type: "Polygon",
5684
+ coordinates: rings
5685
+ };
5686
+ }
5687
+ break;
5688
+ case "MultiPolygon": if (polygonOffsets && geomOffsets && ringOffsets) {
5689
+ const startPoly = polygonOffsets[i];
5690
+ const endPoly = polygonOffsets[i + 1];
5691
+ const polys = [];
5692
+ for (let p = startPoly; p < endPoly; p++) {
5693
+ const startRing = geomOffsets[p];
5694
+ const endRing = geomOffsets[p + 1];
5695
+ const rings = [];
5696
+ for (let r = startRing; r < endRing; r++) {
5697
+ const vStart = ringOffsets[r];
5698
+ const vEnd = ringOffsets[r + 1];
5699
+ if (vEnd > vStart) rings.push(readLine(vStart, vEnd));
5700
+ }
5701
+ if (rings.length > 0) polys.push(rings);
5702
+ }
5703
+ if (polys.length > 0) geometry = {
5704
+ type: "MultiPolygon",
5705
+ coordinates: polys
5706
+ };
5707
+ }
5708
+ }
5709
+ features[i] = {
5710
+ type: "Feature",
5711
+ id,
5712
+ properties: props,
5713
+ geometry
5714
+ };
5715
+ }
5716
+ return {
5717
+ type: "FeatureCollection",
5718
+ features
5719
+ };
5720
+ }
5721
+ function extractVerticesForLineOrMultiPoint(geometry, primaryType) {
5722
+ if (!geometry) return null;
5723
+ if (primaryType === "LineString") {
5724
+ if (geometry.type === "LineString" && Array.isArray(geometry.coordinates)) return geometry.coordinates;
5725
+ return null;
5726
+ }
5727
+ if (primaryType === "MultiPoint") {
5728
+ if (geometry.type === "MultiPoint" && Array.isArray(geometry.coordinates)) return geometry.coordinates;
5729
+ if (geometry.type === "Point" && Array.isArray(geometry.coordinates)) return [geometry.coordinates];
5730
+ return null;
5731
+ }
5732
+ return null;
5733
+ }
5734
+ /**
5735
+ * Flatten supported Point, MultiPoint, LineString and Polygon collections.
5736
+ * Empty collections produce an empty Point batch; unsupported geometries return null.
5737
+ */
5738
+ function featureCollectionToGeoArrow(collection) {
5739
+ const { features } = collection;
5740
+ if (!features || features.length === 0) return {
5741
+ geometryType: "Point",
5742
+ coordinates: /* @__PURE__ */ new Float64Array(0),
5743
+ coordDimensions: 2,
5744
+ featureCount: 0,
5745
+ properties: []
5746
+ };
5747
+ let primaryType = null;
5748
+ let hasZ = false;
5749
+ for (const f of features) {
5750
+ const g = f.geometry;
5751
+ if (!g) continue;
5752
+ if (primaryType === null) {
5753
+ if (g.type === "Point" || g.type === "MultiPoint" || g.type === "LineString" || g.type === "Polygon") primaryType = g.type;
5754
+ else return null;
5755
+ } else if (g.type !== primaryType) {
5756
+ if (primaryType === "Point" && g.type === "MultiPoint" || primaryType === "MultiPoint" && g.type === "Point") primaryType = "MultiPoint";
5757
+ else return null;
5758
+ }
5759
+ if (!hasZ) {
5760
+ const positionHasZ = (position) => position.length > 2 && Number.isFinite(position[2]);
5761
+ if (g.type === "Point") hasZ = positionHasZ(g.coordinates);
5762
+ else if (g.type === "Polygon") hasZ = g.coordinates.some((ring) => ring.some(positionHasZ));
5763
+ else if (g.type === "LineString" || g.type === "MultiPoint") hasZ = g.coordinates.some(positionHasZ);
5764
+ }
5765
+ }
5766
+ if (!primaryType) primaryType = "Point";
5767
+ const dim = hasZ ? 3 : 2;
5768
+ const featureCount = features.length;
5769
+ const properties = new Array(featureCount);
5770
+ const featureIds = new Array(featureCount);
5771
+ for (let i = 0; i < featureCount; i++) {
5772
+ properties[i] = features[i].properties ?? {};
5773
+ featureIds[i] = features[i].id;
5774
+ }
5775
+ if (primaryType === "Point") {
5776
+ const coordinates = new Float64Array(featureCount * dim);
5777
+ for (let i = 0; i < featureCount; i++) {
5778
+ const g = features[i].geometry;
5779
+ const o = i * dim;
5780
+ if (g && g.type === "Point" && Array.isArray(g.coordinates)) {
5781
+ coordinates[o] = Number(g.coordinates[0] ?? NaN);
5782
+ coordinates[o + 1] = Number(g.coordinates[1] ?? NaN);
5783
+ if (dim === 3) coordinates[o + 2] = Number(g.coordinates[2] ?? 0);
5784
+ } else {
5785
+ coordinates[o] = NaN;
5786
+ coordinates[o + 1] = NaN;
5787
+ if (dim === 3) coordinates[o + 2] = NaN;
5788
+ }
5789
+ }
5790
+ const batch = {
5791
+ geometryType: "Point",
5792
+ coordinates,
5793
+ coordDimensions: dim,
5794
+ featureCount,
5795
+ properties,
5796
+ featureIds
5797
+ };
5798
+ const bbox = computeGeoArrowBBox(batch);
5799
+ return bbox ? {
5800
+ ...batch,
5801
+ bbox
5802
+ } : batch;
5803
+ }
5804
+ if (primaryType === "LineString" || primaryType === "MultiPoint") {
5805
+ const geomOffsets = new Int32Array(featureCount + 1);
5806
+ let totalVertices = 0;
5807
+ for (let i = 0; i < featureCount; i++) {
5808
+ geomOffsets[i] = totalVertices;
5809
+ const vertices = extractVerticesForLineOrMultiPoint(features[i].geometry, primaryType);
5810
+ if (vertices) totalVertices += vertices.length;
5811
+ }
5812
+ geomOffsets[featureCount] = totalVertices;
5813
+ const coordinates = new Float64Array(totalVertices * dim);
5814
+ let vIdx = 0;
5815
+ for (let i = 0; i < featureCount; i++) {
5816
+ const vertices = extractVerticesForLineOrMultiPoint(features[i].geometry, primaryType);
5817
+ if (vertices) for (const coord of vertices) {
5818
+ const o = vIdx * dim;
5819
+ coordinates[o] = Number(coord[0] ?? NaN);
5820
+ coordinates[o + 1] = Number(coord[1] ?? NaN);
5821
+ if (dim === 3) coordinates[o + 2] = Number(coord[2] ?? 0);
5822
+ vIdx++;
5823
+ }
5824
+ }
5825
+ const batch = {
5826
+ geometryType: primaryType,
5827
+ coordinates,
5828
+ coordDimensions: dim,
5829
+ geomOffsets,
5830
+ featureCount,
5831
+ properties,
5832
+ featureIds
5833
+ };
5834
+ const bbox = computeGeoArrowBBox(batch);
5835
+ return bbox ? {
5836
+ ...batch,
5837
+ bbox
5838
+ } : batch;
5839
+ }
5840
+ if (primaryType === "Polygon") {
5841
+ const geomOffsets = new Int32Array(featureCount + 1);
5842
+ let totalRings = 0;
5843
+ let totalVertices = 0;
5844
+ for (let i = 0; i < featureCount; i++) {
5845
+ geomOffsets[i] = totalRings;
5846
+ const g = features[i].geometry;
5847
+ if (g && g.type === "Polygon" && Array.isArray(g.coordinates)) {
5848
+ totalRings += g.coordinates.length;
5849
+ for (const ring of g.coordinates) totalVertices += ring.length;
5850
+ }
5851
+ }
5852
+ geomOffsets[featureCount] = totalRings;
5853
+ const ringOffsets = new Int32Array(totalRings + 1);
5854
+ const coordinates = new Float64Array(totalVertices * dim);
5855
+ let rIdx = 0;
5856
+ let vIdx = 0;
5857
+ for (let i = 0; i < featureCount; i++) {
5858
+ const g = features[i].geometry;
5859
+ if (g && g.type === "Polygon" && Array.isArray(g.coordinates)) for (const ring of g.coordinates) {
5860
+ ringOffsets[rIdx++] = vIdx;
5861
+ for (const coord of ring) {
5862
+ const o = vIdx * dim;
5863
+ coordinates[o] = Number(coord[0] ?? NaN);
5864
+ coordinates[o + 1] = Number(coord[1] ?? NaN);
5865
+ if (dim === 3) coordinates[o + 2] = Number(coord[2] ?? 0);
5866
+ vIdx++;
5867
+ }
5868
+ }
5869
+ }
5870
+ ringOffsets[totalRings] = vIdx;
5871
+ const batch = {
5872
+ geometryType: "Polygon",
5873
+ coordinates,
5874
+ coordDimensions: dim,
5875
+ geomOffsets,
5876
+ ringOffsets,
5877
+ featureCount,
5878
+ properties,
5879
+ featureIds
5880
+ };
5881
+ const bbox = computeGeoArrowBBox(batch);
5882
+ return bbox ? {
5883
+ ...batch,
5884
+ bbox
5885
+ } : batch;
5886
+ }
5887
+ return null;
5888
+ }
5889
+ // src/czml.ts
4946
5890
  /**
4947
5891
  * CZML (Cesium Language) domain models and layer authoring (issue #2290).
4948
5892
  *
@@ -5127,8 +6071,7 @@ function createCzmlLayer(options) {
5127
6071
  }
5128
6072
  };
5129
6073
  }
5130
- //#endregion
5131
- //#region src/cesium-kml.ts
6074
+ // src/cesium-kml.ts
5132
6075
  /** Native KML/KMZ documents retain their own styling on the globe. */
5133
6076
  const CESIUM_KML_SOURCE_KIND = "cesium-kml";
5134
6077
  function isCesiumKmlLayer(layer) {
@@ -5164,8 +6107,7 @@ function createCesiumKmlLayer(options) {
5164
6107
  }
5165
6108
  };
5166
6109
  }
5167
- //#endregion
5168
- //#region src/cesium-ion.ts
6110
+ // src/cesium-ion.ts
5169
6111
  /** `metadata.sourceKind` of a layer that references a Cesium Ion asset. */
5170
6112
  const CESIUM_ION_SOURCE_KIND = "cesium-ion";
5171
6113
  /** Cesium OSM Buildings, the one-click 3D Tiles asset every Ion account can use. */
@@ -5306,8 +6248,7 @@ function createCesiumIonLayer(options) {
5306
6248
  }
5307
6249
  };
5308
6250
  }
5309
- //#endregion
5310
- //#region src/header-references.ts
6251
+ // src/header-references.ts
5311
6252
  /**
5312
6253
  * `${NAME}` references in request header values.
5313
6254
  *
@@ -5382,8 +6323,7 @@ function resolveHeaderReferences(headers, values) {
5382
6323
  }
5383
6324
  return Object.keys(resolved).length > 0 ? resolved : void 0;
5384
6325
  }
5385
- //#endregion
5386
- //#region src/credentials.ts
6326
+ // src/credentials.ts
5387
6327
  /**
5388
6328
  * Project fields whose values are credentials. Keep this registry as the
5389
6329
  * schema-level inventory. Keep the explicit preference/plugin handling in
@@ -5436,7 +6376,12 @@ const PUBLISHABLE_PLUGIN_SETTINGS = {
5436
6376
  "maplibre-gl-components": ["legend", "colorbar"],
5437
6377
  "maplibre-gl-time-slider": null,
5438
6378
  "gods-eye-view": null,
5439
- "geolibre-point-cloud-annotation": null
6379
+ "geolibre-point-cloud-annotation": null,
6380
+ "maplibre-gl-geolens": [
6381
+ "serverUrl",
6382
+ "mapKey",
6383
+ "layerKeys"
6384
+ ]
5440
6385
  };
5441
6386
  /**
5442
6387
  * Hash a credential value to a short, stable 64-bit token: two independent
@@ -5786,8 +6731,1288 @@ function redactConfigurationCredentials(value) {
5786
6731
  function redactCredentials(project) {
5787
6732
  return redactProjectCredentials(project).project;
5788
6733
  }
5789
- //#endregion
5790
- //#region src/project.ts
6734
+ // src/storymap-duration.ts
6735
+ /** Default chapter dwell time after the camera arrives, in seconds. */
6736
+ const DEFAULT_STORY_CHAPTER_DURATION_SECONDS = 15;
6737
+ const MIN_STORY_CHAPTER_DURATION_SECONDS = 1;
6738
+ const MAX_STORY_CHAPTER_DURATION_SECONDS = 3600;
6739
+ /** Resolve old or hand-authored chapter timings to a safe autoplay duration. */
6740
+ function normalizeStoryChapterDurationSeconds(value) {
6741
+ if (typeof value !== "number" || !Number.isFinite(value)) return 15;
6742
+ return Math.min(MAX_STORY_CHAPTER_DURATION_SECONDS, Math.max(1, value));
6743
+ }
6744
+ // src/geocoding.ts
6745
+ /**
6746
+ * Geocoding client and pure helpers shared by the batch-geocode dialog and the
6747
+ * reverse-geocode plugin.
6748
+ *
6749
+ * This module lives in `@geolibre/core` rather than `@geolibre/processing`
6750
+ * because `@geolibre/plugins` depends on core but not on processing, and the
6751
+ * reverse-geocode plugin needs the same client. The pure helpers (URL builders,
6752
+ * result mappers, pacing) carry no React or MapLibre dependency so they can be
6753
+ * unit-tested without a browser or network.
6754
+ *
6755
+ * Providers: geocoding is dispatched through a small {@link GeocodingProvider}
6756
+ * registry so alternatives to Nominatim (ArcGIS World Geocoder, CartoCiudad, Mapbox,
6757
+ * Pelias, Google, organization-hosted ArcGIS GeocodeServer locators) can be
6758
+ * selected per project. Each provider builds its own request
6759
+ * URLs and normalizes its response into a {@link GeocodeMatch} (forward) or a
6760
+ * {@link ReverseGeocodeDisplay} (reverse), so the dialog and plugin stay
6761
+ * provider-agnostic. Nominatim is the default.
6762
+ *
6763
+ * The 1 request/second throttle and the row cap are part of Nominatim's public
6764
+ * usage policy and are therefore applied ONLY to the default public host; a
6765
+ * self-hosted endpoint relaxes both, and keyed providers (Mapbox/ArcGIS/Google)
6766
+ * are paced by their own quotas, not by us.
6767
+ *
6768
+ * Browser fetch cannot set `User-Agent`/`Referer`, so the app is identified to
6769
+ * Nominatim via the optional `email` query parameter plus the automatically
6770
+ * sent `Referer`. The desktop build overrides this with a native (Tauri) fetch
6771
+ * via {@link setGeocodingFetch} — see the note there. See
6772
+ * docs/user-guide/data-integrations.md#geocoding.
6773
+ */
6774
+ const DEFAULT_FORWARD_GEOCODE_ENDPOINT = "https://nominatim.openstreetmap.org/search";
6775
+ const DEFAULT_REVERSE_GEOCODE_ENDPOINT = "https://nominatim.openstreetmap.org/reverse";
6776
+ /** Host whose public usage policy (1 req/sec, bulk limits) we must respect. */
6777
+ const NOMINATIM_PUBLIC_HOST = "nominatim.openstreetmap.org";
6778
+ /** Minimum spacing between requests to the public Nominatim endpoint. */
6779
+ const NOMINATIM_MIN_INTERVAL_MS = 1100;
6780
+ /** Host of the free, unkeyed public CartoCiudad geocoder (IGN/CNIG). */
6781
+ const CARTOCIUDAD_PUBLIC_HOST = "cartociudad.es";
6782
+ /**
6783
+ * Courtesy spacing (~5 req/sec) between requests to the public CartoCiudad
6784
+ * endpoint. Unlike {@link NOMINATIM_MIN_INTERVAL_MS} this is not a published
6785
+ * policy — IGN documents no rate limit — it is our own conservative default so
6786
+ * a large batch does not burst at a free public government service. See
6787
+ * {@link geocoderMinIntervalMs}.
6788
+ */
6789
+ const CARTOCIUDAD_MIN_INTERVAL_MS = 200;
6790
+ /** Max rows a single batch run will geocode against the public endpoint. */
6791
+ const PUBLIC_GEOCODE_ROW_CAP = 1e3;
6792
+ /** Property keys added to each geocoded feature. */
6793
+ const GEOCODE_LAT_KEY = "geocode_lat";
6794
+ const GEOCODE_LON_KEY = "geocode_lon";
6795
+ const GEOCODE_DISPLAY_NAME_KEY = "geocode_display_name";
6796
+ const GEOCODE_SCORE_KEY = "geocode_importance";
6797
+ /** Provider id that produced (or failed to produce) a match, when stamped. */
6798
+ const GEOCODE_PROVIDER_KEY = "geocode_provider";
6799
+ /** Whether a row matched, when stamped. See {@link GeocodeStatus}. */
6800
+ const GEOCODE_STATUS_KEY = "geocode_status";
6801
+ /** Prefix of a matched candidate's own attributes, when they are kept. */
6802
+ const GEOCODE_ATTRIBUTE_PREFIX = "geocode_attr_";
6803
+ /** The provider used when none is configured. */
6804
+ const DEFAULT_GEOCODING_PROVIDER_ID = "nominatim";
6805
+ /**
6806
+ * An error the geocoding service reported about the request itself, as opposed
6807
+ * to a network failure or a search with no matches. ArcGIS answers failures with
6808
+ * HTTP 200 and an `error` envelope, so `response.ok` alone would turn them into
6809
+ * an empty result. `code` is the service's own code (498/499 for a missing or
6810
+ * invalid token, 403 for insufficient permission).
6811
+ */
6812
+ var GeocoderServiceError = class extends Error {
6813
+ code;
6814
+ constructor(message, code) {
6815
+ super(message);
6816
+ this.name = "GeocoderServiceError";
6817
+ this.code = code;
6818
+ }
6819
+ /** Whether the service asked for a token or rejected the one it got. */
6820
+ get isAuthError() {
6821
+ return this.code === 401 || this.code === 403 || this.code === 498 || this.code === 499;
6822
+ }
6823
+ };
6824
+ /** The configured geocoder does not support the requested operation. */
6825
+ var GeocoderUnsupportedError = class extends Error {
6826
+ constructor(message) {
6827
+ super(message);
6828
+ this.name = "GeocoderUnsupportedError";
6829
+ }
6830
+ };
6831
+ function coerceScore(value) {
6832
+ if (value === void 0 || value === null || value === "") return null;
6833
+ const num = Number(value);
6834
+ return Number.isFinite(num) ? num : null;
6835
+ }
6836
+ /** Append `base` to `existing`, suffixing `_2`, `_3`, ... if the key collides. */
6837
+ function uniqueKey(base, existing) {
6838
+ if (!(base in existing)) return base;
6839
+ let suffix = 2;
6840
+ while (`${base}_${suffix}` in existing) suffix += 1;
6841
+ return `${base}_${suffix}`;
6842
+ }
6843
+ /**
6844
+ * Coerce an address-parts record to `Record<string, string>`. Provider address
6845
+ * objects can carry numbers (ArcGIS `Score`/`X`/`Y`, Pelias coordinates), so
6846
+ * values are stringified rather than unsafely cast; null/undefined are dropped.
6847
+ */
6848
+ function stringifyParts(obj) {
6849
+ const parts = {};
6850
+ for (const [key, value] of Object.entries(obj)) {
6851
+ if (value === null || value === void 0) continue;
6852
+ parts[key] = typeof value === "string" ? value : String(value);
6853
+ }
6854
+ return parts;
6855
+ }
6856
+ /** Read a string property off an unknown object, or undefined. */
6857
+ function readString(obj, key) {
6858
+ if (obj && typeof obj === "object" && key in obj) {
6859
+ const value = obj[key];
6860
+ if (typeof value === "string") return value;
6861
+ if (typeof value === "number") return String(value);
6862
+ }
6863
+ }
6864
+ /** Build a Nominatim forward-geocoding URL (jsonv2, address details on). */
6865
+ function buildForwardGeocodeUrl(endpoint, query, options = {}) {
6866
+ const url = new URL(endpoint);
6867
+ url.searchParams.set("q", query);
6868
+ url.searchParams.set("format", "jsonv2");
6869
+ url.searchParams.set("addressdetails", "1");
6870
+ url.searchParams.set("limit", String(options.limit ?? 1));
6871
+ if (options.email) url.searchParams.set("email", options.email);
6872
+ return url.toString();
6873
+ }
6874
+ /** Build a Nominatim reverse-geocoding URL (jsonv2, address details on). */
6875
+ function buildReverseGeocodeUrl(endpoint, lon, lat, options = {}) {
6876
+ const url = new URL(endpoint);
6877
+ url.searchParams.set("lat", String(lat));
6878
+ url.searchParams.set("lon", String(lon));
6879
+ url.searchParams.set("format", "jsonv2");
6880
+ url.searchParams.set("addressdetails", "1");
6881
+ if (options.zoom !== void 0) url.searchParams.set("zoom", String(options.zoom));
6882
+ if (options.email) url.searchParams.set("email", options.email);
6883
+ return url.toString();
6884
+ }
6885
+ /** Map one Nominatim forward result onto a normalized match, or null. */
6886
+ function nominatimForwardResultToMatch(result) {
6887
+ const lat = Number(result.lat);
6888
+ const lon = Number(result.lon);
6889
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
6890
+ return {
6891
+ lat,
6892
+ lon,
6893
+ displayName: result.display_name ?? "",
6894
+ score: coerceScore(result.importance)
6895
+ };
6896
+ }
6897
+ /**
6898
+ * Convert a normalized {@link GeocodeMatch} into a point Feature whose
6899
+ * properties carry the original CSV row plus `geocode_lat`/`geocode_lon`/
6900
+ * `geocode_display_name`/`geocode_importance`. The added keys are de-duplicated
6901
+ * against the original columns so an existing `geocode_lat` is not clobbered.
6902
+ * Geometry coordinates are `[lon, lat]`. Returns null when the match has no
6903
+ * finite coordinates.
6904
+ *
6905
+ * `extra.providerId`, when passed, also stamps `geocode_provider` and
6906
+ * `geocode_status: "matched"` (dropped when omitted, so the existing
6907
+ * `GeocodeDialog` call site keeps its current output unchanged).
6908
+ * `extra.attributes` also adds the candidate's own attributes, each under
6909
+ * {@link GEOCODE_ATTRIBUTE_PREFIX} plus its name.
6910
+ */
6911
+ function geocodeMatchToFeature(match, originalRow = {}, extra) {
6912
+ if (!Number.isFinite(match.lat) || !Number.isFinite(match.lon)) return null;
6913
+ const properties = { ...originalRow };
6914
+ const added = {
6915
+ [GEOCODE_LAT_KEY]: match.lat,
6916
+ [GEOCODE_LON_KEY]: match.lon,
6917
+ [GEOCODE_DISPLAY_NAME_KEY]: match.displayName ?? "",
6918
+ [GEOCODE_SCORE_KEY]: match.score
6919
+ };
6920
+ if (extra?.providerId) {
6921
+ added[GEOCODE_PROVIDER_KEY] = extra.providerId;
6922
+ added[GEOCODE_STATUS_KEY] = "matched";
6923
+ }
6924
+ if (extra?.attributes) for (const [key, value] of Object.entries(match.attributes ?? {})) added[`${GEOCODE_ATTRIBUTE_PREFIX}${key}`] = value;
6925
+ for (const [key, value] of Object.entries(added)) properties[uniqueKey(key, properties)] = value;
6926
+ return {
6927
+ type: "Feature",
6928
+ geometry: {
6929
+ type: "Point",
6930
+ coordinates: [match.lon, match.lat]
6931
+ },
6932
+ properties
6933
+ };
6934
+ }
6935
+ /**
6936
+ * Build a null-geometry Feature for a CSV row that could not be geocoded, so
6937
+ * it is kept (not silently dropped) alongside the matched point features.
6938
+ * Mirrors the null-geometry shape {@link parseDelimitedTextLayer} already uses
6939
+ * for a non-spatial attribute table. Stamps `geocode_status: "unmatched"` and
6940
+ * `geocode_provider`, de-duplicated against the original columns the same way
6941
+ * {@link geocodeMatchToFeature} does.
6942
+ */
6943
+ function unmatchedGeocodeFeature(originalRow, providerId) {
6944
+ const properties = { ...originalRow };
6945
+ const added = {
6946
+ [GEOCODE_STATUS_KEY]: "unmatched",
6947
+ [GEOCODE_PROVIDER_KEY]: providerId
6948
+ };
6949
+ for (const [key, value] of Object.entries(added)) properties[uniqueKey(key, properties)] = value;
6950
+ return {
6951
+ type: "Feature",
6952
+ geometry: null,
6953
+ properties
6954
+ };
6955
+ }
6956
+ /**
6957
+ * Convert a Nominatim forward result into a point Feature. Retained as a thin
6958
+ * wrapper over {@link geocodeMatchToFeature} for callers and tests that work
6959
+ * directly with Nominatim results. Returns null when the result has no finite
6960
+ * coordinates.
6961
+ */
6962
+ function nominatimResultToFeature(result, originalRow = {}) {
6963
+ const match = nominatimForwardResultToMatch(result);
6964
+ return match ? geocodeMatchToFeature(match, originalRow) : null;
6965
+ }
6966
+ /**
6967
+ * Resolve a Nominatim reverse result to a display string and address parts, or
6968
+ * null when the point could not be reverse-geocoded.
6969
+ */
6970
+ function nominatimReverseResultToDisplay(result) {
6971
+ if (!result || result.error) return null;
6972
+ const displayName = result.display_name?.trim();
6973
+ if (!displayName) return null;
6974
+ return {
6975
+ displayName,
6976
+ parts: result.address ?? {}
6977
+ };
6978
+ }
6979
+ const nominatimProvider = {
6980
+ id: "nominatim",
6981
+ label: "Nominatim (OpenStreetMap)",
6982
+ forward: true,
6983
+ reverse: true,
6984
+ requiresApiKey: false,
6985
+ acceptsApiKey: false,
6986
+ defaultForwardEndpoint: DEFAULT_FORWARD_GEOCODE_ENDPOINT,
6987
+ defaultReverseEndpoint: DEFAULT_REVERSE_GEOCODE_ENDPOINT,
6988
+ buildForwardUrl: (config, query, options) => buildForwardGeocodeUrl(config.forwardEndpoint, query, {
6989
+ email: options.email,
6990
+ limit: options.limit
6991
+ }),
6992
+ parseForward: (data) => Array.isArray(data) ? data.map(nominatimForwardResultToMatch).filter((m) => m !== null) : [],
6993
+ buildReverseUrl: (config, lon, lat, options) => buildReverseGeocodeUrl(config.reverseEndpoint, lon, lat, {
6994
+ email: options.email,
6995
+ zoom: options.zoom
6996
+ }),
6997
+ parseReverse: (data) => nominatimReverseResultToDisplay(data && typeof data === "object" ? data : null)
6998
+ };
6999
+ /** Read a GeoJSON FeatureCollection's features array, or []. */
7000
+ function geojsonFeatures(data) {
7001
+ if (data && typeof data === "object" && Array.isArray(data.features)) return data.features.filter((f) => !!f && typeof f === "object");
7002
+ return [];
7003
+ }
7004
+ /** Pull `[lon, lat]` from a GeoJSON Point geometry, or null. */
7005
+ function pointCoords(feature) {
7006
+ const coords = feature.geometry?.coordinates;
7007
+ if (Array.isArray(coords) && coords.length >= 2) {
7008
+ const lon = Number(coords[0]);
7009
+ const lat = Number(coords[1]);
7010
+ if (Number.isFinite(lon) && Number.isFinite(lat)) return [lon, lat];
7011
+ }
7012
+ return null;
7013
+ }
7014
+ const peliasProvider = {
7015
+ id: "pelias",
7016
+ label: "Pelias",
7017
+ forward: true,
7018
+ reverse: true,
7019
+ requiresApiKey: false,
7020
+ acceptsApiKey: true,
7021
+ defaultForwardEndpoint: "https://api.geocode.earth/v1/search",
7022
+ defaultReverseEndpoint: "https://api.geocode.earth/v1/reverse",
7023
+ buildForwardUrl: (config, query, options) => {
7024
+ const url = new URL(config.forwardEndpoint);
7025
+ url.searchParams.set("text", query);
7026
+ if (options.limit) url.searchParams.set("size", String(options.limit));
7027
+ if (config.apiKey) url.searchParams.set("api_key", config.apiKey);
7028
+ return url.toString();
7029
+ },
7030
+ parseForward: (data) => geojsonFeatures(data).map((feature) => {
7031
+ const coords = pointCoords(feature);
7032
+ if (!coords) return null;
7033
+ const props = feature.properties ?? {};
7034
+ return {
7035
+ lon: coords[0],
7036
+ lat: coords[1],
7037
+ displayName: readString(props, "label") ?? "",
7038
+ score: coerceScore(props.confidence)
7039
+ };
7040
+ }).filter((m) => m !== null),
7041
+ buildReverseUrl: (config, lon, lat) => {
7042
+ const url = new URL(config.reverseEndpoint);
7043
+ url.searchParams.set("point.lat", String(lat));
7044
+ url.searchParams.set("point.lon", String(lon));
7045
+ if (config.apiKey) url.searchParams.set("api_key", config.apiKey);
7046
+ return url.toString();
7047
+ },
7048
+ parseReverse: (data) => {
7049
+ const feature = geojsonFeatures(data)[0];
7050
+ if (!feature) return null;
7051
+ const props = feature.properties ?? {};
7052
+ const displayName = readString(props, "label")?.trim();
7053
+ return displayName ? {
7054
+ displayName,
7055
+ parts: stringifyParts(props)
7056
+ } : null;
7057
+ }
7058
+ };
7059
+ const arcgisProvider = {
7060
+ id: "arcgis",
7061
+ label: "ArcGIS World Geocoder",
7062
+ forward: true,
7063
+ reverse: true,
7064
+ requiresApiKey: true,
7065
+ acceptsApiKey: true,
7066
+ defaultForwardEndpoint: "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates",
7067
+ defaultReverseEndpoint: "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode",
7068
+ buildForwardUrl: (config, query, options) => {
7069
+ const url = new URL(config.forwardEndpoint);
7070
+ url.searchParams.set("SingleLine", query);
7071
+ url.searchParams.set("f", "json");
7072
+ url.searchParams.set("outFields", "Match_addr");
7073
+ if (options.limit) url.searchParams.set("maxLocations", String(options.limit));
7074
+ if (config.apiKey) url.searchParams.set("token", config.apiKey);
7075
+ return url.toString();
7076
+ },
7077
+ parseForward: parseArcGISCandidates,
7078
+ buildReverseUrl: (config, lon, lat) => {
7079
+ const url = new URL(config.reverseEndpoint);
7080
+ url.searchParams.set("location", `${lon},${lat}`);
7081
+ url.searchParams.set("f", "json");
7082
+ if (config.apiKey) url.searchParams.set("token", config.apiKey);
7083
+ return url.toString();
7084
+ },
7085
+ parseReverse: parseArcGISReverse
7086
+ };
7087
+ /** Spatial references ArcGIS reports for Web Mercator. */
7088
+ const WEB_MERCATOR_WKIDS = /* @__PURE__ */ new Set([
7089
+ 102100,
7090
+ 102113,
7091
+ 3857,
7092
+ 900913
7093
+ ]);
7094
+ /** Earth radius of the Web Mercator sphere, in meters. */
7095
+ const WEB_MERCATOR_RADIUS = 6378137;
7096
+ /** The operations a pasted GeocodeServer URL may already end in. */
7097
+ const LOCATOR_OPERATION = /\/(findAddressCandidates|reverseGeocode|suggest|geocodeAddresses)$/i;
7098
+ /** The ArcGIS `error` envelope in a response body, as a {@link GeocoderServiceError}. */
7099
+ function arcgisServiceError(data) {
7100
+ const error = data && typeof data === "object" ? data.error : void 0;
7101
+ if (!error || typeof error !== "object") return null;
7102
+ const code = Number(error.code);
7103
+ return new GeocoderServiceError(readString(error, "message") ?? "ArcGIS geocoding service error", Number.isFinite(code) ? code : void 0);
7104
+ }
7105
+ /** The well-known ID of an ArcGIS `spatialReference`, preferring `latestWkid`. */
7106
+ function spatialReferenceWkid(spatialReference) {
7107
+ if (!spatialReference || typeof spatialReference !== "object") return null;
7108
+ const sr = spatialReference;
7109
+ const wkid = Number(sr.latestWkid ?? sr.wkid);
7110
+ return Number.isFinite(wkid) ? wkid : null;
7111
+ }
7112
+ /**
7113
+ * A candidate's position as longitude/latitude. Organization-hosted locators
7114
+ * often work in a projected system, and ArcGIS ignores an `outSR` it cannot
7115
+ * honor, so the reported spatial reference decides: geographic WGS 84 (or none,
7116
+ * the World Geocoder's default) is read as is, Web Mercator is unprojected, and
7117
+ * anything else is refused rather than plotted in the wrong place.
7118
+ *
7119
+ * @param x - The candidate's x coordinate.
7120
+ * @param y - The candidate's y coordinate.
7121
+ * @param wkid - The response's spatial reference, or null when it reports none.
7122
+ * @returns The position, or null when the coordinates are not finite or not on Earth.
7123
+ * @throws GeocoderServiceError When the coordinates are in an unsupported spatial reference.
7124
+ */
7125
+ function arcgisCandidatePosition(x, y, wkid) {
7126
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
7127
+ let lon = x;
7128
+ let lat = y;
7129
+ if (wkid !== null && WEB_MERCATOR_WKIDS.has(wkid)) {
7130
+ lon = x / WEB_MERCATOR_RADIUS * (180 / Math.PI);
7131
+ lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
7132
+ } else if (wkid !== null && wkid !== 4326) throw new GeocoderServiceError(`The geocoder returned coordinates in spatial reference ${wkid}, which GeoLibre cannot place on the map.`);
7133
+ if (lon < -180 || lon > 180 || lat < -90 || lat > 90) return null;
7134
+ return {
7135
+ lon,
7136
+ lat
7137
+ };
7138
+ }
7139
+ /**
7140
+ * A candidate's attributes worth keeping: plain values that are not empty.
7141
+ * The shape and the location fields the match already carries are dropped.
7142
+ */
7143
+ function candidateAttributes(value) {
7144
+ if (!value || typeof value !== "object") return void 0;
7145
+ const kept = {};
7146
+ for (const [key, entry] of Object.entries(value)) {
7147
+ if (/^(shape|x|y|displayx|displayy|xmin|xmax|ymin|ymax)$/i.test(key)) continue;
7148
+ if (typeof entry === "string" ? entry.trim() === "" : typeof entry !== "number" && typeof entry !== "boolean") continue;
7149
+ if (typeof entry === "number" && !Number.isFinite(entry)) continue;
7150
+ kept[key] = entry;
7151
+ }
7152
+ return Object.keys(kept).length ? kept : void 0;
7153
+ }
7154
+ /**
7155
+ * Normalize an ArcGIS `findAddressCandidates` response. Throws the service's
7156
+ * error envelope instead of reporting it as "no matches".
7157
+ *
7158
+ * @param data - The parsed response body.
7159
+ * @returns The candidates as normalized matches.
7160
+ */
7161
+ function parseArcGISCandidates(data) {
7162
+ const error = arcgisServiceError(data);
7163
+ if (error) throw error;
7164
+ if (!data || typeof data !== "object") return [];
7165
+ const body = data;
7166
+ if (!Array.isArray(body.candidates)) return [];
7167
+ const wkid = spatialReferenceWkid(body.spatialReference);
7168
+ return body.candidates.map((candidate) => {
7169
+ const location = candidate.location;
7170
+ const position = arcgisCandidatePosition(Number(location?.x), Number(location?.y), wkid);
7171
+ if (!position) return null;
7172
+ const attributes = candidateAttributes(candidate.attributes);
7173
+ return {
7174
+ ...position,
7175
+ displayName: readString(candidate, "address") ?? "",
7176
+ score: coerceScore(candidate.score),
7177
+ ...attributes ? { attributes } : {}
7178
+ };
7179
+ }).filter((m) => m !== null);
7180
+ }
7181
+ /**
7182
+ * Whether an ArcGIS error envelope is the "Unable to find address" answer:
7183
+ * code 400 with that text in its message or (Enterprise) its details. Other
7184
+ * 400s, such as malformed parameters, are real failures.
7185
+ */
7186
+ function isNoAddressError(data, error) {
7187
+ if (error.code !== 400) return false;
7188
+ const envelope = data.error;
7189
+ const details = Array.isArray(envelope?.details) ? envelope.details.join(" ") : "";
7190
+ return /unable to find address/i.test(`${error.message} ${details}`);
7191
+ }
7192
+ /**
7193
+ * Normalize an ArcGIS `reverseGeocode` response. "Unable to find address" comes
7194
+ * back as an `error` envelope with code 400, which is a valid empty answer
7195
+ * rather than a failure.
7196
+ *
7197
+ * @param data - The parsed response body.
7198
+ * @returns The address, or null when there is none.
7199
+ */
7200
+ function parseArcGISReverse(data) {
7201
+ const error = arcgisServiceError(data);
7202
+ if (error && !isNoAddressError(data, error)) throw error;
7203
+ const address = data && typeof data === "object" ? data.address : void 0;
7204
+ if (!address || typeof address !== "object") return null;
7205
+ const displayName = (readString(address, "LongLabel") ?? readString(address, "Match_addr") ?? readString(address, "Address"))?.trim();
7206
+ return displayName ? {
7207
+ displayName,
7208
+ parts: stringifyParts(address)
7209
+ } : null;
7210
+ }
7211
+ /**
7212
+ * The GeocodeServer base URL of a pasted locator URL: an operation suffix
7213
+ * (`/findAddressCandidates`, ...), the query string and any credentials are
7214
+ * dropped, so a token in a pasted URL is never stored.
7215
+ *
7216
+ * @param input - The URL as typed.
7217
+ * @returns The base URL ending in `/GeocodeServer`, or null when it is not one.
7218
+ */
7219
+ function normalizeArcGISLocatorUrl(input) {
7220
+ let url;
7221
+ try {
7222
+ url = new URL((input ?? "").trim());
7223
+ } catch {
7224
+ return null;
7225
+ }
7226
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
7227
+ const path = url.pathname.replace(/\/+$/, "").replace(LOCATOR_OPERATION, "");
7228
+ if (!/\/GeocodeServer$/i.test(path)) return null;
7229
+ url.pathname = path;
7230
+ url.search = "";
7231
+ url.hash = "";
7232
+ url.username = "";
7233
+ url.password = "";
7234
+ return url.toString();
7235
+ }
7236
+ /**
7237
+ * Coerce a stored locator to a {@link GeocodingLocator}, or null when it has no
7238
+ * usable id or GeocodeServer URL. Used for project files and runtime env, so
7239
+ * anything unexpected is dropped rather than trusted.
7240
+ *
7241
+ * @param value - The stored value.
7242
+ * @returns The locator, or null.
7243
+ */
7244
+ function normalizeGeocodingLocator(value) {
7245
+ if (!value || typeof value !== "object") return null;
7246
+ const candidate = value;
7247
+ const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
7248
+ const url = normalizeArcGISLocatorUrl(typeof candidate.url === "string" ? candidate.url : void 0);
7249
+ if (!id || !url) return null;
7250
+ const text = (key) => typeof candidate[key] === "string" && candidate[key].trim() ? candidate[key].trim() : void 0;
7251
+ const flag = (key) => typeof candidate[key] === "boolean" ? candidate[key] : void 0;
7252
+ const addressFields = Array.isArray(candidate.addressFields) ? candidate.addressFields.filter((field) => typeof field === "string" && field.trim() !== "") : void 0;
7253
+ const locator = {
7254
+ id,
7255
+ name: text("name") ?? id,
7256
+ url
7257
+ };
7258
+ const portalUrl = text("portalUrl");
7259
+ if (portalUrl) locator.portalUrl = portalUrl;
7260
+ const singleLineField = text("singleLineField");
7261
+ if (singleLineField) locator.singleLineField = singleLineField;
7262
+ if (addressFields?.length) locator.addressFields = addressFields;
7263
+ const reverse = flag("reverse");
7264
+ if (reverse !== void 0) locator.reverse = reverse;
7265
+ const suggest = flag("suggest");
7266
+ if (suggest !== void 0) locator.suggest = suggest;
7267
+ const searchField = text("searchField");
7268
+ if (searchField) locator.searchField = searchField;
7269
+ return locator;
7270
+ }
7271
+ /**
7272
+ * Read a GeocodeServer's capabilities from its `?f=json` metadata. A service
7273
+ * that lists no `capabilities` (older ArcGIS Server) is taken to support
7274
+ * geocoding and reverse geocoding but not suggestions.
7275
+ *
7276
+ * @param data - The parsed metadata.
7277
+ * @returns The capabilities.
7278
+ * @throws GeocoderServiceError For an error envelope, or metadata that is not a
7279
+ * GeocodeServer's.
7280
+ * @throws GeocoderUnsupportedError When the service does not geocode.
7281
+ */
7282
+ function parseArcGISLocatorInfo(data) {
7283
+ const error = arcgisServiceError(data);
7284
+ if (error) throw error;
7285
+ if (!data || typeof data !== "object") throw new GeocoderServiceError("The service did not return GeocodeServer metadata.");
7286
+ const info = data;
7287
+ const fieldName = (field) => field && typeof field === "object" ? readString(field, "name")?.trim() || void 0 : void 0;
7288
+ const addressFields = Array.isArray(info.addressFields) ? info.addressFields.map(fieldName).filter((name) => !!name) : [];
7289
+ const singleLineField = fieldName(info.singleLineAddressField);
7290
+ if (!singleLineField && !addressFields.length) throw new GeocoderServiceError("The service did not return GeocodeServer metadata.");
7291
+ const listed = typeof info.capabilities === "string" ? new Set(info.capabilities.split(",").map((entry) => entry.trim().toLowerCase())) : null;
7292
+ if (listed && !listed.has("geocode")) throw new GeocoderUnsupportedError("This locator does not support geocoding.");
7293
+ return {
7294
+ singleLineField,
7295
+ addressFields: addressFields.length ? addressFields : void 0,
7296
+ reverse: listed ? listed.has("reversegeocode") : true,
7297
+ suggest: listed ? listed.has("suggest") : false
7298
+ };
7299
+ }
7300
+ /**
7301
+ * The input field a single search string is sent in: the published field the
7302
+ * user chose for it, else the locator's single-line field, else its only
7303
+ * structured field (an asset ID locator often publishes just one), else
7304
+ * ArcGIS's conventional `SingleLine`.
7305
+ *
7306
+ * @param locator - The locator.
7307
+ * @returns The input field name.
7308
+ */
7309
+ function arcgisLocatorInputField(locator) {
7310
+ const published = [locator.singleLineField, ...locator.addressFields ?? []];
7311
+ if (locator.searchField && published.includes(locator.searchField)) return locator.searchField;
7312
+ if (locator.singleLineField) return locator.singleLineField;
7313
+ if (locator.addressFields?.length === 1) return locator.addressFields[0];
7314
+ return "SingleLine";
7315
+ }
7316
+ /** The config's locator, or an error naming what is missing. */
7317
+ function requireLocator(config) {
7318
+ if (!config.locator) throw new GeocoderUnsupportedError("No ArcGIS GeocodeServer locator is selected.");
7319
+ return config.locator;
7320
+ }
7321
+ const arcgisLocatorProvider = {
7322
+ id: "arcgis-locator",
7323
+ label: "ArcGIS GeocodeServer (organization)",
7324
+ forward: true,
7325
+ reverse: true,
7326
+ requiresApiKey: false,
7327
+ acceptsApiKey: false,
7328
+ defaultForwardEndpoint: "",
7329
+ defaultReverseEndpoint: "",
7330
+ buildForwardUrl: (config, query, options) => {
7331
+ const locator = requireLocator(config);
7332
+ const url = new URL(config.forwardEndpoint || `${locator.url}/findAddressCandidates`);
7333
+ url.searchParams.set(arcgisLocatorInputField(locator), query);
7334
+ url.searchParams.set("f", "json");
7335
+ url.searchParams.set("outSR", "4326");
7336
+ if (options.attributes) url.searchParams.set("outFields", "*");
7337
+ if (options.magicKey) url.searchParams.set("magicKey", options.magicKey);
7338
+ if (options.limit) url.searchParams.set("maxLocations", String(options.limit));
7339
+ return url.toString();
7340
+ },
7341
+ parseForward: parseArcGISCandidates,
7342
+ buildReverseUrl: (config, lon, lat) => {
7343
+ const locator = requireLocator(config);
7344
+ const url = new URL(config.reverseEndpoint || `${locator.url}/reverseGeocode`);
7345
+ url.searchParams.set("location", JSON.stringify({
7346
+ x: lon,
7347
+ y: lat,
7348
+ spatialReference: { wkid: 4326 }
7349
+ }));
7350
+ url.searchParams.set("outSR", "4326");
7351
+ url.searchParams.set("f", "json");
7352
+ return url.toString();
7353
+ },
7354
+ parseReverse: parseArcGISReverse
7355
+ };
7356
+ const mapboxProvider = {
7357
+ id: "mapbox",
7358
+ label: "Mapbox",
7359
+ forward: true,
7360
+ reverse: true,
7361
+ requiresApiKey: true,
7362
+ acceptsApiKey: true,
7363
+ defaultForwardEndpoint: "https://api.mapbox.com/geocoding/v5/mapbox.places",
7364
+ defaultReverseEndpoint: "https://api.mapbox.com/geocoding/v5/mapbox.places",
7365
+ buildForwardUrl: (config, query, options) => {
7366
+ const base = config.forwardEndpoint.replace(/\/+$/, "");
7367
+ const url = new URL(`${base}/${encodeURIComponent(query)}.json`);
7368
+ if (options.limit) url.searchParams.set("limit", String(options.limit));
7369
+ if (config.apiKey) url.searchParams.set("access_token", config.apiKey);
7370
+ return url.toString();
7371
+ },
7372
+ parseForward: (data) => geojsonFeatures(data).map((feature) => {
7373
+ const center = feature.center;
7374
+ let coords = null;
7375
+ if (Array.isArray(center) && center.length >= 2) {
7376
+ const lon = Number(center[0]);
7377
+ const lat = Number(center[1]);
7378
+ if (Number.isFinite(lon) && Number.isFinite(lat)) coords = [lon, lat];
7379
+ }
7380
+ coords ??= pointCoords(feature);
7381
+ if (!coords) return null;
7382
+ return {
7383
+ lon: coords[0],
7384
+ lat: coords[1],
7385
+ displayName: readString(feature, "place_name") ?? "",
7386
+ score: coerceScore(feature.relevance)
7387
+ };
7388
+ }).filter((m) => m !== null),
7389
+ buildReverseUrl: (config, lon, lat) => {
7390
+ const base = config.reverseEndpoint.replace(/\/+$/, "");
7391
+ const url = new URL(`${base}/${lon},${lat}.json`);
7392
+ if (config.apiKey) url.searchParams.set("access_token", config.apiKey);
7393
+ return url.toString();
7394
+ },
7395
+ parseReverse: (data) => {
7396
+ const feature = geojsonFeatures(data)[0];
7397
+ if (!feature) return null;
7398
+ const displayName = readString(feature, "place_name")?.trim();
7399
+ return displayName ? {
7400
+ displayName,
7401
+ parts: stringifyParts(feature.properties ?? {})
7402
+ } : null;
7403
+ }
7404
+ };
7405
+ /**
7406
+ * Detect a Google Geocoding API error embedded in an HTTP 200 body. Google v3
7407
+ * returns REQUEST_DENIED / OVER_QUERY_LIMIT / INVALID_REQUEST and friends with
7408
+ * a 200 status and the failure only in the `status` field, so `response.ok`
7409
+ * alone would silently turn those into empty results. Returns null for OK and
7410
+ * ZERO_RESULTS (a successful empty match).
7411
+ */
7412
+ function googleErrorMessage(data) {
7413
+ const status = readString(data, "status");
7414
+ if (!status || status === "OK" || status === "ZERO_RESULTS") return null;
7415
+ const detail = readString(data, "error_message");
7416
+ return detail ? `Google geocoder error: ${status} - ${detail}` : `Google geocoder error: ${status}`;
7417
+ }
7418
+ const googleProvider = {
7419
+ id: "google",
7420
+ label: "Google",
7421
+ forward: true,
7422
+ reverse: true,
7423
+ requiresApiKey: true,
7424
+ acceptsApiKey: true,
7425
+ browserCorsRestricted: true,
7426
+ defaultForwardEndpoint: "https://maps.googleapis.com/maps/api/geocode/json",
7427
+ defaultReverseEndpoint: "https://maps.googleapis.com/maps/api/geocode/json",
7428
+ buildForwardUrl: (config, query) => {
7429
+ const url = new URL(config.forwardEndpoint);
7430
+ url.searchParams.set("address", query);
7431
+ if (config.apiKey) url.searchParams.set("key", config.apiKey);
7432
+ return url.toString();
7433
+ },
7434
+ parseForward: (data) => {
7435
+ const error = googleErrorMessage(data);
7436
+ if (error) throw new Error(error);
7437
+ return parseGoogleResults(data);
7438
+ },
7439
+ buildReverseUrl: (config, lon, lat) => {
7440
+ const url = new URL(config.reverseEndpoint);
7441
+ url.searchParams.set("latlng", `${lat},${lon}`);
7442
+ if (config.apiKey) url.searchParams.set("key", config.apiKey);
7443
+ return url.toString();
7444
+ },
7445
+ parseReverse: (data) => {
7446
+ const error = googleErrorMessage(data);
7447
+ if (error) throw new Error(error);
7448
+ const displayName = readString((data && typeof data === "object" && Array.isArray(data.results) ? data.results : [])[0], "formatted_address")?.trim();
7449
+ return displayName ? {
7450
+ displayName,
7451
+ parts: {}
7452
+ } : null;
7453
+ }
7454
+ };
7455
+ /** Map a Google Geocoding API `results` array onto normalized matches. */
7456
+ function parseGoogleResults(data) {
7457
+ return (data && typeof data === "object" && Array.isArray(data.results) ? data.results : []).map((result) => {
7458
+ const location = result.geometry?.location;
7459
+ const lat = Number(location?.lat);
7460
+ const lon = Number(location?.lng);
7461
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
7462
+ return {
7463
+ lat,
7464
+ lon,
7465
+ displayName: readString(result, "formatted_address") ?? "",
7466
+ score: null
7467
+ };
7468
+ }).filter((m) => m !== null);
7469
+ }
7470
+ /**
7471
+ * Read a CartoCiudad `lat`/`lng` field as a finite number, or null when the
7472
+ * field is absent, blank, or not numeric (`"NaN"` and `"invalid"` included).
7473
+ * Guarding the raw value matters because `Number(null)` is 0, which would turn
7474
+ * a coordinate-less response into a match in the Gulf of Guinea.
7475
+ */
7476
+ function cartociudadCoordinate(value) {
7477
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
7478
+ if (typeof value !== "string" || !value.trim()) return null;
7479
+ const parsed = Number(value);
7480
+ return Number.isFinite(parsed) ? parsed : null;
7481
+ }
7482
+ /** Validated WGS84 position for a result, or null when it carries none. */
7483
+ function cartociudadCoords(r) {
7484
+ const lat = cartociudadCoordinate(r.lat);
7485
+ const lon = cartociudadCoordinate(r.lng);
7486
+ if (lat === null || lon === null) return null;
7487
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
7488
+ return {
7489
+ lat,
7490
+ lon
7491
+ };
7492
+ }
7493
+ /** Default endpoints for the public CartoCiudad REST API (no auth required). */
7494
+ const CARTOCIUDAD_FORWARD_ENDPOINT = "https://www.cartociudad.es/geocoder/api/geocoder/find";
7495
+ const CARTOCIUDAD_REVERSE_ENDPOINT = "https://www.cartociudad.es/geocoder/api/geocoder/reverseGeocode";
7496
+ /**
7497
+ * Build a human-readable display name from CartoCiudad address fields.
7498
+ * Format: "Tip Via Address Num, CP Poblacion (Province)"
7499
+ */
7500
+ function cartociudadDisplayName(r) {
7501
+ const parts = [];
7502
+ const streetParts = [r.tip_via, r.address].filter((s) => s && String(s).trim()).map((s) => String(s).trim());
7503
+ if (streetParts.length) {
7504
+ let street = streetParts.join(" ");
7505
+ const portalNumber = r.portalNumber === void 0 || r.portalNumber === null ? "" : String(r.portalNumber).trim();
7506
+ if (portalNumber) street += ` ${portalNumber}`;
7507
+ parts.push(street);
7508
+ }
7509
+ const localityName = [r.poblacion, r.muni].find((s) => s && String(s).trim());
7510
+ const locality = [r.postalCode, localityName].filter((s) => s && String(s).trim()).map((s) => String(s).trim()).join(" ");
7511
+ if (locality) parts.push(locality);
7512
+ const province = r.province ? String(r.province).trim() : "";
7513
+ if (province) parts.push(`(${province})`);
7514
+ return parts.join(", ") || r.address || r.poblacion || r.muni || "";
7515
+ }
7516
+ /** All selectable geocoding providers, Nominatim first (the default). */
7517
+ const GEOCODING_PROVIDERS = [
7518
+ nominatimProvider,
7519
+ peliasProvider,
7520
+ arcgisProvider,
7521
+ arcgisLocatorProvider,
7522
+ mapboxProvider,
7523
+ googleProvider,
7524
+ {
7525
+ id: "cartociudad",
7526
+ label: "CartoCiudad (IGN España)",
7527
+ forward: true,
7528
+ reverse: true,
7529
+ requiresApiKey: false,
7530
+ acceptsApiKey: false,
7531
+ defaultForwardEndpoint: CARTOCIUDAD_FORWARD_ENDPOINT,
7532
+ defaultReverseEndpoint: CARTOCIUDAD_REVERSE_ENDPOINT,
7533
+ buildForwardUrl: (config, query) => {
7534
+ const url = new URL(config.forwardEndpoint);
7535
+ url.searchParams.set("q", query);
7536
+ return url.toString();
7537
+ },
7538
+ parseForward: (data) => {
7539
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
7540
+ const r = data;
7541
+ const coords = cartociudadCoords(r);
7542
+ if (!coords) return [];
7543
+ const displayName = cartociudadDisplayName(r).trim();
7544
+ if (!displayName) return [];
7545
+ return [{
7546
+ lat: coords.lat,
7547
+ lon: coords.lon,
7548
+ displayName,
7549
+ score: null
7550
+ }];
7551
+ },
7552
+ buildReverseUrl: (config, lon, lat) => {
7553
+ const url = new URL(config.reverseEndpoint);
7554
+ url.searchParams.set("lat", String(lat));
7555
+ url.searchParams.set("lon", String(lon));
7556
+ return url.toString();
7557
+ },
7558
+ parseReverse: (data) => {
7559
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
7560
+ const r = data;
7561
+ if (!cartociudadCoords(r)) return null;
7562
+ const displayName = cartociudadDisplayName(r).trim();
7563
+ if (!displayName) return null;
7564
+ const parts = {};
7565
+ for (const [key, value] of Object.entries({
7566
+ type: r.type,
7567
+ tip_via: r.tip_via,
7568
+ address: r.address,
7569
+ portalNumber: r.portalNumber,
7570
+ postalCode: r.postalCode,
7571
+ poblacion: r.poblacion,
7572
+ muni: r.muni,
7573
+ province: r.province,
7574
+ comunidadAutonoma: r.comunidadAutonoma,
7575
+ refCatastral: r.refCatastral
7576
+ })) if (value !== void 0 && value !== null && value !== "") parts[key] = String(value);
7577
+ return {
7578
+ displayName,
7579
+ parts
7580
+ };
7581
+ }
7582
+ }
7583
+ ];
7584
+ const PROVIDERS_BY_ID = new Map(GEOCODING_PROVIDERS.map((provider) => [provider.id, provider]));
7585
+ /** Coerce an arbitrary string to a known provider id, defaulting to Nominatim. */
7586
+ function normalizeGeocodingProviderId(value) {
7587
+ const id = value?.trim();
7588
+ return id && PROVIDERS_BY_ID.has(id) ? id : DEFAULT_GEOCODING_PROVIDER_ID;
7589
+ }
7590
+ /** Look up a provider by id, falling back to Nominatim for unknown ids. */
7591
+ function getGeocodingProvider(id) {
7592
+ return PROVIDERS_BY_ID.get(normalizeGeocodingProviderId(id)) ?? nominatimProvider;
7593
+ }
7594
+ /**
7595
+ * Resolve the geocoder configuration from runtime env, falling back to the
7596
+ * selected provider's default endpoints. `VITE_GEOCODER_PROVIDER` selects the
7597
+ * backend; `VITE_GEOCODER_ENDPOINT`/`VITE_GEOCODER_REVERSE_ENDPOINT` override
7598
+ * the endpoints (except for `arcgis-locator`, whose endpoints always come from
7599
+ * `VITE_GEOCODER_LOCATOR`); `VITE_GEOCODER_EMAIL` supplies the contact email; and
7600
+ * `VITE_GEOCODER_API_KEY` supplies the API key for keyed providers.
7601
+ */
7602
+ function getGeocoderConfig() {
7603
+ const env = getRuntimeEnvironment();
7604
+ const provider = getGeocodingProvider(env.VITE_GEOCODER_PROVIDER);
7605
+ let locator;
7606
+ if (provider.id === "arcgis-locator" && env.VITE_GEOCODER_LOCATOR) try {
7607
+ locator = normalizeGeocodingLocator(JSON.parse(env.VITE_GEOCODER_LOCATOR)) ?? void 0;
7608
+ } catch {
7609
+ locator = void 0;
7610
+ }
7611
+ const endpoints = locator ? locatorEndpoints(locator) : provider;
7612
+ const overrides = provider.id === "arcgis-locator" ? {} : env;
7613
+ return {
7614
+ providerId: provider.id,
7615
+ forwardEndpoint: overrides.VITE_GEOCODER_ENDPOINT?.trim() || endpoints.defaultForwardEndpoint,
7616
+ reverseEndpoint: overrides.VITE_GEOCODER_REVERSE_ENDPOINT?.trim() || endpoints.defaultReverseEndpoint,
7617
+ email: env.VITE_GEOCODER_EMAIL?.trim() || void 0,
7618
+ apiKey: env.VITE_GEOCODER_API_KEY?.trim() || void 0,
7619
+ ...locator ? { locator } : {}
7620
+ };
7621
+ }
7622
+ /** A locator's operation URLs, derived from its base GeocodeServer URL. */
7623
+ function locatorEndpoints(locator) {
7624
+ return {
7625
+ defaultForwardEndpoint: `${locator.url}/findAddressCandidates`,
7626
+ defaultReverseEndpoint: `${locator.url}/reverseGeocode`
7627
+ };
7628
+ }
7629
+ /**
7630
+ * The locator the `arcgis-locator` provider uses: the one `locatorId` names,
7631
+ * else the first configured.
7632
+ *
7633
+ * @param input - The geocoding preference.
7634
+ * @returns The locator, or undefined when none is configured.
7635
+ */
7636
+ function selectedGeocodingLocator(input) {
7637
+ const locators = input.locators ?? [];
7638
+ return locators.find((locator) => locator.id === input.locatorId) ?? locators[0];
7639
+ }
7640
+ /**
7641
+ * Build a {@link GeocoderConfig} from structured project preferences. Used by
7642
+ * the geocode dialog so a per-run provider choice resolves the right endpoints
7643
+ * and API key without round-tripping through runtime env.
7644
+ */
7645
+ function resolveGeocoderConfig(input) {
7646
+ const provider = getGeocodingProvider(input.providerId);
7647
+ if (provider.id === "arcgis-locator") {
7648
+ const locator = selectedGeocodingLocator(input);
7649
+ const endpoints = locator ? locatorEndpoints(locator) : provider;
7650
+ return {
7651
+ providerId: provider.id,
7652
+ forwardEndpoint: endpoints.defaultForwardEndpoint,
7653
+ reverseEndpoint: endpoints.defaultReverseEndpoint,
7654
+ email: input.email?.trim() || void 0,
7655
+ ...locator ? { locator } : {}
7656
+ };
7657
+ }
7658
+ return {
7659
+ providerId: provider.id,
7660
+ forwardEndpoint: input.forwardEndpoint?.trim() || provider.defaultForwardEndpoint,
7661
+ reverseEndpoint: input.reverseEndpoint?.trim() || provider.defaultReverseEndpoint,
7662
+ email: input.email?.trim() || void 0,
7663
+ apiKey: input.apiKeys?.[provider.id]?.trim() || void 0
7664
+ };
7665
+ }
7666
+ /** Hostname of `endpoint`, or null when it does not parse as a URL. */
7667
+ function geocoderHostname(endpoint) {
7668
+ try {
7669
+ return new URL(endpoint).hostname;
7670
+ } catch {
7671
+ return null;
7672
+ }
7673
+ }
7674
+ /** Whether `hostname` is the public CartoCiudad service or a subdomain of it. */
7675
+ function isCartociudadHost(hostname) {
7676
+ if (hostname === null) return false;
7677
+ return hostname === "cartociudad.es" || hostname.endsWith(`.cartociudad.es`);
7678
+ }
7679
+ /**
7680
+ * Whether requests to `endpoint` must be throttled AND row-capped. True for the
7681
+ * public Nominatim host (its usage policy applies) and, defensively, for any
7682
+ * endpoint that does not parse as a URL. Every other host (a self-hosted
7683
+ * Nominatim or a keyed provider) returns false.
7684
+ *
7685
+ * Note this is the *Nominatim policy* gate, not "is this host paced at all" —
7686
+ * see {@link geocoderMinIntervalMs}, which paces CartoCiudad more gently
7687
+ * without capping its row count.
7688
+ */
7689
+ function shouldThrottle(endpoint) {
7690
+ const hostname = geocoderHostname(endpoint);
7691
+ return hostname === null || hostname === "nominatim.openstreetmap.org";
7692
+ }
7693
+ /**
7694
+ * The row cap to apply for `endpoint`: a finite cap for the public Nominatim
7695
+ * host, else Infinity. CartoCiudad is uncapped — IGN publishes no row limit and
7696
+ * its own bulk address geocoder processes up to 60,000 records per run, so a
7697
+ * cap here would be an invented constraint. It is still paced; see
7698
+ * {@link geocoderMinIntervalMs}.
7699
+ */
7700
+ function rowCap(endpoint) {
7701
+ return shouldThrottle(endpoint) ? PUBLIC_GEOCODE_ROW_CAP : Number.POSITIVE_INFINITY;
7702
+ }
7703
+ /**
7704
+ * Minimum spacing (ms) between requests for `endpoint`. Gated on the hostname,
7705
+ * not the selected provider, so pointing any provider at the public Nominatim
7706
+ * host still honors its 1 req/sec policy.
7707
+ *
7708
+ * CartoCiudad gets a gentler pace of its own. IGN documents no rate limit for
7709
+ * the single-lookup `find`/`reverseGeocode` endpoints either way, so the honest
7710
+ * position is that unbounded bursting is unverified rather than sanctioned:
7711
+ * a large CSV run against a free public government service with zero spacing
7712
+ * risks getting the caller's IP throttled and degrading the service for
7713
+ * everyone. Nominatim's 1 req/sec is that provider's published policy and is
7714
+ * not borrowed here. Keyed providers and self-hosted endpoints are paced by
7715
+ * their own quotas, not by us.
7716
+ */
7717
+ function geocoderMinIntervalMs(endpoint) {
7718
+ if (shouldThrottle(endpoint)) return NOMINATIM_MIN_INTERVAL_MS;
7719
+ return isCartociudadHost(geocoderHostname(endpoint)) ? 200 : 0;
7720
+ }
7721
+ /** Hosted Pelias service that rejects keyless requests. */
7722
+ const GEOCODE_EARTH_HOST_SUFFIX = "geocode.earth";
7723
+ /**
7724
+ * Whether `config` cannot geocode without an API key. True for the keyed
7725
+ * providers (ArcGIS, Mapbox, Google) and for Pelias only when it targets the
7726
+ * hosted geocode.earth endpoint (a self-hosted Pelias needs no key). Drives the
7727
+ * batch dialog's "missing key" warning and disabled run.
7728
+ */
7729
+ function geocoderNeedsApiKey(config) {
7730
+ const provider = getGeocodingProvider(config.providerId);
7731
+ if (provider.requiresApiKey) return true;
7732
+ if (provider.id === "pelias") try {
7733
+ const host = new URL(config.forwardEndpoint).hostname;
7734
+ return host === GEOCODE_EARTH_HOST_SUFFIX || host.endsWith(`.${GEOCODE_EARTH_HOST_SUFFIX}`);
7735
+ } catch {
7736
+ return false;
7737
+ }
7738
+ return false;
7739
+ }
7740
+ /**
7741
+ * Milliseconds to wait before starting the next request so consecutive
7742
+ * requests are spaced at least `intervalMs` apart, measured from the previous
7743
+ * request's start time (not its completion) so a slow network does not double
7744
+ * the wait. Returns 0 for the first request or when enough time has elapsed.
7745
+ */
7746
+ function nextDelayMs(lastStartedAt, now, intervalMs) {
7747
+ if (lastStartedAt === null) return 0;
7748
+ return Math.max(0, intervalMs - (now - lastStartedAt));
7749
+ }
7750
+ /**
7751
+ * Build geocoding requests from parsed CSV rows. The address for each row is
7752
+ * the selected columns trimmed and joined with ", " (so multi-part addresses
7753
+ * like street/city/country can be combined). Rows whose composed address is
7754
+ * empty are skipped.
7755
+ */
7756
+ function csvRowsToGeocodeRequests(rows, addressColumns) {
7757
+ const requests = [];
7758
+ rows.forEach((row, index) => {
7759
+ const address = addressColumns.map((column) => (row[column] ?? "").trim()).filter(Boolean).join(", ").trim();
7760
+ if (!address) return;
7761
+ requests.push({
7762
+ index,
7763
+ address,
7764
+ row
7765
+ });
7766
+ });
7767
+ return requests;
7768
+ }
7769
+ /**
7770
+ * The fetch implementation used by {@link geocodeForward}/{@link geocodeReverse}.
7771
+ * Null means "use the global browser `fetch`" (the default for the web and
7772
+ * embedded builds).
7773
+ */
7774
+ let geocodingFetch = null;
7775
+ /**
7776
+ * Override the fetch used for geocoding requests, or pass null to restore the
7777
+ * global browser `fetch`.
7778
+ *
7779
+ * The desktop shell sets this to Tauri's native HTTP fetch so geocoding requests
7780
+ * are made from the Rust side, which:
7781
+ * - bypasses the WebView's CORS enforcement. Public Nominatim's CDN
7782
+ * intermittently omits the `Access-Control-Allow-Origin` header on cached
7783
+ * responses, which the browser then rejects — surfacing to the user as
7784
+ * "Search failed. Try again." (the symptom that failed Microsoft Store
7785
+ * certification); and
7786
+ * - can send a `User-Agent` identifying the app, as Nominatim's usage policy
7787
+ * requires (browser fetch cannot set that header).
7788
+ *
7789
+ * Safe to call multiple times; only the most recent implementation is used.
7790
+ */
7791
+ function setGeocodingFetch(fetchImpl) {
7792
+ geocodingFetch = fetchImpl;
7793
+ }
7794
+ let geocodingTokenProvider = null;
7795
+ /**
7796
+ * Register how a secured locator gets its token, or pass null to remove it.
7797
+ *
7798
+ * Core has no sign-in of its own: the app installs a provider that reuses its
7799
+ * ArcGIS portal sessions. It is only consulted for a locator that names a
7800
+ * `portalUrl`, and the provider decides whether that portal's token may be sent
7801
+ * to the locator's host, so a token is never forwarded to an unrelated server.
7802
+ *
7803
+ * @param provider - The token provider, or null.
7804
+ */
7805
+ function setGeocodingTokenProvider(provider) {
7806
+ geocodingTokenProvider = provider;
7807
+ }
7808
+ /** The token to send with a request for `config`, when its locator is secured. */
7809
+ async function locatorToken(config) {
7810
+ const locator = config.providerId === "arcgis-locator" ? config.locator : void 0;
7811
+ if (!locator?.portalUrl || !geocodingTokenProvider) return void 0;
7812
+ return geocodingTokenProvider(locator);
7813
+ }
7814
+ /**
7815
+ * `url` with the locator's token added, when there is one. The token is only
7816
+ * attached to a request under the locator's own GeocodeServer URL, whatever
7817
+ * endpoint the config carries.
7818
+ *
7819
+ * @returns The URL, and whether a token was attached (such a request must not
7820
+ * follow redirects, which could replay the token to another host).
7821
+ */
7822
+ async function withLocatorToken(url, config) {
7823
+ const token = await locatorToken(config);
7824
+ if (!token) return {
7825
+ url,
7826
+ token: false
7827
+ };
7828
+ const withToken = new URL(url);
7829
+ const base = `${config.locator?.url}/`;
7830
+ if (!`${withToken.origin}${withToken.pathname}`.startsWith(base)) throw new GeocoderServiceError("The geocoding request does not go to the locator's own server, so its sign-in is not sent.");
7831
+ withToken.searchParams.set("token", token);
7832
+ return {
7833
+ url: withToken.toString(),
7834
+ token: true
7835
+ };
7836
+ }
7837
+ /**
7838
+ * Whether `config` can reverse-geocode: a locator only when its metadata
7839
+ * advertises `reverseGeocode` (unchecked locators are assumed to).
7840
+ *
7841
+ * @param config - The geocoder configuration.
7842
+ * @returns Whether reverse geocoding is available.
7843
+ */
7844
+ function geocoderSupportsReverse(config) {
7845
+ const provider = getGeocodingProvider(config.providerId);
7846
+ if (!provider.reverse) return false;
7847
+ return provider.id !== "arcgis-locator" || config.locator?.reverse !== false;
7848
+ }
7849
+ /**
7850
+ * Whether `config` offers suggestions while typing: a locator whose metadata
7851
+ * advertises `suggest`, searched through its single-line field (a suggestion
7852
+ * is a whole address, so it does not fit a structured field the user chose).
7853
+ *
7854
+ * @param config - The geocoder configuration.
7855
+ * @returns Whether {@link geocodeSuggest} can be used.
7856
+ */
7857
+ function geocoderSupportsSuggest(config) {
7858
+ const locator = config.providerId === "arcgis-locator" ? config.locator : void 0;
7859
+ if (!locator?.suggest || !locator.singleLineField) return false;
7860
+ return arcgisLocatorInputField(locator) === locator.singleLineField;
7861
+ }
7862
+ /**
7863
+ * Normalize an ArcGIS `suggest` response. Collection suggestions (a category
7864
+ * such as "Coffee shop") are dropped: they name many places, not one.
7865
+ *
7866
+ * @param data - The parsed response body.
7867
+ * @returns The suggestions.
7868
+ */
7869
+ function parseArcGISSuggestions(data) {
7870
+ const error = arcgisServiceError(data);
7871
+ if (error) throw error;
7872
+ const suggestions = data && typeof data === "object" ? data.suggestions : void 0;
7873
+ if (!Array.isArray(suggestions)) return [];
7874
+ return suggestions.flatMap((entry) => {
7875
+ const text = readString(entry, "text")?.trim();
7876
+ const magicKey = readString(entry, "magicKey");
7877
+ const collection = entry?.isCollection === true;
7878
+ return text && magicKey && !collection ? [{
7879
+ text,
7880
+ magicKey
7881
+ }] : [];
7882
+ });
7883
+ }
7884
+ /**
7885
+ * Ask the selected locator for suggestions that complete `text`. Resolve one
7886
+ * with {@link geocodeForward} and its `magicKey`.
7887
+ *
7888
+ * @param text - What the user has typed so far.
7889
+ * @param options - Abort signal, configuration and the most suggestions wanted.
7890
+ * @returns The suggestions.
7891
+ * @throws GeocoderUnsupportedError When the geocoder does not suggest.
7892
+ * @throws GeocoderServiceError For an HTTP or service error.
7893
+ */
7894
+ async function geocodeSuggest(text, options = {}) {
7895
+ const config = options.config ?? getGeocoderConfig();
7896
+ if (!geocoderSupportsSuggest(config)) throw new GeocoderUnsupportedError("The selected geocoder does not offer suggestions.");
7897
+ const url = new URL(`${requireLocator(config).url}/suggest`);
7898
+ url.searchParams.set("text", text);
7899
+ url.searchParams.set("f", "json");
7900
+ if (options.limit) url.searchParams.set("maxSuggestions", String(options.limit));
7901
+ const request = await withLocatorToken(url.toString(), config);
7902
+ const response = await geocodeFetch()(request.url, {
7903
+ signal: options.signal,
7904
+ headers: { Accept: "application/json" },
7905
+ ...request.token ? { redirect: "error" } : {}
7906
+ });
7907
+ if (!response.ok) throw new GeocoderServiceError(`Geocoder returned HTTP ${response.status}`, response.status);
7908
+ return parseArcGISSuggestions(await readGeocodeJson(response));
7909
+ }
7910
+ /**
7911
+ * Fetch and read a GeocodeServer's capabilities. A secured service is asked
7912
+ * with `token`.
7913
+ *
7914
+ * @param url - The locator's GeocodeServer URL (normalized first).
7915
+ * @param options - An abort signal and an optional token.
7916
+ * @returns The capabilities.
7917
+ * @throws GeocoderServiceError For an HTTP or service error.
7918
+ */
7919
+ async function fetchArcGISLocatorInfo(url, options = {}) {
7920
+ const base = normalizeArcGISLocatorUrl(url);
7921
+ if (!base) throw new GeocoderServiceError("Not an ArcGIS GeocodeServer URL.");
7922
+ const request = new URL(base);
7923
+ request.searchParams.set("f", "json");
7924
+ if (options.token) request.searchParams.set("token", options.token);
7925
+ const response = await geocodeFetch()(request.toString(), {
7926
+ signal: options.signal,
7927
+ headers: { Accept: "application/json" },
7928
+ ...options.token ? { redirect: "error" } : {}
7929
+ });
7930
+ if (!response.ok) throw new GeocoderServiceError(`Geocoder returned HTTP ${response.status}`, response.status);
7931
+ return parseArcGISLocatorInfo(await readGeocodeJson(response));
7932
+ }
7933
+ /** The active geocoding fetch: the injected override, else the global fetch. */
7934
+ function geocodeFetch() {
7935
+ return geocodingFetch ?? fetch;
7936
+ }
7937
+ /**
7938
+ * Read a geocoder response body as JSON, treating an empty one as "no results".
7939
+ *
7940
+ * CartoCiudad answers an unmatched query with HTTP 204 and an empty body rather
7941
+ * than a JSON null (verified against the live `find` and `reverseGeocode`
7942
+ * endpoints). `response.ok` is true for 204, so parsing unconditionally would
7943
+ * throw a JSON syntax error and surface a nonsense address as "Search failed"
7944
+ * instead of "no match". Returning undefined lets each provider's parser report
7945
+ * no results the way it already does for a null body.
7946
+ */
7947
+ async function readGeocodeJson(response) {
7948
+ if (response.status === 204) return void 0;
7949
+ const text = await response.text();
7950
+ if (!text.trim()) return void 0;
7951
+ return JSON.parse(text);
7952
+ }
7953
+ /**
7954
+ * Normalize a longitude into [-180, 180], or null when it is not finite.
7955
+ *
7956
+ * MapLibre reports `lngLat.lng` unwrapped once the map is zoomed out far enough
7957
+ * to show repeated world copies, so a click on a non-primary copy arrives as
7958
+ * e.g. 210 rather than -150. Rejecting those outright would silently drop a
7959
+ * legitimate reverse-geocode click for every provider, so wrap instead.
7960
+ *
7961
+ * An in-range longitude returns untouched: the modulo round-trip is not exact
7962
+ * in floating point (-3.7 comes back as -3.7000000000000455), and the common
7963
+ * case should not be perturbed to handle the rare one.
7964
+ */
7965
+ function wrapLongitude(lon) {
7966
+ if (!Number.isFinite(lon)) return null;
7967
+ if (lon >= -180 && lon <= 180) return lon;
7968
+ return ((lon + 180) % 360 + 360) % 360 - 180;
7969
+ }
7970
+ /**
7971
+ * Forward-geocode a single query through the configured provider, returning
7972
+ * normalized matches.
7973
+ */
7974
+ async function geocodeForward(query, options = {}) {
7975
+ const config = options.config ?? getGeocoderConfig();
7976
+ const provider = getGeocodingProvider(config.providerId);
7977
+ const request = await withLocatorToken(provider.buildForwardUrl(config, query, {
7978
+ email: config.email,
7979
+ limit: options.limit,
7980
+ magicKey: options.magicKey,
7981
+ attributes: options.attributes
7982
+ }), config);
7983
+ const response = await geocodeFetch()(request.url, {
7984
+ signal: options.signal,
7985
+ headers: { Accept: "application/json" },
7986
+ ...request.token ? { redirect: "error" } : {}
7987
+ });
7988
+ if (!response.ok) throw new GeocoderServiceError(`Geocoder returned HTTP ${response.status}`, response.status);
7989
+ const data = await readGeocodeJson(response);
7990
+ return provider.parseForward(data);
7991
+ }
7992
+ /**
7993
+ * Reverse-geocode a single point ([lon, lat]) through the configured provider,
7994
+ * returning a normalized display result or null when no address was found.
7995
+ */
7996
+ async function geocodeReverse(lon, lat, options = {}) {
7997
+ const wrappedLon = wrapLongitude(lon);
7998
+ if (wrappedLon === null || !Number.isFinite(lat) || lat < -90 || lat > 90) return null;
7999
+ const config = options.config ?? getGeocoderConfig();
8000
+ const provider = getGeocodingProvider(config.providerId);
8001
+ if (!geocoderSupportsReverse(config)) throw new GeocoderUnsupportedError("The selected geocoder does not support reverse geocoding.");
8002
+ const request = await withLocatorToken(provider.buildReverseUrl(config, wrappedLon, lat, {
8003
+ email: config.email,
8004
+ zoom: options.zoom
8005
+ }), config);
8006
+ const response = await geocodeFetch()(request.url, {
8007
+ signal: options.signal,
8008
+ headers: { Accept: "application/json" },
8009
+ ...request.token ? { redirect: "error" } : {}
8010
+ });
8011
+ if (!response.ok) throw new GeocoderServiceError(`Geocoder returned HTTP ${response.status}`, response.status);
8012
+ const data = await readGeocodeJson(response);
8013
+ return provider.parseReverse(data);
8014
+ }
8015
+ // src/project.ts
5791
8016
  /** Placeholder name a project carries before the user names it. */
5792
8017
  const DEFAULT_PROJECT_NAME = "Untitled Project";
5793
8018
  function normalizeBlankBackgroundColor(value) {
@@ -6276,6 +8501,7 @@ function normalizeStoryChapter(chapter) {
6276
8501
  },
6277
8502
  mapAnimation: STORY_ANIMATIONS.has(candidate.mapAnimation) ? candidate.mapAnimation : "flyTo",
6278
8503
  rotateAnimation: normalizeBoolean(candidate.rotateAnimation, false),
8504
+ ...typeof candidate.durationSeconds === "number" && Number.isFinite(candidate.durationSeconds) ? { durationSeconds: normalizeStoryChapterDurationSeconds(candidate.durationSeconds) } : {},
6279
8505
  onChapterEnter: normalizeOpacityChanges(candidate.onChapterEnter),
6280
8506
  onChapterExit: normalizeOpacityChanges(candidate.onChapterExit)
6281
8507
  };
@@ -6351,9 +8577,16 @@ function normalizeModels(value) {
6351
8577
  const MODEL_NODE_KINDS = /* @__PURE__ */ new Set([
6352
8578
  "input",
6353
8579
  "tool",
6354
- "output"
8580
+ "output",
8581
+ "iterate-features",
8582
+ "iterate-rasters",
8583
+ "collect-results",
8584
+ "conditional-switch",
8585
+ "conditional-merge"
6355
8586
  ]);
6356
8587
  const MODEL_TOOL_PROVIDERS = /* @__PURE__ */ new Set(["vector", "whitebox"]);
8588
+ /** Deepest loop-body nesting a loaded model keeps; anything deeper is dropped. */
8589
+ const MAX_MODEL_BODY_DEPTH = 16;
6357
8590
  /** Coerce an untrusted number to a finite canvas coordinate. */
6358
8591
  function normalizeCoordinate(value) {
6359
8592
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -6369,9 +8602,10 @@ function normalizeCoordinate(value) {
6369
8602
  * project layer deliberately does not import.
6370
8603
  *
6371
8604
  * @param value Raw `graph` value from the project JSON.
8605
+ * @param depth How many loop bodies deep this graph sits (internal).
6372
8606
  * @returns The normalized graph, or `null` when it has no usable nodes.
6373
8607
  */
6374
- function normalizeModelGraph(value) {
8608
+ function normalizeModelGraph(value, depth = 0) {
6375
8609
  if (!value || typeof value !== "object") return null;
6376
8610
  const raw = value;
6377
8611
  const nodes = [];
@@ -6387,6 +8621,7 @@ function normalizeModelGraph(value) {
6387
8621
  const toolId = normalizeString(node.toolId).trim();
6388
8622
  const name = normalizeString(node.name).trim();
6389
8623
  const provider = node.provider;
8624
+ const body = node.body && depth < MAX_MODEL_BODY_DEPTH ? normalizeModelGraph(node.body, depth + 1) : null;
6390
8625
  nodes.push({
6391
8626
  id: nodeId,
6392
8627
  kind,
@@ -6396,7 +8631,8 @@ function normalizeModelGraph(value) {
6396
8631
  ...toolId ? { toolId } : {},
6397
8632
  ...MODEL_TOOL_PROVIDERS.has(provider) ? { provider } : {},
6398
8633
  ...node.parameters && typeof node.parameters === "object" && !Array.isArray(node.parameters) ? { parameters: node.parameters } : {},
6399
- ...name ? { name } : {}
8634
+ ...name ? { name } : {},
8635
+ ...body ? { body } : {}
6400
8636
  });
6401
8637
  }
6402
8638
  if (nodes.length === 0) return null;
@@ -6866,7 +9102,19 @@ function normalizeProjectInteraction(value) {
6866
9102
  }
6867
9103
  if (raw.controls && typeof raw.controls === "object" && !Array.isArray(raw.controls)) {
6868
9104
  const controls = {};
6869
- for (const [name, shown] of Object.entries(raw.controls)) if (name && typeof shown === "boolean") controls[name] = shown;
9105
+ for (const [name, shown] of Object.entries(raw.controls)) {
9106
+ if (!name) continue;
9107
+ if (typeof shown === "boolean") {
9108
+ controls[name] = shown;
9109
+ continue;
9110
+ }
9111
+ if (!shown || typeof shown !== "object" || Array.isArray(shown)) continue;
9112
+ const setting = shown;
9113
+ if (typeof setting.visible !== "boolean") continue;
9114
+ const kept = { visible: setting.visible };
9115
+ for (const [option, value] of Object.entries(setting)) if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) kept[option] = value;
9116
+ controls[name] = kept;
9117
+ }
6870
9118
  if (Object.keys(controls).length > 0) result.controls = controls;
6871
9119
  }
6872
9120
  return "identify" in result || result.controls ? result : null;
@@ -6891,6 +9139,7 @@ function normalizeProjectPreferences(preferences) {
6891
9139
  mapboxStyleUrl: normalizeString(map.mapboxStyleUrl) || void 0,
6892
9140
  arcgisBasemap: normalizeString(map.arcgisBasemap) || void 0,
6893
9141
  arcgisWkid: normalizeArcgisWkid(map.arcgisWkid),
9142
+ mapCrs: normalizeMapCrs(map.mapCrs),
6894
9143
  cesiumBasemap: normalizeCesiumBasemap(map.cesiumBasemap),
6895
9144
  terrainEnabled: normalizeBoolean(map.terrainEnabled, DEFAULT_PROJECT_PREFERENCES.map.terrainEnabled),
6896
9145
  terrainIonAssetId: parseCesiumIonAssetId(map.terrainIonAssetId) ?? void 0,
@@ -6930,9 +9179,29 @@ function normalizeGeocodingPreferences(geocoding) {
6930
9179
  apiKeys,
6931
9180
  forwardEndpoint: typeof candidate.forwardEndpoint === "string" && candidate.forwardEndpoint.trim() ? candidate.forwardEndpoint.trim() : void 0,
6932
9181
  reverseEndpoint: typeof candidate.reverseEndpoint === "string" && candidate.reverseEndpoint.trim() ? candidate.reverseEndpoint.trim() : void 0,
6933
- email: typeof candidate.email === "string" && candidate.email.trim() ? candidate.email.trim() : void 0
9182
+ email: typeof candidate.email === "string" && candidate.email.trim() ? candidate.email.trim() : void 0,
9183
+ ...normalizeGeocodingLocators(candidate.locators, candidate.locatorId)
6934
9184
  };
6935
9185
  }
9186
+ /**
9187
+ * The project's ArcGIS GeocodeServer locators, dropping malformed entries and
9188
+ * duplicate ids, plus the selected locator id when it still names one. Both are
9189
+ * omitted when there are none, so older projects round-trip unchanged.
9190
+ */
9191
+ function normalizeGeocodingLocators(locators, locatorId) {
9192
+ if (!Array.isArray(locators)) return {};
9193
+ const seen = /* @__PURE__ */ new Set();
9194
+ const result = locators.map(normalizeGeocodingLocator).filter((locator) => {
9195
+ if (!locator || seen.has(locator.id)) return false;
9196
+ seen.add(locator.id);
9197
+ return true;
9198
+ });
9199
+ if (!result.length) return {};
9200
+ return typeof locatorId === "string" && seen.has(locatorId) ? {
9201
+ locators: result,
9202
+ locatorId
9203
+ } : { locators: result };
9204
+ }
6936
9205
  /** Coerce an unknown value to a supported scale unit, defaulting to metric. */
6937
9206
  function normalizeScaleUnit(value) {
6938
9207
  return value === "imperial" || value === "nautical" ? value : "metric";
@@ -7073,6 +9342,11 @@ function withoutZarrHeaders(layer) {
7073
9342
  source
7074
9343
  };
7075
9344
  }
9345
+ function withoutRuntimeGeoArrow(layer) {
9346
+ if (layer.geoarrow === void 0) return layer;
9347
+ const { geoarrow: _geoarrow, ...rest } = layer;
9348
+ return rest;
9349
+ }
7076
9350
  /**
7077
9351
  * Strip the session-only state every serializer must leave out, whether or not
7078
9352
  * the project came through `projectFromStore`.
@@ -7081,11 +9355,11 @@ function withoutZarrHeaders(layer) {
7081
9355
  * @returns The layer as it may be written to a project file.
7082
9356
  */
7083
9357
  function portableLayer(layer) {
7084
- return withoutZarrHeaders(withoutLocalRasterBytes(layer));
9358
+ return withoutRuntimeGeoArrow(withoutZarrHeaders(withoutLocalRasterBytes(layer)));
7085
9359
  }
7086
9360
  function normalizeLayer(layer) {
7087
- layer = withoutLocalRasterBytes(layer);
7088
- const { capabilities: rawCapabilities, filterExpression: rawFilterExpression, descriptiveMetadata: rawDescriptiveMetadata, ...rest } = layer;
9361
+ layer = withRasterSourceDefaults(withoutLocalRasterBytes(layer));
9362
+ const { capabilities: rawCapabilities, filterExpression: rawFilterExpression, descriptiveMetadata: rawDescriptiveMetadata, geoarrow: _rawGeoarrow, ...rest } = layer;
7089
9363
  const capabilities = normalizeLayerCapabilities(rawCapabilities);
7090
9364
  const descriptiveMetadata = normalizeLayerDescriptiveMetadata(rawDescriptiveMetadata);
7091
9365
  const filterExpression = Array.isArray(rawFilterExpression) && rawFilterExpression.length > 0 && validateMapExpression(JSON.stringify(rawFilterExpression), { expectedType: "boolean" }).ok ? rawFilterExpression : void 0;
@@ -7387,6 +9661,7 @@ function portableWmsTileUrl(tile) {
7387
9661
  * @returns A new layer record ready for the store.
7388
9662
  */
7389
9663
  function hydrateProjectLayer(project, layer) {
9664
+ layer = withRasterSourceDefaults(layer);
7390
9665
  const topLevel = project.styles?.[layer.id];
7391
9666
  return {
7392
9667
  ...layer,
@@ -7502,8 +9777,7 @@ function createProjectTemplate(project, options = {}) {
7502
9777
  }
7503
9778
  };
7504
9779
  }
7505
- //#endregion
7506
- //#region src/project-diff.ts
9780
+ // src/project-diff.ts
7507
9781
  const DEFAULT_PREVIEW_LENGTH = 80;
7508
9782
  const CAMERA_EPSILON = 1e-6;
7509
9783
  /** Layer fields reported in their own group, never under `other`. */
@@ -8134,7 +10408,6 @@ function metadataRank(path) {
8134
10408
  if (path === "description") return 1;
8135
10409
  return 2;
8136
10410
  }
8137
- //#endregion
8138
- export { isHeaderReferenceOnly as $, geoJSONToPolylineStr as $n, MAX_DASHBOARD_COLUMNS as $r, layerGroupSortability as $t, normalizeSecondaryMapViews as A, metadataDateToRfc3339 as An, getActiveEllipsoid as Ar, normalizeStyleLibraryEntries as At, storyMapHasContent as B, isStyleSpecColor as Bn, DEFAULT_BASEMAP as Br, scrubPrintLayoutForRemovedLayers as Bt, normalizeModelGraph as C, stripVirtualFieldColumns as Cn, PLANETARY_BASEMAP_GROUPS as Cr, RAMP_STYLE_KEYS as Ct, normalizeProcessingHistory as D, isValidMetadataDate as Dn, earthAreaToBody as Dr, createStyleLibraryEntryId as Dt, normalizePrimaryRenderer as E, buildLayerStacItem as En, bodyLengthToEarth as Er, SYMBOL_STYLE_KEYS as Et, projectFromStore as F, EXPRESSION_FUNCTION_CATEGORIES as Fn, getPlanetaryBasemapByStyleUrl as Fr, createDefaultPrintLayout as Ft, isCredentialUrlParam as G, SELECTION_MODES as Gn, DEFAULT_MAP_GRID_LAYOUT as Gr, DEFAULT_LAYER_GROUP_OPACITY as Gt, PROJECT_CREDENTIAL_FIELDS as H, removeTrailingJsonCommas as Hn, DEFAULT_DASHBOARD_COLUMNS as Hr, scrubCommentsForRemovedLayers as Ht, resolveMapGrid as I, compileFeatureExpression as In, meanRadiusMeters as Ir, isDefaultPrintLayout as It, redactProjectCredentials as J, invertSelection as Jn, DEFAULT_STORY_MAP as Jr, buildLayerTree as Jt, redactConfigurationCredentials as K, applySelectionMode as Kn, DEFAULT_PRIMARY_RENDERER as Kr, applyGroupEffects as Kt, serializeProject as L, evaluateMapExpression as Ln, setActiveEllipsoidId as Lr, normalizePrintLayoutConfig as Lt, normalizeWidgets as M, parseMetadataKeywords as Mn, getActiveSemiMajorAxisMeters as Mr, sanitizeLayerStylePatch as Mt, parseProject as N, stacAssetMediaType as Nn, getEllipsoid as Nr, serializeStyleLibrary as Nt, normalizeProjectComments as O, isValidMetadataEmail as On, earthLengthToBody as Or, extractStyleLibraryStyle as Ot, portableWmsTileUrl as P, validateLayerDescriptiveMetadata as Pn, getPlanetaryBasemapById as Pr, DEFAULT_PRINT_LAYOUT as Pt, hasHeaderReferences as Q, encodePolyline as Qn, LAYER_TYPES as Qr, layerGroupMoveability as Qt, serializeProjectWithLayerCache as R, formatExpressionPreviewValue as Rn, BLANK_BASEMAP as Rr, printLayoutConfigsEqual as Rt, normalizeMapViewState as S, applyLayerVirtualFields as Sn, PLANETARY_BASEMAPS as Sr, BUILT_IN_STYLE_PRESETS as St, normalizeObiaWorkbench as T, STAC_VERSION as Tn, PLANET_SWITCHER_OPTIONS as Tr, STYLE_LIBRARY_BUNDLE_VERSION as Tt, PUBLISHABLE_PLUGIN_SETTINGS as U, substituteExpressionVariables as Un, DEFAULT_LAYER_STYLE as Ur, scrubLegendForRemovedLayers as Ut, MAX_REDACT_DEPTH as V, matchFeaturesByExpression as Vn, DEFAULT_BLEND_MODE as Vr, removedLayerIdSet as Vt, isCredentialFieldName as W, validateMapExpression as Wn, DEFAULT_LEGEND_CONFIG as Wr, scrubWidgetsForRemovedLayers as Wt, setRegistryPublishableSettings as X, decodePolyline as Xn, DUCKDB_VECTOR_ROUTE_BYTES as Xr, groupMemberIndices as Xt, redactUrlCredentials as Y, batchDecodePolylines as Yn, DUCKDB_VECTOR_FEATURE_WARN_COUNT as Yr, effectiveLayerRenderState as Yt, allowsCredentialHeaders as Z, decodePolylineDetailed as Zn, LARGE_VECTOR_FEATURE_THRESHOLD as Zr, layerGroupDepth as Zt, normalizeBookmarkGroups as _, cascadeLayerJoinRefresh as _n, getRegionalBasemapByStyleUrl as _r, CZML_SOURCE_KIND as _t, DEFAULT_PROJECT_NAME as a, PROJECT_VERSION as ai, ROLE_PRIVILEGES as an, ARCGIS_PROJECTION_PRESETS as ar, CESIUM_OSM_BUILDINGS_ASSET_ID as at, normalizeLegendConfig as b, reapplyLayerJoins as bn, EARTH_MEAN_RADIUS_METERS as br, isCzmlLayer as bt, createDefaultMapView as c, detectNonGeographicCoordinates as ci, hasAppPrivilege as cn, availableCesiumBasemap as cr, createCesiumIonLayer as ct, createProjectTemplate as d, styleValue as di, normalizeAppPrivileges as dn, sameCesiumImagery as dr, parseCesiumIonAssetId as dt, MAX_MAP_GRID_DIM as ei, layerPanelGroupHeaders as en, polylineStrToGeoJSON as er, resolveHeaderReferences as et, detachProjectCopy as f, normalizeLayerCapabilities as fn, CHINA_BASEMAPS as fr, CESIUM_KML_SOURCE_KIND as ft, normalizeBlankBackgroundColor as g, applyLayerJoins as gn, getRegionalBasemapById as gr, CZML_QUICK_PICKS as gt, isSessionOnlyLayer as h, applyJoinsToLayer as hn, REGIONAL_BASEMAP_SENTINEL_PREFIX as hr, isCesiumKmlLayer as ht, previewValue as i, OPENFREEMAP_BASEMAPS as ii, ALL_APP_PRIVILEGES as in, transformGeojsonElevation as ir, CESIUM_ION_SOURCE_KIND as it, normalizeStoryMap as j, normalizeLayerDescriptiveMetadata as jn, getActiveMeanRadiusMeters as jr, parseStyleLibrary as jt, normalizeProjectInteraction as k, isValidMetadataUrl as kn, getActiveBodyRadiusRatio as kr, isStyleLibraryTargetLayer as kt, createEmptyProject as l, isDuckDBQueryLayer as li, inferLayerCapabilities as ln, basemapToCesiumImagery as lr, isCesiumIonLayer as lt, isAllowedPluginManifestUrl as m, resolveRolePrivileges as mn, REGIONAL_BASEMAP_GROUPS as mr, createCesiumKmlLayer as mt, diffProjects as n, MIN_DASHBOARD_COLUMNS as ni, reorderLayerGroupInPanel as nn, geojsonHasZCoordinates as nr, CESIUM_GOOGLE_PHOTOREALISTIC_ASSET_ID as nt, MAX_PROJECT_BOOKMARKS as o, PROTOMAPS_BASEMAPS as oi, appPrivilegeReason as on, normalizeArcgisWkid as or, cesiumIonAssetId as ot, hydrateProjectLayer as p, resolveLayerCapabilities as pn, REGIONAL_BASEMAPS as pr, cesiumKmlSource as pt, redactCredentials as q, featureSelectionId as qn, DEFAULT_PROJECT_PREFERENCES as qr, buildLayerPanelUnits as qt, jsonValuesEqual as r, NETCDF_IMAGE_SOURCE_KIND as ri, sortLayerGroupInPanel as rn, horizontalBbox as rr, CESIUM_ION_QUICK_PICKS as rt, applyProjectToStore as s, SQL_QUERY_SOURCE_KIND as si, createDefaultAppCapabilities as sn, CESIUM_BASEMAPS as sr, cesiumIonAssetKind as st, diffFeatureCollections as t, MAX_PROCESSING_HISTORY as ti, normalizeGroupContiguity as tn, unescapePolyline as tr, CESIUM_BING_AERIAL_ASSET_ID as tt, createProjectLayerSerializationCache as u, shouldUseTiledRendering as ui, intersectPrivileges as un, normalizeCesiumBasemap as ur, isCesiumOnlyLayer as ut, normalizeBookmarks as v, collectTransitiveJoinSourceIds as vn, isRegionalBasemapSentinel as vr, createCzmlLayer as vt, normalizeModels as w, STAC_PROCESSING_EXTENSION as wn, PLANETARY_BASEMAP_SENTINEL_PREFIX as wr, STYLE_LIBRARY_BUNDLE_TYPE as wt, normalizeMapLayout as x, stripJoinFields as xn, ELLIPSOIDS as xr, parseCzml as xt, normalizeDashboardColumns as y, layerJoinKey as yn, DEFAULT_ELLIPSOID_ID as yr, czmlSource as yt, serializeProjectWithLayerCacheAsync as z, inferFieldTypes as zn, BLEND_MODES as zr, scrubPrintLayoutForLayers as zt };
10411
+ export { GEOCODING_PROVIDERS as $, getPlanetaryBasemapById as $i, groupMemberIndices as $n, getBuildEnvironment as $r, CESIUM_BING_AERIAL_ASSET_ID as $t, normalizeSecondaryMapViews as A, styleValue as Aa, sameCesiumImagery as Ai, createStyleLibraryEntryId as An, isValidMetadataDate as Ar, selectedGeocodingLocator as At, storyMapHasContent as B, ELLIPSOIDS as Bi, normalizePrintLayoutConfig as Bn, evaluateMapExpression as Br, PROJECT_CREDENTIAL_FIELDS as Bt, normalizeModelGraph as C, OPENFREEMAP_BASEMAPS as Ca, parseMapCrsEpsgCode as Ci, isGeoArrowBatch as Cn, reapplyLayerJoins as Cr, normalizeArcGISLocatorUrl as Ct, normalizeProcessingHistory as D, detectNonGeographicCoordinates as Da, availableCesiumBasemap as Di, STYLE_LIBRARY_BUNDLE_TYPE as Dn, STAC_PROCESSING_EXTENSION as Dr, parseArcGISSuggestions as Dt, normalizePrimaryRenderer as E, SQL_QUERY_SOURCE_KIND as Ea, CESIUM_BASEMAPS as Ei, RAMP_STYLE_KEYS as En, stripVirtualFieldColumns as Er, parseArcGISLocatorInfo as Et, projectFromStore as F, getRegionalBasemapById as Fi, sanitizeLayerStylePatch as Fn, parseMetadataKeywords as Fr, DEFAULT_STORY_CHAPTER_DURATION_SECONDS as Ft, DEFAULT_REVERSE_GEOCODE_ENDPOINT as G, bodyLengthToEarth as Gi, scrubCommentsForRemovedLayers as Gn, removeTrailingJsonCommas as Gr, redactCredentials as Gt, CARTOCIUDAD_PUBLIC_HOST as H, PLANETARY_BASEMAP_GROUPS as Hi, scrubPrintLayoutForLayers as Hn, inferFieldTypes as Hr, isCredentialFieldName as Ht, resolveMapGrid as I, getRegionalBasemapByStyleUrl as Ii, serializeStyleLibrary as In, stacAssetMediaType as Ir, MAX_STORY_CHAPTER_DURATION_SECONDS as It, GEOCODE_LAT_KEY as J, getActiveBodyRadiusRatio as Ji, DEFAULT_LAYER_GROUP_OPACITY as Jn, SELECTION_MODES as Jr, setRegistryPublishableSettings as Jt, GEOCODE_ATTRIBUTE_PREFIX as K, earthAreaToBody as Ki, scrubLegendForRemovedLayers as Kn, substituteExpressionVariables as Kr, redactProjectCredentials as Kt, serializeProject as L, isRegionalBasemapSentinel as Li, DEFAULT_PRINT_LAYOUT as Ln, validateLayerDescriptiveMetadata as Lr, MIN_STORY_CHAPTER_DURATION_SECONDS as Lt, normalizeWidgets as M, REGIONAL_BASEMAPS as Mi, isStyleLibraryTargetLayer as Mn, isValidMetadataUrl as Mr, setGeocodingTokenProvider as Mt, parseProject as N, REGIONAL_BASEMAP_GROUPS as Ni, normalizeStyleLibraryEntries as Nn, metadataDateToRfc3339 as Nr, shouldThrottle as Nt, normalizeProjectComments as O, isDuckDBQueryLayer as Oa, basemapToCesiumImagery as Oi, STYLE_LIBRARY_BUNDLE_VERSION as On, STAC_VERSION as Or, resolveGeocoderConfig as Ot, portableWmsTileUrl as P, REGIONAL_BASEMAP_SENTINEL_PREFIX as Pi, parseStyleLibrary as Pn, normalizeLayerDescriptiveMetadata as Pr, unmatchedGeocodeFeature as Pt, GEOCODE_STATUS_KEY as Q, getEllipsoid as Qi, effectiveLayerRenderState as Qn, getArcgisApiKey as Qr, resolveHeaderReferences as Qt, serializeProjectWithLayerCache as R, DEFAULT_ELLIPSOID_ID as Ri, createDefaultPrintLayout as Rn, EXPRESSION_FUNCTION_CATEGORIES as Rr, normalizeStoryChapterDurationSeconds as Rt, normalizeMapViewState as S, NETCDF_IMAGE_SOURCE_KIND as Sa, normalizeMapCrs as Si, geoArrowToFeatureCollection as Sn, layerJoinKey as Sr, nominatimReverseResultToDisplay as St, normalizeObiaWorkbench as T, PROTOMAPS_BASEMAPS as Ta, normalizeArcgisWkid as Ti, BUILT_IN_STYLE_PRESETS as Tn, applyLayerVirtualFields as Tr, normalizeGeocodingProviderId as Tt, DEFAULT_FORWARD_GEOCODE_ENDPOINT as U, PLANETARY_BASEMAP_SENTINEL_PREFIX as Ui, scrubPrintLayoutForRemovedLayers as Un, isStyleSpecColor as Ur, isCredentialUrlParam as Ut, CARTOCIUDAD_MIN_INTERVAL_MS as V, PLANETARY_BASEMAPS as Vi, printLayoutConfigsEqual as Vn, formatExpressionPreviewValue as Vr, PUBLISHABLE_PLUGIN_SETTINGS as Vt, DEFAULT_GEOCODING_PROVIDER_ID as W, PLANET_SWITCHER_OPTIONS as Wi, removedLayerIdSet as Wn, matchFeaturesByExpression as Wr, redactConfigurationCredentials as Wt, GEOCODE_PROVIDER_KEY as X, getActiveMeanRadiusMeters as Xi, buildLayerPanelUnits as Xn, featureSelectionId as Xr, hasHeaderReferences as Xt, GEOCODE_LON_KEY as Y, getActiveEllipsoid as Yi, applyGroupEffects as Yn, applySelectionMode as Yr, allowsCredentialHeaders as Yt, GEOCODE_SCORE_KEY as Z, getActiveSemiMajorAxisMeters as Zi, buildLayerTree as Zn, invertSelection as Zr, isHeaderReferenceOnly as Zt, normalizeBookmarkGroups as _, LAYER_TYPES as _a, MAP_CRS_CHANGE_EVENT as _i, czmlSource as _n, resolveRolePrivileges as _r, geocoderSupportsSuggest as _t, DEFAULT_PROJECT_NAME as a, DEFAULT_BASEMAP as aa, getRuntimeEnvironment as ai, cesiumIonAssetKind as an, reorderLayerGroupInPanel as ar, arcgisLocatorInputField as at, normalizeLegendConfig as b, MAX_PROCESSING_HISTORY as ba, deriveTileMatrixSet as bi, computeGeoArrowBBox as bn, cascadeLayerJoinRefresh as br, nextDelayMs as bt, createDefaultMapView as c, DEFAULT_LAYER_STYLE as ca, decodePolyline as ci, isCesiumOnlyLayer as cn, ROLE_PRIVILEGES as cr, csvRowsToGeocodeRequests as ct, createProjectTemplate as d, DEFAULT_PRIMARY_RENDERER as da, geoJSONToPolylineStr as di, cesiumKmlSource as dn, hasAppPrivilege as dr, geocodeMatchToFeature as dt, getPlanetaryBasemapByStyleUrl as ea, getCesiumIonToken as ei, CESIUM_GOOGLE_PHOTOREALISTIC_ASSET_ID as en, layerGroupDepth as er, GeocoderServiceError as et, detachProjectCopy as f, DEFAULT_PROJECT_PREFERENCES as fa, polylineStrToGeoJSON as fi, createCesiumKmlLayer as fn, inferLayerCapabilities as fr, geocodeReverse as ft, normalizeBlankBackgroundColor as g, LARGE_VECTOR_FEATURE_THRESHOLD as ga, transformGeojsonElevation as gi, createCzmlLayer as gn, resolveLayerCapabilities as gr, geocoderSupportsReverse as gt, isSessionOnlyLayer as h, DUCKDB_VECTOR_ROUTE_BYTES as ha, horizontalBbox as hi, CZML_SOURCE_KIND as hn, normalizeLayerCapabilities as hr, geocoderNeedsApiKey as ht, previewValue as i, BLEND_MODES as ia, getProtomapsStyleUrl as ii, cesiumIonAssetId as in, normalizeGroupContiguity as ir, PUBLIC_GEOCODE_ROW_CAP as it, normalizeStoryMap as j, CHINA_BASEMAPS as ji, extractStyleLibraryStyle as jn, isValidMetadataEmail as jr, setGeocodingFetch as jt, normalizeProjectInteraction as k, shouldUseTiledRendering as ka, normalizeCesiumBasemap as ki, SYMBOL_STYLE_KEYS as kn, buildLayerStacItem as kr, rowCap as kt, createEmptyProject as l, DEFAULT_LEGEND_CONFIG as la, decodePolylineDetailed as li, parseCesiumIonAssetId as ln, appPrivilegeReason as lr, fetchArcGISLocatorInfo as lt, isAllowedPluginManifestUrl as m, DUCKDB_VECTOR_FEATURE_WARN_COUNT as ma, geojsonHasZCoordinates as mi, CZML_QUICK_PICKS as mn, normalizeAppPrivileges as mr, geocoderMinIntervalMs as mt, diffProjects as n, setActiveEllipsoidId as na, getMapboxAccessToken as ni, CESIUM_ION_SOURCE_KIND as nn, layerGroupSortability as nr, NOMINATIM_MIN_INTERVAL_MS as nt, MAX_PROJECT_BOOKMARKS as o, DEFAULT_BLEND_MODE as oa, getSpatialExtensionPath as oi, createCesiumIonLayer as on, sortLayerGroupInPanel as or, buildForwardGeocodeUrl as ot, hydrateProjectLayer as p, DEFAULT_STORY_MAP as pa, unescapePolyline as pi, isCesiumKmlLayer as pn, intersectPrivileges as pr, geocodeSuggest as pt, GEOCODE_DISPLAY_NAME_KEY as q, earthLengthToBody as qi, scrubWidgetsForRemovedLayers as qn, validateMapExpression as qr, redactUrlCredentials as qt, jsonValuesEqual as r, BLANK_BASEMAP as ra, getProtomapsApiKey as ri, CESIUM_OSM_BUILDINGS_ASSET_ID as rn, layerPanelGroupHeaders as rr, NOMINATIM_PUBLIC_HOST as rt, applyProjectToStore as s, DEFAULT_DASHBOARD_COLUMNS as sa, batchDecodePolylines as si, isCesiumIonLayer as sn, ALL_APP_PRIVILEGES as sr, buildReverseGeocodeUrl as st, diffFeatureCollections as t, meanRadiusMeters as ta, getGoogleMapsApiKey as ti, CESIUM_ION_QUICK_PICKS as tn, layerGroupMoveability as tr, GeocoderUnsupportedError as tt, createProjectLayerSerializationCache as u, DEFAULT_MAP_GRID_LAYOUT as ua, encodePolyline as ui, CESIUM_KML_SOURCE_KIND as un, createDefaultAppCapabilities as ur, geocodeForward as ut, normalizeBookmarks as v, MAX_DASHBOARD_COLUMNS as va, MAP_CRS_PRESETS as vi, isCzmlLayer as vn, applyJoinsToLayer as vr, getGeocoderConfig as vt, normalizeModels as w, PROJECT_VERSION as wa, ARCGIS_PROJECTION_PRESETS as wi, withRasterSourceDefaults as wn, stripJoinFields as wr, normalizeGeocodingLocator as wt, normalizeMapLayout as x, MIN_DASHBOARD_COLUMNS as xa, getMapCrsPreset as xi, featureCollectionToGeoArrow as xn, collectTransitiveJoinSourceIds as xr, nominatimResultToFeature as xt, normalizeDashboardColumns as y, MAX_MAP_GRID_DIM as ya, adaptBboxTemplateToCrs as yi, parseCzml as yn, applyLayerJoins as yr, getGeocodingProvider as yt, serializeProjectWithLayerCacheAsync as z, EARTH_MEAN_RADIUS_METERS as zi, isDefaultPrintLayout as zn, compileFeatureExpression as zr, MAX_REDACT_DEPTH as zt };
8139
10412
 
8140
- //# sourceMappingURL=project-diff-Bua7fBME.mjs.map
10413
+ //# sourceMappingURL=project-diff-CSG1AgSK.mjs.map