diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java index aad1e1c1..29a63bdb 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/DatetimeUtils.java @@ -7,11 +7,57 @@ public class DatetimeUtils { private static final Pattern MM_YYYY_PATTERN = Pattern.compile("^(\\d{2})-(\\d{4})$"); private static final Pattern YYYY_MM_DD_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); private static final DateTimeFormatter ISO_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + // Australian display format (dd/MM/yyyy), matching the frontend DISPLAY_FORMAT + private static final DateTimeFormatter AU_DISPLAY_DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); public static final String NON_SPECIFIED_DATE = "non-specified"; + // Frontend default lower bound (dateDefault.min); its default upper bound is "now" + public static final String DEFAULT_MIN_DATE = "1970-01-01"; private DatetimeUtils() { } + /** + * A lower bound is "open" (no real start) when it is null/empty/non-specified, + * or equals the frontend default minimum (1970-01-01). + */ + public static boolean isDefaultLowerBound(String date) { + if (date == null || date.trim().isEmpty() || NON_SPECIFIED_DATE.equalsIgnoreCase(date.trim())) { + return true; + } + return DEFAULT_MIN_DATE.equals(date.trim()); + } + + /** + * An upper bound is "open" (no real end) when it is null/empty/non-specified, + * or falls on today or later (the frontend default maximum is "now"). + * Avoids an exact today-equality check so it is not fragile across timezones/midnight. + */ + public static boolean isOpenUpperBound(String date) { + if (date == null || date.trim().isEmpty() || NON_SPECIFIED_DATE.equalsIgnoreCase(date.trim())) { + return true; + } + try { + return !java.time.LocalDate.parse(date.trim(), ISO_DATE_FORMAT).isBefore(java.time.LocalDate.now()); + } catch (java.time.format.DateTimeParseException e) { + return false; + } + } + + /** + * Format an ISO date (yyyy-MM-dd) for display in the Australian format (dd/MM/yyyy). + * Returns "" for null/empty/non-specified, and falls back to the raw input if it cannot be parsed. + */ + public static String toDisplayDate(String isoDate) { + if (isoDate == null || isoDate.trim().isEmpty() || NON_SPECIFIED_DATE.equalsIgnoreCase(isoDate.trim())) { + return ""; + } + try { + return java.time.LocalDate.parse(isoDate.trim(), ISO_DATE_FORMAT).format(AU_DISPLAY_DATE_FORMAT); + } catch (java.time.format.DateTimeParseException e) { + return isoDate; + } + } + public static String formatOGCDateTime(String startDate, String endDate) { if(startDate == null || startDate.trim().isEmpty()) { startDate = ".."; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java index b8b8b110..4c8a47f7 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/util/EmailUtils.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.io.InputStream; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -45,41 +46,53 @@ public static String generateSubsettingSection( ObjectMapper objectMapper ) { try { - // Format dates - String displayStartDate = (startDate != null && !startDate.equals(DatetimeUtils.NON_SPECIFIED_DATE)) - ? startDate.replace("-", "/") : ""; - String displayEndDate = (endDate != null && !endDate.equals(DatetimeUtils.NON_SPECIFIED_DATE)) - ? endDate.replace("-", "/") : ""; + // Format dates in the Australian display format (dd/MM/yyyy), matching the frontend + String displayStartDate = DatetimeUtils.toDisplayDate(startDate); + String displayEndDate = DatetimeUtils.toDisplayDate(endDate); + + // The frontend default full range (1970-01-01 to now) means "no temporal subset" - treat as unspecified + boolean isOpenRange = DatetimeUtils.isDefaultLowerBound(startDate) && DatetimeUtils.isOpenUpperBound(endDate); // Check if dates are specified - boolean hasDateSubsetting = !displayStartDate.isEmpty() || !displayEndDate.isEmpty(); + boolean hasDateSubsetting = (!displayStartDate.isEmpty() || !displayEndDate.isEmpty()) && !isOpenRange; + + // Check if spatial selection is specified + boolean hasSpatialSubsetting = multipolygon != null && !isEmptyMultiPolygon(multipolygon); - // Check if bbox is specified - boolean hasBboxSubsetting = multipolygon != null && !isEmptyMultiPolygon(multipolygon); + // Separate bbox and polygon HTML + String bboxHtml = ""; + String polygonHtml = ""; + if (hasSpatialSubsetting) { + bboxHtml = generateBboxHtml(multipolygon, objectMapper); + polygonHtml = generatePolygonHtml(multipolygon, objectMapper); + } + boolean hasBboxes = !bboxHtml.isEmpty(); + boolean hasPolygons = !polygonHtml.isEmpty(); - // If no subsetting at all, return empty string - if (!hasDateSubsetting && !hasBboxSubsetting) { + // If no subsetting data at all, return empty string + if (!hasDateSubsetting && !hasBboxes && !hasPolygons) { return ""; } StringBuilder html = new StringBuilder(); + html.append(buildSubsettingHeader()); - // Add subsetting header - if (hasBboxSubsetting || hasDateSubsetting) { - html.append(buildSubsettingHeader()); + // Add bbox section + if (hasBboxes) { + html.append(buildBboxWrapper(bboxHtml)); } - // Add bbox section if present - if (hasBboxSubsetting) { - html.append(buildBboxWrapper(generateBboxHtml(multipolygon, objectMapper))); + // Add polygon section + if (hasPolygons) { + html.append(buildBboxWrapper(polygonHtml)); } - // Add spacing between bbox and time range if both exist - if (hasBboxSubsetting && hasDateSubsetting) { + // Add spacing before time range if spatial sections exist + if ((hasBboxes || hasPolygons) && hasDateSubsetting) { html.append(buildSpacerSection()); } - // Add time range section if present + // Add time range section if (hasDateSubsetting) { html.append(buildTimeRangeWrapper(displayStartDate, displayEndDate)); } @@ -93,11 +106,12 @@ public static String generateSubsettingSection( } /** - * Generate HTML content for bounding box data only (without wrapper) + * Generate HTML for bounding box selections only (rectangles with 4 or fewer unique vertices). + * Freeform polygons are handled separately by {@link #generatePolygonHtml}. * * @param multipolygon - the multipolygon object * @param objectMapper - Jackson ObjectMapper for JSON processing - * @return HTML string for bbox data rows + * @return HTML string for bbox data rows, or empty string if there are none */ public static String generateBboxHtml(Object multipolygon, ObjectMapper objectMapper) { try { @@ -105,9 +119,7 @@ public static String generateBboxHtml(Object multipolygon, ObjectMapper objectMa return ""; } - // Extract coordinates directly from the object List>>> coordinates = extractCoordinates(multipolygon, objectMapper); - if (coordinates == null || coordinates.isEmpty()) { return ""; } @@ -115,61 +127,124 @@ public static String generateBboxHtml(Object multipolygon, ObjectMapper objectMa StringBuilder html = new StringBuilder(); int bboxCounter = 0; - // Process each polygon separately for (List>> polygon : coordinates) { - // Find min/max for THIS polygon only + List> ring = polygon.isEmpty() ? List.of() : polygon.get(0); + List> uniqueVertices = removeClosingPoint(ring); + + // Skip freeform polygons, those are handled by generatePolygonHtml + if (uniqueVertices.size() > 4) { + continue; + } + double minLon = Double.MAX_VALUE; double maxLon = Double.NEGATIVE_INFINITY; double minLat = Double.MAX_VALUE; double maxLat = Double.NEGATIVE_INFINITY; - for (List> ring : polygon) { - for (List point : ring) { - if (point.size() >= 2) { - double lon = point.get(0).doubleValue(); - double lat = point.get(1).doubleValue(); - minLon = Math.min(minLon, lon); - maxLon = Math.max(maxLon, lon); - minLat = Math.min(minLat, lat); - maxLat = Math.max(maxLat, lat); - } + for (List point : ring) { + if (point.size() >= 2) { + double lon = point.get(0).doubleValue(); + double lat = point.get(1).doubleValue(); + minLon = Math.min(minLon, lon); + maxLon = Math.max(maxLon, lon); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); } } - // Use BboxUtils to normalize this polygon's bbox MultiPolygon normalizedBbox = BboxUtils.normalizeBbox(minLon, maxLon, minLat, maxLat); - // Build HTML for each normalized bbox for (int i = 0; i < normalizedBbox.getNumGeometries(); i++) { - Polygon normalizedPolygon = (Polygon) normalizedBbox.getGeometryN(i); - Envelope envelope = normalizedPolygon.getEnvelopeInternal(); - - String north = "" + envelope.getMaxY(); - String south = "" + envelope.getMinY(); - String west = "" + envelope.getMinX(); - String east = "" + envelope.getMaxX(); - - // Add spacing between multiple bboxes if (bboxCounter > 0) { html.append("") .append("
") .append(""); } + Polygon normalizedPolygon = (Polygon) normalizedBbox.getGeometryN(i); + Envelope envelope = normalizedPolygon.getEnvelopeInternal(); + + String north = String.valueOf(envelope.getMaxY()); + String south = String.valueOf(envelope.getMinY()); + String west = String.valueOf(envelope.getMinX()); + String east = String.valueOf(envelope.getMaxX()); + bboxCounter++; - int displayIndex = (coordinates.size() > 1 || normalizedBbox.getNumGeometries() > 1) ? bboxCounter : 0; + int displayIndex = bboxCounter > 1 ? bboxCounter : 0; html.append(buildBboxSection(north, south, west, east, displayIndex)); } } return html.toString(); - } catch (Exception e) { log.error("Error generating bbox HTML", e); return ""; } } + /** + * Generate HTML for freeform polygon selections (more than 4 unique vertices). + * Bounding boxes are handled separately by {@link #generateBboxHtml}. + * + * @param multipolygon - the multipolygon object + * @param objectMapper - Jackson ObjectMapper for JSON processing + * @return HTML string for polygon data rows, or empty string if there are none + */ + public static String generatePolygonHtml(Object multipolygon, ObjectMapper objectMapper) { + try { + if (multipolygon == null) { + return ""; + } + + List>>> coordinates = extractCoordinates(multipolygon, objectMapper); + if (coordinates == null || coordinates.isEmpty()) { + return ""; + } + + StringBuilder html = new StringBuilder(); + int polygonCounter = 0; + + for (List>> polygon : coordinates) { + List> ring = polygon.isEmpty() ? List.of() : polygon.get(0); + List> uniqueVertices = removeClosingPoint(ring); + + // Skip bounding boxes, those are handled by generateBboxHtml + if (uniqueVertices.size() <= 4) { + continue; + } + + if (polygonCounter > 0) { + html.append("") + .append("
") + .append(""); + } + + polygonCounter++; + int displayIndex = polygonCounter > 1 ? polygonCounter : 0; + html.append(buildPolygonSection(uniqueVertices, displayIndex)); + } + + return html.toString(); + } catch (Exception e) { + log.error("Error generating polygon HTML", e); + return ""; + } + } + + /** + * Remove the GeoJSON closing point (where the last vertex repeats the first) to get the unique vertices. + * + * @param ring - the outer ring of a polygon, as a list of [lon, lat] points + * @return the ring's unique vertices, without the repeated closing point + */ + private static List> removeClosingPoint(List> ring) { + List> vertices = new ArrayList<>(ring); + if (vertices.size() > 1 && vertices.get(0).equals(vertices.get(vertices.size() - 1))) { + vertices.remove(vertices.size() - 1); + } + return vertices; + } + /** * Check if multipolygon is empty or represents the full world */ @@ -474,8 +549,44 @@ private static String buildTimeRangeWrapper(String startDate, String endDate) { ""; } - protected static String buildBboxSection(String north, String south, String west, String east, int index) { - String title = index > 0 ? "Bounding Box " + index : "Bounding Box Selection"; + /** + * Build a single coordinate row for polygon vertex display + */ + private static String buildCoordinateRow(String text) { + return "" + + "" + + "" + + "" + + "" + + "" + + "
" + + "
" + + "

" + text + "

" + + "
" + + "
" + + "" + + "" + + "" + + "" + + "
" + + "" + + ""; + } + + /** + * Build polygon selection section showing individual vertex coordinates + */ + protected static String buildPolygonSection(List> vertices, int index) { + String title = index > 0 ? "Polygon Selection " + index : "Polygon Selection"; + + StringBuilder coordinateRows = new StringBuilder(); + for (int i = 0; i < vertices.size(); i++) { + List point = vertices.get(i); + // GeoJSON stores [lon, lat]; display latitude-first per geographic convention. + String lon = point.get(0).toPlainString(); + String lat = point.get(1).toPlainString(); + coordinateRows.append(buildCoordinateRow("Point " + (i + 1) + ": (" + lat + ", " + lon + ")")); + } return "" + "" + @@ -484,7 +595,7 @@ protected static String buildBboxSection(String north, String south, String west "" + "" + "" + - "" + + "" + "" + "
" + "" + @@ -512,73 +623,68 @@ protected static String buildBboxSection(String north, String south, String west "" + "" + "" + - "" + - "" + - "" + - "" + - "" + - "" + + ""; + } + + /** + * Build a bounding box section showing its north, west, south and east bounds + */ + protected static String buildBboxSection(String north, String south, String west, String east, int index) { + String title = index > 0 ? "Bounding Box " + index : "Bounding Box Selection"; + + // Coordinate display order groups the latitude bounds (N, S) then the longitude bounds (W, E). + // Keep this list as the single source of truth for the order. + List coordinates = List.of( + new String[]{"N", north}, + new String[]{"S", south}, + new String[]{"W", west}, + new String[]{"E", east} + ); + + StringBuilder coordinateRows = new StringBuilder(); + for (String[] coordinate : coordinates) { + coordinateRows.append(buildCoordinateRow(coordinate[0] + ": " + coordinate[1])); + } + + return "" + + "" + "" + "" + - "" + "" + + "" + + "" + ""; diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java b/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java index 80fea273..69e552b7 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java @@ -177,6 +177,7 @@ private String generateStartedEmailContent( .replace("{{collectionTitle}}", collectionTitle != null ? collectionTitle : "") .replace("{{subsettingSection}}", subsettingSection) .replace("{{BBOX_IMG}}", EmailUtils.readBase64Image("bbox.txt")) + .replace("{{POLYGON_IMG}}", EmailUtils.readBase64Image("polygon.txt")) .replace("{{TIME_RANGE_IMG}}", EmailUtils.readBase64Image("time-range.txt")) .replace("{{ATTRIBUTES_IMG}}", EmailUtils.readBase64Image("attributes.txt")) .replace("{{fullMetadataLink}}", fullMetadataLink != null ? fullMetadataLink : "") diff --git a/server/src/main/resources/img/polygon.txt b/server/src/main/resources/img/polygon.txt new file mode 100644 index 00000000..3e3a3266 --- /dev/null +++ b/server/src/main/resources/img/polygon.txt @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADtklEQVR4nMWW7VXbWBCGX8nwO04FayrA/A574q0gpgJMAyBVgFKBBA3EVBCngnXOkt8WFaBUgPI7oLvPyJbXJvKXYE8eH517NbqeeWfu6MPTb6aRgF4Qdx4LnTpPHc8p2/N1M07CTA3YWcCfQTwoCn1iuoTv6+SfJBwx3YmdBMwynzjp+76vvmVttp+FRjj6g0ocYMtZujX8b3uq7D1ff90m4VgzsPexf6YKZ1RhqGcgsv2z8D8QrL3nF18RmWIuwbY9x+dxRPaXZH+Ak0wzjoO45wr97Xka7XkKF68hrhTNdA7rxqw7YV2+kwCclZnKV/gtCRNMJZUwphUZqY0956dOha27Q/RAUs4WDhxrCXx1ex0GjNtDKbvs94SpCJD4fmtUFE99OZmjr/RAQIA+V3sEec9YQvC3li3Tknfn8VDSqdn533ZY9pT5E4E9OZHZfwFwYsGtKedBENtG7FiS+3YdHjHOqSqGgCP+uxmCV/topbRAGQE6j1Jnj3LbuWpYzJQ1c3GVHWG0wwZQe+mkSNPgvUVHm6iak2nqt1pByz3dPRb+qfUFgZd7gCz7ZBkz7cjwNJThyua5IXiwS/AK/FbVW+QL/gbmz+PEFlnwz0y/Yxg6qSNKxGHcUKqBGsJWWS88ePSJ8zXal8YETrlUgr3ck3uGH6jqcTFnPi8fCz5SqkgNIbmyAv6KR7WHwi4KJ3WBji/iXHQ89p4aQnIWtEsVO6rBBLQR8FAr4Dx+YLjD3lMD8G3viXt8X+EjwPQLXCtVZkzecC8fsQWZ4F0QB6Ipsa/88yYqH2yt+U0x/QL+S6XdR8dDw/HzeMg4tTF3OaZwR+xPn/E5Z1tDYhOGt6vKb5QCDERYuSIRGGPOOKYiCY/WyEkXnKdkcoaIlPlGZv7Wlt/g+mbo5D5VGcoqJD+6vb64wrwW+idi8SWiDxCdaQVbCTBmGY2YHnrT1+4ZjnPOa6H89ww/KH+XcSVbC6ggs8RNtyQjuxNEpMyXQGwXsRM9e23XsbMAo9oSjjc4YEvCj5gtcPvJax3yih6IRzgCDxCYaQ0eRyMIZt+HQ6rxni0Zi7uIeaQFnn+61dFYQAVbEhH4kqnxhaAJTjsFdw/j0rOlDta8HEQ8MNyxFT3NsArRB/dM177MXixg3UuLOyHFnmPvaQVcfzkEcji6IlDAaQkVaFMBq8z/WwEDASOGD9Urtwzu+LjhTqAn1jbiqwgoA04/QA855uB8qSp1sOb14PkwoB+6TEX3D+n+lOlaXlVAE/4FzHHk7qgJ7xIAAAAASUVORK5CYII= diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java index 99216cfc..61f95ebb 100644 --- a/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/util/EmailUtilsTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -21,6 +23,17 @@ void testReadImageNotFound() { }); } + /** + * Test the polygon icon resource exists and is a PNG data URL + */ + @Test + void testPolygonImageResourceExists() throws IOException { + String dataUrl = EmailUtils.readBase64Image("polygon.txt"); + + assertTrue(dataUrl.startsWith("data:image/png;base64,")); + assertTrue(dataUrl.length() > "data:image/png;base64,".length()); + } + /** * Test single bbox with no number */ @@ -35,6 +48,21 @@ void testSingleBbox() { assertTrue(html.contains("E: 146.0")); } + /** + * Test bbox coordinate order groups latitude bounds then longitude bounds (N, S, W, E) + */ + @Test + void testBboxCoordinateOrder() { + String html = EmailUtils.buildBboxSection("-40.0", "-41.0", "145.0", "146.0", 0); + + int n = html.indexOf("N: -40.0"); + int s = html.indexOf("S: -41.0"); + int w = html.indexOf("W: 145.0"); + int e = html.indexOf("E: 146.0"); + + assertTrue(n < s && s < w && w < e, "Expected order N, S, W, E"); + } + /** * Test multiple bboxes with numbers */ @@ -213,6 +241,46 @@ void testOnlyDatesShowsSection() { assertFalse(result.contains("Bounding Box")); } + /** + * Test that dates are shown in the Australian format (dd/MM/yyyy), matching the frontend + */ + @Test + void testDatesUseAustralianFormat() { + String result = EmailUtils.generateSubsettingSection( + "2024-01-05", "2024-12-31", null, new ObjectMapper() + ); + + assertTrue(result.contains("05/01/2024 - 31/12/2024"), "Expected dd/MM/yyyy Australian date format"); + } + + /** + * Test that the frontend default full range (1970-01-01 to today) is treated as no subsetting and hidden + */ + @Test + void testDefaultFullRangeHidesSection() { + String today = LocalDate.now().toString(); + + String result = EmailUtils.generateSubsettingSection( + "1970-01-01", today, null, new ObjectMapper() + ); + + assertEquals("", result); + } + + /** + * Test that a default lower bound with a real upper bound still shows the time range + */ + @Test + void testDefaultLowerBoundWithRealEndShowsSection() { + String result = EmailUtils.generateSubsettingSection( + "1970-01-01", "2024-12-31", null, new ObjectMapper() + ); + + assertFalse(result.isEmpty()); + assertTrue(result.contains("Time Range")); + assertTrue(result.contains("01/01/1970 - 31/12/2024")); + } + /** * Test that only bbox (no dates) shows subsetting section */ @@ -242,4 +310,141 @@ void testOnlyBboxShowsSection() { assertTrue(result.contains("Bounding Box")); assertFalse(result.contains("Time Range")); } + + /** + * Test single polygon with no number + */ + @Test + void testSinglePolygon() { + List> vertices = List.of( + List.of(new BigDecimal("145.0"), new BigDecimal("-40.0")), + List.of(new BigDecimal("146.0"), new BigDecimal("-40.0")), + List.of(new BigDecimal("146.5"), new BigDecimal("-41.0")), + List.of(new BigDecimal("145.5"), new BigDecimal("-42.0")), + List.of(new BigDecimal("144.5"), new BigDecimal("-41.0")) + ); + + String html = EmailUtils.buildPolygonSection(vertices, 0); + + assertTrue(html.contains("Polygon Selection")); + assertTrue(html.contains("Point 1: (-40.0, 145.0)")); + assertTrue(html.contains("Point 5: (-41.0, 144.5)")); + } + + /** + * Test multiple polygons with numbers + */ + @Test + void testMultiplePolygons() { + List> vertices = List.of( + List.of(new BigDecimal("145.0"), new BigDecimal("-40.0")), + List.of(new BigDecimal("146.0"), new BigDecimal("-40.0")), + List.of(new BigDecimal("146.5"), new BigDecimal("-41.0")), + List.of(new BigDecimal("145.5"), new BigDecimal("-42.0")), + List.of(new BigDecimal("144.5"), new BigDecimal("-41.0")) + ); + + String html1 = EmailUtils.buildPolygonSection(vertices, 1); + String html2 = EmailUtils.buildPolygonSection(vertices, 2); + + assertTrue(html1.contains("Polygon Selection 1")); + assertTrue(html2.contains("Polygon Selection 2")); + } + + /** + * Test polygon vertices preserve full precision (no scientific notation) + */ + @Test + void testPolygonDecimalFormat() { + List> vertices = List.of( + List.of(new BigDecimal("150.11111"), new BigDecimal("-35.12345")), + List.of(new BigDecimal("151.99999"), new BigDecimal("-36.54321")), + List.of(new BigDecimal("152.50000"), new BigDecimal("-37.00001")), + List.of(new BigDecimal("151.00000"), new BigDecimal("-38.00002")), + List.of(new BigDecimal("150.00000"), new BigDecimal("-37.00003")) + ); + + String html = EmailUtils.buildPolygonSection(vertices, 0); + + assertTrue(html.contains("Point 1: (-35.12345, 150.11111)")); + assertTrue(html.contains("Point 2: (-36.54321, 151.99999)")); + } + + /** + * Test polygon uses its own image placeholder + */ + @Test + void testPolygonImagePlaceholder() { + List> vertices = List.of( + List.of(new BigDecimal("145.0"), new BigDecimal("-40.0")), + List.of(new BigDecimal("146.0"), new BigDecimal("-40.0")), + List.of(new BigDecimal("146.5"), new BigDecimal("-41.0")), + List.of(new BigDecimal("145.5"), new BigDecimal("-42.0")), + List.of(new BigDecimal("144.5"), new BigDecimal("-41.0")) + ); + + String html = EmailUtils.buildPolygonSection(vertices, 0); + + assertTrue(html.contains("{{POLYGON_IMG}}")); + assertFalse(html.contains("{{BBOX_IMG}}")); + } + + /** + * Test that a freeform polygon (>4 vertices) shows the polygon section, not a bbox + */ + @Test + void testFreeformPolygonShowsPolygonSection() { + Map polygon = Map.of( + "type", "MultiPolygon", + "coordinates", List.of( + List.of( + List.of( + List.of(145.0, -40.0), + List.of(146.0, -40.0), + List.of(146.5, -41.0), + List.of(145.5, -42.0), + List.of(144.5, -41.0), + List.of(145.0, -40.0) + ) + ) + ) + ); + + String result = EmailUtils.generateSubsettingSection( + "non-specified", "non-specified", polygon, new ObjectMapper() + ); + + assertFalse(result.isEmpty()); + assertTrue(result.contains("Subsetting for this collection")); + assertTrue(result.contains("Polygon Selection")); + assertFalse(result.contains("Bounding Box")); + } + + /** + * Test that a rectangle (4 vertices) shows the bbox section, not a polygon + */ + @Test + void testBboxNotRenderedAsPolygon() { + Map validBbox = Map.of( + "type", "MultiPolygon", + "coordinates", List.of( + List.of( + List.of( + List.of(145, -40), + List.of(145, -41), + List.of(146, -41), + List.of(146, -40), + List.of(145, -40) + ) + ) + ) + ); + + String result = EmailUtils.generateSubsettingSection( + "non-specified", "non-specified", validBbox, new ObjectMapper() + ); + + assertTrue(result.contains("Bounding Box")); + assertFalse(result.contains("Polygon Selection")); + } }
" + - "" + - "" + - "" + - "" + + coordinateRows + "
" + - "
" + - "

N: " + north + "

" + - "
" + - "
" + "
" + - "
" + - "
" + + "" + "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + + "" + "" + - "" + - "
" + + "" + "" + "" + - "" + + "" + "" + "
" + - "
" + - "

S: " + south + "

" + - "
" + - "
" + "
" + - "
" + - "
" + + "" + "" + "" + "" + "" + "
" + - "
" + - "

W: " + west + "

" + + "
" + + "

" + title + "

" + "
" + "
" + "
" + - "
" + + "
" + "
" + - "" + - "" + - "" + - "" + - "
" + - "
" + - "

E: " + east + "

" + - "
" + - "
" + + "
" + + "
" + "
" + + "" + + coordinateRows + "
" + "