Repository: nasa/earthdata-search
Pull request #1879: EDSC-4413: Handles drawing polygons with holes, makes place labels style an env variable, fixes boxes drawing
Author: login: macrouch
Created: 2025-03-18T02:14:46Z | Merged: 2025-03-19T20:25:01Z

# Overview

### What is the feature?

Handles drawing polygons with holes.
Makes place labels style an env variable.
Bounding boxes are now draw correctly in polar projections.

### What areas of the application does this impact?

Drawing granules

# Testing

### Reproduction steps

Here is a collection that has a granule with polygon spatial and holes in the polygon
http://localhost:8080/search/granules?p=C1000000300-NSIDC_ECS&pg[0][v]=f&q=C1000000300-NSIDC_ECS&tl=1372766400!4!!&lat=77.54691817019817&long=-46.81839479731755&zoom=4.042402631944105

Here is a collection with bounding boxes around the poles in the polar projections.
(There is still an issue with bounding boxes not being drawn in the stack like regular polygons, but I'm still looking into it)
http://localhost:8080/search/granules?p=C2515837343-GES_DISC&pg[0][v]=f&pg[0][gsk]=-start_date&tl=1386509289.242!5!!&lat=87.38227284167463&long=130.96516171776165&projection=EPSG%3A3413&zoom=1.4633762091561429

### Attachments

<img width="647" alt="Screenshot 2025-03-17 at 10 14 09 PM" src="https://github.com/user-attachments/assets/a17ae772-066c-4ba4-be91-e3356d49c46f" />

<img width="650" alt="Screenshot 2025-03-17 at 9 19 12 PM" src="https://github.com/user-attachments/assets/291cec82-25ff-4ce1-9abf-3dc2157126ce" />

<img width="677" alt="Screenshot 2025-03-17 at 11 00 01 PM" src="https://github.com/user-attachments/assets/11d38641-b15e-4d0a-9bbe-f1ec35575bd3" />


# Checklist

- [x] I have added automated tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings



Diff:
diff --git a/bin/deploy-bamboo.sh b/bin/deploy-bamboo.sh
index 31a5c3b9ea..21138c286c 100755
--- a/bin/deploy-bamboo.sh
+++ b/bin/deploy-bamboo.sh
@@ -35,6 +35,7 @@ config="`jq '.application.orderStatusRefreshTime = $newValue' --arg newValue $ba
 config="`jq '.application.orderStatusRefreshTimeCreating = $newValue' --arg newValue $bamboo_ORDER_STATUS_REFRESH_TIME_CREATING <<< $config`"
 config="`jq '.application.collectionSearchResultsSortKey = $newValue' --arg newValue $bamboo_COLLECTION_SEARCH_RESULTS_SORT_KEY <<< $config`"
 config="`jq '.application.mapPointsSimplifyThreshold = $newValue' --arg newValue $bamboo_MAP_POINTS_SIMPLIFY_THRESHOLD <<< $config`"
+config="`jq '.application.placeLabelsStyleUrl = $newValue' --arg newValue $bamboo_PLACE_LABELS_STYLE_URL <<< $config`"
 config="`jq '.environment.production.apiHost = $newValue' --arg newValue $bamboo_API_HOST <<< $config`"
 config="`jq '.environment.production.edscHost = $newValue' --arg newValue $bamboo_EDSC_HOST <<< $config`"
 
diff --git a/static.config.json b/static.config.json
index c4d64cd888..c05667e910 100644
--- a/static.config.json
+++ b/static.config.json
@@ -51,7 +51,8 @@
     "disableOrdering": "false",
     "disableSwodlr": "false",
     "disableSiteTour": "false",
-    "mapPointsSimplifyThreshold": 2000
+    "mapPointsSimplifyThreshold": 2000,
+    "placeLabelsStyleUrl": "https://nasa.maps.arcgis.com/sharing/rest/content/items/8a79d68248f4415fb191077549d982e1/resources/styles/root.json?f=pjson"
   },
   "environment": {
     "test": {
diff --git a/static/src/js/util/map/__tests__/normalizeGranuleSpatial.test.js b/static/src/js/util/map/__tests__/normalizeGranuleSpatial.test.js
index 2b6ecf5bdd..a2703a12a3 100644
--- a/static/src/js/util/map/__tests__/normalizeGranuleSpatial.test.js
+++ b/static/src/js/util/map/__tests__/normalizeGranuleSpatial.test.js
@@ -21,15 +21,28 @@ describe('normalizeGranuleSpatial', () => {
 
       expect(response).toEqual({
         type: 'Feature',
+        properties: {},
         geometry: {
           type: 'MultiPolygon',
           coordinates: [
             [
               [
                 [1, 0],
-                [1, 10],
-                [11, 10],
+                [3.5, 0],
+                [6, 0],
+                [8.5, 0],
                 [11, 0],
+                [11, 2.5],
+                [11, 5],
+                [11, 7.5],
+                [11, 10],
+                [8.5, 10],
+                [6, 10],
+                [3.5, 10],
+                [1, 10],
+                [1, 7.5],
+                [1, 5],
+                [1, 2.5],
                 [1, 0]
               ]
             ]
@@ -48,24 +61,49 @@ describe('normalizeGranuleSpatial', () => {
 
         expect(response).toEqual({
           type: 'Feature',
+          properties: {},
           geometry: {
             type: 'MultiPolygon',
             coordinates: [
               [
                 [
                   [1, 0],
-                  [1, 10],
-                  [11, 10],
+                  [3.5, 0],
+                  [6, 0],
+                  [8.5, 0],
                   [11, 0],
+                  [11, 2.5],
+                  [11, 5],
+                  [11, 7.5],
+                  [11, 10],
+                  [8.5, 10],
+                  [6, 10],
+                  [3.5, 10],
+                  [1, 10],
+                  [1, 7.5],
+                  [1, 5],
+                  [1, 2.5],
                   [1, 0]
                 ]
               ],
               [
                 [
                   [2, 1],
-                  [2, 11],
-                  [12, 11],
+                  [4.5, 1],
+                  [7, 1],
+                  [9.5, 1],
                   [12, 1],
+                  [12, 3.5],
+                  [12, 6],
+                  [12, 8.5],
+                  [12, 11],
+                  [9.5, 11],
+                  [7, 11],
+                  [4.5, 11],
+                  [2, 11],
+                  [2, 8.5],
+                  [2, 6],
+                  [2, 3.5],
                   [2, 1]
                 ]
               ]
@@ -86,6 +124,7 @@ describe('normalizeGranuleSpatial', () => {
 
       expect(response).toEqual({
         type: 'Feature',
+        properties: {},
         geometry: {
           type: 'MultiLineString',
           coordinates: [
@@ -108,6 +147,7 @@ describe('normalizeGranuleSpatial', () => {
 
         expect(response).toEqual({
           type: 'Feature',
+          properties: {},
           geometry: {
             type: 'MultiLineString',
             coordinates: [
@@ -135,6 +175,7 @@ describe('normalizeGranuleSpatial', () => {
 
         expect(response).toEqual({
           type: 'Feature',
+          properties: {},
           geometry: {
             type: 'MultiLineString',
             coordinates: [
@@ -165,6 +206,7 @@ describe('normalizeGranuleSpatial', () => {
 
       expect(response).toEqual({
         type: 'Feature',
+        properties: {},
         geometry: {
           type: 'MultiPoint',
           coordinates: [[10, 0]]
@@ -182,6 +224,7 @@ describe('normalizeGranuleSpatial', () => {
 
         expect(response).toEqual({
           type: 'Feature',
+          properties: {},
           geometry: {
             type: 'MultiPoint',
             coordinates: [
@@ -197,29 +240,29 @@ describe('normalizeGranuleSpatial', () => {
   describe('when the granule has a polygon', () => {
     test('returns a geojson multi polygon', () => {
       const granule = {
-        polygons: [['0 0 0 10 10 10 10 0 0 0']]
+        polygons: [
+          ['0 0 0 10 10 10 10 0 0 0']
+        ]
       }
 
       const response = normalizeGranuleSpatial(granule)
 
       expect(response).toEqual({
         type: 'Feature',
+        properties: {},
         geometry: {
           type: 'MultiPolygon',
           coordinates: [
             [
               [
                 [0, 0],
-                [10.000000000000012, 6.092193914545939e-16],
                 [10, 0],
-                [10, 10.000000000000012],
                 [10, 10],
                 [7.500144327690457, 10.02806255960527],
                 [4.999999999999976, 10.037423045910712],
                 [2.499855672309495, 10.028062559605273],
-                [-4.929859756268451e-14, 10],
                 [0, 10],
-                [1.2184387829091878e-15, -1.2722218725854067e-14]
+                [0, 0]
               ]
             ]
           ]
@@ -240,35 +283,73 @@ describe('normalizeGranuleSpatial', () => {
 
         expect(response).toEqual({
           type: 'Feature',
+          properties: {},
           geometry: {
             type: 'MultiPolygon',
             coordinates: [
               [
                 [
                   [0, 0],
-                  [10.000000000000012, 6.092193914545939e-16],
                   [10, 0],
-                  [10, 10.000000000000012],
                   [10, 10],
                   [7.500144327690457, 10.02806255960527],
                   [4.999999999999976, 10.037423045910712],
                   [2.499855672309495, 10.028062559605273],
-                  [-4.929859756268451e-14, 10],
                   [0, 10],
-                  [1.2184387829091878e-15, -1.2722218725854067e-14]
+                  [0, 0]
                 ]
               ],
               [
                 [
                   [0, 0],
-                  [4.999999999999992, 3.0577325527910283e-16],
                   [5, 0],
-                  [5, 4.999999999999992],
                   [5, 5],
                   [2.5000000000000027, 5.004739244608396],
-                  [4.770832022195275e-15, 5.000000000000001],
                   [0, 5],
-                  [6.115465105582057e-16, 7.951386703658792e-15]
+                  [0, 0]
+                ]
+              ]
+            ]
+          }
+        })
+      })
+    })
+
+    describe('when the granule has a polygon with holes', () => {
+      test('returns a geojson multi polygon', () => {
+        const granule = {
+          polygons: [
+            [
+              '0 0 0 10 10 10 10 0 0 0',
+              '5 5 7 7 3 7 5 5'
+            ]
+          ]
+        }
+
+        const response = normalizeGranuleSpatial(granule)
+
+        expect(response).toEqual({
+          type: 'Feature',
+          properties: {},
+          geometry: {
+            type: 'MultiPolygon',
+            coordinates: [
+              [
+                [
+                  [0, 0],
+                  [10, 0],
+                  [10, 10],
+                  [7.500144327690457, 10.02806255960527],
+                  [4.999999999999976, 10.037423045910712],
+                  [2.499855672309495, 10.028062559605273],
+                  [0, 10],
+                  [0, 0]
+                ],
+                [
+                  [5, 5],
+                  [7, 3],
+                  [7, 7],
+                  [5, 5]
                 ]
               ]
             ]
@@ -280,42 +361,107 @@ describe('normalizeGranuleSpatial', () => {
     describe('when the polygon crosses the antimeridian', () => {
       test('returns a geojson multi polygon', () => {
         const granule = {
-          polygons: [['0 170 10 170 10 -170 0 -170 0 170']]
+          polygons: [
+            ['0 170 10 170 10 -170 0 -170 0 170']
+          ]
         }
 
         const response = normalizeGranuleSpatial(granule)
 
         expect(response).toEqual({
           type: 'Feature',
+          properties: {},
           geometry: {
             type: 'MultiPolygon',
             coordinates: [
               [
                 [
-                  [180, 10.151081711048118],
+                  [180, 10.15108171104812],
                   [177.49926515208145, 10.141615202612158],
                   [174.99882567334598, 10.113234824310313],
                   [172.49897454560028, 10.065997952552072],
-                  [169.9999999999999, 9.999999999999996],
                   [170, 10],
-                  [170, -1.2722218725854067e-14],
                   [170, 0],
                   [170, 0],
+                  [180, 0],
+                  [180, 10.15108171104812]
+                ]
+              ],
+              [
+                [
+                  [-180, 0],
+                  [-170, 0],
+                  [-170, 10],
+                  [-172.49897454560036, 10.065997952552076],
+                  [-174.99882567334606, 10.113234824310316],
+                  [-177.49926515208156, 10.141615202612163],
+                  [-180, 10.15108171104812],
+                  [-180, 0]
+                ]
+              ]
+            ]
+          }
+        })
+      })
+    })
+
+    describe('when the polygon crosses the antimeridian and the polygon hole also crosses the antimeridian', () => {
+      test('returns a geojson multi polygon', () => {
+        const granule = {
+          polygons: [
+            [
+              '0 170 10 170 10 -170 0 -170 0 170',
+              '3 175 4 175 2 -175 3 175'
+            ]
+          ]
+        }
+
+        const response = normalizeGranuleSpatial(granule)
+
+        expect(response).toEqual({
+          type: 'Feature',
+          properties: {},
+          geometry: {
+            type: 'MultiPolygon',
+            coordinates: [
+              [
+                [
+                  [180, 10.15108171104812],
+                  [177.49926515208145, 10.141615202612158],
+                  [174.99882567334598, 10.113234824310313],
+                  [172.49897454560028, 10.065997952552072],
+                  [170, 10],
+                  [170, 0],
                   [170, 0],
-                  [180.00000000000003, 6.092193914545939e-16]
+                  [180, 0],
+                  [180, 10.15108171104812]
+                ],
+                [
+                  [180, 3.0123568091770365],
+                  [177.50132107630438, 3.5095103431534436],
+                  [175, 4],
+                  [175, 3],
+                  [175, 3],
+                  [180, 2.509728649780264],
+                  [180, 3.0123568091770365]
                 ]
               ],
               [
                 [
                   [-180, 0],
-                  [-170, 6.092193914545939e-16],
                   [-170, 0],
-                  [-170, 10.000000000000012],
                   [-170, 10],
                   [-172.49897454560036, 10.065997952552076],
                   [-174.99882567334606, 10.113234824310316],
                   [-177.49926515208156, 10.141615202612163],
-                  [-180.0000000000001, 10.151081711048121]
+                  [-180, 10.15108171104812],
+                  [-180, 0]
+                ],
+                [
+                  [-180, 2.509728649780264],
+                  [-175, 2],
+                  [-180, 3.0123568091770365],
+                  [-180, 2.509728649780264]
                 ]
               ]
             ]
diff --git a/static/src/js/util/map/drawGranuleBackgrounds.js b/static/src/js/util/map/drawGranuleBackgrounds.js
index 26e292a157..b6d0755c1f 100644
--- a/static/src/js/util/map/drawGranuleBackgrounds.js
+++ b/static/src/js/util/map/drawGranuleBackgrounds.js
@@ -12,7 +12,7 @@ import projections from './projections'
 const drawGranuleBackgrounds = (granulesMetadata, vectorSource, projectionCode) => {
   if (!granulesMetadata.length) return
 
-  granulesMetadata.forEach((granule) => {
+  granulesMetadata.forEach((granule, index) => {
     const {
       backgroundStyle,
       collectionId,
@@ -33,6 +33,9 @@ const drawGranuleBackgrounds = (granulesMetadata, vectorSource, projectionCode)
     }).readFeatures(spatial)
 
     backgroundFeatures.forEach((backgroundFeature) => {
+      // Set the index for the feature. This will be used to sort the features by the order we drew them
+      backgroundFeature.set('index', index)
+
       // Save the collectionId and granuleId to the properties
       backgroundFeature.set('collectionId', collectionId)
       backgroundFeature.set('granuleId', granuleId)
diff --git a/static/src/js/util/map/drawGranuleOutlines.js b/static/src/js/util/map/drawGranuleOutlines.js
index cbd164e5f4..169e16f5a5 100644
--- a/static/src/js/util/map/drawGranuleOutlines.js
+++ b/static/src/js/util/map/drawGranuleOutlines.js
@@ -1,47 +1,5 @@
-import projections from './projections'
 import { pointRadius } from './styles'
 
-// Draw a boundary around the full extent for the projection. This allows
-// us to clip the granule outlines to the projection boundary, which is what
-// allows the outlines to 'stack' on top of each other.
-const drawBoundary = ({
-  ctx,
-  map,
-  projectionCode,
-  scale
-}) => {
-  let nw
-  let ne
-  let se
-  let sw
-
-  // Get the pixel locations of the corners of the projection
-  if (projectionCode === projections.geographic) {
-    nw = map.getPixelFromCoordinate([-190, 100])
-    ne = map.getPixelFromCoordinate([190, 100])
-    se = map.getPixelFromCoordinate([190, -100])
-    sw = map.getPixelFromCoordinate([-190, -100])
-  } else if (projectionCode === projections.arctic || projectionCode === projections.antarctic) {
-    const polarBounds = 3314693.24
-
-    nw = map.getPixelFromCoordinate([-polarBounds, polarBounds])
-    ne = map.getPixelFromCoordinate([polarBounds, polarBounds])
-    se = map.getPixelFromCoordinate([polarBounds, -polarBounds])
-    sw = map.getPixelFromCoordinate([-polarBounds, -polarBounds])
-  }
-
-  // Draw the boundary on the canvas
-  ctx.moveTo(nw[0] * scale, nw[1] * scale)
-  ctx.lineTo(ne[0] * scale, ne[1] * scale)
-  ctx.lineTo(se[0] * scale, se[1] * scale)
-  ctx.lineTo(sw[0] * scale, sw[1] * scale)
-  ctx.lineTo(nw[0] * scale, nw[1] * scale)
-  ctx.closePath()
-
-  // We don't call ctx.stroke() because we don't want to actually draw the boundary
-  // We just want to use it as a clipping path
-}
-
 // Draw the granule outlines
 const drawOutline = ({
   ctx,
@@ -108,8 +66,7 @@ const drawOutline = ({
 const drawGranuleOutlines = ({
   ctx,
   granuleBackgroundsSource,
-  map,
-  projectionCode
+  map
 }) => {
   // Remove existing drawings on the canvas
   ctx.reset()
@@ -117,8 +74,16 @@ const drawGranuleOutlines = ({
   // Get the device pixel ratio for use in scaling the drawing
   const dpr = window.devicePixelRatio || 1
 
+  const viewExtent = map.getView().calculateExtent(map.getSize())
+
+  // Only get the features that intersect the view extent
+  const features = granuleBackgroundsSource.getFeaturesInExtent(viewExtent)
+
+  // Sort features by the index property
+  const sortedFeatures = features.sort((a, b) => a.get('index') - b.get('index'))
+
   // Loop through the features that have been drawn on the granuleBackgroundsLayer
-  granuleBackgroundsSource.forEachFeature((feature) => {
+  sortedFeatures.forEach((feature) => {
     ctx.beginPath()
 
     // Set the globalCompositeOperation to 'destination-over' so new granule outlines
@@ -153,13 +118,8 @@ const drawGranuleOutlines = ({
       scale: dpr
     })
 
-    // Add path around full earth
-    drawBoundary({
-      ctx,
-      map,
-      projectionCode,
-      scale: dpr
-    })
+    // Add path around full canvas extent to clip the granule outlines
+    ctx.rect(0, 0, ctx.canvas.width, ctx.canvas.height)
 
     // Clip
     ctx.clip()
diff --git a/static/src/js/util/map/interactions/highlightFeature.js b/static/src/js/util/map/interactions/highlightFeature.js
index 0f2fe4b137..5fb6ef5a27 100644
--- a/static/src/js/util/map/interactions/highlightFeature.js
+++ b/static/src/js/util/map/interactions/highlightFeature.js
@@ -30,8 +30,13 @@ export const highlightFeature = ({
 
   if (coordinate) {
     // Find the first feature at the coordinate, this will be the top granule
-    const features = granuleBackgroundsSource.getFeaturesAtCoordinate(coordinate);
-    [featureToHighlight] = features
+    const features = granuleBackgroundsSource.getFeaturesAtCoordinate(coordinate)
+
+    // Make sure the features are sorted by index
+    const sortedFeatures = features.sort((a, b) => a.get('index') - b.get('index'));
+
+    // The first feature will be the one that was drawn first
+    [featureToHighlight] = sortedFeatures
   } else if (granuleId) {
     // Find the feature with the granuleId
     const features = granuleBackgroundsSource.getFeatures()
diff --git a/static/src/js/util/map/interactions/onClickMap.js b/static/src/js/util/map/interactions/onClickMap.js
index 7ca3ccab76..0af23f6e94 100644
--- a/static/src/js/util/map/interactions/onClickMap.js
+++ b/static/src/js/util/map/interactions/onClickMap.js
@@ -17,7 +17,12 @@ const onClickMap = ({
 }) => {
   // Find the first feature at the coordinate, this will be the top granule
   const features = granuleBackgroundsSource.getFeaturesAtCoordinate(coordinate)
-  const [featureToFocus] = features
+
+  // Make sure the features are sorted by index
+  const sortedFeatures = features.sort((a, b) => a.get('index') - b.get('index'))
+
+  // The first feature will be the one that was drawn first
+  const [featureToFocus] = sortedFeatures
 
   // If we haven't found any features, don't focus a granule
   if (!featureToFocus) {
diff --git a/static/src/js/util/map/layers/placeLabels.js b/static/src/js/util/map/layers/placeLabels.js
index 8baff2c14a..274435e6fe 100644
--- a/static/src/js/util/map/layers/placeLabels.js
+++ b/static/src/js/util/map/layers/placeLabels.js
@@ -6,8 +6,9 @@ import { applyStyle } from 'ol-mapbox-style'
 
 import { crsProjections } from '../crs'
 
-// Style provided by Worldview
-const styleUrl = 'https://nasa.maps.arcgis.com/sharing/rest/content/items/b611632010304f9a9358e1eec064cd25/resources/styles/root.json?f=pjson'
+import { getApplicationConfig } from '../../../../../../sharedUtils/config'
+
+const { placeLabelsStyleUrl } = getApplicationConfig()
 
 /**
  * Builds the place labels layer
@@ -34,8 +35,8 @@ const placeLabels = async ({
     renderMode: 'hybrid'
   })
 
-  // Style the layer according to the styleUrl
-  await applyStyle(layer, styleUrl, {
+  // Style the layer according to the placeLabelsStyleUrl, provided by Worldview
+  await applyStyle(layer, placeLabelsStyleUrl, {
     resolutions: layer.getSource().getTileGrid().getResolutions(),
     transformRequest(url, resourceType) {
       if (resourceType === 'Tile') {
diff --git a/static/src/js/util/map/normalizeGranuleSpatial.js b/static/src/js/util/map/normalizeGranuleSpatial.js
index 22bc98347a..f65e0adc61 100644
--- a/static/src/js/util/map/normalizeGranuleSpatial.js
+++ b/static/src/js/util/map/normalizeGranuleSpatial.js
@@ -1,6 +1,12 @@
 import { greatCircleArc } from 'ol/geom/flat/geodesic'
+import { distance } from 'ol/coordinate'
 import { dividePolygon } from '@edsc/geo-utils'
-import { simplify } from '@turf/turf'
+import {
+  booleanContains,
+  polygon as turfPolygon,
+  simplify,
+  booleanClockwise
+} from '@turf/turf'
 
 import { crsProjections } from './crs'
 
@@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
 
     // Pair the interpolated coordinates before pushing them to the array
-    for (let j = 0; j < interpolated.length - 1; j += 2) {
+    for (let j = 2; j < interpolated.length - 3; j += 2) {
       interpolatedCoordinates.push([interpolated[j], interpolated[j + 1]])
     }
   }
 
+  // `greatCircleArc` sometimes changes the last point slightly. We want to keep the original point to ensure the polygon is closed
+  interpolatedCoordinates.push([coordinates[0].lng, coordinates[0].lat])
+
   return [interpolatedCoordinates]
 }
 
+// Interpolate the points of a polygon derived from a bounding box
+const interpolateBoxPolygon = (polygon, tolerance, maxDepth) => {
+  const interpolatedPolygon = polygon
+
+  // This is a cartesian interpolation function that adds points between each point in the polygon
+  const interpolate = (coordinates) => {
+    const interpolatedPoints = []
+
+    // Iterate over the coordinates and add the original point and the interpolated points
+    coordinates.forEach((point, index) => {
+      interpolatedPoints.push(point)
+
+      const nextIndex = index + 1 === coordinates.length ? 0 : index + 1
+      const nextPoint = coordinates[nextIndex]
+
+      // Take the average of the two points to get the midpoint
+      const lng = (point[0] + nextPoint[0]) / 2
+      const lat = (point[1] + nextPoint[1]) / 2
+      const newPoint = [lng, lat]
+
+      // Find the distance between the two points
+      const pointDistance = distance(point, newPoint)
+
+      // If the distance between the two points is greater than the tolerance, add the new point
+      if (pointDistance > tolerance) {
+        interpolatedPoints.push(newPoint)
+      }
+    })
+
+    return interpolatedPoints
+  }
+
+  // Interpolate the polygon until it has more points than the threshold
+  for (let i = 0; i < maxDepth; i += 1) {
+    const newInterpolatedPoints = interpolate(interpolatedPolygon)
+
+    // If the new interpolated points are greater than the threshold, break
+    if (newInterpolatedPoints.length > mapPointsSimplifyThreshold) {
+      break
+    }
+
+    // Replace the interpolated polygon with the new interpolated points
+    interpolatedPolygon.splice(0, interpolatedPolygon.length)
+    interpolatedPolygon.push(...newInterpolatedPoints)
+  }
+
+  return [interpolatedPolygon]
+}
+
 // If the line crosses the antimeridian, divide it and return the divided coordinates
 // This needs to split every time the line crosses the antimeridian
 const divideLine = (line) => {
@@ -65,6 +128,26 @@ const divideLine = (line) => {
   return dividedCoordinates
 }
 
+// Return the given polygon as counter-clockwise.
+// Exterior rings of polygons should be counter-clockwise and holes should be clockwise
+const makeCounterClockwise = (polygon) => {
+  if (booleanClockwise(polygon)) {
+    return polygon.reverse()
+  }
+
+  return polygon
+}
+
+// Return the given polygon as clockwise
+// Exterior rings of polygons should be counter-clockwise and holes should be clockwise
+const makeClockwise = (polygon) => {
+  if (!booleanClockwise(polygon)) {
+    return polygon.reverse()
+  }
+
+  return polygon
+}
+
 // Normalize granule spatial (boxes, lines, points, polygons) to polygons for simplified handling on the map
 const normalizeGranuleSpatial = (granule) => {
   const {
@@ -83,19 +166,25 @@ const normalizeGranuleSpatial = (granule) => {
       const [swLat, swLon, neLat, neLon] = box.split(' ').map((coord) => parseFloat(coord))
 
       // Create a polygon from the bounding box
-      const polygonCoordinates = [
+      // TODO make sure this is counter-clockwise
+      const polygonCoordinates = makeCounterClockwise([
         [swLon, swLat],
-        [swLon, neLat],
-        [neLon, neLat],
         [neLon, swLat],
+        [neLon, neLat],
+        [swLon, neLat],
         [swLon, swLat]
-      ]
-      multiPolygons.push([polygonCoordinates])
+      ])
+
+      // Interpolate the polygon to add points between each point
+      const interpolatedPolygon = interpolateBoxPolygon(polygonCoordinates, 2, 6)
+
+      multiPolygons.push(interpolatedPolygon)
     })
 
     // Return the bounding box as GeoJSON MultiPolygon
     return {
       type: 'Feature',
+      properties: {},
       geometry: {
         type: 'MultiPolygon',
         coordinates: multiPolygons
@@ -123,6 +212,7 @@ const normalizeGranuleSpatial = (granule) => {
     // Return the line as GeoJSON MultiLineString
     const json = {
       type: 'Feature',
+      properties: {},
       geometry: {
         type: 'MultiLineString',
         coordinates: multipleLines
@@ -161,6 +251,7 @@ const normalizeGranuleSpatial = (granule) => {
     // Return the point as GeoJSON MultiPoint
     return {
       type: 'Feature',
+      properties: {},
       geometry: {
         type: 'MultiPoint',
         coordinates: multiPoints
@@ -170,17 +261,60 @@ const normalizeGranuleSpatial = (granule) => {
 
   // If the granule has a polygon, return a GeoJSON MultiPolygon
   if (polygons) {
-    const multiPolygons = []
-    polygons.forEach((polygon) => {
-      polygon.forEach((shape) => {
+    // CMR provides polygons spatial with multiple polygons, and each polygon can have multiple holes
+    //
+    // Metadata that includes mutliple polygons looks like this:
+    // [
+    //   [polygon1],
+    //   [polygon2],
+    //   [polygon3]
+    // ]
+    //
+    // The GeoJSON coordinates returned for multiple polygons should be returned like this:
+    // [
+    //   [
+    //     [polygon1],
+    //   ],
+    //   [
+    //     [polygon2],
+    //   ],
+    //   [
+    //     [polygon3],
+    //   ]
+    // ]
+    //
+    // Metadata that includes a polygon with holes will look like this:
+    // [
+    //   [polygon1, hole1, hole2],
+    // ]
+    //
+    // The GeoJSON coordinates returned for a polygon with holes should be returned like this:
+    // [
+    //   [
+    //     [polygon1],
+    //     [hole1],
+    //     [hole2],
+    //   ],
+    // ]
+
+    // If a polygon crosses the antimeridian, it will be divided into multiple polygons.
+    // If that polygon also has holes, the hole needs to be applied to the correct polygon.
+    // If the hole also crosses the antimeridian, it will be divided into multiple polygons, and each
+    // hole will be applied to the correct polygon.
+    let polygonsArray = []
+
+    polygons.forEach((polygonWithHoles) => {
+      // If more than one polygon is within polygonWithHoles, the first polygon is the exterior and the rest are holes
+
+      polygonWithHoles.forEach((polygon, polygonWithHolesIndex) => {
         // `polygons` is an array of an array of coordinates that looks like this:
         // [ ["0 0 0 1 1 1 1 0 0 0"] ]
         // We need to convert this into an array of arrays of coordinates
         // that looks like this:
         // [[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]
-        const coordinates = shape.split(' ').reduce((acc, coord, polygonsIndex) => {
+        const coordinates = polygon.split(' ').reduce((acc, coord, polygonsIndex) => {
           if (polygonsIndex % 2 === 0) {
-            acc.push([parseFloat(shape.split(' ')[polygonsIndex + 1]), parseFloat(coord)])
+            acc.push([parseFloat(polygon.split(' ')[polygonsIndex + 1]), parseFloat(coord)])
           }
 
           return acc
@@ -192,11 +326,45 @@ const normalizeGranuleSpatial = (granule) => {
           lat
         })))
 
-        dividedCoordinates.forEach((p) => {
-          if (p.length < 3) return
+        const [closedDividedCoordinates] = [dividedCoordinates.map((interior) => {
+          // If the interior is not closed, close it
+          if (
+            interior[0].lng !== interior[interior.length - 1].lng
+            || interior[0].lat !== interior[interior.length - 1].lat
+          ) {
+            return interior.concat([interior[0]])
+          }
+
+          return interior
+        })]
+
+        closedDividedCoordinates.forEach((polygonPoints) => {
+          if (polygonPoints.length < 3) return
+
+          const interpolatedPolygon = interpolatePolygon(polygonPoints)
+
+          // If polygonWithHolesIndex is 0, it is an exterior polygon
+          if (polygonWithHolesIndex === 0) {
+            // Add each exterior polygon to the list
+            polygonsArray.push(makeCounterClockwise(interpolatedPolygon))
+          } else {
+            // If polygonWithHolesIndex is > 0, it is a hole in a polygon, it needs to be added to
+            // the correct polygon array
 
-          const interpolatedPolygon = interpolatePolygon(p)
-          multiPolygons.push(interpolatedPolygon)
+            // Look through multiPolygons and determine if the hole fits inside
+            polygonsArray = polygonsArray.map((polygonsItem) => {
+              const exterior = turfPolygon(polygonsItem)
+              const hole = turfPolygon(interpolatedPolygon)
+
+              const containsHole = booleanContains(exterior, hole)
+
+              if (containsHole) {
+                return polygonsItem.concat(makeClockwise(interpolatedPolygon))
+              }
+
+              return polygonsItem
+            })
+          }
         })
       })
     })
@@ -204,9 +372,10 @@ const normalizeGranuleSpatial = (granule) => {
     // Return the polygons as GeoJSON MultiPolygon (to simplify drawing on the map)
     const json = {
       type: 'Feature',
+      properties: {},
       geometry: {
         type: 'MultiPolygon',
-        coordinates: multiPolygons
+        coordinates: polygonsArray
       }
     }
 
diff --git a/static/src/js/util/request/__tests__/granuleRequest.test.js b/static/src/js/util/request/__tests__/granuleRequest.test.js
index 55e82ae8fc..97b10c331f 100644
--- a/static/src/js/util/request/__tests__/granuleRequest.test.js
+++ b/static/src/js/util/request/__tests__/granuleRequest.test.js
@@ -156,6 +156,7 @@ describe('GranuleRequest#transformResponse', () => {
               points: ['0 10'],
               spatial: {
                 type: 'Feature',
+                properties: {},
                 geometry: {
                   type: 'MultiPoint',
                   coordinates: [[10, 0]]


Reviews:
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:11:14Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:12:07Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:16:07Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:17:24Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:18:43Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:22:32Z
user: login: macrouch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T00:35:36Z
user: login: macrouch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T00:36:33Z
user: login: macrouch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T00:37:45Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T12:56:09Z
user: login: daniel-zamora
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T13:02:18Z
user: login: daniel-zamora
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T13:07:55Z
user: login: eudoroolivares2016
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T19:45:44Z
user: login: macrouch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:08:08Z
user: login: macrouch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:15:35Z
user: login: macrouch
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T20:15:42Z

Review comments:
diff_hunk: @@ -65,6 +128,26 @@ const divideLine = (line) => {
   return dividedCoordinates
 }
 
+// Return the given polygon as counter-clockwise.
+// Exterior rings of polygons should be counter-clockwise and holes should be clockwise
+const makeCounterClockwise = (polygon) => {
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: daniel-zamora
body: makeCounterClockwise and makeClockwise can probably be combined into one function
created_at: 2025-03-18T20:11:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
 
     // Pair the interpolated coordinates before pushing them to the array
-    for (let j = 0; j < interpolated.length - 1; j += 2) {
+    for (let j = 2; j < interpolated.length - 3; j += 2) {
       interpolatedCoordinates.push([interpolated[j], interpolated[j + 1]])
     }
   }
 
+  // `greatCircleArc` sometimes changes the last point slightly. We want to keep the original point to ensure the polygon is closed
+  interpolatedCoordinates.push([coordinates[0].lng, coordinates[0].lat])
+
   return [interpolatedCoordinates]
 }
 
+// Interpolate the points of a polygon derived from a bounding box
+const interpolateBoxPolygon = (polygon, tolerance, maxDepth) => {
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: daniel-zamora
body: This might could use a unit test.
created_at: 2025-03-18T20:12:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
 
     // Pair the interpolated coordinates before pushing them to the array
-    for (let j = 0; j < interpolated.length - 1; j += 2) {
+    for (let j = 2; j < interpolated.length - 3; j += 2) {
       interpolatedCoordinates.push([interpolated[j], interpolated[j + 1]])
     }
   }
 
+  // `greatCircleArc` sometimes changes the last point slightly. We want to keep the original point to ensure the polygon is closed
+  interpolatedCoordinates.push([coordinates[0].lng, coordinates[0].lat])
+
   return [interpolatedCoordinates]
 }
 
+// Interpolate the points of a polygon derived from a bounding box
+const interpolateBoxPolygon = (polygon, tolerance, maxDepth) => {
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: daniel-zamora
body: interpolatePolygon could volunteer for one as well, if you think its sufficiently covered elsewhere that I'm not seeing in the PR feel free to disregard.
created_at: 2025-03-18T20:16:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: daniel-zamora
body: 0.00001 is a bit magic number-y,  maybe a comment discribing the stages or options and/or a named constant.
created_at: 2025-03-18T20:17:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,17 +66,24 @@ const drawOutline = ({
 const drawGranuleOutlines = ({
   ctx,
   granuleBackgroundsSource,
-  map,
-  projectionCode
+  map
 }) => {
   // Remove existing drawings on the canvas
   ctx.reset()
 
   // Get the device pixel ratio for use in scaling the drawing
   const dpr = window.devicePixelRatio || 1
 
+  const viewExtent = map.getView().calculateExtent(map.getSize())
+
+  // Only get the features that intersect the view extent
+  const features = granuleBackgroundsSource.getFeaturesInExtent(viewExtent)
+
+  // Sort features by the index property
+  const sortedFeatures = features.sort((a, b) => a.get('index') - b.get('index'))
path: static/src/js/util/map/drawGranuleOutlines.js
user: login: daniel-zamora
body: these sorts never go above like 20 elements to sort right?
created_at: 2025-03-18T20:18:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: daniel-zamora
body: by describing the stages or options I mean, what is the difference you found between say 0.00001 and 0.0001 as a potential example.
created_at: 2025-03-18T20:22:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,17 +66,24 @@ const drawOutline = ({
 const drawGranuleOutlines = ({
   ctx,
   granuleBackgroundsSource,
-  map,
-  projectionCode
+  map
 }) => {
   // Remove existing drawings on the canvas
   ctx.reset()
 
   // Get the device pixel ratio for use in scaling the drawing
   const dpr = window.devicePixelRatio || 1
 
+  const viewExtent = map.getView().calculateExtent(map.getSize())
+
+  // Only get the features that intersect the view extent
+  const features = granuleBackgroundsSource.getFeaturesInExtent(viewExtent)
+
+  // Sort features by the index property
+  const sortedFeatures = features.sort((a, b) => a.get('index') - b.get('index'))
path: static/src/js/util/map/drawGranuleOutlines.js
user: login: macrouch
body: The length will be the total number of geometries loaded on the map. So if 20 granules on the first page all have a single polygon it will be 20, but if they have more than one polygon (or any shape type) or if the user loads more than one page of granules it will be more
created_at: 2025-03-19T00:35:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
 
     // Pair the interpolated coordinates before pushing them to the array
-    for (let j = 0; j < interpolated.length - 1; j += 2) {
+    for (let j = 2; j < interpolated.length - 3; j += 2) {
       interpolatedCoordinates.push([interpolated[j], interpolated[j + 1]])
     }
   }
 
+  // `greatCircleArc` sometimes changes the last point slightly. We want to keep the original point to ensure the polygon is closed
+  interpolatedCoordinates.push([coordinates[0].lng, coordinates[0].lat])
+
   return [interpolatedCoordinates]
 }
 
+// Interpolate the points of a polygon derived from a bounding box
+const interpolateBoxPolygon = (polygon, tolerance, maxDepth) => {
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: macrouch
body: They aren't directly tested, but they are covered by the tests for each spatial type in normalizeGranuleSpatial.test.js
created_at: 2025-03-19T00:36:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,6 +128,26 @@ const divideLine = (line) => {
   return dividedCoordinates
 }
 
+// Return the given polygon as counter-clockwise.
+// Exterior rings of polygons should be counter-clockwise and holes should be clockwise
+const makeCounterClockwise = (polygon) => {
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: macrouch
body: How would you structure that function? The ways I have thought to do that wouldn't read as clearly as two separate functions
created_at: 2025-03-19T00:37:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,6 +128,26 @@ const divideLine = (line) => {
   return dividedCoordinates
 }
 
+// Return the given polygon as counter-clockwise.
+// Exterior rings of polygons should be counter-clockwise and holes should be clockwise
+const makeCounterClockwise = (polygon) => {
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: daniel-zamora
body: ```
const setPolygonWinding = (polygon, clockwise) => {
  return booleanClockwise(polygon) !== clockwise ? polygon.reverse() : polygon
}
```

or 


```
const setPolygonWinding = (polygon, clockwise) => {
  const isClockwise = booleanClockwise(polygon)
  
  if (isClockwise !== clockwise) {
    return polygon.reverse()
  }
  
  return polygon
}
```

if you think its more readable with two separate functions I'm not against that, the functions do read very similar to each other so it suggests dedup when reading it as a reviewer.
created_at: 2025-03-19T12:56:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -108,17 +66,24 @@ const drawOutline = ({
 const drawGranuleOutlines = ({
   ctx,
   granuleBackgroundsSource,
-  map,
-  projectionCode
+  map
 }) => {
   // Remove existing drawings on the canvas
   ctx.reset()
 
   // Get the device pixel ratio for use in scaling the drawing
   const dpr = window.devicePixelRatio || 1
 
+  const viewExtent = map.getView().calculateExtent(map.getSize())
+
+  // Only get the features that intersect the view extent
+  const features = granuleBackgroundsSource.getFeaturesInExtent(viewExtent)
+
+  // Sort features by the index property
+  const sortedFeatures = features.sort((a, b) => a.get('index') - b.get('index'))
path: static/src/js/util/map/drawGranuleOutlines.js
user: login: daniel-zamora
body: I wonder how high the number of features needs to be for it to noticeably impact performance and if the sort even has a chance at being a bottleneck.  
created_at: 2025-03-19T13:02:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,19 +166,25 @@ const normalizeGranuleSpatial = (granule) => {
       const [swLat, swLon, neLat, neLon] = box.split(' ').map((coord) => parseFloat(coord))
 
       // Create a polygon from the bounding box
-      const polygonCoordinates = [
+      // TODO make sure this is counter-clockwise
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: eudoroolivares2016
body: ```suggestion
      // TODO make sure this is counter-clockwise
```
still needed?
created_at: 2025-03-19T19:26:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -192,21 +326,56 @@ const normalizeGranuleSpatial = (granule) => {
           lat
         })))
 
-        dividedCoordinates.forEach((p) => {
-          if (p.length < 3) return
+        const [closedDividedCoordinates] = [dividedCoordinates.map((interior) => {
+          // If the interior is not closed, close it
+          if (
+            interior[0].lng !== interior[interior.length - 1].lng
+            || interior[0].lat !== interior[interior.length - 1].lat
+          ) {
+            return interior.concat([interior[0]])
+          }
+
+          return interior
+        })]
+
+        closedDividedCoordinates.forEach((polygonPoints) => {
+          if (polygonPoints.length < 3) return
+
+          const interpolatedPolygon = interpolatePolygon(polygonPoints)
+
+          // If polygonWithHolesIndex is 0, it is an exterior polygon
+          if (polygonWithHolesIndex === 0) {
+            // Add each exterior polygon to the list
+            polygonsArray.push(makeCounterClockwise(interpolatedPolygon))
+          } else {
+            // If polygonWithHolesIndex is > 0, it is a hole in a polygon, it needs to be added to
+            // the correct polygon array
 
-          const interpolatedPolygon = interpolatePolygon(p)
-          multiPolygons.push(interpolatedPolygon)
+            // Look through multiPolygons and determine if the hole fits inside
+            polygonsArray = polygonsArray.map((polygonsItem) => {
+              const exterior = turfPolygon(polygonsItem)
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: eudoroolivares2016
body: Turf library is really great
created_at: 2025-03-19T19:43:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -192,21 +326,56 @@ const normalizeGranuleSpatial = (granule) => {
           lat
         })))
 
-        dividedCoordinates.forEach((p) => {
-          if (p.length < 3) return
+        const [closedDividedCoordinates] = [dividedCoordinates.map((interior) => {
+          // If the interior is not closed, close it
+          if (
+            interior[0].lng !== interior[interior.length - 1].lng
+            || interior[0].lat !== interior[interior.length - 1].lat
+          ) {
+            return interior.concat([interior[0]])
+          }
+
+          return interior
+        })]
+
+        closedDividedCoordinates.forEach((polygonPoints) => {
+          if (polygonPoints.length < 3) return
+
+          const interpolatedPolygon = interpolatePolygon(polygonPoints)
+
+          // If polygonWithHolesIndex is 0, it is an exterior polygon
+          if (polygonWithHolesIndex === 0) {
+            // Add each exterior polygon to the list
+            polygonsArray.push(makeCounterClockwise(interpolatedPolygon))
+          } else {
+            // If polygonWithHolesIndex is > 0, it is a hole in a polygon, it needs to be added to
+            // the correct polygon array
 
-          const interpolatedPolygon = interpolatePolygon(p)
-          multiPolygons.push(interpolatedPolygon)
+            // Look through multiPolygons and determine if the hole fits inside
+            polygonsArray = polygonsArray.map((polygonsItem) => {
+              const exterior = turfPolygon(polygonsItem)
+              const hole = turfPolygon(interpolatedPolygon)
+
+              const containsHole = booleanContains(exterior, hole)
+
+              if (containsHole) {
+                return polygonsItem.concat(makeClockwise(interpolatedPolygon))
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: eudoroolivares2016
body: ```suggestion
// apply the polygon hole to the exterior polygon
                return polygonsItem.concat(makeClockwise(interpolatedPolygon))
```
created_at: 2025-03-19T19:45:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10,23 +16,80 @@ const { mapPointsSimplifyThreshold } = getApplicationConfig()
 
 // This function adds points to the polygon so that the polygon follows the curvature of the Earth
 const interpolatePolygon = (coordinates) => {
-  // Interpolate the polygon coordinates so that the polygon follows the curvature of the Earth
   const interpolatedCoordinates = []
+
+  // Iterate over the coordinates and add the original point and the interpolated points
   for (let i = 0; i < coordinates.length - 1; i += 1) {
     const { lng, lat } = coordinates[i]
     const { lng: lng2, lat: lat2 } = coordinates[i + 1]
 
+    // `greatCircleArc` sometimes changes the first point slightly. We want to keep the original point to ensure the polygon is closed
+    interpolatedCoordinates.push([lng, lat])
+
+    // Interpolate the points between the two coordinates
     const interpolated = greatCircleArc(lng, lat, lng2, lat2, crsProjections.epsg4326, 0.00001)
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: macrouch
body: I'll add a comment about this in my next PR
created_at: 2025-03-19T20:08:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,19 +166,25 @@ const normalizeGranuleSpatial = (granule) => {
       const [swLat, swLon, neLat, neLon] = box.split(' ').map((coord) => parseFloat(coord))
 
       // Create a polygon from the bounding box
-      const polygonCoordinates = [
+      // TODO make sure this is counter-clockwise
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: macrouch
body: You can remove those in your branch since you are working in that file
created_at: 2025-03-19T20:15:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -192,21 +326,56 @@ const normalizeGranuleSpatial = (granule) => {
           lat
         })))
 
-        dividedCoordinates.forEach((p) => {
-          if (p.length < 3) return
+        const [closedDividedCoordinates] = [dividedCoordinates.map((interior) => {
+          // If the interior is not closed, close it
+          if (
+            interior[0].lng !== interior[interior.length - 1].lng
+            || interior[0].lat !== interior[interior.length - 1].lat
+          ) {
+            return interior.concat([interior[0]])
+          }
+
+          return interior
+        })]
+
+        closedDividedCoordinates.forEach((polygonPoints) => {
+          if (polygonPoints.length < 3) return
+
+          const interpolatedPolygon = interpolatePolygon(polygonPoints)
+
+          // If polygonWithHolesIndex is 0, it is an exterior polygon
+          if (polygonWithHolesIndex === 0) {
+            // Add each exterior polygon to the list
+            polygonsArray.push(makeCounterClockwise(interpolatedPolygon))
+          } else {
+            // If polygonWithHolesIndex is > 0, it is a hole in a polygon, it needs to be added to
+            // the correct polygon array
 
-          const interpolatedPolygon = interpolatePolygon(p)
-          multiPolygons.push(interpolatedPolygon)
+            // Look through multiPolygons and determine if the hole fits inside
+            polygonsArray = polygonsArray.map((polygonsItem) => {
+              const exterior = turfPolygon(polygonsItem)
+              const hole = turfPolygon(interpolatedPolygon)
+
+              const containsHole = booleanContains(exterior, hole)
+
+              if (containsHole) {
+                return polygonsItem.concat(makeClockwise(interpolatedPolygon))
path: static/src/js/util/map/normalizeGranuleSpatial.js
user: login: macrouch
body: You can add a comment in your branch since you are working in that file
created_at: 2025-03-19T20:15:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T02:17:30Z
body: ## Bundle Size Comparison

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Main</th>
      <th>Branch</th>
      <th>Diff</th>
    </tr>
  </thead>
<tbody>
  <tr style="color: #82071e; background-color: #ffebe9;">
    <td>Total</td>
    <td>7396.5 kB</td>
    <td>7626.74 kB</td>
    <td>230.24 kB</td>
  </tr>
  <tr style="color: #82071e; background-color: #ffebe9;">
    <td>index.js</td>
    <td>3752.44 kB</td>
    <td>3903.74 kB</td>
    <td>151.3 kB</td>
  </tr>
  <tr style="color: #82071e; background-color: #ffebe9;">
    <td>index.js (gzip)</td>
    <td>998.25 kB</td>
    <td>1044.13 kB</td>
    <td>45.88 kB</td>
  </tr>
  <tr style="color: #116329; background-color: #dafbe1;">
    <td>index.css</td>
    <td>421.9 kB</td>
    <td>415.38 kB</td>
    <td>-6.52 kB</td>
  </tr>
  <tr style="color: #116329; background-color: #dafbe1;">
    <td>index.css (gzip)</td>
    <td>124.54 kB</td>
    <td>121 kB</td>
    <td>-3.54 kB</td>
  </tr>
  <tr style="color: #116329; background-color: #dafbe1;">
    <td>Number of files</td>
    <td>75</td>
    <td>72</td>
    <td>-3</td>
  </tr>
  <tr style="color: #82071e; background-color: #ffebe9;">
    <td>Build Time</td>
    <td>34.35 s</td>
    <td>37.37 s</td>
    <td>3.02 s</td>
  </tr>
</tbody>

<details><summary>Full build details</summary>

  ```
  
&gt; earthdata-search@1.0.0 build
&gt; vite build

vite v5.4.14 building for production...
transforming...
✓ 4513 modules transformed.
rendering chunks...
computing gzip size...
static/dist/assets/logo-Bbl3mFh1.png                                      4.74 kB
static/dist/assets/orange-bars-circle-Dv91mTXe.png                        6.21 kB
static/dist/assets/logo-Caj-8pKX.png                                     10.40 kB
static/dist/index.html                                                   11.98 kB │ gzip:     2.76 kB
static/dist/assets/logo-B0XgzUx6.png                                     16.71 kB
static/dist/assets/blue-bars-circle-BnwbAiRm.png                         17.43 kB
static/dist/assets/logo-DJkM1oZm.png                                     18.46 kB
static/dist/assets/logo-DV4XbSnR.png                                     19.40 kB
static/dist/assets/logo-BzPAKkwn.png                                     19.52 kB
static/dist/assets/logo-CiiGKrme.png                                     22.18 kB
static/dist/assets/logo-9zKL5_dX.png                                     26.10 kB
static/dist/assets/logo-BQ2w-_9p.png                                     37.37 kB
static/dist/assets/plate_carree_earth_scaled@2x-BIj0Wstc.png             45.03 kB
static/dist/assets/image-unavailable-CcXgOtQr.svg                        52.20 kB │ gzip:    20.77 kB
static/dist/assets/logo-Dcyqcuvp.png                                     85.17 kB
static/dist/assets/logo-ypkTnNcY.png                                    116.54 kB
static/dist/assets/logo-CeuXi2Mj.png                                    140.06 kB
static/dist/assets/tour-video-thumbnail-Dp_EYCVz.png                    163.50 kB
static/dist/assets/earthdata-search-og-image-NnlQJae1.jpg               669.27 kB
static/dist/assets/Preferences-CYv3T8NS.css                               0.17 kB │ gzip:     0.15 kB
static/dist/assets/Card-4Z2ojUmq.css                                      0.24 kB │ gzip:     0.14 kB
static/dist/assets/ContactInfo-C7Pr11wu.css                               0.28 kB │ gzip:     0.18 kB
static/dist/assets/GranuleFiltersContainer-CfwvM6Mp.css                   0.59 kB │ gzip:     0.27 kB
static/dist/assets/PreferencesForm-CNfW6AJC.css                           0.67 kB │ gzip:     0.24 kB
static/dist/assets/TextWindowActions-CXBmanDH.css                         0.83 kB │ gzip:     0.36 kB
static/dist/assets/Subscriptions-whcrpTXZ.css                             1.20 kB │ gzip:     0.38 kB
static/dist/assets/CollectionDetailsHighlightsContainer-CHDYEfjF.css      1.24 kB │ gzip:     0.44 kB
static/dist/assets/GranuleResultsHighlightsContainer-BIY0O9bb.css         1.24 kB │ gzip:     0.41 kB
static/dist/assets/EchoForm-Vzt37tVR.css                                  3.45 kB │ gzip:     1.05 kB
static/dist/assets/CollectionDetailsBody-D5LlDVAO.css                     6.14 kB │ gzip:     1.32 kB
static/dist/assets/Downloads-DqmON7i8.css                                 8.86 kB │ gzip:     2.02 kB
static/dist/assets/Admin-Bo7x_TTl.css                                    12.79 kB │ gzip:     2.11 kB
static/dist/assets/MapContainer-B4I25KqB.css                             13.82 kB │ gzip:     5.28 kB
static/dist/assets/Project-Y1e8hRTa.css                                  20.27 kB │ gzip:     4.09 kB
static/dist/assets/index-BDOxVFqM.css                                   415.38 kB │ gzip:   121.00 kB
static/dist/assets/logo-kIfxR0kt.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-wNST2Htl.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-CCmqYjka.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-DrPA0kJR.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-Ckt66ggc.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-B3er5AlO.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-BWTFBqRn.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-BPTx5Aq2.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-Ci-dnVwS.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-Bw50Xt1w.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-DWpcBQbn.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-BVv-1ePi.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-DJCGC4Zf.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-Bf2AJzFF.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-BcKojMYq.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-D7UOcUuS.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/logo-BE1QrLrG.js                                       0.06 kB │ gzip:     0.08 kB
static/dist/assets/Table-CE57gX_x.js                                      0.54 kB │ gzip:     0.35 kB
static/dist/assets/RelatedCollection-BPi9TDLN.js                          1.00 kB │ gzip:     0.52 kB
static/dist/assets/logo-CxSOswgE.js                                       3.08 kB │ gzip:     2.37 kB
static/dist/assets/Preferences-CZRHnXEf.js                                4.29 kB │ gzip:     0.86 kB
static/dist/assets/EarthdataDownloadRedirect-gQSrDw7r.js                  6.12 kB │ gzip:     1.12 kB
static/dist/assets/Card-Cip_kueT.js                                       6.25 kB │ gzip:     2.02 kB
static/dist/assets/GranuleResultsHighlightsContainer-DvLrjkRC.js          8.53 kB │ gzip:     1.55 kB
static/dist/assets/CollectionDetailsHighlightsContainer-ZtlySxW0.js      10.81 kB │ gzip:     1.38 kB
static/dist/assets/ContactInfo-CJt0amLj.js                               14.09 kB │ gzip:     1.93 kB
static/dist/assets/Subscriptions-BY7F7OcI.js                             14.30 kB │ gzip:     2.15 kB
static/dist/assets/index-CJpOEgzi.js                                     15.08 kB │ gzip:     6.28 kB
static/dist/assets/GranuleFiltersContainer-CgtN6FY5.js                   39.52 kB │ gzip:     6.47 kB
static/dist/assets/CollectionDetailsBody-CjR8OyIX.js                     44.62 kB │ gzip:     5.28 kB
static/dist/assets/Downloads-DQYRUon4.js                                 94.50 kB │ gzip:    15.61 kB
static/dist/assets/Admin-b6YrJpru.js                                    104.98 kB │ gzip:    18.79 kB
static/dist/assets/EchoForm-Cm5jYOPG.js                                 108.89 kB │ gzip:    33.70 kB
static/dist/assets/PreferencesForm-CDCNr4di.js                          328.06 kB │ gzip:   101.27 kB
static/dist/assets/Project-CBgzeC8c.js                                  397.85 kB │ gzip:   118.02 kB
static/dist/assets/MapContainer-DMrHF0qC.js                             565.11 kB │ gzip:   164.10 kB
static/dist/assets/index-DgFRXMW2.js                                  3,888.66 kB │ gzip: 1,037.85 kB
✓ built in 37.37s

  ```
</details>

The full bundle is larger than main by 230.24 kB. :exclamation:

The index.js is larger than main by 151.3 kB. :exclamation:

Run `npx vite-bundle-visualizer` to review the bundle in more detail.

author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-18T02:18:02Z
body: ## [Codecov](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) Report
Attention: Patch coverage is `85.13514%` with `11 lines` in your changes missing coverage. Please review.
> Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`EDSC-4410@bf3ac42`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa#section-missing-base-commit) about missing BASE report.

| [Files with missing lines](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) | Patch % | Lines |
|---|---|---|
| [...c/src/js/util/map/interactions/highlightFeature.js](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&filepath=static%2Fsrc%2Fjs%2Futil%2Fmap%2Finteractions%2FhighlightFeature.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa#diff-c3RhdGljL3NyYy9qcy91dGlsL21hcC9pbnRlcmFjdGlvbnMvaGlnaGxpZ2h0RmVhdHVyZS5qcw==) | 0.00% | [3 Missing :warning: ](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) |
| [static/src/js/util/map/normalizeGranuleSpatial.js](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&filepath=static%2Fsrc%2Fjs%2Futil%2Fmap%2FnormalizeGranuleSpatial.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa#diff-c3RhdGljL3NyYy9qcy91dGlsL21hcC9ub3JtYWxpemVHcmFudWxlU3BhdGlhbC5qcw==) | 95.00% | [3 Missing :warning: ](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) |
| [static/src/js/util/map/drawGranuleBackgrounds.js](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&filepath=static%2Fsrc%2Fjs%2Futil%2Fmap%2FdrawGranuleBackgrounds.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa#diff-c3RhdGljL3NyYy9qcy91dGlsL21hcC9kcmF3R3JhbnVsZUJhY2tncm91bmRzLmpz) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) |
| [static/src/js/util/map/interactions/onClickMap.js](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&filepath=static%2Fsrc%2Fjs%2Futil%2Fmap%2Finteractions%2FonClickMap.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa#diff-c3RhdGljL3NyYy9qcy91dGlsL21hcC9pbnRlcmFjdGlvbnMvb25DbGlja01hcC5qcw==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) |
| [static/src/js/util/map/drawGranuleOutlines.js](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&filepath=static%2Fsrc%2Fjs%2Futil%2Fmap%2FdrawGranuleOutlines.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa#diff-c3RhdGljL3NyYy9qcy91dGlsL21hcC9kcmF3R3JhbnVsZU91dGxpbmVzLmpz) | 80.00% | [1 Missing :warning: ](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##             EDSC-4410    #1879   +/-   ##
============================================
  Coverage             ?   82.53%           
============================================
  Files                ?      796           
  Lines                ?    19841           
  Branches             ?     5089           
============================================
  Hits                 ?    16375           
  Misses               ?     3262           
  Partials             ?      204           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nasa/earthdata-search/pull/1879?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nasa).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE